lecodes-sdk 0.20.0 → 0.20.2
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/inject.js +361 -260
- package/dist/types/audio/Bus.d.ts +45 -0
- package/dist/types/audio/Sound.d.ts +28 -0
- package/dist/types/audio/Voice.d.ts +27 -0
- package/dist/types/audio/audio.d.ts +83 -0
- package/dist/types/audio/support.d.ts +1 -0
- package/dist/types/gl/AudioSource.d.ts +60 -0
- package/dist/types/gl/AudioZone.d.ts +32 -0
- package/dist/types/gl/DecalSet.d.ts +103 -0
- package/dist/types/gl/Geometry.d.ts +5 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Material.d.ts +86 -2
- package/dist/types/gl/Mesh.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +23 -0
- package/dist/types/gl/SceneAudio.d.ts +11 -0
- package/dist/types/gl/Texture.d.ts +29 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
- package/dist/types/gl/animation/core.d.ts +15 -10
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/inject.d.ts +10 -0
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/dist/2d-game.md +408 -197
- package/prompts/dist/3d-app.md +491 -166
- package/prompts/dist/ar-app.md +373 -163
- package/prompts/dist/design.md +83 -87
- package/prompts/dist/ui-app.md +325 -136
- package/src/audio/Bus.ts +102 -0
- package/src/audio/Sound.ts +96 -0
- package/src/audio/Voice.ts +102 -0
- package/src/audio/audio.ts +161 -0
- package/src/audio/support.ts +6 -0
- package/src/bridges.d.ts +1481 -1352
- package/src/compile/compileProject.ts +30 -15
- package/src/compile/index.ts +3 -0
- package/src/core/Aspect.ts +33 -8
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/AudioSource.ts +113 -0
- package/src/gl/AudioZone.ts +75 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/DecalSet.ts +233 -0
- package/src/gl/Geometry.ts +5 -0
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +3 -2
- package/src/gl/Material.ts +152 -4
- package/src/gl/Mesh.ts +20 -1
- package/src/gl/Ragdoll.ts +270 -270
- package/src/gl/Scene.ts +41 -7
- package/src/gl/SceneAudio.ts +26 -0
- package/src/gl/Texture.ts +43 -3
- package/src/gl/Trigger.ts +45 -45
- package/src/gl/Vehicle.ts +5 -5
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +4 -3
- package/src/gl/animation/core.ts +20 -15
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +1 -1
- package/src/inject.ts +12 -0
- package/src/runtime/input.ts +6 -1
- package/src/scene/gizmos.ts +148 -148
- package/src/ui/UIImage.ts +21 -7
package/prompts/dist/ar-app.md
CHANGED
|
@@ -102,6 +102,16 @@ 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
|
+
|
|
105
115
|
## Tools
|
|
106
116
|
|
|
107
117
|
You may be given tools (they appear in the API request, each with its own description). Default to
|
|
@@ -209,16 +219,22 @@ const sfx = new AudioPlayer(src) // src: asset('./x.mp3') or URL; same API: ne
|
|
|
209
219
|
// .dispose() — frees the native player; ANY use after dispose throws
|
|
210
220
|
// No pitch control, no auto-pooling: for overlapping SFX create several players up front and rotate.
|
|
211
221
|
|
|
212
|
-
// ===== INPUT (keyboard) =====
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
218
232
|
setLoop(dt => {
|
|
219
|
-
|
|
220
|
-
|
|
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')
|
|
221
235
|
})
|
|
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.
|
|
222
238
|
|
|
223
239
|
// ===== TOUCH GESTURES =====
|
|
224
240
|
// 'click' fires on pointer-up over a target; 'touchstart' on pointer-down. ev: { clientX, clientY,
|
|
@@ -266,17 +282,23 @@ implement what you can with the loaded ones.
|
|
|
266
282
|
// Canvas: Canvas (retained 2D drawing baked to a texture — sprites/UI images), Bitmap
|
|
267
283
|
// Aspects: Aspect base — attachable node capabilities: node.aspect(Class, opts), custom aspects
|
|
268
284
|
// with per-frame update(dt)
|
|
269
|
-
// UI: UIScreen, Router,
|
|
285
|
+
// UI: UIScreen, Router, UITabs (bottom-tab shell), UIPager (tabs + per-tab stacks), UIRow,
|
|
286
|
+
// UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
|
|
270
287
|
// UIButton, UIInput, UITextArea, UIWidget/UIModal/UIPopover/UIBottomSheet (floating
|
|
271
288
|
// overlays: HUD / dialog / anchored menu / draggable sheet), UIVirtualizedList,
|
|
272
289
|
// registerFont; UIBox (deprecated)
|
|
273
290
|
// 2D engine: Scene2D, Node2D, Camera2D, Sprite, SpriteAnimation, Tilemap, Texture2D,
|
|
274
291
|
// Shape2D + Physics2D + Trigger2D + CharacterController2D (Box2D physics, sensors,
|
|
275
|
-
// raycast/overlap queries, picking)
|
|
292
|
+
// raycast/overlap queries, picking),
|
|
293
|
+
// defineScene2d (declarative *.scene2d.ts files) + cells + CameraFollow
|
|
276
294
|
// 3D engine: Scene, Node, Camera, Mesh (box/sphere/cylinder/plane), Model (.glb) + ModelAnimation,
|
|
277
295
|
// Geometry, Material (lit/unlit/custom shaders), Texture, Light.sun(),
|
|
278
296
|
// Shape + Physics + Trigger + CharacterController (Jolt physics, raycast),
|
|
279
297
|
// 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
|
|
280
302
|
// AR: ARScene (camera passthrough, anchors, placement gestures via addControls)
|
|
281
303
|
|
|
282
304
|
// ===== CANVAS — retained drawing baked to a texture (engine-independent) =====
|
|
@@ -355,15 +377,13 @@ node.lookAt(point: Vec3Like, mode = '-z', up = [0, 1, 0]): this // aim a local
|
|
|
355
377
|
// 'click' pointer-up over the node — REQUIRES a Shape aspect (pick body)
|
|
356
378
|
// 'touchstart' pointer-down over the node (ev.track() drags) — requires Shape
|
|
357
379
|
// 'enter' / 'exit' (other: Node) physics contact / trigger overlap began/ended — Shape + Physics/Trigger
|
|
358
|
-
// 'completed' (clip: number) non-looping GLB clip finished — Model only
|
|
359
|
-
// 'loopReached' (clip: number) looping GLB clip wrapped — Model only
|
|
360
380
|
// Without a Shape a node is INVISIBLE to taps — only scene listeners fire (ev.target === null).
|
|
361
381
|
|
|
362
382
|
// ===== ASPECTS =====
|
|
363
383
|
// Capabilities attach to nodes as aspects and chain; each adds a named accessor:
|
|
364
384
|
node.aspect(Class, opts?) // attach + configure; returns the node (typed with the accessor)
|
|
365
385
|
node.get(Class) / node.has(Class) / node.removeAspect(Class)
|
|
366
|
-
// Named accessors: node.physics, node.trigger, node.controller, node.shape,
|
|
386
|
+
// Named accessors: node.physics, node.trigger, node.controller, node.shape, node.anim
|
|
367
387
|
// Custom game logic = your own aspect with per-frame update(dt seconds):
|
|
368
388
|
class Spin extends Aspect<'spin', Node> { // <accessor name, node kind>
|
|
369
389
|
speed = 90 // class fields = configurable defaults (opts override)
|
|
@@ -431,24 +451,34 @@ const hero = await Model.load(asset('./hero.glb')) // asset path | https URL |
|
|
|
431
451
|
scene.add(hero) // a Model IS a Node — transform/events/aspects all apply
|
|
432
452
|
hero.traverse(n => { if (n.name === 'Sword') n.visible = false }) // GLB internals are plain child Nodes
|
|
433
453
|
|
|
434
|
-
// Animation — every Model has
|
|
435
|
-
|
|
436
|
-
hero.anim.
|
|
437
|
-
hero.anim.
|
|
438
|
-
hero.anim.
|
|
439
|
-
|
|
440
|
-
hero.anim.
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
hero.
|
|
444
|
-
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
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]
|
|
452
482
|
|
|
453
483
|
// ===== PHYSICS (Jolt) =====
|
|
454
484
|
|
|
@@ -464,9 +494,18 @@ node.aspect(Shape, {
|
|
|
464
494
|
sphere?: number, // radius
|
|
465
495
|
cylinder?: { halfHeight: number, radius: number }, // Y-aligned
|
|
466
496
|
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)
|
|
467
499
|
raycast?: boolean, // default true; false = invisible to taps & raycasts
|
|
468
500
|
})
|
|
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.
|
|
469
504
|
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.
|
|
470
509
|
// A bare Shape (no Physics) creates a STATIC pick-only body → the node gets 'click'/'touchstart'.
|
|
471
510
|
// It snapshots the transform at attach time — a MOVING pickable object needs a Physics body too.
|
|
472
511
|
|
|
@@ -476,11 +515,14 @@ node.aspect(Shape, {}).aspect(Physics, {
|
|
|
476
515
|
mass?: number, // kg, dynamic only; default 1
|
|
477
516
|
})
|
|
478
517
|
node.physics.velocity // Vec3 world units/s — get (fresh copy) / set
|
|
518
|
+
node.physics.angularVelocity // Vec3 DEGREES/s about each world axis — get / set
|
|
479
519
|
node.physics.applyImpulse(v): this // instant impulse, wakes the body
|
|
480
|
-
node.
|
|
481
|
-
|
|
482
|
-
//
|
|
483
|
-
//
|
|
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.
|
|
484
526
|
|
|
485
527
|
// Contact events — on BOTH nodes of a contact/overlap, arg = the other node:
|
|
486
528
|
crate.addEventListener('enter', (other: Node) => {})
|
|
@@ -524,7 +566,8 @@ sparks.destroy() // from Node — removes the system
|
|
|
524
566
|
|
|
525
567
|
scene.camera // a Node — never constructed; every scene owns one.
|
|
526
568
|
// (Who MOVES it is mode-specific — see the Scene/AR section.)
|
|
527
|
-
camera.fov
|
|
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
|
|
528
571
|
camera.getViewDirection(screenX, screenY): Vec3 // world dir through a screen point (logical px = ev.clientX/Y)
|
|
529
572
|
camera.getRay(screenX, screenY): Ray // origin = camera.worldPosition + that direction
|
|
530
573
|
|
|
@@ -670,7 +713,7 @@ try { await arScene.open() } catch { toast('Camera access is required for AR') }
|
|
|
670
713
|
const scene = new ARScene()
|
|
671
714
|
|
|
672
715
|
const hint = UIText('Move your phone to find the floor').style({ color: 'white', fontSize: 16 })
|
|
673
|
-
const hud = UIScreen(
|
|
716
|
+
const hud = UIScreen(hint).style({ p: 'safe-all', pb: 'comfort-bottom', justifyContent: 'flex-end', alignItems: 'center' })
|
|
674
717
|
|
|
675
718
|
async function main() {
|
|
676
719
|
const chair = await Model.load(asset('./chair.glb'))
|
|
@@ -708,18 +751,22 @@ main()
|
|
|
708
751
|
|
|
709
752
|
// ===== UI COMPONENTS =====
|
|
710
753
|
// Every element is created by a global factory function (never `new`) that takes only the element's
|
|
711
|
-
// CONTENT
|
|
712
|
-
// every configuring method returns the element itself, so construction reads
|
|
754
|
+
// CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
|
|
755
|
+
// configured by chaining: every configuring method returns the element itself, so construction reads
|
|
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)
|
|
713
760
|
|
|
714
761
|
// UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
|
|
715
|
-
UIRow(children) / UIColumn(children)
|
|
762
|
+
UIRow(...children) / UIColumn(...children)
|
|
716
763
|
// Child management — imperative, no diffing; works before and after the element is on screen:
|
|
717
764
|
// .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
|
|
718
765
|
// .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
|
|
719
766
|
// For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
|
|
720
767
|
|
|
721
768
|
// UIScreen — root screen, always fills the device. Behaves as a UIColumn.
|
|
722
|
-
UIScreen(children)
|
|
769
|
+
UIScreen(...children)
|
|
723
770
|
// .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
|
|
724
771
|
// .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
|
|
725
772
|
// popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
|
|
@@ -727,12 +774,40 @@ UIScreen(children)
|
|
|
727
774
|
// .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
|
|
728
775
|
// .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
|
|
729
776
|
// Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
|
|
730
|
-
// with flexGrow: 1: UIScreen(
|
|
777
|
+
// with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
|
|
731
778
|
// Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
|
|
732
779
|
|
|
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
|
+
|
|
733
808
|
// UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
|
|
734
809
|
// Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
|
|
735
|
-
UIWidget(children)
|
|
810
|
+
UIWidget(...children)
|
|
736
811
|
// .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
|
|
737
812
|
// extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
|
|
738
813
|
// a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
|
|
@@ -744,7 +819,7 @@ UIWidget(children)
|
|
|
744
819
|
// UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
|
|
745
820
|
// Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
|
|
746
821
|
// back button close it automatically. Create ONCE at module scope, like any widget.
|
|
747
|
-
UIModal(children)
|
|
822
|
+
UIModal(...children)
|
|
748
823
|
// .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
|
|
749
824
|
// — plus the full UIWidget surface
|
|
750
825
|
// .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
|
|
@@ -755,7 +830,7 @@ UIModal(children)
|
|
|
755
830
|
// UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
|
|
756
831
|
// Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
|
|
757
832
|
// dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
|
|
758
|
-
UIBottomSheet(children)
|
|
833
|
+
UIBottomSheet(...children)
|
|
759
834
|
// .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
|
|
760
835
|
// BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
|
|
761
836
|
// .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
|
|
@@ -768,30 +843,33 @@ UIBottomSheet(children)
|
|
|
768
843
|
// Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
|
|
769
844
|
// automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
|
|
770
845
|
// Attaches itself to Presentable.current (hides with the page it opened on).
|
|
771
|
-
UIPopover(children)
|
|
846
|
+
UIPopover(...children)
|
|
772
847
|
// .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
|
|
773
848
|
// .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
|
|
774
849
|
// Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
|
|
775
850
|
|
|
776
851
|
// UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
|
|
777
|
-
UIScrollable(children)
|
|
852
|
+
UIScrollable(...children)
|
|
778
853
|
// extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
|
|
779
854
|
// overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
|
|
780
855
|
// 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.)
|
|
781
859
|
// .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
|
|
782
860
|
// .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
|
|
783
861
|
// element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
|
|
784
862
|
// Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
|
|
785
863
|
// Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
|
|
786
864
|
// themselves. flexGrow: 1 to fill the remaining space is still yours to set.
|
|
787
|
-
// Carousel = scrollDirection: "horizontal" + FIXED-width cards
|
|
865
|
+
// Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
|
|
788
866
|
|
|
789
867
|
// UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
|
|
790
868
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
791
869
|
// is large, grows over time, or is unknown.
|
|
792
870
|
UIVirtualizedList<T>({
|
|
793
871
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
794
|
-
render: (item: T) =>
|
|
872
|
+
render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
|
|
795
873
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
796
874
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
797
875
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
@@ -840,7 +918,7 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
|
|
|
840
918
|
|
|
841
919
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
842
920
|
// To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
|
|
843
|
-
UIButton(children
|
|
921
|
+
UIButton(...children)
|
|
844
922
|
// .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
|
|
845
923
|
// extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
|
|
846
924
|
// rippleColor ("default" or a Color, Android only)
|
|
@@ -879,7 +957,7 @@ UIInput() / UITextArea()
|
|
|
879
957
|
|
|
880
958
|
// UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
|
|
881
959
|
// Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
|
|
882
|
-
UIRow(
|
|
960
|
+
UIRow(title, UISpacer(), closeButton)
|
|
883
961
|
// If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
|
|
884
962
|
|
|
885
963
|
// @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
|
|
@@ -894,9 +972,13 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
|
|
|
894
972
|
// 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
|
|
895
973
|
// 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
|
|
896
974
|
// e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
|
|
897
|
-
// 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
|
|
975
|
+
// 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
|
|
898
976
|
// Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
|
|
899
977
|
// 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.)
|
|
900
982
|
// 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
|
|
901
983
|
// Never modifies the stored style.
|
|
902
984
|
// For free-value tweens use animate() — see ANIMATION above.
|
|
@@ -974,9 +1056,28 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
974
1056
|
.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
|
|
975
1057
|
// Keys: onLandscape, onPortrait.
|
|
976
1058
|
|
|
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
|
+
|
|
977
1078
|
// --- Defaults that surprise ---
|
|
978
|
-
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and
|
|
979
|
-
// flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
1079
|
+
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
|
|
1080
|
+
// default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
980
1081
|
// flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
|
|
981
1082
|
// alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
|
|
982
1083
|
// overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
|
|
@@ -993,10 +1094,10 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
993
1094
|
// ===== REUSABLE COMPONENTS =====
|
|
994
1095
|
// Extract repeated UI into factory functions — they return elements you can chain methods on:
|
|
995
1096
|
|
|
996
|
-
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
1097
|
+
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
997
1098
|
UIText(title).style({ fontWeight: 700, color: "white" }),
|
|
998
1099
|
UIText(subtitle).style({ color: "#888", fontSize: 13 })
|
|
999
|
-
|
|
1100
|
+
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
1000
1101
|
|
|
1001
1102
|
// Use like any other element — chain after the call:
|
|
1002
1103
|
ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
@@ -1005,15 +1106,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
|
1005
1106
|
const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
|
|
1006
1107
|
|
|
1007
1108
|
// ===== CAPTURING ELEMENT REFERENCES =====
|
|
1008
|
-
// Use an assignment expression
|
|
1109
|
+
// Use an assignment expression right in the children — standard TypeScript:
|
|
1009
1110
|
|
|
1010
1111
|
let label: UIText
|
|
1011
1112
|
let input: UIInput
|
|
1012
1113
|
|
|
1013
|
-
UIColumn(
|
|
1114
|
+
UIColumn(
|
|
1014
1115
|
label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
|
|
1015
1116
|
input = UIInput(),
|
|
1016
|
-
|
|
1117
|
+
)
|
|
1017
1118
|
|
|
1018
1119
|
// Later:
|
|
1019
1120
|
label.text = "Updated"
|
|
@@ -1023,15 +1124,17 @@ input.value // read current value
|
|
|
1023
1124
|
// ((button.children[0] as UIText).text vs buttonText.text).
|
|
1024
1125
|
|
|
1025
1126
|
// ===== CONDITIONAL CHILDREN =====
|
|
1026
|
-
// null / undefined / false
|
|
1027
|
-
UIColumn(
|
|
1127
|
+
// A null / undefined / false child is skipped — no element, no layout slot.
|
|
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))
|
|
1028
1131
|
|
|
1029
1132
|
// ===== SIZING: the two axes behave differently =====
|
|
1030
1133
|
// MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
|
|
1031
1134
|
// without flexGrow: 1 (no implicit min-sizes).
|
|
1032
1135
|
// CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
|
|
1033
1136
|
// or alignSelf to opt out.
|
|
1034
|
-
// So UIRow(
|
|
1137
|
+
// So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
1035
1138
|
// EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
|
|
1036
1139
|
// equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
|
|
1037
1140
|
// longer label stays wider.
|
|
@@ -1051,6 +1154,7 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
|
|
|
1051
1154
|
// Position once at an interaction — never poll per frame.
|
|
1052
1155
|
|
|
1053
1156
|
// ===== 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.
|
|
1054
1158
|
Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
|
|
1055
1159
|
Router.push(screen) // push onto the stack, screen becomes active
|
|
1056
1160
|
Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
|
|
@@ -1092,6 +1196,14 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
|
|
|
1092
1196
|
UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
|
|
1093
1197
|
// Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
|
|
1094
1198
|
|
|
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
|
+
|
|
1095
1207
|
// ===== COMMON MISTAKES — DO NOT DO THESE =====
|
|
1096
1208
|
|
|
1097
1209
|
// ❌ CSS that doesn't exist here
|
|
@@ -1100,16 +1212,13 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
|
|
|
1100
1212
|
lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
|
|
1101
1213
|
|
|
1102
1214
|
// ❌ touch handler on a non-touch element
|
|
1103
|
-
UIColumn(
|
|
1104
|
-
|
|
1105
|
-
// ❌ scrollable that overflows the screen
|
|
1106
|
-
// A wrapping container between the scrollable and the screen is missing flexShrink: 1
|
|
1215
|
+
UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
|
|
1107
1216
|
|
|
1108
|
-
// ❌ let/const
|
|
1109
|
-
UIRow(
|
|
1217
|
+
// ❌ let/const among the children — `let` is a statement, not an expression
|
|
1218
|
+
UIRow(let input = UIInput()) // WRONG
|
|
1110
1219
|
// ✅ declare outside, assign inside (assignment both sets the var AND appends)
|
|
1111
1220
|
let input: UIInput
|
|
1112
|
-
UIRow(
|
|
1221
|
+
UIRow(input = UIInput())
|
|
1113
1222
|
|
|
1114
1223
|
// ❌ setting a UIImage's content through bgImage
|
|
1115
1224
|
const img = UIImage("") // WRONG — empty source as a placeholder
|
|
@@ -1119,30 +1228,30 @@ const img = UIImage(url)
|
|
|
1119
1228
|
img.src = newUrl // updates the displayed image
|
|
1120
1229
|
|
|
1121
1230
|
// ❌ expecting an input to fill width like in CSS
|
|
1122
|
-
UIRow(
|
|
1231
|
+
UIRow(UIInput()) // WRONG — collapses to placeholder width
|
|
1123
1232
|
// ✅ stretch it explicitly
|
|
1124
|
-
UIColumn(
|
|
1125
|
-
UIRow(
|
|
1233
|
+
UIColumn(UIInput().style({ width: "100%" })) // cross axis
|
|
1234
|
+
UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
|
|
1126
1235
|
|
|
1127
1236
|
// ❌ a button that should match the input's height but shrinks to its text
|
|
1128
|
-
UIRow(
|
|
1237
|
+
UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
|
|
1129
1238
|
// ✅ give controls the same explicit height
|
|
1130
|
-
UIRow(
|
|
1239
|
+
UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
|
|
1131
1240
|
|
|
1132
1241
|
// ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
|
|
1133
|
-
UIRow(
|
|
1242
|
+
UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
|
|
1134
1243
|
// ✅ flex: 1 — grows from a zero basis, children end up equal
|
|
1135
|
-
UIRow(
|
|
1244
|
+
UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
|
|
1136
1245
|
|
|
1137
1246
|
// ❌ empty containers as spacers to align children (web habit)
|
|
1138
|
-
UIRow(
|
|
1247
|
+
UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
|
|
1139
1248
|
// ✅ alignment is a CONTAINER property, not an extra element
|
|
1140
|
-
UIRow(
|
|
1249
|
+
UIRow(label).style({ justifyContent: "flex-end" })
|
|
1141
1250
|
|
|
1142
1251
|
// ❌ empty element as a placeholder for a conditional child
|
|
1143
|
-
UIRow(
|
|
1252
|
+
UIRow(isGroup ? button : UIColumn()) // WRONG
|
|
1144
1253
|
// ✅ null is skipped in children — no phantom element
|
|
1145
|
-
UIRow(
|
|
1254
|
+
UIRow(isGroup ? button : null)
|
|
1146
1255
|
|
|
1147
1256
|
// ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
|
|
1148
1257
|
UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
|
|
@@ -1156,7 +1265,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
|
|
|
1156
1265
|
list.onEndReached(600, loadNextPage)
|
|
1157
1266
|
|
|
1158
1267
|
// ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
|
|
1159
|
-
const openSheet = () => UIWidget(
|
|
1268
|
+
const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
|
|
1160
1269
|
// ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
|
|
1161
1270
|
|
|
1162
1271
|
// ===== EXAMPLES =====
|
|
@@ -1164,7 +1273,7 @@ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new wid
|
|
|
1164
1273
|
// === EXAMPLE 1: Single-file app ===
|
|
1165
1274
|
<file name="main.ts">
|
|
1166
1275
|
let text: UIText
|
|
1167
|
-
const screen = UIScreen(
|
|
1276
|
+
const screen = UIScreen(
|
|
1168
1277
|
text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
|
|
1169
1278
|
UIText("Your name:").style({ textAlign: "center" }),
|
|
1170
1279
|
UIInput()
|
|
@@ -1172,91 +1281,188 @@ const screen = UIScreen([
|
|
|
1172
1281
|
.onChange(str => {
|
|
1173
1282
|
text.text = `Hello, ${str}!`
|
|
1174
1283
|
})
|
|
1175
|
-
|
|
1284
|
+
).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
1176
1285
|
|
|
1177
1286
|
screen.open()
|
|
1178
1287
|
</file>
|
|
1179
1288
|
|
|
1180
|
-
// === EXAMPLE 2:
|
|
1289
|
+
// === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
|
|
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>
|
|
1181
1306
|
<file name="main.ts">
|
|
1307
|
+
import { colors, font } from './tokens'
|
|
1308
|
+
|
|
1182
1309
|
type User = { id: number; name: string; email: string }
|
|
1183
1310
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1311
|
+
const Row = (u: User) => UIRow(
|
|
1312
|
+
UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
|
|
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 })
|
|
1186
1321
|
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1322
|
+
const Centered = (...children: UINodeChild[]) =>
|
|
1323
|
+
UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
|
|
1324
|
+
|
|
1325
|
+
let body: UIColumn
|
|
1189
1326
|
|
|
1327
|
+
const loadData = async () => {
|
|
1328
|
+
body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
|
|
1190
1329
|
const res = await fetch("https://jsonplaceholder.typicode.com/users")
|
|
1191
1330
|
if (res.status !== 200) {
|
|
1192
|
-
|
|
1331
|
+
body.setContent([Centered(
|
|
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
|
+
)])
|
|
1193
1337
|
return
|
|
1194
1338
|
}
|
|
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
|
-
)
|
|
1339
|
+
body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
|
|
1207
1340
|
}
|
|
1208
1341
|
|
|
1209
|
-
const screen = UIScreen(
|
|
1210
|
-
UIText("Users").style({
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
.style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
|
|
1218
|
-
.onOpen(() => loadData())
|
|
1342
|
+
const screen = UIScreen(
|
|
1343
|
+
UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
|
|
1344
|
+
UIScrollable(
|
|
1345
|
+
body = UIColumn().style({ flexGrow: 1, gap: 8 })
|
|
1346
|
+
).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
|
|
1347
|
+
.onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
|
|
1348
|
+
).style({ bgColor: colors.bg, pt: "comfort-top" })
|
|
1349
|
+
.onOpen(() => loadData())
|
|
1219
1350
|
|
|
1220
1351
|
screen.open()
|
|
1221
1352
|
</file>
|
|
1222
1353
|
|
|
1223
|
-
// === EXAMPLE 3:
|
|
1354
|
+
// === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
|
|
1224
1355
|
<file name="home.ts">
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1356
|
+
const detailScreen = (name: string) => UIScreen(
|
|
1357
|
+
UIButton(
|
|
1358
|
+
UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
|
|
1359
|
+
UIText("Back")
|
|
1360
|
+
).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
|
|
1361
|
+
UIText(name).style({ fontSize: 24, fontWeight: 700 }),
|
|
1362
|
+
UIButton(
|
|
1363
|
+
UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
|
|
1364
|
+
UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
|
|
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" })
|
|
1234
1377
|
</file>
|
|
1235
|
-
<file name="
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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())
|
|
1247
|
-
</file>
|
|
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>
|
|
1378
|
+
<file name="profile.ts">
|
|
1379
|
+
export const profileScreen = UIScreen(
|
|
1380
|
+
UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
|
|
1381
|
+
).style({ p: 16, pt: "comfort-top", bgColor: "black" })
|
|
1252
1382
|
</file>
|
|
1253
1383
|
<file name="main.ts">
|
|
1254
1384
|
import { homeScreen } from './home'
|
|
1385
|
+
import { profileScreen } from './profile'
|
|
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)
|
|
1395
|
+
</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
|
+
<file name="main.ts">
|
|
1401
|
+
// Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
|
|
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
|
+
}
|
|
1255
1451
|
|
|
1256
|
-
|
|
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" })
|
|
1461
|
+
|
|
1462
|
+
screen.open()
|
|
1257
1463
|
</file>
|
|
1258
1464
|
|
|
1259
|
-
// === EXAMPLE
|
|
1465
|
+
// === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
|
|
1260
1466
|
<file name="main.ts">
|
|
1261
1467
|
type Place = { id: number; name: string; distance: string }
|
|
1262
1468
|
const places: Place[] = [
|
|
@@ -1265,32 +1471,32 @@ const places: Place[] = [
|
|
|
1265
1471
|
{ id: 3, name: "Riverside Park", distance: "1.2 km" },
|
|
1266
1472
|
]
|
|
1267
1473
|
|
|
1268
|
-
const Row = (p: Place) => UIRow(
|
|
1474
|
+
const Row = (p: Place) => UIRow(
|
|
1269
1475
|
UIText(p.name).style({ color: "#111", fontSize: 16 }),
|
|
1270
1476
|
UIText(p.distance).style({ color: "#888", fontSize: 14 })
|
|
1271
|
-
|
|
1477
|
+
).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1272
1478
|
|
|
1273
|
-
const sheet = UIBottomSheet(
|
|
1274
|
-
UIColumn(
|
|
1479
|
+
const sheet = UIBottomSheet(
|
|
1480
|
+
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1275
1481
|
UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
|
|
1276
1482
|
UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
|
|
1277
|
-
|
|
1483
|
+
)
|
|
1278
1484
|
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
|
|
1279
1485
|
.detents([0.25, 0.6, 1]) // collapsed / half / full
|
|
1280
1486
|
.dismissible(false) // drag below the lowest detent collapses, never closes
|
|
1281
1487
|
.onDetentChange(i => console.log("detent", i))
|
|
1282
1488
|
|
|
1283
|
-
const mapScreen = UIScreen(
|
|
1489
|
+
const mapScreen = UIScreen(
|
|
1284
1490
|
// the map / page content behind the sheet
|
|
1285
|
-
|
|
1491
|
+
).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1286
1492
|
|
|
1287
1493
|
mapScreen.open()
|
|
1288
1494
|
|
|
1289
|
-
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(
|
|
1495
|
+
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
|
|
1290
1496
|
// scrim and drag-down-to-dismiss included.
|
|
1291
1497
|
</file>
|
|
1292
1498
|
|
|
1293
|
-
// === EXAMPLE
|
|
1499
|
+
// === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
|
|
1294
1500
|
<file name="main.ts">
|
|
1295
1501
|
type Msg = { id: string; text: string; mine: boolean }
|
|
1296
1502
|
|
|
@@ -1302,37 +1508,41 @@ const list = UIVirtualizedList<Msg>({
|
|
|
1302
1508
|
keyOf: m => m.id,
|
|
1303
1509
|
estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
|
|
1304
1510
|
inverted: true, // newest at the bottom
|
|
1305
|
-
render: m => UIRow(
|
|
1306
|
-
UIRow(
|
|
1511
|
+
render: m => UIRow(
|
|
1512
|
+
UIRow(
|
|
1307
1513
|
UIText(m.text).style({ color: m.mine ? "white" : "#111" })
|
|
1308
|
-
|
|
1514
|
+
).style({
|
|
1309
1515
|
bgColor: m.mine ? "#FF4032" : "#EEE",
|
|
1310
1516
|
px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
|
|
1311
1517
|
})
|
|
1312
|
-
|
|
1518
|
+
).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1313
1519
|
}).style({ flexGrow: 1 })
|
|
1314
1520
|
|
|
1315
|
-
let input:
|
|
1521
|
+
let input: UITextArea
|
|
1522
|
+
let sendBtn: UIButton
|
|
1316
1523
|
|
|
1317
1524
|
const send = () => {
|
|
1318
1525
|
const text = input.value.trim()
|
|
1319
1526
|
if (!text) return
|
|
1320
1527
|
list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
|
|
1321
1528
|
input.value = ""
|
|
1529
|
+
sendBtn.style.opacity = 0.4
|
|
1322
1530
|
}
|
|
1323
1531
|
|
|
1324
|
-
const screen = UIScreen(
|
|
1532
|
+
const screen = UIScreen(
|
|
1325
1533
|
list,
|
|
1326
|
-
UIRow(
|
|
1327
|
-
input =
|
|
1328
|
-
flexGrow: 1,
|
|
1329
|
-
|
|
1534
|
+
UIRow(
|
|
1535
|
+
input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
|
|
1536
|
+
color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
|
|
1537
|
+
keyboardDismiss: false
|
|
1330
1538
|
}),
|
|
1331
|
-
UIButton(
|
|
1332
|
-
.style({
|
|
1539
|
+
sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
|
|
1540
|
+
.style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
|
|
1333
1541
|
.onClick(send)
|
|
1334
|
-
|
|
1335
|
-
|
|
1542
|
+
).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
|
|
1543
|
+
).style({ bgColor: "white" })
|
|
1544
|
+
|
|
1545
|
+
input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
|
|
1336
1546
|
|
|
1337
1547
|
screen.open()
|
|
1338
1548
|
</file>
|