lecodes-sdk 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +104 -76
  2. package/dist/global.d.ts +2 -5
  3. package/dist/host.d.ts +3 -0
  4. package/dist/types/inject.d.ts +3 -3
  5. package/dist/types/runtime/device.d.ts +7 -0
  6. package/dist/types/runtime/rpc.d.ts +11 -17
  7. package/dist/types/runtime/wire.d.ts +53 -0
  8. package/dist/types/server/auth/api.d.ts +42 -0
  9. package/dist/types/server/auth/appConfig.d.ts +1 -5
  10. package/dist/types/server/auth/models.d.ts +119 -70
  11. package/dist/types/server/auth/types.d.ts +19 -43
  12. package/dist/types/server/channel.d.ts +57 -19
  13. package/dist/types/server/context.d.ts +2 -2
  14. package/dist/types/server/db/defineDb.d.ts +10 -0
  15. package/dist/types/server/db/index.d.ts +1 -1
  16. package/dist/types/server/db/types.d.ts +76 -6
  17. package/dist/types/server/inject.d.ts +0 -1
  18. package/dist/types/ui/UINode.d.ts +19 -5
  19. package/dist/types/ui/UIScreen.d.ts +1 -0
  20. package/dist/types/ui/UITabs.d.ts +8 -6
  21. package/dist/types/ui/theme.d.ts +48 -13
  22. package/dist/types/version.d.ts +1 -1
  23. package/dist/types.json +1 -1
  24. package/package.json +3 -2
  25. package/prompts/README.md +1 -1
  26. package/prompts/design.md +19 -19
  27. package/prompts/dist/2d-game.md +45 -31
  28. package/prompts/dist/3d-app.md +45 -31
  29. package/prompts/dist/ar-app.md +45 -31
  30. package/prompts/dist/design.md +25 -24
  31. package/prompts/dist/ui-app.md +45 -31
  32. package/prompts/ui-design.md +6 -5
  33. package/prompts/ui.md +25 -22
  34. package/src/bridges/device.d.ts +9 -0
  35. package/src/bridges/tree.d.ts +5 -0
  36. package/src/chisel.ts +1 -1
  37. package/src/compile/bundler.ts +6 -0
  38. package/src/compile/compileProject.ts +3 -1
  39. package/src/compile/index.ts +3 -1
  40. package/src/compile/serverSplit.ts +58 -11
  41. package/src/compile/serverTypes.ts +189 -8
  42. package/src/host.d.ts +3 -0
  43. package/src/inject.ts +6 -6
  44. package/src/runtime/device.ts +12 -0
  45. package/src/runtime/rpc.ts +101 -40
  46. package/src/runtime/wire.ts +35 -0
  47. package/src/server/auth/api.ts +94 -0
  48. package/src/server/auth/appConfig.ts +2 -3
  49. package/src/server/auth/host.ts +244 -174
  50. package/src/server/auth/models.ts +45 -62
  51. package/src/server/auth/types.ts +19 -34
  52. package/src/server/channel.ts +97 -29
  53. package/src/server/channelHub.ts +153 -0
  54. package/src/server/context.ts +2 -2
  55. package/src/server/db/defineDb.ts +96 -36
  56. package/src/server/db/index.ts +1 -1
  57. package/src/server/db/types.ts +76 -8
  58. package/src/server/host.ts +25 -10
  59. package/src/server/inject.ts +2 -2
  60. package/src/server/runtime.ts +34 -12
  61. package/src/ui/UINode.ts +22 -5
  62. package/src/ui/UIScreen.ts +5 -0
  63. package/src/ui/UITabs.ts +19 -17
  64. package/src/ui/styleColor.ts +10 -1
  65. package/src/ui/theme.ts +96 -41
  66. package/src/version.ts +1 -1
  67. package/tests/helpers/fakeTree.ts +1 -0
  68. package/dist/types/plugins/oauth.d.ts +0 -25
  69. package/dist/types/server/auth/global.d.ts +0 -56
  70. package/src/plugins/oauth.ts +0 -61
  71. package/src/server/auth/global.ts +0 -80
  72. package/tests/helpers/memoryMarci.ts +0 -124
package/package.json CHANGED
@@ -41,9 +41,10 @@
41
41
  "@types/bun": "^1.3.14",
42
42
  "@types/node": "^25.9.1",
43
43
  "typescript": "~5.8.3",
44
- "gl-matrix": "^3.4.4"
44
+ "gl-matrix": "^3.4.4",
45
+ "marcidb-embedded": "^0.13.0"
45
46
  },
