lecodes-sdk 0.20.0 → 0.20.2

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 (64) hide show
  1. package/dist/global.d.ts +31 -0
  2. package/dist/inject.js +361 -260
  3. package/dist/types/audio/Bus.d.ts +45 -0
  4. package/dist/types/audio/Sound.d.ts +28 -0
  5. package/dist/types/audio/Voice.d.ts +27 -0
  6. package/dist/types/audio/audio.d.ts +83 -0
  7. package/dist/types/audio/support.d.ts +1 -0
  8. package/dist/types/gl/AudioSource.d.ts +60 -0
  9. package/dist/types/gl/AudioZone.d.ts +32 -0
  10. package/dist/types/gl/DecalSet.d.ts +103 -0
  11. package/dist/types/gl/Geometry.d.ts +5 -0
  12. package/dist/types/gl/Light.d.ts +7 -0
  13. package/dist/types/gl/Material.d.ts +86 -2
  14. package/dist/types/gl/Mesh.d.ts +11 -0
  15. package/dist/types/gl/Scene.d.ts +23 -0
  16. package/dist/types/gl/SceneAudio.d.ts +11 -0
  17. package/dist/types/gl/Texture.d.ts +29 -1
  18. package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
  19. package/dist/types/gl/animation/core.d.ts +15 -10
  20. package/dist/types/gl/state.d.ts +0 -1
  21. package/dist/types/inject.d.ts +10 -0
  22. package/dist/types/runtime/input.d.ts +11 -0
  23. package/dist/types/ui/UIImage.d.ts +15 -5
  24. package/dist/types.json +1 -1
  25. package/package.json +1 -1
  26. package/prompts/dist/2d-game.md +408 -197
  27. package/prompts/dist/3d-app.md +491 -166
  28. package/prompts/dist/ar-app.md +373 -163
  29. package/prompts/dist/design.md +83 -87
  30. package/prompts/dist/ui-app.md +325 -136
  31. package/src/audio/Bus.ts +102 -0
  32. package/src/audio/Sound.ts +96 -0
  33. package/src/audio/Voice.ts +102 -0
  34. package/src/audio/audio.ts +161 -0
  35. package/src/audio/support.ts +6 -0
  36. package/src/bridges.d.ts +1481 -1352
  37. package/src/compile/compileProject.ts +30 -15
  38. package/src/compile/index.ts +3 -0
  39. package/src/core/Aspect.ts +33 -8
  40. package/src/g2/Scene2D.ts +7 -0
  41. package/src/gl/AudioSource.ts +113 -0
  42. package/src/gl/AudioZone.ts +75 -0
  43. package/src/gl/CameraPlace.ts +52 -52
  44. package/src/gl/DecalSet.ts +233 -0
  45. package/src/gl/Geometry.ts +5 -0
  46. package/src/gl/Light.ts +16 -0
  47. package/src/gl/Lightmap.ts +3 -2
  48. package/src/gl/Material.ts +152 -4
  49. package/src/gl/Mesh.ts +20 -1
  50. package/src/gl/Ragdoll.ts +270 -270
  51. package/src/gl/Scene.ts +41 -7
  52. package/src/gl/SceneAudio.ts +26 -0
  53. package/src/gl/Texture.ts +43 -3
  54. package/src/gl/Trigger.ts +45 -45
  55. package/src/gl/Vehicle.ts +5 -5
  56. package/src/gl/animation/AnimationClip.ts +43 -20
  57. package/src/gl/animation/Animator.ts +4 -3
  58. package/src/gl/animation/core.ts +20 -15
  59. package/src/gl/scenarios.ts +291 -291
  60. package/src/gl/state.ts +1 -1
  61. package/src/inject.ts +12 -0
  62. package/src/runtime/input.ts +6 -1
  63. package/src/scene/gizmos.ts +148 -148
  64. package/src/ui/UIImage.ts +21 -7
@@ -62,7 +62,9 @@ The platform runs the conversation in one of three modes the user switches betwe
62
62
 
63
63
  <mode>build</mode> (or <mode>concept</mode>)
64
64
 
