lecodes-sdk 1.2.0 → 2.0.0

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 (260) hide show
  1. package/README.md +5 -2
  2. package/dist/editor.d.ts +12 -0
  3. package/dist/global.d.ts +4 -8
  4. package/dist/host.d.ts +2 -3
  5. package/dist/types/animate/tween/Animation.d.ts +0 -3
  6. package/dist/types/animate/tween/animateValue.d.ts +4 -2
  7. package/dist/types/animate/tween/easing.d.ts +8 -0
  8. package/dist/types/animate/tween/spec.d.ts +15 -7
  9. package/dist/types/audio/audio.d.ts +2 -1
  10. package/dist/types/canvas/Canvas.d.ts +40 -108
  11. package/dist/types/canvas/gen/cssColor.d.ts +17 -0
  12. package/dist/types/canvas/gen/recorder.d.ts +118 -0
  13. package/dist/types/canvas/gen/spec.d.ts +144 -0
  14. package/dist/types/core/color.d.ts +3 -1
  15. package/dist/types/core/pins.d.ts +18 -0
  16. package/dist/types/g2/Node2D.d.ts +5 -8
  17. package/dist/types/g2/Scene2D.d.ts +6 -2
  18. package/dist/types/gl/Foliage.d.ts +30 -7
  19. package/dist/types/gl/Light.d.ts +8 -0
  20. package/dist/types/gl/Lightmap.d.ts +13 -2
  21. package/dist/types/gl/Material.d.ts +17 -3
  22. package/dist/types/gl/Model.d.ts +6 -2
  23. package/dist/types/gl/Node.d.ts +3 -6
  24. package/dist/types/gl/Scene.d.ts +41 -13
  25. package/dist/types/gl/Texture.d.ts +1 -1
  26. package/dist/types/gl/animation/Locomotion.d.ts +8 -1
  27. package/dist/types/inject.d.ts +11 -11
  28. package/dist/types/inject.editor.d.ts +1 -0
  29. package/dist/types/net/core.d.ts +7 -0
  30. package/dist/types/plugin.d.ts +76 -0
  31. package/dist/types/plugins/gen/camera/sdk/camera.d.ts +24 -0
  32. package/dist/types/plugins/gen/camera/sdk/camera.gen.d.ts +25 -0
  33. package/dist/types/plugins/{geolocation.d.ts → gen/geolocation/sdk/geolocation.d.ts} +2 -20
  34. package/dist/types/plugins/gen/geolocation/sdk/geolocation.gen.d.ts +31 -0
  35. package/dist/types/plugins/{map.d.ts → gen/map/sdk/map.d.ts} +9 -50
  36. package/dist/types/plugins/gen/map/sdk/map.gen.d.ts +53 -0
  37. package/dist/types/plugins/gen/push/sdk/push.d.ts +23 -0
  38. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +35 -0
  39. package/dist/types/plugins/{qr.d.ts → gen/qr-scanner/sdk/qr-scanner.d.ts} +2 -3
  40. package/dist/types/plugins/gen/qr-scanner/sdk/qr-scanner.gen.d.ts +15 -0
  41. package/dist/types/runtime/app.d.ts +9 -2
  42. package/dist/types/runtime/fetch.d.ts +2 -0
  43. package/dist/types/runtime/input.d.ts +1 -1
  44. package/dist/types/runtime/media.d.ts +6 -10
  45. package/dist/types/runtime/misc.d.ts +4 -1
  46. package/dist/types/runtime/net.d.ts +3 -2
  47. package/dist/types/runtime/touch.d.ts +32 -0
  48. package/dist/types/scene/defineScene.d.ts +43 -2
  49. package/dist/types/scene/editor.d.ts +52 -0
  50. package/dist/types/scene/gizmos.d.ts +7 -4
  51. package/dist/types/ui/NativeView.d.ts +6 -4
  52. package/dist/types/ui/UI.d.ts +1 -1
  53. package/dist/types/ui/UIBottomSheet.d.ts +6 -12
  54. package/dist/types/ui/UIButton.d.ts +12 -14
  55. package/dist/types/ui/UIContainer.d.ts +0 -6
  56. package/dist/types/ui/UIImage.d.ts +1 -4
  57. package/dist/types/ui/UIInput.d.ts +8 -24
  58. package/dist/types/ui/UIModal.d.ts +0 -2
  59. package/dist/types/ui/UINode.d.ts +70 -56
  60. package/dist/types/ui/UIPager.d.ts +28 -27
  61. package/dist/types/ui/UIPopover.d.ts +0 -2
  62. package/dist/types/ui/UIScreen.d.ts +12 -17
  63. package/dist/types/ui/UIScrollable.d.ts +1 -4
  64. package/dist/types/ui/UIText.d.ts +0 -2
  65. package/dist/types/ui/UIVideo.d.ts +3 -5
  66. package/dist/types/ui/UIVirtualizedList.d.ts +14 -16
  67. package/dist/types/ui/UIWidget.d.ts +10 -9
  68. package/dist/types/ui/colorKeys.gen.d.ts +9 -0
  69. package/dist/types/ui/presentable.d.ts +46 -32
  70. package/dist/types/ui/router.d.ts +18 -7
  71. package/dist/types/ui/styleColor.d.ts +1 -0
  72. package/dist/types/ui/transitions.d.ts +18 -0
  73. package/dist/types/ui/tree.d.ts +75 -0
  74. package/dist/types/version.d.ts +10 -0
  75. package/dist/types.json +1 -1
  76. package/package.json +12 -3
  77. package/prompts/2d.md +2 -6
  78. package/prompts/3d.md +1 -5
  79. package/prompts/README.md +1 -1
  80. package/prompts/canvas.md +9 -8
  81. package/prompts/compose.ts +1 -1
  82. package/prompts/core.md +3 -3
  83. package/prompts/design.md +1 -1
  84. package/prompts/dist/2d-game.md +473 -239
  85. package/prompts/dist/3d-app.md +553 -205
  86. package/prompts/dist/ar-app.md +435 -202
  87. package/prompts/dist/design.md +113 -95
  88. package/prompts/dist/ui-app.md +386 -170
  89. package/prompts/ui-design.md +2 -3
  90. package/prompts/ui.md +45 -37
  91. package/src/animate/tween/Animation.ts +34 -150
  92. package/src/animate/tween/Timeline.ts +175 -175
  93. package/src/animate/tween/animateValue.ts +6 -3
  94. package/src/animate/tween/easing.ts +10 -3
  95. package/src/animate/tween/spec.ts +41 -15
  96. package/src/audio/Sound.ts +3 -3
  97. package/src/audio/audio.ts +2 -1
  98. package/src/bridges/2d.d.ts +317 -0
  99. package/src/bridges/app.d.ts +91 -0
  100. package/src/bridges/audio.d.ts +97 -0
  101. package/src/bridges/canvas.d.ts +79 -0
  102. package/src/bridges/device.d.ts +72 -0
  103. package/src/bridges/fetch.d.ts +80 -0
  104. package/src/bridges/files.d.ts +70 -0
  105. package/src/bridges/gl.d.ts +1133 -0
  106. package/src/bridges/input.d.ts +72 -0
  107. package/src/bridges/media.d.ts +55 -0
  108. package/src/bridges/nav.d.ts +71 -0
  109. package/src/bridges/net.d.ts +52 -0
  110. package/src/bridges/service.d.ts +52 -0
  111. package/src/bridges/socket.d.ts +31 -0
  112. package/src/bridges/storage.d.ts +33 -0
  113. package/src/bridges/tree.d.ts +301 -0
  114. package/src/bridges/types.d.ts +49 -0
  115. package/src/canvas/Canvas.ts +114 -159
  116. package/src/canvas/gen/cssColor.ts +224 -0
  117. package/src/canvas/gen/recorder.ts +212 -0
  118. package/src/canvas/gen/spec.ts +201 -0
  119. package/src/chisel.ts +193 -0
  120. package/src/compile/assetMacro.ts +1 -1
  121. package/src/compile/bundler.ts +11 -2
  122. package/src/compile/compileProject.ts +43 -4
  123. package/src/compile/fontMacro.ts +3 -4
  124. package/src/compile/header.ts +26 -5
  125. package/src/compile/index.ts +3 -1
  126. package/src/compile/liteMaterial.ts +1 -1
  127. package/src/compile/sceneEditor.ts +11 -26
  128. package/src/core/color.ts +73 -30
  129. package/src/core/pins.ts +51 -0
  130. package/src/core/signals.ts +8 -1
  131. package/src/g2/CharacterController2D.ts +3 -3
  132. package/src/g2/Node2D.ts +57 -39
  133. package/src/g2/Physics2D.ts +2 -2
  134. package/src/g2/Scene2D.ts +35 -23
  135. package/src/g2/Texture2D.ts +1 -1
  136. package/src/g2/loop.ts +4 -4
  137. package/src/gl/CameraPlace.ts +52 -52
  138. package/src/gl/Foliage.ts +72 -17
  139. package/src/gl/Geometry.ts +1 -2
  140. package/src/gl/Light.ts +10 -0
  141. package/src/gl/Lightmap.ts +45 -29
  142. package/src/gl/Material.ts +95 -51
  143. package/src/gl/Mesh.ts +120 -120
  144. package/src/gl/Model.ts +21 -16
  145. package/src/gl/Node.ts +91 -24
  146. package/src/gl/Particles.ts +1 -1
  147. package/src/gl/Scene.ts +100 -44
  148. package/src/gl/Texture.ts +8 -7
  149. package/src/gl/animation/AnimationClip.ts +1 -1
  150. package/src/gl/animation/DynamicBone.ts +482 -482
  151. package/src/gl/animation/Locomotion.ts +8 -3
  152. package/src/gl/nav/NavMesh.ts +3 -4
  153. package/src/gl/physics/Physics.ts +2 -2
  154. package/src/gl/physics/physicsEvents.ts +3 -3
  155. package/src/gl/scenarios.ts +291 -291
  156. package/src/gl/terrain/Terrain.ts +4 -5
  157. package/src/gl/touch.ts +14 -15
  158. package/src/host.d.ts +2 -3
  159. package/src/inject.editor.ts +7 -0
  160. package/src/inject.ts +13 -16
  161. package/src/net/core.ts +6 -5
  162. package/src/net/index.ts +1 -1
  163. package/src/net/replication.ts +1 -1
  164. package/src/plugin.ts +191 -0
  165. package/src/plugins/gen/camera/contract.d.ts +27 -0
  166. package/src/plugins/gen/camera/sdk/camera.gen.ts +46 -0
  167. package/src/plugins/gen/camera/sdk/camera.ts +57 -0
  168. package/src/plugins/gen/geolocation/contract.d.ts +50 -0
  169. package/src/plugins/gen/geolocation/sdk/geolocation.gen.ts +54 -0
  170. package/src/plugins/{geolocation.ts → gen/geolocation/sdk/geolocation.ts} +22 -43
  171. package/src/plugins/gen/map/contract.d.ts +144 -0
  172. package/src/plugins/gen/map/sdk/map.gen.ts +88 -0
  173. package/src/plugins/{map.ts → gen/map/sdk/map.ts} +68 -102
  174. package/src/plugins/gen/push/contract.d.ts +61 -0
  175. package/src/plugins/gen/push/sdk/push.gen.ts +60 -0
  176. package/src/plugins/gen/push/sdk/push.ts +105 -0
  177. package/src/plugins/gen/qr-scanner/contract.d.ts +16 -0
  178. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.gen.ts +29 -0
  179. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.ts +52 -0
  180. package/src/plugins/permission.ts +5 -4
  181. package/src/runtime/app.ts +20 -9
  182. package/src/runtime/appEvents.ts +5 -4
  183. package/src/runtime/channel.ts +18 -15
  184. package/src/runtime/clipboard.ts +4 -3
  185. package/src/runtime/datetime.ts +2 -1
  186. package/src/runtime/device.ts +17 -15
  187. package/src/runtime/fetch.ts +30 -20
  188. package/src/runtime/files.ts +16 -15
  189. package/src/runtime/input.ts +12 -10
  190. package/src/runtime/media.ts +50 -46
  191. package/src/runtime/misc.ts +7 -3
  192. package/src/runtime/net.ts +8 -7
  193. package/src/runtime/rpc.ts +1 -3
  194. package/src/runtime/service.ts +19 -14
  195. package/src/runtime/share.ts +4 -3
  196. package/src/runtime/storage.ts +6 -4
  197. package/src/runtime/touch.ts +32 -0
  198. package/src/scene/defineScene.ts +61 -365
  199. package/src/scene/editor.ts +408 -0
  200. package/src/scene/editorPlugins.ts +3 -3
  201. package/src/scene/gizmos.ts +15 -9
  202. package/src/server/db/marci/query.ts +1 -1
  203. package/src/server/host.ts +1 -1
  204. package/src/server/runtime.ts +1 -1
  205. package/src/ui/NativeView.ts +72 -25
  206. package/src/ui/UI.ts +3 -3
  207. package/src/ui/UIBottomSheet.ts +16 -17
  208. package/src/ui/UIButton.ts +54 -16
  209. package/src/ui/UIContainer.ts +0 -6
  210. package/src/ui/UIImage.ts +34 -30
  211. package/src/ui/UIInput.ts +29 -37
  212. package/src/ui/UIModal.ts +1 -3
  213. package/src/ui/UINode.ts +347 -297
  214. package/src/ui/UIPager.ts +93 -78
  215. package/src/ui/UIPopover.ts +0 -2
  216. package/src/ui/UIScreen.ts +41 -36
  217. package/src/ui/UIScrollable.ts +19 -13
  218. package/src/ui/UISpacer.ts +1 -1
  219. package/src/ui/UITabs.ts +8 -6
  220. package/src/ui/UIText.ts +7 -17
  221. package/src/ui/UIVideo.ts +30 -27
  222. package/src/ui/UIVirtualizedList.ts +58 -59
  223. package/src/ui/UIWidget.ts +38 -18
  224. package/src/ui/colorKeys.gen.ts +37 -0
  225. package/src/ui/fonts.ts +2 -2
  226. package/src/ui/presentable.ts +59 -42
  227. package/src/ui/router.ts +52 -35
  228. package/src/ui/styleColor.ts +56 -0
  229. package/src/ui/theme.ts +7 -6
  230. package/src/ui/transitions.ts +249 -0
  231. package/src/ui/tree.ts +346 -0
  232. package/src/version.ts +24 -0
  233. package/tests/helpers/engineWorld.ts +11 -0
  234. package/tests/helpers/fakeTree.ts +353 -0
  235. package/tests/helpers/hostStubs.ts +31 -0
  236. package/tests/helpers/index.ts +14 -0
  237. package/tests/helpers/memoryMarci.ts +124 -0
  238. package/tests/helpers/phases.ts +23 -0
  239. package/tests/helpers/preload.ts +18 -0
  240. package/tests/helpers/stubApp.ts +2 -0
  241. package/tests/helpers/stubDevice.ts +2 -0
  242. package/tests/helpers/stubFetch.ts +2 -0
  243. package/tests/helpers/stubInput.ts +2 -0
  244. package/dist/inject.js +0 -4629
  245. package/dist/types/core/registry.d.ts +0 -7
  246. package/dist/types/plugins/camera.d.ts +0 -25
  247. package/dist/types/plugins/push.d.ts +0 -46
  248. package/src/bridges.d.ts +0 -1769
  249. package/src/compile/__tests__/assetIconMacro.test.ts +0 -219
  250. package/src/compile/__tests__/assetMacro.test.ts +0 -100
  251. package/src/compile/__tests__/assetName.test.ts +0 -55
  252. package/src/compile/__tests__/compile.test.ts +0 -310
  253. package/src/compile/__tests__/detectEntry.test.ts +0 -151
  254. package/src/compile/__tests__/fontMacro.test.ts +0 -199
  255. package/src/compile/__tests__/serverSplit.test.ts +0 -27
  256. package/src/core/__tests__/stateMachine.test.ts +0 -132
  257. package/src/core/registry.ts +0 -23
  258. package/src/plugins/camera.ts +0 -81
  259. package/src/plugins/push.ts +0 -132
  260. package/src/plugins/qr.ts +0 -73
