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.
- package/README.md +5 -2
- package/dist/editor.d.ts +12 -0
- package/dist/global.d.ts +4 -8
- package/dist/host.d.ts +2 -3
- package/dist/types/animate/tween/Animation.d.ts +0 -3
- package/dist/types/animate/tween/animateValue.d.ts +4 -2
- package/dist/types/animate/tween/easing.d.ts +8 -0
- package/dist/types/animate/tween/spec.d.ts +15 -7
- package/dist/types/audio/audio.d.ts +2 -1
- package/dist/types/canvas/Canvas.d.ts +40 -108
- package/dist/types/canvas/gen/cssColor.d.ts +17 -0
- package/dist/types/canvas/gen/recorder.d.ts +118 -0
- package/dist/types/canvas/gen/spec.d.ts +144 -0
- package/dist/types/core/color.d.ts +3 -1
- package/dist/types/core/pins.d.ts +18 -0
- package/dist/types/g2/Node2D.d.ts +5 -8
- package/dist/types/g2/Scene2D.d.ts +6 -2
- package/dist/types/gl/Foliage.d.ts +30 -7
- package/dist/types/gl/Light.d.ts +8 -0
- package/dist/types/gl/Lightmap.d.ts +13 -2
- package/dist/types/gl/Material.d.ts +17 -3
- package/dist/types/gl/Model.d.ts +6 -2
- package/dist/types/gl/Node.d.ts +3 -6
- package/dist/types/gl/Scene.d.ts +41 -13
- package/dist/types/gl/Texture.d.ts +1 -1
- package/dist/types/gl/animation/Locomotion.d.ts +8 -1
- package/dist/types/inject.d.ts +11 -11
- package/dist/types/inject.editor.d.ts +1 -0
- package/dist/types/net/core.d.ts +7 -0
- package/dist/types/plugin.d.ts +76 -0
- package/dist/types/plugins/gen/camera/sdk/camera.d.ts +24 -0
- package/dist/types/plugins/gen/camera/sdk/camera.gen.d.ts +25 -0
- package/dist/types/plugins/{geolocation.d.ts → gen/geolocation/sdk/geolocation.d.ts} +2 -20
- package/dist/types/plugins/gen/geolocation/sdk/geolocation.gen.d.ts +31 -0
- package/dist/types/plugins/{map.d.ts → gen/map/sdk/map.d.ts} +9 -50
- package/dist/types/plugins/gen/map/sdk/map.gen.d.ts +53 -0
- package/dist/types/plugins/gen/push/sdk/push.d.ts +23 -0
- package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +35 -0
- package/dist/types/plugins/{qr.d.ts → gen/qr-scanner/sdk/qr-scanner.d.ts} +2 -3
- package/dist/types/plugins/gen/qr-scanner/sdk/qr-scanner.gen.d.ts +15 -0
- package/dist/types/runtime/app.d.ts +9 -2
- package/dist/types/runtime/fetch.d.ts +2 -0
- package/dist/types/runtime/input.d.ts +1 -1
- package/dist/types/runtime/media.d.ts +6 -10
- package/dist/types/runtime/misc.d.ts +4 -1
- package/dist/types/runtime/net.d.ts +3 -2
- package/dist/types/runtime/touch.d.ts +32 -0
- package/dist/types/scene/defineScene.d.ts +43 -2
- package/dist/types/scene/editor.d.ts +52 -0
- package/dist/types/scene/gizmos.d.ts +7 -4
- package/dist/types/ui/NativeView.d.ts +6 -4
- package/dist/types/ui/UI.d.ts +1 -1
- package/dist/types/ui/UIBottomSheet.d.ts +6 -12
- package/dist/types/ui/UIButton.d.ts +12 -14
- package/dist/types/ui/UIContainer.d.ts +0 -6
- package/dist/types/ui/UIImage.d.ts +1 -4
- package/dist/types/ui/UIInput.d.ts +8 -24
- package/dist/types/ui/UIModal.d.ts +0 -2
- package/dist/types/ui/UINode.d.ts +70 -56
- package/dist/types/ui/UIPager.d.ts +28 -27
- package/dist/types/ui/UIPopover.d.ts +0 -2
- package/dist/types/ui/UIScreen.d.ts +12 -17
- package/dist/types/ui/UIScrollable.d.ts +1 -4
- package/dist/types/ui/UIText.d.ts +0 -2
- package/dist/types/ui/UIVideo.d.ts +3 -5
- package/dist/types/ui/UIVirtualizedList.d.ts +14 -16
- package/dist/types/ui/UIWidget.d.ts +10 -9
- package/dist/types/ui/colorKeys.gen.d.ts +9 -0
- package/dist/types/ui/presentable.d.ts +46 -32
- package/dist/types/ui/router.d.ts +18 -7
- package/dist/types/ui/styleColor.d.ts +1 -0
- package/dist/types/ui/transitions.d.ts +18 -0
- package/dist/types/ui/tree.d.ts +75 -0
- package/dist/types/version.d.ts +10 -0
- package/dist/types.json +1 -1
- package/package.json +12 -3
- package/prompts/2d.md +2 -6
- package/prompts/3d.md +1 -5
- package/prompts/README.md +1 -1
- package/prompts/canvas.md +9 -8
- package/prompts/compose.ts +1 -1
- package/prompts/core.md +3 -3
- package/prompts/design.md +1 -1
- package/prompts/dist/2d-game.md +473 -239
- package/prompts/dist/3d-app.md +553 -205
- package/prompts/dist/ar-app.md +435 -202
- package/prompts/dist/design.md +113 -95
- package/prompts/dist/ui-app.md +386 -170
- package/prompts/ui-design.md +2 -3
- package/prompts/ui.md +45 -37
- package/src/animate/tween/Animation.ts +34 -150
- package/src/animate/tween/Timeline.ts +175 -175
- package/src/animate/tween/animateValue.ts +6 -3
- package/src/animate/tween/easing.ts +10 -3
- package/src/animate/tween/spec.ts +41 -15
- package/src/audio/Sound.ts +3 -3
- package/src/audio/audio.ts +2 -1
- package/src/bridges/2d.d.ts +317 -0
- package/src/bridges/app.d.ts +91 -0
- package/src/bridges/audio.d.ts +97 -0
- package/src/bridges/canvas.d.ts +79 -0
- package/src/bridges/device.d.ts +72 -0
- package/src/bridges/fetch.d.ts +80 -0
- package/src/bridges/files.d.ts +70 -0
- package/src/bridges/gl.d.ts +1133 -0
- package/src/bridges/input.d.ts +72 -0
- package/src/bridges/media.d.ts +55 -0
- package/src/bridges/nav.d.ts +71 -0
- package/src/bridges/net.d.ts +52 -0
- package/src/bridges/service.d.ts +52 -0
- package/src/bridges/socket.d.ts +31 -0
- package/src/bridges/storage.d.ts +33 -0
- package/src/bridges/tree.d.ts +301 -0
- package/src/bridges/types.d.ts +49 -0
- package/src/canvas/Canvas.ts +114 -159
- package/src/canvas/gen/cssColor.ts +224 -0
- package/src/canvas/gen/recorder.ts +212 -0
- package/src/canvas/gen/spec.ts +201 -0
- package/src/chisel.ts +193 -0
- package/src/compile/assetMacro.ts +1 -1
- package/src/compile/bundler.ts +11 -2
- package/src/compile/compileProject.ts +43 -4
- package/src/compile/fontMacro.ts +3 -4
- package/src/compile/header.ts +26 -5
- package/src/compile/index.ts +3 -1
- package/src/compile/liteMaterial.ts +1 -1
- package/src/compile/sceneEditor.ts +11 -26
- package/src/core/color.ts +73 -30
- package/src/core/pins.ts +51 -0
- package/src/core/signals.ts +8 -1
- package/src/g2/CharacterController2D.ts +3 -3
- package/src/g2/Node2D.ts +57 -39
- package/src/g2/Physics2D.ts +2 -2
- package/src/g2/Scene2D.ts +35 -23
- package/src/g2/Texture2D.ts +1 -1
- package/src/g2/loop.ts +4 -4
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Foliage.ts +72 -17
- package/src/gl/Geometry.ts +1 -2
- package/src/gl/Light.ts +10 -0
- package/src/gl/Lightmap.ts +45 -29
- package/src/gl/Material.ts +95 -51
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Model.ts +21 -16
- package/src/gl/Node.ts +91 -24
- package/src/gl/Particles.ts +1 -1
- package/src/gl/Scene.ts +100 -44
- package/src/gl/Texture.ts +8 -7
- package/src/gl/animation/AnimationClip.ts +1 -1
- package/src/gl/animation/DynamicBone.ts +482 -482
- package/src/gl/animation/Locomotion.ts +8 -3
- package/src/gl/nav/NavMesh.ts +3 -4
- package/src/gl/physics/Physics.ts +2 -2
- package/src/gl/physics/physicsEvents.ts +3 -3
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/terrain/Terrain.ts +4 -5
- package/src/gl/touch.ts +14 -15
- package/src/host.d.ts +2 -3
- package/src/inject.editor.ts +7 -0
- package/src/inject.ts +13 -16
- package/src/net/core.ts +6 -5
- package/src/net/index.ts +1 -1
- package/src/net/replication.ts +1 -1
- package/src/plugin.ts +191 -0
- package/src/plugins/gen/camera/contract.d.ts +27 -0
- package/src/plugins/gen/camera/sdk/camera.gen.ts +46 -0
- package/src/plugins/gen/camera/sdk/camera.ts +57 -0
- package/src/plugins/gen/geolocation/contract.d.ts +50 -0
- package/src/plugins/gen/geolocation/sdk/geolocation.gen.ts +54 -0
- package/src/plugins/{geolocation.ts → gen/geolocation/sdk/geolocation.ts} +22 -43
- package/src/plugins/gen/map/contract.d.ts +144 -0
- package/src/plugins/gen/map/sdk/map.gen.ts +88 -0
- package/src/plugins/{map.ts → gen/map/sdk/map.ts} +68 -102
- package/src/plugins/gen/push/contract.d.ts +61 -0
- package/src/plugins/gen/push/sdk/push.gen.ts +60 -0
- package/src/plugins/gen/push/sdk/push.ts +105 -0
- package/src/plugins/gen/qr-scanner/contract.d.ts +16 -0
- package/src/plugins/gen/qr-scanner/sdk/qr-scanner.gen.ts +29 -0
- package/src/plugins/gen/qr-scanner/sdk/qr-scanner.ts +52 -0
- package/src/plugins/permission.ts +5 -4
- package/src/runtime/app.ts +20 -9
- package/src/runtime/appEvents.ts +5 -4
- package/src/runtime/channel.ts +18 -15
- package/src/runtime/clipboard.ts +4 -3
- package/src/runtime/datetime.ts +2 -1
- package/src/runtime/device.ts +17 -15
- package/src/runtime/fetch.ts +30 -20
- package/src/runtime/files.ts +16 -15
- package/src/runtime/input.ts +12 -10
- package/src/runtime/media.ts +50 -46
- package/src/runtime/misc.ts +7 -3
- package/src/runtime/net.ts +8 -7
- package/src/runtime/rpc.ts +1 -3
- package/src/runtime/service.ts +19 -14
- package/src/runtime/share.ts +4 -3
- package/src/runtime/storage.ts +6 -4
- package/src/runtime/touch.ts +32 -0
- package/src/scene/defineScene.ts +61 -365
- package/src/scene/editor.ts +408 -0
- package/src/scene/editorPlugins.ts +3 -3
- package/src/scene/gizmos.ts +15 -9
- package/src/server/db/marci/query.ts +1 -1
- package/src/server/host.ts +1 -1
- package/src/server/runtime.ts +1 -1
- package/src/ui/NativeView.ts +72 -25
- package/src/ui/UI.ts +3 -3
- package/src/ui/UIBottomSheet.ts +16 -17
- package/src/ui/UIButton.ts +54 -16
- package/src/ui/UIContainer.ts +0 -6
- package/src/ui/UIImage.ts +34 -30
- package/src/ui/UIInput.ts +29 -37
- package/src/ui/UIModal.ts +1 -3
- package/src/ui/UINode.ts +347 -297
- package/src/ui/UIPager.ts +93 -78
- package/src/ui/UIPopover.ts +0 -2
- package/src/ui/UIScreen.ts +41 -36
- package/src/ui/UIScrollable.ts +19 -13
- package/src/ui/UISpacer.ts +1 -1
- package/src/ui/UITabs.ts +8 -6
- package/src/ui/UIText.ts +7 -17
- package/src/ui/UIVideo.ts +30 -27
- package/src/ui/UIVirtualizedList.ts +58 -59
- package/src/ui/UIWidget.ts +38 -18
- package/src/ui/colorKeys.gen.ts +37 -0
- package/src/ui/fonts.ts +2 -2
- package/src/ui/presentable.ts +59 -42
- package/src/ui/router.ts +52 -35
- package/src/ui/styleColor.ts +56 -0
- package/src/ui/theme.ts +7 -6
- package/src/ui/transitions.ts +249 -0
- package/src/ui/tree.ts +346 -0
- package/src/version.ts +24 -0
- package/tests/helpers/engineWorld.ts +11 -0
- package/tests/helpers/fakeTree.ts +353 -0
- package/tests/helpers/hostStubs.ts +31 -0
- package/tests/helpers/index.ts +14 -0
- package/tests/helpers/memoryMarci.ts +124 -0
- package/tests/helpers/phases.ts +23 -0
- package/tests/helpers/preload.ts +18 -0
- package/tests/helpers/stubApp.ts +2 -0
- package/tests/helpers/stubDevice.ts +2 -0
- package/tests/helpers/stubFetch.ts +2 -0
- package/tests/helpers/stubInput.ts +2 -0
- package/dist/inject.js +0 -4629
- package/dist/types/core/registry.d.ts +0 -7
- package/dist/types/plugins/camera.d.ts +0 -25
- package/dist/types/plugins/push.d.ts +0 -46
- package/src/bridges.d.ts +0 -1769
- package/src/compile/__tests__/assetIconMacro.test.ts +0 -219
- package/src/compile/__tests__/assetMacro.test.ts +0 -100
- package/src/compile/__tests__/assetName.test.ts +0 -55
- package/src/compile/__tests__/compile.test.ts +0 -310
- package/src/compile/__tests__/detectEntry.test.ts +0 -151
- package/src/compile/__tests__/fontMacro.test.ts +0 -199
- package/src/compile/__tests__/serverSplit.test.ts +0 -27
- package/src/core/__tests__/stateMachine.test.ts +0 -132
- package/src/core/registry.ts +0 -23
- package/src/plugins/camera.ts +0 -81
- package/src/plugins/push.ts +0 -132
- package/src/plugins/qr.ts +0 -73
package/prompts/dist/ar-app.md
CHANGED
|
@@ -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
|
-
-
|
|
35
|
-
-
|
|
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.
|
|
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 <
|
|
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 //
|
|
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:
|
|
167
|
-
//
|
|
168
|
-
//
|
|
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
|
-
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
|
307
|
-
// the
|
|
358
|
+
// GOTCHA: reset() clears the recorded STATE too — font/fillStyle/textAlign fall back to defaults
|
|
359
|
+
// (the getters report the defaults again). Re-set font & friends after every reset().
|
|
308
360
|
// Ever-growing drawings: flatten — const snap = c.toBitmap(); c.reset(); c.drawImage(snap, 0, 0)
|
|
309
361
|
|
|
310
362
|
## 3D engine
|
|
@@ -355,15 +407,13 @@ node.lookAt(point: Vec3Like, mode = '-z', up = [0, 1, 0]): this // aim a local
|
|
|
355
407
|
// 'click' pointer-up over the node — REQUIRES a Shape aspect (pick body)
|
|
356
408
|
// 'touchstart' pointer-down over the node (ev.track() drags) — requires Shape
|
|
357
409
|
// 'enter' / 'exit' (other: Node) physics contact / trigger overlap began/ended — Shape + Physics/Trigger
|
|
358
|
-
// 'completed' (clip: number) non-looping GLB clip finished — Model only
|
|
359
|
-
// 'loopReached' (clip: number) looping GLB clip wrapped — Model only
|
|
360
410
|
// Without a Shape a node is INVISIBLE to taps — only scene listeners fire (ev.target === null).
|
|
361
411
|
|
|
362
412
|
// ===== ASPECTS =====
|
|
363
413
|
// Capabilities attach to nodes as aspects and chain; each adds a named accessor:
|
|
364
414
|
node.aspect(Class, opts?) // attach + configure; returns the node (typed with the accessor)
|
|
365
415
|
node.get(Class) / node.has(Class) / node.removeAspect(Class)
|
|
366
|
-
// Named accessors: node.physics, node.trigger, node.controller, node.shape,
|
|
416
|
+
// Named accessors: node.physics, node.trigger, node.controller, node.shape, node.anim
|
|
367
417
|
// Custom game logic = your own aspect with per-frame update(dt seconds):
|
|
368
418
|
class Spin extends Aspect<'spin', Node> { // <accessor name, node kind>
|
|
369
419
|
speed = 90 // class fields = configurable defaults (opts override)
|
|
@@ -397,7 +447,7 @@ material.map = tex // Texture | Canvas | null
|
|
|
397
447
|
|
|
398
448
|
// Custom compiled shaders (.mat files) — load, then set uniforms by name (chainable):
|
|
399
449
|
const mat = new Material(await fetch(asset('./hologram.mat'), { useOnce: true }))
|
|
400
|
-
.set('baseColor', '#ffa200') //
|
|
450
|
+
.set('baseColor', '#ffa200') // color STRINGS only (any CSS color); a number is a float
|
|
401
451
|
.set('roughness', 0.54) // number → float, boolean → bool
|
|
402
452
|
.set('glowMap', tex) // Texture → sampler
|
|
403
453
|
.set('dir', [0, 1, 0]) // number[] / Float32Array → vector. NOT a Vec3 — spread it: [...v]
|
|
@@ -431,24 +481,34 @@ const hero = await Model.load(asset('./hero.glb')) // asset path | https URL |
|
|
|
431
481
|
scene.add(hero) // a Model IS a Node — transform/events/aspects all apply
|
|
432
482
|
hero.traverse(n => { if (n.name === 'Sword') n.visible = false }) // GLB internals are plain child Nodes
|
|
433
483
|
|
|
434
|
-
// Animation — every Model has
|
|
435
|
-
|
|
436
|
-
hero.anim.
|
|
437
|
-
hero.anim.
|
|
438
|
-
hero.anim.
|
|
439
|
-
|
|
440
|
-
hero.anim.
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
hero.
|
|
444
|
-
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
484
|
+
// Animation — every Model has an Animator pre-attached at model.anim (clip list = the glb's clips). A layer
|
|
485
|
+
// has a LOOP (what it rests on) and ONE-SHOTS played over it; any call overrides what's there over its own fade:
|
|
486
|
+
hero.anim.clips / hero.anim.clip('Run') / hero.anim.clip(0) // AnimationClip[] (file order) / by name / by index; unnamed clips = 'animation_0', …
|
|
487
|
+
hero.anim.playLoop('Idle', { fade: 0.2 }) // rest on a looping clip (crossfades from whatever plays); hero.anim.loop reads it
|
|
488
|
+
const loco = hero.anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 }) // a BLEND as the loop (1D; [x, y] positions = 2D)
|
|
489
|
+
loco.value = speed // position on the axis — members stay in phase (no foot sliding)
|
|
490
|
+
const ok = await hero.anim.play('slash', { fadeIn: 0.1, fadeOut: 0.3 }) // one-shot over the loop, returns to it
|
|
491
|
+
// fades default to 0 (cut): fade = both ends, fadeIn / fadeOut = one end. Unknown name warns, plays nothing.
|
|
492
|
+
// The await resolves at the HAND-OVER (end − fadeOut) with true (false = cut short) — what you start right after is what
|
|
493
|
+
// the clip fades into: chains crossfade: if (ok) await hero.anim.play('slash2', { fadeIn: 0.3, fadeOut: 0.3 })
|
|
494
|
+
// then hero.anim.playLoop('Crouch', { fade: 0.3 }) — or nothing = back to the current loop.
|
|
495
|
+
if (!hero.anim.busy) hero.anim.play('kick') // busy = a one-shot hasn't handed over; play() always takes over otherwise
|
|
496
|
+
hero.anim.stop({ fade: 0.2 }) // everything off → rest; hero.anim.busy; hero.anim.speed = 0.3 (0 = pause)
|
|
497
|
+
// p = play(): p.done (return THIS from async fns) / p.playing / p.progress / p.weight / p.stop({ fade }) / p.seek(t)
|
|
498
|
+
// Clips from other files (Mixamo: one GLB per animation), procedural, sliced — add by name:
|
|
499
|
+
const [idle, slash] = await Promise.all([AnimationClip.load(asset('./idle.glb')), AnimationClip.load(asset('./attacks.glb'), 'Slash')])
|
|
500
|
+
hero.anim.addClip('slash', slash).addClip('kick', hero.anim.clip('Kick').slice(0.2, 1.1)) // slice = a sub-range, re-timed
|
|
501
|
+
AnimationClip.from({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } }) // curves in code, binds by node NAME
|
|
502
|
+
// Clip events live on the CLIP, in SECONDS: slash.addEvent(0.4, 'hit') → hero.anim.on('hit', (clip, layer) => dealDamage())
|
|
503
|
+
// Layers (legs walk, arms aim): const upper = hero.anim.addLayer({ mask: 'Spine1' }); upper.playLoop('Aim', { fade: 0.3 }); upper.stop()
|
|
504
|
+
// { additive: true } = each clip's DELTA vs its first frame on top (recoil, breathe); upper.weight = 0.5
|
|
505
|
+
// Root motion (clips whose hips travel — Mixamo without 'In Place', rolls): hero.anim.rootMotion = true → the node moves
|
|
506
|
+
// (or its CharacterController, as a velocity — it collides); the bone stays put
|
|
507
|
+
hero.bone('RightHand')?.add(sword) // bones are Nodes — sockets
|
|
508
|
+
// FBX assets (Mixamo exports FBX): `lecodes assets convert hero.fbx --clips Idle.fbx Run.fbx -o hero.glb` → one GLB, clips merged by bone name
|
|
509
|
+
// IK — late-phase aspects ON BONES (over the Animator's pose): TwoBone on the END bone, LookAt on the bone itself
|
|
510
|
+
hero.bone('LeftFoot').aspect(IK.TwoBone, { target: footPoint, pole: kneeHint }).ik.weight = grounded ? 1 : 0
|
|
511
|
+
hero.bone('Head').aspect(IK.LookAt, { target: camera, limit: 70 }) // axis = bone-local forward, default [0,0,-1]
|
|
452
512
|
|
|
453
513
|
// ===== PHYSICS (Jolt) =====
|
|
454
514
|
|
|
@@ -464,9 +524,18 @@ node.aspect(Shape, {
|
|
|
464
524
|
sphere?: number, // radius
|
|
465
525
|
cylinder?: { halfHeight: number, radius: number }, // Y-aligned
|
|
466
526
|
capsule?: { halfHeight: number, radius: number }, // halfHeight = cylinder section only
|
|
527
|
+
mesh?: true | 'convex', // the node's OWN triangles (Mesh geometry or Model GLB, × world scale)
|
|
528
|
+
origin?: Vec3Like, // collider centre, offset from the node's pivot (world units, node rotation, NOT scaled)
|
|
467
529
|
raycast?: boolean, // default true; false = invisible to taps & raycasts
|
|
468
530
|
})
|
|
531
|
+
// mesh: true = exact triangle mesh → level geometry / terrain / ramps: static or kinematic Physics, Trigger,
|
|
532
|
+
// pick bodies, character ground. NEVER dynamic (Physics throws). mesh: 'convex' = convex hull → dynamic props.
|
|
533
|
+
// A Model collider is its bind pose (skinned parts skipped) — animated characters keep a capsule.
|
|
469
534
|
node.aspect(Shape, {}) // auto: box from the mesh AABB × world scale — right for most meshes
|
|
535
|
+
// origin: art modelled ABOVE its pivot (a character on its feet, a barrel on its base) needs the
|
|
536
|
+
// collider lifted: { capsule: {…}, origin: [0, 0.9, 0] }. An auto shape already centres on its mesh.
|
|
537
|
+
node.shape.fit(kind?) // measure dimensions + origin from what the node RENDERS (subtree,
|
|
538
|
+
// so a Model root works where {} can't) and rebuild the live collider. Fit AFTER Model.load resolves.
|
|
470
539
|
// A bare Shape (no Physics) creates a STATIC pick-only body → the node gets 'click'/'touchstart'.
|
|
471
540
|
// It snapshots the transform at attach time — a MOVING pickable object needs a Physics body too.
|
|
472
541
|
|
|
@@ -476,11 +545,14 @@ node.aspect(Shape, {}).aspect(Physics, {
|
|
|
476
545
|
mass?: number, // kg, dynamic only; default 1
|
|
477
546
|
})
|
|
478
547
|
node.physics.velocity // Vec3 world units/s — get (fresh copy) / set
|
|
548
|
+
node.physics.angularVelocity // Vec3 DEGREES/s about each world axis — get / set
|
|
479
549
|
node.physics.applyImpulse(v): this // instant impulse, wakes the body
|
|
480
|
-
node.
|
|
481
|
-
|
|
482
|
-
//
|
|
483
|
-
//
|
|
550
|
+
node.position = [x, y, z] // place / teleport — moves the node AND its body (no moveTo())
|
|
551
|
+
node.eulerAngles = [0, 90, 0] // orientation is routed to the body too (NOT for a CharacterController)
|
|
552
|
+
// A teleport keeps BOTH velocities: putting an object down is velocity = 0, angularVelocity = 0, position = p
|
|
553
|
+
// PHYSICS OWNS a dynamic body's transform: a position write teleports, but the sim takes over again —
|
|
554
|
+
// drive motion with velocity / applyImpulse. READING node.position/worldPosition is always correct.
|
|
555
|
+
// static = never moves (floors, walls); kinematic = write position each frame, pushes but isn't pushed.
|
|
484
556
|
|
|
485
557
|
// Contact events — on BOTH nodes of a contact/overlap, arg = the other node:
|
|
486
558
|
crate.addEventListener('enter', (other: Node) => {})
|
|
@@ -524,7 +596,8 @@ sparks.destroy() // from Node — removes the system
|
|
|
524
596
|
|
|
525
597
|
scene.camera // a Node — never constructed; every scene owns one.
|
|
526
598
|
// (Who MOVES it is mode-specific — see the Scene/AR section.)
|
|
527
|
-
camera.fov
|
|
599
|
+
camera.fov / .near / .far // lens: vertical FOV degrees (60), clip 0.01 / 1000 — SETTABLE.
|
|
600
|
+
camera.setProjection({ fov, near, far }) // any subset; .horizontalFov, .displaySize stay read-only
|
|
528
601
|
camera.getViewDirection(screenX, screenY): Vec3 // world dir through a screen point (logical px = ev.clientX/Y)
|
|
529
602
|
camera.getRay(screenX, screenY): Ray // origin = camera.worldPosition + that direction
|
|
530
603
|
|
|
@@ -582,10 +655,6 @@ mat.set('lightDir', dir)
|
|
|
582
655
|
Light.point(...)
|
|
583
656
|
// ✅ Light.sun() + scene IBL is the whole lighting model; fake glows with unlit/bloom/particles
|
|
584
657
|
|
|
585
|
-
// ❌ rgba()/named colors in 3D APIs — silently black
|
|
586
|
-
Material.lit({ color: 'rgba(255, 0, 0, 0.5)' })
|
|
587
|
-
// ✅ hex string or packed int: '#ff0000' / '#ff000080' / 0xff0000
|
|
588
|
-
|
|
589
658
|
// ❌ a fresh Material per frame (or per particle) to animate a look
|
|
590
659
|
setLoop(() => { mesh.material = Material.lit({ color: next() }) })
|
|
591
660
|
// ✅ mutate the one material: mesh.material.color = next() / mat.set('progress', t)
|
|
@@ -670,7 +739,7 @@ try { await arScene.open() } catch { toast('Camera access is required for AR') }
|
|
|
670
739
|
const scene = new ARScene()
|
|
671
740
|
|
|
672
741
|
const hint = UIText('Move your phone to find the floor').style({ color: 'white', fontSize: 16 })
|
|
673
|
-
const hud = UIScreen(
|
|
742
|
+
const hud = UIScreen(hint).style({ p: 'safe-all', pb: 'comfort-bottom', justifyContent: 'flex-end', alignItems: 'center' })
|
|
674
743
|
|
|
675
744
|
async function main() {
|
|
676
745
|
const chair = await Model.load(asset('./chair.glb'))
|
|
@@ -708,32 +777,64 @@ main()
|
|
|
708
777
|
|
|
709
778
|
// ===== UI COMPONENTS =====
|
|
710
779
|
// Every element is created by a global factory function (never `new`) that takes only the element's
|
|
711
|
-
// CONTENT
|
|
712
|
-
// every configuring method returns the element itself, so construction reads
|
|
780
|
+
// CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
|
|
781
|
+
// configured by chaining: every configuring method returns the element itself, so construction reads
|
|
782
|
+
// as one chain.
|
|
783
|
+
UIColumn(UIText("Title"), UIButton(UIText("Go")))
|
|
784
|
+
// An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
|
|
785
|
+
UIColumn(header, items.map(Row), footer)
|
|
713
786
|
|
|
714
787
|
// UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
|
|
715
|
-
UIRow(children) / UIColumn(children)
|
|
788
|
+
UIRow(...children) / UIColumn(...children)
|
|
716
789
|
// Child management — imperative, no diffing; works before and after the element is on screen:
|
|
717
790
|
// .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
|
|
718
791
|
// .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
|
|
719
792
|
// For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
|
|
720
793
|
|
|
721
794
|
// UIScreen — root screen, always fills the device. Behaves as a UIColumn.
|
|
722
|
-
UIScreen(children)
|
|
795
|
+
UIScreen(...children)
|
|
723
796
|
// .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
|
|
724
797
|
// .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
|
|
725
798
|
// popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
|
|
726
799
|
// in onClose (see TIMERS & FRAME LOOP above).
|
|
727
800
|
// .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
|
|
728
|
-
// .
|
|
801
|
+
// .onBack(cb) — Android hardware/gesture back; typically Router.pop()
|
|
729
802
|
// Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
|
|
730
|
-
// with flexGrow: 1: UIScreen(
|
|
803
|
+
// with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
|
|
731
804
|
// Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
|
|
732
805
|
|
|
806
|
+
// UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
|
|
807
|
+
// USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
|
|
808
|
+
const tabs = UITabs({
|
|
809
|
+
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
810
|
+
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
811
|
+
})
|
|
812
|
+
Router.init(tabs) // UITabs IS a UIScreen — present it directly
|
|
813
|
+
// .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
|
|
814
|
+
// .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
|
|
815
|
+
// .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
|
|
816
|
+
// Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
|
|
817
|
+
// restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
|
|
818
|
+
|
|
819
|
+
// UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
|
|
820
|
+
// OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
|
|
821
|
+
// tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
|
|
822
|
+
UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
|
|
823
|
+
// .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
|
|
824
|
+
// bar highlight here. Tabs keep their stack/scroll state when switched away and back.
|
|
825
|
+
// .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
|
|
826
|
+
// .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
|
|
827
|
+
// Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
|
|
828
|
+
// pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
|
|
829
|
+
// Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
|
|
830
|
+
// load initial data at build time, keep onOpen for re-entry.
|
|
831
|
+
const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
|
|
832
|
+
Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
|
|
833
|
+
|
|
733
834
|
// UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
|
|
734
835
|
// Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
|
|
735
|
-
UIWidget(children)
|
|
736
|
-
// .show(), .hide(), .
|
|
836
|
+
UIWidget(...children)
|
|
837
|
+
// .show(), .hide(), .isShown (getter), .onTouchStart(cb), .onBack(cb)
|
|
737
838
|
// extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
|
|
738
839
|
// a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
|
|
739
840
|
// .onOverlayTap(cb) — usually () => widget.hide()
|
|
@@ -744,7 +845,7 @@ UIWidget(children)
|
|
|
744
845
|
// UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
|
|
745
846
|
// Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
|
|
746
847
|
// back button close it automatically. Create ONCE at module scope, like any widget.
|
|
747
|
-
UIModal(children)
|
|
848
|
+
UIModal(...children)
|
|
748
849
|
// .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
|
|
749
850
|
// — plus the full UIWidget surface
|
|
750
851
|
// .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
|
|
@@ -755,10 +856,10 @@ UIModal(children)
|
|
|
755
856
|
// UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
|
|
756
857
|
// Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
|
|
757
858
|
// dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
|
|
758
|
-
UIBottomSheet(children)
|
|
859
|
+
UIBottomSheet(...children)
|
|
759
860
|
// .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
|
|
760
861
|
// BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
|
|
761
|
-
// .setDetent(i) (animated), .detent (getter), .
|
|
862
|
+
// .setDetent(i) (animated), .detent (getter), .onDetent(cb(i)) — every settle: finger snap or setDetent()
|
|
762
863
|
// .show()/.hide() slide in/out — plus the full UIModal surface (scrim, onOpen/onClose, dismissible).
|
|
763
864
|
// Dragging below the lowest detent closes it (fires onClose); .dismissible(false) collapses there instead —
|
|
764
865
|
// the persistent map sheet (pair it with overlayColor: null so the page behind stays interactive).
|
|
@@ -768,30 +869,33 @@ UIBottomSheet(children)
|
|
|
768
869
|
// Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
|
|
769
870
|
// automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
|
|
770
871
|
// Attaches itself to Presentable.current (hides with the page it opened on).
|
|
771
|
-
UIPopover(children)
|
|
872
|
+
UIPopover(...children)
|
|
772
873
|
// .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
|
|
773
874
|
// .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
|
|
774
875
|
// Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
|
|
775
876
|
|
|
776
877
|
// UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
|
|
777
|
-
UIScrollable(children)
|
|
878
|
+
UIScrollable(...children)
|
|
778
879
|
// extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
|
|
779
880
|
// overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
|
|
780
881
|
// keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
|
|
882
|
+
// snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
|
|
883
|
+
// direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
|
|
884
|
+
// the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
|
|
781
885
|
// .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
|
|
782
886
|
// .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
|
|
783
887
|
// element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
|
|
784
888
|
// Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
|
|
785
889
|
// Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
|
|
786
890
|
// themselves. flexGrow: 1 to fill the remaining space is still yours to set.
|
|
787
|
-
// Carousel = scrollDirection: "horizontal" + FIXED-width cards
|
|
891
|
+
// Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
|
|
788
892
|
|
|
789
893
|
// UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
|
|
790
894
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
791
895
|
// is large, grows over time, or is unknown.
|
|
792
896
|
UIVirtualizedList<T>({
|
|
793
897
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
794
|
-
render: (item: T) =>
|
|
898
|
+
render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
|
|
795
899
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
796
900
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
797
901
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
@@ -840,12 +944,13 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
|
|
|
840
944
|
|
|
841
945
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
842
946
|
// To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
|
|
843
|
-
UIButton(children
|
|
844
|
-
// .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
|
|
845
|
-
//
|
|
947
|
+
UIButton(...children)
|
|
948
|
+
// .onClick(cb), .onTouchStart(cb), .isPressed (getter) // gestures: see TOUCH GESTURES above
|
|
949
|
+
// .onMouseEnter(ev => ev.track({ onMove?, onEnd?, onCancel? })), .isHovered (getter) // mouse only; no leave event — onEnd is the leave
|
|
950
|
+
// extra styles: $pressed: { bgColor, opacity, ..., duration? } — style while the finger is down (reserved class);
|
|
846
951
|
// rippleColor ("default" or a Color, Android only)
|
|
847
952
|
// Note: press feedback is OPT-IN — nothing happens visually unless you set it. rippleColor replaces the
|
|
848
|
-
//
|
|
953
|
+
// $pressed visual on Android, so rippleColor + $pressed = ripple on Android, $pressed dim on iOS.
|
|
849
954
|
// Note: buttons render no chrome of their own — style bgColor/borderRadius/padding yourself, and give a
|
|
850
955
|
// button an explicit height (on its own it is only as tall as its text). flexDirection: "column" for cards.
|
|
851
956
|
|
|
@@ -859,7 +964,7 @@ UIInput() / UITextArea()
|
|
|
859
964
|
// maxLength: number; autocapitalize: "none"|"words"|"sentences"|"characters"; autocorrect: boolean
|
|
860
965
|
// keyboardShrink: boolean (default true) — false: keyboard OVERLAYS the UI, no relayout (chat composers)
|
|
861
966
|
// keyboardDismiss: boolean (default true) — false: taps never dismiss the keyboard (chat composers)
|
|
862
|
-
//
|
|
967
|
+
// $focused: { borderColor, ..., duration? } — style while focused (reserved class)
|
|
863
968
|
// Note: type values are KEYBOARD HINTS, not validators — "number" doesn't block pasted letters. The
|
|
864
969
|
// phone-pad value is "phone" (there is NO "tel").
|
|
865
970
|
// Note: "date"/"time" open the native PICKER in the keyboard slot (typing disabled). .value and onChange stay
|
|
@@ -879,7 +984,7 @@ UIInput() / UITextArea()
|
|
|
879
984
|
|
|
880
985
|
// UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
|
|
881
986
|
// Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
|
|
882
|
-
UIRow(
|
|
987
|
+
UIRow(title, UISpacer(), closeButton)
|
|
883
988
|
// If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
|
|
884
989
|
|
|
885
990
|
// @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
|
|
@@ -894,16 +999,20 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
|
|
|
894
999
|
// 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
|
|
895
1000
|
// 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
|
|
896
1001
|
// e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
|
|
897
|
-
// 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
|
|
1002
|
+
// 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
|
|
898
1003
|
// Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
|
|
899
1004
|
// the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
|
|
1005
|
+
// loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
|
|
1006
|
+
// (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
|
|
1007
|
+
// animation is an effect, not a state change: it never commits, delay applies once, and it stops on
|
|
1008
|
+
// the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
|
|
900
1009
|
// 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
|
|
901
1010
|
// Never modifies the stored style.
|
|
902
1011
|
// For free-value tweens use animate() — see ANIMATION above.
|
|
903
1012
|
// Older code may pass a style object as the FIRST factory argument — legacy; write new styles with .style().
|
|
904
1013
|
|
|
905
1014
|
// Mutable CONTENT properties are NOT styles — they live on the element:
|
|
906
|
-
// UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed()
|
|
1015
|
+
// UIText: .text UIInput/UITextArea: .value UIImage: .src UIButton: .isPressed (read-only)
|
|
907
1016
|
myText.text = "Updated" // re-renders immediately
|
|
908
1017
|
|
|
909
1018
|
// Style<T> is a global helper type for reusable style objects, no import needed:
|
|
@@ -941,9 +1050,8 @@ const btn: Style<UIButton> = { bgColor: "#333", borderRadius: 12 }
|
|
|
941
1050
|
// but do NOT support % ("calc(100% - 20px)" is invalid; use "calc(100vw - 20px)")
|
|
942
1051
|
|
|
943
1052
|
// --- Colors in UI styles ---
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
// (UI styles only — engine APIs are hex/packed-int only, see COLORS above)
|
|
1053
|
+
// any CSS color (hex, rgb()/hsl(), the CSS names — green = #008000, transparent/clear), 0xRRGGBB, [r,g,b(,a)],
|
|
1054
|
+
// or "var(--x)" — the same colors as every engine API (see COLORS above); a non-color throws
|
|
947
1055
|
|
|
948
1056
|
// --- Safe areas & comfort ---
|
|
949
1057
|
// Env keywords resolving to the device insets (notch, home indicator):
|
|
@@ -969,14 +1077,30 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
969
1077
|
// Bare comfort-* tokens apply the safe-area formula; "var(--comfort-top)" reads the raw knob.
|
|
970
1078
|
// "var(--name, fallback)" applies the fallback while the key is unset. Env names (safe-*, vw/vh) are not theme keys.
|
|
971
1079
|
|
|
972
|
-
// ---
|
|
973
|
-
//
|
|
974
|
-
|
|
975
|
-
|
|
1080
|
+
// --- Style classes ($name) — the ONLY state mechanism; they CASCADE ---
|
|
1081
|
+
// A $-prefixed key in .style() declares a style state (yours: selected, checked, expanded);
|
|
1082
|
+
// duration/delay/easing inside the block animate the swap. Drive yours via el.class:
|
|
1083
|
+
const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
|
|
1084
|
+
item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
|
|
1085
|
+
// el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
|
|
1086
|
+
// form; names work with or without the $. Class state persists across screen close/reopen.
|
|
1087
|
+
// CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
|
|
1088
|
+
// one toggle restyles a whole composite control, each part declaring its own reaction:
|
|
1089
|
+
UIButton(
|
|
1090
|
+
UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
|
|
1091
|
+
UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
|
|
1092
|
+
) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
|
|
1093
|
+
// RESERVED — the system toggles them, el.class.pressed = … throws:
|
|
1094
|
+
// $hovered / $pressed / $focused (mouse over / finger down / input focused) cascade to children but STOP at
|
|
1095
|
+
// a nested UIButton — a button inside a pressed card is not pressed;
|
|
1096
|
+
// $landscape / $portrait — GLOBAL, from the display size (the swap on rotation is instant).
|
|
1097
|
+
UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $hovered: { opacity: 0.9 }, $pressed: { transform: "scale(0.97)" } })
|
|
1098
|
+
screen.style({ flexDirection: "column", p: 16, $landscape: { flexDirection: "row", p: 32 } })
|
|
1099
|
+
// Precedence on the same prop: base < $landscape/$portrait < $classes < $hovered < $pressed < $focused.
|
|
976
1100
|
|
|
977
1101
|
// --- Defaults that surprise ---
|
|
978
|
-
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and
|
|
979
|
-
// flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
1102
|
+
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
|
|
1103
|
+
// default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
980
1104
|
// flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
|
|
981
1105
|
// alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
|
|
982
1106
|
// overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
|
|
@@ -993,10 +1117,10 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
993
1117
|
// ===== REUSABLE COMPONENTS =====
|
|
994
1118
|
// Extract repeated UI into factory functions — they return elements you can chain methods on:
|
|
995
1119
|
|
|
996
|
-
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
1120
|
+
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
997
1121
|
UIText(title).style({ fontWeight: 700, color: "white" }),
|
|
998
1122
|
UIText(subtitle).style({ color: "#888", fontSize: 13 })
|
|
999
|
-
|
|
1123
|
+
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
1000
1124
|
|
|
1001
1125
|
// Use like any other element — chain after the call:
|
|
1002
1126
|
ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
@@ -1005,15 +1129,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
|
1005
1129
|
const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
|
|
1006
1130
|
|
|
1007
1131
|
// ===== CAPTURING ELEMENT REFERENCES =====
|
|
1008
|
-
// Use an assignment expression
|
|
1132
|
+
// Use an assignment expression right in the children — standard TypeScript:
|
|
1009
1133
|
|
|
1010
1134
|
let label: UIText
|
|
1011
1135
|
let input: UIInput
|
|
1012
1136
|
|
|
1013
|
-
UIColumn(
|
|
1137
|
+
UIColumn(
|
|
1014
1138
|
label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
|
|
1015
1139
|
input = UIInput(),
|
|
1016
|
-
|
|
1140
|
+
)
|
|
1017
1141
|
|
|
1018
1142
|
// Later:
|
|
1019
1143
|
label.text = "Updated"
|
|
@@ -1023,15 +1147,17 @@ input.value // read current value
|
|
|
1023
1147
|
// ((button.children[0] as UIText).text vs buttonText.text).
|
|
1024
1148
|
|
|
1025
1149
|
// ===== CONDITIONAL CHILDREN =====
|
|
1026
|
-
// null / undefined / false
|
|
1027
|
-
UIColumn(
|
|
1150
|
+
// A null / undefined / false child is skipped — no element, no layout slot.
|
|
1151
|
+
UIColumn(header, isLoading ? spinner : null, showFooter && footer)
|
|
1152
|
+
// Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
|
|
1153
|
+
UIColumn(header, showList && items.map(Row))
|
|
1028
1154
|
|
|
1029
1155
|
// ===== SIZING: the two axes behave differently =====
|
|
1030
1156
|
// MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
|
|
1031
1157
|
// without flexGrow: 1 (no implicit min-sizes).
|
|
1032
1158
|
// CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
|
|
1033
1159
|
// or alignSelf to opt out.
|
|
1034
|
-
// So UIRow(
|
|
1160
|
+
// So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
1035
1161
|
// EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
|
|
1036
1162
|
// equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
|
|
1037
1163
|
// longer label stays wider.
|
|
@@ -1051,6 +1177,7 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
|
|
|
1051
1177
|
// Position once at an interaction — never poll per frame.
|
|
1052
1178
|
|
|
1053
1179
|
// ===== ROUTER (multi-page apps) =====
|
|
1180
|
+
// Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
|
|
1054
1181
|
Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
|
|
1055
1182
|
Router.push(screen) // push onto the stack, screen becomes active
|
|
1056
1183
|
Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
|
|
@@ -1092,6 +1219,14 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
|
|
|
1092
1219
|
UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
|
|
1093
1220
|
// Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
|
|
1094
1221
|
|
|
1222
|
+
// ===== ICONS (assetIcon) =====
|
|
1223
|
+
// Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
|
|
1224
|
+
// inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
|
|
1225
|
+
UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
|
|
1226
|
+
// The id must be a string literal; an unknown id is a compile error (with name suggestions).
|
|
1227
|
+
// Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
|
|
1228
|
+
// Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
|
|
1229
|
+
|
|
1095
1230
|
// ===== COMMON MISTAKES — DO NOT DO THESE =====
|
|
1096
1231
|
|
|
1097
1232
|
// ❌ CSS that doesn't exist here
|
|
@@ -1100,16 +1235,13 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
|
|
|
1100
1235
|
lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
|
|
1101
1236
|
|
|
1102
1237
|
// ❌ touch handler on a non-touch element
|
|
1103
|
-
UIColumn(
|
|
1104
|
-
|
|
1105
|
-
// ❌ scrollable that overflows the screen
|
|
1106
|
-
// A wrapping container between the scrollable and the screen is missing flexShrink: 1
|
|
1238
|
+
UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
|
|
1107
1239
|
|
|
1108
|
-
// ❌ let/const
|
|
1109
|
-
UIRow(
|
|
1240
|
+
// ❌ let/const among the children — `let` is a statement, not an expression
|
|
1241
|
+
UIRow(let input = UIInput()) // WRONG
|
|
1110
1242
|
// ✅ declare outside, assign inside (assignment both sets the var AND appends)
|
|
1111
1243
|
let input: UIInput
|
|
1112
|
-
UIRow(
|
|
1244
|
+
UIRow(input = UIInput())
|
|
1113
1245
|
|
|
1114
1246
|
// ❌ setting a UIImage's content through bgImage
|
|
1115
1247
|
const img = UIImage("") // WRONG — empty source as a placeholder
|
|
@@ -1119,30 +1251,30 @@ const img = UIImage(url)
|
|
|
1119
1251
|
img.src = newUrl // updates the displayed image
|
|
1120
1252
|
|
|
1121
1253
|
// ❌ expecting an input to fill width like in CSS
|
|
1122
|
-
UIRow(
|
|
1254
|
+
UIRow(UIInput()) // WRONG — collapses to placeholder width
|
|
1123
1255
|
// ✅ stretch it explicitly
|
|
1124
|
-
UIColumn(
|
|
1125
|
-
UIRow(
|
|
1256
|
+
UIColumn(UIInput().style({ width: "100%" })) // cross axis
|
|
1257
|
+
UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
|
|
1126
1258
|
|
|
1127
1259
|
// ❌ a button that should match the input's height but shrinks to its text
|
|
1128
|
-
UIRow(
|
|
1260
|
+
UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
|
|
1129
1261
|
// ✅ give controls the same explicit height
|
|
1130
|
-
UIRow(
|
|
1262
|
+
UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
|
|
1131
1263
|
|
|
1132
1264
|
// ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
|
|
1133
|
-
UIRow(
|
|
1265
|
+
UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
|
|
1134
1266
|
// ✅ flex: 1 — grows from a zero basis, children end up equal
|
|
1135
|
-
UIRow(
|
|
1267
|
+
UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
|
|
1136
1268
|
|
|
1137
1269
|
// ❌ empty containers as spacers to align children (web habit)
|
|
1138
|
-
UIRow(
|
|
1270
|
+
UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
|
|
1139
1271
|
// ✅ alignment is a CONTAINER property, not an extra element
|
|
1140
|
-
UIRow(
|
|
1272
|
+
UIRow(label).style({ justifyContent: "flex-end" })
|
|
1141
1273
|
|
|
1142
1274
|
// ❌ empty element as a placeholder for a conditional child
|
|
1143
|
-
UIRow(
|
|
1275
|
+
UIRow(isGroup ? button : UIColumn()) // WRONG
|
|
1144
1276
|
// ✅ null is skipped in children — no phantom element
|
|
1145
|
-
UIRow(
|
|
1277
|
+
UIRow(isGroup ? button : null)
|
|
1146
1278
|
|
|
1147
1279
|
// ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
|
|
1148
1280
|
UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
|
|
@@ -1156,7 +1288,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
|
|
|
1156
1288
|
list.onEndReached(600, loadNextPage)
|
|
1157
1289
|
|
|
1158
1290
|
// ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
|
|
1159
|
-
const openSheet = () => UIWidget(
|
|
1291
|
+
const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
|
|
1160
1292
|
// ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
|
|
1161
1293
|
|
|
1162
1294
|
// ===== EXAMPLES =====
|
|
@@ -1164,7 +1296,7 @@ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new wid
|
|
|
1164
1296
|
// === EXAMPLE 1: Single-file app ===
|
|
1165
1297
|
<file name="main.ts">
|
|
1166
1298
|
let text: UIText
|
|
1167
|
-
const screen = UIScreen(
|
|
1299
|
+
const screen = UIScreen(
|
|
1168
1300
|
text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
|
|
1169
1301
|
UIText("Your name:").style({ textAlign: "center" }),
|
|
1170
1302
|
UIInput()
|
|
@@ -1172,91 +1304,188 @@ const screen = UIScreen([
|
|
|
1172
1304
|
.onChange(str => {
|
|
1173
1305
|
text.text = `Hello, ${str}!`
|
|
1174
1306
|
})
|
|
1175
|
-
|
|
1307
|
+
).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
1176
1308
|
|
|
1177
1309
|
screen.open()
|
|
1178
1310
|
</file>
|
|
1179
1311
|
|
|
1180
|
-
// === EXAMPLE 2:
|
|
1312
|
+
// === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
|
|
1313
|
+
// A tokens module every screen imports, light-themed: theme({ color }) sets the default text
|
|
1314
|
+
// color ONCE — no color: "#111" on every label.
|
|
1315
|
+
<file name="tokens.ts">
|
|
1316
|
+
const palette = {
|
|
1317
|
+
bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
|
|
1318
|
+
text: "#131A17", muted: "#606B65",
|
|
1319
|
+
accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
|
|
1320
|
+
}
|
|
1321
|
+
theme({ color: palette.text, primaryColor: palette.accent })
|
|
1322
|
+
// Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
|
|
1323
|
+
export const colors: { [K in keyof typeof palette]: string } = theme(palette)
|
|
1324
|
+
export const font = { // type scale — spread into styles: .style({ ...font.h2 })
|
|
1325
|
+
h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
|
|
1326
|
+
small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
|
|
1327
|
+
}
|
|
1328
|
+
</file>
|
|
1181
1329
|
<file name="main.ts">
|
|
1330
|
+
import { colors, font } from './tokens'
|
|
1331
|
+
|
|
1182
1332
|
type User = { id: number; name: string; email: string }
|
|
1183
1333
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1334
|
+
const Row = (u: User) => UIRow(
|
|
1335
|
+
UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
|
|
1336
|
+
.style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
|
|
1337
|
+
justifyContent: "center", alignItems: "center" }),
|
|
1338
|
+
UIColumn(
|
|
1339
|
+
UIText(u.name).style({ ...font.bodyStrong }),
|
|
1340
|
+
UIText(u.email).style({ ...font.small, color: colors.muted }),
|
|
1341
|
+
).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
|
|
1342
|
+
).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
|
|
1343
|
+
border: `1px solid ${colors.border}`, p: 14 })
|
|
1186
1344
|
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1345
|
+
const Centered = (...children: UINodeChild[]) =>
|
|
1346
|
+
UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
|
|
1347
|
+
|
|
1348
|
+
let body: UIColumn
|
|
1189
1349
|
|
|
1350
|
+
const loadData = async () => {
|
|
1351
|
+
body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
|
|
1190
1352
|
const res = await fetch("https://jsonplaceholder.typicode.com/users")
|
|
1191
1353
|
if (res.status !== 200) {
|
|
1192
|
-
|
|
1354
|
+
body.setContent([Centered(
|
|
1355
|
+
UIText("Couldn't load users"),
|
|
1356
|
+
UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
|
|
1357
|
+
.style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
|
|
1358
|
+
.onClick(() => loadData()),
|
|
1359
|
+
)])
|
|
1193
1360
|
return
|
|
1194
1361
|
}
|
|
1195
|
-
|
|
1196
|
-
const users = res.json<User[]>() // sync — no await
|
|
1197
|
-
|
|
1198
|
-
statusText.text = ""
|
|
1199
|
-
list.setContent(
|
|
1200
|
-
users.map(user =>
|
|
1201
|
-
UIColumn([
|
|
1202
|
-
UIText(user.name).style({ fontWeight: 700, color: "white" }),
|
|
1203
|
-
UIText(user.email).style({ color: "#888", fontSize: 13 })
|
|
1204
|
-
]).style({ px: 16, py: 12, gap: 4 })
|
|
1205
|
-
)
|
|
1206
|
-
)
|
|
1362
|
+
body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
|
|
1207
1363
|
}
|
|
1208
1364
|
|
|
1209
|
-
const screen = UIScreen(
|
|
1210
|
-
UIText("Users").style({
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
.style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
|
|
1218
|
-
.onOpen(() => loadData())
|
|
1365
|
+
const screen = UIScreen(
|
|
1366
|
+
UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
|
|
1367
|
+
UIScrollable(
|
|
1368
|
+
body = UIColumn().style({ flexGrow: 1, gap: 8 })
|
|
1369
|
+
).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
|
|
1370
|
+
.onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
|
|
1371
|
+
).style({ bgColor: colors.bg, pt: "comfort-top" })
|
|
1372
|
+
.onOpen(() => loadData())
|
|
1219
1373
|
|
|
1220
1374
|
screen.open()
|
|
1221
1375
|
</file>
|
|
1222
1376
|
|
|
1223
|
-
// === EXAMPLE 3:
|
|
1377
|
+
// === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
|
|
1224
1378
|
<file name="home.ts">
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
UIText("
|
|
1244
|
-
])
|
|
1245
|
-
.style({ p:
|
|
1246
|
-
.onBackPressed(() => Router.pop())
|
|
1379
|
+
const detailScreen = (name: string) => UIScreen(
|
|
1380
|
+
UIButton(
|
|
1381
|
+
UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
|
|
1382
|
+
UIText("Back")
|
|
1383
|
+
).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
|
|
1384
|
+
UIText(name).style({ fontSize: 24, fontWeight: 700 }),
|
|
1385
|
+
UIButton(
|
|
1386
|
+
UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
|
|
1387
|
+
UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
|
|
1388
|
+
).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
|
|
1389
|
+
.onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
|
|
1390
|
+
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
1391
|
+
|
|
1392
|
+
const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
|
|
1393
|
+
.style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", $pressed: { opacity: 0.7 } })
|
|
1394
|
+
.onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
|
|
1395
|
+
|
|
1396
|
+
export const homeScreen = UIScreen(
|
|
1397
|
+
UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
|
|
1398
|
+
UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
|
|
1399
|
+
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
1247
1400
|
</file>
|
|
1248
|
-
<file name="
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1401
|
+
<file name="profile.ts">
|
|
1402
|
+
export const profileScreen = UIScreen(
|
|
1403
|
+
UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
|
|
1404
|
+
).style({ p: 16, pt: "comfort-top", bgColor: "black" })
|
|
1252
1405
|
</file>
|
|
1253
1406
|
<file name="main.ts">
|
|
1254
1407
|
import { homeScreen } from './home'
|
|
1408
|
+
import { profileScreen } from './profile'
|
|
1409
|
+
|
|
1410
|
+
theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
|
|
1411
|
+
|
|
1412
|
+
const tabs = UITabs({
|
|
1413
|
+
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
1414
|
+
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
1415
|
+
})
|
|
1416
|
+
tabs.badge("profile", true) // notification dot on the tab
|
|
1417
|
+
Router.init(tabs)
|
|
1418
|
+
</file>
|
|
1419
|
+
|
|
1420
|
+
// === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
|
|
1421
|
+
// The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
|
|
1422
|
+
// view and handles dismissal itself.
|
|
1423
|
+
<file name="main.ts">
|
|
1424
|
+
// Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
|
|
1425
|
+
// appearing error never jumps the form). Typing in an errored field clears it.
|
|
1426
|
+
const Field = (label: string, style: Style<UIInput> = {}) => {
|
|
1427
|
+
const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
|
|
1428
|
+
border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
|
|
1429
|
+
$focused: { borderColor: "#4C8DFF" }, ...style })
|
|
1430
|
+
const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
|
|
1431
|
+
input.onChange(() => message.text = "")
|
|
1432
|
+
return {
|
|
1433
|
+
node: UIColumn(
|
|
1434
|
+
UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
|
|
1435
|
+
input, message,
|
|
1436
|
+
).style({ gap: 6 }),
|
|
1437
|
+
input,
|
|
1438
|
+
error: (text: string) => { message.text = text },
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
|
|
1443
|
+
const email = Field("Email", { type: "email", placeholder: "you@example.com" })
|
|
1444
|
+
const password = Field("Password", { type: "password" })
|
|
1445
|
+
|
|
1446
|
+
// The return key walks the form; the last field submits. This is ALL the keyboard code.
|
|
1447
|
+
name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
|
|
1448
|
+
email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
|
|
1449
|
+
password.input.style({ enterKey: "go" }).onSubmit(() => submit())
|
|
1450
|
+
|
|
1451
|
+
let btnLabel: UIText
|
|
1452
|
+
let busy = false
|
|
1453
|
+
|
|
1454
|
+
// The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
|
|
1455
|
+
// first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
|
|
1456
|
+
const submit = async () => {
|
|
1457
|
+
if (busy) return
|
|
1458
|
+
let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
|
|
1459
|
+
if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
|
|
1460
|
+
if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
|
|
1461
|
+
if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
|
|
1462
|
+
if (bad) { bad.input.focus(); return }
|
|
1463
|
+
busy = true
|
|
1464
|
+
btnLabel.text = "Creating…"
|
|
1465
|
+
const res = await fetch("https://api.example.com/register", {
|
|
1466
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1467
|
+
body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
|
|
1468
|
+
})
|
|
1469
|
+
busy = false
|
|
1470
|
+
btnLabel.text = "Create account"
|
|
1471
|
+
if (res.status !== 200) { email.error("Registration failed — try again"); return }
|
|
1472
|
+
toast("Welcome!")
|
|
1473
|
+
}
|
|
1255
1474
|
|
|
1256
|
-
|
|
1475
|
+
const screen = UIScreen(
|
|
1476
|
+
UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
|
|
1477
|
+
UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
|
|
1478
|
+
name.node, email.node, password.node,
|
|
1479
|
+
UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
|
|
1480
|
+
.style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, $pressed: { opacity: 0.85 } })
|
|
1481
|
+
.onClick(() => submit())
|
|
1482
|
+
).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
|
|
1483
|
+
).style({ bgColor: "#0C0F14" })
|
|
1484
|
+
|
|
1485
|
+
screen.open()
|
|
1257
1486
|
</file>
|
|
1258
1487
|
|
|
1259
|
-
// === EXAMPLE
|
|
1488
|
+
// === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
|
|
1260
1489
|
<file name="main.ts">
|
|
1261
1490
|
type Place = { id: number; name: string; distance: string }
|
|
1262
1491
|
const places: Place[] = [
|
|
@@ -1265,32 +1494,32 @@ const places: Place[] = [
|
|
|
1265
1494
|
{ id: 3, name: "Riverside Park", distance: "1.2 km" },
|
|
1266
1495
|
]
|
|
1267
1496
|
|
|
1268
|
-
const Row = (p: Place) => UIRow(
|
|
1497
|
+
const Row = (p: Place) => UIRow(
|
|
1269
1498
|
UIText(p.name).style({ color: "#111", fontSize: 16 }),
|
|
1270
1499
|
UIText(p.distance).style({ color: "#888", fontSize: 14 })
|
|
1271
|
-
|
|
1500
|
+
).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1272
1501
|
|
|
1273
|
-
const sheet = UIBottomSheet(
|
|
1274
|
-
UIColumn(
|
|
1502
|
+
const sheet = UIBottomSheet(
|
|
1503
|
+
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1275
1504
|
UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
|
|
1276
1505
|
UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
|
|
1277
|
-
|
|
1506
|
+
)
|
|
1278
1507
|
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
|
|
1279
1508
|
.detents([0.25, 0.6, 1]) // collapsed / half / full
|
|
1280
1509
|
.dismissible(false) // drag below the lowest detent collapses, never closes
|
|
1281
|
-
.
|
|
1510
|
+
.onDetent(i => console.log("detent", i))
|
|
1282
1511
|
|
|
1283
|
-
const mapScreen = UIScreen(
|
|
1512
|
+
const mapScreen = UIScreen(
|
|
1284
1513
|
// the map / page content behind the sheet
|
|
1285
|
-
|
|
1514
|
+
).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1286
1515
|
|
|
1287
1516
|
mapScreen.open()
|
|
1288
1517
|
|
|
1289
|
-
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(
|
|
1518
|
+
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
|
|
1290
1519
|
// scrim and drag-down-to-dismiss included.
|
|
1291
1520
|
</file>
|
|
1292
1521
|
|
|
1293
|
-
// === EXAMPLE
|
|
1522
|
+
// === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
|
|
1294
1523
|
<file name="main.ts">
|
|
1295
1524
|
type Msg = { id: string; text: string; mine: boolean }
|
|
1296
1525
|
|
|
@@ -1302,37 +1531,41 @@ const list = UIVirtualizedList<Msg>({
|
|
|
1302
1531
|
keyOf: m => m.id,
|
|
1303
1532
|
estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
|
|
1304
1533
|
inverted: true, // newest at the bottom
|
|
1305
|
-
render: m => UIRow(
|
|
1306
|
-
UIRow(
|
|
1534
|
+
render: m => UIRow(
|
|
1535
|
+
UIRow(
|
|
1307
1536
|
UIText(m.text).style({ color: m.mine ? "white" : "#111" })
|
|
1308
|
-
|
|
1537
|
+
).style({
|
|
1309
1538
|
bgColor: m.mine ? "#FF4032" : "#EEE",
|
|
1310
1539
|
px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
|
|
1311
1540
|
})
|
|
1312
|
-
|
|
1541
|
+
).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1313
1542
|
}).style({ flexGrow: 1 })
|
|
1314
1543
|
|
|
1315
|
-
let input:
|
|
1544
|
+
let input: UITextArea
|
|
1545
|
+
let sendBtn: UIButton
|
|
1316
1546
|
|
|
1317
1547
|
const send = () => {
|
|
1318
1548
|
const text = input.value.trim()
|
|
1319
1549
|
if (!text) return
|
|
1320
1550
|
list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
|
|
1321
1551
|
input.value = ""
|
|
1552
|
+
sendBtn.style.opacity = 0.4
|
|
1322
1553
|
}
|
|
1323
1554
|
|
|
1324
|
-
const screen = UIScreen(
|
|
1555
|
+
const screen = UIScreen(
|
|
1325
1556
|
list,
|
|
1326
|
-
UIRow(
|
|
1327
|
-
input =
|
|
1328
|
-
flexGrow: 1,
|
|
1329
|
-
|
|
1557
|
+
UIRow(
|
|
1558
|
+
input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
|
|
1559
|
+
color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
|
|
1560
|
+
keyboardDismiss: false
|
|
1330
1561
|
}),
|
|
1331
|
-
UIButton(
|
|
1332
|
-
.style({
|
|
1562
|
+
sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
|
|
1563
|
+
.style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
|
|
1333
1564
|
.onClick(send)
|
|
1334
|
-
|
|
1335
|
-
|
|
1565
|
+
).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
|
|
1566
|
+
).style({ bgColor: "white" })
|
|
1567
|
+
|
|
1568
|
+
input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
|
|
1336
1569
|
|
|
1337
1570
|
screen.open()
|
|
1338
1571
|
</file>
|