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

Screens & navigation

Step 5 of the UI track. So far the tracker has been one screen opened with screen.open(). Today it becomes an app: "Today" and "History" tabs at the bottom, a habit screen on top of the list, a back button — on-screen and hardware. Three tools: Router for the screen stack, onOpen / onClose for the lifecycle, and UITabs — the ready-made tab shell.

The screen stack — Router

Router holds a stack of screens. Router.init(home) instead of home.open() — and from then on Router.push(screen) puts a screen on top, Router.pop() takes it off. Screens below the top stay in memory with all their state — they just aren't drawn; a screen that leaves the stack is destroyed.

A screen with data is a factory function: HabitScreen(h) builds a fresh screen for a specific habit at the moment of navigation:

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

type Habit = { title: string; time: string; streak: number }
const habits: Habit[] = [
  { title: "Morning run", time: "07:30", streak: 12 },
  { title: "Read 20 pages", time: "13:00", streak: 5 },
  { title: "Stretching", time: "20:30", streak: 31 },
]

const BackButton = () =>
  UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
    .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
    .onClick(() => Router.pop())

const HabitScreen = (h: Habit) =>
  UIScreen(
    BackButton(),
    UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText(`Every day at ${h.time}`).style({ color: colors.muted, fontSize: 15 }),
    UIColumn(
      UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }),
      UIText("day streak").style({ color: colors.muted, fontSize: 13 }),
    ).style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onBackPressed(() => Router.pop())          // the hardware back on Android

