Реальные истории:Как тренер футбольной школы создал приложения за 1 неделю скоро

Тема и токены

Шаг 3 UI-трека. В программе из прошлого шага цвета живут в четырёх константах наверху файла — уже лучше, чем hex по всему коду, но константа не умеет меняться, пока приложение работает. Сегодня переносим палитру в theme(), получаем в подарок переключатель тёмной и светлой темы, убираем color: "white" из каждого UIText и заводим общие стили для типографики.

Токены вместо констант

theme(values) кладёт переменные в одну общую таблицу приложения и возвращает аксессоры — объект с теми же ключами, чьи значения — строки вида "var(--card)". Такую строку можно подставить в любой UI-стиль, а хост разрешит её в момент применения. Числа — это длины в логических px, строки проходят как есть.

Два ключа особенные — их читает сам SDK: color задаёт цвет текста по умолчанию для каждого UIText без собственного color, fontFamily — шрифт по умолчанию. Одна строка вместо color: "white" в каждом элементе:

TypeScript
const colors = theme({
  bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032",
})
theme({ color: "#ffffff" })   // цвет текста по умолчанию — для всех UIText без своего color

const Stat = (value: string, label: string) =>
  UIColumn(
    UIText(value).style({ fontSize: 22, fontWeight: 800 }),
    UIText(label).style({ color: colors.muted, fontSize: 13 }),
  ).style({ bgColor: colors.card, borderRadius: 14, p: 12, gap: 2, flex: 1, alignItems: "center" })

const screen = UIScreen(
  UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
  UIText(date().format("dddd, DD MMMM")).style({ color: colors.muted, fontSize: 15 }),

  UIRow(Stat("3", "готово"), Stat("2", "осталось"), Stat("60%", "за сегодня")).style({ gap: 10, mt: 8 }),

  UIButton(UIText("+ Новая привычка").style({ fontSize: 16, fontWeight: 700 }))
    .style({ bgColor: colors.accent, borderRadius: 16, p: 16, mt: 16, $pressed: { opacity: 0.8 } }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })

screen.open()

Те же пиксели, что и раньше, — но ни одного hex-литерала ниже первых четырёх строк. Что стоит понимать про таблицу:

  • theme() сливает: ключи, которых нет в вызове, сохраняют значения. Поэтому палитру и системные ключи можно задавать отдельными вызовами.
  • colors.card — это буквально строка "var(--card)". Работает она только в UI-стилях: движковые цвета (Sprite.color, материалы), SVG-разметка и параметры нативных вью получат литерал var(--card) и не поймут его. Для них держи рядом сырую палитру с hex.
  • В var() можно указать запасное значение — "var(--accent, #FF4032)" — оно действует, пока ключ не задан.
  • Переменные — для того, что должно меняться на лету. Отступы и размеры шрифтов оставляй обычными константами TypeScript.

Тёмная и светлая

Раз каждый var() разрешается заново при применении, повторный вызов theme() перекрашивает всё, что сейчас на экране, на месте — без перестройки дерева, без прокидывания пропсов, без места, которое можно забыть обновить. Тёмная тема — это второй вызов с другой палитрой:

TypeScript
const dark = { bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" }
const light = { bg: "#f2f2f7", card: "#ffffff", text: "#111114", muted: "#6b6b76", accent: "#FF4032" }

const colors = theme(dark)
theme({ color: dark.text })

const isDark = signal(true)
const applyTheme = (d: boolean) => {
  isDark.value = d
  const p = d ? dark : light
  theme({ ...p, color: p.text })     // перезаписать палитру + цвет текста по умолчанию
}

const ThemeToggle = () =>
  UIButton(UIText(() => (isDark.value ? "Светлая" : "Тёмная")).style({ color: colors.muted, fontSize: 13, fontWeight: 600 }))
    .style({ height: 32, px: 14, borderRadius: 16, bgColor: colors.card, $pressed: { opacity: 0.7 } })
    .onClick(() => applyTheme(!isDark.value))

const Habit = (title: string, time: string, color: string) =>
  UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: color }),
    UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText(time).style({ color: colors.muted, fontSize: 13 }),
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" })