@@ -24,18 +24,43 @@ Remove a single top-level declaration:
24
24
 
25
25
  <remove file="home.ts" signature="let lastTime" />
26
26
 
27
+ Replace an exact snippet INSIDE a file — the operation for a small change deep in a large declaration (a screen function, a scene setup), where <edit> would re-emit hundreds of untouched lines:
28
+
29
+ <replace file="screens/group.ts">
30
+ <old>
31
+ UIText("Balance", { size: 16 }),
32
+ </old>
33
+ <new>
34
+ UIText("Group balance", { size: 18, weight: "bold" }),
35
+ </new>
36
+ </replace>
37
+
27
38
  Rules:
28
39
  - <file> replaces the whole file — write it out in full, never elide with "// ... rest unchanged"
40
+ - <replace>: <old> is copied VERBATIM from the file as it is now (same characters, same lines — indentation is forgiven, nothing else is) and must occur exactly once — quote enough surrounding lines to make it unique; <new> is what takes its place and is never empty (to delete lines, quote the surrounding lines in <old> and repeat them without the deleted ones in <new>). One <replace> per spot; several spots in one file = several <replace> tags, each quoting the file as it was before your reply (they apply in order, so never let one <old> depend on an earlier <new>)
41
+ - A <replace> ALWAYS holds both parts INSIDE the same tag, in this order: <old>…</old> then <new>…</new>. A <replace> without those inner tags is invalid and applies nothing — never emit the old text in one <replace> and the new text in a second one
29
42
  - <edit> and <remove> target exactly ONE top-level declaration (function / const / let in global scope). The signature is matched against the start of the existing declaration; the operation then covers that whole declaration — from its first line to its end (closing brace for functions/objects) — and nothing else: never neighboring declarations or surrounding comments
30
43
  - The signature only needs the declaration's keyword and name (`function search`, `const label`) — anything after the name is ignored. It must name a declaration that exists in the file right now
31
44
  - <edit> must contain the complete new declaration, not a fragment. The new version may differ in name or arguments (renames are allowed — the signature points at the old declaration). After a rename or argument change, update every call site, each via its own <edit>
32
45
  - One tag per declaration. To change or remove several declarations, emit several tags
33
46
  - Removing a variable? Also update every declaration that references it (each via its own <edit>)
34
- - Pick <edit>/<remove> for tweaking a few existing declarations; <file> when adding declarations, changing imports, or restructuring
35
- - If a change doesn't fit these operations, fall back to <file> — never bend <edit> to cover multiple declarations
47
+ - Choosing the operation, cheapest first: <replace> for a change of a few lines anywhere (a value, a label, one call, one branch); <edit>/<remove> when a whole small declaration changes shape; <file> for new files and real restructuring. Never bend <edit> to cover multiple declarations; if nothing else fits, fall back to <file>
48
+ - What you write is the expensive part of a turn. Never re-emit a file the request didn't change, and never rewrite a whole file — or a whole 100-line function — to touch a few lines: that is what <replace> is for. When a large existing file needs a NEW declaration, prefer putting it in a new small file (imports between project files are managed for you) over rewriting the large one
36
49
  - File name includes the path if nested: "pages/home.ts"
