Тема и токены
Шаг 3 UI-трека. В программе из прошлого шага цвета живут в четырёх константах наверху файла — уже лучше, чем hex по всему коду, но константа не умеет меняться, пока приложение работает. Сегодня переносим палитру в theme(), получаем в подарок переключатель тёмной и светлой темы, убираем color: "white" из каждого UIText и заводим общие стили для типографики.
Токены вместо констант
theme(values) кладёт переменные в одну общую таблицу приложения и возвращает аксессоры — объект с теми же ключами, чьи значения — строки вида "var(--card)". Такую строку можно подставить в любой UI-стиль, а хост разрешит её в момент применения. Числа — это длины в логических px, строки проходят как есть.
Два ключа особенные — их читает сам SDK: color задаёт цвет текста по умолчанию для каждого UIText без собственного color, fontFamily — шрифт по умолчанию. Одна строка вместо color: "white" в каждом элементе:
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() перекрашивает всё, что сейчас на экране, на месте — без перестройки дерева, без прокидывания пропсов, без места, которое можно забыть обновить. Тёмная тема — это второй вызов с другой палитрой:
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 из проекта, регистрирует нужные начертания и возвращает имя семейства. Ничего не нужно ждать или загружать вручную:
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 }):
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:
// 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>,
}// 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". Список, статистика и кнопка работают как раньше:
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 для длинной истории.