lecodes-sdk 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/README.md +5 -2
  2. package/dist/editor.d.ts +12 -0
  3. package/dist/global.d.ts +4 -8
  4. package/dist/host.d.ts +2 -3
  5. package/dist/types/animate/tween/Animation.d.ts +0 -3
  6. package/dist/types/animate/tween/animateValue.d.ts +4 -2
  7. package/dist/types/animate/tween/easing.d.ts +8 -0
  8. package/dist/types/animate/tween/spec.d.ts +15 -7
  9. package/dist/types/audio/audio.d.ts +2 -1
  10. package/dist/types/canvas/Canvas.d.ts +40 -108
  11. package/dist/types/canvas/gen/cssColor.d.ts +17 -0
  12. package/dist/types/canvas/gen/recorder.d.ts +118 -0
  13. package/dist/types/canvas/gen/spec.d.ts +144 -0
  14. package/dist/types/core/color.d.ts +3 -1
  15. package/dist/types/core/pins.d.ts +18 -0
  16. package/dist/types/g2/Node2D.d.ts +5 -8
  17. package/dist/types/g2/Scene2D.d.ts +6 -2
  18. package/dist/types/gl/Foliage.d.ts +30 -7
  19. package/dist/types/gl/Light.d.ts +8 -0
  20. package/dist/types/gl/Lightmap.d.ts +13 -2
  21. package/dist/types/gl/Material.d.ts +17 -3
  22. package/dist/types/gl/Model.d.ts +6 -2
  23. package/dist/types/gl/Node.d.ts +3 -6
  24. package/dist/types/gl/Scene.d.ts +41 -13
  25. package/dist/types/gl/Texture.d.ts +1 -1
  26. package/dist/types/gl/animation/Locomotion.d.ts +8 -1
  27. package/dist/types/inject.d.ts +11 -11
  28. package/dist/types/inject.editor.d.ts +1 -0
  29. package/dist/types/net/core.d.ts +7 -0
  30. package/dist/types/plugin.d.ts +76 -0
  31. package/dist/types/plugins/gen/camera/sdk/camera.d.ts +24 -0
  32. package/dist/types/plugins/gen/camera/sdk/camera.gen.d.ts +25 -0
  33. package/dist/types/plugins/{geolocation.d.ts → gen/geolocation/sdk/geolocation.d.ts} +2 -20
  34. package/dist/types/plugins/gen/geolocation/sdk/geolocation.gen.d.ts +31 -0
  35. package/dist/types/plugins/{map.d.ts → gen/map/sdk/map.d.ts} +9 -50
  36. package/dist/types/plugins/gen/map/sdk/map.gen.d.ts +53 -0
  37. package/dist/types/plugins/gen/push/sdk/push.d.ts +23 -0
  38. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +35 -0
  39. package/dist/types/plugins/{qr.d.ts → gen/qr-scanner/sdk/qr-scanner.d.ts} +2 -3
  40. package/dist/types/plugins/gen/qr-scanner/sdk/qr-scanner.gen.d.ts +15 -0
  41. package/dist/types/runtime/app.d.ts +9 -2
  42. package/dist/types/runtime/fetch.d.ts +2 -0
  43. package/dist/types/runtime/input.d.ts +1 -1
  44. package/dist/types/runtime/media.d.ts +6 -10
  45. package/dist/types/runtime/misc.d.ts +4 -1
  46. package/dist/types/runtime/net.d.ts +3 -2
  47. package/dist/types/runtime/touch.d.ts +32 -0
  48. package/dist/types/scene/defineScene.d.ts +43 -2
  49. package/dist/types/scene/editor.d.ts +52 -0
  50. package/dist/types/scene/gizmos.d.ts +7 -4
  51. package/dist/types/ui/NativeView.d.ts +6 -4
  52. package/dist/types/ui/UI.d.ts +1 -1
  53. package/dist/types/ui/UIBottomSheet.d.ts +6 -12
  54. package/dist/types/ui/UIButton.d.ts +12 -14
  55. package/dist/types/ui/UIContainer.d.ts +0 -6
  56. package/dist/types/ui/UIImage.d.ts +1 -4
  57. package/dist/types/ui/UIInput.d.ts +8 -24
  58. package/dist/types/ui/UIModal.d.ts +0 -2
  59. package/dist/types/ui/UINode.d.ts +70 -56
  60. package/dist/types/ui/UIPager.d.ts +28 -27
  61. package/dist/types/ui/UIPopover.d.ts +0 -2
  62. package/dist/types/ui/UIScreen.d.ts +12 -17
  63. package/dist/types/ui/UIScrollable.d.ts +1 -4
  64. package/dist/types/ui/UIText.d.ts +0 -2
  65. package/dist/types/ui/UIVideo.d.ts +3 -5
  66. package/dist/types/ui/UIVirtualizedList.d.ts +14 -16
  67. package/dist/types/ui/UIWidget.d.ts +10 -9
  68. package/dist/types/ui/colorKeys.gen.d.ts +9 -0
  69. package/dist/types/ui/presentable.d.ts +46 -32
  70. package/dist/types/ui/router.d.ts +18 -7
  71. package/dist/types/ui/styleColor.d.ts +1 -0
  72. package/dist/types/ui/transitions.d.ts +18 -0
  73. package/dist/types/ui/tree.d.ts +75 -0
  74. package/dist/types/version.d.ts +10 -0
  75. package/dist/types.json +1 -1
  76. package/package.json +12 -3
  77. package/prompts/2d.md +2 -6
  78. package/prompts/3d.md +1 -5
  79. package/prompts/README.md +1 -1
  80. package/prompts/canvas.md +9 -8
  81. package/prompts/compose.ts +1 -1
  82. package/prompts/core.md +3 -3
  83. package/prompts/design.md +1 -1
  84. package/prompts/dist/2d-game.md +473 -239
  85. package/prompts/dist/3d-app.md +553 -205
  86. package/prompts/dist/ar-app.md +435 -202
  87. package/prompts/dist/design.md +113 -95
  88. package/prompts/dist/ui-app.md +386 -170
  89. package/prompts/ui-design.md +2 -3
  90. package/prompts/ui.md +45 -37
  91. package/src/animate/tween/Animation.ts +34 -150
  92. package/src/animate/tween/Timeline.ts +175 -175
  93. package/src/animate/tween/animateValue.ts +6 -3
  94. package/src/animate/tween/easing.ts +10 -3
  95. package/src/animate/tween/spec.ts +41 -15
  96. package/src/audio/Sound.ts +3 -3
  97. package/src/audio/audio.ts +2 -1
  98. package/src/bridges/2d.d.ts +317 -0
  99. package/src/bridges/app.d.ts +91 -0
  100. package/src/bridges/audio.d.ts +97 -0
  101. package/src/bridges/canvas.d.ts +79 -0
  102. package/src/bridges/device.d.ts +72 -0
  103. package/src/bridges/fetch.d.ts +80 -0
  104. package/src/bridges/files.d.ts +70 -0
  105. package/src/bridges/gl.d.ts +1133 -0
  106. package/src/bridges/input.d.ts +72 -0
  107. package/src/bridges/media.d.ts +55 -0
  108. package/src/bridges/nav.d.ts +71 -0
  109. package/src/bridges/net.d.ts +52 -0
  110. package/src/bridges/service.d.ts +52 -0
  111. package/src/bridges/socket.d.ts +31 -0
  112. package/src/bridges/storage.d.ts +33 -0
  113. package/src/bridges/tree.d.ts +301 -0
  114. package/src/bridges/types.d.ts +49 -0
  115. package/src/canvas/Canvas.ts +114 -159
  116. package/src/canvas/gen/cssColor.ts +224 -0
  117. package/src/canvas/gen/recorder.ts +212 -0
  118. package/src/canvas/gen/spec.ts +201 -0
  119. package/src/chisel.ts +193 -0
  120. package/src/compile/assetMacro.ts +1 -1
  121. package/src/compile/bundler.ts +11 -2
  122. package/src/compile/compileProject.ts +43 -4
  123. package/src/compile/fontMacro.ts +3 -4
  124. package/src/compile/header.ts +26 -5
  125. package/src/compile/index.ts +3 -1
  126. package/src/compile/liteMaterial.ts +1 -1
  127. package/src/compile/sceneEditor.ts +11 -26
  128. package/src/core/color.ts +73 -30
  129. package/src/core/pins.ts +51 -0
  130. package/src/core/signals.ts +8 -1
  131. package/src/g2/CharacterController2D.ts +3 -3
  132. package/src/g2/Node2D.ts +57 -39
  133. package/src/g2/Physics2D.ts +2 -2
  134. package/src/g2/Scene2D.ts +35 -23
  135. package/src/g2/Texture2D.ts +1 -1
  136. package/src/g2/loop.ts +4 -4
  137. package/src/gl/CameraPlace.ts +52 -52
  138. package/src/gl/Foliage.ts +72 -17
  139. package/src/gl/Geometry.ts +1 -2
  140. package/src/gl/Light.ts +10 -0
  141. package/src/gl/Lightmap.ts +45 -29
  142. package/src/gl/Material.ts +95 -51
  143. package/src/gl/Mesh.ts +120 -120
  144. package/src/gl/Model.ts +21 -16
  145. package/src/gl/Node.ts +91 -24
  146. package/src/gl/Particles.ts +1 -1
  147. package/src/gl/Scene.ts +100 -44
  148. package/src/gl/Texture.ts +8 -7
  149. package/src/gl/animation/AnimationClip.ts +1 -1
  150. package/src/gl/animation/DynamicBone.ts +482 -482
  151. package/src/gl/animation/Locomotion.ts +8 -3
  152. package/src/gl/nav/NavMesh.ts +3 -4
  153. package/src/gl/physics/Physics.ts +2 -2
  154. package/src/gl/physics/physicsEvents.ts +3 -3
  155. package/src/gl/scenarios.ts +291 -291
  156. package/src/gl/terrain/Terrain.ts +4 -5
  157. package/src/gl/touch.ts +14 -15
  158. package/src/host.d.ts +2 -3
  159. package/src/inject.editor.ts +7 -0
  160. package/src/inject.ts +13 -16
  161. package/src/net/core.ts +6 -5
  162. package/src/net/index.ts +1 -1
  163. package/src/net/replication.ts +1 -1
  164. package/src/plugin.ts +191 -0
  165. package/src/plugins/gen/camera/contract.d.ts +27 -0
  166. package/src/plugins/gen/camera/sdk/camera.gen.ts +46 -0
  167. package/src/plugins/gen/camera/sdk/camera.ts +57 -0
  168. package/src/plugins/gen/geolocation/contract.d.ts +50 -0
  169. package/src/plugins/gen/geolocation/sdk/geolocation.gen.ts +54 -0
  170. package/src/plugins/{geolocation.ts → gen/geolocation/sdk/geolocation.ts} +22 -43
  171. package/src/plugins/gen/map/contract.d.ts +144 -0
  172. package/src/plugins/gen/map/sdk/map.gen.ts +88 -0
  173. package/src/plugins/{map.ts → gen/map/sdk/map.ts} +68 -102
  174. package/src/plugins/gen/push/contract.d.ts +61 -0
  175. package/src/plugins/gen/push/sdk/push.gen.ts +60 -0
  176. package/src/plugins/gen/push/sdk/push.ts +105 -0
  177. package/src/plugins/gen/qr-scanner/contract.d.ts +16 -0
  178. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.gen.ts +29 -0
  179. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.ts +52 -0
  180. package/src/plugins/permission.ts +5 -4
  181. package/src/runtime/app.ts +20 -9
  182. package/src/runtime/appEvents.ts +5 -4
  183. package/src/runtime/channel.ts +18 -15
  184. package/src/runtime/clipboard.ts +4 -3
  185. package/src/runtime/datetime.ts +2 -1
  186. package/src/runtime/device.ts +17 -15
  187. package/src/runtime/fetch.ts +30 -20
  188. package/src/runtime/files.ts +16 -15
  189. package/src/runtime/input.ts +12 -10
  190. package/src/runtime/media.ts +50 -46
  191. package/src/runtime/misc.ts +7 -3
  192. package/src/runtime/net.ts +8 -7
  193. package/src/runtime/rpc.ts +1 -3
  194. package/src/runtime/service.ts +19 -14
  195. package/src/runtime/share.ts +4 -3
  196. package/src/runtime/storage.ts +6 -4
  197. package/src/runtime/touch.ts +32 -0
  198. package/src/scene/defineScene.ts +61 -365
  199. package/src/scene/editor.ts +408 -0
  200. package/src/scene/editorPlugins.ts +3 -3
  201. package/src/scene/gizmos.ts +15 -9
  202. package/src/server/db/marci/query.ts +1 -1
  203. package/src/server/host.ts +1 -1
  204. package/src/server/runtime.ts +1 -1
  205. package/src/ui/NativeView.ts +72 -25
  206. package/src/ui/UI.ts +3 -3
  207. package/src/ui/UIBottomSheet.ts +16 -17
  208. package/src/ui/UIButton.ts +54 -16
  209. package/src/ui/UIContainer.ts +0 -6
  210. package/src/ui/UIImage.ts +34 -30
  211. package/src/ui/UIInput.ts +29 -37
  212. package/src/ui/UIModal.ts +1 -3
  213. package/src/ui/UINode.ts +347 -297
  214. package/src/ui/UIPager.ts +93 -78
  215. package/src/ui/UIPopover.ts +0 -2
  216. package/src/ui/UIScreen.ts +41 -36
  217. package/src/ui/UIScrollable.ts +19 -13
  218. package/src/ui/UISpacer.ts +1 -1
  219. package/src/ui/UITabs.ts +8 -6
  220. package/src/ui/UIText.ts +7 -17
  221. package/src/ui/UIVideo.ts +30 -27
  222. package/src/ui/UIVirtualizedList.ts +58 -59
  223. package/src/ui/UIWidget.ts +38 -18
  224. package/src/ui/colorKeys.gen.ts +37 -0
  225. package/src/ui/fonts.ts +2 -2
  226. package/src/ui/presentable.ts +59 -42
  227. package/src/ui/router.ts +52 -35
  228. package/src/ui/styleColor.ts +56 -0
  229. package/src/ui/theme.ts +7 -6
  230. package/src/ui/transitions.ts +249 -0
  231. package/src/ui/tree.ts +346 -0
  232. package/src/version.ts +24 -0
  233. package/tests/helpers/engineWorld.ts +11 -0
  234. package/tests/helpers/fakeTree.ts +353 -0
  235. package/tests/helpers/hostStubs.ts +31 -0
  236. package/tests/helpers/index.ts +14 -0
  237. package/tests/helpers/memoryMarci.ts +124 -0
  238. package/tests/helpers/phases.ts +23 -0
  239. package/tests/helpers/preload.ts +18 -0
  240. package/tests/helpers/stubApp.ts +2 -0
  241. package/tests/helpers/stubDevice.ts +2 -0
  242. package/tests/helpers/stubFetch.ts +2 -0
  243. package/tests/helpers/stubInput.ts +2 -0
  244. package/dist/inject.js +0 -4629
  245. package/dist/types/core/registry.d.ts +0 -7
  246. package/dist/types/plugins/camera.d.ts +0 -25
  247. package/dist/types/plugins/push.d.ts +0 -46
  248. package/src/bridges.d.ts +0 -1769
  249. package/src/compile/__tests__/assetIconMacro.test.ts +0 -219
  250. package/src/compile/__tests__/assetMacro.test.ts +0 -100
  251. package/src/compile/__tests__/assetName.test.ts +0 -55
  252. package/src/compile/__tests__/compile.test.ts +0 -310
  253. package/src/compile/__tests__/detectEntry.test.ts +0 -151
  254. package/src/compile/__tests__/fontMacro.test.ts +0 -199
  255. package/src/compile/__tests__/serverSplit.test.ts +0 -27
  256. package/src/core/__tests__/stateMachine.test.ts +0 -132
  257. package/src/core/registry.ts +0 -23
  258. package/src/plugins/camera.ts +0 -81
  259. package/src/plugins/push.ts +0 -132
  260. package/src/plugins/qr.ts +0 -73
