Круг · дизайн-система

Как выглядит и ведёт себя Круг

Одна страница — и инструкция, и живой пример. Цвета здесь берутся из того же файла, что и в приложении, а под каждым компонентом лежит его настоящий код. Если на этой странице что-то не так, значит, в приложении тоже.

Круг — дневник еды, в котором запись занимает секунды: одна строка «название количество», одна подсказка, Enter. Всё остальное на экране подчинено этой строке. Дизайн-система описывает, из чего она и окружение собраны, и какие правила нельзя нарушать, чтобы приложение оставалось быстрым и ощущалось родным на iPhone.

Четыре критерия

Из goal.md: быстрота использования, эстетическое удовольствие, простота поиска, надёжность без интернета. Любое решение проверяется этими четырьмя.

Правила, которые уже приняты

Запись — плоский список. Никаких завтраков и обедов: это лишний тап в самом частом сценарии. Время записи хранится, но не показывается.
Одна подсказка, не список. Строка ввода дописывает набранное серым, как адресная строка браузера. Список совпадений появляется только по просьбе: тап по серому тексту; тот же тап его прячет.
Строка читается как предложение. «Творог 5% 150 грамм», единицы полными словами с согласованием, число справа — табличными цифрами.
Никаких приблизительных КБЖУ. Если у продукта нет данных — справочный «средний» продукт того же типа, а не догадка.
Системные элементы — системные. Поле ввода, клавиатура, листы действий, меню по долгому нажатию, навигация — родные iOS, без копий. Своё рисуем только там, где у системы этого нет: кольца, пилюля, подсказка.
Иерархия — цветом, не весом. Второстепенный текст отличается оттенком (textSecondary, textTertiary), как в самом iOS, а не более тонким начертанием.

Цвет

Две темы, одни имена. Тёмная — не инверсия светлой, а своя палитра по образцу iOS: почти чёрный фон, поверхности #1C1C1E, притушенный второстепенный текст, оттенки макросов чуть светлее, чтобы читались на чёрном. Переключите тему кнопкой в шапке — вся страница перекрасится теми же токенами.

ТокенСветлаяТёмнаяГде
bg#F5F5F7#000000Фон экрана
surface#FFFFFF#1C1C1EПоверхность: плитки, шторки, строки списка
surfaceMuted#EDEDF0#2C2C2EПриглушённая поверхность: дорожка пилюли
text#111114#FFFFFFОсновной текст
textSecondary#696971#9A9A9EВторостепенный текст: подписи, статусы
textTertiary#8D8D97#6B6B70Третьестепенный: подсказка в строке, плейсхолдер, бренд в списке
accent#3B6CF6#6A99FFАкцент: ссылки, активный микрофон, выбранный день в меню
kcal#5DB523#92CF2CКалории: внешнее кольцо, буква К, число записи
protein#F2626C#FF7A82Белки: второе кольцо, буква Б
fat#F2A63B#FFB454Жиры: третье кольцо, буква Ж
carbs#4E8AF4#6B9FFFУглеводы: внутреннее кольцо, буква У
kcalInk#519D1E#92CF2CКалории текстом: число записи, буква К, Ккал в центре колец, плитка
proteinInk#F15661#FF7A82Белки текстом
fatInk#C97B0D#FFB454Жиры текстом
carbsInk#4E8AF4#6B9FFFУглеводы текстом
Дорожка кольца — его же цвет, приглушённый. Незаполненная часть кольца — это тот же оттенок с прозрачностью 20% в светлой теме и 28% в тёмной (Palette.track). Отдельного «серого» для дорожек нет: пустое кольцо читается как «то же кольцо, ещё не заполненное».
Четыре цвета — четыре величины. Зелёный всегда калории, красный — белки, оранжевый — жиры, синий — углеводы. В кольцах, буквах К/Б/Ж/У, числах записей, плитках и полях шторки — везде одно соответствие.
Заливка и чернила. У каждой величины два токена: заливка (kcal) для колец и бегунка и чернила (kcalInk) для той же величины, написанной текстом. В тёмной теме это один цвет; в светлой заливки слишком бледны для текста (зелёный на фоне — 2,4:1), и чернила на тон глубже — ровно до 3:1, порога для крупного текста: цветной текст в приложении всегда крупный (от 20 pt) или жирная буква. Глубже — читается грязно. Обычный серый текст — не ниже 4,5:1; это проверяет тест ContrastTests, и падает, если кто-то сделает цвет слабее.
Плашки — тихие. Поверхность (surface) отделяет группу от фона, не спорит с ним: в тёмной теме #1C1C1E на чёрном, в светлой белая на #F5F5F7. Одна и та же поверхность у плиток статистики, строк профиля и меню, карточек блюда; системные, более светлые плашки списков не используются.
Код · UI/Palette.swift
enum Palette {
    static let bg = dynamic(0xF5F5F7, 0x000000)
    static let surface = dynamic(0xFFFFFF, 0x1C1C1E)
    static let surfaceMuted = dynamic(0xEDEDF0, 0x2C2C2E)
    static let text = dynamic(0x111114, 0xFFFFFF)
    static let textSecondary = dynamic(0x696971, 0x9A9A9E)
    static let textTertiary = dynamic(0x8D8D97, 0x6B6B70)
    static let accent = dynamic(0x3B6CF6, 0x6A99FF)
    // The four values: a fill (rings, the pill's thumb) and an ink for the
    // same value written as text. In the dark theme one colour does both.
    // In the light theme the fills are too pale to read (kcal green on
    // #F5F5F7 is 2.4:1), so the inks are a shade deeper — just enough for
    // 3:1, the bar for large text, which is what this text always is
    // (20 pt and up, or a bold letter). Deeper than that read as muddy to
    // the founder (KrugTests/ContrastTests holds the line).
    static let kcal = dynamic(0x5DB523, 0x92CF2C)
    static let protein = dynamic(0xF2626C, 0xFF7A82)
    static let fat = dynamic(0xF2A63B, 0xFFB454)
    static let carbs = dynamic(0x4E8AF4, 0x6B9FFF)
    static let kcalInk = dynamic(0x519D1E, 0x92CF2C)
    static let proteinInk = dynamic(0xF15661, 0xFF7A82)
    static let fatInk = dynamic(0xC97B0D, 0xFFB454)
    static let carbsInk = dynamic(0x4E8AF4, 0x6B9FFF)

