lecodes-sdk 0.20.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/global.d.ts +48 -5
- 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/canvas/Canvas.d.ts +2 -0
- package/dist/types/gl/DecalSet.d.ts +148 -0
- package/dist/types/gl/Geometry.d.ts +17 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Lightmap.d.ts +9 -0
- package/dist/types/gl/Material.d.ts +90 -2
- package/dist/types/gl/Mesh.d.ts +18 -1
- package/dist/types/gl/Model.d.ts +34 -0
- package/dist/types/gl/Particles.d.ts +13 -0
- package/dist/types/gl/Scene.d.ts +23 -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/DynamicBone.d.ts +173 -0
- package/dist/types/gl/{IK.d.ts → animation/IK.d.ts} +4 -4
- package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
- package/dist/types/gl/animation/core.d.ts +15 -10
- package/dist/types/gl/audio/AudioSource.d.ts +60 -0
- package/dist/types/gl/audio/AudioZone.d.ts +32 -0
- package/dist/types/gl/audio/SceneAudio.d.ts +11 -0
- package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
- package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
- package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
- package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
- package/dist/types/gl/{Ragdoll.d.ts → physics/Ragdoll.d.ts} +4 -4
- package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
- package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +6 -6
- package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
- package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
- package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
- package/dist/types/inject.d.ts +33 -21
- package/dist/types/runtime/files.d.ts +24 -1
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/scene/defineScene.d.ts +10 -3
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/README.md +142 -142
- 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 +279 -32
- package/src/canvas/Canvas.ts +21 -0
- package/src/compile/__tests__/compile.test.ts +11 -0
- package/src/compile/compileProject.ts +30 -15
- package/src/compile/header.ts +6 -3
- package/src/compile/index.ts +4 -0
- package/src/compile/sceneEditor.ts +42 -1
- package/src/core/Aspect.ts +33 -8
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/DecalSet.ts +360 -0
- package/src/gl/Geometry.ts +348 -279
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +35 -7
- package/src/gl/Material.ts +173 -4
- package/src/gl/Mesh.ts +120 -83
- package/src/gl/Model.ts +33 -1
- package/src/gl/Node.ts +1 -1
- package/src/gl/Particles.ts +21 -3
- package/src/gl/Scene.ts +41 -7
- package/src/gl/Texture.ts +43 -3
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +4 -3
- package/src/gl/animation/DynamicBone.ts +459 -0
- package/src/gl/{IK.ts → animation/IK.ts} +4 -4
- package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
- package/src/gl/animation/core.ts +20 -15
- package/src/gl/audio/AudioSource.ts +113 -0
- package/src/gl/audio/AudioZone.ts +75 -0
- package/src/gl/audio/SceneAudio.ts +26 -0
- package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
- package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
- package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
- package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
- package/src/gl/{Ragdoll.ts → physics/Ragdoll.ts} +272 -270
- package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
- package/src/gl/{Trigger.ts → physics/Trigger.ts} +2 -2
- package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +1 -1
- package/src/gl/{Terrain.ts → terrain/Terrain.ts} +10 -10
- package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
- package/src/gl/vehicle/Vehicle.ts +666 -0
- package/src/gl/vehicle/Wheel.ts +290 -0
- package/src/inject.ts +226 -212
- package/src/runtime/files.ts +32 -2
- package/src/runtime/input.ts +6 -1
- package/src/scene/defineScene.ts +26 -10
- package/src/scene/gizmos.ts +148 -148
- package/src/scene/level.ts +2 -2
- package/src/ui/UIImage.ts +21 -7
- package/dist/types/gl/Gearbox.d.ts +0 -86
- package/dist/types/gl/Vehicle.d.ts +0 -191
- package/dist/types/gl/Wheel.d.ts +0 -95
- package/src/gl/Gearbox.ts +0 -212
- package/src/gl/Vehicle.ts +0 -473
- package/src/gl/Wheel.ts +0 -240
- /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
package/prompts/dist/2d-game.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) =====
|
|
@@ -309,7 +331,7 @@ c.toBitmap(): Bitmap // rasterized snapshot, usable with drawImage
|
|
|
309
331
|
|
|
310
332
|
# 2D engine
|
|
311
333
|
|
|
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).
|
|
334
|
+
Scene2D + nodes (Sprite, Tilemap) + aspects (SpriteAnimation, Shape2D/Physics2D/Trigger2D/OneWay2D/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).
|
|
313
335
|
|
|
314
336
|
// ===== SCENE & NODES =====
|
|
315
337
|
|
|
@@ -438,9 +460,9 @@ new Tilemap(options: {
|
|
|
438
460
|
// data row 0 = the TOP row of the map (reads like level text), even though world Y is up. Atlas indices
|
|
439
461
|
// are row-major too: 0 = the atlas's top-left tile. node.position = the map's BOTTOM-LEFT corner.
|
|
440
462
|
map.setTile(x, y, index): this // grid coords as in data (row 0 = top); -1 clears. Cheap.
|
|
441
|
-
// LIMITS — a static uniform grid. NO animated tiles (swap cells with setTile yourself), NO per-tile
|
|
442
|
-
// (
|
|
443
|
-
//
|
|
463
|
+
// LIMITS — a static uniform grid. NO animated tiles (swap cells with setTile yourself), NO per-tile flip/
|
|
464
|
+
// rotate (bake variants into the atlas), and NO collision: physics NEVER reads a tilemap, by design. Solid
|
|
465
|
+
// geometry is scene objects — Node2Ds with Shape2D box/polygon/chain + a static Physics2D.
|
|
444
466
|
|
|
445
467
|
// ===== 2D PHYSICS (Box2D) =====
|
|
446
468
|
|
|
@@ -449,73 +471,95 @@ Physics2D.configure(config?: {
|
|
|
449
471
|
pixelsPerMeter?: number // Box2D tolerance tuning (you still author in world units); default 64
|
|
450
472
|
subSteps?: number // default 4
|
|
451
473
|
})
|
|
452
|
-
//
|
|
453
|
-
// Fixed 60 Hz step; rendered transforms
|
|
454
|
-
Physics2D.supported //
|
|
455
|
-
// events, no picking). Guard physics-critical gameplay with this.
|
|
474
|
+
// OPTIONAL: the world builds itself on the first body. Calling it later changes gravity LIVE and leaves
|
|
475
|
+
// every existing body alone. Fixed 60 Hz step (max 4 sub-steps/frame); rendered transforms interpolated.
|
|
476
|
+
Physics2D.supported // some builds ship without physics — the family then silently no-ops. Guard with it.
|
|
456
477
|
|
|
457
478
|
// --- Shape2D — pure geometry (node.shape). Does nothing alone. Attach BEFORE the body. ---
|
|
458
479
|
node.aspect(Shape2D, {
|
|
459
480
|
box?: [hw, hh] // HALF-extents: box:[12,20] = a 24×40 box
|
|
460
481
|
circle?: number // radius
|
|
461
482
|
capsule?: { from: Vec2Like, to: Vec2Like, radius: number } // between two node-local points
|
|
462
|
-
segment?: { from: Vec2Like, to: Vec2Like } // thin edge — STATIC
|
|
463
|
-
polygon?: Vec2Like[] //
|
|
464
|
-
|
|
483
|
+
segment?: { from: Vec2Like, to: Vec2Like } // one thin edge — STATIC only
|
|
484
|
+
polygon?: Vec2Like[] // CONVEX, ≤ 8 local points (a concave outline is silently replaced by its hull)
|
|
485
|
+
chain?: Vec2Like[] // polyline: long CONCAVE seam-free surfaces (terrain, cave walls). STATIC only.
|
|
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
|
|
465
488
|
})
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
489
|
+
// PREFER `{}`: it measures the rect the node actually DRAWS (sprite size × scale, positioned by anchor),
|
|
490
|
+
// so a sprite anchored at its feet gets a collider around the art with no hand-computed numbers.
|
|
491
|
+
// Re-configuring rebuilds the fixture IN PLACE (same body, same id, same velocity) — that is how a crouch
|
|
492
|
+
// works. A chain is ONE-SIDED: solid on the RIGHT of the point order, so left-to-right ground needs flip.
|
|
469
493
|
|
|
470
494
|
// --- Physics2D — rigid body (node.physics). Requires a Shape2D on the node (throws otherwise). ---
|
|
471
495
|
node.aspect(Physics2D, {
|
|
472
496
|
motion?: 'static' | 'kinematic' | 'dynamic' // default 'dynamic'
|
|
473
|
-
|
|
474
|
-
friction?: number // default 0.
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
497
|
+
mass?: number // omitted = from the collider's AREA (a big crate really is heavier)
|
|
498
|
+
friction?: number // default 0.6; two bodies combine as sqrt(a*b) — the LOWER value wins
|
|
499
|
+
bounce?: number // restitution 0..1, default 0; two bodies combine as MAX — the bouncier wins
|
|
500
|
+
fixedRotation?: boolean // lock rotation; default false. bullet?: continuous collision for fast movers
|
|
501
|
+
gravityScale?: number // per-body multiplier, default 1. linearDamping? / angularDamping?
|
|
502
|
+
group?: PhysicsGroup2D // collision filtering, see below
|
|
504
503
|
})
|
|
505
|
-
node.
|
|
506
|
-
|
|
507
|
-
node.
|
|
508
|
-
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
|
|
504
|
+
// Everything above is LIVE: node.physics.motion = 'static' freezes a crate, .friction / .bounce / .mass /
|
|
505
|
+
// .group / .enabled / .awake all apply immediately.
|
|
506
|
+
node.physics.velocity = [vx, vy] // world units/s (getter returns a fresh Vec2)
|
|
507
|
+
node.physics.angularVelocity = 90 // degrees/s, rw
|
|
508
|
+
node.physics.applyImpulse([x, y]) // at the centre of mass — NEVER spins the body
|
|
509
|
+
node.physics.applyImpulseAt(v, worldPoint) // …the lever arm becomes spin (a bullet's hit point)
|
|
510
|
+
node.physics.applyForce([x, y])
|
|
511
|
+
// PHYSICS OWNS THE TRANSFORM of dynamic/kinematic bodies. Never set node.position per frame on one —
|
|
512
|
+
// drive dynamic with velocity/applyImpulse. A position/rotation WRITE is a teleport that reaches the
|
|
513
|
+
// body (there is no moveTo any more); READING node.position is always correct. Keep bodies and sensors
|
|
514
|
+
// on ROOT nodes — one parented to a moving node does not follow it.
|
|
515
|
+
|
|
516
|
+
// --- Trigger2D — sensor zone (node.trigger). Fires enter/exit, blocks nothing. ---
|
|
517
|
+
goal.aspect(Shape2D, { circle: 48 }).aspect(Trigger2D) // { group? }
|
|
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)).
|
|
519
563
|
|
|
520
564
|
// ===== CANVAS IN 2D =====
|
|
521
565
|
// The Canvas drawing surface (see the CANVAS section above) is how TEXT and vector graphics get
|
|
@@ -603,9 +647,9 @@ async function main() {
|
|
|
603
647
|
|
|
604
648
|
// HUD on top of the 2D canvas (UI kit)
|
|
605
649
|
let scoreText: UIText
|
|
606
|
-
const hud = UIScreen(
|
|
650
|
+
const hud = UIScreen(
|
|
607
651
|
scoreText = UIText("Coins: 0").style({ color: "white", fontSize: 18, fontWeight: 700 }),
|
|
608
|
-
|
|
652
|
+
).style({ p: 16, pt: "max(safe-top, 16px)" })
|
|
609
653
|
hud.open()
|
|
610
654
|
|
|
611
655
|
// Coin pickups: spinning sprite + a Trigger2D sensor — 'enter' fires on overlap, blocks nothing
|
|
@@ -689,18 +733,22 @@ scene.addEventListener('touchstart', ev => {
|
|
|
689
733
|
|
|
690
734
|
// ===== UI COMPONENTS =====
|
|
691
735
|
// Every element is created by a global factory function (never `new`) that takes only the element's
|
|
692
|
-
// CONTENT
|
|
693
|
-
// every configuring method returns the element itself, so construction reads
|
|
736
|
+
// CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
|
|
737
|
+
// configured by chaining: every configuring method returns the element itself, so construction reads
|
|
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)
|
|
694
742
|
|
|
695
743
|
// UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
|
|
696
|
-
UIRow(children) / UIColumn(children)
|
|
744
|
+
UIRow(...children) / UIColumn(...children)
|
|
697
745
|
// Child management — imperative, no diffing; works before and after the element is on screen:
|
|
698
746
|
// .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
|
|
699
747
|
// .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
|
|
700
748
|
// For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
|
|
701
749
|
|
|
702
750
|
// UIScreen — root screen, always fills the device. Behaves as a UIColumn.
|
|
703
|
-
UIScreen(children)
|
|
751
|
+
UIScreen(...children)
|
|
704
752
|
// .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
|
|
705
753
|
// .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
|
|
706
754
|
// popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
|
|
@@ -708,12 +756,40 @@ UIScreen(children)
|
|
|
708
756
|
// .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
|
|
709
757
|
// .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
|
|
710
758
|
// Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
|
|
711
|
-
// with flexGrow: 1: UIScreen(
|
|
759
|
+
// with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
|
|
712
760
|
// Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
|
|
713
761
|
|
|
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
|
+
|
|
714
790
|
// UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
|
|
715
791
|
// Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
|
|
716
|
-
UIWidget(children)
|
|
792
|
+
UIWidget(...children)
|
|
717
793
|
// .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
|
|
718
794
|
// extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
|
|
719
795
|
// a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
|
|
@@ -725,7 +801,7 @@ UIWidget(children)
|
|
|
725
801
|
// UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
|
|
726
802
|
// Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
|
|
727
803
|
// back button close it automatically. Create ONCE at module scope, like any widget.
|
|
728
|
-
UIModal(children)
|
|
804
|
+
UIModal(...children)
|
|
729
805
|
// .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
|
|
730
806
|
// — plus the full UIWidget surface
|
|
731
807
|
// .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
|
|
@@ -736,7 +812,7 @@ UIModal(children)
|
|
|
736
812
|
// UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
|
|
737
813
|
// Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
|
|
738
814
|
// dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
|
|
739
|
-
UIBottomSheet(children)
|
|
815
|
+
UIBottomSheet(...children)
|
|
740
816
|
// .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
|
|
741
817
|
// BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
|
|
742
818
|
// .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
|
|
@@ -749,30 +825,33 @@ UIBottomSheet(children)
|
|
|
749
825
|
// Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
|
|
750
826
|
// automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
|
|
751
827
|
// Attaches itself to Presentable.current (hides with the page it opened on).
|
|
752
|
-
UIPopover(children)
|
|
828
|
+
UIPopover(...children)
|
|
753
829
|
// .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
|
|
754
830
|
// .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
|
|
755
831
|
// Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
|
|
756
832
|
|
|
757
833
|
// UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
|
|
758
|
-
UIScrollable(children)
|
|
834
|
+
UIScrollable(...children)
|
|
759
835
|
// extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
|
|
760
836
|
// overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
|
|
761
837
|
// 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.)
|
|
762
841
|
// .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
|
|
763
842
|
// .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
|
|
764
843
|
// element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
|
|
765
844
|
// Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
|
|
766
845
|
// Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
|
|
767
846
|
// themselves. flexGrow: 1 to fill the remaining space is still yours to set.
|
|
768
|
-
// Carousel = scrollDirection: "horizontal" + FIXED-width cards
|
|
847
|
+
// Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
|
|
769
848
|
|
|
770
849
|
// UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
|
|
771
850
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
772
851
|
// is large, grows over time, or is unknown.
|
|
773
852
|
UIVirtualizedList<T>({
|
|
774
853
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
775
|
-
render: (item: T) =>
|
|
854
|
+
render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
|
|
776
855
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
777
856
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
778
857
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
@@ -821,7 +900,7 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
|
|
|
821
900
|
|
|
822
901
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
823
902
|
// To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
|
|
824
|
-
UIButton(children
|
|
903
|
+
UIButton(...children)
|
|
825
904
|
// .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
|
|
826
905
|
// extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
|
|
827
906
|
// rippleColor ("default" or a Color, Android only)
|
|
@@ -860,7 +939,7 @@ UIInput() / UITextArea()
|
|
|
860
939
|
|
|
861
940
|
// UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
|
|
862
941
|
// Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
|
|
863
|
-
UIRow(
|
|
942
|
+
UIRow(title, UISpacer(), closeButton)
|
|
864
943
|
// If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
|
|
865
944
|
|
|
866
945
|
// @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
|
|
@@ -875,9 +954,13 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
|
|
|
875
954
|
// 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
|
|
876
955
|
// 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
|
|
877
956
|
// e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
|
|
878
|
-
// 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
|
|
957
|
+
// 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
|
|
879
958
|
// Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
|
|
880
959
|
// 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.)
|
|
881
964
|
// 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
|
|
882
965
|
// Never modifies the stored style.
|
|
883
966
|
// For free-value tweens use animate() — see ANIMATION above.
|
|
@@ -955,9 +1038,28 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
955
1038
|
.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
|
|
956
1039
|
// Keys: onLandscape, onPortrait.
|
|
957
1040
|
|
|
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
|
+
|
|
958
1060
|
// --- Defaults that surprise ---
|
|
959
|
-
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and
|
|
960
|
-
// flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
1061
|
+
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
|
|
1062
|
+
// default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
961
1063
|
// flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
|
|
962
1064
|
// alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
|
|
963
1065
|
// overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
|
|
@@ -974,10 +1076,10 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
974
1076
|
// ===== REUSABLE COMPONENTS =====
|
|
975
1077
|
// Extract repeated UI into factory functions — they return elements you can chain methods on:
|
|
976
1078
|
|
|
977
|
-
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
1079
|
+
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
978
1080
|
UIText(title).style({ fontWeight: 700, color: "white" }),
|
|
979
1081
|
UIText(subtitle).style({ color: "#888", fontSize: 13 })
|
|
980
|
-
|
|
1082
|
+
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
981
1083
|
|
|
982
1084
|
// Use like any other element — chain after the call:
|
|
983
1085
|
ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
@@ -986,15 +1088,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
|
986
1088
|
const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
|
|
987
1089
|
|
|
988
1090
|
// ===== CAPTURING ELEMENT REFERENCES =====
|
|
989
|
-
// Use an assignment expression
|
|
1091
|
+
// Use an assignment expression right in the children — standard TypeScript:
|
|
990
1092
|
|
|
991
1093
|
let label: UIText
|
|
992
1094
|
let input: UIInput
|
|
993
1095
|
|
|
994
|
-
UIColumn(
|
|
1096
|
+
UIColumn(
|
|
995
1097
|
label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
|
|
996
1098
|
input = UIInput(),
|
|
997
|
-
|
|
1099
|
+
)
|
|
998
1100
|
|
|
999
1101
|
// Later:
|
|
1000
1102
|
label.text = "Updated"
|
|
@@ -1004,15 +1106,17 @@ input.value // read current value
|
|
|
1004
1106
|
// ((button.children[0] as UIText).text vs buttonText.text).
|
|
1005
1107
|
|
|
1006
1108
|
// ===== CONDITIONAL CHILDREN =====
|
|
1007
|
-
// null / undefined / false
|
|
1008
|
-
UIColumn(
|
|
1109
|
+
// A null / undefined / false child is skipped — no element, no layout slot.
|
|
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))
|
|
1009
1113
|
|
|
1010
1114
|
// ===== SIZING: the two axes behave differently =====
|
|
1011
1115
|
// MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
|
|
1012
1116
|
// without flexGrow: 1 (no implicit min-sizes).
|
|
1013
1117
|
// CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
|
|
1014
1118
|
// or alignSelf to opt out.
|
|
1015
|
-
// So UIRow(
|
|
1119
|
+
// So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
1016
1120
|
// EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
|
|
1017
1121
|
// equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
|
|
1018
1122
|
// longer label stays wider.
|
|
@@ -1032,6 +1136,7 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
|
|
|
1032
1136
|
// Position once at an interaction — never poll per frame.
|
|
1033
1137
|
|
|
1034
1138
|
// ===== 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.
|
|
1035
1140
|
Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
|
|
1036
1141
|
Router.push(screen) // push onto the stack, screen becomes active
|
|
1037
1142
|
Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
|
|
@@ -1073,6 +1178,14 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
|
|
|
1073
1178
|
UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
|
|
1074
1179
|
// Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
|
|
1075
1180
|
|
|
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
|
+
|
|
1076
1189
|
// ===== COMMON MISTAKES — DO NOT DO THESE =====
|
|
1077
1190
|
|
|
1078
1191
|
// ❌ CSS that doesn't exist here
|
|
@@ -1081,16 +1194,13 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
|
|
|
1081
1194
|
lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
|
|
1082
1195
|
|
|
1083
1196
|
// ❌ touch handler on a non-touch element
|
|
1084
|
-
UIColumn(
|
|
1085
|
-
|
|
1086
|
-
// ❌ scrollable that overflows the screen
|
|
1087
|
-
// A wrapping container between the scrollable and the screen is missing flexShrink: 1
|
|
1197
|
+
UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
|
|
1088
1198
|
|
|
1089
|
-
// ❌ let/const
|
|
1090
|
-
UIRow(
|
|
1199
|
+
// ❌ let/const among the children — `let` is a statement, not an expression
|
|
1200
|
+
UIRow(let input = UIInput()) // WRONG
|
|
1091
1201
|
// ✅ declare outside, assign inside (assignment both sets the var AND appends)
|
|
1092
1202
|
let input: UIInput
|
|
1093
|
-
UIRow(
|
|
1203
|
+
UIRow(input = UIInput())
|
|
1094
1204
|
|
|
1095
1205
|
// ❌ setting a UIImage's content through bgImage
|
|
1096
1206
|
const img = UIImage("") // WRONG — empty source as a placeholder
|
|
@@ -1100,30 +1210,30 @@ const img = UIImage(url)
|
|
|
1100
1210
|
img.src = newUrl // updates the displayed image
|
|
1101
1211
|
|
|
1102
1212
|
// ❌ expecting an input to fill width like in CSS
|
|
1103
|
-
UIRow(
|
|
1213
|
+
UIRow(UIInput()) // WRONG — collapses to placeholder width
|
|
1104
1214
|
// ✅ stretch it explicitly
|
|
1105
|
-
UIColumn(
|
|
1106
|
-
UIRow(
|
|
1215
|
+
UIColumn(UIInput().style({ width: "100%" })) // cross axis
|
|
1216
|
+
UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
|
|
1107
1217
|
|
|
1108
1218
|
// ❌ a button that should match the input's height but shrinks to its text
|
|
1109
|
-
UIRow(
|
|
1219
|
+
UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
|
|
1110
1220
|
// ✅ give controls the same explicit height
|
|
1111
|
-
UIRow(
|
|
1221
|
+
UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
|
|
1112
1222
|
|
|
1113
1223
|
// ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
|
|
1114
|
-
UIRow(
|
|
1224
|
+
UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
|
|
1115
1225
|
// ✅ flex: 1 — grows from a zero basis, children end up equal
|
|
1116
|
-
UIRow(
|
|
1226
|
+
UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
|
|
1117
1227
|
|
|
1118
1228
|
// ❌ empty containers as spacers to align children (web habit)
|
|
1119
|
-
UIRow(
|
|
1229
|
+
UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
|
|
1120
1230
|
// ✅ alignment is a CONTAINER property, not an extra element
|
|
1121
|
-
UIRow(
|
|
1231
|
+
UIRow(label).style({ justifyContent: "flex-end" })
|
|
1122
1232
|
|
|
1123
1233
|
// ❌ empty element as a placeholder for a conditional child
|
|
1124
|
-
UIRow(
|
|
1234
|
+
UIRow(isGroup ? button : UIColumn()) // WRONG
|
|
1125
1235
|
// ✅ null is skipped in children — no phantom element
|
|
1126
|
-
UIRow(
|
|
1236
|
+
UIRow(isGroup ? button : null)
|
|
1127
1237
|
|
|
1128
1238
|
// ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
|
|
1129
1239
|
UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
|
|
@@ -1137,7 +1247,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
|
|
|
1137
1247
|
list.onEndReached(600, loadNextPage)
|
|
1138
1248
|
|
|
1139
1249
|
// ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
|
|
1140
|
-
const openSheet = () => UIWidget(
|
|
1250
|
+
const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
|
|
1141
1251
|
// ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
|
|
1142
1252
|
|
|
1143
1253
|
// ===== EXAMPLES =====
|
|
@@ -1145,7 +1255,7 @@ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new wid
|
|
|
1145
1255
|
// === EXAMPLE 1: Single-file app ===
|
|
1146
1256
|
<file name="main.ts">
|
|
1147
1257
|
let text: UIText
|
|
1148
|
-
const screen = UIScreen(
|
|
1258
|
+
const screen = UIScreen(
|
|
1149
1259
|
text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
|
|
1150
1260
|
UIText("Your name:").style({ textAlign: "center" }),
|
|
1151
1261
|
UIInput()
|
|
@@ -1153,91 +1263,188 @@ const screen = UIScreen([
|
|
|
1153
1263
|
.onChange(str => {
|
|
1154
1264
|
text.text = `Hello, ${str}!`
|
|
1155
1265
|
})
|
|
1156
|
-
|
|
1266
|
+
).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
1157
1267
|
|
|
1158
1268
|
screen.open()
|
|
1159
1269
|
</file>
|
|
1160
1270
|
|
|
1161
|
-
// === EXAMPLE 2:
|
|
1271
|
+
// === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
|
|
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>
|
|
1162
1288
|
<file name="main.ts">
|
|
1289
|
+
import { colors, font } from './tokens'
|
|
1290
|
+
|
|
1163
1291
|
type User = { id: number; name: string; email: string }
|
|
1164
1292
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1293
|
+
const Row = (u: User) => UIRow(
|
|
1294
|
+
UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
|
|
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 })
|
|
1167
1303
|
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1304
|
+
const Centered = (...children: UINodeChild[]) =>
|
|
1305
|
+
UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
|
|
1306
|
+
|
|
1307
|
+
let body: UIColumn
|
|
1170
1308
|
|
|
1309
|
+
const loadData = async () => {
|
|
1310
|
+
body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
|
|
1171
1311
|
const res = await fetch("https://jsonplaceholder.typicode.com/users")
|
|
1172
1312
|
if (res.status !== 200) {
|
|
1173
|
-
|
|
1313
|
+
body.setContent([Centered(
|
|
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
|
+
)])
|
|
1174
1319
|
return
|
|
1175
1320
|
}
|
|
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
|
-
)
|
|
1321
|
+
body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
|
|
1188
1322
|
}
|
|
1189
1323
|
|
|
1190
|
-
const screen = UIScreen(
|
|
1191
|
-
UIText("Users").style({
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
.style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
|
|
1199
|
-
.onOpen(() => loadData())
|
|
1324
|
+
const screen = UIScreen(
|
|
1325
|
+
UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
|
|
1326
|
+
UIScrollable(
|
|
1327
|
+
body = UIColumn().style({ flexGrow: 1, gap: 8 })
|
|
1328
|
+
).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
|
|
1329
|
+
.onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
|
|
1330
|
+
).style({ bgColor: colors.bg, pt: "comfort-top" })
|
|
1331
|
+
.onOpen(() => loadData())
|
|
1200
1332
|
|
|
1201
1333
|
screen.open()
|
|
1202
1334
|
</file>
|
|
1203
1335
|
|
|
1204
|
-
// === EXAMPLE 3:
|
|
1336
|
+
// === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
|
|
1205
1337
|
<file name="home.ts">
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
UIText("
|
|
1225
|
-
])
|
|
1226
|
-
.style({ p:
|
|
1227
|
-
.onBackPressed(() => Router.pop())
|
|
1338
|
+
const detailScreen = (name: string) => UIScreen(
|
|
1339
|
+
UIButton(
|
|
1340
|
+
UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
|
|
1341
|
+
UIText("Back")
|
|
1342
|
+
).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
|
|
1343
|
+
UIText(name).style({ fontSize: 24, fontWeight: 700 }),
|
|
1344
|
+
UIButton(
|
|
1345
|
+
UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
|
|
1346
|
+
UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
|
|
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" })
|
|
1228
1359
|
</file>
|
|
1229
|
-
<file name="
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1360
|
+
<file name="profile.ts">
|
|
1361
|
+
export const profileScreen = UIScreen(
|
|
1362
|
+
UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
|
|
1363
|
+
).style({ p: 16, pt: "comfort-top", bgColor: "black" })
|
|
1233
1364
|
</file>
|
|
1234
1365
|
<file name="main.ts">
|
|
1235
1366
|
import { homeScreen } from './home'
|
|
1367
|
+
import { profileScreen } from './profile'
|
|
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)
|
|
1377
|
+
</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
|
+
<file name="main.ts">
|
|
1383
|
+
// Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
|
|
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
|
+
}
|
|
1236
1433
|
|
|
1237
|
-
|
|
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" })
|
|
1443
|
+
|
|
1444
|
+
screen.open()
|
|
1238
1445
|
</file>
|
|
1239
1446
|
|
|
1240
|
-
// === EXAMPLE
|
|
1447
|
+
// === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
|
|
1241
1448
|
<file name="main.ts">
|
|
1242
1449
|
type Place = { id: number; name: string; distance: string }
|
|
1243
1450
|
const places: Place[] = [
|
|
@@ -1246,32 +1453,32 @@ const places: Place[] = [
|
|
|
1246
1453
|
{ id: 3, name: "Riverside Park", distance: "1.2 km" },
|
|
1247
1454
|
]
|
|
1248
1455
|
|
|
1249
|
-
const Row = (p: Place) => UIRow(
|
|
1456
|
+
const Row = (p: Place) => UIRow(
|
|
1250
1457
|
UIText(p.name).style({ color: "#111", fontSize: 16 }),
|
|
1251
1458
|
UIText(p.distance).style({ color: "#888", fontSize: 14 })
|
|
1252
|
-
|
|
1459
|
+
).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1253
1460
|
|
|
1254
|
-
const sheet = UIBottomSheet(
|
|
1255
|
-
UIColumn(
|
|
1461
|
+
const sheet = UIBottomSheet(
|
|
1462
|
+
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1256
1463
|
UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
|
|
1257
1464
|
UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
|
|
1258
|
-
|
|
1465
|
+
)
|
|
1259
1466
|
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
|
|
1260
1467
|
.detents([0.25, 0.6, 1]) // collapsed / half / full
|
|
1261
1468
|
.dismissible(false) // drag below the lowest detent collapses, never closes
|
|
1262
1469
|
.onDetentChange(i => console.log("detent", i))
|
|
1263
1470
|
|
|
1264
|
-
const mapScreen = UIScreen(
|
|
1471
|
+
const mapScreen = UIScreen(
|
|
1265
1472
|
// the map / page content behind the sheet
|
|
1266
|
-
|
|
1473
|
+
).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1267
1474
|
|
|
1268
1475
|
mapScreen.open()
|
|
1269
1476
|
|
|
1270
|
-
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(
|
|
1477
|
+
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
|
|
1271
1478
|
// scrim and drag-down-to-dismiss included.
|
|
1272
1479
|
</file>
|
|
1273
1480
|
|
|
1274
|
-
// === EXAMPLE
|
|
1481
|
+
// === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
|
|
1275
1482
|
<file name="main.ts">
|
|
1276
1483
|
type Msg = { id: string; text: string; mine: boolean }
|
|
1277
1484
|
|
|
@@ -1283,37 +1490,41 @@ const list = UIVirtualizedList<Msg>({
|
|
|
1283
1490
|
keyOf: m => m.id,
|
|
1284
1491
|
estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
|
|
1285
1492
|
inverted: true, // newest at the bottom
|
|
1286
|
-
render: m => UIRow(
|
|
1287
|
-
UIRow(
|
|
1493
|
+
render: m => UIRow(
|
|
1494
|
+
UIRow(
|
|
1288
1495
|
UIText(m.text).style({ color: m.mine ? "white" : "#111" })
|
|
1289
|
-
|
|
1496
|
+
).style({
|
|
1290
1497
|
bgColor: m.mine ? "#FF4032" : "#EEE",
|
|
1291
1498
|
px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
|
|
1292
1499
|
})
|
|
1293
|
-
|
|
1500
|
+
).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1294
1501
|
}).style({ flexGrow: 1 })
|
|
1295
1502
|
|
|
1296
|
-
let input:
|
|
1503
|
+
let input: UITextArea
|
|
1504
|
+
let sendBtn: UIButton
|
|
1297
1505
|
|
|
1298
1506
|
const send = () => {
|
|
1299
1507
|
const text = input.value.trim()
|
|
1300
1508
|
if (!text) return
|
|
1301
1509
|
list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
|
|
1302
1510
|
input.value = ""
|
|
1511
|
+
sendBtn.style.opacity = 0.4
|
|
1303
1512
|
}
|
|
1304
1513
|
|
|
1305
|
-
const screen = UIScreen(
|
|
1514
|
+
const screen = UIScreen(
|
|
1306
1515
|
list,
|
|
1307
|
-
UIRow(
|
|
1308
|
-
input =
|
|
1309
|
-
flexGrow: 1,
|
|
1310
|
-
|
|
1516
|
+
UIRow(
|
|
1517
|
+
input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
|
|
1518
|
+
color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
|
|
1519
|
+
keyboardDismiss: false
|
|
1311
1520
|
}),
|
|
1312
|
-
UIButton(
|
|
1313
|
-
.style({
|
|
1521
|
+
sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
|
|
1522
|
+
.style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
|
|
1314
1523
|
.onClick(send)
|
|
1315
|
-
|
|
1316
|
-
|
|
1524
|
+
).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
|
|
1525
|
+
).style({ bgColor: "white" })
|
|
1526
|
+
|
|
1527
|
+
input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
|
|
1317
1528
|
|
|
1318
1529
|
screen.open()
|
|
1319
1530
|
</file>
|