lecodes-sdk 1.2.0 → 2.0.1

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,32 +340,33 @@ 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
  # 2D engine
311
363
 
312
- Scene2D + nodes (Sprite, Tilemap) + aspects (SpriteAnimation, Shape2D/Physics2D/Trigger2D/CharacterController2D) + Canvas for baked text/vector graphics. All globals. `Scene2D` ≠ the 3D `Scene`; one scene active at a time. World units: 1 unit = 1 logical px at camera zoom 1. **Y-UP** (up = +y, gravity = negative y). Rotations in **degrees**, CCW. Game loop = setLoop(dt) from core. The UI kit renders ON TOP of the 2D canvas — build HUDs/menus/on-screen controls with UIScreen/UIWidget as usual (open the scene, then the HUD screen).
364
+ Scene2D + nodes (Sprite, Tilemap) + aspects (SpriteAnimation, Shape2D/Physics2D/Trigger2D/OneWay2D/CharacterController2D) + Canvas for baked text/vector graphics. All globals. `Scene2D` ≠ the 3D `Scene`; one scene active at a time. World units: 1 unit = 1 logical px at camera zoom 1. **Y-UP** (up = +y, gravity = negative y). Rotations in **degrees**, CCW. Game loop = setLoop(dt) from core. The UI kit renders ON TOP of the 2D canvas — build HUDs/menus/on-screen controls with UIScreen/UIWidget as usual (open the scene, then the HUD screen).
313
365
 
314
366
  // ===== SCENE & NODES =====
315
367
 
316
368
  new Scene2D(options?: {
317
- background?: Color // clear color (hex string or 0xRRGGBB); settable later via scene.background
369
+ background?: Color // clear color (any CSS color or 0xRRGGBB); settable later via scene.background
318
370
  filter?: 'nearest' | 'linear' // texture sampling; default nearest (crisp pixel art), 'linear' for hi-res
319
371
  }) // art. filter is GLOBAL (one sampler for all textures), re-applied on open()
320
372
 
@@ -338,7 +390,7 @@ scene.addEventListener('touchstart', ev => {}) // pointer-down; ev.track({...}
338
390
  // Bare Node2D: grouping parent, spawn marker, invisible collider/trigger. removeEventListener to detach.
339
391
  const node = new Node2D()
340
392
  node.x, node.y // single-axis setters — use for per-axis writes
341
- node.position = [120, 64] // Vec2Like; GETTER RETURNS A COPY — node.position.x = 3 is a silent no-op (core math)
393
+ node.position = [120, 64] // Vec2Like; getter returns a copy — node.position.x = 3 written directly still works (compiled to node.x)
342
394
  node.rotation = 45 // degrees CCW
343
395
  node.scale = 2 // number or [sx, sy] — DRAW scale only (physics shapes ignore it)
344
396
  node.layer = 1; node.z = 5; node.visible = false