    /// Over the camera's viewfinder, whatever the theme: it is always dark.
    static let onCamera = Color.white
    static let cameraBackdrop = Color.black

    /// The shadow a stadium ring's tip casts on the ring under it when it
    /// laps the track (over 100%).
    static let ringShadow = Color.black.opacity(0.45)

    /// A ring's unfilled track: its own hue, dimmed.
    static func track(_ ring: Color, dark: Bool) -> Color { ring.opacity(dark ? 0.28 : 0.2) }

    private static func dynamic(_ light: UInt32, _ dark: UInt32) -> Color {
        Color(UIColor { $0.userInterfaceStyle == .dark ? uiColor(dark) : uiColor(light) })
    }

    private static func uiColor(_ hex: UInt32) -> UIColor {
        UIColor(
            red: CGFloat((hex >> 16) & 0xFF) / 255,
            green: CGFloat((hex >> 8) & 0xFF) / 255,
            blue: CGFloat(hex & 0xFF) / 255,
            alpha: 1
        )
    }
}

Шрифт

SF Pro — системный шрифт iPhone, без вшитых файлов. Приложение использует только системные текстовые стили, поэтому весь интерфейс масштабируется с настройкой «Размер текста» на телефоне. Ниже — размеры при стандартном значении; при крупном тексте все они растут пропорционально, и верстка обязана это выдерживать (проверяется UI-тестами с категорией Accessibility L).

largeTitle · 34556 ккал
title · 28 · medium74
title2 · 22 · semiboldК
title3 · 20 · semiboldСегодня
title3 · 20Творог 5% 150 грамм
body · 17Рост
subheadline · 15Нет сети — ищу только в телефоне
footnote · 13цель 2000
caption · 12Пн

Где какой

СтильРоль
title3 regularГолос дневника: строки записей, строка ввода, подсказка, ингредиенты блюда, поля профиля и шторки
title3 semiboldЗаголовок дня в шапке («Сегодня»)
title2 semibold / mediumБуква К/Б/Ж/У в шапке; числа в центре колец
bodyСтроки настроек, списков, меню, пилюля Д/Н/М (semibold)
subheadlineВсе подписи и статусы под строкой, заголовки групп в шторке, бренд в списке совпадений — вторым цветом
largeTitle, titleКрупные числа: средние калории в статистике, значения в плитках, поля КБЖУ в шторке
Цифры — табличные. Везде, где числа стоят друг под другом или меняются на месте (число справа от записи, центр колец, плитки), включены monospacedDigit(), чтобы колонка не дрожала.
От 20 pt — Display, ниже — Text. Это правило самого SF Pro; системные стили соблюдают его сами. Свои размеры в пунктах в коде не задаются.

Отступы и форма

Шкала небольшая и целиком взята из того, как свёрстаны экраны. Горизонтальное поле дневника — 24, оно же у ингредиентов блюда и шторки; экран статистики и списки — 16; панель колец — 12.

