Полировка
Шаг 8, последний в UI-треке. Трекер работает: экраны, вкладки, форма, оверлеи. Осталось то, что отличает «работает» от «приятно»: появление списка, отклик карточки на нажатие, короткая вибрация, отступы, которые уважают чёлку и жестовую панель, и экран, который не выглядит пустым, когда всё сделано. Ничего из этого не требует новых концепций — только внимания к деталям и трёх-четырёх приёмов.
Появление
el.animateFrom({ … }) — анимация входа: элемент прыгает в заданные значения и плавно возвращается к своему стилю. Стиль элемента при этом не меняется — это эффект, а не состояние. Запускать её удобно в onOpen экрана, а delay по индексу даёт «лесенку»:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const TITLES = ["Утренняя пробежка", "Прочитать 20 страниц", "Позаниматься на пианино", "Растяжка"]
const COLORS = ["#34c759", "#ff9f0a", "#5e9eff", "#af6bff"]
const rows = TITLES.map((title, i) =>
UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: COLORS[i] }),
UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" }),
)
const enter = () => {
rows.forEach((row, i) =>
row.animateFrom({ opacity: 0, transform: "translateY(24px)", duration: 350, delay: i * 70 }),
)
}
const screen = UIScreen(
UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(rows).style({ gap: 10, mt: 12 }),
UIButton(UIText("Показать ещё раз").style({ color: colors.muted, fontSize: 15 }))
.style({ p: 12, mt: 8, $pressed: { opacity: 0.6 } })
.onClick(enter),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
.onOpen(enter) // при каждом открытии, не только первом
screen.open()animateFrom— вход,animateTo— переход к новым значениям с записью их в стиль (commit: false— без записи, для анимаций исчезновения). Длительности — миллисекунды.- Повторяющиеся эффекты —
loop:dot.animateTo({ transform: "scale(1.4)", opacity: 0.4, duration: 600, loop: true })пульсирует туда-обратно и никогда не записывает стиль; остановить — любым следующимanimateToна этом элементе. - Не переусердствуй: 250–400 мс на вход и один эффект на экран — норма. Анимация в
onOpenработает и при возврате по стеку — если это лишнее, оставь её только на первый показ флагом. - Переходы между экранами при
Router.replaceзадаются темой:theme({ replaceTransition: "fade" });push/popанимирует хост.
Отклик на нажатие
Пользователь должен чувствовать, что нажал. Три слоя отклика — от обязательного к приятному: $pressed (пока палец на элементе), плавный переход состояния через класс с duration и короткая вибрация device.vibrate("light") (тихий no-op там, где нет мотора — в браузере, на iPad).
Здесь же — обещанный в шаге про состояние второй способ хранить done: сигнал внутри объекта. Карточка тогда не пересоздаётся, а переключает класс — и переход анимируется:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
type Habit = { title: string; color: string; done: Signal<boolean> }
const habits: Habit[] = [
{ title: "Утренняя пробежка", color: "#34c759", done: signal(true) },
{ title: "Прочитать 20 страниц", color: "#ff9f0a", done: signal(false) },
{ title: "Растяжка", color: "#af6bff", done: signal(false) },
]
const doneCount = computed(() => habits.filter((h) => h.done.value).length)
const HabitRow = (h: Habit) =>
UIButton(
UIColumn(
UIText("✓").style({ fontSize: 15, fontWeight: 700, opacity: 0, $done: { opacity: 1, duration: 200 } }),
).style({
width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
border: `2px solid ${h.color}`, bgColor: "transparent",
$done: { bgColor: h.color, duration: 200 },
}),
UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1, $done: { opacity: 0.55, duration: 200 } }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { transform: "scale(0.97)", duration: 120 } })
.class({ done: () => h.done.value }) // один класс — три анимированных реакции
.onClick(() => {
h.done.value = !h.done.value
device.vibrate(h.done.value ? "light" : "selection")
})
const screen = UIScreen(
UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
UIText(() => `${doneCount.value} из ${habits.length}`).style({ color: colors.muted, fontSize: 15 }),
UIColumn(habits.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
screen.open()$pressed: { transform: "scale(0.97)", duration: 120 }— карточка чуть проседает под пальцем и возвращается. Каскад делает то же с детьми, если у них есть свои$pressed-блоки.- Каждый элемент анимирует переключение класса по своему
duration; сам переключатель — одна запись в сигнал. - Когда что выбирать: массив в сигнале и новый объект — когда меняется структура списка (добавить, удалить, отсортировать); сигнал в объекте — когда часто переключается свойство существующей строки. Их можно совмещать:
habits— сигнал массива,done— сигнал в элементе. - Стили вибрации семантические —
"light" | "medium" | "heavy" | "selection" | "success" | "warning" | "error"— потому что только такой словарь одинаково естественен на iOS и Android.
Безопасные зоны и ориентация
max(safe-top, 24px) из первого шага честно работает, но у SDK есть готовые токены комфорта: comfort-top / comfort-bottom / comfort-left / comfort-right — «где контенту удобно начинаться». На iOS с чёлкой это max(вырез, отступ), на Android с системными панелями точной высоты — вырез + отступ; хост знает, какая у него панель, поэтому один экран правильно ложится везде. Парные формы comfort-x / comfort-y — на px / py; горизонтальные токены заодно служат гуттером страницы и уводят контент от выреза в ландшафте:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", "comfort-left": 20, "comfort-right": 20 }) // гуттер приложения — 20
const Card = (title: string, text: string) =>
UIColumn(
UIText(title).style({ fontSize: 16, fontWeight: 700 }),
UIText(text).style({ color: colors.muted, fontSize: 14, lineHeight: "1.4em", lineClamp: 2 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 16, gap: 6, onLandscape: { flex: 1 } })
const screen = UIScreen(
UIRow(
UIText("Отступы").style({ fontSize: 32, fontWeight: 800, flexGrow: 1 }),
UIImage(assetIcon("lucide:smartphone")).style({ width: 24, height: 24, tintColor: colors.muted }),
).style({ alignItems: "center" }),
UIColumn(
Card("comfort-top", "Не под чёлкой на iPhone и не под статус-баром на Android; в браузере — просто отступ."),
Card("comfort-x", "Гуттер страницы; в ландшафте на телефоне с вырезом он же отодвигает контент от выреза."),
Card("comfort-bottom", "Вплотную над индикатором «домой» на iOS, с зазором над трёхкнопочной панелью Android."),
).style({ gap: 10, mt: 12, onLandscape: { flexDirection: "row" } }), // в ландшафте — в ряд
UISpacer(),
UIButton(UIText("Кнопка у нижнего края").style({ fontSize: 16, fontWeight: 700 }))
.style({ bgColor: colors.accent, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } }),
).style({ bgColor: colors.bg, px: "comfort-x", pt: "comfort-top", pb: "comfort-bottom" })
screen.open()- Сырые вырезы —
safe-topи компания — тоже доступны, когда нужен именно факт о железе (фон-картинка под статус-баром, полноэкранное видео). Для контента бериcomfort-*. - Значения токенов настраиваются через тему:
theme({ "comfort-left": 20 })— обычные длины, без формул. onLandscape: { … }/onPortrait: { … }внутри любого стиля — блок, который накладывается в этой ориентации и снимается при выходе из неё. Три карточки выше становятся рядом, стоит повернуть телефон.lineClamp: 2обрезает описание по двум строкам с многоточием — и меряет элемент по этой высоте, так что карточки в ряду остаются одинаковыми.assetIcon("lucide:smartphone")сtintColor— монохромная иконка из реестра; для цветной графикиtintColorне подходит.
Экран целиком
Экран «Сегодня» со всем сразу: лесенка при открытии, анимированные переключения с вибрацией, комфортные отступы, иконка-кнопка в заголовке и пустое состояние, когда всё сделано:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", "comfort-left": 20, "comfort-right": 20 })
type Habit = { title: string; time: string; color: string; done: Signal<boolean> }
const habits: Habit[] = [
{ title: "Утренняя пробежка", time: "07:30", color: "#34c759", done: signal(true) },
{ title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a", done: signal(true) },
{ title: "Позаниматься на пианино", time: "18:00", color: "#5e9eff", done: signal(false) },
{ title: "Растяжка", time: "20:30", color: "#af6bff", done: signal(false) },
]
const leftCount = computed(() => habits.filter((h) => !h.done.value).length)
const HabitRow = (h: Habit) =>
UIButton(
UIColumn(UIText("✓").style({ fontSize: 15, fontWeight: 700, opacity: 0, $done: { opacity: 1, duration: 200 } })).style({
width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
border: `2px solid ${h.color}`, bgColor: "transparent", $done: { bgColor: h.color, duration: 200 },
}),
UIColumn(
UIText(h.title).style({ fontSize: 16, fontWeight: 600 }),
UIText(h.time).style({ color: colors.muted, fontSize: 13 }),
).style({ gap: 2, flexGrow: 1, $done: { opacity: 0.55, duration: 200 } }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { transform: "scale(0.97)", duration: 120 } })
.class({ done: () => h.done.value })
.onClick(() => { h.done.value = !h.done.value; device.vibrate(h.done.value ? "light" : "selection") })
const rows = habits.map(HabitRow)
const enter = () => rows.forEach((row, i) =>
row.animateFrom({ opacity: 0, transform: "translateY(24px)", duration: 350, delay: i * 60 }))
const allDone = UIColumn(
UIImage(assetIcon("lucide:party-popper")).style({ width: 40, height: 40, tintColor: colors.accent }),
UIText("Всё сделано!").style({ fontSize: 18, fontWeight: 700 }),
UIText("Отличный день. Завтра список появится снова.").style({ color: colors.muted, fontSize: 14, textAlign: "center" }),
).style({ alignItems: "center", gap: 6, p: 24, bgColor: colors.card, borderRadius: 16, mt: 12,
display: () => (leftCount.value === 0 ? "flex" : "none") })
const screen = UIScreen(
UIRow(
UIColumn(
UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
UIText(() => (leftCount.value === 0 ? "Все привычки выполнены" : `Осталось ${leftCount.value}`)).style({ color: colors.muted, fontSize: 15 }),
).style({ flexGrow: 1, gap: 2 }),
UIButton(UIImage(assetIcon("lucide:plus")).style({ width: 22, height: 22, tintColor: "#ffffff" }))
.style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accent, $pressed: { transform: "scale(0.92)", duration: 100 } })
.onClick(() => toast("Тут откроется форма из шага 6")),
).style({ alignItems: "center" }),
UIScrollable(rows).style({ flexGrow: 1, gap: 10, mt: 16, showScrollbar: false }),
allDone,
).style({ bgColor: colors.bg, px: "comfort-x", pt: "comfort-top", pb: "comfort-bottom" })
.onOpen(enter)
screen.open()Отметь все четыре привычки — под списком появится карточка «Всё сделано». Она существует всегда, а показывается привязкой display: () => … — для редкого состояния это проще, чем перестраивать детей. Круглая кнопка «+» вместо широкой — теперь, когда список умеет прокручиваться, ей не нужно занимать нижний край.
Что ты узнал
animateFromвonOpenсdelayпо индексу — лесенка появления;animateTo— переход,loop— пульс; всё в миллисекундах.- Отклик:
$pressedсtransform, классы сdurationдля переходов состояния,device.vibrate("light")для тактильности. - Сигнал внутри объекта — второй способ хранить состояние строки; карточка не пересоздаётся, а анимированно переключается.
comfort-*токены вместо ручных формул безопасных зон;onLandscape— раскладка для поворота;lineClamp— ровные карточки.- Пустое состояние — обычный элемент с реактивным
display.
Справочник: Стилизация — animateTo, безопасные зоны, responsive · Классы стилей · device — вибрация · Контент — иконки и lineClamp.
UI-трек пройден
У тебя есть многоэкранное приложение с состоянием, темой, списками, навигацией, формой и оверлеями — и привычка думать в терминах LeCodes: элементы-объекты, сигналы, один скролл на экран, «создать один раз — показывать по требованию». Куда дальше:
- Данные и сеть — сохранить привычки в
localStorage, синхронизировать с сервером черезfetch, показать состояние загрузки. (пишется) - CLI и локальная разработка — разложить проект по файлам, запускать на телефоне с hot reload, прогонять headless-тесты.
- LeCodes Design — проектировать экраны как живые прототипы, прежде чем писать логику.
- Справочник API — контракт каждого глобала, который здесь встречался.