@@ -24,18 +24,43 @@ Remove a single top-level declaration:
24
24
 
25
25
  <remove file="home.ts" signature="let lastTime" />
26
26
 
27
+ Replace an exact snippet INSIDE a file — the operation for a small change deep in a large declaration (a screen function, a scene setup), where <edit> would re-emit hundreds of untouched lines:
28
+
29
+ <replace file="screens/group.ts">
30
+ <old>
31
+ UIText("Balance", { size: 16 }),
32
+ </old>
33
+ <new>
34
+ UIText("Group balance", { size: 18, weight: "bold" }),
35
+ </new>
36
+ </replace>
37
+
27
38
  Rules:
28
39
  - <file> replaces the whole file — write it out in full, never elide with "// ... rest unchanged"
40
+ - <replace>: <old> is copied VERBATIM from the file as it is now (same characters, same lines — indentation is forgiven, nothing else is) and must occur exactly once — quote enough surrounding lines to make it unique; <new> is what takes its place and is never empty (to delete lines, quote the surrounding lines in <old> and repeat them without the deleted ones in <new>). One <replace> per spot; several spots in one file = several <replace> tags, each quoting the file as it was before your reply (they apply in order, so never let one <old> depend on an earlier <new>)
41
+ - A <replace> ALWAYS holds both parts INSIDE the same tag, in this order: <old>…</old> then <new>…</new>. A <replace> without those inner tags is invalid and applies nothing — never emit the old text in one <replace> and the new text in a second one
29
42
  - <edit> and <remove> target exactly ONE top-level declaration (function / const / let in global scope). The signature is matched against the start of the existing declaration; the operation then covers that whole declaration — from its first line to its end (closing brace for functions/objects) — and nothing else: never neighboring declarations or surrounding comments