const screen = UIScreen(
  UIRow(
    UIText("Сегодня").style({ fontSize: 32, fontWeight: 800, flexGrow: 1 }),
    ThemeToggle(),
  ).style({ alignItems: "center" }),
  UIText(date().format("dddd, DD MMMM")).style({ color: colors.muted, fontSize: 15 }),

  UIColumn(
    Habit("Утренняя пробежка", "07:30", "#34c759"),
    Habit("Прочитать 20 страниц", "13:00", "#ff9f0a"),
    Habit("Растяжка", "20:30", "#af6bff"),
  ).style({ gap: 10, mt: 16 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })

screen.open()

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

  • Порядок не важен: theme() до построения экрана или после — результат один, запись действует немедленно.
  • Так же делается серверный акцент бренда или цвет пользователя: вычислил пару переменных из одного hex — записал их в тему — перекрасилось всё, включая уже открытые экраны.
  • Цвета привычек (#34c759 и компания) остались литералами намеренно: это данные, а не оформление — они не должны меняться вместе с темой.
Заметка

Хочешь помнить выбор пользователя между запусками — сохрани isDark в localStorage и вызови applyTheme при старте. Как — в шаге «Данные и сеть» (пишется); пока хватит справочника по localStorage.

Шрифт и типографика

Шрифт на всё приложение — тот же системный ключ. font()макрос времени компиляции: он берёт семейство из реестра (~26 OFL-шрифтов с кириллицей) или твой .ttf из проекта, регистрирует нужные начертания и возвращает имя семейства. Ничего не нужно ждать или загружать вручную:

TypeScript
theme({ color: "#ffffff", fontFamily: font("manrope") })      // весь текст приложения — Manrope
UIText("Заголовок").style({ fontFamily: font("unbounded") }) // точечно — акцентный шрифт
theme({ fontFamily: font("./fonts/Brand.ttf") })              // свой файл: имя и вес читаются из файла

Аргумент — строковый литерал; неизвестный id — ошибка компиляции со списком доступных. Три системных семейства ("serif", "sans-serif", "monospaced") работают без font(). Подробности и список реестра — в справочнике Шрифты.

Размеры и веса, в отличие от цветов, менять на лету не нужно — это обычные объекты стилей. Типизируй их глобальным помощником Style<T> и раскладывай в .style({ ...type.h1 }):

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })

const type = {
  h1:    { fontSize: 32, fontWeight: 800 } as Style<UIText>,
  h2:    { fontSize: 22, fontWeight: 800 } as Style<UIText>,
  body:  { fontSize: 16, fontWeight: 600 } as Style<UIText>,
  small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}
const card: Style<UIColumn> = { bgColor: colors.card, borderRadius: 16, p: 14 }

