lecodes-sdk 1.1.0 → 1.2.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.
@@ -102,16 +102,6 @@ After every edit you make, the platform automatically verifies it — syntax, a
102
102
 
103
103
  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
104
 
105
- ## Building from a design
106
-
107
- 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:
108
-
109
- - 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.
110
- - 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.
111
- - 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.
112
- - 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>`.
113
- - 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.
114
-
115
105
  ## Tools
116
106
 
117
107
  You may be given tools (they appear in the API request, each with its own description). Default to
@@ -219,22 +209,16 @@ const sfx = new AudioPlayer(src) // src: asset('./x.mp3') or URL; same API: ne
219
209
  // .dispose() — frees the native player; ANY use after dispose throws
220
210
  // No pitch control, no auto-pooling: for overlapping SFX create several players up front and rotate.
221
211
 
222
- // ===== INPUT (keyboard / mouse / gamepad) =====
223
- // A mouse button IS a key; so is a gamepad button. One code vocabulary: KeyboardEvent.code ('KeyW',
224
- // 'Space', 'ArrowLeft'), 'MouseLeft|Right|Middle', 'GamepadSouth|East|West|North|L1|R1|L2|R2|Start|…'.
225
- Input.key(code): boolean // held NOW — poll in setLoop for continuous movement (multiply by dt)
226
- Input.on('keydown', e => …) // one-shot actions: e.code, e.repeat (auto-repeat), e.gamepad (pad index)
227
- Input.on('keyup', e => …) // release (charged shots); Input.off(name, fn) to remove
228
- Input.mouse.delta // { x, y } motion during the previous frame — FPS look; works while locked
229
- Input.mouse.position / .wheel // cursor in logical px / wheel notches this frame
230
- Input.mouse.lock() / unlock() / locked // hide + confine the cursor (call lock() from a keydown — web needs a gesture)
231
- Input.gamepad(0).axis('leftX' | 'leftY' | 'rightX' | 'rightY' | 'leftTrigger' | 'rightTrigger') // −1..1 / 0..1
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]
232
218
  setLoop(dt => {
233
- yaw -= Input.mouse.delta.x * 0.003 + Input.gamepad(0).axis('rightX') * 2 * dt
234
- const x = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) + Input.gamepad(0).axis('leftX')
219
+ if (pressed('Space')) jump()
220
+ for (const c of ['Space', 'KeyE']) wasDown[c] = Input.key(c) // latch at END of frame
235
221
  })
236
- Input.on('keydown', e => { if (e.code === 'Space' || e.code === 'GamepadSouth') jump() })
237
- // No one-frame "pressed" polls (keyDown/actionDown) — discrete = event, continuous = poll.
238
222
 
239
223
  // ===== TOUCH GESTURES =====
240
224
  // 'click' fires on pointer-up over a target; 'touchstart' on pointer-down. ev: { clientX, clientY,
@@ -282,23 +266,17 @@ implement what you can with the loaded ones.
282
266
  // Canvas: Canvas (retained 2D drawing baked to a texture — sprites/UI images), Bitmap
283
267
  // Aspects: Aspect base — attachable node capabilities: node.aspect(Class, opts), custom aspects
284
268
  // with per-frame update(dt)
285
- // UI: UIScreen, Router, UITabs (bottom-tab shell), UIPager (tabs + per-tab stacks), UIRow,
286
- // UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
269
+ // UI: UIScreen, Router, UIRow, UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
287
270
  // UIButton, UIInput, UITextArea, UIWidget/UIModal/UIPopover/UIBottomSheet (floating
288
271
  // overlays: HUD / dialog / anchored menu / draggable sheet), UIVirtualizedList,
289
272
  // registerFont; UIBox (deprecated)
290
273
  // 2D engine: Scene2D, Node2D, Camera2D, Sprite, SpriteAnimation, Tilemap, Texture2D,
291
274
  // Shape2D + Physics2D + Trigger2D + CharacterController2D (Box2D physics, sensors,
292
- // raycast/overlap queries, picking),
293
- // defineScene2d (declarative *.scene2d.ts files) + cells + CameraFollow
275
+ // raycast/overlap queries, picking)
294
276
  // 3D engine: Scene, Node, Camera, Mesh (box/sphere/cylinder/plane), Model (.glb) + ModelAnimation,
295
277
  // Geometry, Material (lit/unlit/custom shaders), Texture, Light.sun(),
296
278
  // Shape + Physics + Trigger + CharacterController (Jolt physics, raycast),
297
279
  // Particles + dynamic/dynamicColor, Ray, Plane, Noise
298
- // 3D scene files: defineScene (declarative *.scene.ts read/written by the visual scene editor;
299
- // default export is a SceneHandle: load()/open()) + use/ref/make in the def,
300
- // scenario aspects MoveTo/FollowPath/Spin/LookAt/PlayAnimation;
301
- // *.editor.ts + EDITOR/InspectorUI are editor-plugin files — never edit or import them
302
280
  // AR: ARScene (camera passthrough, anchors, placement gestures via addControls)
303
281
 
304
282
  // ===== CANVAS — retained drawing baked to a texture (engine-independent) =====
@@ -377,13 +355,15 @@ node.lookAt(point: Vec3Like, mode = '-z', up = [0, 1, 0]): this // aim a local
377
355
  // 'click' pointer-up over the node — REQUIRES a Shape aspect (pick body)
378
356
  // 'touchstart' pointer-down over the node (ev.track() drags) — requires Shape
379
357
  // '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
380
360
  // Without a Shape a node is INVISIBLE to taps — only scene listeners fire (ev.target === null).
381
361
 
382
362
  // ===== ASPECTS =====
383
363
  // Capabilities attach to nodes as aspects and chain; each adds a named accessor:
384
364
  node.aspect(Class, opts?) // attach + configure; returns the node (typed with the accessor)
385
365
  node.get(Class) / node.has(Class) / node.removeAspect(Class)
386
- // Named accessors: node.physics, node.trigger, node.controller, node.shape, node.anim
366
+ // Named accessors: node.physics, node.trigger, node.controller, node.shape, model.anim
387
367
  // Custom game logic = your own aspect with per-frame update(dt seconds):
388
368
  class Spin extends Aspect<'spin', Node> { // <accessor name, node kind>
389
369
  speed = 90 // class fields = configurable defaults (opts override)
@@ -451,34 +431,24 @@ const hero = await Model.load(asset('./hero.glb')) // asset path | https URL |
451
431
  scene.add(hero) // a Model IS a Node — transform/events/aspects all apply
452
432
  hero.traverse(n => { if (n.name === 'Sword') n.visible = false }) // GLB internals are plain child Nodes
453
433
 
454
- // Animation — every Model has an Animator pre-attached at model.anim (clip list = the glb's clips). A layer
455
- // has a LOOP (what it rests on) and ONE-SHOTS played over it; any call overrides what's there over its own fade:
456
- hero.anim.clips / hero.anim.clip('Run') / hero.anim.clip(0) // AnimationClip[] (file order) / by name / by index; unnamed clips = 'animation_0', …
457
- hero.anim.playLoop('Idle', { fade: 0.2 }) // rest on a looping clip (crossfades from whatever plays); hero.anim.loop reads it
458
- const loco = hero.anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 }) // a BLEND as the loop (1D; [x, y] positions = 2D)
459
- loco.value = speed // position on the axis — members stay in phase (no foot sliding)
460
- const ok = await hero.anim.play('slash', { fadeIn: 0.1, fadeOut: 0.3 }) // one-shot over the loop, returns to it
461
- // fades default to 0 (cut): fade = both ends, fadeIn / fadeOut = one end. Unknown name warns, plays nothing.
462
- // The await resolves at the HAND-OVER (end − fadeOut) with true (false = cut short) — what you start right after is what
463
- // the clip fades into: chains crossfade: if (ok) await hero.anim.play('slash2', { fadeIn: 0.3, fadeOut: 0.3 })
464
- // then hero.anim.playLoop('Crouch', { fade: 0.3 }) — or nothing = back to the current loop.
465
- if (!hero.anim.busy) hero.anim.play('kick') // busy = a one-shot hasn't handed over; play() always takes over otherwise
466
- hero.anim.stop({ fade: 0.2 }) // everything off → rest; hero.anim.busy; hero.anim.speed = 0.3 (0 = pause)
467
- // p = play(): p.done (return THIS from async fns) / p.playing / p.progress / p.weight / p.stop({ fade }) / p.seek(t)
468
- // Clips from other files (Mixamo: one GLB per animation), procedural, sliced — add by name:
469
- const [idle, slash] = await Promise.all([AnimationClip.load(asset('./idle.glb')), AnimationClip.load(asset('./attacks.glb'), 'Slash')])
470
- hero.anim.addClip('slash', slash).addClip('kick', hero.anim.clip('Kick').slice(0.2, 1.1)) // slice = a sub-range, re-timed
471
- AnimationClip.from({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } }) // curves in code, binds by node NAME
472
- // Clip events live on the CLIP, in SECONDS: slash.addEvent(0.4, 'hit') → hero.anim.on('hit', (clip, layer) => dealDamage())
473
- // Layers (legs walk, arms aim): const upper = hero.anim.addLayer({ mask: 'Spine1' }); upper.playLoop('Aim', { fade: 0.3 }); upper.stop()
474
- // { additive: true } = each clip's DELTA vs its first frame on top (recoil, breathe); upper.weight = 0.5
475
- // Root motion (clips whose hips travel — Mixamo without 'In Place', rolls): hero.anim.rootMotion = true → the node moves
476
- // (or its CharacterController, as a velocity — it collides); the bone stays put
477
- hero.bone('RightHand')?.add(sword) // bones are Nodes — sockets
478
- // 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
479
- // IK — late-phase aspects ON BONES (over the Animator's pose): TwoBone on the END bone, LookAt on the bone itself
480
- hero.bone('LeftFoot').aspect(IK.TwoBone, { target: footPoint, pole: kneeHint }).ik.weight = grounded ? 1 : 0
481
- hero.bone('Head').aspect(IK.LookAt, { target: camera, limit: 70 }) // axis = bone-local forward, default [0,0,-1]
434
+ // Animation — every Model has the ModelAnimation aspect pre-attached at model.anim:
435
+ hero.anim.clips // { name: string, duration: number }[] baked into the glb
436
+ hero.anim.play(clip?: string | number, options?: { loop?: boolean }): this // no arg = replay current
437
+ hero.anim.stop(): this
438
+ hero.anim.playing = true // get/set pause/resume
439
+ hero.anim.speed = 1.5 // playback rate
440
+ hero.anim.time = 0.5 // seek, seconds
441
+ // Clip-end events fire on the NODE (not on .anim), payload = clip index:
442
+ hero.addEventListener('completed', clip => {}) // non-looping clip finished (playing flips false)
443
+ hero.addEventListener('loopReached', clip => {}) // looping clip wrapped
444
+
445
+ // LIMITS — plan around these:
446
+ // - No cross-fade/blending: play() hard-switches clips next frame; one clip at a time.
447
+ // - play('BadName') is SILENTLY ignored — the old clip keeps playing. Check model.anim.clips.
448
+ // - Models CANNOT be cloned. A spawn pool = load N copies UP FRONT and recycle them:
449
+ const POOL = 8
450
+ const stones = await Promise.all(Array.from({ length: POOL }, () => Model.load(asset('./stone.glb'))))
451
+ const spawn = () => { const s = stones[next++ % POOL]; s.position = [rx(), 8, rz()]; scene.add(s) }
482
452
 
483
453
  // ===== PHYSICS (Jolt) =====
484
454
 
@@ -494,18 +464,9 @@ node.aspect(Shape, {
494
464
  sphere?: number, // radius
495
465
  cylinder?: { halfHeight: number, radius: number }, // Y-aligned
496
466
  capsule?: { halfHeight: number, radius: number }, // halfHeight = cylinder section only
497
- mesh?: true | 'convex', // the node's OWN triangles (Mesh geometry or Model GLB, × world scale)
498
- origin?: Vec3Like, // collider centre, offset from the node's pivot (world units, node rotation, NOT scaled)
499
467
  raycast?: boolean, // default true; false = invisible to taps & raycasts
500
468
  })
501
- // mesh: true = exact triangle mesh → level geometry / terrain / ramps: static or kinematic Physics, Trigger,
502
- // pick bodies, character ground. NEVER dynamic (Physics throws). mesh: 'convex' = convex hull → dynamic props.
503
- // A Model collider is its bind pose (skinned parts skipped) — animated characters keep a capsule.
504
469
  node.aspect(Shape, {}) // auto: box from the mesh AABB × world scale — right for most meshes
505
- // origin: art modelled ABOVE its pivot (a character on its feet, a barrel on its base) needs the
506
- // collider lifted: { capsule: {…}, origin: [0, 0.9, 0] }. An auto shape already centres on its mesh.
507
- node.shape.fit(kind?) // measure dimensions + origin from what the node RENDERS (subtree,
508
- // so a Model root works where {} can't) and rebuild the live collider. Fit AFTER Model.load resolves.
509
470
  // A bare Shape (no Physics) creates a STATIC pick-only body → the node gets 'click'/'touchstart'.
510
471
  // It snapshots the transform at attach time — a MOVING pickable object needs a Physics body too.
511
472
 
@@ -515,14 +476,11 @@ node.aspect(Shape, {}).aspect(Physics, {
515
476
  mass?: number, // kg, dynamic only; default 1
516
477
  })
517
478
  node.physics.velocity // Vec3 world units/s — get (fresh copy) / set
518
- node.physics.angularVelocity // Vec3 DEGREES/s about each world axis — get / set
519
479
  node.physics.applyImpulse(v): this // instant impulse, wakes the body
520
- node.position = [x, y, z] // place / teleport — moves the node AND its body (no moveTo())
521
- node.eulerAngles = [0, 90, 0] // orientation is routed to the body too (NOT for a CharacterController)
522
- // A teleport keeps BOTH velocities: putting an object down is velocity = 0, angularVelocity = 0, position = p
523
- // PHYSICS OWNS a dynamic body's transform: a position write teleports, but the sim takes over again —
524
- // drive motion with velocity / applyImpulse. READING node.position/worldPosition is always correct.
525
- // static = never moves (floors, walls); kinematic = write position each frame, pushes but isn't pushed.
480
+ node.physics.moveTo(p): this // drive a kinematic body (or teleport)
481
+ // PHYSICS OWNS a dynamic body's transform: never set node.position per frame — drive velocity /
482
+ // applyImpulse instead. READING node.position/worldPosition is always correct.
483
+ // static = never moves (floors, walls); kinematic = script-driven via moveTo, pushes but isn't pushed.
526
484
 
527
485
  // Contact events — on BOTH nodes of a contact/overlap, arg = the other node:
528
486
  crate.addEventListener('enter', (other: Node) => {})
@@ -566,8 +524,7 @@ sparks.destroy() // from Node — removes the system
566
524
 
567
525
  scene.camera // a Node — never constructed; every scene owns one.
568
526
  // (Who MOVES it is mode-specific — see the Scene/AR section.)
569
- camera.fov / .near / .far // lens: vertical FOV degrees (60), clip 0.01 / 1000 — SETTABLE.
570
- camera.setProjection({ fov, near, far }) // any subset; .horizontalFov, .displaySize stay read-only
527
+ camera.fov // vertical FOV, DEGREES (read-only); .horizontalFov, .displaySize
571
528
  camera.getViewDirection(screenX, screenY): Vec3 // world dir through a screen point (logical px = ev.clientX/Y)
572
529
  camera.getRay(screenX, screenY): Ray // origin = camera.worldPosition + that direction
573
530
 
@@ -742,129 +699,14 @@ setLoop(() => {
742
699
  if (Input.key('Space')) hero.controller.jump()
743
700
  })
744
701
 
745
- // HUD over the 3D view: a UIWidget ATTACHED to the scene. (A UIScreen would REPLACE the scene —
746
- // exactly one Presentable is visible at a time.)
702
+ // Transparent HUD over the 3D view (see UI section)
747
703
  let scoreLabel: UIText
748
- const hud = UIWidget(
704
+ const hud = UIScreen([
749
705
  scoreLabel = UIText('Score: 0').style({ color: 'white', fontSize: 24, fontWeight: 700 }),
750
- ).style({ top: 'max(safe-top, 16px)', right: 16 })
706
+ ]).style({ p: 16, pt: 'max(safe-top, 16px)', alignItems: 'flex-end' })
751
707
 
752
708
  scene.open()
753
- hud.attachTo(scene).show()
754
- </file>
755
-
756
- ## Scene files (.scene.ts)
757
-
758
- Projects may contain `*.scene.ts` files: declarative scenes that the platform's VISUAL scene
759
- editor reads and writes. The user may have built them by dragging models around — treat the file
760
- as their artwork. You may edit values and add nodes/aspects, but keep the `defineScene({...})`
761
- literal-object shape; an imperative rewrite (`new Scene()` + `scene.add(...)`) destroys their
762
- ability to keep editing it visually. `defineScene`, `use`, `ref` and `make` are globals.
763
-
764
- // ===== DEFINE SCENE =====
765
-
766
- // Default-export exactly one defineScene call per .scene.ts file.
767
- export default defineScene({
768
- env?: SceneOptions, // same options object as `new Scene(...)` above (skybox, ibl, bloom…)
769
- nodes?: {
770
- name: { // names are sibling-unique, no '/' or ':' in them
771
- // -- source: AT MOST ONE of these per node; none = empty group node --
772
- mesh?: { kind: 'box', size?: n | [x,y,z] } | { kind: 'sphere' | 'cylinder' | 'plane', ... },
773
- model?: string, // GLB via the asset macro: asset('./hero.glb')
774
- light?: { kind: 'sun', direction?, intensity?, shadowsQuality? }, // 'sun' is the ONLY kind
775
- camera?: { fov?, near?, far? }, // this node IS the scene camera (defaults 60 / 0.01 / 1000)
776
- make?: make(factoryFn, { ...literalArgs }), // code-built subtree from a user function
777
- prefab?: SceneHandle, // another .scene.ts's default export (import it)
778
- // -- for a mesh source --
779
- material?: { lit: { color?, metallic?, roughness? } } | { unlit: { color? } },
780
- // -- transform / render --
781
- position?: [x,y,z], eulerAngles?: [x,y,z], scale?: [x,y,z] | n,
782
- visible?, castShadows?, receiveShadows?,
783
- locked?: boolean, // editor-only flag, zero runtime effect — leave it alone
784
- // -- behavior / hierarchy --
785
- aspects?: [ use(AspectClass, { ...props }) ], // props may use ref('otherNode') for node refs
786
- children?: { name: { ...same shape } },
787
- // pose parts INSIDE a model/prefab, and attach a child node to such a part:
788
- overrides?: { 'Bone/Path': { position?, eulerAngles?, scale?, visible? } },
789
- mount?: 'Bone/Path', // parents this node to that part of the PARENT node's asset
790
- },
791
- },
792
- })
793
-
794
- // ===== USING A SCENE FROM APP CODE =====
795
-
796
- import city from './city.scene' // extension-less specifier; default export: SceneHandle
797
-
798
- const { scene, nodes, get } = await city.open() // load (fetches GLBs) + become the active view
799
- // city.load() — instantiate without showing; both are idempotent (same instance every call)
800
- nodes.crate // typed by source: model → Model, mesh → Mesh, light → Light
801
- nodes['props/lamp'] // keys are absolute '/'-joined paths; get(path) for dynamic
802
- nodes.crate.position = [1, 0.5, 2] // plain SDK nodes — assign vectors, or scalar axes: node.y = 2
803
- nodes.hero.anim.play('walk') // a model node is a Model: animations, parts — all there
804
- scene.close() // closing goes through the scene (there is NO handle.close())
805
-
806
- // ===== BEHAVIOR & INPUT =====
807
-
808
- // Put per-frame behavior in ASPECTS attached in the file — not in code that mutates the doc:
809
- // aspects: [use(Spin, { speed: 40 })]
810
- // Built-in no-code aspects: MoveTo, FollowPath ({ path: ref('waypoints'), duration }), Spin,
811
- // LookAt, PlayAnimation. Custom ones are ~5 lines (see Aspects; update(dt) — dt in SECONDS):
812
- export class Spinner extends Aspect<'spinner'> {
813
- speed = 30 // class fields = props settable from use(...)
814
- update(dt: number) { this.node.eulerAngles = [0, this.node.eulerAngles.y + this.speed * dt, 0] }
815
- }
816
- // Clicks/taps: a node is pickable only with a Shape aspect — aspects: [use(Shape, {})] — then
817
- // nodes.crate.addEventListener('click', ev => ...). Catch-all with target (null on miss):
818
- // scene.addEventListener('click', ev => ev.target). Shape alone = static pick body; moving
819
- // clickables also need Physics.
820
-
821
- // ===== UI OVER AN OPEN SCENE =====
822
-
823
- // Exactly one Presentable is visible at a time: UIScreen(...).open() REPLACES the scene. A HUD is
824
- // a UIWidget attached to the scene — it shows/hides and transitions together with it:
825
- const hud = UIWidget(UIText('Score: 0').style({ color: 'white', fontSize: 24 }))
826
- .style({ top: 'max(safe-top, 16px)', left: 16 })
827
- hud.attachTo(scene).show()
828
-
829
- // ===== SCENE FILE MISTAKES — DO NOT DO THESE =====
830
-
831
- // - Rebuilding a .scene.ts imperatively, or moving its content into main.ts. Edit the def in
832
- // place; logic goes into aspect classes or app code around handle.open().
833
- // - scene.ref(...), handle.close(), onTap — none exist. It's nodes[path] / get(path),
834
- // loaded.scene.close(), addEventListener('click').
835
- // - Two source keys on one node (e.g. mesh + model) — throws at load.
836
- // - Expecting clicks without use(Shape, {}) on the node.
837
- // - node.position.x = 3 — silent NO-OP (position returns a copy). Assign node.x = 3 or the vector.
838
- // - env HDRI paths or light kinds other than 'sun' — not supported; ibl is just a boolean.
839
-
840
- ## Scene file wiring example
841
-
842
- // A scene built in the visual editor, wired up with a click counter and a HUD.
843
-
844
- <file name="main.scene.ts">
845
- export default defineScene({
846
- env: { skybox: '#a9b6c8' },
847
- nodes: {
848
- camera: { camera: {}, position: [0, 4, 9], eulerAngles: [-22, 0, 0] },
849
- sun: { light: { kind: 'sun', shadowsQuality: 1 } },
850
- ground: { mesh: { kind: 'box', size: [12, 0.4, 12] }, material: { lit: { color: '#3d4351' } },
851
- position: [0, -0.2, 0], locked: true },
852
- crate: { mesh: { kind: 'box' }, material: { lit: { color: '#8a8f98' } }, position: [0, 0.5, 0],
853
- aspects: [use(Shape, {}), use(Spin, { speed: 25 })] },
854
- },
855
- })
856
- </file>
857
-
858
- <file name="main.ts">
859
- import mainScene from './main.scene'
860
-
861
- const { scene, nodes } = await mainScene.open()
862
-
863
- let taps = 0
864
- const label = UIText('Tap the crate').style({ color: 'white', fontSize: 20, fontWeight: 700 })
865
- UIWidget(label).style({ top: 'max(safe-top, 16px)', left: 16 }).attachTo(scene).show()
866
-
867
- nodes.crate.addEventListener('click', () => { label.text = `Taps: ${++taps}` })
709
+ hud.open()
868
710
  </file>
869
711
 
870
712
  // ===== UI RULES =====
@@ -875,22 +717,18 @@ nodes.crate.addEventListener('click', () => { label.text = `Taps: ${++taps}` })
875
717
 
876
718
  // ===== UI COMPONENTS =====
877
719
  // Every element is created by a global factory function (never `new`) that takes only the element's
878
- // CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
879
- // configured by chaining: every configuring method returns the element itself, so construction reads
880
- // as one chain.
881
- UIColumn(UIText("Title"), UIButton(UIText("Go")))
882
- // An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
883
- UIColumn(header, items.map(Row), footer)
720
+ // CONTENT (children array, text, src, ...). Everything else — styles, handlers — is configured by chaining:
721
+ // every configuring method returns the element itself, so construction reads as one chain.
884
722
 
885
723
  // UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
886
- UIRow(...children) / UIColumn(...children)
724
+ UIRow(children) / UIColumn(children)
887
725
  // Child management — imperative, no diffing; works before and after the element is on screen:
888
726
  // .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
889
727
  // .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
890
728
  // For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
891
729
 
892
730
  // UIScreen — root screen, always fills the device. Behaves as a UIColumn.
893
- UIScreen(...children)
731
+ UIScreen(children)
894
732
  // .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
895
733
  // .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
896
734
  // popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
@@ -898,40 +736,12 @@ UIScreen(...children)
898
736
  // .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
899
737
  // .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
900
738
  // Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
901
- // with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
739
+ // with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
902
740
  // Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
903
741
 
904
- // UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
905
- // USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
906
- const tabs = UITabs({
907
- home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
908
- profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
909
- })
910
- Router.init(tabs) // UITabs IS a UIScreen — present it directly
911
- // .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
912
- // .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
913
- // .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
914
- // Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
915
- // restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
916
-
917
- // UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
918
- // OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
919
- // tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
920
- UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
921
- // .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
922
- // bar highlight here. Tabs keep their stack/scroll state when switched away and back.
923
- // .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
924
- // .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
925
- // Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
926
- // pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
927
- // Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
928
- // load initial data at build time, keep onOpen for re-entry.
929
- const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
930
- Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
931
-
932
742
  // UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
933
743
  // Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
934
- UIWidget(...children)
744
+ UIWidget(children)
935
745
  // .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
936
746
  // extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
937
747
  // a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
@@ -943,7 +753,7 @@ UIWidget(...children)
943
753
  // UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
944
754
  // Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
945
755
  // back button close it automatically. Create ONCE at module scope, like any widget.
946
- UIModal(...children)
756
+ UIModal(children)
947
757
  // .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
948
758
  // — plus the full UIWidget surface
949
759
  // .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
@@ -954,7 +764,7 @@ UIModal(...children)
954
764
  // UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
955
765
  // Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
956
766
  // dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
957
- UIBottomSheet(...children)
767
+ UIBottomSheet(children)
958
768
  // .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
959
769
  // BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
960
770
  // .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
@@ -967,33 +777,30 @@ UIBottomSheet(...children)
967
777
  // Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
968
778
  // automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
969
779
  // Attaches itself to Presentable.current (hides with the page it opened on).
970
- UIPopover(...children)
780
+ UIPopover(children)
971
781
  // .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
972
782
  // .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
973
783
  // Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
974
784
 
975
785
  // UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
976
- UIScrollable(...children)
786
+ UIScrollable(children)
977
787
  // extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
978
788
  // overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
979
789
  // keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
980
- // snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
981
- // direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
982
- // the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
983
790
  // .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
984
791
  // .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
985
792
  // element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
986
793
  // Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
987
794
  // Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
988
795
  // themselves. flexGrow: 1 to fill the remaining space is still yours to set.
989
- // Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
796
+ // Carousel = scrollDirection: "horizontal" + FIXED-width cards (explicit width on each card, gap/px on the scrollable)
990
797
 
991
798
  // UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
992
799
  // visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
993
800
  // is large, grows over time, or is unknown.
994
801
  UIVirtualizedList<T>({
995
802
  keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
996
- render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
803
+ render: (item: T) => UIElement, // builds one row; called lazily as rows enter the window
997
804
  estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
998
805
  overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
999
806
  inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
@@ -1042,7 +849,7 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
1042
849
 
1043
850
  // UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
1044
851
  // To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
1045
- UIButton(...children)
852
+ UIButton(children?)
1046
853
  // .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
1047
854
  // extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
1048
855
  // rippleColor ("default" or a Color, Android only)
@@ -1081,7 +888,7 @@ UIInput() / UITextArea()
1081
888
 
1082
889
  // UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
1083
890
  // Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
1084
- UIRow(title, UISpacer(), closeButton)
891
+ UIRow([ title, UISpacer(), closeButton ])
1085
892
  // If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
1086
893
 
1087
894
  // @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
@@ -1096,13 +903,9 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
1096
903
  // 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
1097
904
  // 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
1098
905
  // e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
1099
- // 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
906
+ // 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
1100
907
  // Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
1101
908
  // the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
1102
- // loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
1103
- // (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
1104
- // animation is an effect, not a state change: it never commits, delay applies once, and it stops on
1105
- // the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
1106
909
  // 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
1107
910
  // Never modifies the stored style.
1108
911
  // For free-value tweens use animate() — see ANIMATION above.
@@ -1180,28 +983,9 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
1180
983
  .style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
1181
984
  // Keys: onLandscape, onPortrait.
1182
985
 
1183
- // --- Style classes ($name) — named states that CASCADE ---
1184
- // A $-prefixed key in .style() declares a named style state (like onPressed, but yours: selected,
1185
- // checked, expanded); duration/delay inside the block animate the swap. Drive it via el.class:
1186
- const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
1187
- item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
1188
- // el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
1189
- // form; names work with or without the $. Class state persists across screen close/reopen.
1190
- // CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
1191
- // one toggle restyles a whole composite control, each part declaring its own reaction:
1192
- UIButton(
1193
- UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
1194
- UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
1195
- ) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
1196
- // $pressed / $focused are RESERVED — the system toggles them (finger down / input focused) and they
1197
- // CASCADE, so a button's children can restyle during its press. onPressed/onFocused stay
1198
- // own-element-only — use those on nested interactives (a button inside a clickable card):
1199
- UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed: { transform: "scale(0.97)" } })
1200
- // Precedence on the same prop: base < $classes < $pressed/$focused < onPressed/onFocused.
1201
-
1202
986
  // --- Defaults that surprise ---
1203
- // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
1204
- // default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
987
+ // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and UIVirtualizedList default
988
+ // flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
1205
989
  // flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
1206
990
  // alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
1207
991
  // overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
@@ -1218,10 +1002,10 @@ UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed:
1218
1002
  // ===== REUSABLE COMPONENTS =====
1219
1003
  // Extract repeated UI into factory functions — they return elements you can chain methods on:
1220
1004
 
1221
- const ClickableCard = (title: string, subtitle: string) => UIButton(
1005
+ const ClickableCard = (title: string, subtitle: string) => UIButton([
1222
1006
  UIText(title).style({ fontWeight: 700, color: "white" }),
1223
1007
  UIText(subtitle).style({ color: "#888", fontSize: 13 })
1224
- ).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
1008
+ ]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
1225
1009
 
1226
1010
  // Use like any other element — chain after the call:
1227
1011
  ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
@@ -1230,15 +1014,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
1230
1014
  const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
1231
1015
 
1232
1016
  // ===== CAPTURING ELEMENT REFERENCES =====
1233
- // Use an assignment expression right in the children — standard TypeScript:
1017
+ // Use an assignment expression inside the children array — standard TypeScript:
1234
1018
 
1235
1019
  let label: UIText
1236
1020
  let input: UIInput
1237
1021
 
1238
- UIColumn(
1022
+ UIColumn([
1239
1023
  label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
1240
1024
  input = UIInput(),
1241
- )
1025
+ ])
1242
1026
 
1243
1027
  // Later:
1244
1028
  label.text = "Updated"
@@ -1248,17 +1032,15 @@ input.value // read current value
1248
1032
  // ((button.children[0] as UIText).text vs buttonText.text).
1249
1033
 
1250
1034
  // ===== CONDITIONAL CHILDREN =====
1251
- // A null / undefined / false child is skipped — no element, no layout slot.
1252
- UIColumn(header, isLoading ? spinner : null, showFooter && footer)
1253
- // Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
1254
- UIColumn(header, showList && items.map(Row))
1035
+ // null / undefined / false in a children array is skipped — no element, no layout slot.
1036
+ UIColumn([ header, isLoading ? spinner : null, showFooter && footer ])
1255
1037
 
1256
1038
  // ===== SIZING: the two axes behave differently =====
1257
1039
  // MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
1258
1040
  // without flexGrow: 1 (no implicit min-sizes).
1259
1041
  // CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
1260
1042
  // or alignSelf to opt out.
1261
- // So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
1043
+ // So UIRow([ UIInput() ]) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
1262
1044
  // EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
1263
1045
  // equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
1264
1046
  // longer label stays wider.
@@ -1278,7 +1060,6 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
1278
1060
  // Position once at an interaction — never poll per frame.
1279
1061
 
1280
1062
  // ===== ROUTER (multi-page apps) =====
1281
- // Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
1282
1063
  Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
1283
1064
  Router.push(screen) // push onto the stack, screen becomes active
1284
1065
  Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
@@ -1320,14 +1101,6 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
1320
1101
  UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
1321
1102
  // Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
1322
1103
 
1323
- // ===== ICONS (assetIcon) =====
1324
- // Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
1325
- // inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
1326
- UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
1327
- // The id must be a string literal; an unknown id is a compile error (with name suggestions).
1328
- // Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
1329
- // Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
1330
-
1331
1104
  // ===== COMMON MISTAKES — DO NOT DO THESE =====
1332
1105
 
1333
1106
  // ❌ CSS that doesn't exist here
@@ -1336,13 +1109,16 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
1336
1109
  lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
1337
1110
 
1338
1111
  // ❌ touch handler on a non-touch element
1339
- UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
1112
+ UIColumn([...]).onTouchStart(...) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
1340
1113
 
1341
- // ❌ let/const among the children — `let` is a statement, not an expression
1342
- UIRow(let input = UIInput()) // WRONG
1114
+ // ❌ scrollable that overflows the screen
1115
+ // A wrapping container between the scrollable and the screen is missing flexShrink: 1
1116
+
1117
+ // ❌ let/const inside children array — `let` is a statement, not an expression
1118
+ UIRow([ let input = UIInput() ]) // WRONG
1343
1119
  // ✅ declare outside, assign inside (assignment both sets the var AND appends)
1344
1120
  let input: UIInput
1345
- UIRow(input = UIInput())
1121
+ UIRow([ input = UIInput() ])
1346
1122
 
1347
1123
  // ❌ setting a UIImage's content through bgImage
1348
1124
  const img = UIImage("") // WRONG — empty source as a placeholder
@@ -1352,30 +1128,30 @@ const img = UIImage(url)
1352
1128
  img.src = newUrl // updates the displayed image
1353
1129
 
1354
1130
  // ❌ expecting an input to fill width like in CSS
1355
- UIRow(UIInput()) // WRONG — collapses to placeholder width
1131
+ UIRow([ UIInput() ]) // WRONG — collapses to placeholder width
1356
1132
  // ✅ stretch it explicitly
1357
- UIColumn(UIInput().style({ width: "100%" })) // cross axis
1358
- UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
1133
+ UIColumn([ UIInput().style({ width: "100%" }) ]) // cross axis
1134
+ UIRow([ input = UIInput().style({ flexGrow: 1 }), sendBtn ]) // main axis
1359
1135
 
1360
1136
  // ❌ a button that should match the input's height but shrinks to its text
1361
- UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
1137
+ UIRow([ input.style({ height: 40 }), UIButton([...]) ]) // button ends up shorter
1362
1138
  // ✅ give controls the same explicit height
1363
- UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
1139
+ UIRow([ input.style({ height: 40 }), UIButton([...]).style({ height: 40 }) ])
1364
1140
 
1365
1141
  // ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
1366
- UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
1142
+ UIRow([ yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 }) ]) // longer label = wider button
1367
1143
  // ✅ flex: 1 — grows from a zero basis, children end up equal
1368
- UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
1144
+ UIRow([ yes.style({ flex: 1 }), no.style({ flex: 1 }) ])
1369
1145
 
1370
1146
  // ❌ empty containers as spacers to align children (web habit)
1371
- UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
1147
+ UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
1372
1148
  // ✅ alignment is a CONTAINER property, not an extra element
1373
- UIRow(label).style({ justifyContent: "flex-end" })
1149
+ UIRow([ label ]).style({ justifyContent: "flex-end" })
1374
1150
 
1375
1151
  // ❌ empty element as a placeholder for a conditional child
1376
- UIRow(isGroup ? button : UIColumn()) // WRONG
1152
+ UIRow([ isGroup ? button : UIColumn([]) ]) // WRONG
1377
1153
  // ✅ null is skipped in children — no phantom element
1378
- UIRow(isGroup ? button : null)
1154
+ UIRow([ isGroup ? button : null ])
1379
1155
 
1380
1156
  // ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
1381
1157
  UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
@@ -1389,7 +1165,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
1389
1165
  list.onEndReached(600, loadNextPage)
1390
1166
 
1391
1167
  // ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
1392
- const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
1168
+ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new widget every call
1393
1169
  // ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
1394
1170
 
1395
1171
  // ===== EXAMPLES =====
@@ -1397,7 +1173,7 @@ const openSheet = () => UIWidget(...).show() // WRONG — leaks a new wid
1397
1173
  // === EXAMPLE 1: Single-file app ===
1398
1174
  <file name="main.ts">
1399
1175
  let text: UIText
1400
- const screen = UIScreen(
1176
+ const screen = UIScreen([
1401
1177
  text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
1402
1178
  UIText("Your name:").style({ textAlign: "center" }),
1403
1179
  UIInput()
@@ -1405,188 +1181,91 @@ const screen = UIScreen(
1405
1181
  .onChange(str => {
1406
1182
  text.text = `Hello, ${str}!`
1407
1183
  })
1408
- ).style({ justifyContent: "center", p: 20, gap: 8 })
1184
+ ]).style({ justifyContent: "center", p: 20, gap: 8 })
1409
1185
 
1410
1186
  screen.open()
1411
1187
  </file>
1412
1188
 
1413
- // === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
1414
- // A tokens module every screen imports, light-themed: theme({ color }) sets the default text
1415
- // color ONCE — no color: "#111" on every label.
1416
- <file name="tokens.ts">
1417
- const palette = {
1418
- bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
1419
- text: "#131A17", muted: "#606B65",
1420
- accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
1421
- }
1422
- theme({ color: palette.text, primaryColor: palette.accent })
1423
- // Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
1424
- export const colors: { [K in keyof typeof palette]: string } = theme(palette)
1425
- export const font = { // type scale — spread into styles: .style({ ...font.h2 })
1426
- h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
1427
- small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
1428
- }
1429
- </file>
1189
+ // === EXAMPLE 2: Fetching data with loading state ===
1430
1190
  <file name="main.ts">
1431
- import { colors, font } from './tokens'
1432
-
1433
1191
  type User = { id: number; name: string; email: string }
1434
1192
 
1435
- const Row = (u: User) => UIRow(
1436
- UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
1437
- .style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
1438
- justifyContent: "center", alignItems: "center" }),
1439
- UIColumn(
1440
- UIText(u.name).style({ ...font.bodyStrong }),
1441
- UIText(u.email).style({ ...font.small, color: colors.muted }),
1442
- ).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
1443
- ).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
1444
- border: `1px solid ${colors.border}`, p: 14 })
1445
-
1446
- const Centered = (...children: UINodeChild[]) =>
1447
- UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
1448
-
1449
- let body: UIColumn
1193
+ let statusText: UIText
1194
+ let list: UIColumn
1450
1195
 
1451
1196
  const loadData = async () => {
1452
- body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
1197
+ statusText.text = "Loading..."
1198
+
1453
1199
  const res = await fetch("https://jsonplaceholder.typicode.com/users")
1454
1200
  if (res.status !== 200) {
1455
- body.setContent([Centered(
1456
- UIText("Couldn't load users"),
1457
- UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
1458
- .style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
1459
- .onClick(() => loadData()),
1460
- )])
1201
+ statusText.text = "Error loading data"
1461
1202
  return
1462
1203
  }
1463
- body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
1204
+
1205
+ const users = res.json<User[]>() // sync — no await
1206
+
1207
+ statusText.text = ""
1208
+ list.setContent(
1209
+ users.map(user =>
1210
+ UIColumn([
1211
+ UIText(user.name).style({ fontWeight: 700, color: "white" }),
1212
+ UIText(user.email).style({ color: "#888", fontSize: 13 })
1213
+ ]).style({ px: 16, py: 12, gap: 4 })
1214
+ )
1215
+ )
1464
1216
  }
1465
1217
 
1466
- const screen = UIScreen(
1467
- UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
1468
- UIScrollable(
1469
- body = UIColumn().style({ flexGrow: 1, gap: 8 })
1470
- ).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
1471
- .onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
1472
- ).style({ bgColor: colors.bg, pt: "comfort-top" })
1473
- .onOpen(() => loadData())
1218
+ const screen = UIScreen([
1219
+ UIText("Users").style({ fontSize: 24, fontWeight: 700, color: "white", mb: 8 }),
1220
+ statusText = UIText("").style({ color: "#888" }),
1221
+ UIScrollable([
1222
+ list = UIColumn([])
1223
+ ]).style({ flexGrow: 1 }) // the ONE scrolling body — the screen itself never scrolls
1224
+ .onRefresh(() => loadData()) // pull-to-refresh; attached before screen.open()
1225
+ ])
1226
+ .style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
1227
+ .onOpen(() => loadData())
1474
1228
 
1475
1229
  screen.open()
1476
1230
  </file>
1477
1231
 
1478
- // === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
1232
+ // === EXAMPLE 3: Simple app with navigation ===
1479
1233
  <file name="home.ts">
1480
- const detailScreen = (name: string) => UIScreen(
1481
- UIButton(
1482
- UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
1483
- UIText("Back")
1484
- ).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
1485
- UIText(name).style({ fontSize: 24, fontWeight: 700 }),
1486
- UIButton(
1487
- UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
1488
- UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
1489
- ).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
1490
- .onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
1491
- ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
1492
-
1493
- const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
1494
- .style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", onPressed: { opacity: 0.7 } })
1495
- .onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
1496
-
1497
- export const homeScreen = UIScreen(
1498
- UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
1499
- UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
1500
- ).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
1234
+ import { detailScreen } from './detail'
1235
+
1236
+ export const homeScreen = UIScreen([
1237
+ UIText("Home").style({ fontSize: 24, fontWeight: 700 }),
1238
+ UIButton([
1239
+ UIText("Go to detail").style({ color: "white" })
1240
+ ]).style({ bgColor: "#FF4032", borderRadius: 12, p: 16, rippleColor: "default", onPressed: { opacity: 0.7 } })
1241
+ .onClick(() => Router.push(detailScreen))
1242
+ ]).style({ p: 20, pt: "max(safe-top, 24px)", gap: 16 })
1501
1243
  </file>
1502
- <file name="profile.ts">
1503
- export const profileScreen = UIScreen(
1504
- UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
1505
- ).style({ p: 16, pt: "comfort-top", bgColor: "black" })
1244
+ <file name="detail.ts">
1245
+ import chevronLeft from './assets/chevron-left.svg'
1246
+
1247
+ export const detailScreen = UIScreen([
1248
+ UIButton([
1249
+ UIImage(chevronLeft).style({ width: 18, height: 18, tintColor: "#FF4032" }),
1250
+ UIText("Back").style({ color: "#FF4032" })
1251
+ ]).style({ alignSelf: "flex-start", flexDirection: "row", height: 32, gap: 4 }).onClick(() => Router.pop()),
1252
+ UIText("Detail Screen").style({ fontSize: 24, fontWeight: 700 })
1253
+ ])
1254
+ .style({ p: 20, pt: "max(safe-top, 24px)", gap: 20 })
1255
+ .onBackPressed(() => Router.pop())
1506
1256
  </file>
1507
- <file name="main.ts">
1508
- import { homeScreen } from './home'
1509
- import { profileScreen } from './profile'
1510
-
1511
- theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
1512
-
1513
- const tabs = UITabs({
1514
- home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
1515
- profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
1516
- })
1517
- tabs.badge("profile", true) // notification dot on the tab
1518
- Router.init(tabs)
1257
+ <file name="assets/chevron-left.svg">
1258
+ <svg viewBox="0 0 24 24">
1259
+ <path d="M15 18l-6-6 6-6" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1260
+ </svg>
1519
1261
  </file>
1520
-
1521
- // === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
1522
- // The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
1523
- // view and handles dismissal itself.
1524
1262
  <file name="main.ts">
1525
- // Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
1526
- // appearing error never jumps the form). Typing in an errored field clears it.
1527
- const Field = (label: string, style: Style<UIInput> = {}) => {
1528
- const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
1529
- border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
1530
- onFocused: { borderColor: "#4C8DFF" }, ...style })
1531
- const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
1532
- input.onChange(() => message.text = "")
1533
- return {
1534
- node: UIColumn(
1535
- UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
1536
- input, message,
1537
- ).style({ gap: 6 }),
1538
- input,
1539
- error: (text: string) => { message.text = text },
1540
- }
1541
- }
1542
-
1543
- const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
1544
- const email = Field("Email", { type: "email", placeholder: "you@example.com" })
1545
- const password = Field("Password", { type: "password" })
1546
-
1547
- // The return key walks the form; the last field submits. This is ALL the keyboard code.
1548
- name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
1549
- email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
1550
- password.input.style({ enterKey: "go" }).onSubmit(() => submit())
1551
-
1552
- let btnLabel: UIText
1553
- let busy = false
1554
-
1555
- // The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
1556
- // first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
1557
- const submit = async () => {
1558
- if (busy) return
1559
- let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
1560
- if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
1561
- if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
1562
- if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
1563
- if (bad) { bad.input.focus(); return }
1564
- busy = true
1565
- btnLabel.text = "Creating…"
1566
- const res = await fetch("https://api.example.com/register", {
1567
- method: "POST", headers: { "Content-Type": "application/json" },
1568
- body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
1569
- })
1570
- busy = false
1571
- btnLabel.text = "Create account"
1572
- if (res.status !== 200) { email.error("Registration failed — try again"); return }
1573
- toast("Welcome!")
1574
- }
1575
-
1576
- const screen = UIScreen(
1577
- UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
1578
- UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
1579
- name.node, email.node, password.node,
1580
- UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
1581
- .style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, onPressed: { opacity: 0.85 } })
1582
- .onClick(() => submit())
1583
- ).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
1584
- ).style({ bgColor: "#0C0F14" })
1263
+ import { homeScreen } from './home'
1585
1264
 
1586
- screen.open()
1265
+ Router.init(homeScreen)
1587
1266
  </file>
1588
1267
 
1589
- // === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
1268
+ // === EXAMPLE 4: UIBottomSheet — persistent map-style sheet with detents ===
1590
1269
  <file name="main.ts">
1591
1270
  type Place = { id: number; name: string; distance: string }
1592
1271
  const places: Place[] = [
@@ -1595,32 +1274,32 @@ const places: Place[] = [
1595
1274
  { id: 3, name: "Riverside Park", distance: "1.2 km" },
1596
1275
  ]
1597
1276
 
1598
- const Row = (p: Place) => UIRow(
1277
+ const Row = (p: Place) => UIRow([
1599
1278
  UIText(p.name).style({ color: "#111", fontSize: 16 }),
1600
1279
  UIText(p.distance).style({ color: "#888", fontSize: 14 })
1601
- ).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
1280
+ ]).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
1602
1281
 
1603
- const sheet = UIBottomSheet(
1604
- UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
1282
+ const sheet = UIBottomSheet([
1283
+ UIColumn([]).style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
1605
1284
  UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
1606
1285
  UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
1607
- )
1286
+ ])
1608
1287
  .style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
1609
1288
  .detents([0.25, 0.6, 1]) // collapsed / half / full
1610
1289
  .dismissible(false) // drag below the lowest detent collapses, never closes
1611
1290
  .onDetentChange(i => console.log("detent", i))
1612
1291
 
1613
- const mapScreen = UIScreen(
1292
+ const mapScreen = UIScreen([
1614
1293
  // the map / page content behind the sheet
1615
- ).onOpen(() => sheet.show()).onClose(() => sheet.hide())
1294
+ ]).onOpen(() => sheet.show()).onClose(() => sheet.hide())
1616
1295
 
1617
1296
  mapScreen.open()
1618
1297
 
1619
- // An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
1298
+ // An action sheet is even less: content-sized, no detents() — UIBottomSheet([ ...rows ]).show(),
1620
1299
  // scrim and drag-down-to-dismiss included.
1621
1300
  </file>
1622
1301
 
1623
- // === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
1302
+ // === EXAMPLE 5: UIVirtualizedList — chat (inverted, imperative append) ===
1624
1303
  <file name="main.ts">
1625
1304
  type Msg = { id: string; text: string; mine: boolean }
1626
1305
 
@@ -1632,41 +1311,37 @@ const list = UIVirtualizedList<Msg>({
1632
1311
  keyOf: m => m.id,
1633
1312
  estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
1634
1313
  inverted: true, // newest at the bottom
1635
- render: m => UIRow(
1636
- UIRow(
1314
+ render: m => UIRow([
1315
+ UIRow([
1637
1316
  UIText(m.text).style({ color: m.mine ? "white" : "#111" })
1638
- ).style({
1317
+ ]).style({
1639
1318
  bgColor: m.mine ? "#FF4032" : "#EEE",
1640
1319
  px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
1641
1320
  })
1642
- ).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
1321
+ ]).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
1643
1322
  }).style({ flexGrow: 1 })
1644
1323
 
1645
- let input: UITextArea
1646
- let sendBtn: UIButton
1324
+ let input: UIInput
1647
1325
 
1648
1326
  const send = () => {
1649
1327
  const text = input.value.trim()
1650
1328
  if (!text) return
1651
1329
  list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
1652
1330
  input.value = ""
1653
- sendBtn.style.opacity = 0.4
1654
1331
  }
1655
1332
 
1656
- const screen = UIScreen(
1333
+ const screen = UIScreen([
1657
1334
  list,
1658
- UIRow(
1659
- input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
1660
- color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
1661
- keyboardDismiss: false
1335
+ UIRow([
1336
+ input = UIInput().style({
1337
+ flexGrow: 1, height: 40, px: 16, borderRadius: 20,
1338
+ bgColor: "#F0F0F0", placeholder: "Message...", placeholderColor: "#999"
1662
1339
  }),
1663
- sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
1664
- .style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
1340
+ UIButton([ UIText("Send").style({ color: "white" }) ])
1341
+ .style({ height: 40, px: 16, justifyContent: "center", bgColor: "#FF4032", borderRadius: 20 })
1665
1342
  .onClick(send)
1666
- ).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
1667
- ).style({ bgColor: "white" })
1668
-
1669
- input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
1343
+ ]).style({ p: 8, gap: 8, alignItems: "center" })
1344
+ ]).style({ bgColor: "white" })
1670
1345
 
1671
1346
  screen.open()
1672
1347
  </file>