Real stories:How a football-school coach shipped his apps in one week soon

Polish

Step 8, the last of the UI track. The tracker works: screens, tabs, a form, overlays. What's left is what separates "works" from "feels good": the list's entrance, a card's response to a tap, a short vibration, padding that respects the notch and the gesture bar, and a screen that doesn't look empty when everything is done. None of it needs new concepts — just attention to detail and three or four techniques.

Entrance

el.animateFrom({ … }) is the entry animation: the element snaps to the given values and eases back to its own style. The element's style is untouched — it's an effect, not a state. The natural place to run it is the screen's onOpen, and a delay by index gives a "staircase":

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

const TITLES = ["Morning run", "Read 20 pages", "Practice piano", "Stretching"]
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("Today").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(rows).style({ gap: 10, mt: 12 }),
  UIButton(UIText("Play it again").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)                                   // on every open, not just the first

screen.open()
  • animateFrom is the entrance, animateTo a transition to new values that commits them into the style (commit: false skips the commit — for exit animations). Durations are milliseconds.
  • Repeating effects use loop: dot.animateTo({ transform: "scale(1.4)", opacity: 0.4, duration: 600, loop: true }) pulses back and forth and never commits; stop it with any later animateTo on that element.
  • Don't overdo it: 250–400 ms for an entrance and one effect per screen is the norm. An onOpen animation also plays when you come back down the stack — if that's too much, gate it to the first show with a flag.
  • Transitions between screens on Router.replace come from the theme: theme({ replaceTransition: "fade" }); push / pop are animated by the host.

Press feedback

The user should feel the tap. Three layers of feedback, from mandatory to delightful: $pressed (while the finger is on the element), a smooth state transition through a class with a duration, and a short vibration device.vibrate("light") (a silent no-op where there's no motor — the browser, an iPad).

This is also where the second way of storing done, promised in the state step, comes in: a signal inside the object. The card is then not recreated but switches a class — and the transition animates:

TypeScript
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: "Morning run", color: "#34c759", done: signal(true) },
  { title: "Read 20 pages", color: "#ff9f0a", done: signal(false) },
  { title: "Stretching", 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 })            // one class — three animated reactions
    .onClick(() => {
      h.done.value = !h.done.value
      device.vibrate(h.done.value ? "light" : "selection")
    })

const screen = UIScreen(
  UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
  UIText(() => `${doneCount.value} of ${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 } — the card dips slightly under the finger and comes back. The cascade does the same to children if they declare their own $pressed blocks.
  • Every element animates the class switch by its own duration; the switch itself is one signal write.
  • Which to pick: an array in a signal plus a new object — when the list's structure changes (add, remove, sort); a signal inside the object — when a property of an existing row toggles often. They combine: habits a signal of an array, done a signal in the item.
  • Haptic styles are semantic — "light" | "medium" | "heavy" | "selection" | "success" | "warning" | "error" — because that's the only vocabulary that feels equally native on iOS and Android.

Safe areas and orientation

max(safe-top, 24px) from the first step honestly works, but the SDK has ready comfort tokens: comfort-top / comfort-bottom / comfort-left / comfort-right — "where content should comfortably start". On an iPhone with a notch that's max(inset, knob); on Android with exact-height system bars it's inset + knob; the host knows which kind of bar it has, so one screen lands right everywhere. The pair forms comfort-x / comfort-y go on px / py; the horizontal tokens double as the page gutter and move content clear of the notch in landscape:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", "comfort-left": 20, "comfort-right": 20 })   // this app's gutter is 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("Insets").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", "Clear of the notch on iPhone and of the status bar on Android; in a browser, just a margin."),
    Card("comfort-x", "The page gutter; in landscape on a notched phone it also moves content clear of the notch."),
    Card("comfort-bottom", "Flush over the home indicator on iOS, with a gap above Android's three-button bar."),
  ).style({ gap: 10, mt: 12, onLandscape: { flexDirection: "row" } }),   // a row in landscape

  UISpacer(),
  UIButton(UIText("A button at the bottom edge").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()
  • The raw insets — safe-top and friends — are still there when you need the hardware fact itself (a background image under the status bar, full-screen video). For content, use comfort-*.
  • The token values are tuned through the theme: theme({ "comfort-left": 20 }) — plain lengths, no formulas.
  • onLandscape: { … } / onPortrait: { … } inside any style is a block that overlays in that orientation and lifts off when it ends. The three cards above line up in a row the moment the phone turns.
  • lineClamp: 2 cuts the description at two lines with an ellipsis — and measures the element at that height, so the cards in a row stay equal.
  • assetIcon("lucide:smartphone") with tintColor is a monochrome registry icon; tintColor isn't for colored artwork.

The whole screen

The "Today" screen with everything at once: a staircase on open, animated toggles with haptics, comfort insets, an icon button in the header and an empty state when everything is done:

TypeScript
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: "Morning run", time: "07:30", color: "#34c759", done: signal(true) },
  { title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: signal(true) },
  { title: "Practice piano", time: "18:00", color: "#5e9eff", done: signal(false) },
  { title: "Stretching", 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("All done!").style({ fontSize: 18, fontWeight: 700 }),
  UIText("Great day. The list comes back tomorrow.").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("Today").style({ fontSize: 32, fontWeight: 800 }),
      UIText(() => (leftCount.value === 0 ? "Every habit is done" : `${leftCount.value} left`)).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("The form from step 6 opens here")),
  ).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()

Check off all four habits — an "All done" card appears under the list. It always exists and is shown through a display: () => … binding — for a rare state that's simpler than rebuilding children. A round "+" button instead of a wide one — now that the list scrolls, it doesn't need to occupy the bottom edge.

What you learned

  • animateFrom in onOpen with a delay by index — the entrance staircase; animateTo a transition, loop a pulse; all in milliseconds.
  • Feedback: $pressed with transform, classes with duration for state transitions, device.vibrate("light") for touch.
  • A signal inside the object is the second way to hold row state; the card isn't recreated, it switches with an animation.
  • comfort-* tokens instead of hand-rolled safe-area formulas; onLandscape for the rotated layout; lineClamp for even cards.
  • An empty state is an ordinary element with a reactive display.

Reference: Styling — animateTo, safe areas, responsive · Style classes · device — haptics · Content — icons and lineClamp.

The UI track is done

You have a multi-screen app with state, a theme, lists, navigation, a form and overlays — and the habit of thinking in LeCodes terms: elements as objects, signals, one scroll per screen, "create once — show on demand". Where next:

  • Data & networking — save the habits to localStorage, sync with a server via fetch, show loading states. (being written)
  • CLI & local dev — split the project into files, run it on a phone with hot reload, run headless tests.
  • LeCodes Design — design screens as live prototypes before writing the logic.
  • API reference — the contract of every global you met here.