const screen = UIScreen(
  UIText("Сегодня").style(type.h1),
  UIText(date().format("dddd, DD MMMM")).style({ ...type.small, fontSize: 15 }),

  UIColumn(
    UIText("Прочитать 20 страниц").style(type.body),
    UIText("13:00 · 12 дней подряд").style(type.small),
  ).style({ ...card, gap: 4, mt: 16 }),
  UIColumn(
    UIText("60%").style(type.h2),
    UIText("выполнено за сегодня").style(type.small),
  ).style({ ...card, gap: 2, alignItems: "center", mt: 10 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })

screen.open()

Style<UIText> — только тип, ничего импортировать или создавать не нужно; он подсказывает свойства в редакторе и ловит опечатки. Раскладка { ...type.small, fontSize: 15 } — обычный TypeScript: поздний ключ побеждает. Заметь, что type.small несёт в себе color: colors.muted — стиль может ссылаться на переменную темы и тогда перекрасится вместе с ней.

Модуль токенов

В настоящем проекте всё это живёт в одном файле, который импортирует каждый экран, — это стандартная форма приложения LeCodes:

TypeScript
// theme.ts
const dark = { bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" }
const light = { bg: "#f2f2f7", card: "#ffffff", text: "#111114", muted: "#6b6b76", accent: "#FF4032" }

export const palette = dark                                    // сырые hex — для всего, что не UI-стиль
export const colors: { [K in keyof typeof dark]: string } = theme(dark)
theme({ color: dark.text, fontFamily: font("manrope") })

export const setDark = (d: boolean) => { const p = d ? dark : light; theme({ ...p, color: p.text }) }

export const type = {
  h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
  body: { fontSize: 16, fontWeight: 600 } as Style<UIText>,
  small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}
TypeScript
// screens/today.ts
import { colors, type } from "../theme"

export const todayScreen = UIScreen(
  UIText("Сегодня").style(type.h1),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

Аннотация { [K in keyof typeof dark]: string } у экспорта — не украшение: theme() выводит литеральные типы ("var(--accent)"), и функция с параметром (color = colors.accent) сузила бы его до одного этого токена.

Экран целиком

Трекер из прошлого шага на токенах: палитра и переключатель темы наверху, типографика — общими объектами, ни одного color: "white". Список, статистика и кнопка работают как раньше:

TypeScript
const dark = { bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" }
const light = { bg: "#f2f2f7", card: "#ffffff", text: "#111114", muted: "#6b6b76", accent: "#FF4032" }
const colors = theme(dark)
theme({ color: dark.text })

const isDark = signal(true)
const applyTheme = (d: boolean) => {
  isDark.value = d
  const p = d ? dark : light
  theme({ ...p, color: p.text })
}

const type = {
  h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
  h2: { fontSize: 22, fontWeight: 800 } as Style<UIText>,
  body: { fontSize: 16, fontWeight: 600 } as Style<UIText>,
  small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}

type Habit = { id: number; title: string; time: string; color: string; done: boolean }
const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", time: "07:30", color: "#34c759", done: true },
  { id: 2, title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a", done: true },
  { id: 3, title: "Позаниматься на пианино", time: "18:00", color: "#5e9eff", done: false },
  { id: 4, title: "Растяжка", time: "20:30", color: "#af6bff", done: false },
])
const doneCount = computed(() => habits.value.filter((h) => h.done).length)
const toggle = (h: Habit) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}
const IDEAS = ["Стакан воды", "Медитация 10 минут", "Прогулка", "Без телефона перед сном"]
const addHabit = () => {
  const n = habits.value.length
  habits.value = [...habits.value, { id: n + 1, title: IDEAS[n % IDEAS.length], time: "21:00", color: dark.accent, done: false }]
}

const Chip = (label: () => string, onTap: () => void) =>
  UIButton(UIText(label).style({ ...type.small, fontWeight: 600 }))
    .style({ height: 32, px: 14, borderRadius: 16, bgColor: colors.card, $pressed: { opacity: 0.7 } })
    .onClick(onTap)

const Stat = (value: () => string, label: string) =>
  UIColumn(UIText(value).style(type.h2), UIText(label).style(type.small))
    .style({ bgColor: colors.card, borderRadius: 14, p: 12, gap: 2, flex: 1, alignItems: "center" })

const HabitRow = (h: Habit) =>
  UIButton(
    UIColumn(UIText(h.done ? "✓" : "").style({ fontSize: 15, fontWeight: 700, color: "#ffffff" })).style({
      width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
      bgColor: h.done ? h.color : "transparent", border: h.done ? "0" : `2px solid ${h.color}`,
    }),
    UIColumn(UIText(h.title).style(type.body), UIText(h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
    .onClick(() => toggle(h))

const screen = UIScreen(
  UIRow(
    UIText("Сегодня").style({ ...type.h1, flexGrow: 1 }),
    Chip(() => (isDark.value ? "Светлая" : "Тёмная"), () => applyTheme(!isDark.value)),
  ).style({ alignItems: "center" }),
  UIText(date().format("dddd, DD MMMM")).style({ ...type.small, fontSize: 15 }),

  UIRow(
    Stat(() => `${doneCount.value}`, "готово"),
    Stat(() => `${habits.value.length - doneCount.value}`, "осталось"),
    Stat(() => `${Math.round((doneCount.value / habits.value.length) * 100)}%`, "за сегодня"),
  ).style({ gap: 10, mt: 8 }),

  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
  UISpacer(),

  UIButton(UIText("+ Новая привычка").style({ ...type.body, fontWeight: 700, color: "#ffffff" }))
    .style({ bgColor: colors.accent, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
    .onClick(addHabit),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)", gap: 4 })

screen.open()

Обрати внимание на два места, где цвет задан явно и это правильно: галочка внутри цветного кружка и подпись на акцентной кнопке — они лежат на цветной подложке и должны оставаться белыми в обеих темах. Всё остальное следует за таблицей.

Что ты узнал

  • theme(values) — одна таблица переменных приложения; возвращает аксессоры вида "var(--key)", которые подставляются в любой UI-стиль.
  • Системные ключи color и fontFamily задают текст по умолчанию — color: "white" больше не нужен на каждом элементе.
  • Повторный theme() перекрашивает открытые экраны на месте: тёмная тема, бренд, акцент пользователя — один вызов.
  • font("id") — макрос компиляции: использовать шрифт значит объявить его. Ждать нечего.
  • Размеры и веса — обычные объекты Style<T>, раскладываемые в .style(); в проекте всё это живёт в модуле theme.ts.

Справочник: Тема · Стилизация · Шрифты · Модель элементов.

Дальше: списки и данные — прокрутка, которая не тормозит: UIScrollable для тела экрана и UIVirtualizedList для длинной истории.