2
4
5
6
8
10
12
14
16
20
24
28
32
ЗначениеГде
24 / 5Строка дневника: поле слева-справа 24, сверху-снизу 5. Список начинается через 8 от шапки
16Между названием записи и числом справа; между строкой и кнопкой «очистить» — 12
12Подпись-статус под строкой ввода отступает на 12; плашка «Удалено» — 16 от краёв экрана, 8 от низа
14Между пилюлей и кольцами; внутри плитки по вертикали (по горизонтали 16)
10 / 6Сетка плиток — 10; мини-кольца в календаре — 6; месяцы — 14
52Высота шапки дневника; кнопки в ней занимают всю высоту
212Высота стадиона на главном экране

Скругления и толщины

ФормаЗначение
Карточки: плитки статистики, плашки редактора блюдарадиус 20, непрерывное (continuous) скругление
Пилюля, плашка, кнопки шторкикапсула (радиус = половина высоты)
Стадион на главномчетыре кольца, толщина 13, зазор 3
Кольца статистикитолщина 24, зазор 5
Мини-кольца недели / календаря / месяцев3.5 / 1.5 · 3 / 1.5 · 5 / 2.5
Пилюля Д/Н/Мсегмент 46 × 32, внутренний отступ 2

Движение

Анимаций мало, и каждая отвечает за одно: показать, что изменилось. Кольца едут от прошлого значения, а не от нуля; список совпадений выезжает, а не вспыхивает; страница дня при возврате к «Сегодня» доезжает за треть секунды. Нажмите на карточку, чтобы проиграть.

Кольца стадиона
easeOut · 0.65 s · от прошлого значения · Motion.stadium
Кольца статистики
easeOut · 0.7 s · Motion.rings
Пилюля Д/Н/М
snappy · 0.22 s · matchedGeometry · Motion.pill
Список совпадений, запись, удаление, смена периода
snappy · 0.25 s · opacity + move(.top) · Motion.reveal
Возврат к «Сегодня», переход к дню из меню
easeOut · 0.35 s · Motion.page
Панель колец при фокусе на строке
easeOut · 0.2 s · opacity · Motion.panel
Число, меняющееся на месте
snappy · 0.5 s · contentTransition(.numericText()) · Motion.number

Отклик

СобытиеХаптика
Запись сохраненаimpact(.light)
Enter без подсказки (ничего не записано)selection
Переключение Д/Н/М, К/Б/Ж/У, периода статистикиselection
Штрихкод распознанnotification(.success)
Числа меняются на месте. Там, где число обновляется (центр колец, число записи при смене К/Б/Ж/У, заголовок статистики), стоит contentTransition(.numericText()): цифры перелистываются, а не подменяются.

Компоненты

Слева — как это выглядит, справа — что важно. Примеры нарисованы здесь заново на тех же токенах, а под каждым раскрывается настоящий Swift-код из приложения.

Строка записи

Сырники 2 порции516
Творог 5% 150 грамм182
Рис с куриной бризолью 1 порция394
Куриная грудка отварная 150 грамм46

Одна строка — одна запись: название и количество словами, число справа. Число — калории, или белки/жиры/углеводы, если переключить букву в шапке; цвет числа следует за величиной.

Тап по строке — правка на месте: поле ввода встаёт на её место с её же текстом. Долгое нажатие — системное меню «Изменить / Удалить». Ни бренда, ни времени, ни иконок в строке нет.

Код · UI/DiaryView.swift
    private func entryLine(_ e: DiaryEntry, current: Bool) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 16) {
            Text(e.line(lang: store.lang))
                .foregroundStyle(Palette.text)
                .frame(maxWidth: .infinity, alignment: .leading)
            if e.counted {
                Text(metric.value(e))
                    .monospacedDigit()
                    .foregroundStyle(metric.color)
                    .contentTransition(.numericText())
                    .accessibilityLabel("\(metric.fullName) \(metric.value(e))")
            } else {
                // No product yet: the numbers are a question, and a tap picks one.
                Text(verbatim: "?")
                    .font(.body.weight(.semibold))
                    .foregroundStyle(Palette.textSecondary)
                    .frame(minWidth: 30, minHeight: 30)
                    .background(Palette.surfaceMuted, in: Circle())
                    .accessibilityLabel(L("Не посчитано"))
                    .accessibilityIdentifier("uncounted")
            }
        }
        .font(.title3)
        .padding(.horizontal, 24)
        .padding(.vertical, 5)
        .contentShape(Rectangle())
        .accessibilityElement(children: .combine)
        .accessibilityHint(current ? L("Изменить") : "")
        .onTapGesture { if current { edit(e) } }
        .contextMenu {
            Button("Изменить", systemImage: "pencil") { edit(e) }
            Button("Удалить", systemImage: "trash", role: .destructive) { delete(e) }
        }
    }

Строка ввода с подсказкой

творог 5% 1 пачка⊗
 
ndjhju 150 → Творог 150 грамм⊗
 
сырок глазированный⊗
Ищу…
Нет сети — ищу только в телефонеНе нашли? Добавить новый продукт
Введите продукт…📷 🎤