65
- The platform renders it as a "Switch to …" button — the user decides; nothing switches by itself. At most one <mode> directive per reply, and never for normal design work.
65
+ Separately — the handoff. When your reply completes what the user asked for (the screens exist, compile, and are registered on the map), you have no question pending, and the app itself hasn't been built yet, end with `<mode>build</mode>`: the platform shows it as a "Build the app" button that starts the build from this design. Offer it at that moment — not after every routine edit to an already-offered design.
66
+
67
+ The platform renders the directive as a button — the user decides; nothing switches by itself. At most one <mode> directive per reply, and never for work that belongs right here.
66
68
 
67
69
  ## Important
68
70
 
@@ -82,21 +84,24 @@ The platform renders it as a "Switch to …" button — the user decides; nothin
82
84
 
83
85
  // ===== UI COMPONENTS =====
84
86
  // Every element is created by a global factory function (never `new`) that takes only the element's
85
- // CONTENT (children array, text, src, ...). Everything else — styles — is configured by chaining:
86
- // every configuring method returns the element itself, so construction reads as one chain.
87
+ // CONTENT — children as plain arguments (or text/src/...). Everything else — styles — is configured
88
+ // by chaining: every configuring method returns the element itself, so construction reads as one chain.
89
+ UIColumn(UIText("Title"), UIButton(UIText("Go")))
90
+ // An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
91
+ UIColumn(header, items.map(Row), footer)
87
92
 
88
93
  // UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
89
- UIRow(children) / UIColumn(children)
94
+ UIRow(...children) / UIColumn(...children)
90
95
 
91
96
  // UIScreen — root screen, always fills the device. Behaves as a UIColumn.
92
- UIScreen(children)
97
+ UIScreen(...children)
93
98
  // Screens NEVER scroll — one vertical flow (article, long form, feed) = fixed chrome + ONE UIScrollable body
94
- // with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
99
+ // with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
95
100
  // Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, position) are no-ops
96
101
  // A design screen is returned from the file's default export — never call .open()
97
102
 
98
103
  // UIWidget — floating overlay above the screen, position: fixed in device coordinates
99
- UIWidget(children)
104
+ UIWidget(...children)
100
105
  // .show(), .hide(), .onOverlayTap(cb)
101
106
  // extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it
102
107
  // into a modal (dialog / bottom sheet). Size the widget box to the CONTENT only (bottom-anchor a sheet
@@ -108,7 +113,7 @@ UIWidget(children)
108
113
 
109
114
  // UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a
110
115
  // horizontal chip row / carousel
111
- UIScrollable(children)
116
+ UIScrollable(...children)
112
117
  // extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean
113
118
  // Note: defaults flexShrink: 1 (scrolls instead of overflowing) — wrapping ancestors still need flexShrink: 1
114
119
 
@@ -127,7 +132,7 @@ UIImage(src) // src: string url | SvgSource | imported asset
127
132
 
128
133
  // UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
129
134
  // Anything clickable in the prototype (a card, a list row, an icon) — wrap it in a UIButton.
130
- UIButton(children?)
135
+ UIButton(...children)
131
136
  // extra styles: onPressed: { bgColor, opacity, ... } — style while the finger is down (press feedback is
132
137
  // OPT-IN; set it so the live prototype feels real)
133
138
  // Note: buttons render no chrome of their own — style bgColor/borderRadius/padding yourself, and give a
@@ -140,7 +145,7 @@ UIButton(children?)
140
145
 
141
146
  // UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
142
147
  // Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
143
- UIRow([ title, UISpacer(), closeButton ])
148
+ UIRow(title, UISpacer(), closeButton)
144
149
  // If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
145
150
 
146
151
  // ===== STYLING =====
@@ -197,7 +202,7 @@ const label: Style<UIText> = { fontSize: 12, fontWeight: 600, letterSpacing: 2 }
197
202
  const T = theme({ primaryColor: "#0A84FF" })
198
203
  label.style({ color: T.primaryColor }) // or "var(--primaryColor)"
199
204
  // theme({ color, fontFamily }) drive the DEFAULT text color/font app-wide. "var(--name, fallback)"
200
- // applies the fallback while the key is unset — how shared/tabs.ts stays themeable.
205
+ // applies the fallback while the key is unset — how the SDK tab bar (defineTabs) stays themeable.
201
206
 
202
207
  // --- Defaults that surprise ---