@@ -372,7 +424,7 @@ new Sprite(options?: {
372
424
  // [0.5, 1] = bottom-center = "feet" — use on Y-sorted layers
373
425
  size?: Vec2Like // world size; default = texture pixel dims (1 texel = 1 world unit)
374
426
  frame?: [u0, v0, u1, v1] // normalized UV sub-rect
375
- color?: Color // tint MULTIPLIED with the texture; hex/packed only (default white = untinted)
427
+ color?: Color // tint MULTIPLIED with the texture (default white = untinted)
376
428
  opacity?: number // 0..1
377
429
  layer?: number
378
430
  position?: Vec2Like
@@ -438,9 +490,9 @@ new Tilemap(options: {
438
490
  // data row 0 = the TOP row of the map (reads like level text), even though world Y is up. Atlas indices
439
491
  // are row-major too: 0 = the atlas's top-left tile. node.position = the map's BOTTOM-LEFT corner.
440
492
  map.setTile(x, y, index): this // grid coords as in data (row 0 = top); -1 clears. Cheap.
441
- // LIMITS — a static uniform grid. NO animated tiles (swap cells with setTile yourself), NO per-tile collision
442
- // (build it from invisible Node2Ds with Shape2D boxes/segments + static Physics2D), NO per-tile flip/rotate
443
- // (bake variants into the atlas).
493
+ // LIMITS — a static uniform grid. NO animated tiles (swap cells with setTile yourself), NO per-tile flip/
494
+ // rotate (bake variants into the atlas), and NO collision: physics NEVER reads a tilemap, by design. Solid
495
+ // geometry is scene objects — Node2Ds with Shape2D box/polygon/chain + a static Physics2D.
444
496
 
445
497
  // ===== 2D PHYSICS (Box2D) =====
446
498
 
@@ -449,73 +501,95 @@ Physics2D.configure(config?: {
449
501
  pixelsPerMeter?: number // Box2D tolerance tuning (you still author in world units); default 64
450
502
  subSteps?: number // default 4
451
503
  })
452
- // Call ONCE, BEFORE creating any body — calling it again RESETS the world, orphaning existing bodies.
453
- // Fixed 60 Hz step; rendered transforms are interpolated.
454
- Physics2D.supported // static: some builds ship without physics — the family then silently no-ops (no
455
- // events, no picking). Guard physics-critical gameplay with this.
504
+ // OPTIONAL: the world builds itself on the first body. Calling it later changes gravity LIVE and leaves
505
+ // every existing body alone. Fixed 60 Hz step (max 4 sub-steps/frame); rendered transforms interpolated.
506
+ Physics2D.supported // some builds ship without physics — the family then silently no-ops. Guard with it.
456
507
 
457
508
  // --- Shape2D — pure geometry (node.shape). Does nothing alone. Attach BEFORE the body. ---
458
509
  node.aspect(Shape2D, {
459
510
  box?: [hw, hh] // HALF-extents: box:[12,20] = a 24×40 box
460
511
  circle?: number // radius
461
512
  capsule?: { from: Vec2Like, to: Vec2Like, radius: number } // between two node-local points
462
- segment?: { from: Vec2Like, to: Vec2Like } // thin edge — STATIC ground/walls/slopes only (never dynamic, never a sensor)
463
- polygon?: Vec2Like[] // convex, ≤ 8 local points (hull computed)
464
- offset?: Vec2Like // local offset from node origin (box/circle only)
513
+ segment?: { from: Vec2Like, to: Vec2Like } // one thin edge — STATIC only
514
+ polygon?: Vec2Like[] // CONVEX, ≤ 8 local points (a concave outline is silently replaced by its hull)
515
+ chain?: Vec2Like[] // polyline: long CONCAVE seam-free surfaces (terrain, cave walls). STATIC only.
516
+ loop?: boolean // chain: close the contour. flip?: put the solid side on the other side
517
+ origin?: Vec2Like // the collider's centre relative to the node — applies to EVERY kind
465
518
  })
466
- // Exactly one kind; with NONE, an auto box derives from the sprite's size (16×16 fallback on bare Node2D).
467
- // Shapes NEVER read node.scale — extents are raw world units. With anchor [0.5,1] (feet) the origin is at
468
- // the feet — offset the box UP: { box: [12, 8], offset: [0, 8] }.
519
+ // PREFER `{}`: it measures the rect the node actually DRAWS (sprite size × scale, positioned by anchor),
520
+ // so a sprite anchored at its feet gets a collider around the art with no hand-computed numbers.
521
+ // Re-configuring rebuilds the fixture IN PLACE (same body, same id, same velocity) — that is how a crouch
522
+ // works. A chain is ONE-SIDED: solid on the RIGHT of the point order, so left-to-right ground needs flip.
469
523
 
470
524
  // --- Physics2D — rigid body (node.physics). Requires a Shape2D on the node (throws otherwise). ---
471
525
  node.aspect(Physics2D, {
472
526
  motion?: 'static' | 'kinematic' | 'dynamic' // default 'dynamic'
473
- density?: number // default 1. restitution?: bounciness, default 0
474
- friction?: number // default 0.3 (0 = slide clean along walls — good for player characters)
475
- fixedRotation?: boolean // lock rotation — set true for characters; default false
476
- bullet?: boolean // continuous collision for fast movers. gravityScale?: per-body multiplier, default 1
527
+ mass?: number // omitted = from the collider's AREA (a big crate really is heavier)
528
+ friction?: number // default 0.6; two bodies combine as sqrt(a*b) — the LOWER value wins
529
+ bounce?: number // restitution 0..1, default 0; two bodies combine as MAX — the bouncier wins
530
+ fixedRotation?: boolean // lock rotation; default false. bullet?: continuous collision for fast movers
531
+ gravityScale?: number // per-body multiplier, default 1. linearDamping? / angularDamping?
532
+ group?: PhysicsGroup2D // collision filtering, see below
477
533
  })
478
-
479
- node.physics.velocity = [vx, vy] // world units/s (getter returns a fresh Vec2)
480
- node.physics.angularVelocity = 90 // degrees/s (write-only); .gravityScale = 0 → floats
481
- node.physics.applyImpulse([x, y]): this // instantaneous kick (jumps, knockback)
482
- node.physics.applyForce([x, y]): this // continuous push
483
- node.physics.moveTo(p, deg = 0): this // teleport / drive a KINEMATIC body to a world pose
484
-
485
- // PHYSICS OWNS THE TRANSFORM of dynamic/kinematic bodies: the native step writes node position/rotation.
486
- // NEVER set node.position per frame on a body — drive dynamic with velocity/applyImpulse, kinematic with
487
- // moveTo. READING node.position / worldPosition is always correct (auto re-sync). Set the spawn position
488
- // BEFORE attaching Physics2D (the body seeds from it). Keep bodies on ROOT-level nodes (a body under a
489
- // moving parent fights the step). Dynamic bodies keep momentum: zero velocity to stop.
490
-
491
- // --- Trigger2D — static sensor zone (node.trigger). Fires enter/exit, blocks nothing. ---
492
- const goal = new Node2D()
493
- goal.position = [320, 64] // set BEFORE attaching — a static body doesn't follow later
494
- goal.aspect(Shape2D, { circle: 48 }) // node moves (reposition with goal.trigger.moveTo(p))
495
- .aspect(Trigger2D) // no options
496
- goal.addEventListener('enter', other => {}) // other = the Node2D that entered; 'exit' when it leaves
497
- // enter/exit also fire for SOLID contacts (hero touches a wall), delivered to BOTH nodes.
498
-
499
- // --- CharacterController2D — platformer helper (node.controller) ---
500
- node.aspect(CharacterController2D, {
501
- speed?: number // horizontal, world units/s; default 200. jumpSpeed?: take-off speed; default 500
502
- groundProbe?: number // extra grounded-ray below the feet; default 6
503
- footOffset?: number // node origin → feet; default = half the sprite height
504
- })
505
- node.controller.move(dir: number) // horizontal intent in [-1, 1]; STICKY — call move(0) to stop
506
- node.controller.jump() // queued; consumed next frame if grounded
507
- node.controller.grounded // true while standing on something
508
- // Sets horizontal velocity to dir*speed each frame; Box2D owns the vertical (gravity/jump/landing).
509
- // Auto-adds Shape2D (box from the sprite) + Physics2D { dynamic, fixedRotation } if missing.
510
- // Drive it from setLoop: hero.controller.move(dx); if (Input.key('Space')) hero.controller.jump()
511
-
512
- // Queries (static):
513
- Physics2D.raycast(from, to): { node: Node2D | null, point: Vec2, normal: Vec2, fraction: number } | null // closest hit
514
- Physics2D.overlapPoint(p: Vec2Like): Node2D | null // topmost body (solid or sensor) containing the point
515
-
516
- // Pointer picking: a node with Shape2D + (Physics2D or Trigger2D) is tappable — it receives 'click'/
517
- // 'touchstart'. The scene ALSO gets every event (ev.target, ev.worldX/worldY). Without physics:
518
- // scene.pick(camera.screenToWorld(ev.clientX, ev.clientY)).
534
+ // Everything above is LIVE: node.physics.motion = 'static' freezes a crate, .friction / .bounce / .mass /
535
+ // .group / .enabled / .awake all apply immediately.
536
+ node.physics.velocity = [vx, vy] // world units/s (getter returns a fresh Vec2)
537
+ node.physics.angularVelocity = 90 // degrees/s, rw
538
+ node.physics.applyImpulse([x, y]) // at the centre of mass — NEVER spins the body
539
+ node.physics.applyImpulseAt(v, worldPoint) // …the lever arm becomes spin (a bullet's hit point)
540
+ node.physics.applyForce([x, y])
541
+ // PHYSICS OWNS THE TRANSFORM of dynamic/kinematic bodies. Never set node.position per frame on one —
542
+ // drive dynamic with velocity/applyImpulse. A position/rotation WRITE is a teleport that reaches the
543
+ // body (there is no moveTo any more); READING node.position is always correct. Keep bodies and sensors
544
+ // on ROOT nodes — one parented to a moving node does not follow it.
545
+
546
+ // --- Trigger2D — sensor zone (node.trigger). Fires enter/exit, blocks nothing. ---
547
+ goal.aspect(Shape2D, { circle: 48 }).aspect(Trigger2D) // { group? }
548
+ goal.position = [x, y] // the zone FOLLOWS the node (it is kinematic)
549
+ goal.trigger.enabled = false // switch it off instead of parking it off-screen
550
+ goal.addEventListener('enter', (other, contact) => {}) // contact: { point, normal, speed }
551
+ // enter/exit also fire for SOLID contacts, delivered to BOTH nodes; the normal points back at YOU, so
552
+ // normal.y > 0.7 reads as "I landed on top of it". Adding the listener is what ENABLES contact events on
553
+ // that body — without one nothing is reported and nothing is paid. Characters ARE detected by triggers.
554
+
555
+ // --- OneWay2D — one-way surface (node.oneWay): the semisolid ledge of a platformer ---
556
+ ledge.aspect(Shape2D, {}).aspect(Physics2D, { motion: 'static' }).aspect(OneWay2D)
557
+ // { normal?: Vec2Like (which side is solid, default [0,1]), arc?: degrees (default 90), enabled? }
558
+ // Jump up through it, stand on top. Works for characters AND ordinary bodies, no per-frame cost.
559
+
560
+ // --- Collision groups: SUBTRACTIVE and SYMMETRIC ---
561
+ const player = Physics2D.addGroup()
562
+ const bullets = Physics2D.addGroup().ignoreSelf().ignore(player) // 30 groups max
563
+ // A group collides with everything EXCEPT what it ignores, so adding a new group later can't silently
564
+ // stop existing pairs colliding. ignore() is symmetric (name it once) and works after bodies exist.
565
+
566
+ // --- CharacterController2D — KINEMATIC collide-and-slide mover (node.controller) ---
567
+ node.aspect(CharacterController2D, { gravityScale?: 1, maxSlope?: 45, group?: PhysicsGroup2D })
568
+ // Needs a CAPSULE Shape2D (derived from the sprite if absent). Does NOT use Physics2D. Zero JS per frame.
569
+ c.move(x) // HORIZONTAL command, world units/s — a VELOCITY, not a displacement (never * dt).
570
+ // It EXPIRES each frame: no call = standing still, releasing keys needs no zero.
571
+ c.move(x, y) // FREE MODE (gravityScale 0): both axes. Top-down / swimming / a ladder.
572
+ c.velocityY = 700 // LATCHED ballistic vertical. No ground check — do coyote time yourself.
573
+ c.velocity // read = what the SOLVER did (into a wall reads ~0); write = latch the whole vector
574
+ c.grounded / c.groundState ('ground'|'slope'|'air') / c.groundNormal / c.groundNode
575
+ c.collisions // [{ node, normal }] from the last step — wall jumps, pushing crates
576
+ c.dropThrough = platform // ignore that node's OneWay2D surfaces; a plain latch, you clear it
577
+ c.resizing // a requested Shape2D resize didn't fit (standing up under a beam)
578
+ // PLATFORMER vs TOP-DOWN is gravityScale, not two controllers. A kinematic platform carries the
579
+ // character automatically. Crouch by re-configuring Shape2D with a shorter capsule; a refusal changes
580
+ // nothing, so just call it again next frame. Solid contacts arrive in c.collisions, not as 'enter'.
581
+
582
+ // Queries (static). q = { groups?: PhysicsGroup2D[], ignore?: Node2D | Node2D[] }
583
+ Physics2D.raycast(from, to, q?) // closest → { node, point, normal, fraction } | null
584
+ Physics2D.raycastAll(from, to, q?) // every hit, NEAREST FIRST (max 32)
585
+ Physics2D.overlapPoint(p, q?) // topmost by DRAW order (layer, then z)
586
+ Physics2D.overlapCircle(centre, r, q?) / overlapBox(centre, half, q?) / overlapCapsule(from, to, r, q?)
587
+ // `ignore` is the don't-hit-myself case. overlapCapsule is the DIRECTED shape (a beam with width, a sword
588
+ // arc) and doubles as the SWEPT test for a moving circle — it can't tunnel through a thin wall.
589
+
590
+ // Pointer picking: a node with Shape2D + (Physics2D | Trigger2D | CharacterController2D) is tappable —
591
+ // it receives 'click'/'touchstart'. The scene ALSO gets every event (ev.target, ev.worldX/worldY).
592
+ // Without physics: scene.pick(camera.screenToWorld(ev.clientX, ev.clientY)).
519
593
 
520
594
  // ===== CANVAS IN 2D =====
521
595
  // The Canvas drawing surface (see the CANVAS section above) is how TEXT and vector graphics get
@@ -536,7 +610,7 @@ sprite.aspect(Shape2D, { box: [12, 20] }) // ✅ declare the extents you want
536
610
  // ❌ Physics2D.configure() after bodies exist — resets the world, orphans every body
537
611
  // ✅ configure once at startup, before the first Shape2D/Physics2D/Trigger2D attach
538
612
 
539
- // ❌ node.position.x = 3 — getter returns a copy, silent no-op (see core). ✅ node.x = 3
613
+ // node.position.x = 3 compiles to node.x = 3 (direct spelling only). ❌ const p = node.position; p.x = 3 — a stored copy, no-op
540
614
 
541
615
  // ❌ default [0.5,0.5] anchor on a Y-sorted layer — depth sorts wrong. ✅ anchor: [0.5, 1] (feet)
542
616
 
@@ -546,10 +620,6 @@ sprite.aspect(Shape2D, { box: [12, 20] }) // ✅ declare the extents you want
546
620
  setLoop(() => sprite.setFramePx(...)) // WRONG
547
621
  sprite.aspect(SpriteAnimation, { size: [32, 48], clips: { walk: [0,1,2,3] } }) // ✅ then anim.play('walk')
548
622
 
549
- // ❌ CSS color strings on sprites — engine colors are hex/packed only (Canvas fillStyle is the exception)
550
- sprite.color = 'rgba(255,0,0,0.5)' // WRONG
551
- sprite.color = '#ff0000'; sprite.opacity = 0.5 // ✅
552
-
553
623
  // ❌ attaching SpriteAnimation before the sprite has a texture — throws (the grid slices from it)
554
624
  // ❌ attaching Physics2D before Shape2D on the same node — throws (attach the shape first)
555
625
  // ❌ setting spawn position AFTER attaching Physics2D — the body already seeded from [0,0]; position first
@@ -603,9 +673,9 @@ async function main() {
603
673
 
604
674
  // HUD on top of the 2D canvas (UI kit)
605
675
  let scoreText: UIText
606
- const hud = UIScreen([
676
+ const hud = UIScreen(
607
677
  scoreText = UIText("Coins: 0").style({ color: "white", fontSize: 18, fontWeight: 700 }),
608
- ]).style({ p: 16, pt: "max(safe-top, 16px)" })
678
+ ).style({ p: 16, pt: "max(safe-top, 16px)" })
609
679
  hud.open()
610
680
 
611
681
  // Coin pickups: spinning sprite + a Trigger2D sensor — 'enter' fires on overlap, blocks nothing
@@ -689,32 +759,64 @@ scene.addEventListener('touchstart', ev => {
689
759
 
690
760
  // ===== UI COMPONENTS =====
691
761
  // Every element is created by a global factory function (never `new`) that takes only the element's
692
- // CONTENT (children array, text, src, ...). Everything else — styles, handlers — is configured by chaining:
693
- // every configuring method returns the element itself, so construction reads as one chain.
762
+ // CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
763
+ // configured by chaining: every configuring method returns the element itself, so construction reads
764
+ // as one chain.
765
+ UIColumn(UIText("Title"), UIButton(UIText("Go")))
766
+ // An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
767
+ UIColumn(header, items.map(Row), footer)
694
768
 
695
769
  // UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
696
- UIRow(children) / UIColumn(children)
770
+ UIRow(...children) / UIColumn(...children)
697
771
  // Child management — imperative, no diffing; works before and after the element is on screen:
698
772
  // .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
699
773
  // .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
700
774
  // For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
701
775
 
702
776
  // UIScreen — root screen, always fills the device. Behaves as a UIColumn.
703
- UIScreen(children)
777
+ UIScreen(...children)
704
778
  // .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
705
779
  // .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
706
780
  // popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
707
781
  // in onClose (see TIMERS & FRAME LOOP above).
708
782
  // .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
709
- // .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
783
+ // .onBack(cb) — Android hardware/gesture back; typically Router.pop()
710
784
  // Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
711
- // with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
785
+ // with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
712
786
  // Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
713
787
 
788
+ // UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
789
+ // USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
790
+ const tabs = UITabs({
791
+ home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
792
+ profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
793
+ })
794
+ Router.init(tabs) // UITabs IS a UIScreen — present it directly
795
+ // .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
796
+ // .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
797
+ // .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
798
+ // Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
799
+ // restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
800
+
801
+ // UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
802
+ // OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
803
+ // tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
804
+ UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
805
+ // .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
806
+ // bar highlight here. Tabs keep their stack/scroll state when switched away and back.
807
+ // .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
808
+ // .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
809
+ // Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
810
+ // pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
811
+ // Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
812
+ // load initial data at build time, keep onOpen for re-entry.
813
+ const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
814
+ Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
815
+
714
816
  // UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
715
817
  // Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
716
- UIWidget(children)
717
- // .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
818
+ UIWidget(...children)
819
+ // .show(), .hide(), .isShown (getter), .onTouchStart(cb), .onBack(cb)
718
820
  // extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
719
821
  // a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
720
822
  // .onOverlayTap(cb) — usually () => widget.hide()
@@ -725,7 +827,7 @@ UIWidget(children)
725
827
  // UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
726
828
  // Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
727
829
  // back button close it automatically. Create ONCE at module scope, like any widget.
728
- UIModal(children)
830
+ UIModal(...children)
729
831
  // .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
730
832
  // — plus the full UIWidget surface
731
833
  // .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
@@ -736,10 +838,10 @@ UIModal(children)
736
838
  // UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
737
839
  // Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
738
840
  // dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
739
- UIBottomSheet(children)
841
+ UIBottomSheet(...children)
740
842
  // .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
741
843
  // BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
742
- // .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
844
+ // .setDetent(i) (animated), .detent (getter), .onDetent(cb(i)) — every settle: finger snap or setDetent()
743
845
  // .show()/.hide() slide in/out — plus the full UIModal surface (scrim, onOpen/onClose, dismissible).
744
846
  // Dragging below the lowest detent closes it (fires onClose); .dismissible(false) collapses there instead —
745
847
  // the persistent map sheet (pair it with overlayColor: null so the page behind stays interactive).
@@ -749,30 +851,33 @@ UIBottomSheet(children)
749
851
  // Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
750
852
  // automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
751
853
  // Attaches itself to Presentable.current (hides with the page it opened on).
752
- UIPopover(children)
854
+ UIPopover(...children)
753
855
  // .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
754
856
  // .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
755
857
  // Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
756
858
 
757
859
  // UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
758
- UIScrollable(children)
860
+ UIScrollable(...children)
759
861
  // extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
760
862
  // overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
761
863
  // keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
864
+ // snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
865
+ // direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
866
+ // the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
762
867
  // .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
763
868
  // .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
764
869
  // element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
765
870
  // Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
766
871
  // Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
767
872
  // themselves. flexGrow: 1 to fill the remaining space is still yours to set.
768
- // Carousel = scrollDirection: "horizontal" + FIXED-width cards (explicit width on each card, gap/px on the scrollable)
873
+ // Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
769
874
 
770
875
  // UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
771
876
  // visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
772
877
  // is large, grows over time, or is unknown.
773
878
  UIVirtualizedList<T>({
774
879
  keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
775
- render: (item: T) => UIElement, // builds one row; called lazily as rows enter the window
880
+ render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
776
881
  estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
777
882
  overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
778
883
  inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
@@ -821,12 +926,13 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
821
926
 
822
927
  // UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
823
928
  // To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
824
- UIButton(children?)
825
- // .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
826
- // extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
929
+ UIButton(...children)
930
+ // .onClick(cb), .onTouchStart(cb), .isPressed (getter) // gestures: see TOUCH GESTURES above
931
+ // .onMouseEnter(ev => ev.track({ onMove?, onEnd?, onCancel? })), .isHovered (getter) // mouse only; no leave event — onEnd is the leave
932
+ // extra styles: $pressed: { bgColor, opacity, ..., duration? } — style while the finger is down (reserved class);
827
933
  // rippleColor ("default" or a Color, Android only)
828
934
  // Note: press feedback is OPT-IN — nothing happens visually unless you set it. rippleColor replaces the
829
- // onPressed visual on Android, so rippleColor + onPressed = ripple on Android, onPressed dim on iOS.
935
+ // $pressed visual on Android, so rippleColor + $pressed = ripple on Android, $pressed dim on iOS.
830
936
  // Note: buttons render no chrome of their own — style bgColor/borderRadius/padding yourself, and give a
831
937
  // button an explicit height (on its own it is only as tall as its text). flexDirection: "column" for cards.
832
938
 
@@ -840,7 +946,7 @@ UIInput() / UITextArea()
840
946
  // maxLength: number; autocapitalize: "none"|"words"|"sentences"|"characters"; autocorrect: boolean
841
947
  // keyboardShrink: boolean (default true) — false: keyboard OVERLAYS the UI, no relayout (chat composers)
842
948
  // keyboardDismiss: boolean (default true) — false: taps never dismiss the keyboard (chat composers)
843
- // onFocused: { borderColor, ..., duration? } — style while focused (like onPressed)
949
+ // $focused: { borderColor, ..., duration? } — style while focused (reserved class)
844
950
  // Note: type values are KEYBOARD HINTS, not validators — "number" doesn't block pasted letters. The
845
951
  // phone-pad value is "phone" (there is NO "tel").
846
952
  // Note: "date"/"time" open the native PICKER in the keyboard slot (typing disabled). .value and onChange stay
@@ -860,7 +966,7 @@ UIInput() / UITextArea()
860
966
 
861
967
  // UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
862
968
  // Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
863
- UIRow([ title, UISpacer(), closeButton ])
969
+ UIRow(title, UISpacer(), closeButton)
864
970
  // If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
865
971
 
866
972
  // @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
@@ -875,16 +981,20 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
875
981
  // 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
876
982
  // 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
877
983
  // e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
878
- // 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
984
+ // 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
879
985
  // Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
880
986
  // the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
987
+ // loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
988
+ // (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
989
+ // animation is an effect, not a state change: it never commits, delay applies once, and it stops on
990
+ // the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
881
991
  // 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
882
992
  // Never modifies the stored style.
883
993
  // For free-value tweens use animate() — see ANIMATION above.
884
994
  // Older code may pass a style object as the FIRST factory argument — legacy; write new styles with .style().
885
995
 
886
996
  // Mutable CONTENT properties are NOT styles — they live on the element:
887
- // UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed()
997
+ // UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed (read-only)
888
998
  myText.text = "Updated" // re-renders immediately
889
999
 
890
1000
  // Style<T> is a global helper type for reusable style objects, no import needed:
@@ -922,9 +1032,8 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
922
1032
  // but do NOT support % ("calc(100% - 20px)" is invalid; use "calc(100vw - 20px)")
923
1033
 
924
1034
  // --- Colors in UI styles ---
925
- // hex "#f33"/"#f33c"/"#ff3333"/"#ff3333cc", packed int 0xff3333, "rgb(...)"/"rgba(...)", and exactly these names:
926
- // white black red green blue yellow orange purple gray cyan magenta brown transparent clear
927
- // (UI styles only — engine APIs are hex/packed-int only, see COLORS above)
1035
+ // any CSS color (hex, rgb()/hsl(), the CSS names — green = #008000, transparent/clear), 0xRRGGBB, [r,g,b(,a)],
1036
+ // or "var(--x)" — the same colors as every engine API (see COLORS above); a non-color throws
928
1037
 
929
1038
  // --- Safe areas & comfort ---
930
1039
  // Env keywords resolving to the device insets (notch, home indicator):
@@ -950,14 +1059,30 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
950
1059
  // Bare comfort-* tokens apply the safe-area formula; "var(--comfort-top)" reads the raw knob.
951
1060
  // "var(--name, fallback)" applies the fallback while the key is unset. Env names (safe-*, vw/vh) are not theme keys.
952
1061
 
953
- // --- Responsive: onLandscape / onPortrait ---
954
- // Any style can nest orientation overrides, merged on top while the device is in that orientation:
955
- .style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
956
- // Keys: onLandscape, onPortrait.
1062
+ // --- Style classes ($name) — the ONLY state mechanism; they CASCADE ---
1063
+ // A $-prefixed key in .style() declares a style state (yours: selected, checked, expanded);
1064
+ // duration/delay/easing inside the block animate the swap. Drive yours via el.class:
1065
+ const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
1066
+ item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
1067
+ // el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
1068
+ // form; names work with or without the $. Class state persists across screen close/reopen.
1069
+ // CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
1070
+ // one toggle restyles a whole composite control, each part declaring its own reaction:
1071
+ UIButton(
1072
+ UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
1073
+ UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
1074
+ ) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
1075
+ // RESERVED — the system toggles them, el.class.pressed = … throws:
1076
+ // $hovered / $pressed / $focused (mouse over / finger down / input focused) cascade to children but STOP at
1077
+ // a nested UIButton — a button inside a pressed card is not pressed;
1078
+ // $landscape / $portrait — GLOBAL, from the display size (the swap on rotation is instant).
1079
+ UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $hovered: { opacity: 0.9 }, $pressed: { transform: "scale(0.97)" } })
1080
+ screen.style({ flexDirection: "column", p: 16, $landscape: { flexDirection: "row", p: 32 } })
1081
+ // Precedence on the same prop: base < $landscape/$portrait < $classes < $hovered < $pressed < $focused.
957
1082
 
958
1083
  // --- Defaults that surprise ---
959
- // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and UIVirtualizedList default
960
- // flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
1084
+ // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
1085
+ // default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
961
1086
  // flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
962
1087
  // alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
963
1088
  // overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
@@ -974,10 +1099,10 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
974
1099
  // ===== REUSABLE COMPONENTS =====
975
1100
  // Extract repeated UI into factory functions — they return elements you can chain methods on:
976
1101
 
977
- const ClickableCard = (title: string, subtitle: string) => UIButton([
1102
+ const ClickableCard = (title: string, subtitle: string) => UIButton(
978
1103
  UIText(title).style({ fontWeight: 700, color: "white" }),
979
1104
  UIText(subtitle).style({ color: "#888", fontSize: 13 })
980
- ]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
1105
+ ).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
981
1106
 
982
1107
  // Use like any other element — chain after the call:
983
1108
  ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
@@ -986,15 +1111,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
986
1111
  const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
987
1112
 
988
1113
  // ===== CAPTURING ELEMENT REFERENCES =====
989
- // Use an assignment expression inside the children array — standard TypeScript:
1114
+ // Use an assignment expression right in the children — standard TypeScript:
990
1115
 
991
1116
  let label: UIText
992
1117
  let input: UIInput
993
1118
 
994
- UIColumn([
1119
+ UIColumn(
995
1120
  label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
996
1121
  input = UIInput(),
997
- ])
1122
+ )
998
1123
 
999
1124
  // Later:
1000
1125
  label.text = "Updated"
@@ -1004,15 +1129,17 @@ input.value // read current value
1004
1129
  // ((button.children[0] as UIText).text vs buttonText.text).
1005
1130
 
1006
1131
  // ===== CONDITIONAL CHILDREN =====
1007
- // null / undefined / false in a children array is skipped — no element, no layout slot.
1008
- UIColumn([ header, isLoading ? spinner : null, showFooter && footer ])
1132
+ // A null / undefined / false child is skipped — no element, no layout slot.
1133
+ UIColumn(header, isLoading ? spinner : null, showFooter && footer)
1134
+ // Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
1135
+ UIColumn(header, showList && items.map(Row))
1009
1136
 
1010
1137
  // ===== SIZING: the two axes behave differently =====
1011
1138
  // MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
1012
1139
  // without flexGrow: 1 (no implicit min-sizes).
1013
1140
  // CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
1014
1141
  // or alignSelf to opt out.
1015
- // So UIRow([ UIInput() ]) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
1142
+ // So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
1016
1143
  // EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
1017
1144
  // equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
1018
1145
  // longer label stays wider.
@@ -1032,6 +1159,7 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
1032
1159
  // Position once at an interaction — never poll per frame.
1033
1160
 
1034
1161
  // ===== ROUTER (multi-page apps) =====
1162
+ // Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
1035
1163
  Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
1036
1164
  Router.push(screen) // push onto the stack, screen becomes active
1037
1165
  Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
@@ -1073,6 +1201,14 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
1073
1201
  UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
1074
1202
  // Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
1075
1203
 
1204
+ // ===== ICONS (assetIcon) =====
1205
+ // Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
1206
+ // inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
1207
+ UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
1208
+ // The id must be a string literal; an unknown id is a compile error (with name suggestions).
1209
+ // Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
1210
+ // Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
1211
+
1076
1212
  // ===== COMMON MISTAKES — DO NOT DO THESE =====
1077
1213
 
1078
1214
  // ❌ CSS that doesn't exist here
@@ -1081,16 +1217,13 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
1081
1217
  lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
1082
1218
 
1083
1219
  // ❌ touch handler on a non-touch element
1084
- UIColumn([...]).onTouchStart(...) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
1220
+ UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
1085
1221
 
1086
- // ❌ scrollable that overflows the screen
1087
- // A wrapping container between the scrollable and the screen is missing flexShrink: 1
1088
-
1089
- // ❌ let/const inside children array — `let` is a statement, not an expression
1090
- UIRow([ let input = UIInput() ]) // WRONG
1222
+ // ❌ let/const among the children — `let` is a statement, not an expression
1223
+ UIRow(let input = UIInput()) // WRONG
1091
1224
  // ✅ declare outside, assign inside (assignment both sets the var AND appends)
1092
1225
  let input: UIInput
1093
- UIRow([ input = UIInput() ])
1226
+ UIRow(input = UIInput())
1094
1227
 
1095
1228
  // ❌ setting a UIImage's content through bgImage
1096
1229
  const img = UIImage("") // WRONG — empty source as a placeholder
@@ -1100,30 +1233,30 @@ const img = UIImage(url)
1100
1233
  img.src = newUrl // updates the displayed image
1101
1234
 
1102
1235
  // ❌ expecting an input to fill width like in CSS
1103
- UIRow([ UIInput() ]) // WRONG — collapses to placeholder width
1236
+ UIRow(UIInput()) // WRONG — collapses to placeholder width
1104
1237
  // ✅ stretch it explicitly
1105
- UIColumn([ UIInput().style({ width: "100%" }) ]) // cross axis
1106
- UIRow([ input = UIInput().style({ flexGrow: 1 }), sendBtn ]) // main axis
1238
+ UIColumn(UIInput().style({ width: "100%" })) // cross axis
1239
+ UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
1107
1240
 
1108
1241
  // ❌ a button that should match the input's height but shrinks to its text
1109
- UIRow([ input.style({ height: 40 }), UIButton([...]) ]) // button ends up shorter
1242
+ UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
1110
1243
  // ✅ give controls the same explicit height
1111
- UIRow([ input.style({ height: 40 }), UIButton([...]).style({ height: 40 }) ])
1244
+ UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
1112
1245
 
1113
1246
  // ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
1114
- UIRow([ yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 }) ]) // longer label = wider button
1247
+ UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
1115
1248
  // ✅ flex: 1 — grows from a zero basis, children end up equal
1116
- UIRow([ yes.style({ flex: 1 }), no.style({ flex: 1 }) ])
1249
+ UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
1117
1250
 
1118
1251
  // ❌ empty containers as spacers to align children (web habit)
1119
- UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
1252
+ UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
1120
1253
  // ✅ alignment is a CONTAINER property, not an extra element
1121
- UIRow([ label ]).style({ justifyContent: "flex-end" })
1254
+ UIRow(label).style({ justifyContent: "flex-end" })
1122
1255
 
1123
1256
  // ❌ empty element as a placeholder for a conditional child
1124
- UIRow([ isGroup ? button : UIColumn([]) ]) // WRONG
1257
+ UIRow(isGroup ? button : UIColumn()) // WRONG
1125
1258
  // ✅ null is skipped in children — no phantom element
1126
- UIRow([ isGroup ? button : null ])
1259
+ UIRow(isGroup ? button : null)
1127
1260
 
1128
1261
  // ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
1129
1262
  UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
@@ -1137,7 +1270,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
1137
1270
  list.onEndReached(600, loadNextPage)
1138
1271
 
1139
1272
  // ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
1140
- const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new widget every call
1273
+ const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
1141
1274
  // ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
1142
1275
 
1143
1276
  // ===== EXAMPLES =====
@@ -1145,7 +1278,7 @@ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new wid
1145
1278
  // === EXAMPLE 1: Single-file app ===
1146
1279
  <file name="main.ts">
1147
1280
  let text: UIText
1148
- const screen = UIScreen([
1281
+ const screen = UIScreen(
1149
1282
  text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
1150
1283
  UIText("Your name:").style({ textAlign: "center" }),
1151
1284
  UIInput()
@@ -1153,91 +1286,188 @@ const screen = UIScreen([
1153
1286
  .onChange(str => {
1154
1287
  text.text = `Hello, ${str}!`
1155
1288
  })
1156
- ]).style({ justifyContent: "center", p: 20, gap: 8 })
1289
+ ).style({ justifyContent: "center", p: 20, gap: 8 })
1157
1290
 
1158
1291
  screen.open()
1159
1292
  </file>
1160
1293
 
1161
- // === EXAMPLE 2: Fetching data with loading state ===
1294
+ // === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
1295
+ // A tokens module every screen imports, light-themed: theme({ color }) sets the default text
1296
+ // color ONCE — no color: "#111" on every label.
1297
+ <file name="tokens.ts">
1298
+ const palette = {
1299
+ bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
1300
+ text: "#131A17", muted: "#606B65",
1301
+ accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1302
+ }
1303
+ theme({ color: palette.text, primaryColor: palette.accent })
1304
+ // Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
1305
+ export const colors: { [K in keyof typeof palette]: string } = theme(palette)
1306
+ export const font = { // type scale — spread into styles: .style({ ...font.h2 })
1307
+ h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
1308
+ small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
1309
+ }
1310
+ </file>
1162
1311
  <file name="main.ts">
1312
+ import { colors, font } from './tokens'
1313
+
1163
1314
  type User = { id: number; name: string; email: string }
1164
1315
 
1165
- let statusText: UIText
1166
- let list: UIColumn
1316
+ const Row = (u: User) => UIRow(
1317
+ UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
1318
+ .style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
1319
+ justifyContent: "center", alignItems: "center" }),
1320
+ UIColumn(
1321
+ UIText(u.name).style({ ...font.bodyStrong }),
1322
+ UIText(u.email).style({ ...font.small, color: colors.muted }),
1323
+ ).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
1324
+ ).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
1325
+ border: `1px solid ${colors.border}`, p: 14 })
1167
1326
 
1168
- const loadData = async () => {
1169
- statusText.text = "Loading..."
1327
+ const Centered = (...children: UINodeChild[]) =>
1328
+ UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
1329
+
1330
+ let body: UIColumn
1170
1331
 
1332
+ const loadData = async () => {
1333
+ body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
1171
1334
  const res = await fetch("https://jsonplaceholder.typicode.com/users")
1172
1335
  if (res.status !== 200) {
1173
- statusText.text = "Error loading data"
1336
+ body.setContent([Centered(
1337
+ UIText("Couldn't load users"),
1338
+ UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
1339
+ .style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
1340
+ .onClick(() => loadData()),
1341
+ )])
1174
1342
  return
1175
1343
  }
1176
-
1177
- const users = res.json<User[]>() // sync — no await
1178
-
1179
- statusText.text = ""
1180
- list.setContent(
1181
- users.map(user =>
1182
- UIColumn([
1183
- UIText(user.name).style({ fontWeight: 700, color: "white" }),
1184
- UIText(user.email).style({ color: "#888", fontSize: 13 })
1185
- ]).style({ px: 16, py: 12, gap: 4 })
1186
- )
1187
- )
1344
+ body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
1188
1345
  }
1189
1346
 
1190
- const screen = UIScreen([
1191
- UIText("Users").style({ fontSize: 24, fontWeight: 700, color: "white", mb: 8 }),
1192
- statusText = UIText("").style({ color: "#888" }),
1193
- UIScrollable([
1194
- list = UIColumn([])
1195
- ]).style({ flexGrow: 1 }) // the ONE scrolling body — the screen itself never scrolls
1196
- .onRefresh(() => loadData()) // pull-to-refresh; attached before screen.open()
1197
- ])
1198
- .style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
1199
- .onOpen(() => loadData())
1347
+ const screen = UIScreen(
1348
+ UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
1349
+ UIScrollable(
1350
+ body = UIColumn().style({ flexGrow: 1, gap: 8 })
1351
+ ).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
1352
+ .onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
1353
+ ).style({ bgColor: colors.bg, pt: "comfort-top" })
1354
+ .onOpen(() => loadData())
1200
1355
 
1201
1356
  screen.open()
1202
1357
  </file>
1203
1358
 
1204
- // === EXAMPLE 3: Simple app with navigation ===
1359
+ // === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
1205
1360
  <file name="home.ts">
1206
- import { detailScreen } from './detail'
1207
-
1208
- export const homeScreen = UIScreen([
1209
- UIText("Home").style({ fontSize: 24, fontWeight: 700 }),
1210
- UIButton([
1211
- UIText("Go to detail").style({ color: "white" })
1212
- ]).style({ bgColor: "#FF4032", borderRadius: 12, p: 16, rippleColor: "default", onPressed: { opacity: 0.7 } })
1213
- .onClick(() => Router.push(detailScreen))
1214
- ]).style({ p: 20, pt: "max(safe-top, 24px)", gap: 16 })
1215
- </file>
1216
- <file name="detail.ts">
1217
- import chevronLeft from './assets/chevron-left.svg'
1218
-
1219
- export const detailScreen = UIScreen([
1220
- UIButton([
1221
- UIImage(chevronLeft).style({ width: 18, height: 18, tintColor: "#FF4032" }),
1222
- UIText("Back").style({ color: "#FF4032" })
1223
- ]).style({ alignSelf: "flex-start", flexDirection: "row", height: 32, gap: 4 }).onClick(() => Router.pop()),
1224
- UIText("Detail Screen").style({ fontSize: 24, fontWeight: 700 })
1225
- ])
1226
- .style({ p: 20, pt: "max(safe-top, 24px)", gap: 20 })
1227
- .onBackPressed(() => Router.pop())
1361
+ const detailScreen = (name: string) => UIScreen(
1362
+ UIButton(
1363
+ UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
1364
+ UIText("Back")
1365
+ ).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
1366
+ UIText(name).style({ fontSize: 24, fontWeight: 700 }),
1367
+ UIButton(
1368
+ UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
1369
+ UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
1370
+ ).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
1371
+ .onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
1372
+ ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
1373
+
1374
+ const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
1375
+ .style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", $pressed: { opacity: 0.7 } })
1376
+ .onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
1377
+
1378
+ export const homeScreen = UIScreen(
1379
+ UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
1380
+ UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
1381
+ ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
1228
1382
  </file>
1229
- <file name="assets/chevron-left.svg">
1230
- <svg viewBox="0 0 24 24">
1231
- <path d="M15 18l-6-6 6-6" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1232
- </svg>
1383
+ <file name="profile.ts">
1384
+ export const profileScreen = UIScreen(
1385
+ UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
1386
+ ).style({ p: 16, pt: "comfort-top", bgColor: "black" })
1233
1387
  </file>
1234
1388
  <file name="main.ts">
1235
1389
  import { homeScreen } from './home'
1390
+ import { profileScreen } from './profile'
1391
+
1392
+ theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
1393
+
1394
+ const tabs = UITabs({
1395
+ home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
1396
+ profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
1397
+ })
1398
+ tabs.badge("profile", true) // notification dot on the tab
1399
+ Router.init(tabs)
1400
+ </file>
1401
+
1402
+ // === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
1403
+ // The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
1404
+ // view and handles dismissal itself.
1405
+ <file name="main.ts">
1406
+ // Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
1407
+ // appearing error never jumps the form). Typing in an errored field clears it.
1408
+ const Field = (label: string, style: Style<UIInput> = {}) => {
1409
+ const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
1410
+ border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
1411
+ $focused: { borderColor: "#4C8DFF" }, ...style })
1412
+ const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
1413
+ input.onChange(() => message.text = "")
1414
+ return {
1415
+ node: UIColumn(
1416
+ UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
1417
+ input, message,
1418
+ ).style({ gap: 6 }),
1419
+ input,
1420
+ error: (text: string) => { message.text = text },
1421
+ }
1422
+ }
1423
+
1424
+ const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
1425
+ const email = Field("Email", { type: "email", placeholder: "you@example.com" })
1426
+ const password = Field("Password", { type: "password" })
1427
+
1428
+ // The return key walks the form; the last field submits. This is ALL the keyboard code.
1429
+ name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
1430
+ email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
1431
+ password.input.style({ enterKey: "go" }).onSubmit(() => submit())
1432
+
1433
+ let btnLabel: UIText
1434
+ let busy = false
1435
+
1436
+ // The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
1437
+ // first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
1438
+ const submit = async () => {
1439
+ if (busy) return
1440
+ let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
1441
+ if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
1442
+ if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
1443
+ if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
1444
+ if (bad) { bad.input.focus(); return }
1445
+ busy = true
1446
+ btnLabel.text = "Creating…"
1447
+ const res = await fetch("https://api.example.com/register", {
1448
+ method: "POST", headers: { "Content-Type": "application/json" },
1449
+ body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
1450
+ })
1451
+ busy = false
1452
+ btnLabel.text = "Create account"
1453
+ if (res.status !== 200) { email.error("Registration failed — try again"); return }
1454
+ toast("Welcome!")
1455
+ }
1456
+
1457
+ const screen = UIScreen(
1458
+ UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
1459
+ UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
1460
+ name.node, email.node, password.node,
1461
+ UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
1462
+ .style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, $pressed: { opacity: 0.85 } })
1463
+ .onClick(() => submit())
1464
+ ).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
1465
+ ).style({ bgColor: "#0C0F14" })
1236
1466
 
1237
- Router.init(homeScreen)
1467
+ screen.open()
1238
1468
  </file>
1239
1469
 
1240
- // === EXAMPLE 4: UIBottomSheet — persistent map-style sheet with detents ===
1470
+ // === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
1241
1471
  <file name="main.ts">
1242
1472
  type Place = { id: number; name: string; distance: string }
1243
1473
  const places: Place[] = [
@@ -1246,32 +1476,32 @@ const places: Place[] = [
1246
1476
  { id: 3, name: "Riverside Park", distance: "1.2 km" },
1247
1477
  ]
1248
1478
 
1249
- const Row = (p: Place) => UIRow([
1479
+ const Row = (p: Place) => UIRow(
1250
1480
  UIText(p.name).style({ color: "#111", fontSize: 16 }),
1251
1481
  UIText(p.distance).style({ color: "#888", fontSize: 14 })
1252
- ]).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
1482
+ ).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
1253
1483
 
1254
- const sheet = UIBottomSheet([
1255
- UIColumn([]).style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
1484
+ const sheet = UIBottomSheet(
1485
+ UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
1256
1486
  UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
1257
1487
  UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
1258
- ])
1488
+ )
1259
1489
  .style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
1260
1490
  .detents([0.25, 0.6, 1]) // collapsed / half / full
1261
1491
  .dismissible(false) // drag below the lowest detent collapses, never closes
1262
- .onDetentChange(i => console.log("detent", i))
1492
+ .onDetent(i => console.log("detent", i))
1263
1493
 
1264
- const mapScreen = UIScreen([
1494
+ const mapScreen = UIScreen(
1265
1495
  // the map / page content behind the sheet
1266
- ]).onOpen(() => sheet.show()).onClose(() => sheet.hide())
1496
+ ).onOpen(() => sheet.show()).onClose(() => sheet.hide())
1267
1497
 
1268
1498
  mapScreen.open()
1269
1499
 
1270
- // An action sheet is even less: content-sized, no detents() — UIBottomSheet([ ...rows ]).show(),
1500
+ // An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
1271
1501
  // scrim and drag-down-to-dismiss included.
1272
1502
  </file>
1273
1503
 
1274
- // === EXAMPLE 5: UIVirtualizedList — chat (inverted, imperative append) ===
1504
+ // === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
1275
1505
  <file name="main.ts">
1276
1506
  type Msg = { id: string; text: string; mine: boolean }
1277
1507
 
@@ -1283,37 +1513,41 @@ const list = UIVirtualizedList<Msg>({
1283
1513
  keyOf: m => m.id,
1284
1514
  estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
1285
1515
  inverted: true, // newest at the bottom
1286
- render: m => UIRow([
1287
- UIRow([
1516
+ render: m => UIRow(
1517
+ UIRow(
1288
1518
  UIText(m.text).style({ color: m.mine ? "white" : "#111" })
1289
- ]).style({
1519
+ ).style({
1290
1520
  bgColor: m.mine ? "#FF4032" : "#EEE",
1291
1521
  px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
1292
1522
  })
1293
- ]).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
1523
+ ).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
1294
1524
  }).style({ flexGrow: 1 })
1295
1525
 
1296
- let input: UIInput
1526
+ let input: UITextArea
1527
+ let sendBtn: UIButton
1297
1528
 
1298
1529
  const send = () => {
1299
1530
  const text = input.value.trim()
1300
1531
  if (!text) return
1301
1532
  list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
1302
1533
  input.value = ""
1534
+ sendBtn.style.opacity = 0.4
1303
1535
  }
1304
1536
 
1305
- const screen = UIScreen([
1537
+ const screen = UIScreen(
1306
1538
  list,
1307
- UIRow([
1308
- input = UIInput().style({
1309
- flexGrow: 1, height: 40, px: 16, borderRadius: 20,
1310
- bgColor: "#F0F0F0", placeholder: "Message...", placeholderColor: "#999"
1539
+ UIRow(
1540
+ input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
1541
+ color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
1542
+ keyboardDismiss: false
1311
1543
  }),
1312
- UIButton([ UIText("Send").style({ color: "white" }) ])
1313
- .style({ height: 40, px: 16, justifyContent: "center", bgColor: "#FF4032", borderRadius: 20 })
1544
+ sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
1545
+ .style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
1314
1546
  .onClick(send)
1315
- ]).style({ p: 8, gap: 8, alignItems: "center" })
1316
- ]).style({ bgColor: "white" })
1547
+ ).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
1548
+ ).style({ bgColor: "white" })
1549
+
1550
+ input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
1317
1551
 
1318
1552
  screen.open()
1319
1553
  </file>