Системное поле ввода, а за ним — серый текст подсказки ровно от того места, где кончается набранное. Подсказка одна и всегда целая: продукт и количество. Enter принимает её целиком.

Если догадка не продолжает набранное (опечатка, другой падеж, не та раскладка), она показывается целиком после стрелки: Enter никогда не запишет то, чего не видно.

Под строкой — только то, чего подсказка сказать не может: «Ищу…», «Ничего не найдено», «Нет сети», и всегда — ссылка на свой продукт. Камера и микрофон стоят в пустой строке и исчезают, когда есть текст; при правке записи их нет.

Код · UI/GhostField.swift
import SwiftUI

/// The input line with one inline "ghost" completion, the way a browser's
/// address bar guesses the rest: `suggestion` is the whole line the app
/// expects ("Творог 5% 150 грамм"), and whatever of it lies past the typed
/// text is drawn grayed-out behind the real, system text field. Enter
/// (the Go key) is how the guess is taken.
struct GhostField: View {
    @Binding var text: String
    var suggestion: String?
    var focus: FocusState<Bool>.Binding
    /// Called with the line as submitted (the field's text may not have
    /// caught up within the same update).
    var onSubmit: (String) -> Void
    /// A tap on the grey text — "not this one?" — opens the other matches.
    var onGhostTap: (() -> Void)? = nil

    /// Where the typed text ends on its first line, in the field's
    /// coordinates: a tap to the right of it (or below the first line) is on
    /// the ghost, a tap on the typed text itself just moves the caret.
    @State private var typedWidth: CGFloat = 0
    @State private var lineHeight: CGFloat = 0

    /// What's drawn after the typed text: the rest of a continuation, or —
    /// for a guess that doesn't continue it (a typo, another case form, the
    /// other keyboard layout) — the whole line after an arrow, so Enter never
    /// logs a line the user didn't see.
    private var ghost: String? {
        guard let s = suggestion, !text.isEmpty else { return nil }
        if s.lowercased().hasPrefix(text.lowercased()) {
            return s.count > text.count ? String(s.dropFirst(text.count)) : nil
        }
        return "\(text.hasSuffix(" ") ? "" : " ")→ \(s)"
    }

    var body: some View {
        ZStack(alignment: .topLeading) {
            if let ghost {
                Text(ghostText(ghost))
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .allowsHitTesting(false)
                    .accessibilityHidden(true)
                    .background {
                        // The typed text alone, unwrapped, to measure where it
                        // ends; never drawn.
                        Text(text)
                            .fixedSize()
                            .hidden()
                            .background(GeometryReader { g in
                                Color.clear.preference(key: TypedSizeKey.self, value: g.size)
                            })
                    }
                    .onPreferenceChange(TypedSizeKey.self) { size in
                        typedWidth = size.width
                        lineHeight = size.height
                    }
            }
            // Vertical axis: a long line wraps like the logged lines do. Its
            // Return key would insert a newline, so a newline means "submit".
            // A pasted list ("курица 100⏎рис 50") keeps one food per line,
            // as "; " — the separator that always splits (see splitLine).
            TextField("Введите продукт…", text: $text, axis: .vertical)
                .focused(focus)
                .accessibilityIdentifier("line")
                .submitLabel(.go)
                .autocorrectionDisabled()
                .foregroundStyle(Palette.text)
                .onChange(of: text) { old, new in
                    let breaks = new.filter(\.isNewline).count
                    guard breaks > 0 else { return }
                    let line = new.filter { !$0.isNewline }
                    // Return: one newline, at the end or just put in where the
                    // caret was. Anything else came pasted.
                    let isReturn = breaks == 1 && (new.last?.isNewline == true || line == old)
                    if !isReturn {
                        text = new.split(whereSeparator: \.isNewline)
                            .map { $0.trimmingCharacters(in: .whitespaces) }
                            .filter { !$0.isEmpty }
                            .joined(separator: "; ")
                        return
                    }
                    text = line
                    if line.trimmingCharacters(in: .whitespaces).isEmpty {
                        focus.wrappedValue = false
                    } else {
                        onSubmit(line)
                    }
                }
                .accessibilityHint(suggestion.map { L("Подсказка: \($0)") } ?? "")
            // Over the field, a tap target where the grey text is: to the
            // right of the typed text on its line, and anything below it.
            // The typed text itself stays the field's (caret, selection).
            if ghost != nil, let onGhostTap {
                GeometryReader { g in
                    // Typed text that wraps has no measurable end: no target.
                    if typedWidth < g.size.width {
                        Color.clear
                            .contentShape(Rectangle())
                            .frame(width: g.size.width - typedWidth, height: lineHeight)
                            .offset(x: typedWidth)
                            .onTapGesture(perform: onGhostTap)
                            .accessibilityElement()
                            .accessibilityLabel(L("Другие варианты"))
                            .accessibilityIdentifier("ghost")
                        if g.size.height > lineHeight {
                            Color.clear
                                .contentShape(Rectangle())
                                .frame(width: g.size.width, height: g.size.height - lineHeight)
                                .offset(y: lineHeight)
                                .onTapGesture(perform: onGhostTap)
                        }
                    }
                }
            }
        }
        .font(.title3)
    }

