lecodes-sdk 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/global.d.ts +31 -0
- package/dist/inject.js +361 -260
- package/dist/types/audio/Bus.d.ts +45 -0
- package/dist/types/audio/Sound.d.ts +28 -0
- package/dist/types/audio/Voice.d.ts +27 -0
- package/dist/types/audio/audio.d.ts +83 -0
- package/dist/types/audio/support.d.ts +1 -0
- package/dist/types/gl/AudioSource.d.ts +60 -0
- package/dist/types/gl/AudioZone.d.ts +32 -0
- package/dist/types/gl/DecalSet.d.ts +103 -0
- package/dist/types/gl/Geometry.d.ts +5 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Material.d.ts +86 -2
- package/dist/types/gl/Mesh.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +23 -0
- package/dist/types/gl/SceneAudio.d.ts +11 -0
- package/dist/types/gl/Texture.d.ts +29 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
- package/dist/types/gl/animation/core.d.ts +15 -10
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/inject.d.ts +10 -0
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/dist/2d-game.md +408 -197
- package/prompts/dist/3d-app.md +491 -166
- package/prompts/dist/ar-app.md +373 -163
- package/prompts/dist/design.md +83 -87
- package/prompts/dist/ui-app.md +325 -136
- package/src/audio/Bus.ts +102 -0
- package/src/audio/Sound.ts +96 -0
- package/src/audio/Voice.ts +102 -0
- package/src/audio/audio.ts +161 -0
- package/src/audio/support.ts +6 -0
- package/src/bridges.d.ts +1481 -1352
- package/src/compile/compileProject.ts +30 -15
- package/src/compile/index.ts +3 -0
- package/src/core/Aspect.ts +33 -8
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/AudioSource.ts +113 -0
- package/src/gl/AudioZone.ts +75 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/DecalSet.ts +233 -0
- package/src/gl/Geometry.ts +5 -0
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +3 -2
- package/src/gl/Material.ts +152 -4
- package/src/gl/Mesh.ts +20 -1
- package/src/gl/Ragdoll.ts +270 -270
- package/src/gl/Scene.ts +41 -7
- package/src/gl/SceneAudio.ts +26 -0
- package/src/gl/Texture.ts +43 -3
- package/src/gl/Trigger.ts +45 -45
- package/src/gl/Vehicle.ts +5 -5
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +4 -3
- package/src/gl/animation/core.ts +20 -15
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +1 -1
- package/src/inject.ts +12 -0
- package/src/runtime/input.ts +6 -1
- package/src/scene/gizmos.ts +148 -148
- package/src/ui/UIImage.ts +21 -7
package/prompts/dist/ui-app.md
CHANGED
|
@@ -102,6 +102,16 @@ After every edit you make, the platform automatically verifies it — syntax, a
|
|
|
102
102
|
|
|
103
103
|
A `[Selected element]` section describes a UI element the user picked in the running app's preview — its type, text, current style, and "created at" (the code that creates it). Apply the request to exactly that element, starting from the created-at location.
|
|
104
104
|
|
|
105
|
+
## Building from a design
|
|
106
|
+
|
|
107
|
+
A `design/` folder is the app's finished design, made on the design board: `screens/<id>.ts` — one static mockup per screen, its states a function of a state-string union; `shared/tokens.ts` — the brand as a `theme()` table; `shared/ui.ts` — the component kit; `shared/tabs.ts` — the tab bar (`defineTabs`); `meta.json` — screen descriptions, roles, groups, and the navigation edges between screens; `spec.md` — the data model, actors, and rules. When asked to build or implement the app (or a screen) from it, the design is the authority:
|
|
108
|
+
|
|
109
|
+
- Implement every designed screen, including each state in its union. Navigation follows `meta.json` edges (an `id@state` edge switches that screen's state, not a push) and the tab bar in `shared/tabs.ts` — its `defineTabs({...})` entries carry over 1:1 into `UITabs({...})` (same keys, labels, and icons; add each tab's root `screen`), which renders the identical bar.
|
|
110
|
+
- IMPORT the kit, don't restyle: app code imports `design/shared/tokens.ts` (its `theme()` call makes design and app one live theme) and the presentational components of `design/shared/ui.ts`. Rebuild only the mock-only pieces (static field mocks, fake keyboards) as real interactive equivalents with the exact same look.
|
|
111
|
+
- The mock data at the top of each screen is the schema draft: replace it with real state, storage, and logic per `spec.md`, keeping the field shapes.
|
|
112
|
+
- App screens are your own files (`main.ts` + the usual project layout) — never import `design/screens/*` into the app, and never write into `design/**`: the design stays the reference. If the design itself needs changing, say so and suggest `<mode>design</mode>`.
|
|
113
|
+
- Match the mockups; don't re-design. Where a mockup leaves behavior undefined, `spec.md` decides; where it's silent, pick the simplest behavior consistent with the design.
|
|
114
|
+
|
|
105
115
|
## Tools
|
|
106
116
|
|
|
107
117
|
You may be given tools (they appear in the API request, each with its own description). Default to
|
|
@@ -209,16 +219,22 @@ const sfx = new AudioPlayer(src) // src: asset('./x.mp3') or URL; same API: ne
|
|
|
209
219
|
// .dispose() — frees the native player; ANY use after dispose throws
|
|
210
220
|
// No pitch control, no auto-pooling: for overlapping SFX create several players up front and rotate.
|
|
211
221
|
|
|
212
|
-
// ===== INPUT (keyboard) =====
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
// ===== INPUT (keyboard / mouse / gamepad) =====
|
|
223
|
+
// A mouse button IS a key; so is a gamepad button. One code vocabulary: KeyboardEvent.code ('KeyW',
|
|
224
|
+
// 'Space', 'ArrowLeft'), 'MouseLeft|Right|Middle', 'GamepadSouth|East|West|North|L1|R1|L2|R2|Start|…'.
|
|
225
|
+
Input.key(code): boolean // held NOW — poll in setLoop for continuous movement (multiply by dt)
|
|
226
|
+
Input.on('keydown', e => …) // one-shot actions: e.code, e.repeat (auto-repeat), e.gamepad (pad index)
|
|
227
|
+
Input.on('keyup', e => …) // release (charged shots); Input.off(name, fn) to remove
|
|
228
|
+
Input.mouse.delta // { x, y } motion during the previous frame — FPS look; works while locked
|
|
229
|
+
Input.mouse.position / .wheel // cursor in logical px / wheel notches this frame
|
|
230
|
+
Input.mouse.lock() / unlock() / locked // hide + confine the cursor (call lock() from a keydown — web needs a gesture)
|
|
231
|
+
Input.gamepad(0).axis('leftX' | 'leftY' | 'rightX' | 'rightY' | 'leftTrigger' | 'rightTrigger') // −1..1 / 0..1
|
|
218
232
|
setLoop(dt => {
|
|
219
|
-
|
|
220
|
-
|
|
233
|
+
yaw -= Input.mouse.delta.x * 0.003 + Input.gamepad(0).axis('rightX') * 2 * dt
|
|
234
|
+
const x = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) + Input.gamepad(0).axis('leftX')
|
|
221
235
|
})
|
|
236
|
+
Input.on('keydown', e => { if (e.code === 'Space' || e.code === 'GamepadSouth') jump() })
|
|
237
|
+
// No one-frame "pressed" polls (keyDown/actionDown) — discrete = event, continuous = poll.
|
|
222
238
|
|
|
223
239
|
// ===== TOUCH GESTURES =====
|
|
224
240
|
// 'click' fires on pointer-up over a target; 'touchstart' on pointer-down. ev: { clientX, clientY,
|
|
@@ -266,17 +282,23 @@ implement what you can with the loaded ones.
|
|
|
266
282
|
// Canvas: Canvas (retained 2D drawing baked to a texture — sprites/UI images), Bitmap
|
|
267
283
|
// Aspects: Aspect base — attachable node capabilities: node.aspect(Class, opts), custom aspects
|
|
268
284
|
// with per-frame update(dt)
|
|
269
|
-
// UI: UIScreen, Router,
|
|
285
|
+
// UI: UIScreen, Router, UITabs (bottom-tab shell), UIPager (tabs + per-tab stacks), UIRow,
|
|
286
|
+
// UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
|
|
270
287
|
// UIButton, UIInput, UITextArea, UIWidget/UIModal/UIPopover/UIBottomSheet (floating
|
|
271
288
|
// overlays: HUD / dialog / anchored menu / draggable sheet), UIVirtualizedList,
|
|
272
289
|
// registerFont; UIBox (deprecated)
|
|
273
290
|
// 2D engine: Scene2D, Node2D, Camera2D, Sprite, SpriteAnimation, Tilemap, Texture2D,
|
|
274
291
|
// Shape2D + Physics2D + Trigger2D + CharacterController2D (Box2D physics, sensors,
|
|
275
|
-
// raycast/overlap queries, picking)
|
|
292
|
+
// raycast/overlap queries, picking),
|
|
293
|
+
// defineScene2d (declarative *.scene2d.ts files) + cells + CameraFollow
|
|
276
294
|
// 3D engine: Scene, Node, Camera, Mesh (box/sphere/cylinder/plane), Model (.glb) + ModelAnimation,
|
|
277
295
|
// Geometry, Material (lit/unlit/custom shaders), Texture, Light.sun(),
|
|
278
296
|
// Shape + Physics + Trigger + CharacterController (Jolt physics, raycast),
|
|
279
297
|
// Particles + dynamic/dynamicColor, Ray, Plane, Noise
|
|
298
|
+
// 3D scene files: defineScene (declarative *.scene.ts read/written by the visual scene editor;
|
|
299
|
+
// default export is a SceneHandle: load()/open()) + use/ref/make in the def,
|
|
300
|
+
// scenario aspects MoveTo/FollowPath/Spin/LookAt/PlayAnimation;
|
|
301
|
+
// *.editor.ts + EDITOR/InspectorUI are editor-plugin files — never edit or import them
|
|
280
302
|
// AR: ARScene (camera passthrough, anchors, placement gestures via addControls)
|
|
281
303
|
|
|
282
304
|
// ===== CANVAS — retained drawing baked to a texture (engine-independent) =====
|
|
@@ -315,18 +337,22 @@ c.toBitmap(): Bitmap // rasterized snapshot, usable with drawImage
|
|
|
315
337
|
|
|
316
338
|
// ===== UI COMPONENTS =====
|
|
317
339
|
// Every element is created by a global factory function (never `new`) that takes only the element's
|
|
318
|
-
// CONTENT
|
|
319
|
-
// every configuring method returns the element itself, so construction reads
|
|
340
|
+
// CONTENT — children as plain arguments (or text/src/...). Everything else — styles, handlers — is
|
|
341
|
+
// configured by chaining: every configuring method returns the element itself, so construction reads
|
|
342
|
+
// as one chain.
|
|
343
|
+
UIColumn(UIText("Title"), UIButton(UIText("Go")))
|
|
344
|
+
// An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
|
|
345
|
+
UIColumn(header, items.map(Row), footer)
|
|
320
346
|
|
|
321
347
|
// UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
|
|
322
|
-
UIRow(children) / UIColumn(children)
|
|
348
|
+
UIRow(...children) / UIColumn(...children)
|
|
323
349
|
// Child management — imperative, no diffing; works before and after the element is on screen:
|
|
324
350
|
// .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
|
|
325
351
|
// .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
|
|
326
352
|
// For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
|
|
327
353
|
|
|
328
354
|
// UIScreen — root screen, always fills the device. Behaves as a UIColumn.
|
|
329
|
-
UIScreen(children)
|
|
355
|
+
UIScreen(...children)
|
|
330
356
|
// .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
|
|
331
357
|
// .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
|
|
332
358
|
// popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
|
|
@@ -334,12 +360,40 @@ UIScreen(children)
|
|
|
334
360
|
// .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
|
|
335
361
|
// .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
|
|
336
362
|
// Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
|
|
337
|
-
// with flexGrow: 1: UIScreen(
|
|
363
|
+
// with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
|
|
338
364
|
// Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
|
|
339
365
|
|
|
366
|
+
// UITabs — THE bottom-tab app shell: swipeable tabs (a UIPager) + a themed tab bar, as one UIScreen.
|
|
367
|
+
// USE THIS for every tabbed app — never hand-build a tab bar. Keys are tab ids, in tab order:
|
|
368
|
+
const tabs = UITabs({
|
|
369
|
+
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
370
|
+
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
371
|
+
})
|
|
372
|
+
Router.init(tabs) // UITabs IS a UIScreen — present it directly
|
|
373
|
+
// .select(id), .tab (getter), .onSelect(cb(id, i)) — fires on a bar tap, swipe, or select()
|
|
374
|
+
// .badge(id, value) — true = dot, number/string = count pill, false/null/0 clears
|
|
375
|
+
// .pager — the UIPager underneath; UIPager.push(detail) from any screen keeps the bar
|
|
376
|
+
// Styling is THEME-driven: theme({ primaryColor, mutedColor, tabbarBg, tabbarBorder, badgeColor })
|
|
377
|
+
// restyles the bar app-wide (dark fallbacks built in). For a custom bar layout use UIPager below.
|
|
378
|
+
|
|
379
|
+
// UIPager — the navigation primitive under UITabs: sibling tabs that swipe natively, each tab its
|
|
380
|
+
// OWN push/pop stack. Reach for it directly for a plain stack (one-screen pager) or a fully custom
|
|
381
|
+
// tab bar. Renders no bar — build your own next to it; give the pager flexGrow: 1.
|
|
382
|
+
UIPager(...tabs) // the tab root screens (arrays flatten); tabs are FIXED at construction
|
|
383
|
+
// .select(i, animated?) (instant by default), .index, .onSelect(cb(i)) — fires for taps AND swipes: sync the
|
|
384
|
+
// bar highlight here. Tabs keep their stack/scroll state when switched away and back.
|
|
385
|
+
// .push(screen) — slides onto the CURRENT tab; edge back-swipe / Android back pops natively.
|
|
386
|
+
// .pop(), .popToRoot(), .replace(screen), .depth (tab swiping is disabled while > 1), .onChange(cb(depth))
|
|
387
|
+
// Ambient from any screen, no reference needed: UIPager.push(screen) / UIPager.pop() / UIPager.current
|
|
388
|
+
// pager.push = detail INSIDE the tab (bar stays); Router.push = above the whole shell (bar covered).
|
|
389
|
+
// Note: tab screens are built up front, but onOpen fires only when the tab becomes visible (maybe never) —
|
|
390
|
+
// load initial data at build time, keep onOpen for re-entry.
|
|
391
|
+
const pager = UIPager(homeTab, searchTab, profileTab).style({ flexGrow: 1 })
|
|
392
|
+
Router.init(UIScreen(pager, tabBar)) // bar buttons: .onClick(() => pager.select(i))
|
|
393
|
+
|
|
340
394
|
// UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
|
|
341
395
|
// Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
|
|
342
|
-
UIWidget(children)
|
|
396
|
+
UIWidget(...children)
|
|
343
397
|
// .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
|
|
344
398
|
// extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
|
|
345
399
|
// a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
|
|
@@ -351,7 +405,7 @@ UIWidget(children)
|
|
|
351
405
|
// UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
|
|
352
406
|
// Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
|
|
353
407
|
// back button close it automatically. Create ONCE at module scope, like any widget.
|
|
354
|
-
UIModal(children)
|
|
408
|
+
UIModal(...children)
|
|
355
409
|
// .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
|
|
356
410
|
// — plus the full UIWidget surface
|
|
357
411
|
// .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
|
|
@@ -362,7 +416,7 @@ UIModal(children)
|
|
|
362
416
|
// UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
|
|
363
417
|
// Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
|
|
364
418
|
// dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
|
|
365
|
-
UIBottomSheet(children)
|
|
419
|
+
UIBottomSheet(...children)
|
|
366
420
|
// .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
|
|
367
421
|
// BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
|
|
368
422
|
// .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
|
|
@@ -375,30 +429,33 @@ UIBottomSheet(children)
|
|
|
375
429
|
// Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
|
|
376
430
|
// automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
|
|
377
431
|
// Attaches itself to Presentable.current (hides with the page it opened on).
|
|
378
|
-
UIPopover(children)
|
|
432
|
+
UIPopover(...children)
|
|
379
433
|
// .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
|
|
380
434
|
// .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
|
|
381
435
|
// Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
|
|
382
436
|
|
|
383
437
|
// UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
|
|
384
|
-
UIScrollable(children)
|
|
438
|
+
UIScrollable(...children)
|
|
385
439
|
// extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
|
|
386
440
|
// overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
|
|
387
441
|
// keyboardDismissMode ("interactive" | "scroll" | "none") — "scroll": any drag dismisses the keyboard at once (search lists)
|
|
442
|
+
// snap ("none" | "start" | "center" | "end", default "none") — paging: a released drag settles on a
|
|
443
|
+
// direct child's boundary; the value picks where the child rests in the viewport. Snap targets are
|
|
444
|
+
// the children themselves, so item widths can differ. (Mobile hosts; web degrades to free scrolling.)
|
|
388
445
|
// .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
|
|
389
446
|
// .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
|
|
390
447
|
// element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
|
|
391
448
|
// Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
|
|
392
449
|
// Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
|
|
393
450
|
// themselves. flexGrow: 1 to fill the remaining space is still yours to set.
|
|
394
|
-
// Carousel = scrollDirection: "horizontal" + FIXED-width cards
|
|
451
|
+
// Carousel = scrollDirection: "horizontal" + FIXED-width cards + snap: "start" ("center" for a card-deck-with-peek)
|
|
395
452
|
|
|
396
453
|
// UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
|
|
397
454
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
398
455
|
// is large, grows over time, or is unknown.
|
|
399
456
|
UIVirtualizedList<T>({
|
|
400
457
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
401
|
-
render: (item: T) =>
|
|
458
|
+
render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
|
|
402
459
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
403
460
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
404
461
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
@@ -447,7 +504,7 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
|
|
|
447
504
|
|
|
448
505
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
449
506
|
// To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
|
|
450
|
-
UIButton(children
|
|
507
|
+
UIButton(...children)
|
|
451
508
|
// .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
|
|
452
509
|
// extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
|
|
453
510
|
// rippleColor ("default" or a Color, Android only)
|
|
@@ -486,7 +543,7 @@ UIInput() / UITextArea()
|
|
|
486
543
|
|
|
487
544
|
// UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
|
|
488
545
|
// Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
|
|
489
|
-
UIRow(
|
|
546
|
+
UIRow(title, UISpacer(), closeButton)
|
|
490
547
|
// If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
|
|
491
548
|
|
|
492
549
|
// @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
|
|
@@ -501,9 +558,13 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
|
|
|
501
558
|
// 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
|
|
502
559
|
// 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
|
|
503
560
|
// e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
|
|
504
|
-
// 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
|
|
561
|
+
// 3. .animateTo({..., duration, delay?, commit?, loop?}) → tween current → given values (duration in MS).
|
|
505
562
|
// Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
|
|
506
563
|
// the exit-animation pattern (fade an overlay, then .hide(); next show() starts from the intact style).
|
|
564
|
+
// loop: true | n repeats the tween — loopMode: "ping-pong" (default: there and back) or "restart"
|
|
565
|
+
// (snap back + replay — full-turn spinners via transform: "rotate(360deg)", shimmers). A looping
|
|
566
|
+
// animation is an effect, not a state change: it never commits, delay applies once, and it stops on
|
|
567
|
+
// the element's next animateTo/animateFrom or when it leaves the screen. (web/iOS/desktop; Android plays once until its next runtime.)
|
|
507
568
|
// 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
|
|
508
569
|
// Never modifies the stored style.
|
|
509
570
|
// For free-value tweens use animate() — see ANIMATION above.
|
|
@@ -581,9 +642,28 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
581
642
|
.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
|
|
582
643
|
// Keys: onLandscape, onPortrait.
|
|
583
644
|
|
|
645
|
+
// --- Style classes ($name) — named states that CASCADE ---
|
|
646
|
+
// A $-prefixed key in .style() declares a named style state (like onPressed, but yours: selected,
|
|
647
|
+
// checked, expanded); duration/delay inside the block animate the swap. Drive it via el.class:
|
|
648
|
+
const item = UIButton(UIText("Wi-Fi")).style({ bgColor: "#151515", $selected: { bgColor: "#1d2b45", duration: 150 } })
|
|
649
|
+
item.onClick(ev => ev.target.class.selected = !ev.target.class.selected)
|
|
650
|
+
// el.class.selected reads/writes a boolean; el.class({ a: true, b: false }) is the chainable batch
|
|
651
|
+
// form; names work with or without the $. Class state persists across screen close/reopen.
|
|
652
|
+
// CASCADE: a class set on an element also activates same-name $ blocks on ALL its descendants —
|
|
653
|
+
// one toggle restyles a whole composite control, each part declaring its own reaction:
|
|
654
|
+
UIButton(
|
|
655
|
+
UIImage(icon).style({ tintColor: "#888", $selected: { tintColor: "#5b8cff" } }),
|
|
656
|
+
UIText("Label").style({ color: "#888", $selected: { color: "#5b8cff" } }),
|
|
657
|
+
) // .class.selected = true → icon AND label restyle (the cascade stops at hosted screens/widgets)
|
|
658
|
+
// $pressed / $focused are RESERVED — the system toggles them (finger down / input focused) and they
|
|
659
|
+
// CASCADE, so a button's children can restyle during its press. onPressed/onFocused stay
|
|
660
|
+
// own-element-only — use those on nested interactives (a button inside a clickable card):
|
|
661
|
+
UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed: { transform: "scale(0.97)" } })
|
|
662
|
+
// Precedence on the same prop: base < $classes < $pressed/$focused < onPressed/onFocused.
|
|
663
|
+
|
|
584
664
|
// --- Defaults that surprise ---
|
|
585
|
-
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and
|
|
586
|
-
// flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
665
|
+
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable, UIVirtualizedList and UIPager
|
|
666
|
+
// default flexShrink: 1 so they shrink/scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
587
667
|
// flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
|
|
588
668
|
// alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
|
|
589
669
|
// overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
|
|
@@ -600,10 +680,10 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
600
680
|
// ===== REUSABLE COMPONENTS =====
|
|
601
681
|
// Extract repeated UI into factory functions — they return elements you can chain methods on:
|
|
602
682
|
|
|
603
|
-
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
683
|
+
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
604
684
|
UIText(title).style({ fontWeight: 700, color: "white" }),
|
|
605
685
|
UIText(subtitle).style({ color: "#888", fontSize: 13 })
|
|
606
|
-
|
|
686
|
+
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
607
687
|
|
|
608
688
|
// Use like any other element — chain after the call:
|
|
609
689
|
ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
@@ -612,15 +692,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
|
612
692
|
const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
|
|
613
693
|
|
|
614
694
|
// ===== CAPTURING ELEMENT REFERENCES =====
|
|
615
|
-
// Use an assignment expression
|
|
695
|
+
// Use an assignment expression right in the children — standard TypeScript:
|
|
616
696
|
|
|
617
697
|
let label: UIText
|
|
618
698
|
let input: UIInput
|
|
619
699
|
|
|
620
|
-
UIColumn(
|
|
700
|
+
UIColumn(
|
|
621
701
|
label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
|
|
622
702
|
input = UIInput(),
|
|
623
|
-
|
|
703
|
+
)
|
|
624
704
|
|
|
625
705
|
// Later:
|
|
626
706
|
label.text = "Updated"
|
|
@@ -630,15 +710,17 @@ input.value // read current value
|
|
|
630
710
|
// ((button.children[0] as UIText).text vs buttonText.text).
|
|
631
711
|
|
|
632
712
|
// ===== CONDITIONAL CHILDREN =====
|
|
633
|
-
// null / undefined / false
|
|
634
|
-
UIColumn(
|
|
713
|
+
// A null / undefined / false child is skipped — no element, no layout slot.
|
|
714
|
+
UIColumn(header, isLoading ? spinner : null, showFooter && footer)
|
|
715
|
+
// Works for whole blocks too — a falsy argument is skipped, an array argument is flattened:
|
|
716
|
+
UIColumn(header, showList && items.map(Row))
|
|
635
717
|
|
|
636
718
|
// ===== SIZING: the two axes behave differently =====
|
|
637
719
|
// MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
|
|
638
720
|
// without flexGrow: 1 (no implicit min-sizes).
|
|
639
721
|
// CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
|
|
640
722
|
// or alignSelf to opt out.
|
|
641
|
-
// So UIRow(
|
|
723
|
+
// So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
642
724
|
// EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
|
|
643
725
|
// equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
|
|
644
726
|
// longer label stays wider.
|
|
@@ -658,6 +740,7 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
|
|
|
658
740
|
// Position once at an interaction — never poll per frame.
|
|
659
741
|
|
|
660
742
|
// ===== ROUTER (multi-page apps) =====
|
|
743
|
+
// Tabs are UITabs' job, in-tab stacks UIPager's (see UI COMPONENTS) — the Router owns what sits ABOVE the shell.
|
|
661
744
|
Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
|
|
662
745
|
Router.push(screen) // push onto the stack, screen becomes active
|
|
663
746
|
Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
|
|
@@ -699,6 +782,14 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
|
|
|
699
782
|
UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
|
|
700
783
|
// Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
|
|
701
784
|
|
|
785
|
+
// ===== ICONS (assetIcon) =====
|
|
786
|
+
// Use real icons instead of emoji. assetIcon("pack:name") resolves the icon at COMPILE time and
|
|
787
|
+
// inlines it as an image source (same shape as SvgSource) — no imports, no project files, offline.
|
|
788
|
+
UIImage(assetIcon("lucide:bell")).style({ width: 24, height: 24, tintColor: "#8a8f98" })
|
|
789
|
+
// The id must be a string literal; an unknown id is a compile error (with name suggestions).
|
|
790
|
+
// Recolor via the tintColor style or the { color } option (hex literal bakes in; expression tints).
|
|
791
|
+
// Packs: lucide, tabler, heroicons, feather, bi, carbon, mdi, ri, solar.
|
|
792
|
+
|
|
702
793
|
// ===== COMMON MISTAKES — DO NOT DO THESE =====
|
|
703
794
|
|
|
704
795
|
// ❌ CSS that doesn't exist here
|
|
@@ -707,16 +798,13 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
|
|
|
707
798
|
lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
|
|
708
799
|
|
|
709
800
|
// ❌ touch handler on a non-touch element
|
|
710
|
-
UIColumn(
|
|
711
|
-
|
|
712
|
-
// ❌ scrollable that overflows the screen
|
|
713
|
-
// A wrapping container between the scrollable and the screen is missing flexShrink: 1
|
|
801
|
+
UIColumn(...).onTouchStart(cb) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
|
|
714
802
|
|
|
715
|
-
// ❌ let/const
|
|
716
|
-
UIRow(
|
|
803
|
+
// ❌ let/const among the children — `let` is a statement, not an expression
|
|
804
|
+
UIRow(let input = UIInput()) // WRONG
|
|
717
805
|
// ✅ declare outside, assign inside (assignment both sets the var AND appends)
|
|
718
806
|
let input: UIInput
|
|
719
|
-
UIRow(
|
|
807
|
+
UIRow(input = UIInput())
|
|
720
808
|
|
|
721
809
|
// ❌ setting a UIImage's content through bgImage
|
|
722
810
|
const img = UIImage("") // WRONG — empty source as a placeholder
|
|
@@ -726,30 +814,30 @@ const img = UIImage(url)
|
|
|
726
814
|
img.src = newUrl // updates the displayed image
|
|
727
815
|
|
|
728
816
|
// ❌ expecting an input to fill width like in CSS
|
|
729
|
-
UIRow(
|
|
817
|
+
UIRow(UIInput()) // WRONG — collapses to placeholder width
|
|
730
818
|
// ✅ stretch it explicitly
|
|
731
|
-
UIColumn(
|
|
732
|
-
UIRow(
|
|
819
|
+
UIColumn(UIInput().style({ width: "100%" })) // cross axis
|
|
820
|
+
UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
|
|
733
821
|
|
|
734
822
|
// ❌ a button that should match the input's height but shrinks to its text
|
|
735
|
-
UIRow(
|
|
823
|
+
UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
|
|
736
824
|
// ✅ give controls the same explicit height
|
|
737
|
-
UIRow(
|
|
825
|
+
UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
|
|
738
826
|
|
|
739
827
|
// ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
|
|
740
|
-
UIRow(
|
|
828
|
+
UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
|
|
741
829
|
// ✅ flex: 1 — grows from a zero basis, children end up equal
|
|
742
|
-
UIRow(
|
|
830
|
+
UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
|
|
743
831
|
|
|
744
832
|
// ❌ empty containers as spacers to align children (web habit)
|
|
745
|
-
UIRow(
|
|
833
|
+
UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
|
|
746
834
|
// ✅ alignment is a CONTAINER property, not an extra element
|
|
747
|
-
UIRow(
|
|
835
|
+
UIRow(label).style({ justifyContent: "flex-end" })
|
|
748
836
|
|
|
749
837
|
// ❌ empty element as a placeholder for a conditional child
|
|
750
|
-
UIRow(
|
|
838
|
+
UIRow(isGroup ? button : UIColumn()) // WRONG
|
|
751
839
|
// ✅ null is skipped in children — no phantom element
|
|
752
|
-
UIRow(
|
|
840
|
+
UIRow(isGroup ? button : null)
|
|
753
841
|
|
|
754
842
|
// ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
|
|
755
843
|
UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
|
|
@@ -763,7 +851,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
|
|
|
763
851
|
list.onEndReached(600, loadNextPage)
|
|
764
852
|
|
|
765
853
|
// ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
|
|
766
|
-
const openSheet = () => UIWidget(
|
|
854
|
+
const openSheet = () => UIWidget(...).show() // WRONG — leaks a new widget every call
|
|
767
855
|
// ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
|
|
768
856
|
|
|
769
857
|
// ===== EXAMPLES =====
|
|
@@ -771,7 +859,7 @@ const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new wid
|
|
|
771
859
|
// === EXAMPLE 1: Single-file app ===
|
|
772
860
|
<file name="main.ts">
|
|
773
861
|
let text: UIText
|
|
774
|
-
const screen = UIScreen(
|
|
862
|
+
const screen = UIScreen(
|
|
775
863
|
text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
|
|
776
864
|
UIText("Your name:").style({ textAlign: "center" }),
|
|
777
865
|
UIInput()
|
|
@@ -779,91 +867,188 @@ const screen = UIScreen([
|
|
|
779
867
|
.onChange(str => {
|
|
780
868
|
text.text = `Hello, ${str}!`
|
|
781
869
|
})
|
|
782
|
-
|
|
870
|
+
).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
783
871
|
|
|
784
872
|
screen.open()
|
|
785
873
|
</file>
|
|
786
874
|
|
|
787
|
-
// === EXAMPLE 2:
|
|
875
|
+
// === EXAMPLE 2: Theme tokens + fetched list with loading / error states ===
|
|
876
|
+
// A tokens module every screen imports, light-themed: theme({ color }) sets the default text
|
|
877
|
+
// color ONCE — no color: "#111" on every label.
|
|
878
|
+
<file name="tokens.ts">
|
|
879
|
+
const palette = {
|
|
880
|
+
bg: "#F4F6F5", card: "#FFFFFF", border: "#E4E8E6",
|
|
881
|
+
text: "#131A17", muted: "#606B65",
|
|
882
|
+
accent: "#15A34A", accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
|
|
883
|
+
}
|
|
884
|
+
theme({ color: palette.text, primaryColor: palette.accent })
|
|
885
|
+
// Accessors ARE "var(--x)" strings — re-calling theme() with new values restyles the live app.
|
|
886
|
+
export const colors: { [K in keyof typeof palette]: string } = theme(palette)
|
|
887
|
+
export const font = { // type scale — spread into styles: .style({ ...font.h2 })
|
|
888
|
+
h2: { fontSize: 22, fontWeight: 700 }, bodyStrong: { fontSize: 16, fontWeight: 600 },
|
|
889
|
+
small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
|
|
890
|
+
}
|
|
891
|
+
</file>
|
|
788
892
|
<file name="main.ts">
|
|
893
|
+
import { colors, font } from './tokens'
|
|
894
|
+
|
|
789
895
|
type User = { id: number; name: string; email: string }
|
|
790
896
|
|
|
791
|
-
|
|
792
|
-
|
|
897
|
+
const Row = (u: User) => UIRow(
|
|
898
|
+
UIColumn(UIText(u.name[0]).style({ ...font.bodyStrong, color: colors.accent }))
|
|
899
|
+
.style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accentSoft,
|
|
900
|
+
justifyContent: "center", alignItems: "center" }),
|
|
901
|
+
UIColumn(
|
|
902
|
+
UIText(u.name).style({ ...font.bodyStrong }),
|
|
903
|
+
UIText(u.email).style({ ...font.small, color: colors.muted }),
|
|
904
|
+
).style({ flexGrow: 1, flexShrink: 1, gap: 2, alignItems: "flex-start" }),
|
|
905
|
+
).style({ alignItems: "center", gap: 12, bgColor: colors.card, borderRadius: 16,
|
|
906
|
+
border: `1px solid ${colors.border}`, p: 14 })
|
|
793
907
|
|
|
794
|
-
const
|
|
795
|
-
|
|
908
|
+
const Centered = (...children: UINodeChild[]) =>
|
|
909
|
+
UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
|
|
910
|
+
|
|
911
|
+
let body: UIColumn
|
|
796
912
|
|
|
913
|
+
const loadData = async () => {
|
|
914
|
+
body.setContent([Centered(UIText("Loading…").style({ color: colors.muted }))])
|
|
797
915
|
const res = await fetch("https://jsonplaceholder.typicode.com/users")
|
|
798
916
|
if (res.status !== 200) {
|
|
799
|
-
|
|
917
|
+
body.setContent([Centered(
|
|
918
|
+
UIText("Couldn't load users"),
|
|
919
|
+
UIButton(UIText("Retry").style({ color: colors.onAccent, fontWeight: 600 }))
|
|
920
|
+
.style({ height: 44, px: 24, borderRadius: 12, bgColor: colors.accent })
|
|
921
|
+
.onClick(() => loadData()),
|
|
922
|
+
)])
|
|
800
923
|
return
|
|
801
924
|
}
|
|
802
|
-
|
|
803
|
-
const users = res.json<User[]>() // sync — no await
|
|
804
|
-
|
|
805
|
-
statusText.text = ""
|
|
806
|
-
list.setContent(
|
|
807
|
-
users.map(user =>
|
|
808
|
-
UIColumn([
|
|
809
|
-
UIText(user.name).style({ fontWeight: 700, color: "white" }),
|
|
810
|
-
UIText(user.email).style({ color: "#888", fontSize: 13 })
|
|
811
|
-
]).style({ px: 16, py: 12, gap: 4 })
|
|
812
|
-
)
|
|
813
|
-
)
|
|
925
|
+
body.setContent(res.json<User[]>().map(Row)) // res.json is sync — no await
|
|
814
926
|
}
|
|
815
927
|
|
|
816
|
-
const screen = UIScreen(
|
|
817
|
-
UIText("Users").style({
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
.style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
|
|
825
|
-
.onOpen(() => loadData())
|
|
928
|
+
const screen = UIScreen(
|
|
929
|
+
UIText("Users").style({ ...font.h2, pb: 8, px: 16 }),
|
|
930
|
+
UIScrollable(
|
|
931
|
+
body = UIColumn().style({ flexGrow: 1, gap: 8 })
|
|
932
|
+
).style({ flexGrow: 1, p: 16, pt: 0 }) // the ONE scrolling body — the screen itself never scrolls
|
|
933
|
+
.onRefresh(() => loadData()), // pull-to-refresh; attached before screen.open()
|
|
934
|
+
).style({ bgColor: colors.bg, pt: "comfort-top" })
|
|
935
|
+
.onOpen(() => loadData())
|
|
826
936
|
|
|
827
937
|
screen.open()
|
|
828
938
|
</file>
|
|
829
939
|
|
|
830
|
-
// === EXAMPLE 3:
|
|
940
|
+
// === EXAMPLE 3: Tabbed app — UITabs, font(), in-tab detail ===
|
|
831
941
|
<file name="home.ts">
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
942
|
+
const detailScreen = (name: string) => UIScreen(
|
|
943
|
+
UIButton(
|
|
944
|
+
UIImage(assetIcon("lucide:chevron-left")).style({ width: 20, height: 20, tintColor: "white" }),
|
|
945
|
+
UIText("Back")
|
|
946
|
+
).style({ alignSelf: "flex-start", height: 32, gap: 4 }).onClick(() => UIPager.pop()),
|
|
947
|
+
UIText(name).style({ fontSize: 24, fontWeight: 700 }),
|
|
948
|
+
UIButton(
|
|
949
|
+
UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18, tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
|
|
950
|
+
UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } })
|
|
951
|
+
).style({ name: "fav", alignSelf: "flex-start", height: 36, px: 12, gap: 6, borderRadius: 18, bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })
|
|
952
|
+
.onClick(ev => ev.target.class.fav = !ev.target.class.fav) // one toggle — the $fav blocks on icon + label light up too (cascade)
|
|
953
|
+
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
954
|
+
|
|
955
|
+
const Item = (name: string) => UIButton(UIText(name).style({ color: "white" }))
|
|
956
|
+
.style({ height: 52, px: 16, justifyContent: "flex-start", borderRadius: 12, bgColor: "#151515", onPressed: { opacity: 0.7 } })
|
|
957
|
+
.onClick(() => UIPager.push(detailScreen(name))) // in-tab push: tab bar stays, back-swipe pops
|
|
958
|
+
|
|
959
|
+
export const homeScreen = UIScreen(
|
|
960
|
+
UIText("Home").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") }), // display face
|
|
961
|
+
UIScrollable(["Alpha", "Beta", "Gamma"].map(Item)).style({ flexGrow: 1, gap: 8 })
|
|
962
|
+
).style({ p: 16, pt: "comfort-top", gap: 16, bgColor: "black" })
|
|
841
963
|
</file>
|
|
842
|
-
<file name="
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
UIButton([
|
|
847
|
-
UIImage(chevronLeft).style({ width: 18, height: 18, tintColor: "#FF4032" }),
|
|
848
|
-
UIText("Back").style({ color: "#FF4032" })
|
|
849
|
-
]).style({ alignSelf: "flex-start", flexDirection: "row", height: 32, gap: 4 }).onClick(() => Router.pop()),
|
|
850
|
-
UIText("Detail Screen").style({ fontSize: 24, fontWeight: 700 })
|
|
851
|
-
])
|
|
852
|
-
.style({ p: 20, pt: "max(safe-top, 24px)", gap: 20 })
|
|
853
|
-
.onBackPressed(() => Router.pop())
|
|
854
|
-
</file>
|
|
855
|
-
<file name="assets/chevron-left.svg">
|
|
856
|
-
<svg viewBox="0 0 24 24">
|
|
857
|
-
<path d="M15 18l-6-6 6-6" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
858
|
-
</svg>
|
|
964
|
+
<file name="profile.ts">
|
|
965
|
+
export const profileScreen = UIScreen(
|
|
966
|
+
UIText("Profile").style({ fontSize: 28, fontWeight: 700, fontFamily: font("unbounded") })
|
|
967
|
+
).style({ p: 16, pt: "comfort-top", bgColor: "black" })
|
|
859
968
|
</file>
|
|
860
969
|
<file name="main.ts">
|
|
861
970
|
import { homeScreen } from './home'
|
|
971
|
+
import { profileScreen } from './profile'
|
|
972
|
+
|
|
973
|
+
theme({ fontFamily: font("manrope") }) // app-wide default text font — one line, no loading code
|
|
974
|
+
|
|
975
|
+
const tabs = UITabs({
|
|
976
|
+
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
|
|
977
|
+
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
|
|
978
|
+
})
|
|
979
|
+
tabs.badge("profile", true) // notification dot on the tab
|
|
980
|
+
Router.init(tabs)
|
|
981
|
+
</file>
|
|
982
|
+
|
|
983
|
+
// === EXAMPLE 4: Form — field factory, return-key chain, validation, busy submit ===
|
|
984
|
+
// The keyboard needs NO code beyond the enterKey chain: the host scrolls the focused field into
|
|
985
|
+
// view and handles dismissal itself.
|
|
986
|
+
<file name="main.ts">
|
|
987
|
+
// Field anatomy: label above, styled input, a RESERVED message line below (minHeight — an
|
|
988
|
+
// appearing error never jumps the form). Typing in an errored field clears it.
|
|
989
|
+
const Field = (label: string, style: Style<UIInput> = {}) => {
|
|
990
|
+
const input = UIInput().style({ height: 50, px: 14, borderRadius: 12, bgColor: "#161A22",
|
|
991
|
+
border: "1px solid #262C3A", color: "white", placeholderColor: "#5A6272",
|
|
992
|
+
onFocused: { borderColor: "#4C8DFF" }, ...style })
|
|
993
|
+
const message = UIText("").style({ fontSize: 13, minHeight: 18, color: "#FF6B6B" })
|
|
994
|
+
input.onChange(() => message.text = "")
|
|
995
|
+
return {
|
|
996
|
+
node: UIColumn(
|
|
997
|
+
UIText(label).style({ fontSize: 13, fontWeight: 600, color: "#8A93A6" }),
|
|
998
|
+
input, message,
|
|
999
|
+
).style({ gap: 6 }),
|
|
1000
|
+
input,
|
|
1001
|
+
error: (text: string) => { message.text = text },
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const name = Field("Name", { placeholder: "Jane Appleseed", autocapitalize: "words" })
|
|
1006
|
+
const email = Field("Email", { type: "email", placeholder: "you@example.com" })
|
|
1007
|
+
const password = Field("Password", { type: "password" })
|
|
1008
|
+
|
|
1009
|
+
// The return key walks the form; the last field submits. This is ALL the keyboard code.
|
|
1010
|
+
name.input.style({ enterKey: "next" }).onSubmit(() => email.input.focus())
|
|
1011
|
+
email.input.style({ enterKey: "next" }).onSubmit(() => password.input.focus())
|
|
1012
|
+
password.input.style({ enterKey: "go" }).onSubmit(() => submit())
|
|
1013
|
+
|
|
1014
|
+
let btnLabel: UIText
|
|
1015
|
+
let busy = false
|
|
1016
|
+
|
|
1017
|
+
// The submit button is never disabled — a tap on a bad form PAINTS the errors and focuses the
|
|
1018
|
+
// first offender, which beats a dead button that explains nothing. `busy` swallows double-taps.
|
|
1019
|
+
const submit = async () => {
|
|
1020
|
+
if (busy) return
|
|
1021
|
+
let bad: ReturnType<typeof Field> | null = null // checked bottom-up, so `bad` ends at the FIRST invalid field
|
|
1022
|
+
if (password.input.value.length < 8) { password.error("At least 8 characters"); bad = password }
|
|
1023
|
+
if (!email.input.value.includes("@")) { email.error("Enter a valid email"); bad = email }
|
|
1024
|
+
if (name.input.value.trim() === "") { name.error("Name is required"); bad = name }
|
|
1025
|
+
if (bad) { bad.input.focus(); return }
|
|
1026
|
+
busy = true
|
|
1027
|
+
btnLabel.text = "Creating…"
|
|
1028
|
+
const res = await fetch("https://api.example.com/register", {
|
|
1029
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1030
|
+
body: JSON.stringify({ name: name.input.value.trim(), email: email.input.value, password: password.input.value }),
|
|
1031
|
+
})
|
|
1032
|
+
busy = false
|
|
1033
|
+
btnLabel.text = "Create account"
|
|
1034
|
+
if (res.status !== 200) { email.error("Registration failed — try again"); return }
|
|
1035
|
+
toast("Welcome!")
|
|
1036
|
+
}
|
|
862
1037
|
|
|
863
|
-
|
|
1038
|
+
const screen = UIScreen(
|
|
1039
|
+
UIScrollable( // no fixed chrome — the keyboard leaves ~460px of screen and a form wants all of them
|
|
1040
|
+
UIText("Create account").style({ fontSize: 28, fontWeight: 700, mb: 12 }),
|
|
1041
|
+
name.node, email.node, password.node,
|
|
1042
|
+
UIButton(btnLabel = UIText("Create account").style({ fontSize: 16, fontWeight: 700 }))
|
|
1043
|
+
.style({ name: "submit", height: 52, borderRadius: 14, bgColor: "#4C8DFF", mt: 8, onPressed: { opacity: 0.85 } })
|
|
1044
|
+
.onClick(() => submit())
|
|
1045
|
+
).style({ flexGrow: 1, px: 20, pt: "comfort-top", pb: 28, gap: 8 })
|
|
1046
|
+
).style({ bgColor: "#0C0F14" })
|
|
1047
|
+
|
|
1048
|
+
screen.open()
|
|
864
1049
|
</file>
|
|
865
1050
|
|
|
866
|
-
// === EXAMPLE
|
|
1051
|
+
// === EXAMPLE 5: UIBottomSheet — persistent map-style sheet with detents ===
|
|
867
1052
|
<file name="main.ts">
|
|
868
1053
|
type Place = { id: number; name: string; distance: string }
|
|
869
1054
|
const places: Place[] = [
|
|
@@ -872,32 +1057,32 @@ const places: Place[] = [
|
|
|
872
1057
|
{ id: 3, name: "Riverside Park", distance: "1.2 km" },
|
|
873
1058
|
]
|
|
874
1059
|
|
|
875
|
-
const Row = (p: Place) => UIRow(
|
|
1060
|
+
const Row = (p: Place) => UIRow(
|
|
876
1061
|
UIText(p.name).style({ color: "#111", fontSize: 16 }),
|
|
877
1062
|
UIText(p.distance).style({ color: "#888", fontSize: 14 })
|
|
878
|
-
|
|
1063
|
+
).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
879
1064
|
|
|
880
|
-
const sheet = UIBottomSheet(
|
|
881
|
-
UIColumn(
|
|
1065
|
+
const sheet = UIBottomSheet(
|
|
1066
|
+
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
882
1067
|
UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
|
|
883
1068
|
UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
|
|
884
|
-
|
|
1069
|
+
)
|
|
885
1070
|
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
|
|
886
1071
|
.detents([0.25, 0.6, 1]) // collapsed / half / full
|
|
887
1072
|
.dismissible(false) // drag below the lowest detent collapses, never closes
|
|
888
1073
|
.onDetentChange(i => console.log("detent", i))
|
|
889
1074
|
|
|
890
|
-
const mapScreen = UIScreen(
|
|
1075
|
+
const mapScreen = UIScreen(
|
|
891
1076
|
// the map / page content behind the sheet
|
|
892
|
-
|
|
1077
|
+
).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
893
1078
|
|
|
894
1079
|
mapScreen.open()
|
|
895
1080
|
|
|
896
|
-
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(
|
|
1081
|
+
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
|
|
897
1082
|
// scrim and drag-down-to-dismiss included.
|
|
898
1083
|
</file>
|
|
899
1084
|
|
|
900
|
-
// === EXAMPLE
|
|
1085
|
+
// === EXAMPLE 6: UIVirtualizedList — chat (inverted, imperative append) ===
|
|
901
1086
|
<file name="main.ts">
|
|
902
1087
|
type Msg = { id: string; text: string; mine: boolean }
|
|
903
1088
|
|
|
@@ -909,37 +1094,41 @@ const list = UIVirtualizedList<Msg>({
|
|
|
909
1094
|
keyOf: m => m.id,
|
|
910
1095
|
estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
|
|
911
1096
|
inverted: true, // newest at the bottom
|
|
912
|
-
render: m => UIRow(
|
|
913
|
-
UIRow(
|
|
1097
|
+
render: m => UIRow(
|
|
1098
|
+
UIRow(
|
|
914
1099
|
UIText(m.text).style({ color: m.mine ? "white" : "#111" })
|
|
915
|
-
|
|
1100
|
+
).style({
|
|
916
1101
|
bgColor: m.mine ? "#FF4032" : "#EEE",
|
|
917
1102
|
px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
|
|
918
1103
|
})
|
|
919
|
-
|
|
1104
|
+
).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
920
1105
|
}).style({ flexGrow: 1 })
|
|
921
1106
|
|
|
922
|
-
let input:
|
|
1107
|
+
let input: UITextArea
|
|
1108
|
+
let sendBtn: UIButton
|
|
923
1109
|
|
|
924
1110
|
const send = () => {
|
|
925
1111
|
const text = input.value.trim()
|
|
926
1112
|
if (!text) return
|
|
927
1113
|
list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
|
|
928
1114
|
input.value = ""
|
|
1115
|
+
sendBtn.style.opacity = 0.4
|
|
929
1116
|
}
|
|
930
1117
|
|
|
931
|
-
const screen = UIScreen(
|
|
1118
|
+
const screen = UIScreen(
|
|
932
1119
|
list,
|
|
933
|
-
UIRow(
|
|
934
|
-
input =
|
|
935
|
-
flexGrow: 1,
|
|
936
|
-
|
|
1120
|
+
UIRow(
|
|
1121
|
+
input = UITextArea().style({ placeholder: "Message...", placeholderColor: "#999",
|
|
1122
|
+
color: "#111", flexGrow: 1, flexShrink: 1, px: 16, py: 10, bgColor: "#F0F0F0", borderRadius: 20, maxHeight: 110,
|
|
1123
|
+
keyboardDismiss: false
|
|
937
1124
|
}),
|
|
938
|
-
UIButton(
|
|
939
|
-
.style({
|
|
1125
|
+
sendBtn = UIButton(UIImage(assetIcon("lucide:arrow-up")).style({ width: 20, height: 20, tintColor: "white" }))
|
|
1126
|
+
.style({ width: 40, height: 40, borderRadius: 20, bgColor: "#FF4032", opacity: 0.4 })
|
|
940
1127
|
.onClick(send)
|
|
941
|
-
|
|
942
|
-
|
|
1128
|
+
).style({ p: 8, pb: "comfort-bottom", gap: 8, alignItems: "flex-end" })
|
|
1129
|
+
).style({ bgColor: "white" })
|
|
1130
|
+
|
|
1131
|
+
input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
|
|
943
1132
|
|
|
944
1133
|
screen.open()
|
|
945
1134
|
</file>
|