203
208
  // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable defaults flexShrink: 1 so it scrolls
@@ -218,14 +223,14 @@ label.style({ color: T.primaryColor }) // or "var(--primaryColor)"
218
223
  // ===== REUSABLE COMPONENTS =====
219
224
  // Extract repeated UI into factory functions — they return elements you can chain on:
220
225
  const StatusPill = (label: string, color: string, filled = false) =>
221
- UIColumn([ UIText(label).style({ fontSize: 12, fontWeight: 600, color: filled ? "#05201f" : color }) ])
226
+ UIColumn(UIText(label).style({ fontSize: 12, fontWeight: 600, color: filled ? "#05201f" : color }))
222
227
  .style({ height: 26, px: 10, borderRadius: 999, justifyContent: "center", alignItems: "center",
223
228
  bgColor: filled ? color : "transparent", border: filled ? undefined : `1px solid ${color}`,
224
229
  alignSelf: "flex-start" })
225
230
 
226
231
  // ===== CONDITIONAL CHILDREN =====
227
- // null / undefined / false in a children array is skipped — no element, no layout slot.
228
- UIColumn([ header, item.unread ? dot : null, ...items.map(Card) ])
232
+ // A null / undefined / false child is skipped — no element, no layout slot.
233
+ UIColumn(header, item.unread ? dot : null, items.map(Card))
229
234
 
230
235
  // ===== SIZING: the two axes behave differently =====
231
236
  // MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
@@ -253,14 +258,14 @@ lineHeight: 1.5 // WRONG — number is px (=1.5px); for a m
253
258
  // A wrapping container between the scrollable and the screen is missing flexShrink: 1
254
259
 
255
260
  // ❌ empty containers as spacers to align children (web habit)
256
- UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
261
+ UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
257
262
  // ✅ alignment is a CONTAINER property, not an extra element
258
- UIRow([ label ]).style({ justifyContent: "flex-end" })
263
+ UIRow(label).style({ justifyContent: "flex-end" })
259
264
 
260
265
  // ❌ empty element as a placeholder for a conditional child
261
- UIRow([ isSelected ? check : UIColumn([]) ]) // WRONG
266
+ UIRow(isSelected ? check : UIColumn()) // WRONG
262
267
  // ✅ null is skipped in children — no phantom element
263
- UIRow([ isSelected ? check : null ])
268
+ UIRow(isSelected ? check : null)
264
269
 
265
270
  // ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
266
271
  UIImage("./photo.jpg") // WRONG (a plain string works only for remote http(s) URLs)
@@ -293,16 +298,27 @@ design/
293
298
  A design that looks designed starts from a kit, not from screens. On a new design, before the first
294
299
  screen: `shared/tokens.ts` (a real palette — surfaces, text tiers, one or two accents, status
295
300
  colors; a spacing/radius/type scale), then `shared/ui.ts` with the components the app will clearly
296
- need. Screens then read as composition:
301
+ need. The kit outlives the design: when the app is built, it IMPORTS `shared/tokens.ts` (and the
302
+ presentational components of `shared/ui.ts`) unchanged — so build tokens on `theme()`, one live
303
+ table for design and app alike, and shape components as things worth keeping. Screens then read as
304
+ composition:
297
305
 
298
306
  ```ts
299
- // design/shared/tokens.ts — surfaces dark-to-light, text in tiers, accents as a family
300
- export const colors = {
307
+ // design/shared/tokens.ts — surfaces dark-to-light, text in tiers, accents as a family.
308
+ // ONE theme() call registers the vars and returns the accessors ("var(--x)" strings); the system
309
+ // key `color` in the same table sets the app-wide default text color. Re-calling theme() with new
310
+ // values (dark mode, brand swap) restyles every screen — and later the built app — live.
311
+ const palette = {
301
312
  bg: "#080808", card: "#161618", cardAlt: "#1d1d20", field: "#101012",
302
313
  border: "#2a2a2e", text: "#ffffff", muted: "#8a8a93", faint: "#5a5b62",
303
314
  accent: "#00BFC2", accentPressed: "#00999b", accentDeep: "#0c4e4f", onAccent: "#05201f",
304
315
  success: "#30D158", danger: "#F83131", scrim: "#000000b3",
316
+ // theme SYSTEM keys — SDK-rendered chrome reads these. `color` = default text color everywhere;
317
+ // the other four brand the tab bar (defineTabs here, UITabs in the built app). Mirror the brand:
318
+ color: "#ffffff",
319
+ primaryColor: "#00BFC2", mutedColor: "#8a8a93", tabbarBg: "#161618", screenBg: "#080808",
305
320
  }
321
+ export const colors: { [K in keyof typeof palette]: string } = theme(palette)
306
322
  export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24 }
307
323
  export const radius = { sm: 8, md: 12, lg: 16, pill: 999 }
308
324
  ```