    private struct TypedSizeKey: PreferenceKey {
        static let defaultValue = CGSize.zero
        static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
    }

    private func ghostText(_ ghost: String) -> AttributedString {
        var typed = AttributedString(text)
        typed.foregroundColor = .clear
        var rest = AttributedString(ghost)
        rest.foregroundColor = Palette.textTertiary
        return typed + rest
    }
}

Список совпадений

творог 1 порция⊗
Творог 5%121
Творог 9%159
Творог обезжиренный90
творогБеллакт113

Скрытая функция: раскрывается тапом по серому тексту подсказки или коротким свайпом вниз по строке. Сначала то, что уже записывали, затем точные совпадения, затем с опечаткой; одна строка на название. Тап по строке ставит название в поле — дальше как обычно.

Код · UI/DiaryView.swift
    private var matchList: some View {
        MatchList(items: matches) { f in
            closeList()
            pick(f)
        }
    }

Стадион и сводка дня

ДНМ
Ккал 556
Б 74   Ж 25   У 7.5

Четыре вложенных кольца в форме капсулы, снаружи внутрь: калории, белки, жиры, углеводы. Заполняются по часовой от верхней середины. Переключите пилюлю — кольца доедут от прошлых значений; М показывает перебор: кольцо идёт на второй круг с тенью на кончике, чтобы было видно, насколько сверх цели.

Н и М — среднее по дням с записями за календарную неделю и месяц, и только до сегодняшнего дня: пропущенный день не тянет среднее к нулю, а план на завтра ещё не считается. Панель прячется, пока открыта клавиатура.

Код · UI/StadiumRings.swift
import SwiftUI

struct StadiumRing {
    /// eaten / target, not clamped: past 1 the ring goes round a second lap
    /// over itself, Apple-Fitness style, so an overshoot reads as "how much over".
    var progress: Double
    var color: Color
}

/// Nested progress tracks in a capsule shape, outer to inner — the diary's
/// pinned summary. Each ring fills clockwise from the top center; `content`
/// sits in the empty middle. Progress changes animate from the previous
/// values, so flipping Д/Н/М or adding an entry reads as the rings moving.
struct StadiumRings<Content: View>: View {
    var rings: [StadiumRing]
    var strokeWidth: CGFloat = 13
    var gap: CGFloat = 3
    @ViewBuilder var content: Content
    @Environment(\.colorScheme) private var scheme

    var body: some View {
        ZStack {
            ZStack {
                ForEach(rings.indices, id: \.self) { i in
                    let ring = rings[i]
                    let inset = strokeWidth / 2 + CGFloat(i) * (strokeWidth + gap)
                    let p = max(0, ring.progress)
                    let style = StrokeStyle(lineWidth: strokeWidth, lineCap: .round)
                    let shape = StadiumShape(inset: inset)
                    ZStack {
                        shape.stroke(Palette.track(ring.color, dark: scheme == .dark), style: style)
                        shape.trim(from: 0, to: min(p, 1)).stroke(ring.color, style: style)
                        // Second lap over the full ring. The lap's end would
                        // vanish against the same colour underneath, so a
                        // short blurred dark stretch of the path sits just
                        // ahead of its tip — only there, never along the
                        // ring. Both are always present and trimmed, so they
                        // sweep along the path as the value animates.
                        let over = min(max(p - 1, 0), 0.999)
                        shape.trim(from: over, to: min(over + 0.012, 1))
                            .stroke(Palette.ringShadow.opacity(p > 1 ? 1 : 0), style: style)
                            .blur(radius: strokeWidth * 0.3)
                        shape.trim(from: 0, to: over).stroke(ring.color, style: style)
                    }
                    .animation(Motion.stadium, value: p)
                }
            }
            content
                .padding(CGFloat(rings.count) * (strokeWidth + gap) + 4)
        }
    }
}

/// A capsule path that starts at the top center and runs clockwise — the
/// same origin and direction as a clock-face ring.
struct StadiumShape: Shape {
    var inset: CGFloat