30
43
  - The signature only needs the declaration's keyword and name (`function search`, `const label`) — anything after the name is ignored. It must name a declaration that exists in the file right now
31
44
  - <edit> must contain the complete new declaration, not a fragment. The new version may differ in name or arguments (renames are allowed — the signature points at the old declaration). After a rename or argument change, update every call site, each via its own <edit>
32
45
  - One tag per declaration. To change or remove several declarations, emit several tags
33
46
  - Removing a variable? Also update every declaration that references it (each via its own <edit>)
34
- - Pick <edit>/<remove> for tweaking a few existing declarations; <file> when adding declarations, changing imports, or restructuring
35
- - If a change doesn't fit these operations, fall back to <file> — never bend <edit> to cover multiple declarations
47
+ - Choosing the operation, cheapest first: <replace> for a change of a few lines anywhere (a value, a label, one call, one branch); <edit>/<remove> when a whole small declaration changes shape; <file> for new files and real restructuring. Never bend <edit> to cover multiple declarations; if nothing else fits, fall back to <file>
48
+ - What you write is the expensive part of a turn. Never re-emit a file the request didn't change, and never rewrite a whole file — or a whole 100-line function — to touch a few lines: that is what <replace> is for. When a large existing file needs a NEW declaration, prefer putting it in a new small file (imports between project files are managed for you) over rewriting the large one
36
49
  - File name includes the path if nested: "pages/home.ts"