37
50
  - Only raw file content inside tags, no markdown fences (```). Backticks for template literals in the code are fine, and non-TS assets (e.g. raw SVG XML in a .svg file) are allowed.
38
51
 
52
+ // === EXAMPLE: a small change inside a big screen ===
53
+ // screens/home.ts is a 120-line `export function homeScreen()`. Task: make the title bigger.
54
+
55
+ <replace file="screens/home.ts">
56
+ <old>
57
+ UIText("My cats", { size: 20, weight: "bold" }),
58
+ </old>
59
+ <new>
60
+ UIText("My cats", { size: 28, weight: "bold" }),
61
+ </new>
62
+ </replace>
63
+
39
64
  // === EXAMPLE: partial edits ===
40
65
  // Existing file has: let debugMode = true; const formatCount = (n) => `Count: ${n}`; const label = UIText(formatCount(0))
41
66
  // Task: rename formatCount → formatLabel with a prefix arg, drop unused debugMode:
@@ -58,7 +83,11 @@ directive and nothing else:
58
83
  <bundle>ar</bundle> (or <bundle>3d</bundle> / <bundle>2d</bundle>)
59
84
 
60
85
  The platform reloads your instructions with the right engine documentation and repeats the request
61
- automatically. Never use this when the needed APIs are documented here — just do the work.
86
+ automatically. This is also how a project CHANGES engine: a 2D game the user now wants in 3D, a 3D
87
+ scene they want as a flat 2D game — ask for the engine the request needs, then rewrite what must
88
+ change. Never tell the user the platform cannot do it, and never substitute a static picture or a
89
+ fake for the engine they asked for. Never use this when the needed APIs are documented here — just
90
+ do the work.
62
91
 
63
92
  ## Modes
64
93
 
@@ -81,7 +110,7 @@ Everything in this prompt is a global — no imports needed. A project's only im
81
110
 
82
111
  `main.ts` is the entry point — always name the entry file `main.ts`. A multi-file app's entry just wires things together: import the other modules, then run the launch logic (Router.init(...) / scene.open()). Every other file must be reachable from `main.ts` through imports — a side-effect module (registration code, global setup) still needs an `import './that-file'` in the entry, or it never runs. (In a project without a `main.ts`, the file nothing else imports is treated as the entry.)
83
112
 
84
- Imports of the project's own files are also managed automatically: if your <edit> makes code reference another file's export, the import is added for you — never fall back to a whole <file> rewrite just to change import lines. When writing a complete <file>, include imports normally.
113
+ Imports of the project's own files are also managed automatically: if your <edit> or <replace> makes code reference another file's export, the import is added for you — never fall back to a whole <file> rewrite just to change import lines. When writing a complete <file>, include imports normally.
85
114
 
86
115
  Reference a project asset (image, font, video, .svg, .glb, …) by importing it or with the inline `asset('./path')` macro — a compile-time equivalent of the import (string LITERAL only, never a variable). External `https://…` URLs are used directly as strings.
87
116
 
@@ -91,17 +120,27 @@ Each request carries the project state: `[Assets]` lists binary files by path; `
91
120
 
92
121
  ## Automatic reports
93
122
 
94
- A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user — e.g. a runtime error thrown by the running app, with a source-mapped stack. Fix the problem directly with <file>/<edit> operations. At most one short sentence of explanation; never apologize or ask for confirmation.
123
+ A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user — e.g. a runtime error thrown by the running app, with a source-mapped stack. Fix the problem directly with <replace>/<edit>/<file> operations. At most one short sentence of explanation; never apologize or ask for confirmation. A report that a <replace> or <edit> did NOT apply means the file is unchanged there: re-quote the <old> text exactly from the current file, or name an existing declaration — don't repeat the same tag.
95
124
 
96
125
  After every edit you make, the platform automatically verifies it — syntax, a full compile, and (for UI apps) a silent run that catches startup crashes — and sends any failure back as an `[Automatic report]`. Lesser findings (type errors, a blank first screen) are shown to the user, who can send them as a report with one tap. So:
97
126
  - A report is a checker result, not a person. Fix exactly what it lists; don't re-explain the whole change.
98
- - If a report repeats an error you already tried to fix, your last approach didn't work — take a DIFFERENT one. Prefer rewriting the whole file with `<file>` over another targeted `<edit>`.
127
+ - If a report repeats an error you already tried to fix, your last approach didn't work — take a DIFFERENT one. Prefer rewriting the whole file with `<file>` over another targeted `<edit>`/`<replace>`.
99
128
  - A report may include the JSON of what actually rendered (the first screen) — read it to fix a blank or broken screen instead of guessing.
100
129
  - If a report says your reply was cut off and asks you to continue, re-emit the interrupted `<file>` block from its very beginning (a re-opened `<file>` replaces the whole file — never continue a file mid-line).
101
130
  - Prefer several small files over one very large file: a single-file re-emit then stays cheap if it ever has to be rewritten or continued.
102
131
 
103
132
  A `[Selected element]` section describes a UI element the user picked in the running app's preview — its type, text, current style, and "created at" (the code that creates it). Apply the request to exactly that element, starting from the created-at location.
104
133
 
134
+ ## Building from a design
135
+
136
+ A `design/` folder is the app's finished design, made on the design board: `screens/<id>.ts` — one static mockup per screen, its states a function of a state-string union; `shared/tokens.ts` — the brand as a `theme()` table; `shared/ui.ts` — the component kit; `shared/tabs.ts` — the tab bar (`defineTabs`); `meta.json` — screen descriptions, roles, groups, and the navigation edges between screens; `spec.md` — the data model, actors, and rules. When asked to build or implement the app (or a screen) from it, the design is the authority:
137
+
138
+ - Implement every designed screen, including each state in its union. Navigation follows `meta.json` edges (an `id@state` edge switches that screen's state, not a push) and the tab bar in `shared/tabs.ts` — its `defineTabs({...})` entries carry over 1:1 into `UITabs({...})` (same keys, labels, and icons; add each tab's root `screen`), which renders the identical bar.
139
+ - IMPORT the kit, don't restyle: app code imports `design/shared/tokens.ts` (its `theme()` call makes design and app one live theme) and the presentational components of `design/shared/ui.ts`. Rebuild only the mock-only pieces (static field mocks, fake keyboards) as real interactive equivalents with the exact same look.
140
+ - The mock data at the top of each screen is the schema draft: replace it with real state, storage, and logic per `spec.md`, keeping the field shapes.
141
+ - App screens are your own files (`main.ts` + the usual project layout) — never import `design/screens/*` into the app, and never write into `design/**`: the design stays the reference. If the design itself needs changing, say so and suggest `<mode>design</mode>`.
142
+ - Match the mockups; don't re-design. Where a mockup leaves behavior undefined, `spec.md` decides; where it's silent, pick the simplest behavior consistent with the design.
143
+
105
144
  ## Tools
106
145
 
107
146
  You may be given tools (they appear in the API request, each with its own description). Default to
@@ -148,7 +187,7 @@ const dir = target.position.sub(self.position).normalize()
148
187
  self.position = self.position.add(dir.scale(speed * dt))
149
188
 
150
189
  // Node transforms have VALUE semantics — getters return copies:
151
- node.position.x = 3 // ✗ silent no-op (mutates a discarded copy)
190
+ node.position.x = 3 // ✓ compiled to node.x = 3 (direct spelling only — a STORED copy is a no-op)
152
191
  node.x = 3 // ✓ scalar setters x/y/z
153
192
  const p = node.position; p.y += 1; node.position = p // ✓ mutate local, assign back
154
193
 
@@ -163,9 +202,9 @@ Mathf.clamp(v, min, max) / .lerp(a, b, t) / .remap(v, inMin, inMax, outMin, outM
163
202
  Mathf.damp(a, b, lambda, dt) / .moveTowards(a, b, maxDelta)
164
203
  Mathf.random(min, max) / .randomInt(min, max) // randomInt inclusive both ends
165
204
 
166
- // Colors: hex string "#e33" | "#ff3333" | "#ff3333cc" or packed int 0xff3333.
167
- // rgba(...) / named colors DON'T work in 2D/3D APIs (silently become black). UI styles are the
168
- // exception — they also accept rgb()/rgba() and basic names ("white", "black", "transparent", …).
205
+ // Colors (every API, UI and engine alike): any CSS color string ("#e33", "#ff3333cc", "rgb(255 0 0 / 50%)",
206
+ // "hsl(0 100% 50%)", "red" — the CSS names, green = #008000), packed int 0xff3333 (opaque 0xRRGGBB),
207
+ // or [r, g, b(, a)] 0..1. A non-color throws. var(--x) only in UI styles.
169
208
 
170
209
  // ===== NETWORK =====
171
210
 
@@ -209,16 +248,22 @@ const sfx = new AudioPlayer(src) // src: asset('./x.mp3') or URL; same API: ne
209
248
  // .dispose() — frees the native player; ANY use after dispose throws
210
249
  // No pitch control, no auto-pooling: for overlapping SFX create several players up front and rotate.
211
250
 
212
- // ===== INPUT (keyboard) =====
213
-
214
- Input.key(code: string): boolean // is the key held NOW; KeyboardEvent.code names: 'KeyW', 'Space', 'ArrowLeft'
215
- // Poll-only — there are NO key events. For "pressed this frame", latch previous state:
216
- const wasDown: Record<string, boolean> = {}
217
- const pressed = (c: string) => Input.key(c) && !wasDown[c]
251
+ // ===== INPUT (keyboard / mouse / gamepad) =====
252
+ // A mouse button IS a key; so is a gamepad button. One code vocabulary: KeyboardEvent.code ('KeyW',
253
+ // 'Space', 'ArrowLeft'), 'MouseLeft|Right|Middle', 'GamepadSouth|East|West|North|L1|R1|L2|R2|Start|…'.
254
+ Input.key(code): boolean // held NOW — poll in setLoop for continuous movement (multiply by dt)
255
+ Input.on('keydown', e => …) // one-shot actions: e.code, e.repeat (auto-repeat), e.gamepad (pad index)
256
+ Input.on('keyup', e => …) // release (charged shots); Input.off(name, fn) to remove
257
+ Input.mouse.delta // { x, y } motion during the previous frame — FPS look; works while locked
258
+ Input.mouse.position / .wheel // cursor in logical px / wheel notches this frame
259
+ Input.mouse.lock() / unlock() / locked // hide + confine the cursor (call lock() from a keydown — web needs a gesture)
260
+ Input.gamepad(0).axis('leftX' | 'leftY' | 'rightX' | 'rightY' | 'leftTrigger' | 'rightTrigger') // −1..1 / 0..1
218
261
  setLoop(dt => {
219
- if (pressed('Space')) jump()
220
- for (const c of ['Space', 'KeyE']) wasDown[c] = Input.key(c) // latch at END of frame
262
+ yaw -= Input.mouse.delta.x * 0.003 + Input.gamepad(0).axis('rightX') * 2 * dt
263
+ const x = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) + Input.gamepad(0).axis('leftX')
221
264
  })
265
+ Input.on('keydown', e => { if (e.code === 'Space' || e.code === 'GamepadSouth') jump() })
266
+ // No one-frame "pressed" polls (keyDown/actionDown) — discrete = event, continuous = poll.
222
267
 
223
268
  // ===== TOUCH GESTURES =====
224
269
  // 'click' fires on pointer-up over a target; 'touchstart' on pointer-down. ev: { clientX, clientY,
@@ -266,17 +311,23 @@ implement what you can with the loaded ones.
266
311
  // Canvas: Canvas (retained 2D drawing baked to a texture — sprites/UI images), Bitmap
267
312
  // Aspects: Aspect base — attachable node capabilities: node.aspect(Class, opts), custom aspects
268
313
  // with per-frame update(dt)
269
- // UI: UIScreen, Router, UIRow, UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
314
+ // UI: UIScreen, Router, UITabs (bottom-tab shell), UIPager (tabs + per-tab stacks), UIRow,
315
+ // UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
270
316
  // UIButton, UIInput, UITextArea, UIWidget/UIModal/UIPopover/UIBottomSheet (floating
271
317
  // overlays: HUD / dialog / anchored menu / draggable sheet), UIVirtualizedList,
272
318
  // registerFont; UIBox (deprecated)
273
319
  // 2D engine: Scene2D, Node2D, Camera2D, Sprite, SpriteAnimation, Tilemap, Texture2D,
274
320
  // Shape2D + Physics2D + Trigger2D + CharacterController2D (Box2D physics, sensors,
275
- // raycast/overlap queries, picking)
321
+ // raycast/overlap queries, picking),
322
+ // defineScene2d (declarative *.scene2d.ts files) + cells + CameraFollow
276
323
  // 3D engine: Scene, Node, Camera, Mesh (box/sphere/cylinder/plane), Model (.glb) + ModelAnimation,
277
324
  // Geometry, Material (lit/unlit/custom shaders), Texture, Light.sun(),
278
325
  // Shape + Physics + Trigger + CharacterController (Jolt physics, raycast),
279
326
  // Particles + dynamic/dynamicColor, Ray, Plane, Noise
327
+ // 3D scene files: defineScene (declarative *.scene.ts read/written by the visual scene editor;
328
+ // default export is a SceneHandle: load()/open()) + use/ref/make in the def,
329
+ // scenario aspects MoveTo/FollowPath/Spin/LookAt/PlayAnimation;
330
+ // *.editor.ts + EDITOR/InspectorUI are editor-plugin files — never edit or import them
280
331
  // AR: ARScene (camera passthrough, anchors, placement gestures via addControls)
281
332
 
282
333
  // ===== CANVAS — retained drawing baked to a texture (engine-independent) =====
@@ -289,22 +340,23 @@ const c = new Canvas(width, height, { pixelRatio: device.pixelRatio })
289
340
  // width/height are LOGICAL units — author all drawing logical; pixelRatio only multiplies baked
290
341
  // resolution (crispness), never the on-screen size.
291
342
 
292
- c.fillStyle = '#ff8800' // CSS color STRINGS work here (the exception to engine hex-only colors);
293
- c.lineCap = 'round' // also strokeStyle, lineWidth, lineJoin, globalAlpha,
343
+ c.fillStyle = '#ff8800' // any CSS color string, as everywhere in the SDK;
344
+ c.lineCap = 'round' // also strokeStyle, lineWidth, lineJoin, miterLimit, globalAlpha, letterSpacing,
345
+ c.setLineDash([4, 2]) // dashes; a gradient: c.fillStyle = c.createLinearGradient(x0,y0,x1,y1).addColorStop(0,'#fff').addColorStop(1,'transparent')
294
346
  c.font = 'bold 16px sans-serif' // textAlign ('left'|'center'|'right'), textBaseline
295
347
  // custom fonts: draw only AFTER await registerFont(...)
296
348
  c.fillRect(x, y, w, h) / c.strokeRect / c.clearRect
297
- c.beginPath().moveTo(x,y).lineTo(x,y).arc(x,y,r,a0,a1).rect(x,y,w,h).roundRect(x,y,w,h,r).closePath() // all chain
298
- c.fill() / c.stroke() / c.fillText(text, x, y) / c.strokeText(text, x, y)
299
- c.measureText(text): { width, ascent, descent } // in the CURRENT font, logical px
300
- c.drawImage(bmp: Bitmap, dx, dy [, dw, dh]) // blits a Bitmap only (from c.toBitmap()), nothing else
349
+ c.beginPath().moveTo(x,y).lineTo(x,y).arc(x,y,r,a0,a1).arcTo(x1,y1,x2,y2,r).ellipse(x,y,rx,ry,rot,a0,a1).rect(x,y,w,h).roundRect(x,y,w,h,r).closePath() // all chain
350
+ c.fill('evenodd'?) / c.stroke() / c.clip() / c.fillText(text, x, y) / c.strokeText(text, x, y)
351
+ c.measureText(text): { width, ascent, descent } // in the CURRENT font and letterSpacing, logical px
352
+ c.drawImage(bmp: Bitmap, dx, dy [, dw, dh]) // blits a Bitmap only (c.toBitmap(), await c.loadImage(SvgSource(svg) | fetchResponse))
301
353
 
302
354
  c.update(): this // after redrawing, push new pixels to EVERY texture/image this canvas produced
303
355
  c.reset(): this // discard the recorded drawing to start over
304
356
  c.resize(w, h): this // new logical size (takes effect next bake; measure-then-resize works pre-bake)
305
357
  c.toBitmap(): Bitmap // rasterized snapshot, usable with drawImage
306
- // GOTCHA: reset() clears the recorded STATE too — font/fillStyle/textAlign fall back to defaults on
307
- // the next bake (getters still report old values). Re-set font & friends after every reset().
358
+ // GOTCHA: reset() clears the recorded STATE too — font/fillStyle/textAlign fall back to defaults
359
+ // (the getters report the defaults again). Re-set font & friends after every reset().
308
360
  // Ever-growing drawings: flatten — const snap = c.toBitmap(); c.reset(); c.drawImage(snap, 0, 0)
309
361
 
310
362
  ## 3D engine
@@ -355,15 +407,13 @@ node.lookAt(point: Vec3Like, mode = '-z', up = [0, 1, 0]): this // aim a local
355
407
  // 'click' pointer-up over the node — REQUIRES a Shape aspect (pick body)
356
408
  // 'touchstart' pointer-down over the node (ev.track() drags) — requires Shape
357
409
  // 'enter' / 'exit' (other: Node) physics contact / trigger overlap began/ended — Shape + Physics/Trigger
358
- // 'completed' (clip: number) non-looping GLB clip finished — Model only
359
- // 'loopReached' (clip: number) looping GLB clip wrapped — Model only
360
410
  // Without a Shape a node is INVISIBLE to taps — only scene listeners fire (ev.target === null).
361
411
 
362
412
  // ===== ASPECTS =====
363
413
  // Capabilities attach to nodes as aspects and chain; each adds a named accessor:
364
414
  node.aspect(Class, opts?) // attach + configure; returns the node (typed with the accessor)
365
415
  node.get(Class) / node.has(Class) / node.removeAspect(Class)
366
- // Named accessors: node.physics, node.trigger, node.controller, node.shape, model.anim
416
+ // Named accessors: node.physics, node.trigger, node.controller, node.shape, node.anim
367
417
  // Custom game logic = your own aspect with per-frame update(dt seconds):
368
418
  class Spin extends Aspect<'spin', Node> { // <accessor name, node kind>
369
419
  speed = 90 // class fields = configurable defaults (opts override)
@@ -397,7 +447,7 @@ material.map = tex // Texture | Canvas | null
397
447
 
398
448
  // Custom compiled shaders (.mat files) — load, then set uniforms by name (chainable):
399
449
  const mat = new Material(await fetch(asset('./hologram.mat'), { useOnce: true }))
400
- .set('baseColor', '#ffa200') // '#rrggbb' / '#rrggbbaa' hex STRINGS only
450
+ .set('baseColor', '#ffa200') // color STRINGS only (any CSS color); a number is a float
401
451
  .set('roughness', 0.54) // number → float, boolean → bool
402
452
  .set('glowMap', tex) // Texture → sampler
403
453
  .set('dir', [0, 1, 0]) // number[] / Float32Array → vector. NOT a Vec3 — spread it: [...v]
@@ -431,24 +481,34 @@ const hero = await Model.load(asset('./hero.glb')) // asset path | https URL |
431
481
  scene.add(hero) // a Model IS a Node — transform/events/aspects all apply
432
482
  hero.traverse(n => { if (n.name === 'Sword') n.visible = false }) // GLB internals are plain child Nodes
433
483
 
434
- // Animation — every Model has the ModelAnimation aspect pre-attached at model.anim:
435
- hero.anim.clips // { name: string, duration: number }[] baked into the glb
436
- hero.anim.play(clip?: string | number, options?: { loop?: boolean }): this // no arg = replay current
437
- hero.anim.stop(): this
438
- hero.anim.playing = true // get/set pause/resume
439
- hero.anim.speed = 1.5 // playback rate
440
- hero.anim.time = 0.5 // seek, seconds
441
- // Clip-end events fire on the NODE (not on .anim), payload = clip index:
442
- hero.addEventListener('completed', clip => {}) // non-looping clip finished (playing flips false)
443
- hero.addEventListener('loopReached', clip => {}) // looping clip wrapped
444
-
445
- // LIMITS — plan around these:
446
- // - No cross-fade/blending: play() hard-switches clips next frame; one clip at a time.
447
- // - play('BadName') is SILENTLY ignored — the old clip keeps playing. Check model.anim.clips.
448
- // - Models CANNOT be cloned. A spawn pool = load N copies UP FRONT and recycle them:
449
- const POOL = 8
450
- const stones = await Promise.all(Array.from({ length: POOL }, () => Model.load(asset('./stone.glb'))))
451
- const spawn = () => { const s = stones[next++ % POOL]; s.position = [rx(), 8, rz()]; scene.add(s) }
484
+ // Animation — every Model has an Animator pre-attached at model.anim (clip list = the glb's clips). A layer
485
+ // has a LOOP (what it rests on) and ONE-SHOTS played over it; any call overrides what's there over its own fade:
486
+ hero.anim.clips / hero.anim.clip('Run') / hero.anim.clip(0) // AnimationClip[] (file order) / by name / by index; unnamed clips = 'animation_0', …
487
+ hero.anim.playLoop('Idle', { fade: 0.2 }) // rest on a looping clip (crossfades from whatever plays); hero.anim.loop reads it
488
+ const loco = hero.anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 }) // a BLEND as the loop (1D; [x, y] positions = 2D)
489
+ loco.value = speed // position on the axis — members stay in phase (no foot sliding)
490
+ const ok = await hero.anim.play('slash', { fadeIn: 0.1, fadeOut: 0.3 }) // one-shot over the loop, returns to it
491
+ // fades default to 0 (cut): fade = both ends, fadeIn / fadeOut = one end. Unknown name warns, plays nothing.
492
+ // The await resolves at the HAND-OVER (end − fadeOut) with true (false = cut short) — what you start right after is what
493
+ // the clip fades into: chains crossfade: if (ok) await hero.anim.play('slash2', { fadeIn: 0.3, fadeOut: 0.3 })
494
+ // then hero.anim.playLoop('Crouch', { fade: 0.3 }) — or nothing = back to the current loop.
495
+ if (!hero.anim.busy) hero.anim.play('kick') // busy = a one-shot hasn't handed over; play() always takes over otherwise
496
+ hero.anim.stop({ fade: 0.2 }) // everything off → rest; hero.anim.busy; hero.anim.speed = 0.3 (0 = pause)
497
+ // p = play(): p.done (return THIS from async fns) / p.playing / p.progress / p.weight / p.stop({ fade }) / p.seek(t)
498
+ // Clips from other files (Mixamo: one GLB per animation), procedural, sliced — add by name:
499
+ const [idle, slash] = await Promise.all([AnimationClip.load(asset('./idle.glb')), AnimationClip.load(asset('./attacks.glb'), 'Slash')])
500
+ hero.anim.addClip('slash', slash).addClip('kick', hero.anim.clip('Kick').slice(0.2, 1.1)) // slice = a sub-range, re-timed
501
+ AnimationClip.from({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } }) // curves in code, binds by node NAME
502
+ // Clip events live on the CLIP, in SECONDS: slash.addEvent(0.4, 'hit') → hero.anim.on('hit', (clip, layer) => dealDamage())
503
+ // Layers (legs walk, arms aim): const upper = hero.anim.addLayer({ mask: 'Spine1' }); upper.playLoop('Aim', { fade: 0.3 }); upper.stop()
504
+ // { additive: true } = each clip's DELTA vs its first frame on top (recoil, breathe); upper.weight = 0.5
505
+ // Root motion (clips whose hips travel — Mixamo without 'In Place', rolls): hero.anim.rootMotion = true → the node moves
506
+ // (or its CharacterController, as a velocity — it collides); the bone stays put
507
+ hero.bone('RightHand')?.add(sword) // bones are Nodes — sockets
508
+ // FBX assets (Mixamo exports FBX): `lecodes assets convert hero.fbx --clips Idle.fbx Run.fbx -o hero.glb` → one GLB, clips merged by bone name
509
+ // IK — late-phase aspects ON BONES (over the Animator's pose): TwoBone on the END bone, LookAt on the bone itself
510
+ hero.bone('LeftFoot').aspect(IK.TwoBone, { target: footPoint, pole: kneeHint }).ik.weight = grounded ? 1 : 0
511
+ hero.bone('Head').aspect(IK.LookAt, { target: camera, limit: 70 }) // axis = bone-local forward, default [0,0,-1]
452
512
 
453
513
  // ===== PHYSICS (Jolt) =====
454
514
 
@@ -464,9 +524,18 @@ node.aspect(Shape, {
464
524
  sphere?: number, // radius
465
525
  cylinder?: { halfHeight: number, radius: number }, // Y-aligned
466
526
  capsule?: { halfHeight: number, radius: number }, // halfHeight = cylinder section only
527
+ mesh?: true | 'convex', // the node's OWN triangles (Mesh geometry or Model GLB, × world scale)
528
+ origin?: Vec3Like, // collider centre, offset from the node's pivot (world units, node rotation, NOT scaled)
467
529
  raycast?: boolean, // default true; false = invisible to taps & raycasts
468
530
  })
531
+ // mesh: true = exact triangle mesh → level geometry / terrain / ramps: static or kinematic Physics, Trigger,
532
+ // pick bodies, character ground. NEVER dynamic (Physics throws). mesh: 'convex' = convex hull → dynamic props.
533
+ // A Model collider is its bind pose (skinned parts skipped) — animated characters keep a capsule.
469
534
  node.aspect(Shape, {}) // auto: box from the mesh AABB × world scale — right for most meshes
535
+ // origin: art modelled ABOVE its pivot (a character on its feet, a barrel on its base) needs the
536
+ // collider lifted: { capsule: {…}, origin: [0, 0.9, 0] }. An auto shape already centres on its mesh.
537
+ node.shape.fit(kind?) // measure dimensions + origin from what the node RENDERS (subtree,
538
+ // so a Model root works where {} can't) and rebuild the live collider. Fit AFTER Model.load resolves.
470
539
  // A bare Shape (no Physics) creates a STATIC pick-only body → the node gets 'click'/'touchstart'.
471
540
  // It snapshots the transform at attach time — a MOVING pickable object needs a Physics body too.
472
541
 
@@ -476,11 +545,14 @@ node.aspect(Shape, {}).aspect(Physics, {
476
545
  mass?: number, // kg, dynamic only; default 1
477
546
  })
478
547
  node.physics.velocity // Vec3 world units/s — get (fresh copy) / set
548
+ node.physics.angularVelocity // Vec3 DEGREES/s about each world axis — get / set
479
549
  node.physics.applyImpulse(v): this // instant impulse, wakes the body
480
- node.physics.moveTo(p): this // drive a kinematic body (or teleport)
481
- // PHYSICS OWNS a dynamic body's transform: never set node.position per frame — drive velocity /
482
- // applyImpulse instead. READING node.position/worldPosition is always correct.
483
- // static = never moves (floors, walls); kinematic = script-driven via moveTo, pushes but isn't pushed.
550
+ node.position = [x, y, z] // place / teleport — moves the node AND its body (no moveTo())
551
+ node.eulerAngles = [0, 90, 0] // orientation is routed to the body too (NOT for a CharacterController)
552
+ // A teleport keeps BOTH velocities: putting an object down is velocity = 0, angularVelocity = 0, position = p
553
+ // PHYSICS OWNS a dynamic body's transform: a position write teleports, but the sim takes over again —
554
+ // drive motion with velocity / applyImpulse. READING node.position/worldPosition is always correct.
555
+ // static = never moves (floors, walls); kinematic = write position each frame, pushes but isn't pushed.
484
556
 
485
557
  // Contact events — on BOTH nodes of a contact/overlap, arg = the other node:
486
558
  crate.addEventListener('enter', (other: Node) => {})
@@ -524,7 +596,8 @@ sparks.destroy() // from Node — removes the system
524
596
 
525
597
  scene.camera // a Node — never constructed; every scene owns one.
526
598
  // (Who MOVES it is mode-specific — see the Scene/AR section.)
527
- camera.fov // vertical FOV, DEGREES (read-only); .horizontalFov, .displaySize
599
+ camera.fov / .near / .far // lens: vertical FOV degrees (60), clip 0.01 / 1000 — SETTABLE.
600
+ camera.setProjection({ fov, near, far }) // any subset; .horizontalFov, .displaySize stay read-only
528
601
  camera.getViewDirection(screenX, screenY): Vec3 // world dir through a screen point (logical px = ev.clientX/Y)
529
602
  camera.getRay(screenX, screenY): Ray // origin = camera.worldPosition + that direction
530
603
 
@@ -582,10 +655,6 @@ mat.set('lightDir', dir)
582
655
  Light.point(...)
583
656
  // ✅ Light.sun() + scene IBL is the whole lighting model; fake glows with unlit/bloom/particles
584
657
 
585
- // ❌ rgba()/named colors in 3D APIs — silently black
586
- Material.lit({ color: 'rgba(255, 0, 0, 0.5)' })
587
- // ✅ hex string or packed int: '#ff0000' / '#ff000080' / 0xff0000
588
-
589
658
  // ❌ a fresh Material per frame (or per particle) to animate a look
590
659
  setLoop(() => { mesh.material = Material.lit({ color: next() }) })
591
660
  // ✅ mutate the one material: mesh.material.color = next() / mat.set('progress', t)
@@ -699,14 +768,129 @@ setLoop(() => {
699
768
  if (Input.key('Space')) hero.controller.jump()
700
769
  })
701
770
 
702
- // Transparent HUD over the 3D view (see UI section)
771
+ // HUD over the 3D view: a UIWidget ATTACHED to the scene. (A UIScreen would REPLACE the scene —
772
+ // exactly one Presentable is visible at a time.)
703
773
  let scoreLabel: UIText
704
- const hud = UIScreen([
774
+ const hud = UIWidget(
705
775
  scoreLabel = UIText('Score: 0').style({ color: 'white', fontSize: 24, fontWeight: 700 }),
706
- ]).style({ p: 16, pt: 'max(safe-top, 16px)', alignItems: 'flex-end' })
776
+ ).style({ top: 'max(safe-top, 16px)', right: 16 })
707
777
 
708
778
  scene.open()
709
- hud.open()
779
+ hud.attachTo(scene).show()
780
+ </file>
781
+
782
+ ## Scene files (.scene.ts)
783
+
784
+ Projects may contain `*.scene.ts` files: declarative scenes that the platform's VISUAL scene
785
+ editor reads and writes. The user may have built them by dragging models around — treat the file
786
+ as their artwork. You may edit values and add nodes/aspects, but keep the `defineScene({...})`
787
+ literal-object shape; an imperative rewrite (`new Scene()` + `scene.add(...)`) destroys their
788
+ ability to keep editing it visually. `defineScene`, `use`, `ref` and `make` are globals.
789
+
790
+ // ===== DEFINE SCENE =====
791
+
792
+ // Default-export exactly one defineScene call per .scene.ts file.
793
+ export default defineScene({
794
+ env?: SceneOptions, // same options object as `new Scene(...)` above (skybox, ibl, bloom…)
795
+ nodes?: {
796
+ name: { // names are sibling-unique, no '/' or ':' in them
797
+ // -- source: AT MOST ONE of these per node; none = empty group node --
798
+ mesh?: { kind: 'box', size?: n | [x,y,z] } | { kind: 'sphere' | 'cylinder' | 'plane', ... },
799
+ model?: string, // GLB via the asset macro: asset('./hero.glb')
800
+ light?: { kind: 'sun', direction?, intensity?, shadowsQuality? }, // 'sun' is the ONLY kind
801
+ camera?: { fov?, near?, far? }, // this node IS the scene camera (defaults 60 / 0.01 / 1000)
802
+ make?: make(factoryFn, { ...literalArgs }), // code-built subtree from a user function
803
+ prefab?: SceneHandle, // another .scene.ts's default export (import it)
804
+ // -- for a mesh source --
805
+ material?: { lit: { color?, metallic?, roughness? } } | { unlit: { color? } },
806
+ // -- transform / render --
807
+ position?: [x,y,z], eulerAngles?: [x,y,z], scale?: [x,y,z] | n,
808
+ visible?, castShadows?, receiveShadows?,
809
+ locked?: boolean, // editor-only flag, zero runtime effect — leave it alone
810
+ // -- behavior / hierarchy --
811
+ aspects?: [ use(AspectClass, { ...props }) ], // props may use ref('otherNode') for node refs
812
+ children?: { name: { ...same shape } },
813
+ // pose parts INSIDE a model/prefab, and attach a child node to such a part:
814
+ overrides?: { 'Bone/Path': { position?, eulerAngles?, scale?, visible? } },
815
+ mount?: 'Bone/Path', // parents this node to that part of the PARENT node's asset
816
+ },
817
+ },
818
+ })
819
+
820
+ // ===== USING A SCENE FROM APP CODE =====
821
+
822
+ import city from './city.scene' // extension-less specifier; default export: SceneHandle
823
+
824
+ const { scene, nodes, get } = await city.open() // load (fetches GLBs) + become the active view
825
+ // city.load() — instantiate without showing; both are idempotent (same instance every call)
826
+ nodes.crate // typed by source: model → Model, mesh → Mesh, light → Light
827
+ nodes['props/lamp'] // keys are absolute '/'-joined paths; get(path) for dynamic
828
+ nodes.crate.position = [1, 0.5, 2] // plain SDK nodes — assign vectors, or scalar axes: node.y = 2
829
+ nodes.hero.anim.play('walk') // a model node is a Model: animations, parts — all there
830
+ scene.close() // closing goes through the scene (there is NO handle.close())
831
+
832
+ // ===== BEHAVIOR & INPUT =====
833
+
834
+ // Put per-frame behavior in ASPECTS attached in the file — not in code that mutates the doc:
835
+ // aspects: [use(Spin, { speed: 40 })]
836
+ // Built-in no-code aspects: MoveTo, FollowPath ({ path: ref('waypoints'), duration }), Spin,
837
+ // LookAt, PlayAnimation. Custom ones are ~5 lines (see Aspects; update(dt) — dt in SECONDS):
838
+ export class Spinner extends Aspect<'spinner'> {
839
+ speed = 30 // class fields = props settable from use(...)
840
+ update(dt: number) { this.node.eulerAngles = [0, this.node.eulerAngles.y + this.speed * dt, 0] }
841
+ }
842
+ // Clicks/taps: a node is pickable only with a Shape aspect — aspects: [use(Shape, {})] — then
843
+ // nodes.crate.addEventListener('click', ev => ...). Catch-all with target (null on miss):
844
+ // scene.addEventListener('click', ev => ev.target). Shape alone = static pick body; moving
845
+ // clickables also need Physics.
846
+
847
+ // ===== UI OVER AN OPEN SCENE =====
848
+
849
+ // Exactly one Presentable is visible at a time: UIScreen(...).open() REPLACES the scene. A HUD is
850
+ // a UIWidget attached to the scene — it shows/hides and transitions together with it:
851
+ const hud = UIWidget(UIText('Score: 0').style({ color: 'white', fontSize: 24 }))
852
+ .style({ top: 'max(safe-top, 16px)', left: 16 })
853
+ hud.attachTo(scene).show()
854
+
855
+ // ===== SCENE FILE MISTAKES — DO NOT DO THESE =====
856
+
857
+ // - Rebuilding a .scene.ts imperatively, or moving its content into main.ts. Edit the def in
858
+ // place; logic goes into aspect classes or app code around handle.open().
859
+ // - scene.ref(...), handle.close(), onTap — none exist. It's nodes[path] / get(path),
860
+ // loaded.scene.close(), addEventListener('click').
861
+ // - Two source keys on one node (e.g. mesh + model) — throws at load.
862
+ // - Expecting clicks without use(Shape, {}) on the node.
863
+ // - const p = node.position; p.x = 3 — NO-OP (a stored copy). node.position.x = 3 itself compiles to node.x = 3.
864
+ // - env HDRI paths or light kinds other than 'sun' — not supported; ibl is just a boolean.
865
+
866
+ ## Scene file wiring example
867
+
868
+ // A scene built in the visual editor, wired up with a click counter and a HUD.
869
+
870
+ <file name="main.scene.ts">
871
+ export default defineScene({
872
+ env: { skybox: '#a9b6c8' },
873
+ nodes: {
874
+ camera: { camera: {}, position: [0, 4, 9], eulerAngles: [-22, 0, 0] },
875
+ sun: { light: { kind: 'sun', shadowsQuality: 1 } },
876
+ ground: { mesh: { kind: 'box', size: [12, 0.4, 12] }, material: { lit: { color: '#3d4351' } },
877
+ position: [0, -0.2, 0], locked: true },
878
+ crate: { mesh: { kind: 'box' }, material: { lit: { color: '#8a8f98' } }, position: [0, 0.5, 0],
879
+ aspects: [use(Shape, {}), use(Spin, { speed: 25 })] },
880
+ },
881
+ })
882
+ </file>
883
+
884
+ <file name="main.ts">
885
+ import mainScene from './main.scene'
886
+
887
+ const { scene, nodes } = await mainScene.open()
888
+
889
+ let taps = 0
890
+ const label = UIText('Tap the crate').style({ color: 'white', fontSize: 20, fontWeight: 700 })
891
+ UIWidget(label).style({ top: 'max(safe-top, 16px)', left: 16 }).attachTo(scene).show()
892
+
893
+ nodes.crate.addEventListener('click', () => { label.text = `Taps: ${++taps}` })
710
894
  </file>
711
895
 
712
896
  // ===== UI RULES =====
@@ -717,32 +901,64 @@ hud.open()
717
901
 
718
902
  // ===== UI COMPONENTS =====
719
903
  // Every element is created by a global factory function (never `new`) that takes only the element's
720
- // CONTENT (children array, text, src, ...). Everything else — styles, handlers — is configured by chaining:
721
- // every configuring method returns the element itself, so construction reads as one chain.
904
+ // CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
905
+ // configured by chaining: every configuring method returns the element itself, so construction reads
906
+ // as one chain.
907
+ UIColumn(UIText("Title"), UIButton(UIText("Go")))
908
+ // An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
909
+ UIColumn(header, items.map(Row), footer)
722
910
 
723
911
  // UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
724
- UIRow(children) / UIColumn(children)
912
+ UIRow(...children) / UIColumn(...children)
725
913
  // Child management — imperative, no diffing; works before and after the element is on screen:
726
914
  // .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
727
915
  // .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
728
916
  // For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
729
917
 
730
918
  // UIScreen — root screen, always fills the device. Behaves as a UIColumn.
731
- UIScreen(children)
919
+ UIScreen(...children)
732
920
  // .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
733
921
  // .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
734
922
  // popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
735
923
  // in onClose (see TIMERS & FRAME LOOP above).
736
924
  // .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
737
- // .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
925
+ // .onBack(cb) — Android hardware/gesture back; typically Router.pop()
738
926
  // Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
739
- // with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
927
+ // with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
740
928
  // Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
741
929
 
930
+ // UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
931
+ // USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
932
+ const tabs = UITabs({
933
+ home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
934
+ profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
935
+ })
936
+ Router.init(tabs) // UITabs IS a UIScreen — present it directly
937
+ // .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
938
+ // .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
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.
942
+
943
+ // UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
944
+ // OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
945
+ // tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
946
+ UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
947
+ // .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
948
+ // bar highlight here. Tabs keep their stack/scroll state when switched away and back.
949
+ // .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
950
+ // .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
951
+ // Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
952
+ // pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
953
+ // Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
954
+ // load initial data at build time, keep onOpen for re-entry.
955
+ const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
956
+ Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
957
+
742
958
  // UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
743
959
  // Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
744
- UIWidget(children)
745
- // .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
960
+ UIWidget(...children)
961
+ // .show(), .hide(), .isShown (getter), .onTouchStart(cb), .onBack(cb)
746
962
  // extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
747
963
  // a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
748
964
  // .onOverlayTap(cb) — usually () => widget.hide()
@@ -753,7 +969,7 @@ UIWidget(children)
753
969
  // UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
754
970
  // Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
755
971
  // back button close it automatically. Create ONCE at module scope, like any widget.
756
- UIModal(children)
972
+ UIModal(...children)
757
973
  // .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
758
974
  // — plus the full UIWidget surface
759
975
  // .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
@@ -764,10 +980,10 @@ UIModal(children)
764
980
  // UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
765
981
  // Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
766
982
  // dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
767
- UIBottomSheet(children)
983
+ UIBottomSheet(...children)
768
984
  // .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
769
985
  // BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
770
- // .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
986
+ // .setDetent(i) (animated), .detent (getter), .onDetent(cb(i)) — every settle: finger snap or setDetent()
771
987
  // .show()/.hide() slide in/out — plus the full UIModal surface (scrim, onOpen/onClose, dismissible).
772
988
  // Dragging below the lowest detent closes it (fires onClose); .dismissible(false) collapses there instead —
773
989
  // the persistent map sheet (pair it with overlayColor: null so the page behind stays interactive).
@@ -777,30 +993,33 @@ UIBottomSheet(children)
777
993
  // Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
778
994
  // automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
779
995
  // Attaches itself to Presentable.current (hides with the page it opened on).
780
- UIPopover(children)
996
+ UIPopover(...children)
781
997
  // .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
782
998
  // .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
783
999
  // Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
784
1000
 
785
1001
  // UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
786
- UIScrollable(children)
1002
+ UIScrollable(...children)
787
1003
  // extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
788
1004
  // overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
789
1005
  // keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
1006
+ // snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
1007
+ // direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
1008
+ // the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
790
1009
  // .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
791
1010
  // .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
792
1011
  // element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
793
1012
  // Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
794
1013
  // Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
795
1014
  // themselves. flexGrow: 1 to fill the remaining space is still yours to set.
796
- // Carousel = scrollDirection: "horizontal" + FIXED-width cards (explicit width on each card, gap/px on the scrollable)
1015
+ // Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
797
1016
 
798
1017
  // UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
799
1018
  // visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
800
1019
  // is large, grows over time, or is unknown.
801
1020
  UIVirtualizedList<T>({
802
1021
  keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
803
- render: (item: T) => UIElement, // builds one row; called lazily as rows enter the window
1022
+ render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
804
1023
  estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
805
1024
  overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
806
1025
  inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
@@ -849,12 +1068,13 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
849
1068
 
850
1069
  // UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
851
1070
  // To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
852
- UIButton(children?)
853
- // .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
854
- // extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
1071
+ UIButton(...children)
1072
+ // .onClick(cb), .onTouchStart(cb), .isPressed (getter) // gestures: see TOUCH GESTURES above
1073
+ // .onMouseEnter(ev => ev.track({ onMove?, onEnd?, onCancel? })), .isHovered (getter) // mouse only; no leave event — onEnd is the leave
1074
+ // extra styles: $pressed: { bgColor, opacity, ..., duration? } — style while the finger is down (reserved class);
855
1075
  // rippleColor ("default" or a Color, Android only)
856
1076
  // Note: press feedback is OPT-IN — nothing happens visually unless you set it. rippleColor replaces the
857
- // onPressed visual on Android, so rippleColor + onPressed = ripple on Android, onPressed dim on iOS.
1077
+ // $pressed visual on Android, so rippleColor + $pressed = ripple on Android, $pressed dim on iOS.
858
1078
  // Note: buttons render no chrome of their own — style bgColor/borderRadius/padding yourself, and give a
859
1079
  // button an explicit height (on its own it is only as tall as its text). flexDirection: "column" for cards.
860
1080
 
@@ -868,7 +1088,7 @@ UIInput() / UITextArea()
868
1088
  // maxLength: number; autocapitalize: "none"|"words"|"sentences"|"characters"; autocorrect: boolean
869
1089
  // keyboardShrink: boolean (default true) — false: keyboard OVERLAYS the UI, no relayout (chat composers)
870
1090
  // keyboardDismiss: boolean (default true) — false: taps never dismiss the keyboard (chat composers)
871
- // onFocused: { borderColor, ..., duration? } — style while focused (like onPressed)
1091
+ // $focused: { borderColor, ..., duration? } — style while focused (reserved class)
872
1092
  // Note: type values are KEYBOARD HINTS, not validators — "number" doesn't block pasted letters. The
873
1093
  // phone-pad value is "phone" (there is NO "tel").
874
1094
  // Note: "date"/"time" open the native PICKER in the keyboard slot (typing disabled). .value and onChange stay
@@ -888,7 +1108,7 @@ UIInput() / UITextArea()
888
1108
 
889
1109
  // UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
890
1110
  // Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
891
- UIRow([ title, UISpacer(), closeButton ])
1111
+ UIRow(title, UISpacer(), closeButton)
892
1112
  // If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
893
1113
 
894
1114
  // @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
@@ -903,16 +1123,20 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
903
1123
  // 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
904
1124
  // 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
905
1125
  // e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
906
- // 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
1126
+ // 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
907
1127
  // Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
908
1128
  // the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
1129
+ // loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
1130
+ // (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
1131
+ // animation is an effect, not a state change: it never commits, delay applies once, and it stops on
1132
+ // the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
909
1133
  // 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
910
1134
  // Never modifies the stored style.
911
1135
  // For free-value tweens use animate() — see ANIMATION above.
912
1136
  // Older code may pass a style object as the FIRST factory argument — legacy; write new styles with .style().
913
1137
 
914
1138
  // Mutable CONTENT properties are NOT styles — they live on the element:
915
- // UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed()
1139
+ // UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed (read-only)
916
1140
  myText.text = "Updated" // re-renders immediately
917
1141
 
918
1142
  // Style<T> is a global helper type for reusable style objects, no import needed:
@@ -950,9 +1174,8 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
950
1174
  // but do NOT support % ("calc(100% - 20px)" is invalid; use "calc(100vw - 20px)")
951
1175
 
952
1176
  // --- Colors in UI styles ---
953
- // hex "#f33"/"#f33c"/"#ff3333"/"#ff3333cc", packed int 0xff3333, "rgb(...)"/"rgba(...)", and exactly these names:
954
- // white black red green blue yellow orange purple gray cyan magenta brown transparent clear
955
- // (UI styles only — engine APIs are hex/packed-int only, see COLORS above)
1177
+ // any CSS color (hex, rgb()/hsl(), the CSS names — green = #008000, transparent/clear), 0xRRGGBB, [r,g,b(,a)],
1178
+ // or "var(--x)" — the same colors as every engine API (see COLORS above); a non-color throws
956
1179
 
957
1180
  // --- Safe areas & comfort ---
958
1181
  // Env keywords resolving to the device insets (notch, home indicator):
@@ -978,14 +1201,30 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
978
1201
  // Bare comfort-* tokens apply the safe-area formula; "var(--comfort-top)" reads the raw knob.
979
1202
  // "var(--name, fallback)" applies the fallback while the key is unset. Env names (safe-*, vw/vh) are not theme keys.
980
1203
 
981
- // --- Responsive: onLandscape / onPortrait ---
982
- // Any style can nest orientation overrides, merged on top while the device is in that orientation:
983
- .style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
984
- // Keys: onLandscape, onPortrait.
1204
+ // --- Style classes ($name) — the ONLY state mechanism; they CASCADE ---
1205
+ // A $-prefixed key in .style() declares a style state (yours: selected, checked, expanded);
1206
+ // duration/delay/easing inside the block animate the swap. Drive yours via el.class:
1207
+ const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
1208
+ item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
1209
+ // el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
1210
+ // form; names work with or without the $. Class state persists across screen close/reopen.
1211
+ // CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
1212
+ // one toggle restyles a whole composite control, each part declaring its own reaction:
1213
+ UIButton(
1214
+ UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
1215
+ UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
1216
+ ) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
1217
+ // RESERVED — the system toggles them, el.class.pressed = … throws:
1218
+ // $hovered / $pressed / $focused (mouse over / finger down / input focused) cascade to children but STOP at
1219
+ // a nested UIButton — a button inside a pressed card is not pressed;
1220
+ // $landscape / $portrait — GLOBAL, from the display size (the swap on rotation is instant).
1221
+ UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $hovered: { opacity: 0.9 }, $pressed: { transform: "scale(0.97)" } })
1222
+ screen.style({ flexDirection: "column", p: 16, $landscape: { flexDirection: "row", p: 32 } })
1223
+ // Precedence on the same prop: base < $landscape/$portrait < $classes < $hovered < $pressed < $focused.
985
1224
 
986
1225
  // --- Defaults that surprise ---
987
- // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and UIVirtualizedList default
988
- // flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
1226
+ // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
1227
+ // default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
989
1228
  // flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
990
1229
  // alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
991
1230
  // overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
@@ -1002,10 +1241,10 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
1002
1241
  // ===== REUSABLE COMPONENTS =====
1003
1242
  // Extract repeated UI into factory functions — they return elements you can chain methods on:
1004
1243
 
1005
- const ClickableCard = (title: string, subtitle: string) => UIButton([
1244
+ const ClickableCard = (title: string, subtitle: string) => UIButton(
1006
1245
  UIText(title).style({ fontWeight: 700, color: "white" }),
1007
1246
  UIText(subtitle).style({ color: "#888", fontSize: 13 })
1008
- ]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
1247
+ ).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
1009
1248
 
1010
1249
  // Use like any other element — chain after the call:
1011
1250
  ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
@@ -1014,15 +1253,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
1014
1253
  const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
1015
1254
 
1016
1255
  // ===== CAPTURING ELEMENT REFERENCES =====
1017
- // Use an assignment expression inside the children array — standard TypeScript:
1256
+ // Use an assignment expression right in the children — standard TypeScript:
1018
1257
 
1019
1258
  let label: UIText
1020
1259
  let input: UIInput
1021
1260
 
1022
- UIColumn([
1261
+ UIColumn(
1023
1262
  label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
1024
1263
  input = UIInput(),
1025
- ])
1264
+ )
1026
1265
 
1027
1266
  // Later:
1028
1267
  label.text = "Updated"
@@ -1032,15 +1271,17 @@ input.value // read current value
1032
1271
  // ((button.children[0] as UIText).text vs buttonText.text).
1033
1272
 
1034
1273
  // ===== CONDITIONAL CHILDREN =====
1035
- // null / undefined / false in a children array is skipped — no element, no layout slot.
1036
- UIColumn([ header, isLoading ? spinner : null, showFooter && footer ])
1274
+ // A null / undefined / false child is skipped — no element, no layout slot.
1275
+ UIColumn(header, isLoading ? spinner : null, showFooter && footer)
1276
+ // Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
1277
+ UIColumn(header, showList && items.map(Row))
1037
1278
 
1038
1279
  // ===== SIZING: the two axes behave differently =====
1039
1280
  // MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
1040
1281
  // without flexGrow: 1 (no implicit min-sizes).
1041
1282
  // CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
1042
1283
  // or alignSelf to opt out.
1043
- // So UIRow([ UIInput() ]) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
1284
+ // So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
1044
1285
  // EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
1045
1286
  // equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
1046
1287
  // longer label stays wider.
@@ -1060,6 +1301,7 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
1060
1301
  // Position once at an interaction — never poll per frame.
1061
1302
 
1062
1303
  // ===== ROUTER (multi-page apps) =====
1304
+ // Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
1063
1305
  Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
1064
1306
  Router.push(screen) // push onto the stack, screen becomes active
1065
1307
  Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
@@ -1101,6 +1343,14 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
1101
1343
  UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
1102
1344
  // Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
1103
1345
 
1346
+ // ===== ICONS (assetIcon) =====
1347
+ // Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
1348
+ // inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
1349
+ UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
1350
+ // The id must be a string literal; an unknown id is a compile error (with name suggestions).
1351
+ // Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
1352
+ // Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
1353
+
1104
1354
  // ===== COMMON MISTAKES — DO NOT DO THESE =====
1105
1355
 
1106
1356
  // ❌ CSS that doesn't exist here
@@ -1109,16 +1359,13 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
1109
1359
  lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
1110
1360
 
1111
1361
  // ❌ touch handler on a non-touch element
1112
- UIColumn([...]).onTouchStart(...) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
1362
+ UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
1113
1363
 
1114
- // ❌ scrollable that overflows the screen
1115
- // A wrapping container between the scrollable and the screen is missing flexShrink: 1
1116
-
1117
- // ❌ let/const inside children array — `let` is a statement, not an expression
1118
- UIRow([ let input = UIInput() ]) // WRONG
1364
+ // ❌ let/const among the children — `let` is a statement, not an expression
1365
+ UIRow(let input = UIInput()) // WRONG
1119
1366
  // ✅ declare outside, assign inside (assignment both sets the var AND appends)
1120
1367
  let input: UIInput
1121
- UIRow([ input = UIInput() ])
1368
+ UIRow(input = UIInput())
1122
1369
 
1123
1370
  // ❌ setting a UIImage's content through bgImage
1124
1371
  const img = UIImage("") // WRONG — empty source as a placeholder
@@ -1128,30 +1375,30 @@ const img = UIImage(url)
1128
1375
  img.src = newUrl // updates the displayed image
1129
1376
 
1130
1377
  // ❌ expecting an input to fill width like in CSS
1131
- UIRow([ UIInput() ]) // WRONG — collapses to placeholder width
1378
+ UIRow(UIInput()) // WRONG — collapses to placeholder width
1132
1379
  // ✅ stretch it explicitly
1133
- UIColumn([ UIInput().style({ width: "100%" }) ]) // cross axis
1134
- UIRow([ input = UIInput().style({ flexGrow: 1 }), sendBtn ]) // main axis
1380
+ UIColumn(UIInput().style({ width: "100%" })) // cross axis
1381
+ UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
1135
1382
 
1136
1383
  // ❌ a button that should match the input's height but shrinks to its text
1137
- UIRow([ input.style({ height: 40 }), UIButton([...]) ]) // button ends up shorter
1384
+ UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
1138
1385
  // ✅ give controls the same explicit height
1139
- UIRow([ input.style({ height: 40 }), UIButton([...]).style({ height: 40 }) ])
1386
+ UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
1140
1387
 
1141
1388
  // ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
1142
- UIRow([ yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 }) ]) // longer label = wider button
1389
+ UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
1143
1390
  // ✅ flex: 1 — grows from a zero basis, children end up equal
1144
- UIRow([ yes.style({ flex: 1 }), no.style({ flex: 1 }) ])
1391
+ UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
1145
1392
 
1146
1393
  // ❌ empty containers as spacers to align children (web habit)
1147
- UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
1394
+ UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
1148
1395
  // ✅ alignment is a CONTAINER property, not an extra element
1149
- UIRow([ label ]).style({ justifyContent: "flex-end" })
1396
+ UIRow(label).style({ justifyContent: "flex-end" })
1150
1397
 
1151
1398
  // ❌ empty element as a placeholder for a conditional child
1152
- UIRow([ isGroup ? button : UIColumn([]) ]) // WRONG
1399
+ UIRow(isGroup ? button : UIColumn()) // WRONG
1153
1400
  // ✅ null is skipped in children — no phantom element
1154
- UIRow([ isGroup ? button : null ])
1401
+ UIRow(isGroup ? button : null)
1155
1402
 
1156
1403
  // ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
1157
1404
  UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
@@ -1165,7 +1412,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
1165
1412
  list.onEndReached(600, loadNextPage)
1166
1413
 
1167
1414
  // ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
1168
- const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new widget every call
1415
+ const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
1169
1416
  // ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
1170
1417
 
1171
1418
  // ===== EXAMPLES =====
@@ -1173,7 +1420,7 @@ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new wid
1173
1420
  // === EXAMPLE 1: Single-file app ===
1174
1421
  <file name="main.ts">
1175
1422
  let text: UIText
1176
- const screen = UIScreen([
1423
+ const screen = UIScreen(
1177
1424
  text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
1178
1425
  UIText("Your name:").style({ textAlign: "center" }),
1179
1426
  UIInput()
@@ -1181,91 +1428,188 @@ const screen = UIScreen([
1181
1428
  .onChange(str => {
1182
1429
  text.text = `Hello, ${str}!`
1183
1430
  })
1184
- ]).style({ justifyContent: "center", p: 20, gap: 8 })
1431
+ ).style({ justifyContent: "center", p: 20, gap: 8 })
1185
1432
 
1186
1433
  screen.open()
1187
1434
  </file>
1188
1435
 
1189
- // === EXAMPLE 2: Fetching data with loading state ===
1436
+ // === 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.
1439
+ <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
+ // 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)
1448
+ export const font = { // type scale — spread into styles: .style({ ...font.h2 })
1449
+ h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
1450
+ small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
1451
+ }
1452
+ </file>
1190
1453
  <file name="main.ts">
1454
+ import { colors, font } from './tokens'
1455
+
1191
1456
  type User = { id: number; name: string; email: string }
1192
1457
 
1193
- let statusText: UIText
1194
- let list: UIColumn
1458
+ const Row = (u: User) => UIRow(
1459
+ UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
1460
+ .style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
1461
+ justifyContent: "center", alignItems: "center" }),
1462
+ UIColumn(
1463
+ UIText(u.name).style({ ...font.bodyStrong }),
1464
+ UIText(u.email).style({ ...font.small, color: colors.muted }),
1465
+ ).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
1466
+ ).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
1467
+ border: `1px solid ${colors.border}`, p: 14 })
1195
1468
 
1196
- const loadData = async () => {
1197
- statusText.text = "Loading..."
1469
+ const Centered = (...children: UINodeChild[]) =>
1470
+ UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
1471
+
1472
+ let body: UIColumn
1198
1473
 
1474
+ const loadData = async () => {
1475
+ body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
1199
1476
  const res = await fetch("https://jsonplaceholder.typicode.com/users")
1200
1477
  if (res.status !== 200) {
1201
- statusText.text = "Error loading data"
1478
+ body.setContent([Centered(
1479
+ UIText("Couldn't load users"),
1480
+ UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
1481
+ .style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
1482
+ .onClick(() => loadData()),
1483
+ )])
1202
1484
  return
1203
1485
  }
1204
-
1205
- const users = res.json<User[]>() // sync — no await
1206
-
1207
- statusText.text = ""
1208
- list.setContent(
1209
- users.map(user =>
1210
- UIColumn([
1211
- UIText(user.name).style({ fontWeight: 700, color: "white" }),
1212
- UIText(user.email).style({ color: "#888", fontSize: 13 })
1213
- ]).style({ px: 16, py: 12, gap: 4 })
1214
- )
1215
- )
1486
+ body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
1216
1487
  }
1217
1488
 
1218
- const screen = UIScreen([
1219
- UIText("Users").style({ fontSize: 24, fontWeight: 700, color: "white", mb: 8 }),
1220
- statusText = UIText("").style({ color: "#888" }),
1221
- UIScrollable([
1222
- list = UIColumn([])
1223
- ]).style({ flexGrow: 1 }) // the ONE scrolling body — the screen itself never scrolls
1224
- .onRefresh(() => loadData()) // pull-to-refresh; attached before screen.open()
1225
- ])
1226
- .style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
1227
- .onOpen(() => loadData())
1489
+ const screen = UIScreen(
1490
+ UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
1491
+ UIScrollable(
1492
+ body = UIColumn().style({ flexGrow: 1, gap: 8 })
1493
+ ).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
1494
+ .onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
1495
+ ).style({ bgColor: colors.bg, pt: "comfort-top" })
1496
+ .onOpen(() => loadData())
1228
1497
 
1229
1498
  screen.open()
1230
1499
  </file>
1231
1500
 
1232
- // === EXAMPLE 3: Simple app with navigation ===
1501
+ // === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
1233
1502
  <file name="home.ts">
1234
- import { detailScreen } from './detail'
1235
-
1236
- export const homeScreen = UIScreen([
1237
- UIText("Home").style({ fontSize: 24, fontWeight: 700 }),
1238
- UIButton([
1239
- UIText("Go to detail").style({ color: "white" })
1240
- ]).style({ bgColor: "#FF4032", borderRadius: 12, p: 16, rippleColor: "default", onPressed: { opacity: 0.7 } })
1241
- .onClick(() => Router.push(detailScreen))
1242
- ]).style({ p: 20, pt: "max(safe-top, 24px)", gap: 16 })
1503
+ const detailScreen = (name: string) => UIScreen(
1504
+ UIButton(
1505
+ UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
1506
+ UIText("Back")
1507
+ ).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
1508
+ UIText(name).style({ fontSize: 24, fontWeight: 700 }),
1509
+ UIButton(
1510
+ UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
1511
+ UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
1512
+ ).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
1513
+ .onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
1514
+ ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
1515
+
1516
+ const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
1517
+ .style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", $pressed: { opacity: 0.7 } })
1518
+ .onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
1519
+
1520
+ export const homeScreen = UIScreen(
1521
+ UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
1522
+ UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
1523
+ ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
1243
1524
  </file>
1244
- <file name="detail.ts">
1245
- import chevronLeft from './assets/chevron-left.svg'
1246
-
1247
- export const detailScreen = UIScreen([
1248
- UIButton([
1249
- UIImage(chevronLeft).style({ width: 18, height: 18, tintColor: "#FF4032" }),
1250
- UIText("Back").style({ color: "#FF4032" })
1251
- ]).style({ alignSelf: "flex-start", flexDirection: "row", height: 32, gap: 4 }).onClick(() => Router.pop()),
1252
- UIText("Detail Screen").style({ fontSize: 24, fontWeight: 700 })
1253
- ])
1254
- .style({ p: 20, pt: "max(safe-top, 24px)", gap: 20 })
1255
- .onBackPressed(() => Router.pop())
1256
- </file>
1257
- <file name="assets/chevron-left.svg">
1258
- <svg viewBox="0 0 24 24">
1259
- <path d="M15 18l-6-6 6-6" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1260
- </svg>
1525
+ <file name="profile.ts">
1526
+ export const profileScreen = UIScreen(
1527
+ UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
1528
+ ).style({ p: 16, pt: "comfort-top", bgColor: "black" })
1261
1529
  </file>
1262
1530
  <file name="main.ts">
1263
1531
  import { homeScreen } from './home'
1532
+ import { profileScreen } from './profile'
1533
+
1534
+ theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
1264
1535
 
1265
- Router.init(homeScreen)
1536
+ const tabs = UITabs({
1537
+ home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
1538
+ profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
1539
+ })
1540
+ tabs.badge("profile", true) // notification dot on the tab
1541
+ Router.init(tabs)
1266
1542
  </file>
1267
1543
 
1268
- // === EXAMPLE 4: UIBottomSheet — persistent map-style sheet with detents ===
1544
+ // === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
1545
+ // The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
1546
+ // view and handles dismissal itself.
1547
+ <file name="main.ts">
1548
+ // Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
1549
+ // appearing error never jumps the form). Typing in an errored field clears it.
1550
+ const Field = (label: string, style: Style<UIInput> = {}) => {
1551
+ const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
1552
+ border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
1553
+ $focused: { borderColor: "#4C8DFF" }, ...style })
1554
+ const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
1555
+ input.onChange(() => message.text = "")
1556
+ return {
1557
+ node: UIColumn(
1558
+ UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
1559
+ input, message,
1560
+ ).style({ gap: 6 }),
1561
+ input,
1562
+ error: (text: string) => { message.text = text },
1563
+ }
1564
+ }
1565
+
1566
+ const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
1567
+ const email = Field("Email", { type: "email", placeholder: "you@example.com" })
1568
+ const password = Field("Password", { type: "password" })
1569
+
1570
+ // The return key walks the form; the last field submits. This is ALL the keyboard code.
1571
+ name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
1572
+ email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
1573
+ password.input.style({ enterKey: "go" }).onSubmit(() => submit())
1574
+
1575
+ let btnLabel: UIText
1576
+ let busy = false
1577
+
1578
+ // The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
1579
+ // first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
1580
+ const submit = async () => {
1581
+ if (busy) return
1582
+ let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
1583
+ if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
1584
+ if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
1585
+ if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
1586
+ if (bad) { bad.input.focus(); return }
1587
+ busy = true
1588
+ btnLabel.text = "Creating…"
1589
+ const res = await fetch("https://api.example.com/register", {
1590
+ method: "POST", headers: { "Content-Type": "application/json" },
1591
+ body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
1592
+ })
1593
+ busy = false
1594
+ btnLabel.text = "Create account"
1595
+ if (res.status !== 200) { email.error("Registration failed — try again"); return }
1596
+ toast("Welcome!")
1597
+ }
1598
+
1599
+ const screen = UIScreen(
1600
+ UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
1601
+ UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
1602
+ name.node, email.node, password.node,
1603
+ UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
1604
+ .style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, $pressed: { opacity: 0.85 } })
1605
+ .onClick(() => submit())
1606
+ ).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
1607
+ ).style({ bgColor: "#0C0F14" })
1608
+
1609
+ screen.open()
1610
+ </file>
1611
+
1612
+ // === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
1269
1613
  <file name="main.ts">
1270
1614
  type Place = { id: number; name: string; distance: string }
1271
1615
  const places: Place[] = [
@@ -1274,32 +1618,32 @@ const places: Place[] = [
1274
1618
  { id: 3, name: "Riverside Park", distance: "1.2 km" },
1275
1619
  ]
1276
1620
 
1277
- const Row = (p: Place) => UIRow([
1621
+ const Row = (p: Place) => UIRow(
1278
1622
  UIText(p.name).style({ color: "#111", fontSize: 16 }),
1279
1623
  UIText(p.distance).style({ color: "#888", fontSize: 14 })
1280
- ]).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
1624
+ ).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
1281
1625
 
1282
- const sheet = UIBottomSheet([
1283
- UIColumn([]).style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
1626
+ const sheet = UIBottomSheet(
1627
+ UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
1284
1628
  UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
1285
1629
  UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
1286
- ])
1630
+ )
1287
1631
  .style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
1288
1632
  .detents([0.25, 0.6, 1]) // collapsed / half / full
1289
1633
  .dismissible(false) // drag below the lowest detent collapses, never closes
1290
- .onDetentChange(i => console.log("detent", i))
1634
+ .onDetent(i => console.log("detent", i))
1291
1635
 
1292
- const mapScreen = UIScreen([
1636
+ const mapScreen = UIScreen(
1293
1637
  // the map / page content behind the sheet
1294
- ]).onOpen(() => sheet.show()).onClose(() => sheet.hide())
1638
+ ).onOpen(() => sheet.show()).onClose(() => sheet.hide())
1295
1639
 
1296
1640
  mapScreen.open()
1297
1641
 
1298
- // An action sheet is even less: content-sized, no detents() — UIBottomSheet([ ...rows ]).show(),
1642
+ // An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
1299
1643
  // scrim and drag-down-to-dismiss included.
1300
1644
  </file>
1301
1645
 
1302
- // === EXAMPLE 5: UIVirtualizedList — chat (inverted, imperative append) ===
1646
+ // === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
1303
1647
  <file name="main.ts">
1304
1648
  type Msg = { id: string; text: string; mine: boolean }
1305
1649
 
@@ -1311,37 +1655,41 @@ const list = UIVirtualizedList<Msg>({
1311
1655
  keyOf: m => m.id,
1312
1656
  estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
1313
1657
  inverted: true, // newest at the bottom
1314
- render: m => UIRow([
1315
- UIRow([
1658
+ render: m => UIRow(
1659
+ UIRow(
1316
1660
  UIText(m.text).style({ color: m.mine ? "white" : "#111" })
1317
- ]).style({
1661
+ ).style({
1318
1662
  bgColor: m.mine ? "#FF4032" : "#EEE",
1319
1663
  px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
1320
1664
  })
1321
- ]).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
1665
+ ).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
1322
1666
  }).style({ flexGrow: 1 })
1323
1667
 
1324
- let input: UIInput
1668
+ let input: UITextArea
1669
+ let sendBtn: UIButton
1325
1670
 
1326
1671
  const send = () => {
1327
1672
  const text = input.value.trim()
1328
1673
  if (!text) return
1329
1674
  list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
1330
1675
  input.value = ""
1676
+ sendBtn.style.opacity = 0.4
1331
1677
  }
1332
1678
 
1333
- const screen = UIScreen([
1679
+ const screen = UIScreen(
1334
1680
  list,
1335
- UIRow([
1336
- input = UIInput().style({
1337
- flexGrow: 1, height: 40, px: 16, borderRadius: 20,
1338
- bgColor: "#F0F0F0", placeholder: "Message...", placeholderColor: "#999"
1681
+ UIRow(
1682
+ input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
1683
+ color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
1684
+ keyboardDismiss: false
1339
1685
  }),
1340
- UIButton([ UIText("Send").style({ color: "white" }) ])
1341
- .style({ height: 40, px: 16, justifyContent: "center", bgColor: "#FF4032", borderRadius: 20 })
1686
+ sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
1687
+ .style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
1342
1688
  .onClick(send)
1343
- ]).style({ p: 8, gap: 8, alignItems: "center" })
1344
- ]).style({ bgColor: "white" })
1689
+ ).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
1690
+ ).style({ bgColor: "white" })
1691
+
1692
+ input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
1345
1693
 
1346
1694
  screen.open()
1347
1695
  </file>