    func path(in bounds: CGRect) -> Path {
        let r = bounds.insetBy(dx: inset, dy: inset)
        guard r.width > 0, r.height > 0 else { return Path() }
        let radius = min(r.width, r.height) / 2
        var p = Path()
        p.move(to: CGPoint(x: r.midX, y: r.minY))
        p.addLine(to: CGPoint(x: r.maxX - radius, y: r.minY))
        p.addArc(center: CGPoint(x: r.maxX - radius, y: r.midY), radius: radius, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: false)
        p.addLine(to: CGPoint(x: r.minX + radius, y: r.maxY))
        p.addArc(center: CGPoint(x: r.minX + radius, y: r.midY), radius: radius, startAngle: .degrees(90), endAngle: .degrees(270), clockwise: false)
        p.addLine(to: CGPoint(x: r.midX, y: r.minY))
        return p
    }
}
Код · UI/SummaryPanel.swift
struct SummaryPanel: View {
    @Environment(DiaryStore.self) private var store
    let dayKey: String
    @Binding var period: SummaryPeriod

    var body: some View {
        let targets = store.profile.targets
        let total = switch period {
        case .day: store.summary(dayKey...dayKey, average: false)
        case .week: store.summary(DayKeys.weekRange(dayKey), average: true)
        case .month: store.summary(DayKeys.monthRange(dayKey), average: true)
        }
        VStack(spacing: 14) {
            PeriodToggle(period: $period)
            StadiumRings(rings: [
                StadiumRing(progress: ratio(total.kcal, targets.kcal), color: Palette.kcal),
                StadiumRing(progress: ratio(total.protein, targets.protein), color: Palette.protein),
                StadiumRing(progress: ratio(total.fat, targets.fat), color: Palette.fat),
                StadiumRing(progress: ratio(total.carbs, targets.carbs), color: Palette.carbs),
            ]) {
                VStack(spacing: 2) {
                    Text("\(Text("Ккал").foregroundStyle(Palette.kcalInk)) \(formatKcal(total.kcal))")
                    Text("\(Text("Б").foregroundStyle(Palette.proteinInk)) \(formatGrams(total.protein))   \(Text("Ж").foregroundStyle(Palette.fatInk)) \(formatGrams(total.fat))   \(Text("У").foregroundStyle(Palette.carbsInk)) \(formatGrams(total.carbs))")
                }
                .font(.title2.weight(.medium))
                .monospacedDigit()
                .foregroundStyle(Palette.text)
                .contentTransition(.numericText())
                .animation(Motion.number, value: total)
                .lineLimit(1)
                .minimumScaleFactor(0.5)
            }
            .frame(height: 212)
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("\(L("Калории")) \(formatKcal(total.kcal)), \(L("Белки")) \(formatGrams(total.protein)), \(L("Жиры")) \(formatGrams(total.fat)), \(L("Углеводы")) \(formatGrams(total.carbs))")
        }
        .padding(.horizontal, 12)
        .padding(.vertical, 8)
    }
}

Пилюля

ДНМ

Сегментный переключатель в духе iOS: светлый бегунок под выбранной буквой, остальные — вторым цветом. Бегунок едет (matchedGeometry), подпись меняет цвет за 0.22 с. Та же пилюля — К/Б/Ж/У не нужна: там одна буква, которая переключается тапом.

Код · UI/SummaryPanel.swift
/// iOS-style segmented pill: a thumb that slides under the chosen letter.
struct PeriodToggle: View {
    @Binding var period: SummaryPeriod
    @Namespace private var thumb

    var body: some View {
        HStack(spacing: 0) {
            ForEach(SummaryPeriod.allCases) { p in
                Text(p.letter)
                    .font(.body.weight(.semibold))
                    .foregroundStyle(p == period ? Palette.bg : Palette.textSecondary)
                    .padding(.horizontal, 8)
                    .frame(minWidth: 46, minHeight: 32)
                    .background {
                        if p == period {
                            Capsule().fill(Palette.text).matchedGeometryEffect(id: "thumb", in: thumb)
                        }
                    }
                    .contentShape(Rectangle())
                    .onTapGesture { withAnimation(Motion.pill) { period = p } }
                    .accessibilityLabel(p.fullName)
                    .accessibilityAddTraits(p == period ? [.isButton, .isSelected] : .isButton)
            }
        }
        .padding(2)
        .background(Capsule().fill(Palette.surfaceMuted))
        .sensoryFeedback(.selection, trigger: period)
    }
}

Круглые кольца и плитки

Калории
556ккал
цель 2000
Белки
74г
цель 100
Жиры
25г
цель 70
Углеводы
7.5г
цель 250
ср7
чт8
пт9
сб10
вс11
пн12
вт13

Статистика в духе Fitness: кольца в круге на весь экран — средний день за период; под ними четыре плитки со значением, единицей и целью; ниже — дни недели, календарь месяца или месяцы мини-кольцами. Круглое кольцо, в отличие от стадиона, не идёт на второй круг: перебор здесь говорят цифры.

Код · UI/ActivityRings.swift
import SwiftUI