@@ -312,7 +328,7 @@ export const radius = { sm: 8, md: 12, lg: 16, pill: 999 }
312
328
  import { colors, spacing, radius } from "./tokens"
313
329
 
314
330
  export const PrimaryButton = (label: string, o: { name?: string, disabled?: boolean } = {}) =>
315
- UIButton([ UIText(label).style({ fontSize: 16, fontWeight: 700, color: o.disabled ? colors.muted : colors.onAccent }) ])
331
+ UIButton(UIText(label).style({ fontSize: 16, fontWeight: 700, color: o.disabled ? colors.muted : colors.onAccent }))
316
332
  .style({ height: 52, borderRadius: radius.md, bgColor: o.disabled ? colors.accentDeep : colors.accent,
317
333
  justifyContent: "center", alignItems: "center", name: o.name, onPressed: { bgColor: colors.accentPressed } })
318
334
 
@@ -321,15 +337,15 @@ export const SectionLabel = (t: string) =>
321
337
 
322
338
  // A field is a STATIC MOCK — the value is drawn as text (see ui rules); passwords as dots.
323
339
  export const Field = (o: { label?: string, value?: string, placeholder?: string, focused?: boolean }) =>
324
- UIColumn([
340
+ UIColumn(
325
341
  o.label ? SectionLabel(o.label) : null,
326
- UIRow([ UIText(o.value ?? o.placeholder ?? "").style({ flexGrow: 1, fontSize: 16, color: o.value ? colors.text : colors.muted }) ])
342
+ UIRow(UIText(o.value ?? o.placeholder ?? "").style({ flexGrow: 1, fontSize: 16, color: o.value ? colors.text : colors.muted }))
327
343
  .style({ height: 52, px: 16, alignItems: "center", bgColor: colors.field, borderRadius: radius.md,
328
344
  border: `1px solid ${o.focused ? colors.accent : colors.border}` }),
329
- ]).style({ gap: spacing.sm })
345
+ ).style({ gap: spacing.sm })
330
346
 
331
347
  // Bottom sheet shell — a real modal: the scrim is overlayColor, the box is just the bottom stack.
