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/2d-game.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) =====
|
|
@@ -331,7 +309,7 @@ c.toBitmap(): Bitmap // rasterized snapshot, usable with drawImage
|
|
|
331
309
|
|
|
332
310
|
# 2D engine
|
|
333
311
|
|
|
334
|
-
Scene2D + nodes (Sprite, Tilemap) + aspects (SpriteAnimation, Shape2D/Physics2D/Trigger2D/
|
|
312
|
+
Scene2D + nodes (Sprite, Tilemap) + aspects (SpriteAnimation, Shape2D/Physics2D/Trigger2D/CharacterController2D) + Canvas for baked text/vector graphics. All globals. `Scene2D` ≠ the 3D `Scene`; one scene active at a time. World units: 1 unit = 1 logical px at camera zoom 1. **Y-UP** (up = +y, gravity = negative y). Rotations in **degrees**, CCW. Game loop = setLoop(dt) from core. The UI kit renders ON TOP of the 2D canvas — build HUDs/menus/on-screen controls with UIScreen/UIWidget as usual (open the scene, then the HUD screen).
|
|
335
313
|
|
|
336
314
|
// ===== SCENE & NODES =====
|
|
337
315
|
|
|
@@ -460,9 +438,9 @@ new Tilemap(options: {
|
|
|
460
438
|
// data row 0 = the TOP row of the map (reads like level text), even though world Y is up. Atlas indices
|
|
461
439
|
// are row-major too: 0 = the atlas's top-left tile. node.position = the map's BOTTOM-LEFT corner.
|
|
462
440
|
map.setTile(x, y, index): this // grid coords as in data (row 0 = top); -1 clears. Cheap.
|
|
463
|
-
// LIMITS — a static uniform grid. NO animated tiles (swap cells with setTile yourself), NO per-tile
|
|
464
|
-
//
|
|
465
|
-
//
|
|
441
|
+
// LIMITS — a static uniform grid. NO animated tiles (swap cells with setTile yourself), NO per-tile collision
|
|
442
|
+
// (build it from invisible Node2Ds with Shape2D boxes/segments + static Physics2D), NO per-tile flip/rotate
|
|
443
|
+
// (bake variants into the atlas).
|
|
466
444
|
|
|
467
445
|
// ===== 2D PHYSICS (Box2D) =====
|
|
468
446
|
|
|
@@ -471,95 +449,73 @@ Physics2D.configure(config?: {
|
|
|
471
449
|
pixelsPerMeter?: number // Box2D tolerance tuning (you still author in world units); default 64
|
|
472
450
|
subSteps?: number // default 4
|
|
473
451
|
})
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
Physics2D.supported // some builds ship without physics — the family then silently no-ops
|
|
452
|
+
// Call ONCE, BEFORE creating any body — calling it again RESETS the world, orphaning existing bodies.
|
|
453
|
+
// Fixed 60 Hz step; rendered transforms are interpolated.
|
|
454
|
+
Physics2D.supported // static: some builds ship without physics — the family then silently no-ops (no
|
|
455
|
+
// events, no picking). Guard physics-critical gameplay with this.
|
|
477
456
|
|
|
478
457
|
// --- Shape2D — pure geometry (node.shape). Does nothing alone. Attach BEFORE the body. ---
|
|
479
458
|
node.aspect(Shape2D, {
|
|
480
459
|
box?: [hw, hh] // HALF-extents: box:[12,20] = a 24×40 box
|
|
481
460
|
circle?: number // radius
|
|
482
461
|
capsule?: { from: Vec2Like, to: Vec2Like, radius: number } // between two node-local points
|
|
483
|
-
segment?: { from: Vec2Like, to: Vec2Like } //
|
|
484
|
-
polygon?: Vec2Like[] //
|
|
485
|
-
|
|
486
|
-
loop?: boolean // chain: close the contour. flip?: put the solid side on the other side
|
|
487
|
-
origin?: Vec2Like // the collider's centre relative to the node — applies to EVERY kind
|
|
462
|
+
segment?: { from: Vec2Like, to: Vec2Like } // thin edge — STATIC ground/walls/slopes only (never dynamic, never a sensor)
|
|
463
|
+
polygon?: Vec2Like[] // convex, ≤ 8 local points (hull computed)
|
|
464
|
+
offset?: Vec2Like // local offset from node origin (box/circle only)
|
|
488
465
|
})
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
// works. A chain is ONE-SIDED: solid on the RIGHT of the point order, so left-to-right ground needs flip.
|
|
466
|
+
// Exactly one kind; with NONE, an auto box derives from the sprite's size (16×16 fallback on bare Node2D).
|
|
467
|
+
// Shapes NEVER read node.scale — extents are raw world units. With anchor [0.5,1] (feet) the origin is at
|
|
468
|
+
// the feet — offset the box UP: { box: [12, 8], offset: [0, 8] }.
|
|
493
469
|
|
|
494
470
|
// --- Physics2D — rigid body (node.physics). Requires a Shape2D on the node (throws otherwise). ---
|
|
495
471
|
node.aspect(Physics2D, {
|
|
496
472
|
motion?: 'static' | 'kinematic' | 'dynamic' // default 'dynamic'
|
|
497
|
-
|
|
498
|
-
friction?: number // default 0.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
473
|
+
density?: number // default 1. restitution?: bounciness, default 0
|
|
474
|
+
friction?: number // default 0.3 (0 = slide clean along walls — good for player characters)
|
|
475
|
+
fixedRotation?: boolean // lock rotation — set true for characters; default false
|
|
476
|
+
bullet?: boolean // continuous collision for fast movers. gravityScale?: per-body multiplier, default 1
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
node.physics.velocity = [vx, vy] // world units/s (getter returns a fresh Vec2)
|
|
480
|
+
node.physics.angularVelocity = 90 // degrees/s (write-only); .gravityScale = 0 → floats
|
|
481
|
+
node.physics.applyImpulse([x, y]): this // instantaneous kick (jumps, knockback)
|
|
482
|
+
node.physics.applyForce([x, y]): this // continuous push
|
|
483
|
+
node.physics.moveTo(p, deg = 0): this // teleport / drive a KINEMATIC body to a world pose
|
|
484
|
+
|
|
485
|
+
// PHYSICS OWNS THE TRANSFORM of dynamic/kinematic bodies: the native step writes node position/rotation.
|
|
486
|
+
// NEVER set node.position per frame on a body — drive dynamic with velocity/applyImpulse, kinematic with
|
|
487
|
+
// moveTo. READING node.position / worldPosition is always correct (auto re-sync). Set the spawn position
|
|
488
|
+
// BEFORE attaching Physics2D (the body seeds from it). Keep bodies on ROOT-level nodes (a body under a
|
|
489
|
+
// moving parent fights the step). Dynamic bodies keep momentum: zero velocity to stop.
|
|
490
|
+
|
|
491
|
+
// --- Trigger2D — static sensor zone (node.trigger). Fires enter/exit, blocks nothing. ---
|
|
492
|
+
const goal = new Node2D()
|
|
493
|
+
goal.position = [320, 64] // set BEFORE attaching — a static body doesn't follow later
|
|
494
|
+
goal.aspect(Shape2D, { circle: 48 }) // node moves (reposition with goal.trigger.moveTo(p))
|
|
495
|
+
.aspect(Trigger2D) // no options
|
|
496
|
+
goal.addEventListener('enter', other => {}) // other = the Node2D that entered; 'exit' when it leaves
|
|
497
|
+
// enter/exit also fire for SOLID contacts (hero touches a wall), delivered to BOTH nodes.
|
|
498
|
+
|
|
499
|
+
// --- CharacterController2D — platformer helper (node.controller) ---
|
|
500
|
+
node.aspect(CharacterController2D, {
|
|
501
|
+
speed?: number // horizontal, world units/s; default 200. jumpSpeed?: take-off speed; default 500
|
|
502
|
+
groundProbe?: number // extra grounded-ray below the feet; default 6
|
|
503
|
+
footOffset?: number // node origin → feet; default = half the sprite height
|
|
503
504
|
})
|
|
504
|
-
|
|
505
|
-
//
|
|
506
|
-
node.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
goal.position = [x, y] // the zone FOLLOWS the node (it is kinematic)
|
|
519
|
-
goal.trigger.enabled = false // switch it off instead of parking it off-screen
|
|
520
|
-
goal.addEventListener('enter', (other, contact) => {}) // contact: { point, normal, speed }
|
|
521
|
-
// enter/exit also fire for SOLID contacts, delivered to BOTH nodes; the normal points back at YOU, so
|
|
522
|
-
// normal.y > 0.7 reads as "I landed on top of it". Adding the listener is what ENABLES contact events on
|
|
523
|
-
// that body — without one nothing is reported and nothing is paid. Characters ARE detected by triggers.
|
|
524
|
-
|
|
525
|
-
// --- OneWay2D — one-way surface (node.oneWay): the semisolid ledge of a platformer ---
|
|
526
|
-
ledge.aspect(Shape2D, {}).aspect(Physics2D, { motion: 'static' }).aspect(OneWay2D)
|
|
527
|
-
// { normal?: Vec2Like (which side is solid, default [0,1]), arc?: degrees (default 90), enabled? }
|
|
528
|
-
// Jump up through it, stand on top. Works for characters AND ordinary bodies, no per-frame cost.
|
|
529
|
-
|
|
530
|
-
// --- Collision groups: SUBTRACTIVE and SYMMETRIC ---
|
|
531
|
-
const player = Physics2D.addGroup()
|
|
532
|
-
const bullets = Physics2D.addGroup().ignoreSelf().ignore(player) // 30 groups max
|
|
533
|
-
// A group collides with everything EXCEPT what it ignores, so adding a new group later can't silently
|
|
534
|
-
// stop existing pairs colliding. ignore() is symmetric (name it once) and works after bodies exist.
|
|
535
|
-
|
|
536
|
-
// --- CharacterController2D — KINEMATIC collide-and-slide mover (node.controller) ---
|
|
537
|
-
node.aspect(CharacterController2D, { gravityScale?: 1, maxSlope?: 45, group?: PhysicsGroup2D })
|
|
538
|
-
// Needs a CAPSULE Shape2D (derived from the sprite if absent). Does NOT use Physics2D. Zero JS per frame.
|
|
539
|
-
c.move(x) // HORIZONTAL command, world units/s — a VELOCITY, not a displacement (never * dt).
|
|
540
|
-
// It EXPIRES each frame: no call = standing still, releasing keys needs no zero.
|
|
541
|
-
c.move(x, y) // FREE MODE (gravityScale 0): both axes. Top-down / swimming / a ladder.
|
|
542
|
-
c.velocityY = 700 // LATCHED ballistic vertical. No ground check — do coyote time yourself.
|
|
543
|
-
c.velocity // read = what the SOLVER did (into a wall reads ~0); write = latch the whole vector
|
|
544
|
-
c.grounded / c.groundState ('ground'|'slope'|'air') / c.groundNormal / c.groundNode
|
|
545
|
-
c.collisions // [{ node, normal }] from the last step — wall jumps, pushing crates
|
|
546
|
-
c.dropThrough = platform // ignore that node's OneWay2D surfaces; a plain latch, you clear it
|
|
547
|
-
c.resizing // a requested Shape2D resize didn't fit (standing up under a beam)
|
|
548
|
-
// PLATFORMER vs TOP-DOWN is gravityScale, not two controllers. A kinematic platform carries the
|
|
549
|
-
// character automatically. Crouch by re-configuring Shape2D with a shorter capsule; a refusal changes
|
|
550
|
-
// nothing, so just call it again next frame. Solid contacts arrive in c.collisions, not as 'enter'.
|
|
551
|
-
|
|
552
|
-
// Queries (static). q = { groups?: PhysicsGroup2D[], ignore?: Node2D | Node2D[] }
|
|
553
|
-
Physics2D.raycast(from, to, q?) // closest → { node, point, normal, fraction } | null
|
|
554
|
-
Physics2D.raycastAll(from, to, q?) // every hit, NEAREST FIRST (max 32)
|
|
555
|
-
Physics2D.overlapPoint(p, q?) // topmost by DRAW order (layer, then z)
|
|
556
|
-
Physics2D.overlapCircle(centre, r, q?) / overlapBox(centre, half, q?) / overlapCapsule(from, to, r, q?)
|
|
557
|
-
// `ignore` is the don't-hit-myself case. overlapCapsule is the DIRECTED shape (a beam with width, a sword
|
|
558
|
-
// arc) and doubles as the SWEPT test for a moving circle — it can't tunnel through a thin wall.
|
|
559
|
-
|
|
560
|
-
// Pointer picking: a node with Shape2D + (Physics2D | Trigger2D | CharacterController2D) is tappable —
|
|
561
|
-
// it receives 'click'/'touchstart'. The scene ALSO gets every event (ev.target, ev.worldX/worldY).
|
|
562
|
-
// Without physics: scene.pick(camera.screenToWorld(ev.clientX, ev.clientY)).
|
|
505
|
+
node.controller.move(dir: number) // horizontal intent in [-1, 1]; STICKY — call move(0) to stop
|
|
506
|
+
node.controller.jump() // queued; consumed next frame if grounded
|
|
507
|
+
node.controller.grounded // true while standing on something
|
|
508
|
+
// Sets horizontal velocity to dir*speed each frame; Box2D owns the vertical (gravity/jump/landing).
|
|
509
|
+
// Auto-adds Shape2D (box from the sprite) + Physics2D { dynamic, fixedRotation } if missing.
|
|
510
|
+
// Drive it from setLoop: hero.controller.move(dx); if (Input.key('Space')) hero.controller.jump()
|
|
511
|
+
|
|
512
|
+
// Queries (static):
|
|
513
|
+
Physics2D.raycast(from, to): { node: Node2D | null, point: Vec2, normal: Vec2, fraction: number } | null // closest hit
|
|
514
|
+
Physics2D.overlapPoint(p: Vec2Like): Node2D | null // topmost body (solid or sensor) containing the point
|
|
515
|
+
|
|
516
|
+
// Pointer picking: a node with Shape2D + (Physics2D or Trigger2D) is tappable — it receives 'click'/
|
|
517
|
+
// 'touchstart'. The scene ALSO gets every event (ev.target, ev.worldX/worldY). Without physics:
|
|
518
|
+
// scene.pick(camera.screenToWorld(ev.clientX, ev.clientY)).
|
|
563
519
|
|
|
564
520
|
// ===== CANVAS IN 2D =====
|
|
565
521
|
// The Canvas drawing surface (see the CANVAS section above) is how TEXT and vector graphics get
|
|
@@ -647,9 +603,9 @@ async function main() {
|
|
|
647
603
|
|
|
648
604
|
// HUD on top of the 2D canvas (UI kit)
|
|
649
605
|
let scoreText: UIText
|
|
650
|
-
const hud = UIScreen(
|
|
606
|
+
const hud = UIScreen([
|
|
651
607
|
scoreText = UIText("Coins: 0").style({ color: "white", fontSize: 18, fontWeight: 700 }),
|
|
652
|
-
).style({ p: 16, pt: "max(safe-top, 16px)" })
|
|
608
|
+
]).style({ p: 16, pt: "max(safe-top, 16px)" })
|
|
653
609
|
hud.open()
|
|
654
610
|
|
|
655
611
|
// Coin pickups: spinning sprite + a Trigger2D sensor — 'enter' fires on overlap, blocks nothing
|
|
@@ -733,22 +689,18 @@ scene.addEventListener('touchstart', ev => {
|
|
|
733
689
|
|
|
734
690
|
// ===== UI COMPONENTS =====
|
|
735
691
|
// Every element is created by a global factory function (never `new`) that takes only the element's
|
|
736
|
-
// CONTENT
|
|
737
|
-
//
|
|
738
|
-
// as one chain.
|
|
739
|
-
UIColumn(UIText("Title"), UIButton(UIText("Go")))
|
|
740
|
-
// An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
|
|
741
|
-
UIColumn(header, items.map(Row), footer)
|
|
692
|
+
// CONTENT (children array, text, src, ...). Everything else — styles, handlers — is configured by chaining:
|
|
693
|
+
// every configuring method returns the element itself, so construction reads as one chain.
|
|
742
694
|
|
|
743
695
|
// UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
|
|
744
|
-
UIRow(
|
|
696
|
+
UIRow(children) / UIColumn(children)
|
|
745
697
|
// Child management — imperative, no diffing; works before and after the element is on screen:
|
|
746
698
|
// .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
|
|
747
699
|
// .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
|
|
748
700
|
// For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
|
|
749
701
|
|
|
750
702
|
// UIScreen — root screen, always fills the device. Behaves as a UIColumn.
|
|
751
|
-
UIScreen(
|
|
703
|
+
UIScreen(children)
|
|
752
704
|
// .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
|
|
753
705
|
// .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
|
|
754
706
|
// popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
|
|
@@ -756,40 +708,12 @@ UIScreen(...children)
|
|
|
756
708
|
// .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
|
|
757
709
|
// .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
|
|
758
710
|
// Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
|
|
759
|
-
// with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
|
|
711
|
+
// with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
|
|
760
712
|
// Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
|
|
761
713
|
|
|
762
|
-
// UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
|
|
763
|
-
// USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
|
|
764
|
-
const tabs = UITabs({
|
|
765
|
-
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
766
|
-
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
767
|
-
})
|
|
768
|
-
Router.init(tabs) // UITabs IS a UIScreen — present it directly
|
|
769
|
-
// .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
|
|
770
|
-
// .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
|
|
771
|
-
// .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
|
|
772
|
-
// Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
|
|
773
|
-
// restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
|
|
774
|
-
|
|
775
|
-
// UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
|
|
776
|
-
// OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
|
|
777
|
-
// tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
|
|
778
|
-
UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
|
|
779
|
-
// .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
|
|
780
|
-
// bar highlight here. Tabs keep their stack/scroll state when switched away and back.
|
|
781
|
-
// .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
|
|
782
|
-
// .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
|
|
783
|
-
// Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
|
|
784
|
-
// pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
|
|
785
|
-
// Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
|
|
786
|
-
// load initial data at build time, keep onOpen for re-entry.
|
|
787
|
-
const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
|
|
788
|
-
Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
|
|
789
|
-
|
|
790
714
|
// UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
|
|
791
715
|
// Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
|
|
792
|
-
UIWidget(
|
|
716
|
+
UIWidget(children)
|
|
793
717
|
// .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
|
|
794
718
|
// extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
|
|
795
719
|
// a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
|
|
@@ -801,7 +725,7 @@ UIWidget(...children)
|
|
|
801
725
|
// UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
|
|
802
726
|
// Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
|
|
803
727
|
// back button close it automatically. Create ONCE at module scope, like any widget.
|
|
804
|
-
UIModal(
|
|
728
|
+
UIModal(children)
|
|
805
729
|
// .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
|
|
806
730
|
// — plus the full UIWidget surface
|
|
807
731
|
// .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
|
|
@@ -812,7 +736,7 @@ UIModal(...children)
|
|
|
812
736
|
// UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
|
|
813
737
|
// Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
|
|
814
738
|
// dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
|
|
815
|
-
UIBottomSheet(
|
|
739
|
+
UIBottomSheet(children)
|
|
816
740
|
// .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
|
|
817
741
|
// BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
|
|
818
742
|
// .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
|
|
@@ -825,33 +749,30 @@ UIBottomSheet(...children)
|
|
|
825
749
|
// Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
|
|
826
750
|
// automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
|
|
827
751
|
// Attaches itself to Presentable.current (hides with the page it opened on).
|
|
828
|
-
UIPopover(
|
|
752
|
+
UIPopover(children)
|
|
829
753
|
// .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
|
|
830
754
|
// .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
|
|
831
755
|
// Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
|
|
832
756
|
|
|
833
757
|
// UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
|
|
834
|
-
UIScrollable(
|
|
758
|
+
UIScrollable(children)
|
|
835
759
|
// extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
|
|
836
760
|
// overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
|
|
837
761
|
// keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
|
|
838
|
-
// snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
|
|
839
|
-
// direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
|
|
840
|
-
// the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
|
|
841
762
|
// .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
|
|
842
763
|
// .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
|
|
843
764
|
// element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
|
|
844
765
|
// Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
|
|
845
766
|
// Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
|
|
846
767
|
// themselves. flexGrow: 1 to fill the remaining space is still yours to set.
|
|
847
|
-
// Carousel = scrollDirection: "horizontal" + FIXED-width cards
|
|
768
|
+
// Carousel = scrollDirection: "horizontal" + FIXED-width cards (explicit width on each card, gap/px on the scrollable)
|
|
848
769
|
|
|
849
770
|
// UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
|
|
850
771
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
851
772
|
// is large, grows over time, or is unknown.
|
|
852
773
|
UIVirtualizedList<T>({
|
|
853
774
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
854
|
-
render: (item: T) =>
|
|
775
|
+
render: (item: T) => UIElement, // builds one row; called lazily as rows enter the window
|
|
855
776
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
856
777
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
857
778
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
@@ -900,7 +821,7 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
|
|
|
900
821
|
|
|
901
822
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
902
823
|
// To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
|
|
903
|
-
UIButton(
|
|
824
|
+
UIButton(children?)
|
|
904
825
|
// .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
|
|
905
826
|
// extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
|
|
906
827
|
// rippleColor ("default" or a Color, Android only)
|
|
@@ -939,7 +860,7 @@ UIInput() / UITextArea()
|
|
|
939
860
|
|
|
940
861
|
// UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
|
|
941
862
|
// Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
|
|
942
|
-
UIRow(title, UISpacer(), closeButton)
|
|
863
|
+
UIRow([ title, UISpacer(), closeButton ])
|
|
943
864
|
// If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
|
|
944
865
|
|
|
945
866
|
// @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
|
|
@@ -954,13 +875,9 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
|
|
|
954
875
|
// 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
|
|
955
876
|
// 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
|
|
956
877
|
// e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
|
|
957
|
-
// 3. .animateTo({..., duration, delay?, commit
|
|
878
|
+
// 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
|
|
958
879
|
// Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
|
|
959
880
|
// the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
|
|
960
|
-
// loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
|
|
961
|
-
// (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
|
|
962
|
-
// animation is an effect, not a state change: it never commits, delay applies once, and it stops on
|
|
963
|
-
// the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
|
|
964
881
|
// 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
|
|
965
882
|
// Never modifies the stored style.
|
|
966
883
|
// For free-value tweens use animate() — see ANIMATION above.
|
|
@@ -1038,28 +955,9 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
1038
955
|
.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
|
|
1039
956
|
// Keys: onLandscape, onPortrait.
|
|
1040
957
|
|
|
1041
|
-
// --- Style classes ($name) — named states that CASCADE ---
|
|
1042
|
-
// A $-prefixed key in .style() declares a named style state (like onPressed, but yours: selected,
|
|
1043
|
-
// checked, expanded); duration/delay inside the block animate the swap. Drive it via el.class:
|
|
1044
|
-
const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
|
|
1045
|
-
item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
|
|
1046
|
-
// el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
|
|
1047
|
-
// form; names work with or without the $. Class state persists across screen close/reopen.
|
|
1048
|
-
// CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
|
|
1049
|
-
// one toggle restyles a whole composite control, each part declaring its own reaction:
|
|
1050
|
-
UIButton(
|
|
1051
|
-
UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
|
|
1052
|
-
UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
|
|
1053
|
-
) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
|
|
1054
|
-
// $pressed / $focused are RESERVED — the system toggles them (finger down / input focused) and they
|
|
1055
|
-
// CASCADE, so a button's children can restyle during its press. onPressed/onFocused stay
|
|
1056
|
-
// own-element-only — use those on nested interactives (a button inside a clickable card):
|
|
1057
|
-
UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed: { transform: "scale(0.97)" } })
|
|
1058
|
-
// Precedence on the same prop: base < $classes < $pressed/$focused < onPressed/onFocused.
|
|
1059
|
-
|
|
1060
958
|
// --- Defaults that surprise ---
|
|
1061
|
-
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable
|
|
1062
|
-
//
|
|
959
|
+
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and UIVirtualizedList default
|
|
960
|
+
// flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
1063
961
|
// flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
|
|
1064
962
|
// alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
|
|
1065
963
|
// overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
|
|
@@ -1076,10 +974,10 @@ UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed:
|
|
|
1076
974
|
// ===== REUSABLE COMPONENTS =====
|
|
1077
975
|
// Extract repeated UI into factory functions — they return elements you can chain methods on:
|
|
1078
976
|
|
|
1079
|
-
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
977
|
+
const ClickableCard = (title: string, subtitle: string) => UIButton([
|
|
1080
978
|
UIText(title).style({ fontWeight: 700, color: "white" }),
|
|
1081
979
|
UIText(subtitle).style({ color: "#888", fontSize: 13 })
|
|
1082
|
-
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
980
|
+
]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
1083
981
|
|
|
1084
982
|
// Use like any other element — chain after the call:
|
|
1085
983
|
ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
@@ -1088,15 +986,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
|
1088
986
|
const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
|
|
1089
987
|
|
|
1090
988
|
// ===== CAPTURING ELEMENT REFERENCES =====
|
|
1091
|
-
// Use an assignment expression
|
|
989
|
+
// Use an assignment expression inside the children array — standard TypeScript:
|
|
1092
990
|
|
|
1093
991
|
let label: UIText
|
|
1094
992
|
let input: UIInput
|
|
1095
993
|
|
|
1096
|
-
UIColumn(
|
|
994
|
+
UIColumn([
|
|
1097
995
|
label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
|
|
1098
996
|
input = UIInput(),
|
|
1099
|
-
)
|
|
997
|
+
])
|
|
1100
998
|
|
|
1101
999
|
// Later:
|
|
1102
1000
|
label.text = "Updated"
|
|
@@ -1106,17 +1004,15 @@ input.value // read current value
|
|
|
1106
1004
|
// ((button.children[0] as UIText).text vs buttonText.text).
|
|
1107
1005
|
|
|
1108
1006
|
// ===== CONDITIONAL CHILDREN =====
|
|
1109
|
-
//
|
|
1110
|
-
UIColumn(header, isLoading ? spinner : null, showFooter && footer)
|
|
1111
|
-
// Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
|
|
1112
|
-
UIColumn(header, showList && items.map(Row))
|
|
1007
|
+
// null / undefined / false in a children array is skipped — no element, no layout slot.
|
|
1008
|
+
UIColumn([ header, isLoading ? spinner : null, showFooter && footer ])
|
|
1113
1009
|
|
|
1114
1010
|
// ===== SIZING: the two axes behave differently =====
|
|
1115
1011
|
// MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
|
|
1116
1012
|
// without flexGrow: 1 (no implicit min-sizes).
|
|
1117
1013
|
// CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
|
|
1118
1014
|
// or alignSelf to opt out.
|
|
1119
|
-
// So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
1015
|
+
// So UIRow([ UIInput() ]) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
1120
1016
|
// EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
|
|
1121
1017
|
// equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
|
|
1122
1018
|
// longer label stays wider.
|
|
@@ -1136,7 +1032,6 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
|
|
|
1136
1032
|
// Position once at an interaction — never poll per frame.
|
|
1137
1033
|
|
|
1138
1034
|
// ===== ROUTER (multi-page apps) =====
|
|
1139
|
-
// Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
|
|
1140
1035
|
Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
|
|
1141
1036
|
Router.push(screen) // push onto the stack, screen becomes active
|
|
1142
1037
|
Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
|
|
@@ -1178,14 +1073,6 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
|
|
|
1178
1073
|
UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
|
|
1179
1074
|
// Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
|
|
1180
1075
|
|
|
1181
|
-
// ===== ICONS (assetIcon) =====
|
|
1182
|
-
// Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
|
|
1183
|
-
// inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
|
|
1184
|
-
UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
|
|
1185
|
-
// The id must be a string literal; an unknown id is a compile error (with name suggestions).
|
|
1186
|
-
// Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
|
|
1187
|
-
// Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
|
|
1188
|
-
|
|
1189
1076
|
// ===== COMMON MISTAKES — DO NOT DO THESE =====
|
|
1190
1077
|
|
|
1191
1078
|
// ❌ CSS that doesn't exist here
|
|
@@ -1194,13 +1081,16 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
|
|
|
1194
1081
|
lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
|
|
1195
1082
|
|
|
1196
1083
|
// ❌ touch handler on a non-touch element
|
|
1197
|
-
UIColumn(...).onTouchStart(
|
|
1084
|
+
UIColumn([...]).onTouchStart(...) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
|
|
1085
|
+
|
|
1086
|
+
// ❌ scrollable that overflows the screen
|
|
1087
|
+
// A wrapping container between the scrollable and the screen is missing flexShrink: 1
|
|
1198
1088
|
|
|
1199
|
-
// ❌ let/const
|
|
1200
|
-
UIRow(let input = UIInput()) // WRONG
|
|
1089
|
+
// ❌ let/const inside children array — `let` is a statement, not an expression
|
|
1090
|
+
UIRow([ let input = UIInput() ]) // WRONG
|
|
1201
1091
|
// ✅ declare outside, assign inside (assignment both sets the var AND appends)
|
|
1202
1092
|
let input: UIInput
|
|
1203
|
-
UIRow(input = UIInput())
|
|
1093
|
+
UIRow([ input = UIInput() ])
|
|
1204
1094
|
|
|
1205
1095
|
// ❌ setting a UIImage's content through bgImage
|
|
1206
1096
|
const img = UIImage("") // WRONG — empty source as a placeholder
|
|
@@ -1210,30 +1100,30 @@ const img = UIImage(url)
|
|
|
1210
1100
|
img.src = newUrl // updates the displayed image
|
|
1211
1101
|
|
|
1212
1102
|
// ❌ expecting an input to fill width like in CSS
|
|
1213
|
-
UIRow(UIInput()) // WRONG — collapses to placeholder width
|
|
1103
|
+
UIRow([ UIInput() ]) // WRONG — collapses to placeholder width
|
|
1214
1104
|
// ✅ stretch it explicitly
|
|
1215
|
-
UIColumn(UIInput().style({ width: "100%" })) // cross axis
|
|
1216
|
-
UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
|
|
1105
|
+
UIColumn([ UIInput().style({ width: "100%" }) ]) // cross axis
|
|
1106
|
+
UIRow([ input = UIInput().style({ flexGrow: 1 }), sendBtn ]) // main axis
|
|
1217
1107
|
|
|
1218
1108
|
// ❌ a button that should match the input's height but shrinks to its text
|
|
1219
|
-
UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
|
|
1109
|
+
UIRow([ input.style({ height: 40 }), UIButton([...]) ]) // button ends up shorter
|
|
1220
1110
|
// ✅ give controls the same explicit height
|
|
1221
|
-
UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
|
|
1111
|
+
UIRow([ input.style({ height: 40 }), UIButton([...]).style({ height: 40 }) ])
|
|
1222
1112
|
|
|
1223
1113
|
// ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
|
|
1224
|
-
UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
|
|
1114
|
+
UIRow([ yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 }) ]) // longer label = wider button
|
|
1225
1115
|
// ✅ flex: 1 — grows from a zero basis, children end up equal
|
|
1226
|
-
UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
|
|
1116
|
+
UIRow([ yes.style({ flex: 1 }), no.style({ flex: 1 }) ])
|
|
1227
1117
|
|
|
1228
1118
|
// ❌ empty containers as spacers to align children (web habit)
|
|
1229
|
-
UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
|
|
1119
|
+
UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
|
|
1230
1120
|
// ✅ alignment is a CONTAINER property, not an extra element
|
|
1231
|
-
UIRow(label).style({ justifyContent: "flex-end" })
|
|
1121
|
+
UIRow([ label ]).style({ justifyContent: "flex-end" })
|
|
1232
1122
|
|
|
1233
1123
|
// ❌ empty element as a placeholder for a conditional child
|
|
1234
|
-
UIRow(isGroup ? button : UIColumn()) // WRONG
|
|
1124
|
+
UIRow([ isGroup ? button : UIColumn([]) ]) // WRONG
|
|
1235
1125
|
// ✅ null is skipped in children — no phantom element
|
|
1236
|
-
UIRow(isGroup ? button : null)
|
|
1126
|
+
UIRow([ isGroup ? button : null ])
|
|
1237
1127
|
|
|
1238
1128
|
// ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
|
|
1239
1129
|
UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
|
|
@@ -1247,7 +1137,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
|
|
|
1247
1137
|
list.onEndReached(600, loadNextPage)
|
|
1248
1138
|
|
|
1249
1139
|
// ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
|
|
1250
|
-
const openSheet = () => UIWidget(...).show()
|
|
1140
|
+
const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new widget every call
|
|
1251
1141
|
// ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
|
|
1252
1142
|
|
|
1253
1143
|
// ===== EXAMPLES =====
|
|
@@ -1255,7 +1145,7 @@ const openSheet = () => UIWidget(...).show() // WRONG — leaks a new wid
|
|
|
1255
1145
|
// === EXAMPLE 1: Single-file app ===
|
|
1256
1146
|
<file name="main.ts">
|
|
1257
1147
|
let text: UIText
|
|
1258
|
-
const screen = UIScreen(
|
|
1148
|
+
const screen = UIScreen([
|
|
1259
1149
|
text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
|
|
1260
1150
|
UIText("Your name:").style({ textAlign: "center" }),
|
|
1261
1151
|
UIInput()
|
|
@@ -1263,188 +1153,91 @@ const screen = UIScreen(
|
|
|
1263
1153
|
.onChange(str => {
|
|
1264
1154
|
text.text = `Hello, ${str}!`
|
|
1265
1155
|
})
|
|
1266
|
-
).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
1156
|
+
]).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
1267
1157
|
|
|
1268
1158
|
screen.open()
|
|
1269
1159
|
</file>
|
|
1270
1160
|
|
|
1271
|
-
// === EXAMPLE 2:
|
|
1272
|
-
// A tokens module every screen imports, light-themed: theme({ color }) sets the default text
|
|
1273
|
-
// color ONCE — no color: "#111" on every label.
|
|
1274
|
-
<file name="tokens.ts">
|
|
1275
|
-
const palette = {
|
|
1276
|
-
bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
|
|
1277
|
-
text: "#131A17", muted: "#606B65",
|
|
1278
|
-
accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
|
|
1279
|
-
}
|
|
1280
|
-
theme({ color: palette.text, primaryColor: palette.accent })
|
|
1281
|
-
// Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
|
|
1282
|
-
export const colors: { [K in keyof typeof palette]: string } = theme(palette)
|
|
1283
|
-
export const font = { // type scale — spread into styles: .style({ ...font.h2 })
|
|
1284
|
-
h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
|
|
1285
|
-
small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
|
|
1286
|
-
}
|
|
1287
|
-
</file>
|
|
1161
|
+
// === EXAMPLE 2: Fetching data with loading state ===
|
|
1288
1162
|
<file name="main.ts">
|
|
1289
|
-
import { colors, font } from './tokens'
|
|
1290
|
-
|
|
1291
1163
|
type User = { id: number; name: string; email: string }
|
|
1292
1164
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
.style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
|
|
1296
|
-
justifyContent: "center", alignItems: "center" }),
|
|
1297
|
-
UIColumn(
|
|
1298
|
-
UIText(u.name).style({ ...font.bodyStrong }),
|
|
1299
|
-
UIText(u.email).style({ ...font.small, color: colors.muted }),
|
|
1300
|
-
).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
|
|
1301
|
-
).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
|
|
1302
|
-
border: `1px solid ${colors.border}`, p: 14 })
|
|
1303
|
-
|
|
1304
|
-
const Centered = (...children: UINodeChild[]) =>
|
|
1305
|
-
UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
|
|
1306
|
-
|
|
1307
|
-
let body: UIColumn
|
|
1165
|
+
let statusText: UIText
|
|
1166
|
+
let list: UIColumn
|
|
1308
1167
|
|
|
1309
1168
|
const loadData = async () => {
|
|
1310
|
-
|
|
1169
|
+
statusText.text = "Loading..."
|
|
1170
|
+
|
|
1311
1171
|
const res = await fetch("https://jsonplaceholder.typicode.com/users")
|
|
1312
1172
|
if (res.status !== 200) {
|
|
1313
|
-
|
|
1314
|
-
UIText("Couldn't load users"),
|
|
1315
|
-
UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
|
|
1316
|
-
.style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
|
|
1317
|
-
.onClick(() => loadData()),
|
|
1318
|
-
)])
|
|
1173
|
+
statusText.text = "Error loading data"
|
|
1319
1174
|
return
|
|
1320
1175
|
}
|
|
1321
|
-
|
|
1176
|
+
|
|
1177
|
+
const users = res.json<User[]>() // sync — no await
|
|
1178
|
+
|
|
1179
|
+
statusText.text = ""
|
|
1180
|
+
list.setContent(
|
|
1181
|
+
users.map(user =>
|
|
1182
|
+
UIColumn([
|
|
1183
|
+
UIText(user.name).style({ fontWeight: 700, color: "white" }),
|
|
1184
|
+
UIText(user.email).style({ color: "#888", fontSize: 13 })
|
|
1185
|
+
]).style({ px: 16, py: 12, gap: 4 })
|
|
1186
|
+
)
|
|
1187
|
+
)
|
|
1322
1188
|
}
|
|
1323
1189
|
|
|
1324
|
-
const screen = UIScreen(
|
|
1325
|
-
UIText("Users").style({
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
.
|
|
1330
|
-
|
|
1331
|
-
|
|
1190
|
+
const screen = UIScreen([
|
|
1191
|
+
UIText("Users").style({ fontSize: 24, fontWeight: 700, color: "white", mb: 8 }),
|
|
1192
|
+
statusText = UIText("").style({ color: "#888" }),
|
|
1193
|
+
UIScrollable([
|
|
1194
|
+
list = UIColumn([])
|
|
1195
|
+
]).style({ flexGrow: 1 }) // the ONE scrolling body — the screen itself never scrolls
|
|
1196
|
+
.onRefresh(() => loadData()) // pull-to-refresh; attached before screen.open()
|
|
1197
|
+
])
|
|
1198
|
+
.style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
|
|
1199
|
+
.onOpen(() => loadData())
|
|
1332
1200
|
|
|
1333
1201
|
screen.open()
|
|
1334
1202
|
</file>
|
|
1335
1203
|
|
|
1336
|
-
// === EXAMPLE 3:
|
|
1204
|
+
// === EXAMPLE 3: Simple app with navigation ===
|
|
1337
1205
|
<file name="home.ts">
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
|
|
1348
|
-
.onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
|
|
1349
|
-
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
1350
|
-
|
|
1351
|
-
const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
|
|
1352
|
-
.style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", onPressed: { opacity: 0.7 } })
|
|
1353
|
-
.onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
|
|
1354
|
-
|
|
1355
|
-
export const homeScreen = UIScreen(
|
|
1356
|
-
UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
|
|
1357
|
-
UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
|
|
1358
|
-
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
1206
|
+
import { detailScreen } from './detail'
|
|
1207
|
+
|
|
1208
|
+
export const homeScreen = UIScreen([
|
|
1209
|
+
UIText("Home").style({ fontSize: 24, fontWeight: 700 }),
|
|
1210
|
+
UIButton([
|
|
1211
|
+
UIText("Go to detail").style({ color: "white" })
|
|
1212
|
+
]).style({ bgColor: "#FF4032", borderRadius: 12, p: 16, rippleColor: "default", onPressed: { opacity: 0.7 } })
|
|
1213
|
+
.onClick(() => Router.push(detailScreen))
|
|
1214
|
+
]).style({ p: 20, pt: "max(safe-top, 24px)", gap: 16 })
|
|
1359
1215
|
</file>
|
|
1360
|
-
<file name="
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1216
|
+
<file name="detail.ts">
|
|
1217
|
+
import chevronLeft from './assets/chevron-left.svg'
|
|
1218
|
+
|
|
1219
|
+
export const detailScreen = UIScreen([
|
|
1220
|
+
UIButton([
|
|
1221
|
+
UIImage(chevronLeft).style({ width: 18, height: 18, tintColor: "#FF4032" }),
|
|
1222
|
+
UIText("Back").style({ color: "#FF4032" })
|
|
1223
|
+
]).style({ alignSelf: "flex-start", flexDirection: "row", height: 32, gap: 4 }).onClick(() => Router.pop()),
|
|
1224
|
+
UIText("Detail Screen").style({ fontSize: 24, fontWeight: 700 })
|
|
1225
|
+
])
|
|
1226
|
+
.style({ p: 20, pt: "max(safe-top, 24px)", gap: 20 })
|
|
1227
|
+
.onBackPressed(() => Router.pop())
|
|
1364
1228
|
</file>
|
|
1365
|
-
<file name="
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
|
|
1370
|
-
|
|
1371
|
-
const tabs = UITabs({
|
|
1372
|
-
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
1373
|
-
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
1374
|
-
})
|
|
1375
|
-
tabs.badge("profile", true) // notification dot on the tab
|
|
1376
|
-
Router.init(tabs)
|
|
1229
|
+
<file name="assets/chevron-left.svg">
|
|
1230
|
+
<svg viewBox="0 0 24 24">
|
|
1231
|
+
<path d="M15 18l-6-6 6-6" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1232
|
+
</svg>
|
|
1377
1233
|
</file>
|
|
1378
|
-
|
|
1379
|
-
// === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
|
|
1380
|
-
// The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
|
|
1381
|
-
// view and handles dismissal itself.
|
|
1382
1234
|
<file name="main.ts">
|
|
1383
|
-
|
|
1384
|
-
// appearing error never jumps the form). Typing in an errored field clears it.
|
|
1385
|
-
const Field = (label: string, style: Style<UIInput> = {}) => {
|
|
1386
|
-
const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
|
|
1387
|
-
border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
|
|
1388
|
-
onFocused: { borderColor: "#4C8DFF" }, ...style })
|
|
1389
|
-
const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
|
|
1390
|
-
input.onChange(() => message.text = "")
|
|
1391
|
-
return {
|
|
1392
|
-
node: UIColumn(
|
|
1393
|
-
UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
|
|
1394
|
-
input, message,
|
|
1395
|
-
).style({ gap: 6 }),
|
|
1396
|
-
input,
|
|
1397
|
-
error: (text: string) => { message.text = text },
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
|
|
1402
|
-
const email = Field("Email", { type: "email", placeholder: "you@example.com" })
|
|
1403
|
-
const password = Field("Password", { type: "password" })
|
|
1404
|
-
|
|
1405
|
-
// The return key walks the form; the last field submits. This is ALL the keyboard code.
|
|
1406
|
-
name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
|
|
1407
|
-
email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
|
|
1408
|
-
password.input.style({ enterKey: "go" }).onSubmit(() => submit())
|
|
1409
|
-
|
|
1410
|
-
let btnLabel: UIText
|
|
1411
|
-
let busy = false
|
|
1412
|
-
|
|
1413
|
-
// The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
|
|
1414
|
-
// first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
|
|
1415
|
-
const submit = async () => {
|
|
1416
|
-
if (busy) return
|
|
1417
|
-
let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
|
|
1418
|
-
if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
|
|
1419
|
-
if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
|
|
1420
|
-
if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
|
|
1421
|
-
if (bad) { bad.input.focus(); return }
|
|
1422
|
-
busy = true
|
|
1423
|
-
btnLabel.text = "Creating…"
|
|
1424
|
-
const res = await fetch("https://api.example.com/register", {
|
|
1425
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1426
|
-
body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
|
|
1427
|
-
})
|
|
1428
|
-
busy = false
|
|
1429
|
-
btnLabel.text = "Create account"
|
|
1430
|
-
if (res.status !== 200) { email.error("Registration failed — try again"); return }
|
|
1431
|
-
toast("Welcome!")
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
const screen = UIScreen(
|
|
1435
|
-
UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
|
|
1436
|
-
UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
|
|
1437
|
-
name.node, email.node, password.node,
|
|
1438
|
-
UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
|
|
1439
|
-
.style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, onPressed: { opacity: 0.85 } })
|
|
1440
|
-
.onClick(() => submit())
|
|
1441
|
-
).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
|
|
1442
|
-
).style({ bgColor: "#0C0F14" })
|
|
1235
|
+
import { homeScreen } from './home'
|
|
1443
1236
|
|
|
1444
|
-
|
|
1237
|
+
Router.init(homeScreen)
|
|
1445
1238
|
</file>
|
|
1446
1239
|
|
|
1447
|
-
// === EXAMPLE
|
|
1240
|
+
// === EXAMPLE 4: UIBottomSheet — persistent map-style sheet with detents ===
|
|
1448
1241
|
<file name="main.ts">
|
|
1449
1242
|
type Place = { id: number; name: string; distance: string }
|
|
1450
1243
|
const places: Place[] = [
|
|
@@ -1453,32 +1246,32 @@ const places: Place[] = [
|
|
|
1453
1246
|
{ id: 3, name: "Riverside Park", distance: "1.2 km" },
|
|
1454
1247
|
]
|
|
1455
1248
|
|
|
1456
|
-
const Row = (p: Place) => UIRow(
|
|
1249
|
+
const Row = (p: Place) => UIRow([
|
|
1457
1250
|
UIText(p.name).style({ color: "#111", fontSize: 16 }),
|
|
1458
1251
|
UIText(p.distance).style({ color: "#888", fontSize: 14 })
|
|
1459
|
-
).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1252
|
+
]).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1460
1253
|
|
|
1461
|
-
const sheet = UIBottomSheet(
|
|
1462
|
-
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1254
|
+
const sheet = UIBottomSheet([
|
|
1255
|
+
UIColumn([]).style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1463
1256
|
UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
|
|
1464
1257
|
UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
|
|
1465
|
-
)
|
|
1258
|
+
])
|
|
1466
1259
|
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
|
|
1467
1260
|
.detents([0.25, 0.6, 1]) // collapsed / half / full
|
|
1468
1261
|
.dismissible(false) // drag below the lowest detent collapses, never closes
|
|
1469
1262
|
.onDetentChange(i => console.log("detent", i))
|
|
1470
1263
|
|
|
1471
|
-
const mapScreen = UIScreen(
|
|
1264
|
+
const mapScreen = UIScreen([
|
|
1472
1265
|
// the map / page content behind the sheet
|
|
1473
|
-
).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1266
|
+
]).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1474
1267
|
|
|
1475
1268
|
mapScreen.open()
|
|
1476
1269
|
|
|
1477
|
-
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
|
|
1270
|
+
// An action sheet is even less: content-sized, no detents() — UIBottomSheet([ ...rows ]).show(),
|
|
1478
1271
|
// scrim and drag-down-to-dismiss included.
|
|
1479
1272
|
</file>
|
|
1480
1273
|
|
|
1481
|
-
// === EXAMPLE
|
|
1274
|
+
// === EXAMPLE 5: UIVirtualizedList — chat (inverted, imperative append) ===
|
|
1482
1275
|
<file name="main.ts">
|
|
1483
1276
|
type Msg = { id: string; text: string; mine: boolean }
|
|
1484
1277
|
|
|
@@ -1490,41 +1283,37 @@ const list = UIVirtualizedList<Msg>({
|
|
|
1490
1283
|
keyOf: m => m.id,
|
|
1491
1284
|
estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
|
|
1492
1285
|
inverted: true, // newest at the bottom
|
|
1493
|
-
render: m => UIRow(
|
|
1494
|
-
UIRow(
|
|
1286
|
+
render: m => UIRow([
|
|
1287
|
+
UIRow([
|
|
1495
1288
|
UIText(m.text).style({ color: m.mine ? "white" : "#111" })
|
|
1496
|
-
).style({
|
|
1289
|
+
]).style({
|
|
1497
1290
|
bgColor: m.mine ? "#FF4032" : "#EEE",
|
|
1498
1291
|
px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
|
|
1499
1292
|
})
|
|
1500
|
-
).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1293
|
+
]).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1501
1294
|
}).style({ flexGrow: 1 })
|
|
1502
1295
|
|
|
1503
|
-
let input:
|
|
1504
|
-
let sendBtn: UIButton
|
|
1296
|
+
let input: UIInput
|
|
1505
1297
|
|
|
1506
1298
|
const send = () => {
|
|
1507
1299
|
const text = input.value.trim()
|
|
1508
1300
|
if (!text) return
|
|
1509
1301
|
list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
|
|
1510
1302
|
input.value = ""
|
|
1511
|
-
sendBtn.style.opacity = 0.4
|
|
1512
1303
|
}
|
|
1513
1304
|
|
|
1514
|
-
const screen = UIScreen(
|
|
1305
|
+
const screen = UIScreen([
|
|
1515
1306
|
list,
|
|
1516
|
-
UIRow(
|
|
1517
|
-
input =
|
|
1518
|
-
|
|
1519
|
-
|
|
1307
|
+
UIRow([
|
|
1308
|
+
input = UIInput().style({
|
|
1309
|
+
flexGrow: 1, height: 40, px: 16, borderRadius: 20,
|
|
1310
|
+
bgColor: "#F0F0F0", placeholder: "Message...", placeholderColor: "#999"
|
|
1520
1311
|
}),
|
|
1521
|
-
|
|
1522
|
-
.style({
|
|
1312
|
+
UIButton([ UIText("Send").style({ color: "white" }) ])
|
|
1313
|
+
.style({ height: 40, px: 16, justifyContent: "center", bgColor: "#FF4032", borderRadius: 20 })
|
|
1523
1314
|
.onClick(send)
|
|
1524
|
-
).style({ p: 8,
|
|
1525
|
-
).style({ bgColor: "white" })
|
|
1526
|
-
|
|
1527
|
-
input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
|
|
1315
|
+
]).style({ p: 8, gap: 8, alignItems: "center" })
|
|
1316
|
+
]).style({ bgColor: "white" })
|
|
1528
1317
|
|
|
1529
1318
|
screen.open()
|
|
1530
1319
|
</file>
|