/// Nested circular progress rings, outer to inner, Apple-Fitness style —
/// the statistics screen's picture of a period. Each fills clockwise from
/// the top; a ring past its target stays full (the diary's stadium rings
/// show the overshoot, here the numbers do).
struct ActivityRings<Content: View>: View {
    var rings: [StadiumRing]
    var strokeWidth: CGFloat = 8
    var gap: CGFloat = 3
    @ViewBuilder var content: Content
    @Environment(\.colorScheme) private var scheme

    init(rings: [StadiumRing], strokeWidth: CGFloat = 8, gap: CGFloat = 3, @ViewBuilder content: () -> Content = { EmptyView() }) {
        self.rings = rings
        self.strokeWidth = strokeWidth
        self.gap = gap
        self.content = content()
    }

    var body: some View {
        ZStack {
            ForEach(rings.indices, id: \.self) { i in
                let ring = rings[i]
                let inset = strokeWidth / 2 + CGFloat(i) * (strokeWidth + gap)
                let p = min(max(ring.progress, 0), 1)
                Circle()
                    .inset(by: inset)
                    .stroke(Palette.track(ring.color, dark: scheme == .dark), lineWidth: strokeWidth)
                Circle()
                    .inset(by: inset)
                    .trim(from: 0, to: p)
                    .stroke(ring.color, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
                    .rotationEffect(.degrees(-90))
                    .animation(Motion.rings, value: p)
            }
            content
        }
        .aspectRatio(1, contentMode: .fit)
    }
}

/// The four rings for a total against the day's targets.
func ringsFor(_ n: Nutrition, targets: Nutrition) -> [StadiumRing] {
    func ratio(_ v: Double, _ t: Double) -> Double { t > 0 ? v / t : 0 }
    return [
        StadiumRing(progress: ratio(n.kcal, targets.kcal), color: Palette.kcal),
        StadiumRing(progress: ratio(n.protein, targets.protein), color: Palette.protein),
        StadiumRing(progress: ratio(n.fat, targets.fat), color: Palette.fat),
        StadiumRing(progress: ratio(n.carbs, targets.carbs), color: Palette.carbs),
    ]
}
Код · UI/StatsView.swift
    private func tile(_ label: String, _ value: String, color: Color) -> some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(label).font(.subheadline).foregroundStyle(Palette.textSecondary)
            Text(value).font(.title.weight(.medium)).monospacedDigit().foregroundStyle(color)
                .contentTransition(.numericText())
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.horizontal, 16)
        .padding(.vertical, 14)
        .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.card, style: .continuous))
        .accessibilityElement(children: .combine)
    }

Плашка «Удалено»

Удалено Вернуть

Внизу экрана на четыре секунды после удаления. Удаления подряд копятся под одной плашкой, «Вернуть» возвращает всё: ничего, удалённое при живой плашке, не теряется. Одна и та же плашка в дневнике и в «Моих продуктах».

Код · UI/UndoBar.swift
import SwiftUI

/// What was just deleted, kept for a few seconds so "Вернуть" can put it
/// back. Several deletes in a row stack up; one tap restores them all —
/// nothing deleted while the bar is up is ever lost.
@MainActor
@Observable
final class PendingUndo<Item> {
    private(set) var items: [Item] = []
    private var expiry: Task<Void, Never>?

    static var window: Duration { .seconds(4) }

    func add(_ item: Item) {
        withAnimation { items.append(item) }
        expiry?.cancel()
        expiry = Task { [weak self] in
            try? await Task.sleep(for: Self.window)
            guard !Task.isCancelled else { return }
            withAnimation { self?.items = [] }
        }
    }

    /// Everything pending, most recent last; the bar goes down.
    func take() -> [Item] {
        expiry?.cancel()
        let all = items
        withAnimation { items = [] }
        return all
    }
}

/// "Удалено — Вернуть" at the bottom of a screen while something can still
/// be brought back.
struct UndoBar: View {
    var shown: Bool
    var restore: () -> Void

    var body: some View {
        if shown {
            HStack {
                Text("Удалено").foregroundStyle(Palette.text)
                Spacer()
                Button("Вернуть", action: restore)
                    .fontWeight(.semibold)
                    .foregroundStyle(Palette.accent)
            }
            .padding(.horizontal, 20)
            .padding(.vertical, 14)
            .background(.regularMaterial, in: Capsule())
            .padding(.horizontal, 16)
            .padding(.bottom, 8)
            .transition(.move(edge: .bottom).combined(with: .opacity))
        }
    }
}

Строки настроек и списков

Рост168см
Вес61.5кг
ПолЖенский ◇
Калории1800ккал
Белки120г
Жиры70г
Углеводы250г
Сырникиблюдо172 ккал
Запеканка мамина180 ккал

Профиль — системная форма: группы, строка с числом справа и единицей, строка с выбором, строка-действие, подпись под группой. Число сохраняется, когда уходишь из поля или с экрана. Цели окрашены в цвет своей величины.