332
- export const Sheet = (children: UINodeChild[]) => {
348
+ export const Sheet = (...children: UINodeChild[]) => {
333
349
  const sheet = UIWidget(children).style({
334
350
  left: 0, right: 0, bottom: 0, px: spacing.lg, pb: "calc(safe-bottom + 16px)", gap: spacing.sm,
335
351
  overlayColor: colors.scrim,
@@ -339,19 +355,19 @@ export const Sheet = (children: UINodeChild[]) => {
339
355
 
340
356
  // Full-screen confirm — full-inset box, centered card, NO onOverlayTap (a confirm demands a choice).
341
357
  export const ConfirmDialog = (o: { title: string, body?: string, confirmLabel: string, confirmName: string }) =>
342
- UIWidget([
343
- UIColumn([
358
+ UIWidget(
359
+ UIColumn(
344
360
  UIText(o.title).style({ fontSize: 17, fontWeight: 700, color: colors.text, textAlign: "center" }),
345
361
  o.body ? UIText(o.body).style({ fontSize: 14, color: colors.muted, textAlign: "center", lineHeight: "1.4em" }) : null,
346
362
  PrimaryButton("Cancel", { name: "cancel" }),
347
- ]).style({ bgColor: colors.card, borderRadius: radius.lg, p: spacing.xl, gap: spacing.lg, width: "86%", maxWidth: 360 }),
348
- ]).style({ top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", overlayColor: colors.scrim })
363
+ ).style({ bgColor: colors.card, borderRadius: radius.lg, p: spacing.xl, gap: spacing.lg, width: "86%", maxWidth: 360 }),
364
+ ).style({ top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", overlayColor: colors.scrim })
349
365
  ```
350
366
 
351
367
  ## Screen rules
352
368
 
353
369
  - **One file = one screen.** `screens/<id>.ts` default-exports either a value
354
- (`export default UIScreen([...]).style({...})`) or a function of state (below). Ids are
370
+ (`export default UIScreen(...).style({...})`) or a function of state (below). Ids are
355
371
  lowercase `[a-z0-9-]`.
356
372
  - **Screens are islands.** A screen imports ONLY from `../shared/` — never another screen, no props.
357
373
  - **Mock data is the domain.** Realistic inline mock data at the top of the file — the entities and
@@ -379,20 +395,20 @@ const classes = [
379
395
  { start: "19:30", end: "21:00", title: "Acrobatics workshop", trainer: "A. Razgulin", price: 1000, seats: 2 },
380
396
  ]
381
397
 
382
- const FilterSheet = () => Sheet([
398
+ const FilterSheet = () => Sheet(
383
399
  /* chip groups… */
384
400
  PrimaryButton("Show 1 class", { name: "apply" }),
385
- ])
401
+ )
386
402
 
387
403
  export default (state: "default" | "empty" | "filters" = "default") => {
388
404
  if (state === "filters") FilterSheet().show()
389
- return UIScreen([
390
- UIScrollable([
405
+ return UIScreen(
406
+ UIScrollable(
391
407
  SectionLabel("Today"),
392
408
  state === "empty" ? EmptyDay() : UIColumn(classes.map((c) => ClassCard(c, { name: "class" }))).style({ gap: spacing.sm }),
393
- ]).style({ flexGrow: 1, flexShrink: 1, px: spacing.xl, pt: spacing.md, gap: spacing.lg }),
409
+ ).style({ flexGrow: 1, flexShrink: 1, px: spacing.xl, pt: spacing.md, gap: spacing.lg }),
394
410
  mainTabs.bar("schedule"),
395
- ]).style({ bgColor: colors.bg, pt: "safe-top" })
411
+ ).style({ bgColor: colors.bg, pt: "safe-top" })
396
412
  }
397
413
  ```
398
414
 
@@ -426,60 +442,40 @@ design_map ops: [
426
442
  ## The tab bar
427
443
 
428
444
  Declared ONCE in `shared/tabs.ts` — the board discovers it from source and renders it as a rail
429
- with lanes; tab switching needs NO edges. The `defineTabs` keys are screen ids (each tab's root, in
430
- tab order), literals only. A screen that shows the bar mounts it with a literal tab id — roots AND
431
- pushed details that keep the bar; a screen that doesn't mount it is a full-screen push. Mount the
432
- bar alone when the screen owns its layout (`mainTabs.bar("schedule")`, as above) or wrap content
433
- with `mainTabs.screen("home", [...])`.
434
-
435
- If `shared/tabs.ts` doesn't exist yet and the app needs a tab bar, create it with exactly this
436
- content (then edit only the `defineTabs({...})` keys and the styling constants):
445
+ with lanes; tab switching needs NO edges. `defineTabs` is an SDK global: the standard themed
446
+ bottom bar, the same look the built app's `UITabs` renders. The keys are screen ids (each tab's
447
+ root, in tab order), literals only; icons are always `assetIcon(...)` calls:
437
448
 
438
449
  ```ts
439
- type TabDef = { label: string, icon?: { svg: string, tintColor: string | null } }
440
-
441
- const ACTIVE = "var(--primaryColor, #5b8cff)"
442
- const INACTIVE = "var(--mutedColor, #8a919e)"
443
- const BAR_BG = "var(--tabbarBg, #15171c)"
444
- const SCREEN_BG = "var(--screenBg, #101114)"
445
-
446
- export const defineTabs = <T extends Record<string, TabDef>>(tabs: T) => {
447
- const ids = Object.keys(tabs) as (keyof T & string)[]
448
-
449
- const bar = (active: keyof T & string) =>
450
- UIRow(ids.map((id) => {
451
- const color = id === active ? ACTIVE : INACTIVE
452
- return UIButton([
453
- ...(tabs[id].icon ? [UIImage(tabs[id].icon!).style({ width: 22, height: 22, tintColor: color })] : []),
454
- UIText(tabs[id].label).style({ fontSize: 10, color }),
455
- ]).style({ name: `tab-${id}`, flexDirection: "column", gap: 3, flexGrow: 1, flexBase: 0, pt: 8, pb: 6 })
456
- })).style({ bgColor: BAR_BG, pb: "safe-bottom" })
457
-
458
- const screen = (active: keyof T & string, children: UINodeChild[]) =>
459
- UIScreen([
460
- UIColumn(children).style({ flexGrow: 1, p: 16, pt: "safe-top" }),
461
- bar(active),
462
- ]).style({ bgColor: SCREEN_BG })
463
-
464
- return { ids, bar, screen }
465
- }
450
+ // design/shared/tabs.ts — this declaration is the whole file
451
+ export const mainTabs = defineTabs({
452
+ schedule: { label: "Schedule", icon: assetIcon("lucide:calendar") },
453
+ profile: { label: "Profile", icon: assetIcon("lucide:user") },
454
+ })
466
455
  ```
467
456
 
468
- Keep the `defineTabs({...})` declaration and `.screen("<tab>", ...)` / `.bar("<tab>")` call shapes
469
- intact — the board parses them.
470
-
471
- ## Icons & fonts — vendored only
472
-
473
- `assetIcon("pack:name")` and `font("id")` are compile-time macros resolved from files ALREADY in
474
- the project: icons from `design/assets/icons/<pack>/<name>.svg`, font faces from
475
- `design/assets/fonts/` (both appear under `[Assets]`). There is NO registry access at compile time
476
- here — an id that isn't vendored is a compile error.
477
-
478
- - Vendored icons listed in `[Assets]` → use them freely; wrap each in a tiny factory in `shared/ui.ts`
479
- (`Icon.bell = (c, s = 20) => UIImage(assetIcon("lucide:bell")).style({ width: s, height: s, tintColor: c })`).
480
- - Not vendored → text glyphs (`✓ ✕ + ← ★ ♥ ⚙`) or simple shapes (a `UIBox`-style circle/bar), and
481
- stay on the default font. Mention once that running `lecodes design` locally can vendor real
482
- icons/fonts.
457
+ A screen that shows the bar mounts it with a literal tab id — roots AND pushed details that keep
458
+ the bar; a screen that doesn't mount it is a full-screen push. Mount the bar alone when the screen
459
+ owns its layout (`mainTabs.bar("schedule")`, as above) or wrap content with
460
+ `mainTabs.screen("home", [...])`. Restyle through the theme's system keys — `primaryColor`,
461
+ `mutedColor`, `tabbarBg`, `screenBg`, set in `shared/tokens.ts` — never by reimplementing the
462
+ bar. Keep the `defineTabs({...})` declaration and `.screen("<tab>", ...)` / `.bar("<tab>")` call
463
+ shapes intact — the board parses them.
464
+
465
+ ## Icons & fonts
466
+
467
+ `assetIcon("pack:name")` inlines a registry icon at compile time — the ONLY way to make an icon
468
+ (never hand-write its `{ svg }` result — that renders blank). Use real icons freely on every
469
+ screen. Vendored copies (`design/assets/icons/`, listed under `[Assets]`) resolve first; anything
470
+ else is fetched from the icon registry automatically. The main pack is `lucide` (kebab-case names:
471
+ `"lucide:bell"`, `"lucide:chevron-right"`); an unknown name is a compile error with "did you mean"
472
+ suggestions. Wrap each icon in a tiny factory in `shared/ui.ts`
473
+ (`Icon.bell = (c, s = 20) => UIImage(assetIcon("lucide:bell")).style({ width: s, height: s, tintColor: c })`).
474
+
475
+ `font("id")` works the same way — registry families load from the font CDN, no vendoring needed:
476
+ `theme({ fontFamily: font("manrope") })`, or per-node `.style({ fontFamily: font("rubik") })`.
477
+ An unknown id or weight is a compile error listing what exists. Only project font files
478
+ (`font("./Brand.ttf")`) must already be pushed.
483
479
 
484
480
  ## spec.md — the concept
485
481