46
- "version": "2.0.4",
47
+ "version": "2.0.5",
47
48
  "files": [
48
49
  "src",
49
50
  "dist",
package/prompts/README.md CHANGED
@@ -136,7 +136,7 @@ escalation directive (above) lets it swap in the right engine module instead of
136
136
  - `index.md` must list every global exported from `sdk/src/inject.ts` — same contract as
137
137
  `docs/check.mjs`. When a global is added, update docs, then the module, then index.md.
138
138
  - Known corrections baked into these prompts (do not regress; see `docs/AUTHORING.md`):
139
- `onEndReached(thresholdPx, cb)` (threshold first), `bgSize: cover|contain|tile` (no `fill`),
139
+ `onEndReached(thresholdPx, cb)` (threshold first), `bgSize: cover|contain|fill|tile`,
140
140
  track `deltaX/deltaY` are per-move, colors are CSS strings / 0xRRGGBB / [r,g,b(,a)] everywhere, `animate` can't
141
141
  tween strings/colors, `node.physics` (not `.body`), no `Camera2D.follow`, no
142
142
  `UIScrollable.scrollTo`, models can't be cloned.
package/prompts/design.md CHANGED
@@ -25,20 +25,20 @@ composition:
25
25
 
26
26
  ```ts
27
27
  // design/shared/tokens.ts — surfaces dark-to-light, text in tiers, accents as a family.
28
- // ONE theme() call registers the vars and returns the accessors ("var(--x)" strings); the system
29
- // key `color` in the same table sets the app-wide default text color. Re-calling theme() with new
30
- // values (dark mode, brand swap) restyles every screen — and later the built app — live.
31
- const palette = {
32
- bg: "#080808", card: "#161618", cardAlt: "#1d1d20", field: "#101012",
33
- border: "#2a2a2e", text: "#ffffff", muted: "#8a8a93", faint: "#5a5b62",
34
- accent: "#00BFC2", accentPressed: "#00999b", accentDeep: "#0c4e4f", onAccent: "#05201f",
28
+ // ONE theme() call registers the vars and returns the accessors ("var(--x)" strings). Re-calling
29
+ // theme() with new values (dark mode, brand swap) restyles every screen — and later the built app
30
+ // — live.
31
+ export const colors = theme({
32
+ // the ROLES — the SDK reads these too: bg = every screen's background, text = text that names
33
+ // no color, surface / border / textMuted / accent = the tab bar (defineTabs here, UITabs in the
34
+ // built app). Name the palette by them; there are no separate keys for the chrome.
35
+ bg: "#080808", surface: "#161618", border: "#2a2a2e",
36
+ text: "#ffffff", textMuted: "#8a8a93", accent: "#00BFC2",
37
+ // the design's own
38
+ surfaceAlt: "#1d1d20", field: "#101012", faint: "#5a5b62",
39
+ accentPressed: "#00999b", accentDeep: "#0c4e4f", onAccent: "#05201f",
35
40
  success: "#30D158", danger: "#F83131", scrim: "#000000b3",
36
- // theme SYSTEM keys — SDK-rendered chrome reads these. `color` = default text color everywhere;
37
- // the other four brand the tab bar (defineTabs here, UITabs in the built app). Mirror the brand:
38
- color: "#ffffff",
39
- primaryColor: "#00BFC2", mutedColor: "#8a8a93", tabbarBg: "#161618", screenBg: "#080808",
40
- }
41
- export const colors: { [K in keyof typeof palette]: string } = theme(palette)
41
+ })
42
42
  export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24 }
43
43
  export const radius = { sm: 8, md: 12, lg: 16, pill: 999 }
44
44
  ```
@@ -48,7 +48,7 @@ export const radius = { sm: 8, md: 12, lg: 16, pill: 999 }
48
48
  import { colors, spacing, radius } from "./tokens"
49
49
 
50
50
  export const PrimaryButton = (label: string, o: { name?: string, disabled?: boolean } = {}) =>
51
- UIButton(UIText(label).style({ fontSize: 16, fontWeight: 700, color: o.disabled ? colors.muted : colors.onAccent }))
51
+ UIButton(UIText(label).style({ fontSize: 16, fontWeight: 700, color: o.disabled ? colors.textMuted : colors.onAccent }))
52
52
  .style({ height: 52, borderRadius: radius.md, bgColor: o.disabled ? colors.accentDeep : colors.accent,
53
53
  justifyContent: "center", alignItems: "center", name: o.name, $pressed: { bgColor: colors.accentPressed } })
54
54
 
@@ -59,7 +59,7 @@ export const SectionLabel = (t: string) =>
59
59
  export const Field = (o: { label?: string, value?: string, placeholder?: string, focused?: boolean }) =>
60
60
  UIColumn(
61
61
  o.label ? SectionLabel(o.label) : null,
62
- UIRow(UIText(o.value ?? o.placeholder ?? "").style({ flexGrow: 1, fontSize: 16, color: o.value ? colors.text : colors.muted }))
62
+ UIRow(UIText(o.value ?? o.placeholder ?? "").style({ flexGrow: 1, fontSize: 16, color: o.value ? colors.text : colors.textMuted }))
63
63
  .style({ height: 52, px: 16, alignItems: "center", bgColor: colors.field, borderRadius: radius.md,
64
64
  border: `1px solid ${o.focused ? colors.accent : colors.border}` }),
65
65
  ).style({ gap: spacing.sm })
@@ -78,9 +78,9 @@ export const ConfirmDialog = (o: { title: string, body?: string, confirmLabel: s
78
78
  UIWidget(
79
79
  UIColumn(
80
80
  UIText(o.title).style({ fontSize: 17, fontWeight: 700, color: colors.text, textAlign: "center" }),
81
- o.body ? UIText(o.body).style({ fontSize: 14, color: colors.muted, textAlign: "center", lineHeight: "1.4em" }) : null,
81
+ o.body ? UIText(o.body).style({ fontSize: 14, color: colors.textMuted, textAlign: "center", lineHeight: "1.4em" }) : null,
82
82
  PrimaryButton("Cancel", { name: "cancel" }),
83
- ).style({ bgColor: colors.card, borderRadius: radius.lg, p: spacing.xl, gap: spacing.lg, width: "86%", maxWidth: 360 }),
83
+ ).style({ bgColor: colors.surface, borderRadius: radius.lg, p: spacing.xl, gap: spacing.lg, width: "86%", maxWidth: 360 }),
84
84
  ).style({ top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", overlayColor: colors.scrim })
85
85
  ```
86
86
 
@@ -177,8 +177,8 @@ export const mainTabs = defineTabs({
177
177
  A screen that shows the bar mounts it with a literal tab id — roots AND pushed details that keep
178
178
  the bar; a screen that doesn't mount it is a full-screen push. Mount the bar alone when the screen
179
179
  owns its layout (`mainTabs.bar("schedule")`, as above) or wrap content with
180
- `mainTabs.screen("home", [...])`. Restyle through the theme's system keys — `primaryColor`,
181
- `mutedColor`, `tabbarBg`, `screenBg`, set in `shared/tokens.ts` — never by reimplementing the
180
+ `mainTabs.screen("home", [...])`. It is colored by the theme's roles — `accent`,
181
+ `textMuted`, `surface`, `border`, `bg`, set in `shared/tokens.ts` — never by reimplementing the
182
182
  bar. Keep the `defineTabs({...})` declaration and `.screen("<tab>", ...)` / `.bar("<tab>")` call
183
183
  shapes intact — the board parses them.
184
184
 
@@ -795,8 +795,8 @@ Router.init(tabs) // UITabs IS a UIScreen — present it directly
795
795
  // .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
796
796
  // .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
797
797
  // .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
798
- // Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
799
- // restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
798
+ // The bar is colored by the theme's roles (accent, textMuted, surface, border) — no keys of its
799
+ // own; one bar's own colors: tabs.bar.theme({ accent, surface }). Custom layout: UIPager below.
800
800
 
801
801
  // UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
802
802
  // OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
@@ -1012,7 +1012,7 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
1012
1012
  // Padding: p, px, py, pt, pb, pl, pr (≡ padding, paddingHorizontal, paddingVertical, paddingTop, ...)
1013
1013
  // Margin: m, mx, my, mt, mb, ml, mr — margins also accept "auto" (mx: "auto" centers a fixed-width element)
1014
1014
  // Background (containers/screen/button/input/video; UIText/UIImage/UISpacer have bgColor only):
1015
- // bgColor, bgImage (string | FetchResponse | File), bgSize ("cover"|"contain"|"tile"), bgGradient
1015
+ // bgColor, bgImage (string | FetchResponse | File | SvgSource), bgSize ("cover"|"contain"|"fill"|"tile"), bgGradient
1016
1016
  // bgGradient: a CSS linear-gradient() or radial-gradient() string, e.g. "linear-gradient(to top, rgba(0,0,0,0.7), transparent)"
1017
1017
  // or "radial-gradient(circle at 50% 40%, #7B2FF7, #0a0a1a)". Comma-separate several gradients to stack them (first = on top).
1018
1018
  // Layers paint bottom-to-top: bgColor → bgImage → bgGradient (gradient over image = text scrim).
@@ -1039,23 +1039,27 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
1039
1039
  // Env keywords resolving to the device insets (notch, home indicator):
1040
1040
  // "safe-top" | "safe-bottom" | "safe-left" | "safe-right"; "safe-all" — p / m shorthands only
1041
1041
  // Comfort tokens — where content comfortably starts, never collapse to 0: max(safe-edge, knob) on clearance
1042
- // insets (iOS notch/home indicator, gesture nav); past exact-height system BARS (Android status/nav bar) the
1043
- // knob ADDS instead. Knob defaults: 12 top, 4 bottom, 16 horizontal (the page gutter):
1042
+ // insets (iOS notch/home indicator); past exact-height system BARS (Android status/nav bar) the
1043
+ // knob ADDS instead. Knob defaults: 12 top, 8 bottom, 16 horizontal (the page gutter):
1044
1044
  // "comfort-top" | "comfort-bottom" | "comfort-left" | "comfort-right"
1045
1045
  // "comfort-x" — px/mx | "comfort-y" — py/my | "comfort-all" — p / m
1046
1046
  // Accepted on per-side padding & margin, position offsets (top/left/bottom/right), and inside calc()/min()/max():
1047
- .style({ pb: "comfort-bottom" }) // bottom bar: 34 over a home indicator, 52 above Android's button bar, 4 on inset-less devices
1047
+ .style({ pb: "comfort-bottom" }) // bottom bar: 34 over a home indicator, 56 above Android's button bar, 8 on inset-less devices
1048
1048
  .style({ px: "comfort-x" }) // page gutter that also clears the notch in landscape
1049
1049
  .style({ pb: "calc(comfort-bottom + 56px)" }) // scroll content clearing a 56px floating bar
1050
1050
 
1051
1051
  // --- Theme variables ---
1052
1052
  // theme({...}) merges app vars into one table; styles read them with "var(--name)" (numbers are px
1053
1053
  // lengths, null removes a key). Re-calling re-styles the LIVE UI — dark mode is a second call.
1054
- // Returned accessors ARE the var() strings; system vars are statics (theme.primaryColor, …):
1055
- const T = theme({ primaryColor: "#0A84FF", "comfort-left": 20 })
1056
- label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
1057
- // SYSTEM DEFAULTS: theme({ color, fontFamily }) drive the DEFAULT text color/font — all text
1058
- // without explicit values follows (light theme = set once, not color:"black" per label).
1054
+ // ONE call per app. Returned accessors ARE the var() strings:
1055
+ const T = theme({ accent: "#0A84FF", "comfort-left": 20 })
1056
+ label.style({ color: T.accent, pl: "var(--comfort-left)" })
1057
+ // ROLES — keys the SDK reads itself; use them as your palette and add your own keys beside them:
1058
+ // bg (every screen's background) · surface (cards, bars; the tab bar) · border (hairlines)
1059
+ // text (text with no color of its own) · textMuted (secondary; inactive tabs) · accent (active tab)
1060
+ // fontFamily (text with no font of its own). Light theme = set text once, not a color per label.
1061
+ // SCOPE: el.theme({...}) — the same keys for ONE subtree (a card, tabs.bar, a whole screen); its
1062
+ // descendants read them first. A pushed screen / a widget is its own root: give it its own.
1059
1063
  // Bare comfort-* tokens apply the safe-area formula; "var(--comfort-top)" reads the raw knob.
1060
1064
  // "var(--name, fallback)" applies the fallback while the key is unset. Env names (safe-*, vw/vh) are not theme keys.
1061
1065
 
@@ -1087,7 +1091,7 @@ screen.style({ flexDirection: "column", p: 16, $landscape: { flexDirection: "row
1087
1091
  // alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
1088
1092
  // overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
1089
1093
  // boxSizing: "border-box" — width/height include padding & border.
1090
- // Screen bg is black, default text is WHITE at fontSize 14 (retarget once via theme({ color, fontFamily })).
1094
+ // Screen bg is black, default text is WHITE at fontSize 14 (retarget once via theme({ bg, text, fontFamily })).
1091
1095
  // On any light surface — white cards, sheets, inputs — white text renders invisible: use dark text there
1092
1096
  // ("#111"/black), checking every UIText against the surface it actually sits on, not the screen.
1093
1097
 
@@ -1161,14 +1165,25 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
1161
1165
  // ===== ROUTER (multi-page apps) =====
1162
1166
  // Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
1163
1167
  Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
1164
- Router.push(screen) // push onto the stack, screen becomes active
1165
- Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
1168
+ Router.push(screen, opts?: { transition, popTransition }) // push onto the stack, screen becomes active (default "push")
1169
+ Router.pop(to?: number, opts?: { transition })
1170
+ // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
1166
1171
  // stack index (0 = home). Popped screens' native trees are destroyed; the UIScreen
1167
- // object stays reusable (push remounts it).
1168
- Router.replace(screen, opts?: { transition }) // swap the top screen; transition: "slide-from-left" |
1169
- // "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-out" | "zoom-in" | "fade" | "none" (default "fade")
1172
+ // object stays reusable (push remounts it). Plays the way back the screen remembers.
1173
+ Router.replace(screen, opts?: { transition, popTransition }) // swap the top screen (default "none"; theme({ replaceTransition }))
1170
1174
  Router.current // the active UIScreen (getter)
1171
- Router.hide() / Router.restore() // hide/restore the whole router — stack stays alive; fires the top screen's onClose/onOpen
1175
+ Router.hide(opts?) / Router.restore(opts?) // hide/restore the whole router — stack stays alive; fires the top screen's onClose/onOpen
1176
+ // TRANSITIONS — every call above, and open() / close() of any destination, takes { transition }:
1177
+ // a name: "push" | "pop" | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" |
1178
+ // "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
1179
+ // or your own: { enter?: pose, exit?: pose, onTop?: "enter" | "exit" } — the incoming screen comes FROM `enter`,
1180
+ // the outgoing one goes TO `exit`. pose = { transform?, opacity?, dim?, duration? (ms, 300), delay?, easing? };
1181
+ // arrays are keyframes; in a pose a % in translate is a percent of the screen ("translateY(100%)").
1182
+ // e.g. Router.push(page, { transition: { enter: { transform: "translateY(100%)", duration: 400 }, exit: { dim: 0.3 } } })
1183
+ // popTransition = the way BACK the screen remembers (Router.pop(), the back gesture, the system's back play it).
1184
+ // Default: the transition it came with, the other way round (a slide from the bottom leaves downwards;
1185
+ // a zoom / a fade go back with a fade). A replace without one keeps the way back of the screen it replaces.
1186
+ // A screen's own transform / opacity are not seen while a transition moves it — style a child instead.
1172
1187
  Router.addEventListener("change", (screen: UIScreen) => ...) / .removeEventListener("change", cb) // after every navigation
1173
1188
  // Screens BELOW the top stay mounted — element trees and state survive, they're just not rendered.
1174
1189
  // push fires the outgoing screen's onClose + the incoming one's onOpen; pop fires them in reverse and
@@ -1190,7 +1205,7 @@ font("ibm-plex-sans", { weights: [400, 600], italic: true }) // narrow/extend t
1190
1205
  // A project's own file: font("./fonts/Brand.ttf") → real family name read from the file itself
1191
1206
  // (weight/italic too — no options); two weight files of one family return the same name.
1192
1207
  // registerFont(family, url, {weight, style}) survives ONLY for runtime-computed URLs — rare.
1193
- // No style inheritance — set fontFamily per UIText or via theme() (extract a shared Style<UIText>).
1208
+ // The app's font: theme({ fontFamily }). A second family is set per UIText (extract a shared Style<UIText>).
1194
1209
 
1195
1210
  // ===== SVG IMAGES =====
1196
1211
  // SvgSource wraps raw SVG XML for use as an image source: UIImage(SvgSource(`<svg ...>`)).
@@ -1292,17 +1307,16 @@ screen.open()
1292
1307
  </file>
1293
1308
 
1294
1309
  // === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
1295
- // A tokens module every screen imports, light-themed: theme({ color }) sets the default text
1296
- // color ONCE — no color: "#111" on every label.
1310
+ // A tokens module every screen imports, light-themed. ONE theme() call: the roles (bg, surface,
1311
+ // border, text, textMuted, accent) color the screens, the default text and the tab bar — no
1312
+ // color: "#111" on every label — and the app's own keys sit beside them.
1297
1313
  <file name="tokens.ts">
1298
- const palette = {
1299
- bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
1300
- text: "#131A17", muted: "#606B65",
1301
- accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1302
- }
1303
- theme({ color: palette.text, primaryColor: palette.accent })
1304
1314
  // Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
1305
- export const colors: { [K in keyof typeof palette]: string } = theme(palette)
1315
+ export const colors = theme({
1316
+ bg: "#F4F6F5", surface: "#FFFFFF", border: "#E4E8E6",
1317
+ text: "#131A17", textMuted: "#606B65", accent: "#15A34A",
1318
+ accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1319
+ })
1306
1320
  export const font = { // type scale — spread into styles: .style({ ...font.h2 })
1307
1321
  h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
1308
1322
  small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
@@ -1319,9 +1333,9 @@ const Row = (u: User) => UIRow(
1319
1333
  justifyContent: "center", alignItems: "center" }),
1320
1334
  UIColumn(
1321
1335
  UIText(u.name).style({ ...font.bodyStrong }),
1322
- UIText(u.email).style({ ...font.small, color: colors.muted }),
1336
+ UIText(u.email).style({ ...font.small, color: colors.textMuted }),
1323
1337
  ).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
1324
- ).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
1338
+ ).style({ alignItems: "center", gap: 12, bgColor: colors.surface, borderRadius: 16,
1325
1339
  border: `1px solid ${colors.border}`, p: 14 })
1326
1340
 
1327
1341
  const Centered = (...children: UINodeChild[]) =>
@@ -1330,7 +1344,7 @@ const Centered = (...children: UINodeChild[]) =>
1330
1344
  let body: UIColumn
1331
1345
 
1332
1346
  const loadData = async () => {
1333
- body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
1347
+ body.setContent([Centered(UIText("Loading…").style({ color: colors.textMuted }))])
1334
1348
  const res = await fetch("https://jsonplaceholder.typicode.com/users")
1335
1349
  if (res.status !== 200) {
1336
1350
  body.setContent([Centered(
@@ -937,8 +937,8 @@ Router.init(tabs) // UITabs IS a UIScreen — present it directly
937
937
  // .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
938
938
  // .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
939
939
  // .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
940
- // Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
941
- // restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
940
+ // The bar is colored by the theme's roles (accent, textMuted, surface, border) — no keys of its
941
+ // own; one bar's own colors: tabs.bar.theme({ accent, surface }). Custom layout: UIPager below.
942
942
 
943
943
  // UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
944
944
  // OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
@@ -1154,7 +1154,7 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
1154
1154
  // Padding: p, px, py, pt, pb, pl, pr (≡ padding, paddingHorizontal, paddingVertical, paddingTop, ...)
1155
1155
  // Margin: m, mx, my, mt, mb, ml, mr — margins also accept "auto" (mx: "auto" centers a fixed-width element)
1156
1156
  // Background (containers/screen/button/input/video; UIText/UIImage/UISpacer have bgColor only):
1157
- // bgColor, bgImage (string | FetchResponse | File), bgSize ("cover"|"contain"|"tile"), bgGradient
1157
+ // bgColor, bgImage (string | FetchResponse | File | SvgSource), bgSize ("cover"|"contain"|"fill"|"tile"), bgGradient
1158
1158
  // bgGradient: a CSS linear-gradient() or radial-gradient() string, e.g. "linear-gradient(to top, rgba(0,0,0,0.7), transparent)"
1159
1159
  // or "radial-gradient(circle at 50% 40%, #7B2FF7, #0a0a1a)". Comma-separate several gradients to stack them (first = on top).
1160
1160
  // Layers paint bottom-to-top: bgColor → bgImage → bgGradient (gradient over image = text scrim).
@@ -1181,23 +1181,27 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
1181
1181
  // Env keywords resolving to the device insets (notch, home indicator):
1182
1182
  // "safe-top" | "safe-bottom" | "safe-left" | "safe-right"; "safe-all" — p / m shorthands only
1183
1183
  // Comfort tokens — where content comfortably starts, never collapse to 0: max(safe-edge, knob) on clearance
1184
- // insets (iOS notch/home indicator, gesture nav); past exact-height system BARS (Android status/nav bar) the
1185
- // knob ADDS instead. Knob defaults: 12 top, 4 bottom, 16 horizontal (the page gutter):
1184
+ // insets (iOS notch/home indicator); past exact-height system BARS (Android status/nav bar) the
1185
+ // knob ADDS instead. Knob defaults: 12 top, 8 bottom, 16 horizontal (the page gutter):
1186
1186
  // "comfort-top" | "comfort-bottom" | "comfort-left" | "comfort-right"
1187
1187
  // "comfort-x" — px/mx | "comfort-y" — py/my | "comfort-all" — p / m
1188
1188
  // Accepted on per-side padding & margin, position offsets (top/left/bottom/right), and inside calc()/min()/max():
1189
- .style({ pb: "comfort-bottom" }) // bottom bar: 34 over a home indicator, 52 above Android's button bar, 4 on inset-less devices
1189
+ .style({ pb: "comfort-bottom" }) // bottom bar: 34 over a home indicator, 56 above Android's button bar, 8 on inset-less devices
1190
1190
  .style({ px: "comfort-x" }) // page gutter that also clears the notch in landscape
1191
1191
  .style({ pb: "calc(comfort-bottom + 56px)" }) // scroll content clearing a 56px floating bar
1192
1192
 
1193
1193
  // --- Theme variables ---
1194
1194
  // theme({...}) merges app vars into one table; styles read them with "var(--name)" (numbers are px
1195
1195
  // lengths, null removes a key). Re-calling re-styles the LIVE UI — dark mode is a second call.
1196
- // Returned accessors ARE the var() strings; system vars are statics (theme.primaryColor, …):
1197
- const T = theme({ primaryColor: "#0A84FF", "comfort-left": 20 })
1198
- label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
1199
- // SYSTEM DEFAULTS: theme({ color, fontFamily }) drive the DEFAULT text color/font — all text
1200
- // without explicit values follows (light theme = set once, not color:"black" per label).
1196
+ // ONE call per app. Returned accessors ARE the var() strings:
1197
+ const T = theme({ accent: "#0A84FF", "comfort-left": 20 })
1198
+ label.style({ color: T.accent, pl: "var(--comfort-left)" })
1199
+ // ROLES — keys the SDK reads itself; use them as your palette and add your own keys beside them:
1200
+ // bg (every screen's background) · surface (cards, bars; the tab bar) · border (hairlines)
1201
+ // text (text with no color of its own) · textMuted (secondary; inactive tabs) · accent (active tab)
1202
+ // fontFamily (text with no font of its own). Light theme = set text once, not a color per label.
1203
+ // SCOPE: el.theme({...}) — the same keys for ONE subtree (a card, tabs.bar, a whole screen); its
1204
+ // descendants read them first. A pushed screen / a widget is its own root: give it its own.
1201
1205
  // Bare comfort-* tokens apply the safe-area formula; "var(--comfort-top)" reads the raw knob.
1202
1206
  // "var(--name, fallback)" applies the fallback while the key is unset. Env names (safe-*, vw/vh) are not theme keys.
1203
1207
 
@@ -1229,7 +1233,7 @@ screen.style({ flexDirection: "column", p: 16, $landscape: { flexDirection: "row
1229
1233
  // alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
1230
1234
  // overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
1231
1235
  // boxSizing: "border-box" — width/height include padding & border.
1232
- // Screen bg is black, default text is WHITE at fontSize 14 (retarget once via theme({ color, fontFamily })).
1236
+ // Screen bg is black, default text is WHITE at fontSize 14 (retarget once via theme({ bg, text, fontFamily })).
1233
1237
  // On any light surface — white cards, sheets, inputs — white text renders invisible: use dark text there
1234
1238
  // ("#111"/black), checking every UIText against the surface it actually sits on, not the screen.
1235
1239
 
@@ -1303,14 +1307,25 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
1303
1307
  // ===== ROUTER (multi-page apps) =====
1304
1308
  // Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
1305
1309
  Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
1306
- Router.push(screen) // push onto the stack, screen becomes active
1307
- Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
1310
+ Router.push(screen, opts?: { transition, popTransition }) // push onto the stack, screen becomes active (default "push")
1311
+ Router.pop(to?: number, opts?: { transition })
1312
+ // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
1308
1313
  // stack index (0 = home). Popped screens' native trees are destroyed; the UIScreen
1309
- // object stays reusable (push remounts it).
1310
- Router.replace(screen, opts?: { transition }) // swap the top screen; transition: "slide-from-left" |
1311
- // "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-out" | "zoom-in" | "fade" | "none" (default "fade")
1314
+ // object stays reusable (push remounts it). Plays the way back the screen remembers.
1315
+ Router.replace(screen, opts?: { transition, popTransition }) // swap the top screen (default "none"; theme({ replaceTransition }))
1312
1316
  Router.current // the active UIScreen (getter)
1313
- Router.hide() / Router.restore() // hide/restore the whole router — stack stays alive; fires the top screen's onClose/onOpen
1317
+ Router.hide(opts?) / Router.restore(opts?) // hide/restore the whole router — stack stays alive; fires the top screen's onClose/onOpen
1318
+ // TRANSITIONS — every call above, and open() / close() of any destination, takes { transition }:
1319
+ // a name: "push" | "pop" | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" |
1320
+ // "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
1321
+ // or your own: { enter?: pose, exit?: pose, onTop?: "enter" | "exit" } — the incoming screen comes FROM `enter`,
1322
+ // the outgoing one goes TO `exit`. pose = { transform?, opacity?, dim?, duration? (ms, 300), delay?, easing? };
1323
+ // arrays are keyframes; in a pose a % in translate is a percent of the screen ("translateY(100%)").
1324
+ // e.g. Router.push(page, { transition: { enter: { transform: "translateY(100%)", duration: 400 }, exit: { dim: 0.3 } } })
1325
+ // popTransition = the way BACK the screen remembers (Router.pop(), the back gesture, the system's back play it).
1326
+ // Default: the transition it came with, the other way round (a slide from the bottom leaves downwards;
1327
+ // a zoom / a fade go back with a fade). A replace without one keeps the way back of the screen it replaces.
1328
+ // A screen's own transform / opacity are not seen while a transition moves it — style a child instead.
1314
1329
  Router.addEventListener("change", (screen: UIScreen) => ...) / .removeEventListener("change", cb) // after every navigation
1315
1330
  // Screens BELOW the top stay mounted — element trees and state survive, they're just not rendered.
1316
1331
  // push fires the outgoing screen's onClose + the incoming one's onOpen; pop fires them in reverse and
@@ -1332,7 +1347,7 @@ font("ibm-plex-sans", { weights: [400, 600], italic: true }) // narrow/extend t
1332
1347
  // A project's own file: font("./fonts/Brand.ttf") → real family name read from the file itself
1333
1348
  // (weight/italic too — no options); two weight files of one family return the same name.
1334
1349
  // registerFont(family, url, {weight, style}) survives ONLY for runtime-computed URLs — rare.
1335
- // No style inheritance — set fontFamily per UIText or via theme() (extract a shared Style<UIText>).
1350
+ // The app's font: theme({ fontFamily }). A second family is set per UIText (extract a shared Style<UIText>).
1336
1351
 
1337
1352
  // ===== SVG IMAGES =====
1338
1353
  // SvgSource wraps raw SVG XML for use as an image source: UIImage(SvgSource(`<svg ...>`)).
@@ -1434,17 +1449,16 @@ screen.open()
1434
1449
  </file>
1435
1450
 
1436
1451
  // === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
1437
- // A tokens module every screen imports, light-themed: theme({ color }) sets the default text
1438
- // color ONCE — no color: "#111" on every label.
1452
+ // A tokens module every screen imports, light-themed. ONE theme() call: the roles (bg, surface,
1453
+ // border, text, textMuted, accent) color the screens, the default text and the tab bar — no
1454
+ // color: "#111" on every label — and the app's own keys sit beside them.
1439
1455
  <file name="tokens.ts">
1440
- const palette = {
1441
- bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
1442
- text: "#131A17", muted: "#606B65",
1443
- accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1444
- }
1445
- theme({ color: palette.text, primaryColor: palette.accent })
1446
1456
  // Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
1447
- export const colors: { [K in keyof typeof palette]: string } = theme(palette)
1457
+ export const colors = theme({
1458
+ bg: "#F4F6F5", surface: "#FFFFFF", border: "#E4E8E6",
1459
+ text: "#131A17", textMuted: "#606B65", accent: "#15A34A",
1460
+ accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1461
+ })
1448
1462
  export const font = { // type scale — spread into styles: .style({ ...font.h2 })
1449
1463
  h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
1450
1464
  small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
@@ -1461,9 +1475,9 @@ const Row = (u: User) => UIRow(
1461
1475
  justifyContent: "center", alignItems: "center" }),
1462
1476
  UIColumn(
1463
1477
  UIText(u.name).style({ ...font.bodyStrong }),
1464
- UIText(u.email).style({ ...font.small, color: colors.muted }),
1478
+ UIText(u.email).style({ ...font.small, color: colors.textMuted }),
1465
1479
  ).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
1466
- ).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
1480
+ ).style({ alignItems: "center", gap: 12, bgColor: colors.surface, borderRadius: 16,
1467
1481
  border: `1px solid ${colors.border}`, p: 14 })
1468
1482
 
1469
1483
  const Centered = (...children: UINodeChild[]) =>
@@ -1472,7 +1486,7 @@ const Centered = (...children: UINodeChild[]) =>
1472
1486
  let body: UIColumn
1473
1487
 
1474
1488
  const loadData = async () => {
1475
- body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
1489
+ body.setContent([Centered(UIText("Loading…").style({ color: colors.textMuted }))])
1476
1490
  const res = await fetch("https://jsonplaceholder.typicode.com/users")
1477
1491
  if (res.status !== 200) {
1478
1492
  body.setContent([Centered(
@@ -813,8 +813,8 @@ Router.init(tabs) // UITabs IS a UIScreen — present it directly
813
813
  // .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
814
814
  // .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
815
815
  // .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
816
- // Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
817
- // restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
816
+ // The bar is colored by the theme's roles (accent, textMuted, surface, border) — no keys of its
817
+ // own; one bar's own colors: tabs.bar.theme({ accent, surface }). Custom layout: UIPager below.
818
818
 
819
819
  // UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
820
820
  // OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
@@ -1030,7 +1030,7 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
1030
1030
  // Padding: p, px, py, pt, pb, pl, pr (≡ padding, paddingHorizontal, paddingVertical, paddingTop, ...)
1031
1031
  // Margin: m, mx, my, mt, mb, ml, mr — margins also accept "auto" (mx: "auto" centers a fixed-width element)
1032
1032
  // Background (containers/screen/button/input/video; UIText/UIImage/UISpacer have bgColor only):
1033
- // bgColor, bgImage (string | FetchResponse | File), bgSize ("cover"|"contain"|"tile"), bgGradient
1033
+ // bgColor, bgImage (string | FetchResponse | File | SvgSource), bgSize ("cover"|"contain"|"fill"|"tile"), bgGradient
1034
1034
  // bgGradient: a CSS linear-gradient() or radial-gradient() string, e.g. "linear-gradient(to top, rgba(0,0,0,0.7), transparent)"
1035
1035
  // or "radial-gradient(circle at 50% 40%, #7B2FF7, #0a0a1a)". Comma-separate several gradients to stack them (first = on top).
1036
1036
  // Layers paint bottom-to-top: bgColor → bgImage → bgGradient (gradient over image = text scrim).
@@ -1057,23 +1057,27 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
1057
1057
  // Env keywords resolving to the device insets (notch, home indicator):
1058
1058
  // "safe-top" | "safe-bottom" | "safe-left" | "safe-right"; "safe-all" — p / m shorthands only
1059
1059
  // Comfort tokens — where content comfortably starts, never collapse to 0: max(safe-edge, knob) on clearance
1060
- // insets (iOS notch/home indicator, gesture nav); past exact-height system BARS (Android status/nav bar) the
1061
- // knob ADDS instead. Knob defaults: 12 top, 4 bottom, 16 horizontal (the page gutter):
1060
+ // insets (iOS notch/home indicator); past exact-height system BARS (Android status/nav bar) the
1061
+ // knob ADDS instead. Knob defaults: 12 top, 8 bottom, 16 horizontal (the page gutter):
1062
1062
  // "comfort-top" | "comfort-bottom" | "comfort-left" | "comfort-right"
1063
1063
  // "comfort-x" — px/mx | "comfort-y" — py/my | "comfort-all" — p / m
1064
1064
  // Accepted on per-side padding & margin, position offsets (top/left/bottom/right), and inside calc()/min()/max():
1065
- .style({ pb: "comfort-bottom" }) // bottom bar: 34 over a home indicator, 52 above Android's button bar, 4 on inset-less devices
1065
+ .style({ pb: "comfort-bottom" }) // bottom bar: 34 over a home indicator, 56 above Android's button bar, 8 on inset-less devices
1066
1066
  .style({ px: "comfort-x" }) // page gutter that also clears the notch in landscape
1067
1067
  .style({ pb: "calc(comfort-bottom + 56px)" }) // scroll content clearing a 56px floating bar
1068
1068
 
1069
1069
  // --- Theme variables ---
1070
1070
  // theme({...}) merges app vars into one table; styles read them with "var(--name)" (numbers are px
1071
1071
  // lengths, null removes a key). Re-calling re-styles the LIVE UI — dark mode is a second call.
1072
- // Returned accessors ARE the var() strings; system vars are statics (theme.primaryColor, …):
1073
- const T = theme({ primaryColor: "#0A84FF", "comfort-left": 20 })
1074
- label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
1075
- // SYSTEM DEFAULTS: theme({ color, fontFamily }) drive the DEFAULT text color/font — all text
1076
- // without explicit values follows (light theme = set once, not color:"black" per label).
1072
+ // ONE call per app. Returned accessors ARE the var() strings:
1073
+ const T = theme({ accent: "#0A84FF", "comfort-left": 20 })
1074
+ label.style({ color: T.accent, pl: "var(--comfort-left)" })
1075
+ // ROLES — keys the SDK reads itself; use them as your palette and add your own keys beside them:
1076
+ // bg (every screen's background) · surface (cards, bars; the tab bar) · border (hairlines)
1077
+ // text (text with no color of its own) · textMuted (secondary; inactive tabs) · accent (active tab)
1078
+ // fontFamily (text with no font of its own). Light theme = set text once, not a color per label.
1079
+ // SCOPE: el.theme({...}) — the same keys for ONE subtree (a card, tabs.bar, a whole screen); its
1080
+ // descendants read them first. A pushed screen / a widget is its own root: give it its own.
1077
1081
  // Bare comfort-* tokens apply the safe-area formula; "var(--comfort-top)" reads the raw knob.
1078
1082
  // "var(--name, fallback)" applies the fallback while the key is unset. Env names (safe-*, vw/vh) are not theme keys.
1079
1083
 
@@ -1105,7 +1109,7 @@ screen.style({ flexDirection: "column", p: 16, $landscape: { flexDirection: "row
1105
1109
  // alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
1106
1110
  // overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
1107
1111
  // boxSizing: "border-box" — width/height include padding & border.
1108
- // Screen bg is black, default text is WHITE at fontSize 14 (retarget once via theme({ color, fontFamily })).
1112
+ // Screen bg is black, default text is WHITE at fontSize 14 (retarget once via theme({ bg, text, fontFamily })).
1109
1113
  // On any light surface — white cards, sheets, inputs — white text renders invisible: use dark text there
1110
1114
  // ("#111"/black), checking every UIText against the surface it actually sits on, not the screen.
1111
1115
 
@@ -1179,14 +1183,25 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
1179
1183
  // ===== ROUTER (multi-page apps) =====
1180
1184
  // Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
1181
1185
  Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
1182
- Router.push(screen) // push onto the stack, screen becomes active
1183
- Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
1186
+ Router.push(screen, opts?: { transition, popTransition }) // push onto the stack, screen becomes active (default "push")
1187
+ Router.pop(to?: number, opts?: { transition })
1188
+ // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
1184
1189
  // stack index (0 = home). Popped screens' native trees are destroyed; the UIScreen
1185
- // object stays reusable (push remounts it).
1186
- Router.replace(screen, opts?: { transition }) // swap the top screen; transition: "slide-from-left" |
1187
- // "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-out" | "zoom-in" | "fade" | "none" (default "fade")
1190
+ // object stays reusable (push remounts it). Plays the way back the screen remembers.
1191
+ Router.replace(screen, opts?: { transition, popTransition }) // swap the top screen (default "none"; theme({ replaceTransition }))
1188
1192
  Router.current // the active UIScreen (getter)
1189
- Router.hide() / Router.restore() // hide/restore the whole router — stack stays alive; fires the top screen's onClose/onOpen
1193
+ Router.hide(opts?) / Router.restore(opts?) // hide/restore the whole router — stack stays alive; fires the top screen's onClose/onOpen
1194
+ // TRANSITIONS — every call above, and open() / close() of any destination, takes { transition }:
1195
+ // a name: "push" | "pop" | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" |
1196
+ // "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
1197
+ // or your own: { enter?: pose, exit?: pose, onTop?: "enter" | "exit" } — the incoming screen comes FROM `enter`,
1198
+ // the outgoing one goes TO `exit`. pose = { transform?, opacity?, dim?, duration? (ms, 300), delay?, easing? };
1199
+ // arrays are keyframes; in a pose a % in translate is a percent of the screen ("translateY(100%)").
1200
+ // e.g. Router.push(page, { transition: { enter: { transform: "translateY(100%)", duration: 400 }, exit: { dim: 0.3 } } })
1201
+ // popTransition = the way BACK the screen remembers (Router.pop(), the back gesture, the system's back play it).
1202
+ // Default: the transition it came with, the other way round (a slide from the bottom leaves downwards;
1203
+ // a zoom / a fade go back with a fade). A replace without one keeps the way back of the screen it replaces.
1204
+ // A screen's own transform / opacity are not seen while a transition moves it — style a child instead.
1190
1205
  Router.addEventListener("change", (screen: UIScreen) => ...) / .removeEventListener("change", cb) // after every navigation
1191
1206
  // Screens BELOW the top stay mounted — element trees and state survive, they're just not rendered.
1192
1207
  // push fires the outgoing screen's onClose + the incoming one's onOpen; pop fires them in reverse and
@@ -1208,7 +1223,7 @@ font("ibm-plex-sans", { weights: [400, 600], italic: true }) // narrow/extend t
1208
1223
  // A project's own file: font("./fonts/Brand.ttf") → real family name read from the file itself
1209
1224
  // (weight/italic too — no options); two weight files of one family return the same name.
1210
1225
  // registerFont(family, url, {weight, style}) survives ONLY for runtime-computed URLs — rare.
1211
- // No style inheritance — set fontFamily per UIText or via theme() (extract a shared Style<UIText>).
1226
+ // The app's font: theme({ fontFamily }). A second family is set per UIText (extract a shared Style<UIText>).
1212
1227
 
1213
1228
  // ===== SVG IMAGES =====
1214
1229
  // SvgSource wraps raw SVG XML for use as an image source: UIImage(SvgSource(`<svg ...>`)).
@@ -1310,17 +1325,16 @@ screen.open()
1310
1325
  </file>
1311
1326
 
1312
1327
  // === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
1313
- // A tokens module every screen imports, light-themed: theme({ color }) sets the default text
1314
- // color ONCE — no color: "#111" on every label.
1328
+ // A tokens module every screen imports, light-themed. ONE theme() call: the roles (bg, surface,
1329
+ // border, text, textMuted, accent) color the screens, the default text and the tab bar — no
1330
+ // color: "#111" on every label — and the app's own keys sit beside them.
1315
1331
  <file name="tokens.ts">
1316
- const palette = {
1317
- bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
1318
- text: "#131A17", muted: "#606B65",
1319
- accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1320
- }
1321
- theme({ color: palette.text, primaryColor: palette.accent })
1322
1332
  // Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
1323
- export const colors: { [K in keyof typeof palette]: string } = theme(palette)
1333
+ export const colors = theme({
1334
+ bg: "#F4F6F5", surface: "#FFFFFF", border: "#E4E8E6",
1335
+ text: "#131A17", textMuted: "#606B65", accent: "#15A34A",
1336
+ accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1337
+ })
1324
1338
  export const font = { // type scale — spread into styles: .style({ ...font.h2 })
1325
1339
  h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
1326
1340
  small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
@@ -1337,9 +1351,9 @@ const Row = (u: User) => UIRow(
1337
1351
  justifyContent: "center", alignItems: "center" }),
1338
1352
  UIColumn(
1339
1353
  UIText(u.name).style({ ...font.bodyStrong }),
1340
- UIText(u.email).style({ ...font.small, color: colors.muted }),
1354
+ UIText(u.email).style({ ...font.small, color: colors.textMuted }),
1341
1355
  ).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
1342
- ).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
1356
+ ).style({ alignItems: "center", gap: 12, bgColor: colors.surface, borderRadius: 16,
1343
1357
  border: `1px solid ${colors.border}`, p: 14 })
1344
1358
 
1345
1359
  const Centered = (...children: UINodeChild[]) =>
@@ -1348,7 +1362,7 @@ const Centered = (...children: UINodeChild[]) =>
1348
1362
  let body: UIColumn
1349
1363
 
1350
1364
  const loadData = async () => {
1351
- body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
1365
+ body.setContent([Centered(UIText("Loading…").style({ color: colors.textMuted }))])
1352
1366
  const res = await fetch("https://jsonplaceholder.typicode.com/users")
1353
1367
  if (res.status !== 200) {
1354
1368
  body.setContent([Centered(