«Мои продукты» — системный список: название, пометка «блюдо» третьим цветом, калории на 100 г справа. Свайп влево — удалить, долгое нажатие — меню.

Код · UI/ProfileView.swift
/// A form row with a number at the right, saved when editing ends — or
/// when the screen goes away mid-edit. Text that isn't a number leaves the
/// stored value alone, and the row shows it again.
private struct NumberRow: View {
    let label: String
    let stored: String
    let unit: String
    let decimal: Bool
    let color: Color
    let save: (String) -> Void
    @State private var text = ""
    @FocusState private var focused: Bool

    var body: some View {
        HStack {
            Text(label).foregroundStyle(color)
            Spacer()
            TextField("—", text: $text)
                .accessibilityIdentifier("field.\(label)")
                .keyboardType(decimal ? .decimalPad : .numberPad)
                .multilineTextAlignment(.trailing)
                .frame(width: 90)
                .focused($focused)
                .onAppear { text = stored }
                .onChange(of: stored) { if !focused { text = stored } }
                .onChange(of: focused) { if !focused { commit() } }
                .onDisappear { if focused { commit() } }
            Text(unit).foregroundStyle(Palette.textSecondary)
        }
    }

    private func commit() {
        save(text)
        // Whatever was stored after the save (the new number, or the old
        // one when the text wasn't a number) is what the row shows.
        DispatchQueue.main.async { text = stored }
    }
}

/// The system share sheet.
private struct ActivityView: UIViewControllerRepresentable {
    let items: [Any]
    func makeUIViewController(context: Context) -> UIActivityViewController {
        UIActivityViewController(activityItems: items, applicationActivities: nil)
    }
    func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}

Шторка «Свой продукт»

сырники
на 100 г
246
Ккал
12.4
Б
0
Ж
0
У
Вес порции, г—

Системная шторка с панелью «Отмена / Сохранить». Название, четыре числа на 100 г крупно с цветной буквой под каждым, вес порции. Одна шторка на четыре случая: новый продукт, правка своего, после фото этикетки (числа подставлены), после неизвестного штрихкода (с подсказкой сверху). Фокус сразу в первом пустом поле.

Код · UI/NewFoodSheet.swift
    private func numberField(_ value: Binding<String>, _ label: String, _ color: Color, _ field: Field) -> some View {
        VStack(alignment: .leading, spacing: 2) {
            TextField("0", text: value)
                .font(.largeTitle)
                .monospacedDigit()
                .keyboardType(.decimalPad)
                .focused($focus, equals: field)
            Text(label)
                .font(.subheadline)
                .foregroundStyle(color)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

Иконки

SF Symbols — родные знаки iOS, в залитом варианте там, где он есть. Свои иконки появятся вместе с дизайном; пока список того, что стоит, и где.

SF SymbolГде
line.3.horizontalМеню, слева в шапке дневника
camera.fill · mic.fillСтрока ввода; микрофон во время диктовки — акцентным цветом, с пульсацией
xmark.circle.fillОчистить строку
barcode.viewfinder · camera · photoМеню камеры: штрихкод, этикетка, фото
pencil · trashМеню записи и продукта: изменить, удалить
person.fill · chart.bar.fill · bookmark.fillМеню: профиль, статистика, мои продукты
plus · carrot · frying.pan«Мои продукты»: добавить — продукт, блюдо
square.and.arrow.up · square.and.arrow.downПрофиль: сохранить копию, восстановить

Как править

Источник правды — код приложения в apps/ios/Krug. Эта страница собирается из него скриптом, поэтому менять нужно код, а не страницу:

  1. Цвета — UI/Palette.swift: пара hex для светлой и тёмной темы на каждый токен.
  2. Отступы, радиус, движение — UI/Style.swift: шкала Space.scale, радиус Radius.card, анимации Motion.…. Шкала и карточки движения выше берутся оттуда.
  3. Шрифт — только системные стили (.title3, .body…); размеры в пунктах не задаются.
  4. Компоненты — файлы в UI/; код под компонентами на этой странице берётся из них при сборке.
  5. Текст страницы — design-system/src/index.template.html; стили — style.css.

Железное правило

Экран не может взять цвет, отступ, радиус или анимацию «из головы». design-system/tools/lint.py читает каждый Swift-файл приложения и не пропускает: цвет, созданный на месте, или системный (.gray, .white…) вместо токена Palette; размер шрифта в пунктах; отступ не из шкалы; радиус числом; анимацию не из Motion; хаптику не из таблицы выше. Он запускается хуком перед каждым коммитом и в CI — коммит с нарушением не проходит.

Пересобрать: python3 design-system/tools/build.py. Хук перед коммитом не пропустит устаревшую страницу. Опубликованная версия обновляется при каждом пуше в main.