const HabitRow = (h: Habit) =>
  UIButton(
    UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText("›").style({ color: colors.muted, fontSize: 22 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
    .onClick(() => Router.push(HabitScreen(h)))

const home = UIScreen(
  UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(habits.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

Router.init(home)
  • Router.init(home) is called once, in the entry file. Router.push / Router.pop are global: call them from any file, no router reference to thread around.
  • pop() removes one screen by default; pop(-2) two, pop(0) all the way to the first. Popped screens are destroyed, but the UIScreen object stays usable — you can push it again.
  • Router.replace(screen) swaps the top screen without growing the stack — that's "sign in → home". The transition is configurable: theme({ replaceTransition: "fade" }) or a per-call option.
  • The Android back button is screen.onBackPressed(cb). The press resolves down a chain: an open widget → the current screen → a drilled-in tab → the router → the global app.onBackPressed. If nobody handles it, the press is ignored — the app doesn't exit.
  • The on-screen "back" is an ordinary button. A system-provided one can be enabled: Router.init(home, { showDefaultBackButton: true }).

Screen lifecycle

A screen has two events: onOpen — it became active, onClose — it stopped being active. They fire on every activation, not just the first: a push on top fires the lower screen's onClose, a pop back fires its onOpen again. One rule, no exceptions: whatever starts in onOpen stops in onClose — loops, intervals, sockets — or it keeps running behind the next screen.

A habit screen with a focus timer — the minute ticks only while it's open:

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

const TimerScreen = (title: string, seconds: number) => {
  const left = signal(seconds)
  let timer: number | null = null

  return UIScreen(
    UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
      .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
      .onClick(() => Router.pop()),
    UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText(() => `${left.value} s`).style({ fontSize: 64, fontWeight: 800, mt: 24 }),
    UIText(() => (left.value > 0 ? "The timer runs while the screen is open" : "Done!"))
      .style({ color: colors.muted, fontSize: 15 }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onOpen(() => {
      timer = setInterval(() => { if (left.value > 0) left.value-- }, 1000)
    })
    .onClose(() => {
      if (timer !== null) { clearInterval(timer); timer = null }
    })
    .onBackPressed(() => Router.pop())
}

const Row = (title: string, seconds: number) =>
  UIButton(
    UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText(`${seconds} s`).style({ color: colors.muted, fontSize: 13 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
    .onClick(() => Router.push(TimerScreen(title, seconds)))

const home = UIScreen(
  UIText("Focus").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(Row("Stretching", 60), Row("Meditation", 120), Row("Plank", 45)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

Router.init(home)

Open a timer, wait a couple of seconds, go back and open it again — it starts over with a fresh screen, and the old interval was stopped in onClose. Note that left and timer are declared inside the factory: every opened screen has its own state, and it dies with it.

The same pattern goes for setLoop in animations, setInterval polling a server, a WebSocket in a chat. The full rules are in the SDK conventions.

Tabs — UITabs

A bottom tab bar is the most common shell of a mobile app, and in LeCodes it's ready-made: UITabs is a UIScreen of a UIPager (tabs that swipe natively, each with its own stack) plus a tab bar styled through theme variables. The object keys are the tab ids, in order:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })

const HabitScreen = (title: string) =>
  UIScreen(
    UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
      .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
      .onClick(() => UIPager.pop()),
    UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText("Opened inside the tab — the tab bar stayed").style({ color: colors.muted, fontSize: 15 }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onBackPressed(() => UIPager.pop())

const Row = (title: string) =>
  UIButton(UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }), UIText("›").style({ color: colors.muted, fontSize: 22 }))
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
    .onClick(() => UIPager.push(HabitScreen(title)))     // push INSIDE the current tab

const today = UIScreen(
  UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(Row("Morning run"), Row("Read 20 pages"), Row("Stretching")).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

const history = UIScreen(
  UIText("History").style({ fontSize: 32, fontWeight: 800 }),
  UIText("The check-in list goes here").style({ color: colors.muted, fontSize: 15 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })

const tabs = UITabs({
  today:   { label: "Today",   icon: assetIcon("lucide:circle-check"), screen: today },
  history: { label: "History", icon: assetIcon("lucide:calendar"),     screen: history },
})
tabs.badge("history", 3)

Router.init(tabs)

Tap a habit — the screen opens inside the tab and the tab bar stays; swiping between tabs works at each tab's root. What to remember:

  • Two navigation verbs. UIPager.push(screen) opens a screen in the current tab (bar visible, the edge back-swipe is native). Router.push(screen) opens on top of everything, bar included: sign-in, a full-screen player. Both are global — from any screen, no references.
  • The bar is themed: primaryColor for the active tab, mutedColor for inactive ones, tabbarBg / tabbarBorder for the surface and hairline. An unthemed app gets dark defaults.
  • assetIcon("lucide:calendar") is a compile macro: an icon from the registry is inlined into the bundle at build time; the id is a string literal.
  • tabs.select("history"), tabs.tab, tabs.onSelect((id) => …), tabs.badge(id, 3 | true | 0) — programmatic control. Each tab remembers its stack: leave a tab three screens deep, come back — you're exactly there.
  • Need a custom bar — drop one level down: UIPager(a, b) plus your own UIRow of buttons; pager.onSelect keeps the highlight in sync on swipes.

How this splits into files

One file per screen, the entry point assembling the shell — the standard shape of a LeCodes app:

TypeScript
// screens/today.ts
import { colors } from "../theme"
export const todayScreen = UIScreen(/* … */).style({ bgColor: colors.bg })

// screens/habit.ts
export const HabitScreen = (h: Habit) => UIScreen(/* … */).onBackPressed(() => UIPager.pop())

// main.ts
import { todayScreen } from "./screens/today"
import { historyScreen } from "./screens/history"

Router.init(UITabs({
  today:   { label: "Today",   icon: assetIcon("lucide:circle-check"), screen: todayScreen },
  history: { label: "History", icon: assetIcon("lucide:calendar"),     screen: historyScreen },
}))

A screen that opens with data is an exported factory; a tab screen is a ready object. Neither imports a router: Router and UIPager are globals.

The whole app

The tracker with two tabs, a habit screen and a live badge — how many habits are left today:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })
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>,
}

type Habit = { id: number; title: string; time: string; color: string; done: boolean; streak: number }
const habits = signal<Habit[]>([
  { id: 1, title: "Morning run", time: "07:30", color: "#34c759", done: true, streak: 12 },
  { id: 2, title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: true, streak: 5 },
  { id: 3, title: "Practice piano", time: "18:00", color: "#5e9eff", done: false, streak: 2 },
  { id: 4, title: "Stretching", time: "20:30", color: "#af6bff", done: false, streak: 31 },
])
const leftCount = 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 BackButton = () =>
  UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
    .style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
    .onClick(() => UIPager.pop())

const HabitScreen = (h: Habit) =>
  UIScreen(
    BackButton(),
    UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
    UIText(`Every day at ${h.time}`).style({ ...type.small, fontSize: 15 }),
    UIColumn(UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }), UIText("day streak").style(type.small))
      .style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
    UIButton(UIText(() => (habits.value.find((x) => x.id === h.id)?.done ? "Unmark" : "Mark done")).style({ ...type.body, color: "#ffffff" }))
      .style({ bgColor: h.color, borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
      .onClick(() => { const cur = habits.value.find((x) => x.id === h.id); if (cur) toggle(cur) }),
  )
    .style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
    .onBackPressed(() => UIPager.pop())

const HabitRow = (h: Habit) =>
  UIButton(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color, opacity: h.done ? 1 : 0.35 }),
    UIColumn(UIText(h.title).style(type.body), UIText(h.done ? "done" : h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
    UIText("›").style({ color: colors.muted, fontSize: 22 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { opacity: 0.6 } })
    .onClick(() => UIPager.push(HabitScreen(h)))

const today = UIScreen(
  UIText("Today").style(type.h1),
  UIText(() => `${leftCount.value} of ${habits.value.length} left`).style({ ...type.small, fontSize: 15 }),
  UIScrollable(() => habits.value.map(HabitRow)).style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

const history = UIScreen(
  UIText("History").style(type.h1),
  UIColumn(
    [0, 1, 2, 3, 4, 5, 6].map((d) =>
      UIRow(
        UIText(date().subtract(d, "day").format("dddd, D MMMM")).style({ ...type.body, flexGrow: 1 }),
        UIText(`${4 - (d % 3)} / 4`).style(type.small),
      ).style({ bgColor: colors.card, borderRadius: 16, p: 14 }),
    ),
  ).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

const tabs = UITabs({
  today:   { label: "Today",   icon: assetIcon("lucide:circle-check"), screen: today },
  history: { label: "History", icon: assetIcon("lucide:calendar"),     screen: history },
})
effect(() => tabs.badge("today", leftCount.value))     // the badge follows the state

Router.init(tabs)

Two details that are easy to miss. First, effect — the first time in the track: a tab badge isn't a binding inside an element but a method call, so it has to be repeated by hand on every state change — exactly what effect does. Second, the habit screen receives a snapshot h at the moment it opens, so the "Mark done" button looks up the current record by id in the signal — that way the screen doesn't go stale if the habit was checked off from the list.

What you learned

  • Router.init once in the entry file; Router.push / pop / replace are global. Screens on the stack keep their state, popped ones are destroyed.
  • A screen with data is a factory function; its state is declared inside it.
  • onOpen / onClose fire on every activation; whatever starts in onOpen stops in onClose.
  • UITabs is the ready shell: tabs with their own stacks, a bar made of theme variables, badge. UIPager.push inside a tab, Router.push over everything.
  • onBackPressed on a screen for the hardware back; assetIcon("pack:name") for registry icons at build time.

Reference: Screens & Router · UIPager & UITabs · Conventions — lifecycle · Content: icons.

Next: forms & keyboard — the "New habit" screen: inputs, validation, the focus chain and everything you need to know about the keyboard.