37
50
  - Only raw file content inside tags, no markdown fences (```). Backticks for template literals in the code are fine, and non-TS assets (e.g. raw SVG XML in a .svg file) are allowed.
38
51
 
52
+ // === EXAMPLE: a small change inside a big screen ===
53
+ // screens/home.ts is a 120-line `export function homeScreen()`. Task: make the title bigger.
54
+
55
+ <replace file="screens/home.ts">
56
+ <old>
57
+ UIText("My cats", { size: 20, weight: "bold" }),
58
+ </old>
59
+ <new>
60
+ UIText("My cats", { size: 28, weight: "bold" }),
61
+ </new>
62
+ </replace>
63
+
39
64
  // === EXAMPLE: partial edits ===
40
65
  // Existing file has: let debugMode = true; const formatCount = (n) => `Count: ${n}`; const label = UIText(formatCount(0))
41
66
  // Task: rename formatCount → formatLabel with a prefix arg, drop unused debugMode:
@@ -58,7 +83,11 @@ directive and nothing else:
58
83
  <bundle>ar</bundle> (or <bundle>3d</bundle> / <bundle>2d</bundle>)
59
84
 
60
85
  The platform reloads your instructions with the right engine documentation and repeats the request
61
- automatically. Never use this when the needed APIs are documented here — just do the work.
86
+ automatically. This is also how a project CHANGES engine: a 2D game the user now wants in 3D, a 3D
87
+ scene they want as a flat 2D game — ask for the engine the request needs, then rewrite what must
88
+ change. Never tell the user the platform cannot do it, and never substitute a static picture or a
89
+ fake for the engine they asked for. Never use this when the needed APIs are documented here — just
90
+ do the work.
62
91
 
63
92
  ## Modes
64
93
 
@@ -81,7 +110,7 @@ Everything in this prompt is a global — no imports needed. A project's only im
81
110
 
82
111
  `main.ts` is the entry point — always name the entry file `main.ts`. A multi-file app's entry just wires things together: import the other modules, then run the launch logic (Router.init(...) / scene.open()). Every other file must be reachable from `main.ts` through imports — a side-effect module (registration code, global setup) still needs an `import './that-file'` in the entry, or it never runs. (In a project without a `main.ts`, the file nothing else imports is treated as the entry.)
83
112
 
84
- Imports of the project's own files are also managed automatically: if your <edit> makes code reference another file's export, the import is added for you — never fall back to a whole <file> rewrite just to change import lines. When writing a complete <file>, include imports normally.
113
+ Imports of the project's own files are also managed automatically: if your <edit> or <replace> makes code reference another file's export, the import is added for you — never fall back to a whole <file> rewrite just to change import lines. When writing a complete <file>, include imports normally.
85
114
 
86
115
  Reference a project asset (image, font, video, .svg, .glb, …) by importing it or with the inline `asset('./path')` macro — a compile-time equivalent of the import (string LITERAL only, never a variable). External `https://…` URLs are used directly as strings.
87
116
 
@@ -91,17 +120,27 @@ Each request carries the project state: `[Assets]` lists binary files by path; `
91
120
 
92
121
  ## Automatic reports
93
122
 
94
- A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user — e.g. a runtime error thrown by the running app, with a source-mapped stack. Fix the problem directly with <file>/<edit> operations. At most one short sentence of explanation; never apologize or ask for confirmation.
123
+ A user message starting with `[Automatic report]` is machine-generated feedback from the platform, not the user — e.g. a runtime error thrown by the running app, with a source-mapped stack. Fix the problem directly with <replace>/<edit>/<file> operations. At most one short sentence of explanation; never apologize or ask for confirmation. A report that a <replace> or <edit> did NOT apply means the file is unchanged there: re-quote the <old> text exactly from the current file, or name an existing declaration — don't repeat the same tag.
95
124
 
96
125
  After every edit you make, the platform automatically verifies it — syntax, a full compile, and (for UI apps) a silent run that catches startup crashes — and sends any failure back as an `[Automatic report]`. Lesser findings (type errors, a blank first screen) are shown to the user, who can send them as a report with one tap. So:
97
126
  - A report is a checker result, not a person. Fix exactly what it lists; don't re-explain the whole change.
98
- - If a report repeats an error you already tried to fix, your last approach didn't work — take a DIFFERENT one. Prefer rewriting the whole file with `<file>` over another targeted `<edit>`.
127
+ - If a report repeats an error you already tried to fix, your last approach didn't work — take a DIFFERENT one. Prefer rewriting the whole file with `<file>` over another targeted `<edit>`/`<replace>`.
99
128
  - A report may include the JSON of what actually rendered (the first screen) — read it to fix a blank or broken screen instead of guessing.
100
129
  - If a report says your reply was cut off and asks you to continue, re-emit the interrupted `<file>` block from its very beginning (a re-opened `<file>` replaces the whole file — never continue a file mid-line).
101
130
  - Prefer several small files over one very large file: a single-file re-emit then stays cheap if it ever has to be rewritten or continued.
102
131
 
103
132
  A `[Selected element]` section describes a UI element the user picked in the running app's preview — its type, text, current style, and "created at" (the code that creates it). Apply the request to exactly that element, starting from the created-at location.
104
133
 
134
+ ## Building from a design
135
+
136
+ A `design/` folder is the app's finished design, made on the design board: `screens/<id>.ts` — one static mockup per screen, its states a function of a state-string union; `shared/tokens.ts` — the brand as a `theme()` table; `shared/ui.ts` — the component kit; `shared/tabs.ts` — the tab bar (`defineTabs`); `meta.json` — screen descriptions, roles, groups, and the navigation edges between screens; `spec.md` — the data model, actors, and rules. When asked to build or implement the app (or a screen) from it, the design is the authority:
137
+
138
+ - Implement every designed screen, including each state in its union. Navigation follows `meta.json` edges (an `id@state` edge switches that screen's state, not a push) and the tab bar in `shared/tabs.ts` — its `defineTabs({...})` entries carry over 1:1 into `UITabs({...})` (same keys, labels, and icons; add each tab's root `screen`), which renders the identical bar.
139
+ - IMPORT the kit, don't restyle: app code imports `design/shared/tokens.ts` (its `theme()` call makes design and app one live theme) and the presentational components of `design/shared/ui.ts`. Rebuild only the mock-only pieces (static field mocks, fake keyboards) as real interactive equivalents with the exact same look.
140
+ - The mock data at the top of each screen is the schema draft: replace it with real state, storage, and logic per `spec.md`, keeping the field shapes.
141
+ - App screens are your own files (`main.ts` + the usual project layout) — never import `design/screens/*` into the app, and never write into `design/**`: the design stays the reference. If the design itself needs changing, say so and suggest `<mode>design</mode>`.
142
+ - Match the mockups; don't re-design. Where a mockup leaves behavior undefined, `spec.md` decides; where it's silent, pick the simplest behavior consistent with the design.
143
+
105
144
  ## Tools
106
145
 
107
146
  You may be given tools (they appear in the API request, each with its own description). Default to
@@ -148,7 +187,7 @@ const dir = target.position.sub(self.position).normalize()
148
187
  self.position = self.position.add(dir.scale(speed * dt))
149
188
 
150
189
  // Node transforms have VALUE semantics — getters return copies:
151
- node.position.x = 3 // ✗ silent no-op (mutates a discarded copy)
190
+ node.position.x = 3 // ✓ compiled to node.x = 3 (direct spelling only — a STORED copy is a no-op)
152
191
  node.x = 3 // ✓ scalar setters x/y/z
153
192
  const p = node.position; p.y += 1; node.position = p // ✓ mutate local, assign back
154
193
 
@@ -163,9 +202,9 @@ Mathf.clamp(v, min, max) / .lerp(a, b, t) / .remap(v, inMin, inMax, outMin, outM
163
202
  Mathf.damp(a, b, lambda, dt) / .moveTowards(a, b, maxDelta)
164
203
  Mathf.random(min, max) / .randomInt(min, max) // randomInt inclusive both ends
165
204
 
166
- // Colors: hex string "#e33" | "#ff3333" | "#ff3333cc" or packed int 0xff3333.
167
- // rgba(...) / named colors DON'T work in 2D/3D APIs (silently become black). UI styles are the
168
- // exception — they also accept rgb()/rgba() and basic names ("white", "black", "transparent", …).
205
+ // Colors (every API, UI and engine alike): any CSS color string ("#e33", "#ff3333cc", "rgb(255 0 0 / 50%)",
206
+ // "hsl(0 100% 50%)", "red" — the CSS names, green = #008000), packed int 0xff3333 (opaque 0xRRGGBB),
207
+ // or [r, g, b(, a)] 0..1. A non-color throws. var(--x) only in UI styles.
169
208
 
170
209
  // ===== NETWORK =====
171
210
 
@@ -209,16 +248,22 @@ const sfx = new AudioPlayer(src) // src: asset('./x.mp3') or URL; same API: ne
209
248
  // .dispose() — frees the native player; ANY use after dispose throws
210
249
  // No pitch control, no auto-pooling: for overlapping SFX create several players up front and rotate.
211
250
 
212
- // ===== INPUT (keyboard) =====
213
-
214
- Input.key(code: string): boolean // is the key held NOW; KeyboardEvent.code names: 'KeyW', 'Space', 'ArrowLeft'
215
- // Poll-only — there are NO key events. For "pressed this frame", latch previous state:
216
- const wasDown: Record<string, boolean> = {}
217
- const pressed = (c: string) => Input.key(c) && !wasDown[c]
251
+ // ===== INPUT (keyboard / mouse / gamepad) =====
252
+ // A mouse button IS a key; so is a gamepad button. One code vocabulary: KeyboardEvent.code ('KeyW',
253
+ // 'Space', 'ArrowLeft'), 'MouseLeft|Right|Middle', 'GamepadSouth|East|West|North|L1|R1|L2|R2|Start|…'.
254
+ Input.key(code): boolean // held NOW — poll in setLoop for continuous movement (multiply by dt)
255
+ Input.on('keydown', e => …) // one-shot actions: e.code, e.repeat (auto-repeat), e.gamepad (pad index)
256
+ Input.on('keyup', e => …) // release (charged shots); Input.off(name, fn) to remove
257
+ Input.mouse.delta // { x, y } motion during the previous frame — FPS look; works while locked
258
+ Input.mouse.position / .wheel // cursor in logical px / wheel notches this frame
259
+ Input.mouse.lock() / unlock() / locked // hide + confine the cursor (call lock() from a keydown — web needs a gesture)
260
+ Input.gamepad(0).axis('leftX' | 'leftY' | 'rightX' | 'rightY' | 'leftTrigger' | 'rightTrigger') // −1..1 / 0..1
218
261
  setLoop(dt => {
219
- if (pressed('Space')) jump()
220
- for (const c of ['Space', 'KeyE']) wasDown[c] = Input.key(c) // latch at END of frame
262
+ yaw -= Input.mouse.delta.x * 0.003 + Input.gamepad(0).axis('rightX') * 2 * dt
263
+ const x = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) + Input.gamepad(0).axis('leftX')
221
264
  })
265
+ Input.on('keydown', e => { if (e.code === 'Space' || e.code === 'GamepadSouth') jump() })
266
+ // No one-frame "pressed" polls (keyDown/actionDown) — discrete = event, continuous = poll.
222
267
 
223
268
  // ===== TOUCH GESTURES =====
224
269
  // 'click' fires on pointer-up over a target; 'touchstart' on pointer-down. ev: { clientX, clientY,
@@ -266,17 +311,23 @@ implement what you can with the loaded ones.
266
311
  // Canvas: Canvas (retained 2D drawing baked to a texture — sprites/UI images), Bitmap
267
312
  // Aspects: Aspect base — attachable node capabilities: node.aspect(Class, opts), custom aspects
268
313
  // with per-frame update(dt)
269
- // UI: UIScreen, Router, UIRow, UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
314
+ // UI: UIScreen, Router, UITabs (bottom-tab shell), UIPager (tabs + per-tab stacks), UIRow,
315
+ // UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
270
316
  // UIButton, UIInput, UITextArea, UIWidget/UIModal/UIPopover/UIBottomSheet (floating
271
317
  // overlays: HUD / dialog / anchored menu / draggable sheet), UIVirtualizedList,
272
318
  // registerFont; UIBox (deprecated)
273
319
  // 2D engine: Scene2D, Node2D, Camera2D, Sprite, SpriteAnimation, Tilemap, Texture2D,
274
320
  // Shape2D + Physics2D + Trigger2D + CharacterController2D (Box2D physics, sensors,
275
- // raycast/overlap queries, picking)
321
+ // raycast/overlap queries, picking),
322
+ // defineScene2d (declarative *.scene2d.ts files) + cells + CameraFollow
276
323
  // 3D engine: Scene, Node, Camera, Mesh (box/sphere/cylinder/plane), Model (.glb) + ModelAnimation,
277
324
  // Geometry, Material (lit/unlit/custom shaders), Texture, Light.sun(),
278
325
  // Shape + Physics + Trigger + CharacterController (Jolt physics, raycast),
279
326
  // Particles + dynamic/dynamicColor, Ray, Plane, Noise
327
+ // 3D scene files: defineScene (declarative *.scene.ts read/written by the visual scene editor;
328
+ // default export is a SceneHandle: load()/open()) + use/ref/make in the def,
329
+ // scenario aspects MoveTo/FollowPath/Spin/LookAt/PlayAnimation;
330
+ // *.editor.ts + EDITOR/InspectorUI are editor-plugin files — never edit or import them
280
331
  // AR: ARScene (camera passthrough, anchors, placement gestures via addControls)
281
332
 
282
333
  // ===== CANVAS — retained drawing baked to a texture (engine-independent) =====
@@ -289,22 +340,23 @@ const c = new Canvas(width, height, { pixelRatio: device.pixelRatio })
289
340
  // width/height are LOGICAL units — author all drawing logical; pixelRatio only multiplies baked
290
341
  // resolution (crispness), never the on-screen size.
291
342
 
292
- c.fillStyle = '#ff8800' // CSS color STRINGS work here (the exception to engine hex-only colors);
293
- c.lineCap = 'round' // also strokeStyle, lineWidth, lineJoin, globalAlpha,
343
+ c.fillStyle = '#ff8800' // any CSS color string, as everywhere in the SDK;
344
+ c.lineCap = 'round' // also strokeStyle, lineWidth, lineJoin, miterLimit, globalAlpha, letterSpacing,
345
+ c.setLineDash([4, 2]) // dashes; a gradient: c.fillStyle = c.createLinearGradient(x0,y0,x1,y1).addColorStop(0,'#fff').addColorStop(1,'transparent')
294
346
  c.font = 'bold 16px sans-serif' // textAlign ('left'|'center'|'right'), textBaseline
295
347
  // custom fonts: draw only AFTER await registerFont(...)
296
348
  c.fillRect(x, y, w, h) / c.strokeRect / c.clearRect
297
- c.beginPath().moveTo(x,y).lineTo(x,y).arc(x,y,r,a0,a1).rect(x,y,w,h).roundRect(x,y,w,h,r).closePath() // all chain
298
- c.fill() / c.stroke() / c.fillText(text, x, y) / c.strokeText(text, x, y)
299
- c.measureText(text): { width, ascent, descent } // in the CURRENT font, logical px
300
- c.drawImage(bmp: Bitmap, dx, dy [, dw, dh]) // blits a Bitmap only (from c.toBitmap()), nothing else
349
+ c.beginPath().moveTo(x,y).lineTo(x,y).arc(x,y,r,a0,a1).arcTo(x1,y1,x2,y2,r).ellipse(x,y,rx,ry,rot,a0,a1).rect(x,y,w,h).roundRect(x,y,w,h,r).closePath() // all chain
350
+ c.fill('evenodd'?) / c.stroke() / c.clip() / c.fillText(text, x, y) / c.strokeText(text, x, y)
351
+ c.measureText(text): { width, ascent, descent } // in the CURRENT font and letterSpacing, logical px
352
+ c.drawImage(bmp: Bitmap, dx, dy [, dw, dh]) // blits a Bitmap only (c.toBitmap(), await c.loadImage(SvgSource(svg) | fetchResponse))
301
353
 
302
354
  c.update(): this // after redrawing, push new pixels to EVERY texture/image this canvas produced
303
355
  c.reset(): this // discard the recorded drawing to start over
304
356
  c.resize(w, h): this // new logical size (takes effect next bake; measure-then-resize works pre-bake)
305
357
  c.toBitmap(): Bitmap // rasterized snapshot, usable with drawImage
306
- // GOTCHA: reset() clears the recorded STATE too — font/fillStyle/textAlign fall back to defaults on
307
- // the next bake (getters still report old values). Re-set font & friends after every reset().
358
+ // GOTCHA: reset() clears the recorded STATE too — font/fillStyle/textAlign fall back to defaults
359
+ // (the getters report the defaults again). Re-set font & friends after every reset().
308
360
  // Ever-growing drawings: flatten — const snap = c.toBitmap(); c.reset(); c.drawImage(snap, 0, 0)
309
361
 
310
362
  // ===== UI RULES =====
@@ -315,32 +367,64 @@ c.toBitmap(): Bitmap // rasterized snapshot, usable with drawImage
315
367
 
316
368
  // ===== UI COMPONENTS =====
317
369
  // Every element is created by a global factory function (never `new`) that takes only the element's
318
- // CONTENT (children array, text, src, ...). Everything else — styles, handlers — is configured by chaining:
319
- // every configuring method returns the element itself, so construction reads as one chain.
370
+ // CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
371
+ // configured by chaining: every configuring method returns the element itself, so construction reads
372
+ // as one chain.
373
+ UIColumn(UIText("Title"), UIButton(UIText("Go")))
374
+ // An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
375
+ UIColumn(header, items.map(Row), footer)
320
376
 
321
377
  // UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
322
- UIRow(children) / UIColumn(children)
378
+ UIRow(...children) / UIColumn(...children)
323
379
  // Child management — imperative, no diffing; works before and after the element is on screen:
324
380
  // .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
325
381
  // .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
326
382
  // For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
327
383
 
328
384
  // UIScreen — root screen, always fills the device. Behaves as a UIColumn.
329
- UIScreen(children)
385
+ UIScreen(...children)
330
386
  // .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
331
387
  // .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
332
388
  // popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
333
389
  // in onClose (see TIMERS & FRAME LOOP above).
334
390
  // .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
335
- // .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
391
+ // .onBack(cb) — Android hardware/gesture back; typically Router.pop()
336
392
  // Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
337
- // with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
393
+ // with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
338
394
  // Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
339
395
 
396
+ // UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
397
+ // USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
398
+ const tabs = UITabs({
399
+ home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
400
+ profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
401
+ })
402
+ Router.init(tabs) // UITabs IS a UIScreen — present it directly
403
+ // .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
404
+ // .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
405
+ // .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
406
+ // Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
407
+ // restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
408
+
409
+ // UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
410
+ // OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
411
+ // tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
412
+ UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
413
+ // .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
414
+ // bar highlight here. Tabs keep their stack/scroll state when switched away and back.
415
+ // .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
416
+ // .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
417
+ // Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
418
+ // pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
419
+ // Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
420
+ // load initial data at build time, keep onOpen for re-entry.
421
+ const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
422
+ Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
423
+
340
424
  // UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
341
425
  // Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
342
- UIWidget(children)
343
- // .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
426
+ UIWidget(...children)
427
+ // .show(), .hide(), .isShown (getter), .onTouchStart(cb), .onBack(cb)
344
428
  // extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
345
429
  // a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
346
430
  // .onOverlayTap(cb) — usually () => widget.hide()
@@ -351,7 +435,7 @@ UIWidget(children)
351
435
  // UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
352
436
  // Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
353
437
  // back button close it automatically. Create ONCE at module scope, like any widget.
354
- UIModal(children)
438
+ UIModal(...children)
355
439
  // .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
356
440
  // — plus the full UIWidget surface
357
441
  // .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
@@ -362,10 +446,10 @@ UIModal(children)
362
446
  // UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
363
447
  // Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
364
448
  // dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
365
- UIBottomSheet(children)
449
+ UIBottomSheet(...children)
366
450
  // .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
367
451
  // BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
368
- // .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
452
+ // .setDetent(i) (animated), .detent (getter), .onDetent(cb(i)) — every settle: finger snap or setDetent()
369
453
  // .show()/.hide() slide in/out — plus the full UIModal surface (scrim, onOpen/onClose, dismissible).
370
454
  // Dragging below the lowest detent closes it (fires onClose); .dismissible(false) collapses there instead —
371
455
  // the persistent map sheet (pair it with overlayColor: null so the page behind stays interactive).
@@ -375,30 +459,33 @@ UIBottomSheet(children)
375
459
  // Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
376
460
  // automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
377
461
  // Attaches itself to Presentable.current (hides with the page it opened on).
378
- UIPopover(children)
462
+ UIPopover(...children)
379
463
  // .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
380
464
  // .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
381
465
  // Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
382
466
 
383
467
  // UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
384
- UIScrollable(children)
468
+ UIScrollable(...children)
385
469
  // extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
386
470
  // overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
387
471
  // keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
472
+ // snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
473
+ // direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
474
+ // the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
388
475
  // .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
389
476
  // .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
390
477
  // element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
391
478
  // Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
392
479
  // Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
393
480
  // themselves. flexGrow: 1 to fill the remaining space is still yours to set.
394
- // Carousel = scrollDirection: "horizontal" + FIXED-width cards (explicit width on each card, gap/px on the scrollable)
481
+ // Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
395
482
 
396
483
  // UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
397
484
  // visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
398
485
  // is large, grows over time, or is unknown.
399
486
  UIVirtualizedList<T>({
400
487
  keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
401
- render: (item: T) => UIElement, // builds one row; called lazily as rows enter the window
488
+ render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
402
489
  estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
403
490
  overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
404
491
  inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
@@ -447,12 +534,13 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
447
534
 
448
535
  // UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
449
536
  // To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
450
- UIButton(children?)
451
- // .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
452
- // extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
537
+ UIButton(...children)
538
+ // .onClick(cb), .onTouchStart(cb), .isPressed (getter) // gestures: see TOUCH GESTURES above
539
+ // .onMouseEnter(ev => ev.track({ onMove?, onEnd?, onCancel? })), .isHovered (getter) // mouse only; no leave event — onEnd is the leave
540
+ // extra styles: $pressed: { bgColor, opacity, ..., duration? } — style while the finger is down (reserved class);
453
541
  // rippleColor ("default" or a Color, Android only)
454
542
  // Note: press feedback is OPT-IN — nothing happens visually unless you set it. rippleColor replaces the
455
- // onPressed visual on Android, so rippleColor + onPressed = ripple on Android, onPressed dim on iOS.
543
+ // $pressed visual on Android, so rippleColor + $pressed = ripple on Android, $pressed dim on iOS.
456
544
  // Note: buttons render no chrome of their own — style bgColor/borderRadius/padding yourself, and give a
457
545
  // button an explicit height (on its own it is only as tall as its text). flexDirection: "column" for cards.
458
546
 
@@ -466,7 +554,7 @@ UIInput() / UITextArea()
466
554
  // maxLength: number; autocapitalize: "none"|"words"|"sentences"|"characters"; autocorrect: boolean
467
555
  // keyboardShrink: boolean (default true) — false: keyboard OVERLAYS the UI, no relayout (chat composers)
468
556
  // keyboardDismiss: boolean (default true) — false: taps never dismiss the keyboard (chat composers)
469
- // onFocused: { borderColor, ..., duration? } — style while focused (like onPressed)
557
+ // $focused: { borderColor, ..., duration? } — style while focused (reserved class)
470
558
  // Note: type values are KEYBOARD HINTS, not validators — "number" doesn't block pasted letters. The
471
559
  // phone-pad value is "phone" (there is NO "tel").
472
560
  // Note: "date"/"time" open the native PICKER in the keyboard slot (typing disabled). .value and onChange stay
@@ -486,7 +574,7 @@ UIInput() / UITextArea()
486
574
 
487
575
  // UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
488
576
  // Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
489
- UIRow([ title, UISpacer(), closeButton ])
577
+ UIRow(title, UISpacer(), closeButton)
490
578
  // If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
491
579
 
492
580
  // @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
@@ -501,16 +589,20 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
501
589
  // 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
502
590
  // 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
503
591
  // e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
504
- // 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
592
+ // 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
505
593
  // Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
506
594
  // the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
595
+ // loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
596
+ // (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
597
+ // animation is an effect, not a state change: it never commits, delay applies once, and it stops on
598
+ // the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
507
599
  // 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
508
600
  // Never modifies the stored style.
509
601
  // For free-value tweens use animate() — see ANIMATION above.
510
602
  // Older code may pass a style object as the FIRST factory argument — legacy; write new styles with .style().
511
603
 
512
604
  // Mutable CONTENT properties are NOT styles — they live on the element:
513
- // UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed()
605
+ // UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed (read-only)
514
606
  myText.text = "Updated" // re-renders immediately
515
607
 
516
608
  // Style<T> is a global helper type for reusable style objects, no import needed:
@@ -548,9 +640,8 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
548
640
  // but do NOT support % ("calc(100% - 20px)" is invalid; use "calc(100vw - 20px)")
549
641
 
550
642
  // --- Colors in UI styles ---
551
- // hex "#f33"/"#f33c"/"#ff3333"/"#ff3333cc", packed int 0xff3333, "rgb(...)"/"rgba(...)", and exactly these names:
552
- // white black red green blue yellow orange purple gray cyan magenta brown transparent clear
553
- // (UI styles only — engine APIs are hex/packed-int only, see COLORS above)
643
+ // any CSS color (hex, rgb()/hsl(), the CSS names — green = #008000, transparent/clear), 0xRRGGBB, [r,g,b(,a)],
644
+ // or "var(--x)" — the same colors as every engine API (see COLORS above); a non-color throws
554
645
 
555
646
  // --- Safe areas & comfort ---
556
647
  // Env keywords resolving to the device insets (notch, home indicator):
@@ -576,14 +667,30 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
576
667
  // Bare comfort-* tokens apply the safe-area formula; "var(--comfort-top)" reads the raw knob.
577
668
  // "var(--name, fallback)" applies the fallback while the key is unset. Env names (safe-*, vw/vh) are not theme keys.
578
669
 
579
- // --- Responsive: onLandscape / onPortrait ---
580
- // Any style can nest orientation overrides, merged on top while the device is in that orientation:
581
- .style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
582
- // Keys: onLandscape, onPortrait.
670
+ // --- Style classes ($name) — the ONLY state mechanism; they CASCADE ---
671
+ // A $-prefixed key in .style() declares a style state (yours: selected, checked, expanded);
672
+ // duration/delay/easing inside the block animate the swap. Drive yours via el.class:
673
+ const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
674
+ item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
675
+ // el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
676
+ // form; names work with or without the $. Class state persists across screen close/reopen.
677
+ // CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
678
+ // one toggle restyles a whole composite control, each part declaring its own reaction:
679
+ UIButton(
680
+ UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
681
+ UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
682
+ ) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
683
+ // RESERVED — the system toggles them, el.class.pressed = … throws:
684
+ // $hovered / $pressed / $focused (mouse over / finger down / input focused) cascade to children but STOP at
685
+ // a nested UIButton — a button inside a pressed card is not pressed;
686
+ // $landscape / $portrait — GLOBAL, from the display size (the swap on rotation is instant).
687
+ UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $hovered: { opacity: 0.9 }, $pressed: { transform: "scale(0.97)" } })
688
+ screen.style({ flexDirection: "column", p: 16, $landscape: { flexDirection: "row", p: 32 } })
689
+ // Precedence on the same prop: base < $landscape/$portrait < $classes < $hovered < $pressed < $focused.
583
690
 
584
691
  // --- Defaults that surprise ---
585
- // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and UIVirtualizedList default
586
- // flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
692
+ // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
693
+ // default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
587
694
  // flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
588
695
  // alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
589
696
  // overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
@@ -600,10 +707,10 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
600
707
  // ===== REUSABLE COMPONENTS =====
601
708
  // Extract repeated UI into factory functions — they return elements you can chain methods on:
602
709
 
603
- const ClickableCard = (title: string, subtitle: string) => UIButton([
710
+ const ClickableCard = (title: string, subtitle: string) => UIButton(
604
711
  UIText(title).style({ fontWeight: 700, color: "white" }),
605
712
  UIText(subtitle).style({ color: "#888", fontSize: 13 })
606
- ]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
713
+ ).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
607
714
 
608
715
  // Use like any other element — chain after the call:
609
716
  ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
@@ -612,15 +719,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
612
719
  const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
613
720
 
614
721
  // ===== CAPTURING ELEMENT REFERENCES =====
615
- // Use an assignment expression inside the children array — standard TypeScript:
722
+ // Use an assignment expression right in the children — standard TypeScript:
616
723
 
617
724
  let label: UIText
618
725
  let input: UIInput
619
726
 
620
- UIColumn([
727
+ UIColumn(
621
728
  label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
622
729
  input = UIInput(),
623
- ])
730
+ )
624
731
 
625
732
  // Later:
626
733
  label.text = "Updated"
@@ -630,15 +737,17 @@ input.value // read current value
630
737
  // ((button.children[0] as UIText).text vs buttonText.text).
631
738
 
632
739
  // ===== CONDITIONAL CHILDREN =====
633
- // null / undefined / false in a children array is skipped — no element, no layout slot.
634
- UIColumn([ header, isLoading ? spinner : null, showFooter && footer ])
740
+ // A null / undefined / false child is skipped — no element, no layout slot.
741
+ UIColumn(header, isLoading ? spinner : null, showFooter && footer)
742
+ // Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
743
+ UIColumn(header, showList && items.map(Row))
635
744
 
636
745
  // ===== SIZING: the two axes behave differently =====
637
746
  // MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
638
747
  // without flexGrow: 1 (no implicit min-sizes).
639
748
  // CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
640
749
  // or alignSelf to opt out.
641
- // So UIRow([ UIInput() ]) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
750
+ // So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
642
751
  // EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
643
752
  // equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
644
753
  // longer label stays wider.
@@ -658,6 +767,7 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
658
767
  // Position once at an interaction — never poll per frame.
659
768
 
660
769
  // ===== ROUTER (multi-page apps) =====
770
+ // Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
661
771
  Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
662
772
  Router.push(screen) // push onto the stack, screen becomes active
663
773
  Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
@@ -699,6 +809,14 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
699
809
  UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
700
810
  // Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
701
811
 
812
+ // ===== ICONS (assetIcon) =====
813
+ // Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
814
+ // inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
815
+ UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
816
+ // The id must be a string literal; an unknown id is a compile error (with name suggestions).
817
+ // Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
818
+ // Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
819
+
702
820
  // ===== COMMON MISTAKES — DO NOT DO THESE =====
703
821
 
704
822
  // ❌ CSS that doesn't exist here
@@ -707,16 +825,13 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
707
825
  lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
708
826
 
709
827
  // ❌ touch handler on a non-touch element
710
- UIColumn([...]).onTouchStart(...) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
711
-
712
- // ❌ scrollable that overflows the screen
713
- // A wrapping container between the scrollable and the screen is missing flexShrink: 1
828
+ UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
714
829
 
715
- // ❌ let/const inside children array — `let` is a statement, not an expression
716
- UIRow([ let input = UIInput() ]) // WRONG
830
+ // ❌ let/const among the children — `let` is a statement, not an expression
831
+ UIRow(let input = UIInput()) // WRONG
717
832
  // ✅ declare outside, assign inside (assignment both sets the var AND appends)
718
833
  let input: UIInput
719
- UIRow([ input = UIInput() ])
834
+ UIRow(input = UIInput())
720
835
 
721
836
  // ❌ setting a UIImage's content through bgImage
722
837
  const img = UIImage("") // WRONG — empty source as a placeholder
@@ -726,30 +841,30 @@ const img = UIImage(url)
726
841
  img.src = newUrl // updates the displayed image
727
842
 
728
843
  // ❌ expecting an input to fill width like in CSS
729
- UIRow([ UIInput() ]) // WRONG — collapses to placeholder width
844
+ UIRow(UIInput()) // WRONG — collapses to placeholder width
730
845
  // ✅ stretch it explicitly
731
- UIColumn([ UIInput().style({ width: "100%" }) ]) // cross axis
732
- UIRow([ input = UIInput().style({ flexGrow: 1 }), sendBtn ]) // main axis
846
+ UIColumn(UIInput().style({ width: "100%" })) // cross axis
847
+ UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
733
848
 
734
849
  // ❌ a button that should match the input's height but shrinks to its text
735
- UIRow([ input.style({ height: 40 }), UIButton([...]) ]) // button ends up shorter
850
+ UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
736
851
  // ✅ give controls the same explicit height
737
- UIRow([ input.style({ height: 40 }), UIButton([...]).style({ height: 40 }) ])
852
+ UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
738
853
 
739
854
  // ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
740
- UIRow([ yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 }) ]) // longer label = wider button
855
+ UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
741
856
  // ✅ flex: 1 — grows from a zero basis, children end up equal
742
- UIRow([ yes.style({ flex: 1 }), no.style({ flex: 1 }) ])
857
+ UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
743
858
 
744
859
  // ❌ empty containers as spacers to align children (web habit)
745
- UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
860
+ UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
746
861
  // ✅ alignment is a CONTAINER property, not an extra element
747
- UIRow([ label ]).style({ justifyContent: "flex-end" })
862
+ UIRow(label).style({ justifyContent: "flex-end" })
748
863
 
749
864
  // ❌ empty element as a placeholder for a conditional child
750
- UIRow([ isGroup ? button : UIColumn([]) ]) // WRONG
865
+ UIRow(isGroup ? button : UIColumn()) // WRONG
751
866
  // ✅ null is skipped in children — no phantom element
752
- UIRow([ isGroup ? button : null ])
867
+ UIRow(isGroup ? button : null)
753
868
 
754
869
  // ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
755
870
  UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
@@ -763,7 +878,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
763
878
  list.onEndReached(600, loadNextPage)
764
879
 
765
880
  // ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
766
- const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new widget every call
881
+ const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
767
882
  // ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
768
883
 
769
884
  // ===== EXAMPLES =====
@@ -771,7 +886,7 @@ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new wid
771
886
  // === EXAMPLE 1: Single-file app ===
772
887
  <file name="main.ts">
773
888
  let text: UIText
774
- const screen = UIScreen([
889
+ const screen = UIScreen(
775
890
  text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
776
891
  UIText("Your name:").style({ textAlign: "center" }),
777
892
  UIInput()
@@ -779,91 +894,188 @@ const screen = UIScreen([
779
894
  .onChange(str => {
780
895
  text.text = `Hello, ${str}!`
781
896
  })
782
- ]).style({ justifyContent: "center", p: 20, gap: 8 })
897
+ ).style({ justifyContent: "center", p: 20, gap: 8 })
783
898
 
784
899
  screen.open()
785
900
  </file>
786
901
 
787
- // === EXAMPLE 2: Fetching data with loading state ===
902
+ // === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
903
+ // A tokens module every screen imports, light-themed: theme({ color }) sets the default text
904
+ // color ONCE — no color: "#111" on every label.
905
+ <file name="tokens.ts">
906
+ const palette = {
907
+ bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
908
+ text: "#131A17", muted: "#606B65",
909
+ accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
910
+ }
911
+ theme({ color: palette.text, primaryColor: palette.accent })
912
+ // Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
913
+ export const colors: { [K in keyof typeof palette]: string } = theme(palette)
914
+ export const font = { // type scale — spread into styles: .style({ ...font.h2 })
915
+ h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
916
+ small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
917
+ }
918
+ </file>
788
919
  <file name="main.ts">
920
+ import { colors, font } from './tokens'
921
+
789
922
  type User = { id: number; name: string; email: string }
790
923
 
791
- let statusText: UIText
792
- let list: UIColumn
924
+ const Row = (u: User) => UIRow(
925
+ UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
926
+ .style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
927
+ justifyContent: "center", alignItems: "center" }),
928
+ UIColumn(
929
+ UIText(u.name).style({ ...font.bodyStrong }),
930
+ UIText(u.email).style({ ...font.small, color: colors.muted }),
931
+ ).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
932
+ ).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
933
+ border: `1px solid ${colors.border}`, p: 14 })
793
934
 
794
- const loadData = async () => {
795
- statusText.text = "Loading..."
935
+ const Centered = (...children: UINodeChild[]) =>
936
+ UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
937
+
938
+ let body: UIColumn
796
939
 
940
+ const loadData = async () => {
941
+ body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
797
942
  const res = await fetch("https://jsonplaceholder.typicode.com/users")
798
943
  if (res.status !== 200) {
799
- statusText.text = "Error loading data"
944
+ body.setContent([Centered(
945
+ UIText("Couldn't load users"),
946
+ UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
947
+ .style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
948
+ .onClick(() => loadData()),
949
+ )])
800
950
  return
801
951
  }
802
-
803
- const users = res.json<User[]>() // sync — no await
804
-
805
- statusText.text = ""
806
- list.setContent(
807
- users.map(user =>
808
- UIColumn([
809
- UIText(user.name).style({ fontWeight: 700, color: "white" }),
810
- UIText(user.email).style({ color: "#888", fontSize: 13 })
811
- ]).style({ px: 16, py: 12, gap: 4 })
812
- )
813
- )
952
+ body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
814
953
  }
815
954
 
816
- const screen = UIScreen([
817
- UIText("Users").style({ fontSize: 24, fontWeight: 700, color: "white", mb: 8 }),
818
- statusText = UIText("").style({ color: "#888" }),
819
- UIScrollable([
820
- list = UIColumn([])
821
- ]).style({ flexGrow: 1 }) // the ONE scrolling body — the screen itself never scrolls
822
- .onRefresh(() => loadData()) // pull-to-refresh; attached before screen.open()
823
- ])
824
- .style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
825
- .onOpen(() => loadData())
955
+ const screen = UIScreen(
956
+ UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
957
+ UIScrollable(
958
+ body = UIColumn().style({ flexGrow: 1, gap: 8 })
959
+ ).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
960
+ .onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
961
+ ).style({ bgColor: colors.bg, pt: "comfort-top" })
962
+ .onOpen(() => loadData())
826
963
 
827
964
  screen.open()
828
965
  </file>
829
966
 
830
- // === EXAMPLE 3: Simple app with navigation ===
967
+ // === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
831
968
  <file name="home.ts">
832
- import { detailScreen } from './detail'
833
-
834
- export const homeScreen = UIScreen([
835
- UIText("Home").style({ fontSize: 24, fontWeight: 700 }),
836
- UIButton([
837
- UIText("Go to detail").style({ color: "white" })
838
- ]).style({ bgColor: "#FF4032", borderRadius: 12, p: 16, rippleColor: "default", onPressed: { opacity: 0.7 } })
839
- .onClick(() => Router.push(detailScreen))
840
- ]).style({ p: 20, pt: "max(safe-top, 24px)", gap: 16 })
841
- </file>
842
- <file name="detail.ts">
843
- import chevronLeft from './assets/chevron-left.svg'
844
-
845
- export const detailScreen = UIScreen([
846
- UIButton([
847
- UIImage(chevronLeft).style({ width: 18, height: 18, tintColor: "#FF4032" }),
848
- UIText("Back").style({ color: "#FF4032" })
849
- ]).style({ alignSelf: "flex-start", flexDirection: "row", height: 32, gap: 4 }).onClick(() => Router.pop()),
850
- UIText("Detail Screen").style({ fontSize: 24, fontWeight: 700 })
851
- ])
852
- .style({ p: 20, pt: "max(safe-top, 24px)", gap: 20 })
853
- .onBackPressed(() => Router.pop())
969
+ const detailScreen = (name: string) => UIScreen(
970
+ UIButton(
971
+ UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
972
+ UIText("Back")
973
+ ).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
974
+ UIText(name).style({ fontSize: 24, fontWeight: 700 }),
975
+ UIButton(
976
+ UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
977
+ UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
978
+ ).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
979
+ .onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
980
+ ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
981
+
982
+ const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
983
+ .style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", $pressed: { opacity: 0.7 } })
984
+ .onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
985
+
986
+ export const homeScreen = UIScreen(
987
+ UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
988
+ UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
989
+ ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
854
990
  </file>
855
- <file name="assets/chevron-left.svg">
856
- <svg viewBox="0 0 24 24">
857
- <path d="M15 18l-6-6 6-6" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
858
- </svg>
991
+ <file name="profile.ts">
992
+ export const profileScreen = UIScreen(
993
+ UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
994
+ ).style({ p: 16, pt: "comfort-top", bgColor: "black" })
859
995
  </file>
860
996
  <file name="main.ts">
861
997
  import { homeScreen } from './home'
998
+ import { profileScreen } from './profile'
999
+
1000
+ theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
1001
+
1002
+ const tabs = UITabs({
1003
+ home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
1004
+ profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
1005
+ })
1006
+ tabs.badge("profile", true) // notification dot on the tab
1007
+ Router.init(tabs)
1008
+ </file>
1009
+
1010
+ // === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
1011
+ // The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
1012
+ // view and handles dismissal itself.
1013
+ <file name="main.ts">
1014
+ // Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
1015
+ // appearing error never jumps the form). Typing in an errored field clears it.
1016
+ const Field = (label: string, style: Style<UIInput> = {}) => {
1017
+ const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
1018
+ border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
1019
+ $focused: { borderColor: "#4C8DFF" }, ...style })
1020
+ const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
1021
+ input.onChange(() => message.text = "")
1022
+ return {
1023
+ node: UIColumn(
1024
+ UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
1025
+ input, message,
1026
+ ).style({ gap: 6 }),
1027
+ input,
1028
+ error: (text: string) => { message.text = text },
1029
+ }
1030
+ }
1031
+
1032
+ const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
1033
+ const email = Field("Email", { type: "email", placeholder: "you@example.com" })
1034
+ const password = Field("Password", { type: "password" })
1035
+
1036
+ // The return key walks the form; the last field submits. This is ALL the keyboard code.
1037
+ name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
1038
+ email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
1039
+ password.input.style({ enterKey: "go" }).onSubmit(() => submit())
1040
+
1041
+ let btnLabel: UIText
1042
+ let busy = false
1043
+
1044
+ // The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
1045
+ // first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
1046
+ const submit = async () => {
1047
+ if (busy) return
1048
+ let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
1049
+ if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
1050
+ if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
1051
+ if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
1052
+ if (bad) { bad.input.focus(); return }
1053
+ busy = true
1054
+ btnLabel.text = "Creating…"
1055
+ const res = await fetch("https://api.example.com/register", {
1056
+ method: "POST", headers: { "Content-Type": "application/json" },
1057
+ body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
1058
+ })
1059
+ busy = false
1060
+ btnLabel.text = "Create account"
1061
+ if (res.status !== 200) { email.error("Registration failed — try again"); return }
1062
+ toast("Welcome!")
1063
+ }
862
1064
 
863
- Router.init(homeScreen)
1065
+ const screen = UIScreen(
1066
+ UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
1067
+ UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
1068
+ name.node, email.node, password.node,
1069
+ UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
1070
+ .style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, $pressed: { opacity: 0.85 } })
1071
+ .onClick(() => submit())
1072
+ ).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
1073
+ ).style({ bgColor: "#0C0F14" })
1074
+
1075
+ screen.open()
864
1076
  </file>
865
1077
 
866
- // === EXAMPLE 4: UIBottomSheet — persistent map-style sheet with detents ===
1078
+ // === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
867
1079
  <file name="main.ts">
868
1080
  type Place = { id: number; name: string; distance: string }
869
1081
  const places: Place[] = [
@@ -872,32 +1084,32 @@ const places: Place[] = [
872
1084
  { id: 3, name: "Riverside Park", distance: "1.2 km" },
873
1085
  ]
874
1086
 
875
- const Row = (p: Place) => UIRow([
1087
+ const Row = (p: Place) => UIRow(
876
1088
  UIText(p.name).style({ color: "#111", fontSize: 16 }),
877
1089
  UIText(p.distance).style({ color: "#888", fontSize: 14 })
878
- ]).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
1090
+ ).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
879
1091
 
880
- const sheet = UIBottomSheet([
881
- UIColumn([]).style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
1092
+ const sheet = UIBottomSheet(
1093
+ UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
882
1094
  UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
883
1095
  UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
884
- ])
1096
+ )
885
1097
  .style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
886
1098
  .detents([0.25, 0.6, 1]) // collapsed / half / full
887
1099
  .dismissible(false) // drag below the lowest detent collapses, never closes
888
- .onDetentChange(i => console.log("detent", i))
1100
+ .onDetent(i => console.log("detent", i))
889
1101
 
890
- const mapScreen = UIScreen([
1102
+ const mapScreen = UIScreen(
891
1103
  // the map / page content behind the sheet
892
- ]).onOpen(() => sheet.show()).onClose(() => sheet.hide())
1104
+ ).onOpen(() => sheet.show()).onClose(() => sheet.hide())
893
1105
 
894
1106
  mapScreen.open()
895
1107
 
896
- // An action sheet is even less: content-sized, no detents() — UIBottomSheet([ ...rows ]).show(),
1108
+ // An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
897
1109
  // scrim and drag-down-to-dismiss included.
898
1110
  </file>
899
1111
 
900
- // === EXAMPLE 5: UIVirtualizedList — chat (inverted, imperative append) ===
1112
+ // === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
901
1113
  <file name="main.ts">
902
1114
  type Msg = { id: string; text: string; mine: boolean }
903
1115
 
@@ -909,37 +1121,41 @@ const list = UIVirtualizedList<Msg>({
909
1121
  keyOf: m => m.id,
910
1122
  estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
911
1123
  inverted: true, // newest at the bottom
912
- render: m => UIRow([
913
- UIRow([
1124
+ render: m => UIRow(
1125
+ UIRow(
914
1126
  UIText(m.text).style({ color: m.mine ? "white" : "#111" })
915
- ]).style({
1127
+ ).style({
916
1128
  bgColor: m.mine ? "#FF4032" : "#EEE",
917
1129
  px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
918
1130
  })
919
- ]).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
1131
+ ).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
920
1132
  }).style({ flexGrow: 1 })
921
1133
 
922
- let input: UIInput
1134
+ let input: UITextArea
1135
+ let sendBtn: UIButton
923
1136
 
924
1137
  const send = () => {
925
1138
  const text = input.value.trim()
926
1139
  if (!text) return
927
1140
  list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
928
1141
  input.value = ""
1142
+ sendBtn.style.opacity = 0.4
929
1143
  }
930
1144
 
931
- const screen = UIScreen([
1145
+ const screen = UIScreen(
932
1146
  list,
933
- UIRow([
934
- input = UIInput().style({
935
- flexGrow: 1, height: 40, px: 16, borderRadius: 20,
936
- bgColor: "#F0F0F0", placeholder: "Message...", placeholderColor: "#999"
1147
+ UIRow(
1148
+ input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
1149
+ color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
1150
+ keyboardDismiss: false
937
1151
  }),
938
- UIButton([ UIText("Send").style({ color: "white" }) ])
939
- .style({ height: 40, px: 16, justifyContent: "center", bgColor: "#FF4032", borderRadius: 20 })
1152
+ sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
1153
+ .style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
940
1154
  .onClick(send)
941
- ]).style({ p: 8, gap: 8, alignItems: "center" })
942
- ]).style({ bgColor: "white" })
1155
+ ).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
1156
+ ).style({ bgColor: "white" })
1157
+
1158
+ input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
943
1159
 
944
1160
  screen.open()
945
1161
  </file>