lecodes-sdk 0.19.1 → 0.20.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/dist/global.d.ts +31 -0
- package/dist/host.d.ts +3 -0
- package/dist/inject.js +260 -361
- package/dist/types/gl/Locomotion.d.ts +3 -1
- package/dist/types/gl/animation/Animator.d.ts +51 -183
- package/dist/types/gl/animation/Feet.d.ts +85 -0
- package/dist/types/gl/animation/Warp.d.ts +53 -0
- package/dist/types/gl/animation/core.d.ts +46 -7
- package/dist/types/inject.d.ts +7 -2
- package/dist/types/plugins/map.d.ts +174 -0
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/dist/2d-game.md +197 -408
- package/prompts/dist/3d-app.md +166 -491
- package/prompts/dist/ar-app.md +163 -373
- package/prompts/dist/design.md +87 -83
- package/prompts/dist/ui-app.md +136 -325
- package/src/bridges.d.ts +13 -6
- package/src/compile/__tests__/compile.test.ts +12 -0
- package/src/compile/compileProject.ts +5 -0
- package/src/core/Aspect.ts +1 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Locomotion.ts +7 -5
- package/src/gl/Particles.ts +3 -3
- package/src/gl/Ragdoll.ts +270 -270
- package/src/gl/Scene.ts +1 -1
- package/src/gl/Trigger.ts +45 -45
- package/src/gl/animation/Animator.ts +137 -329
- package/src/gl/animation/Feet.ts +134 -0
- package/src/gl/animation/Loop.ts +3 -1
- package/src/gl/animation/Warp.ts +96 -0
- package/src/gl/animation/core.ts +736 -670
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +6 -6
- package/src/host.d.ts +3 -0
- package/src/inject.ts +11 -2
- package/src/plugins/map.ts +396 -0
- package/src/scene/gizmos.ts +148 -148
package/prompts/dist/ar-app.md
CHANGED
|
@@ -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
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
234
|
-
const
|
|
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,
|
|
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,
|
|
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
|
|
455
|
-
//
|
|
456
|
-
hero.anim.
|
|
457
|
-
hero.anim.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
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.
|
|
521
|
-
|
|
522
|
-
//
|
|
523
|
-
//
|
|
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
|
|
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
|
|
|
@@ -713,7 +670,7 @@ try { await arScene.open() } catch { toast('Camera access is required for AR') }
|
|
|
713
670
|
const scene = new ARScene()
|
|
714
671
|
|
|
715
672
|
const hint = UIText('Move your phone to find the floor').style({ color: 'white', fontSize: 16 })
|
|
716
|
-
const hud = UIScreen(hint).style({ p: 'safe-all', pb: 'comfort-bottom', justifyContent: 'flex-end', alignItems: 'center' })
|
|
673
|
+
const hud = UIScreen([hint]).style({ p: 'safe-all', pb: 'comfort-bottom', justifyContent: 'flex-end', alignItems: 'center' })
|
|
717
674
|
|
|
718
675
|
async function main() {
|
|
719
676
|
const chair = await Model.load(asset('./chair.glb'))
|
|
@@ -751,22 +708,18 @@ main()
|
|
|
751
708
|
|
|
752
709
|
// ===== UI COMPONENTS =====
|
|
753
710
|
// Every element is created by a global factory function (never `new`) that takes only the element's
|
|
754
|
-
// CONTENT
|
|
755
|
-
//
|
|
756
|
-
// as one chain.
|
|
757
|
-
UIColumn(UIText("Title"), UIButton(UIText("Go")))
|
|
758
|
-
// An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
|
|
759
|
-
UIColumn(header, items.map(Row), footer)
|
|
711
|
+
// CONTENT (children array, text, src, ...). Everything else — styles, handlers — is configured by chaining:
|
|
712
|
+
// every configuring method returns the element itself, so construction reads as one chain.
|
|
760
713
|
|
|
761
714
|
// UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
|
|
762
|
-
UIRow(
|
|
715
|
+
UIRow(children) / UIColumn(children)
|
|
763
716
|
// Child management — imperative, no diffing; works before and after the element is on screen:
|
|
764
717
|
// .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
|
|
765
718
|
// .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
|
|
766
719
|
// For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
|
|
767
720
|
|
|
768
721
|
// UIScreen — root screen, always fills the device. Behaves as a UIColumn.
|
|
769
|
-
UIScreen(
|
|
722
|
+
UIScreen(children)
|
|
770
723
|
// .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
|
|
771
724
|
// .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
|
|
772
725
|
// popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
|
|
@@ -774,40 +727,12 @@ UIScreen(...children)
|
|
|
774
727
|
// .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
|
|
775
728
|
// .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
|
|
776
729
|
// Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
|
|
777
|
-
// with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
|
|
730
|
+
// with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
|
|
778
731
|
// Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
|
|
779
732
|
|
|
780
|
-
// UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
|
|
781
|
-
// USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
|
|
782
|
-
const tabs = UITabs({
|
|
783
|
-
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
784
|
-
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
785
|
-
})
|
|
786
|
-
Router.init(tabs) // UITabs IS a UIScreen — present it directly
|
|
787
|
-
// .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
|
|
788
|
-
// .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
|
|
789
|
-
// .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
|
|
790
|
-
// Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
|
|
791
|
-
// restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
|
|
792
|
-
|
|
793
|
-
// UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
|
|
794
|
-
// OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
|
|
795
|
-
// tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
|
|
796
|
-
UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
|
|
797
|
-
// .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
|
|
798
|
-
// bar highlight here. Tabs keep their stack/scroll state when switched away and back.
|
|
799
|
-
// .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
|
|
800
|
-
// .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
|
|
801
|
-
// Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
|
|
802
|
-
// pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
|
|
803
|
-
// Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
|
|
804
|
-
// load initial data at build time, keep onOpen for re-entry.
|
|
805
|
-
const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
|
|
806
|
-
Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
|
|
807
|
-
|
|
808
733
|
// UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
|
|
809
734
|
// Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
|
|
810
|
-
UIWidget(
|
|
735
|
+
UIWidget(children)
|
|
811
736
|
// .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
|
|
812
737
|
// extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
|
|
813
738
|
// a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
|
|
@@ -819,7 +744,7 @@ UIWidget(...children)
|
|
|
819
744
|
// UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
|
|
820
745
|
// Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
|
|
821
746
|
// back button close it automatically. Create ONCE at module scope, like any widget.
|
|
822
|
-
UIModal(
|
|
747
|
+
UIModal(children)
|
|
823
748
|
// .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
|
|
824
749
|
// — plus the full UIWidget surface
|
|
825
750
|
// .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
|
|
@@ -830,7 +755,7 @@ UIModal(...children)
|
|
|
830
755
|
// UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
|
|
831
756
|
// Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
|
|
832
757
|
// dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
|
|
833
|
-
UIBottomSheet(
|
|
758
|
+
UIBottomSheet(children)
|
|
834
759
|
// .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
|
|
835
760
|
// BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
|
|
836
761
|
// .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
|
|
@@ -843,33 +768,30 @@ UIBottomSheet(...children)
|
|
|
843
768
|
// Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
|
|
844
769
|
// automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
|
|
845
770
|
// Attaches itself to Presentable.current (hides with the page it opened on).
|
|
846
|
-
UIPopover(
|
|
771
|
+
UIPopover(children)
|
|
847
772
|
// .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
|
|
848
773
|
// .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
|
|
849
774
|
// Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
|
|
850
775
|
|
|
851
776
|
// UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
|
|
852
|
-
UIScrollable(
|
|
777
|
+
UIScrollable(children)
|
|
853
778
|
// extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
|
|
854
779
|
// overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
|
|
855
780
|
// keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
|
|
856
|
-
// snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
|
|
857
|
-
// direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
|
|
858
|
-
// the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
|
|
859
781
|
// .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
|
|
860
782
|
// .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
|
|
861
783
|
// element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
|
|
862
784
|
// Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
|
|
863
785
|
// Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
|
|
864
786
|
// themselves. flexGrow: 1 to fill the remaining space is still yours to set.
|
|
865
|
-
// Carousel = scrollDirection: "horizontal" + FIXED-width cards
|
|
787
|
+
// Carousel = scrollDirection: "horizontal" + FIXED-width cards (explicit width on each card, gap/px on the scrollable)
|
|
866
788
|
|
|
867
789
|
// UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
|
|
868
790
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
869
791
|
// is large, grows over time, or is unknown.
|
|
870
792
|
UIVirtualizedList<T>({
|
|
871
793
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
872
|
-
render: (item: T) =>
|
|
794
|
+
render: (item: T) => UIElement, // builds one row; called lazily as rows enter the window
|
|
873
795
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
874
796
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
875
797
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
@@ -918,7 +840,7 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
|
|
|
918
840
|
|
|
919
841
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
920
842
|
// To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
|
|
921
|
-
UIButton(
|
|
843
|
+
UIButton(children?)
|
|
922
844
|
// .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
|
|
923
845
|
// extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
|
|
924
846
|
// rippleColor ("default" or a Color, Android only)
|
|
@@ -957,7 +879,7 @@ UIInput() / UITextArea()
|
|
|
957
879
|
|
|
958
880
|
// UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
|
|
959
881
|
// Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
|
|
960
|
-
UIRow(title, UISpacer(), closeButton)
|
|
882
|
+
UIRow([ title, UISpacer(), closeButton ])
|
|
961
883
|
// If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
|
|
962
884
|
|
|
963
885
|
// @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
|
|
@@ -972,13 +894,9 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
|
|
|
972
894
|
// 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
|
|
973
895
|
// 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
|
|
974
896
|
// e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
|
|
975
|
-
// 3. .animateTo({..., duration, delay?, commit
|
|
897
|
+
// 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
|
|
976
898
|
// Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
|
|
977
899
|
// the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
|
|
978
|
-
// loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
|
|
979
|
-
// (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
|
|
980
|
-
// animation is an effect, not a state change: it never commits, delay applies once, and it stops on
|
|
981
|
-
// the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
|
|
982
900
|
// 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
|
|
983
901
|
// Never modifies the stored style.
|
|
984
902
|
// For free-value tweens use animate() — see ANIMATION above.
|
|
@@ -1056,28 +974,9 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
1056
974
|
.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
|
|
1057
975
|
// Keys: onLandscape, onPortrait.
|
|
1058
976
|
|
|
1059
|
-
// --- Style classes ($name) — named states that CASCADE ---
|
|
1060
|
-
// A $-prefixed key in .style() declares a named style state (like onPressed, but yours: selected,
|
|
1061
|
-
// checked, expanded); duration/delay inside the block animate the swap. Drive it via el.class:
|
|
1062
|
-
const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
|
|
1063
|
-
item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
|
|
1064
|
-
// el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
|
|
1065
|
-
// form; names work with or without the $. Class state persists across screen close/reopen.
|
|
1066
|
-
// CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
|
|
1067
|
-
// one toggle restyles a whole composite control, each part declaring its own reaction:
|
|
1068
|
-
UIButton(
|
|
1069
|
-
UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
|
|
1070
|
-
UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
|
|
1071
|
-
) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
|
|
1072
|
-
// $pressed / $focused are RESERVED — the system toggles them (finger down / input focused) and they
|
|
1073
|
-
// CASCADE, so a button's children can restyle during its press. onPressed/onFocused stay
|
|
1074
|
-
// own-element-only — use those on nested interactives (a button inside a clickable card):
|
|
1075
|
-
UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed: { transform: "scale(0.97)" } })
|
|
1076
|
-
// Precedence on the same prop: base < $classes < $pressed/$focused < onPressed/onFocused.
|
|
1077
|
-
|
|
1078
977
|
// --- Defaults that surprise ---
|
|
1079
|
-
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable
|
|
1080
|
-
//
|
|
978
|
+
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and UIVirtualizedList default
|
|
979
|
+
// flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
1081
980
|
// flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
|
|
1082
981
|
// alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
|
|
1083
982
|
// overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
|
|
@@ -1094,10 +993,10 @@ UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed:
|
|
|
1094
993
|
// ===== REUSABLE COMPONENTS =====
|
|
1095
994
|
// Extract repeated UI into factory functions — they return elements you can chain methods on:
|
|
1096
995
|
|
|
1097
|
-
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
996
|
+
const ClickableCard = (title: string, subtitle: string) => UIButton([
|
|
1098
997
|
UIText(title).style({ fontWeight: 700, color: "white" }),
|
|
1099
998
|
UIText(subtitle).style({ color: "#888", fontSize: 13 })
|
|
1100
|
-
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
999
|
+
]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
1101
1000
|
|
|
1102
1001
|
// Use like any other element — chain after the call:
|
|
1103
1002
|
ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
@@ -1106,15 +1005,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
|
1106
1005
|
const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
|
|
1107
1006
|
|
|
1108
1007
|
// ===== CAPTURING ELEMENT REFERENCES =====
|
|
1109
|
-
// Use an assignment expression
|
|
1008
|
+
// Use an assignment expression inside the children array — standard TypeScript:
|
|
1110
1009
|
|
|
1111
1010
|
let label: UIText
|
|
1112
1011
|
let input: UIInput
|
|
1113
1012
|
|
|
1114
|
-
UIColumn(
|
|
1013
|
+
UIColumn([
|
|
1115
1014
|
label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
|
|
1116
1015
|
input = UIInput(),
|
|
1117
|
-
)
|
|
1016
|
+
])
|
|
1118
1017
|
|
|
1119
1018
|
// Later:
|
|
1120
1019
|
label.text = "Updated"
|
|
@@ -1124,17 +1023,15 @@ input.value // read current value
|
|
|
1124
1023
|
// ((button.children[0] as UIText).text vs buttonText.text).
|
|
1125
1024
|
|
|
1126
1025
|
// ===== CONDITIONAL CHILDREN =====
|
|
1127
|
-
//
|
|
1128
|
-
UIColumn(header, isLoading ? spinner : null, showFooter && footer)
|
|
1129
|
-
// Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
|
|
1130
|
-
UIColumn(header, showList && items.map(Row))
|
|
1026
|
+
// null / undefined / false in a children array is skipped — no element, no layout slot.
|
|
1027
|
+
UIColumn([ header, isLoading ? spinner : null, showFooter && footer ])
|
|
1131
1028
|
|
|
1132
1029
|
// ===== SIZING: the two axes behave differently =====
|
|
1133
1030
|
// MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
|
|
1134
1031
|
// without flexGrow: 1 (no implicit min-sizes).
|
|
1135
1032
|
// CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
|
|
1136
1033
|
// or alignSelf to opt out.
|
|
1137
|
-
// So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
1034
|
+
// So UIRow([ UIInput() ]) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
1138
1035
|
// EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
|
|
1139
1036
|
// equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
|
|
1140
1037
|
// longer label stays wider.
|
|
@@ -1154,7 +1051,6 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
|
|
|
1154
1051
|
// Position once at an interaction — never poll per frame.
|
|
1155
1052
|
|
|
1156
1053
|
// ===== ROUTER (multi-page apps) =====
|
|
1157
|
-
// Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
|
|
1158
1054
|
Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
|
|
1159
1055
|
Router.push(screen) // push onto the stack, screen becomes active
|
|
1160
1056
|
Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
|
|
@@ -1196,14 +1092,6 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
|
|
|
1196
1092
|
UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
|
|
1197
1093
|
// Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
|
|
1198
1094
|
|
|
1199
|
-
// ===== ICONS (assetIcon) =====
|
|
1200
|
-
// Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
|
|
1201
|
-
// inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
|
|
1202
|
-
UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
|
|
1203
|
-
// The id must be a string literal; an unknown id is a compile error (with name suggestions).
|
|
1204
|
-
// Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
|
|
1205
|
-
// Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
|
|
1206
|
-
|
|
1207
1095
|
// ===== COMMON MISTAKES — DO NOT DO THESE =====
|
|
1208
1096
|
|
|
1209
1097
|
// ❌ CSS that doesn't exist here
|
|
@@ -1212,13 +1100,16 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
|
|
|
1212
1100
|
lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
|
|
1213
1101
|
|
|
1214
1102
|
// ❌ touch handler on a non-touch element
|
|
1215
|
-
UIColumn(...).onTouchStart(
|
|
1103
|
+
UIColumn([...]).onTouchStart(...) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
|
|
1104
|
+
|
|
1105
|
+
// ❌ scrollable that overflows the screen
|
|
1106
|
+
// A wrapping container between the scrollable and the screen is missing flexShrink: 1
|
|
1216
1107
|
|
|
1217
|
-
// ❌ let/const
|
|
1218
|
-
UIRow(let input = UIInput()) // WRONG
|
|
1108
|
+
// ❌ let/const inside children array — `let` is a statement, not an expression
|
|
1109
|
+
UIRow([ let input = UIInput() ]) // WRONG
|
|
1219
1110
|
// ✅ declare outside, assign inside (assignment both sets the var AND appends)
|
|
1220
1111
|
let input: UIInput
|
|
1221
|
-
UIRow(input = UIInput())
|
|
1112
|
+
UIRow([ input = UIInput() ])
|
|
1222
1113
|
|
|
1223
1114
|
// ❌ setting a UIImage's content through bgImage
|
|
1224
1115
|
const img = UIImage("") // WRONG — empty source as a placeholder
|
|
@@ -1228,30 +1119,30 @@ const img = UIImage(url)
|
|
|
1228
1119
|
img.src = newUrl // updates the displayed image
|
|
1229
1120
|
|
|
1230
1121
|
// ❌ expecting an input to fill width like in CSS
|
|
1231
|
-
UIRow(UIInput()) // WRONG — collapses to placeholder width
|
|
1122
|
+
UIRow([ UIInput() ]) // WRONG — collapses to placeholder width
|
|
1232
1123
|
// ✅ stretch it explicitly
|
|
1233
|
-
UIColumn(UIInput().style({ width: "100%" })) // cross axis
|
|
1234
|
-
UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
|
|
1124
|
+
UIColumn([ UIInput().style({ width: "100%" }) ]) // cross axis
|
|
1125
|
+
UIRow([ input = UIInput().style({ flexGrow: 1 }), sendBtn ]) // main axis
|
|
1235
1126
|
|
|
1236
1127
|
// ❌ a button that should match the input's height but shrinks to its text
|
|
1237
|
-
UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
|
|
1128
|
+
UIRow([ input.style({ height: 40 }), UIButton([...]) ]) // button ends up shorter
|
|
1238
1129
|
// ✅ give controls the same explicit height
|
|
1239
|
-
UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
|
|
1130
|
+
UIRow([ input.style({ height: 40 }), UIButton([...]).style({ height: 40 }) ])
|
|
1240
1131
|
|
|
1241
1132
|
// ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
|
|
1242
|
-
UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
|
|
1133
|
+
UIRow([ yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 }) ]) // longer label = wider button
|
|
1243
1134
|
// ✅ flex: 1 — grows from a zero basis, children end up equal
|
|
1244
|
-
UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
|
|
1135
|
+
UIRow([ yes.style({ flex: 1 }), no.style({ flex: 1 }) ])
|
|
1245
1136
|
|
|
1246
1137
|
// ❌ empty containers as spacers to align children (web habit)
|
|
1247
|
-
UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
|
|
1138
|
+
UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
|
|
1248
1139
|
// ✅ alignment is a CONTAINER property, not an extra element
|
|
1249
|
-
UIRow(label).style({ justifyContent: "flex-end" })
|
|
1140
|
+
UIRow([ label ]).style({ justifyContent: "flex-end" })
|
|
1250
1141
|
|
|
1251
1142
|
// ❌ empty element as a placeholder for a conditional child
|
|
1252
|
-
UIRow(isGroup ? button : UIColumn()) // WRONG
|
|
1143
|
+
UIRow([ isGroup ? button : UIColumn([]) ]) // WRONG
|
|
1253
1144
|
// ✅ null is skipped in children — no phantom element
|
|
1254
|
-
UIRow(isGroup ? button : null)
|
|
1145
|
+
UIRow([ isGroup ? button : null ])
|
|
1255
1146
|
|
|
1256
1147
|
// ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
|
|
1257
1148
|
UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
|
|
@@ -1265,7 +1156,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
|
|
|
1265
1156
|
list.onEndReached(600, loadNextPage)
|
|
1266
1157
|
|
|
1267
1158
|
// ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
|
|
1268
|
-
const openSheet = () => UIWidget(...).show()
|
|
1159
|
+
const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new widget every call
|
|
1269
1160
|
// ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
|
|
1270
1161
|
|
|
1271
1162
|
// ===== EXAMPLES =====
|
|
@@ -1273,7 +1164,7 @@ const openSheet = () => UIWidget(...).show() // WRONG — leaks a new wid
|
|
|
1273
1164
|
// === EXAMPLE 1: Single-file app ===
|
|
1274
1165
|
<file name="main.ts">
|
|
1275
1166
|
let text: UIText
|
|
1276
|
-
const screen = UIScreen(
|
|
1167
|
+
const screen = UIScreen([
|
|
1277
1168
|
text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
|
|
1278
1169
|
UIText("Your name:").style({ textAlign: "center" }),
|
|
1279
1170
|
UIInput()
|
|
@@ -1281,188 +1172,91 @@ const screen = UIScreen(
|
|
|
1281
1172
|
.onChange(str => {
|
|
1282
1173
|
text.text = `Hello, ${str}!`
|
|
1283
1174
|
})
|
|
1284
|
-
).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
1175
|
+
]).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
1285
1176
|
|
|
1286
1177
|
screen.open()
|
|
1287
1178
|
</file>
|
|
1288
1179
|
|
|
1289
|
-
// === EXAMPLE 2:
|
|
1290
|
-
// A tokens module every screen imports, light-themed: theme({ color }) sets the default text
|
|
1291
|
-
// color ONCE — no color: "#111" on every label.
|
|
1292
|
-
<file name="tokens.ts">
|
|
1293
|
-
const palette = {
|
|
1294
|
-
bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
|
|
1295
|
-
text: "#131A17", muted: "#606B65",
|
|
1296
|
-
accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
|
|
1297
|
-
}
|
|
1298
|
-
theme({ color: palette.text, primaryColor: palette.accent })
|
|
1299
|
-
// Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
|
|
1300
|
-
export const colors: { [K in keyof typeof palette]: string } = theme(palette)
|
|
1301
|
-
export const font = { // type scale — spread into styles: .style({ ...font.h2 })
|
|
1302
|
-
h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
|
|
1303
|
-
small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
|
|
1304
|
-
}
|
|
1305
|
-
</file>
|
|
1180
|
+
// === EXAMPLE 2: Fetching data with loading state ===
|
|
1306
1181
|
<file name="main.ts">
|
|
1307
|
-
import { colors, font } from './tokens'
|
|
1308
|
-
|
|
1309
1182
|
type User = { id: number; name: string; email: string }
|
|
1310
1183
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
.style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
|
|
1314
|
-
justifyContent: "center", alignItems: "center" }),
|
|
1315
|
-
UIColumn(
|
|
1316
|
-
UIText(u.name).style({ ...font.bodyStrong }),
|
|
1317
|
-
UIText(u.email).style({ ...font.small, color: colors.muted }),
|
|
1318
|
-
).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
|
|
1319
|
-
).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
|
|
1320
|
-
border: `1px solid ${colors.border}`, p: 14 })
|
|
1321
|
-
|
|
1322
|
-
const Centered = (...children: UINodeChild[]) =>
|
|
1323
|
-
UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
|
|
1324
|
-
|
|
1325
|
-
let body: UIColumn
|
|
1184
|
+
let statusText: UIText
|
|
1185
|
+
let list: UIColumn
|
|
1326
1186
|
|
|
1327
1187
|
const loadData = async () => {
|
|
1328
|
-
|
|
1188
|
+
statusText.text = "Loading..."
|
|
1189
|
+
|
|
1329
1190
|
const res = await fetch("https://jsonplaceholder.typicode.com/users")
|
|
1330
1191
|
if (res.status !== 200) {
|
|
1331
|
-
|
|
1332
|
-
UIText("Couldn't load users"),
|
|
1333
|
-
UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
|
|
1334
|
-
.style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
|
|
1335
|
-
.onClick(() => loadData()),
|
|
1336
|
-
)])
|
|
1192
|
+
statusText.text = "Error loading data"
|
|
1337
1193
|
return
|
|
1338
1194
|
}
|
|
1339
|
-
|
|
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
|
+
)
|
|
1340
1207
|
}
|
|
1341
1208
|
|
|
1342
|
-
const screen = UIScreen(
|
|
1343
|
-
UIText("Users").style({
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
.
|
|
1348
|
-
|
|
1349
|
-
|
|
1209
|
+
const screen = UIScreen([
|
|
1210
|
+
UIText("Users").style({ fontSize: 24, fontWeight: 700, color: "white", mb: 8 }),
|
|
1211
|
+
statusText = UIText("").style({ color: "#888" }),
|
|
1212
|
+
UIScrollable([
|
|
1213
|
+
list = UIColumn([])
|
|
1214
|
+
]).style({ flexGrow: 1 }) // the ONE scrolling body — the screen itself never scrolls
|
|
1215
|
+
.onRefresh(() => loadData()) // pull-to-refresh; attached before screen.open()
|
|
1216
|
+
])
|
|
1217
|
+
.style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
|
|
1218
|
+
.onOpen(() => loadData())
|
|
1350
1219
|
|
|
1351
1220
|
screen.open()
|
|
1352
1221
|
</file>
|
|
1353
1222
|
|
|
1354
|
-
// === EXAMPLE 3:
|
|
1223
|
+
// === EXAMPLE 3: Simple app with navigation ===
|
|
1355
1224
|
<file name="home.ts">
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
|
|
1366
|
-
.onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
|
|
1367
|
-
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
1368
|
-
|
|
1369
|
-
const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
|
|
1370
|
-
.style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", onPressed: { opacity: 0.7 } })
|
|
1371
|
-
.onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
|
|
1372
|
-
|
|
1373
|
-
export const homeScreen = UIScreen(
|
|
1374
|
-
UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
|
|
1375
|
-
UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
|
|
1376
|
-
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
1225
|
+
import { detailScreen } from './detail'
|
|
1226
|
+
|
|
1227
|
+
export const homeScreen = UIScreen([
|
|
1228
|
+
UIText("Home").style({ fontSize: 24, fontWeight: 700 }),
|
|
1229
|
+
UIButton([
|
|
1230
|
+
UIText("Go to detail").style({ color: "white" })
|
|
1231
|
+
]).style({ bgColor: "#FF4032", borderRadius: 12, p: 16, rippleColor: "default", onPressed: { opacity: 0.7 } })
|
|
1232
|
+
.onClick(() => Router.push(detailScreen))
|
|
1233
|
+
]).style({ p: 20, pt: "max(safe-top, 24px)", gap: 16 })
|
|
1377
1234
|
</file>
|
|
1378
|
-
<file name="
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1235
|
+
<file name="detail.ts">
|
|
1236
|
+
import chevronLeft from './assets/chevron-left.svg'
|
|
1237
|
+
|
|
1238
|
+
export const detailScreen = UIScreen([
|
|
1239
|
+
UIButton([
|
|
1240
|
+
UIImage(chevronLeft).style({ width: 18, height: 18, tintColor: "#FF4032" }),
|
|
1241
|
+
UIText("Back").style({ color: "#FF4032" })
|
|
1242
|
+
]).style({ alignSelf: "flex-start", flexDirection: "row", height: 32, gap: 4 }).onClick(() => Router.pop()),
|
|
1243
|
+
UIText("Detail Screen").style({ fontSize: 24, fontWeight: 700 })
|
|
1244
|
+
])
|
|
1245
|
+
.style({ p: 20, pt: "max(safe-top, 24px)", gap: 20 })
|
|
1246
|
+
.onBackPressed(() => Router.pop())
|
|
1382
1247
|
</file>
|
|
1383
|
-
<file name="
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
|
|
1388
|
-
|
|
1389
|
-
const tabs = UITabs({
|
|
1390
|
-
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
1391
|
-
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
1392
|
-
})
|
|
1393
|
-
tabs.badge("profile", true) // notification dot on the tab
|
|
1394
|
-
Router.init(tabs)
|
|
1248
|
+
<file name="assets/chevron-left.svg">
|
|
1249
|
+
<svg viewBox="0 0 24 24">
|
|
1250
|
+
<path d="M15 18l-6-6 6-6" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1251
|
+
</svg>
|
|
1395
1252
|
</file>
|
|
1396
|
-
|
|
1397
|
-
// === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
|
|
1398
|
-
// The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
|
|
1399
|
-
// view and handles dismissal itself.
|
|
1400
1253
|
<file name="main.ts">
|
|
1401
|
-
|
|
1402
|
-
// appearing error never jumps the form). Typing in an errored field clears it.
|
|
1403
|
-
const Field = (label: string, style: Style<UIInput> = {}) => {
|
|
1404
|
-
const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
|
|
1405
|
-
border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
|
|
1406
|
-
onFocused: { borderColor: "#4C8DFF" }, ...style })
|
|
1407
|
-
const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
|
|
1408
|
-
input.onChange(() => message.text = "")
|
|
1409
|
-
return {
|
|
1410
|
-
node: UIColumn(
|
|
1411
|
-
UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
|
|
1412
|
-
input, message,
|
|
1413
|
-
).style({ gap: 6 }),
|
|
1414
|
-
input,
|
|
1415
|
-
error: (text: string) => { message.text = text },
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
|
|
1420
|
-
const email = Field("Email", { type: "email", placeholder: "you@example.com" })
|
|
1421
|
-
const password = Field("Password", { type: "password" })
|
|
1422
|
-
|
|
1423
|
-
// The return key walks the form; the last field submits. This is ALL the keyboard code.
|
|
1424
|
-
name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
|
|
1425
|
-
email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
|
|
1426
|
-
password.input.style({ enterKey: "go" }).onSubmit(() => submit())
|
|
1427
|
-
|
|
1428
|
-
let btnLabel: UIText
|
|
1429
|
-
let busy = false
|
|
1430
|
-
|
|
1431
|
-
// The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
|
|
1432
|
-
// first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
|
|
1433
|
-
const submit = async () => {
|
|
1434
|
-
if (busy) return
|
|
1435
|
-
let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
|
|
1436
|
-
if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
|
|
1437
|
-
if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
|
|
1438
|
-
if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
|
|
1439
|
-
if (bad) { bad.input.focus(); return }
|
|
1440
|
-
busy = true
|
|
1441
|
-
btnLabel.text = "Creating…"
|
|
1442
|
-
const res = await fetch("https://api.example.com/register", {
|
|
1443
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1444
|
-
body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
|
|
1445
|
-
})
|
|
1446
|
-
busy = false
|
|
1447
|
-
btnLabel.text = "Create account"
|
|
1448
|
-
if (res.status !== 200) { email.error("Registration failed — try again"); return }
|
|
1449
|
-
toast("Welcome!")
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
const screen = UIScreen(
|
|
1453
|
-
UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
|
|
1454
|
-
UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
|
|
1455
|
-
name.node, email.node, password.node,
|
|
1456
|
-
UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
|
|
1457
|
-
.style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, onPressed: { opacity: 0.85 } })
|
|
1458
|
-
.onClick(() => submit())
|
|
1459
|
-
).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
|
|
1460
|
-
).style({ bgColor: "#0C0F14" })
|
|
1254
|
+
import { homeScreen } from './home'
|
|
1461
1255
|
|
|
1462
|
-
|
|
1256
|
+
Router.init(homeScreen)
|
|
1463
1257
|
</file>
|
|
1464
1258
|
|
|
1465
|
-
// === EXAMPLE
|
|
1259
|
+
// === EXAMPLE 4: UIBottomSheet — persistent map-style sheet with detents ===
|
|
1466
1260
|
<file name="main.ts">
|
|
1467
1261
|
type Place = { id: number; name: string; distance: string }
|
|
1468
1262
|
const places: Place[] = [
|
|
@@ -1471,32 +1265,32 @@ const places: Place[] = [
|
|
|
1471
1265
|
{ id: 3, name: "Riverside Park", distance: "1.2 km" },
|
|
1472
1266
|
]
|
|
1473
1267
|
|
|
1474
|
-
const Row = (p: Place) => UIRow(
|
|
1268
|
+
const Row = (p: Place) => UIRow([
|
|
1475
1269
|
UIText(p.name).style({ color: "#111", fontSize: 16 }),
|
|
1476
1270
|
UIText(p.distance).style({ color: "#888", fontSize: 14 })
|
|
1477
|
-
).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1271
|
+
]).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1478
1272
|
|
|
1479
|
-
const sheet = UIBottomSheet(
|
|
1480
|
-
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1273
|
+
const sheet = UIBottomSheet([
|
|
1274
|
+
UIColumn([]).style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1481
1275
|
UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
|
|
1482
1276
|
UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
|
|
1483
|
-
)
|
|
1277
|
+
])
|
|
1484
1278
|
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
|
|
1485
1279
|
.detents([0.25, 0.6, 1]) // collapsed / half / full
|
|
1486
1280
|
.dismissible(false) // drag below the lowest detent collapses, never closes
|
|
1487
1281
|
.onDetentChange(i => console.log("detent", i))
|
|
1488
1282
|
|
|
1489
|
-
const mapScreen = UIScreen(
|
|
1283
|
+
const mapScreen = UIScreen([
|
|
1490
1284
|
// the map / page content behind the sheet
|
|
1491
|
-
).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1285
|
+
]).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1492
1286
|
|
|
1493
1287
|
mapScreen.open()
|
|
1494
1288
|
|
|
1495
|
-
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
|
|
1289
|
+
// An action sheet is even less: content-sized, no detents() — UIBottomSheet([ ...rows ]).show(),
|
|
1496
1290
|
// scrim and drag-down-to-dismiss included.
|
|
1497
1291
|
</file>
|
|
1498
1292
|
|
|
1499
|
-
// === EXAMPLE
|
|
1293
|
+
// === EXAMPLE 5: UIVirtualizedList — chat (inverted, imperative append) ===
|
|
1500
1294
|
<file name="main.ts">
|
|
1501
1295
|
type Msg = { id: string; text: string; mine: boolean }
|
|
1502
1296
|
|
|
@@ -1508,41 +1302,37 @@ const list = UIVirtualizedList<Msg>({
|
|
|
1508
1302
|
keyOf: m => m.id,
|
|
1509
1303
|
estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
|
|
1510
1304
|
inverted: true, // newest at the bottom
|
|
1511
|
-
render: m => UIRow(
|
|
1512
|
-
UIRow(
|
|
1305
|
+
render: m => UIRow([
|
|
1306
|
+
UIRow([
|
|
1513
1307
|
UIText(m.text).style({ color: m.mine ? "white" : "#111" })
|
|
1514
|
-
).style({
|
|
1308
|
+
]).style({
|
|
1515
1309
|
bgColor: m.mine ? "#FF4032" : "#EEE",
|
|
1516
1310
|
px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
|
|
1517
1311
|
})
|
|
1518
|
-
).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1312
|
+
]).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1519
1313
|
}).style({ flexGrow: 1 })
|
|
1520
1314
|
|
|
1521
|
-
let input:
|
|
1522
|
-
let sendBtn: UIButton
|
|
1315
|
+
let input: UIInput
|
|
1523
1316
|
|
|
1524
1317
|
const send = () => {
|
|
1525
1318
|
const text = input.value.trim()
|
|
1526
1319
|
if (!text) return
|
|
1527
1320
|
list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
|
|
1528
1321
|
input.value = ""
|
|
1529
|
-
sendBtn.style.opacity = 0.4
|
|
1530
1322
|
}
|
|
1531
1323
|
|
|
1532
|
-
const screen = UIScreen(
|
|
1324
|
+
const screen = UIScreen([
|
|
1533
1325
|
list,
|
|
1534
|
-
UIRow(
|
|
1535
|
-
input =
|
|
1536
|
-
|
|
1537
|
-
|
|
1326
|
+
UIRow([
|
|
1327
|
+
input = UIInput().style({
|
|
1328
|
+
flexGrow: 1, height: 40, px: 16, borderRadius: 20,
|
|
1329
|
+
bgColor: "#F0F0F0", placeholder: "Message...", placeholderColor: "#999"
|
|
1538
1330
|
}),
|
|
1539
|
-
|
|
1540
|
-
.style({
|
|
1331
|
+
UIButton([ UIText("Send").style({ color: "white" }) ])
|
|
1332
|
+
.style({ height: 40, px: 16, justifyContent: "center", bgColor: "#FF4032", borderRadius: 20 })
|
|
1541
1333
|
.onClick(send)
|
|
1542
|
-
).style({ p: 8,
|
|
1543
|
-
).style({ bgColor: "white" })
|
|
1544
|
-
|
|
1545
|
-
input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
|
|
1334
|
+
]).style({ p: 8, gap: 8, alignItems: "center" })
|
|
1335
|
+
]).style({ bgColor: "white" })
|
|
1546
1336
|
|
|
1547
1337
|
screen.open()
|
|
1548
1338
|
</file>
|