lecodes-sdk 1.1.0 → 1.2.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/inject.js +260 -361
- package/dist/types/gl/Light.d.ts +3 -0
- package/dist/types/gl/Particles.d.ts +15 -0
- package/dist/types/gl/animation/Locomotion.d.ts +52 -3
- package/dist/types/gl/terrain/Terrain.d.ts +8 -0
- package/dist/types/runtime/input.d.ts +7 -0
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/README.md +142 -142
- package/prompts/dist/2d-game.md +197 -408
- package/prompts/dist/3d-app.md +166 -491
- package/prompts/dist/ar-app.md +163 -373
- package/prompts/dist/design.md +87 -83
- package/prompts/dist/ui-app.md +136 -325
- package/src/animate/tween/Animation.ts +378 -378
- package/src/animate/tween/Timeline.ts +175 -175
- package/src/animate/tween/animateValue.ts +100 -100
- package/src/animate/tween/spec.ts +479 -479
- package/src/audio/audio.ts +161 -161
- package/src/bridges.d.ts +10 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Light.ts +3 -0
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Model.ts +167 -167
- package/src/gl/Particles.ts +19 -0
- package/src/gl/animation/DynamicBone.ts +482 -482
- package/src/gl/animation/Locomotion.ts +72 -8
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/terrain/Terrain.ts +29 -0
- package/src/inject.ts +236 -236
- package/src/runtime/input.ts +11 -0
- package/src/scene/gizmos.ts +148 -148
package/prompts/dist/ui-app.md
CHANGED
|
@@ -102,16 +102,6 @@ After every edit you make, the platform automatically verifies it — syntax, a
|
|
|
102
102
|
|
|
103
103
|
A `[Selected element]` section describes a UI element the user picked in the running app's preview — its type, text, current style, and "created at" (the code that creates it). Apply the request to exactly that element, starting from the created-at location.
|
|
104
104
|
|
|
105
|
-
## Building from a design
|
|
106
|
-
|
|
107
|
-
A `design/` folder is the app's finished design, made on the design board: `screens/<id>.ts` — one static mockup per screen, its states a function of a state-string union; `shared/tokens.ts` — the brand as a `theme()` table; `shared/ui.ts` — the component kit; `shared/tabs.ts` — the tab bar (`defineTabs`); `meta.json` — screen descriptions, roles, groups, and the navigation edges between screens; `spec.md` — the data model, actors, and rules. When asked to build or implement the app (or a screen) from it, the design is the authority:
|
|
108
|
-
|
|
109
|
-
- Implement every designed screen, including each state in its union. Navigation follows `meta.json` edges (an `id@state` edge switches that screen's state, not a push) and the tab bar in `shared/tabs.ts` — its `defineTabs({...})` entries carry over 1:1 into `UITabs({...})` (same keys, labels, and icons; add each tab's root `screen`), which renders the identical bar.
|
|
110
|
-
- IMPORT the kit, don't restyle: app code imports `design/shared/tokens.ts` (its `theme()` call makes design and app one live theme) and the presentational components of `design/shared/ui.ts`. Rebuild only the mock-only pieces (static field mocks, fake keyboards) as real interactive equivalents with the exact same look.
|
|
111
|
-
- The mock data at the top of each screen is the schema draft: replace it with real state, storage, and logic per `spec.md`, keeping the field shapes.
|
|
112
|
-
- App screens are your own files (`main.ts` + the usual project layout) — never import `design/screens/*` into the app, and never write into `design/**`: the design stays the reference. If the design itself needs changing, say so and suggest `<mode>design</mode>`.
|
|
113
|
-
- Match the mockups; don't re-design. Where a mockup leaves behavior undefined, `spec.md` decides; where it's silent, pick the simplest behavior consistent with the design.
|
|
114
|
-
|
|
115
105
|
## Tools
|
|
116
106
|
|
|
117
107
|
You may be given tools (they appear in the API request, each with its own description). Default to
|
|
@@ -219,22 +209,16 @@ const sfx = new AudioPlayer(src) // src: asset('./x.mp3') or URL; same API: ne
|
|
|
219
209
|
// .dispose() — frees the native player; ANY use after dispose throws
|
|
220
210
|
// No pitch control, no auto-pooling: for overlapping SFX create several players up front and rotate.
|
|
221
211
|
|
|
222
|
-
// ===== INPUT (keyboard
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
Input.mouse.delta // { x, y } motion during the previous frame — FPS look; works while locked
|
|
229
|
-
Input.mouse.position / .wheel // cursor in logical px / wheel notches this frame
|
|
230
|
-
Input.mouse.lock() / unlock() / locked // hide + confine the cursor (call lock() from a keydown — web needs a gesture)
|
|
231
|
-
Input.gamepad(0).axis('leftX' | 'leftY' | 'rightX' | 'rightY' | 'leftTrigger' | 'rightTrigger') // −1..1 / 0..1
|
|
212
|
+
// ===== INPUT (keyboard) =====
|
|
213
|
+
|
|
214
|
+
Input.key(code: string): boolean // is the key held NOW; KeyboardEvent.code names: 'KeyW', 'Space', 'ArrowLeft'
|
|
215
|
+
// Poll-only — there are NO key events. For "pressed this frame", latch previous state:
|
|
216
|
+
const wasDown: Record<string, boolean> = {}
|
|
217
|
+
const pressed = (c: string) => Input.key(c) && !wasDown[c]
|
|
232
218
|
setLoop(dt => {
|
|
233
|
-
|
|
234
|
-
const
|
|
219
|
+
if (pressed('Space')) jump()
|
|
220
|
+
for (const c of ['Space', 'KeyE']) wasDown[c] = Input.key(c) // latch at END of frame
|
|
235
221
|
})
|
|
236
|
-
Input.on('keydown', e => { if (e.code === 'Space' || e.code === 'GamepadSouth') jump() })
|
|
237
|
-
// No one-frame "pressed" polls (keyDown/actionDown) — discrete = event, continuous = poll.
|
|
238
222
|
|
|
239
223
|
// ===== TOUCH GESTURES =====
|
|
240
224
|
// 'click' fires on pointer-up over a target; 'touchstart' on pointer-down. ev: { clientX, clientY,
|
|
@@ -282,23 +266,17 @@ implement what you can with the loaded ones.
|
|
|
282
266
|
// Canvas: Canvas (retained 2D drawing baked to a texture — sprites/UI images), Bitmap
|
|
283
267
|
// Aspects: Aspect base — attachable node capabilities: node.aspect(Class, opts), custom aspects
|
|
284
268
|
// with per-frame update(dt)
|
|
285
|
-
// UI: UIScreen, Router,
|
|
286
|
-
// UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
|
|
269
|
+
// UI: UIScreen, Router, UIRow, UIColumn, UIScrollable, UISpacer, UIText, UIImage, UIVideo,
|
|
287
270
|
// UIButton, UIInput, UITextArea, UIWidget/UIModal/UIPopover/UIBottomSheet (floating
|
|
288
271
|
// overlays: HUD / dialog / anchored menu / draggable sheet), UIVirtualizedList,
|
|
289
272
|
// registerFont; UIBox (deprecated)
|
|
290
273
|
// 2D engine: Scene2D, Node2D, Camera2D, Sprite, SpriteAnimation, Tilemap, Texture2D,
|
|
291
274
|
// Shape2D + Physics2D + Trigger2D + CharacterController2D (Box2D physics, sensors,
|
|
292
|
-
// raycast/overlap queries, picking)
|
|
293
|
-
// defineScene2d (declarative *.scene2d.ts files) + cells + CameraFollow
|
|
275
|
+
// raycast/overlap queries, picking)
|
|
294
276
|
// 3D engine: Scene, Node, Camera, Mesh (box/sphere/cylinder/plane), Model (.glb) + ModelAnimation,
|
|
295
277
|
// Geometry, Material (lit/unlit/custom shaders), Texture, Light.sun(),
|
|
296
278
|
// Shape + Physics + Trigger + CharacterController (Jolt physics, raycast),
|
|
297
279
|
// Particles + dynamic/dynamicColor, Ray, Plane, Noise
|
|
298
|
-
// 3D scene files: defineScene (declarative *.scene.ts read/written by the visual scene editor;
|
|
299
|
-
// default export is a SceneHandle: load()/open()) + use/ref/make in the def,
|
|
300
|
-
// scenario aspects MoveTo/FollowPath/Spin/LookAt/PlayAnimation;
|
|
301
|
-
// *.editor.ts + EDITOR/InspectorUI are editor-plugin files — never edit or import them
|
|
302
280
|
// AR: ARScene (camera passthrough, anchors, placement gestures via addControls)
|
|
303
281
|
|
|
304
282
|
// ===== CANVAS — retained drawing baked to a texture (engine-independent) =====
|
|
@@ -337,22 +315,18 @@ c.toBitmap(): Bitmap // rasterized snapshot, usable with drawImage
|
|
|
337
315
|
|
|
338
316
|
// ===== UI COMPONENTS =====
|
|
339
317
|
// Every element is created by a global factory function (never `new`) that takes only the element's
|
|
340
|
-
// CONTENT
|
|
341
|
-
//
|
|
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)
|
|
318
|
+
// CONTENT (children array, text, src, ...). Everything else — styles, handlers — is configured by chaining:
|
|
319
|
+
// every configuring method returns the element itself, so construction reads as one chain.
|
|
346
320
|
|
|
347
321
|
// UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
|
|
348
|
-
UIRow(
|
|
322
|
+
UIRow(children) / UIColumn(children)
|
|
349
323
|
// Child management — imperative, no diffing; works before and after the element is on screen:
|
|
350
324
|
// .append(...nodes), .insert(index, ...nodes), .remove(...nodes), .setContent(nodes), .children (readonly)
|
|
351
325
|
// .setContent is the "re-render" primitive — build a fresh array (items.map(Row)) and swap it in.
|
|
352
326
|
// For long or unbounded data use UIVirtualizedList instead of setContent over a big array.
|
|
353
327
|
|
|
354
328
|
// UIScreen — root screen, always fills the device. Behaves as a UIColumn.
|
|
355
|
-
UIScreen(
|
|
329
|
+
UIScreen(children)
|
|
356
330
|
// .open() / .close() — show/close directly (single-screen apps; open() while a Router is active hides the router)
|
|
357
331
|
// .onOpen(cb), .onClose(cb) — fire on EVERY activation, not just the first: Router.push away fires onClose,
|
|
358
332
|
// popping back fires onOpen again. Anything started in onOpen (loops, intervals, sockets) MUST be stopped
|
|
@@ -360,40 +334,12 @@ UIScreen(...children)
|
|
|
360
334
|
// .onTouchStart(cb) — fires for touches anywhere on the screen; use for full-screen gestures (see TOUCH GESTURES above)
|
|
361
335
|
// .onBackPressed(cb) — Android hardware/gesture back; typically Router.pop()
|
|
362
336
|
// Screens NEVER scroll — the canonical screen is fixed chrome (header, tab bar) + ONE UIScrollable body
|
|
363
|
-
// with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
|
|
337
|
+
// with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
|
|
364
338
|
// Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops
|
|
365
339
|
|
|
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
|
-
|
|
394
340
|
// UIWidget — floating overlay, independent of screens, always position: fixed in device coordinates
|
|
395
341
|
// Persists across Router navigation — create ONCE at module scope, reuse; hidden by default.
|
|
396
|
-
UIWidget(
|
|
342
|
+
UIWidget(children)
|
|
397
343
|
// .show(), .hide(), .isShow (getter), .onTouchStart(cb), .onBackPressed(cb)
|
|
398
344
|
// extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it into
|
|
399
345
|
// a modal; null (default) = no layer, "transparent" = invisible but still blocks. A scrim tap fires
|
|
@@ -405,7 +351,7 @@ UIWidget(...children)
|
|
|
405
351
|
// UIModal — a UIWidget prewired as a dialog: USE THIS for confirm/alert dialogs
|
|
406
352
|
// Scrim on by default (overlayColor "rgba(0,0,0,0.5)"), animated show/hide (200ms fade), scrim tap and
|
|
407
353
|
// back button close it automatically. Create ONCE at module scope, like any widget.
|
|
408
|
-
UIModal(
|
|
354
|
+
UIModal(children)
|
|
409
355
|
// .show(), .hide() (animated on a modal), .isOpen (getter), .onOpen(cb), .onClose(cb), .dismissible(false)
|
|
410
356
|
// — plus the full UIWidget surface
|
|
411
357
|
// .transition(hidden) — replace the show/hide animation: `hidden` is the off-screen pose (show animates FROM
|
|
@@ -416,7 +362,7 @@ UIModal(...children)
|
|
|
416
362
|
// UIBottomSheet — a UIModal pinned to the bottom edge: USE THIS for every bottom sheet, never hand-build one
|
|
417
363
|
// Content-sized by default (as tall as its children, capped at the screen) — one position, drag down to
|
|
418
364
|
// dismiss: the action-sheet shape. Native hosts own the drag (snap, velocity, scroll handoff); web shows it static.
|
|
419
|
-
UIBottomSheet(
|
|
365
|
+
UIBottomSheet(children)
|
|
420
366
|
// .detents([0.3, 0.6, 1]) — snap positions, ascending fractions of screen height (the map-app model). Call
|
|
421
367
|
// BEFORE show(); sizes the sheet to the HIGHEST detent — lower detents show the top slice of the content.
|
|
422
368
|
// .setDetent(i) (animated), .detent (getter), .onDetentChange(cb(i)) — every settle: finger snap or setDetent()
|
|
@@ -429,33 +375,30 @@ UIBottomSheet(...children)
|
|
|
429
375
|
// Transparent intercepting scrim (outside tap dismisses; content under it can't scroll), 120ms fade,
|
|
430
376
|
// automatic placement: below the anchor, flips above near the bottom edge, clamped into the viewport.
|
|
431
377
|
// Attaches itself to Presentable.current (hides with the page it opened on).
|
|
432
|
-
UIPopover(
|
|
378
|
+
UIPopover(children)
|
|
433
379
|
// .show(anchor?) — anchor: any element, or { x: ev.clientX, y: ev.clientY } for long-press context menus
|
|
434
380
|
// .hide() — plus the full UIModal surface (isOpen, onOpen/onClose, dismissible, transition)
|
|
435
381
|
// Style the menu box yourself (width, bgColor, borderRadius); do NOT set left/top — show(anchor) owns them.
|
|
436
382
|
|
|
437
383
|
// UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a carousel
|
|
438
|
-
UIScrollable(
|
|
384
|
+
UIScrollable(children)
|
|
439
385
|
// extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean,
|
|
440
386
|
// overscrollMode ("none" | "absorb" | "default"), refreshControlColor — tints the pull-to-refresh spinner,
|
|
441
387
|
// 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.)
|
|
445
388
|
// .onScroll(cb(pos)), .onScrollRelease(cb), .onOverscroll(cb(delta))
|
|
446
389
|
// .onRefresh(async cb) — pull-to-refresh; spinner stays until the returned promise settles. Attach BEFORE the
|
|
447
390
|
// element mounts; vertical only; native hosts (web preview: no-op). UIVirtualizedList has the same contract.
|
|
448
391
|
// Note: NO programmatic scrolling (no scrollTo) — if you need scrollTo/scrollToEnd, use UIVirtualizedList
|
|
449
392
|
// Note: defaults flexShrink: 1 (scrolls instead of overflowing); wrapping ancestors still need flexShrink: 1
|
|
450
393
|
// themselves. flexGrow: 1 to fill the remaining space is still yours to set.
|
|
451
|
-
// Carousel = scrollDirection: "horizontal" + FIXED-width cards
|
|
394
|
+
// Carousel = scrollDirection: "horizontal" + FIXED-width cards (explicit width on each card, gap/px on the scrollable)
|
|
452
395
|
|
|
453
396
|
// UIVirtualizedList<T> — windowed list for LONG or unbounded data (feeds, chats, search results): only the
|
|
454
397
|
// visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
|
|
455
398
|
// is large, grows over time, or is unknown.
|
|
456
399
|
UIVirtualizedList<T>({
|
|
457
400
|
keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
|
|
458
|
-
render: (item: T) =>
|
|
401
|
+
render: (item: T) => UIElement, // builds one row; called lazily as rows enter the window
|
|
459
402
|
estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
|
|
460
403
|
overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
|
|
461
404
|
inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
|
|
@@ -504,7 +447,7 @@ player.play() // stop playback in the screen's onClose; .dispose() when gone f
|
|
|
504
447
|
|
|
505
448
|
// UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
|
|
506
449
|
// To make anything clickable (a card, a list row, an icon) — wrap it in a UIButton.
|
|
507
|
-
UIButton(
|
|
450
|
+
UIButton(children?)
|
|
508
451
|
// .onClick(cb), .onTouchStart(cb), .isPressed() // gestures: see TOUCH GESTURES above
|
|
509
452
|
// extra styles: onPressed: { bgColor, opacity, ..., duration? } — style while the finger is down;
|
|
510
453
|
// rippleColor ("default" or a Color, Android only)
|
|
@@ -543,7 +486,7 @@ UIInput() / UITextArea()
|
|
|
543
486
|
|
|
544
487
|
// UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
|
|
545
488
|
// Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
|
|
546
|
-
UIRow(title, UISpacer(), closeButton)
|
|
489
|
+
UIRow([ title, UISpacer(), closeButton ])
|
|
547
490
|
// If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
|
|
548
491
|
|
|
549
492
|
// @deprecated UIBox — legacy centered container: defaults justifyContent AND alignItems to "center"
|
|
@@ -558,13 +501,9 @@ UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }).onClick(...)
|
|
|
558
501
|
// 1. .style({...}) → declarative merge, the default. Use this 99% of the time.
|
|
559
502
|
// 2. el.style.foo = bar → direct single-property mutation after creation (hot paths),
|
|
560
503
|
// e.g. el.style.transform = `translateY(${y}px)`; el.style.foo reads it back.
|
|
561
|
-
// 3. .animateTo({..., duration, delay?, commit
|
|
504
|
+
// 3. .animateTo({..., duration, delay?, commit?}) → tween current → given values (duration in MS).
|
|
562
505
|
// Targets are COMMITTED into the style immediately; pass commit: false to play without persisting —
|
|
563
506
|
// 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.)
|
|
568
507
|
// 4. .animateFrom({..., duration, delay?}) → snap to given values, animate back to current (fade-in).
|
|
569
508
|
// Never modifies the stored style.
|
|
570
509
|
// For free-value tweens use animate() — see ANIMATION above.
|
|
@@ -642,28 +581,9 @@ label.style({ color: T.primaryColor, pl: "var(--comfort-left)" })
|
|
|
642
581
|
.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })
|
|
643
582
|
// Keys: onLandscape, onPortrait.
|
|
644
583
|
|
|
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
|
-
|
|
664
584
|
// --- Defaults that surprise ---
|
|
665
|
-
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable
|
|
666
|
-
//
|
|
585
|
+
// flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable and UIVirtualizedList default
|
|
586
|
+
// flexShrink: 1 so they scroll instead of overflowing; wrapping ancestors still default to 0).
|
|
667
587
|
// flexGrow: 0 — nothing grows along the main axis without asking; no implicit min-sizes either.
|
|
668
588
|
// alignItems: "stretch" — children FILL the cross axis by default; set a size or alignSelf to opt out.
|
|
669
589
|
// overflow: "hidden" — children are clipped to the parent's box; set overflow: "visible" to let them escape.
|
|
@@ -680,10 +600,10 @@ UIButton(UIText("Buy").style({ $pressed: { color: "#999" } })).style({ $pressed:
|
|
|
680
600
|
// ===== REUSABLE COMPONENTS =====
|
|
681
601
|
// Extract repeated UI into factory functions — they return elements you can chain methods on:
|
|
682
602
|
|
|
683
|
-
const ClickableCard = (title: string, subtitle: string) => UIButton(
|
|
603
|
+
const ClickableCard = (title: string, subtitle: string) => UIButton([
|
|
684
604
|
UIText(title).style({ fontWeight: 700, color: "white" }),
|
|
685
605
|
UIText(subtitle).style({ color: "#888", fontSize: 13 })
|
|
686
|
-
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
606
|
+
]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
|
|
687
607
|
|
|
688
608
|
// Use like any other element — chain after the call:
|
|
689
609
|
ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
@@ -692,15 +612,15 @@ ClickableCard("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => ...)
|
|
|
692
612
|
const headingStyle: Style<UIText> = { color: "#ffffff", fontSize: 24, fontWeight: 700 }
|
|
693
613
|
|
|
694
614
|
// ===== CAPTURING ELEMENT REFERENCES =====
|
|
695
|
-
// Use an assignment expression
|
|
615
|
+
// Use an assignment expression inside the children array — standard TypeScript:
|
|
696
616
|
|
|
697
617
|
let label: UIText
|
|
698
618
|
let input: UIInput
|
|
699
619
|
|
|
700
|
-
UIColumn(
|
|
620
|
+
UIColumn([
|
|
701
621
|
label = UIText("Hello"), // = both assigns the variable AND adds the element to the column
|
|
702
622
|
input = UIInput(),
|
|
703
|
-
)
|
|
623
|
+
])
|
|
704
624
|
|
|
705
625
|
// Later:
|
|
706
626
|
label.text = "Updated"
|
|
@@ -710,17 +630,15 @@ input.value // read current value
|
|
|
710
630
|
// ((button.children[0] as UIText).text vs buttonText.text).
|
|
711
631
|
|
|
712
632
|
// ===== CONDITIONAL CHILDREN =====
|
|
713
|
-
//
|
|
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))
|
|
633
|
+
// null / undefined / false in a children array is skipped — no element, no layout slot.
|
|
634
|
+
UIColumn([ header, isLoading ? spinner : null, showFooter && footer ])
|
|
717
635
|
|
|
718
636
|
// ===== SIZING: the two axes behave differently =====
|
|
719
637
|
// MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
|
|
720
638
|
// without flexGrow: 1 (no implicit min-sizes).
|
|
721
639
|
// CROSS axis: children fill the parent by default (alignItems defaults to "stretch"); set an explicit size
|
|
722
640
|
// or alignSelf to opt out.
|
|
723
|
-
// So UIRow(UIInput()) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
641
|
+
// So UIRow([ UIInput() ]) leaves the input at placeholder width — give it flexGrow: 1 to fill the row.
|
|
724
642
|
// EQUAL-width children (tab bars, button pairs): flex: 1 on each — it grows from a ZERO basis, so they end up
|
|
725
643
|
// equal. flexGrow: 1 alone splits only the LEFTOVER space on top of content-sized bases — the child with the
|
|
726
644
|
// longer label stays wider.
|
|
@@ -740,7 +658,6 @@ UIButton().onLayout(({ width }) => { buttonWidth = width })
|
|
|
740
658
|
// Position once at an interaction — never poll per frame.
|
|
741
659
|
|
|
742
660
|
// ===== 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.
|
|
744
661
|
Router.init(homeScreen, opts?: { showDefaultBackButton?: boolean }) // call once in the entry file (default false)
|
|
745
662
|
Router.push(screen) // push onto the stack, screen becomes active
|
|
746
663
|
Router.pop(to?: number) // default -1 = one back; negative = relative (-2 = back two), 0/positive = absolute
|
|
@@ -782,14 +699,6 @@ import heart from './assets/heart.svg' // or inline: asset('./assets/hear
|
|
|
782
699
|
UIImage(heart).style({ width: 24, height: 24, tintColor: "#666" }) // tintColor recolors the icon
|
|
783
700
|
// Keep inline SvgSource(`...`) only for SVG generated dynamically from data.
|
|
784
701
|
|
|
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
|
-
|
|
793
702
|
// ===== COMMON MISTAKES — DO NOT DO THESE =====
|
|
794
703
|
|
|
795
704
|
// ❌ CSS that doesn't exist here
|
|
@@ -798,13 +707,16 @@ calc(100% - 20px) // WRONG — calc() can't mix with %
|
|
|
798
707
|
lineHeight: 1.5 // WRONG — number is px (=1.5px); for a multiplier use "1.5em"
|
|
799
708
|
|
|
800
709
|
// ❌ touch handler on a non-touch element
|
|
801
|
-
UIColumn(...).onTouchStart(
|
|
710
|
+
UIColumn([...]).onTouchStart(...) // WRONG — only UIButton/UIScreen/UIWidget; wrap in UIButton
|
|
711
|
+
|
|
712
|
+
// ❌ scrollable that overflows the screen
|
|
713
|
+
// A wrapping container between the scrollable and the screen is missing flexShrink: 1
|
|
802
714
|
|
|
803
|
-
// ❌ let/const
|
|
804
|
-
UIRow(let input = UIInput()) // WRONG
|
|
715
|
+
// ❌ let/const inside children array — `let` is a statement, not an expression
|
|
716
|
+
UIRow([ let input = UIInput() ]) // WRONG
|
|
805
717
|
// ✅ declare outside, assign inside (assignment both sets the var AND appends)
|
|
806
718
|
let input: UIInput
|
|
807
|
-
UIRow(input = UIInput())
|
|
719
|
+
UIRow([ input = UIInput() ])
|
|
808
720
|
|
|
809
721
|
// ❌ setting a UIImage's content through bgImage
|
|
810
722
|
const img = UIImage("") // WRONG — empty source as a placeholder
|
|
@@ -814,30 +726,30 @@ const img = UIImage(url)
|
|
|
814
726
|
img.src = newUrl // updates the displayed image
|
|
815
727
|
|
|
816
728
|
// ❌ expecting an input to fill width like in CSS
|
|
817
|
-
UIRow(UIInput()) // WRONG — collapses to placeholder width
|
|
729
|
+
UIRow([ UIInput() ]) // WRONG — collapses to placeholder width
|
|
818
730
|
// ✅ stretch it explicitly
|
|
819
|
-
UIColumn(UIInput().style({ width: "100%" })) // cross axis
|
|
820
|
-
UIRow(input = UIInput().style({ flexGrow: 1 }), sendBtn) // main axis
|
|
731
|
+
UIColumn([ UIInput().style({ width: "100%" }) ]) // cross axis
|
|
732
|
+
UIRow([ input = UIInput().style({ flexGrow: 1 }), sendBtn ]) // main axis
|
|
821
733
|
|
|
822
734
|
// ❌ a button that should match the input's height but shrinks to its text
|
|
823
|
-
UIRow(input.style({ height: 40 }), UIButton()) // button ends up shorter
|
|
735
|
+
UIRow([ input.style({ height: 40 }), UIButton([...]) ]) // button ends up shorter
|
|
824
736
|
// ✅ give controls the same explicit height
|
|
825
|
-
UIRow(input.style({ height: 40 }), UIButton().style({ height: 40 }))
|
|
737
|
+
UIRow([ input.style({ height: 40 }), UIButton([...]).style({ height: 40 }) ])
|
|
826
738
|
|
|
827
739
|
// ❌ flexGrow: 1 for equal-width children — it splits only the LEFTOVER space, bases stay content-sized
|
|
828
|
-
UIRow(yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 })) // longer label = wider button
|
|
740
|
+
UIRow([ yes.style({ flexGrow: 1 }), no.style({ flexGrow: 1 }) ]) // longer label = wider button
|
|
829
741
|
// ✅ flex: 1 — grows from a zero basis, children end up equal
|
|
830
|
-
UIRow(yes.style({ flex: 1 }), no.style({ flex: 1 }))
|
|
742
|
+
UIRow([ yes.style({ flex: 1 }), no.style({ flex: 1 }) ])
|
|
831
743
|
|
|
832
744
|
// ❌ empty containers as spacers to align children (web habit)
|
|
833
|
-
UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
|
|
745
|
+
UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
|
|
834
746
|
// ✅ alignment is a CONTAINER property, not an extra element
|
|
835
|
-
UIRow(label).style({ justifyContent: "flex-end" })
|
|
747
|
+
UIRow([ label ]).style({ justifyContent: "flex-end" })
|
|
836
748
|
|
|
837
749
|
// ❌ empty element as a placeholder for a conditional child
|
|
838
|
-
UIRow(isGroup ? button : UIColumn()) // WRONG
|
|
750
|
+
UIRow([ isGroup ? button : UIColumn([]) ]) // WRONG
|
|
839
751
|
// ✅ null is skipped in children — no phantom element
|
|
840
|
-
UIRow(isGroup ? button : null)
|
|
752
|
+
UIRow([ isGroup ? button : null ])
|
|
841
753
|
|
|
842
754
|
// ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
|
|
843
755
|
UIImage("./photo.png") // WRONG (a plain string works only for remote http(s) URLs)
|
|
@@ -851,7 +763,7 @@ list.onEndReached(loadNextPage) // WRONG — threshold is th
|
|
|
851
763
|
list.onEndReached(600, loadNextPage)
|
|
852
764
|
|
|
853
765
|
// ❌ re-creating a UIWidget or UIVirtualizedList to "re-render"
|
|
854
|
-
const openSheet = () => UIWidget(...).show()
|
|
766
|
+
const openSheet = () => UIWidget([...]).show() // WRONG — leaks a new widget every call
|
|
855
767
|
// ✅ create once at module scope; show()/hide() the widget, setData/append/update the list
|
|
856
768
|
|
|
857
769
|
// ===== EXAMPLES =====
|
|
@@ -859,7 +771,7 @@ const openSheet = () => UIWidget(...).show() // WRONG — leaks a new wid
|
|
|
859
771
|
// === EXAMPLE 1: Single-file app ===
|
|
860
772
|
<file name="main.ts">
|
|
861
773
|
let text: UIText
|
|
862
|
-
const screen = UIScreen(
|
|
774
|
+
const screen = UIScreen([
|
|
863
775
|
text = UIText("Hello, world!").style({ mb: 16, fontWeight: 700, fontSize: 24, textAlign: "center" }),
|
|
864
776
|
UIText("Your name:").style({ textAlign: "center" }),
|
|
865
777
|
UIInput()
|
|
@@ -867,188 +779,91 @@ const screen = UIScreen(
|
|
|
867
779
|
.onChange(str => {
|
|
868
780
|
text.text = `Hello, ${str}!`
|
|
869
781
|
})
|
|
870
|
-
).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
782
|
+
]).style({ justifyContent: "center", p: 20, gap: 8 })
|
|
871
783
|
|
|
872
784
|
screen.open()
|
|
873
785
|
</file>
|
|
874
786
|
|
|
875
|
-
// === EXAMPLE 2:
|
|
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>
|
|
787
|
+
// === EXAMPLE 2: Fetching data with loading state ===
|
|
892
788
|
<file name="main.ts">
|
|
893
|
-
import { colors, font } from './tokens'
|
|
894
|
-
|
|
895
789
|
type User = { id: number; name: string; email: string }
|
|
896
790
|
|
|
897
|
-
|
|
898
|
-
|
|
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 })
|
|
907
|
-
|
|
908
|
-
const Centered = (...children: UINodeChild[]) =>
|
|
909
|
-
UIColumn(children).style({ flexGrow: 1, justifyContent: "center", alignItems: "center", gap: 12 })
|
|
910
|
-
|
|
911
|
-
let body: UIColumn
|
|
791
|
+
let statusText: UIText
|
|
792
|
+
let list: UIColumn
|
|
912
793
|
|
|
913
794
|
const loadData = async () => {
|
|
914
|
-
|
|
795
|
+
statusText.text = "Loading..."
|
|
796
|
+
|
|
915
797
|
const res = await fetch("https://jsonplaceholder.typicode.com/users")
|
|
916
798
|
if (res.status !== 200) {
|
|
917
|
-
|
|
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
|
-
)])
|
|
799
|
+
statusText.text = "Error loading data"
|
|
923
800
|
return
|
|
924
801
|
}
|
|
925
|
-
|
|
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
|
+
)
|
|
926
814
|
}
|
|
927
815
|
|
|
928
|
-
const screen = UIScreen(
|
|
929
|
-
UIText("Users").style({
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
.
|
|
934
|
-
|
|
935
|
-
|
|
816
|
+
const screen = UIScreen([
|
|
817
|
+
UIText("Users").style({ fontSize: 24, fontWeight: 700, color: "white", mb: 8 }),
|
|
818
|
+
statusText = UIText("").style({ color: "#888" }),
|
|
819
|
+
UIScrollable([
|
|
820
|
+
list = UIColumn([])
|
|
821
|
+
]).style({ flexGrow: 1 }) // the ONE scrolling body — the screen itself never scrolls
|
|
822
|
+
.onRefresh(() => loadData()) // pull-to-refresh; attached before screen.open()
|
|
823
|
+
])
|
|
824
|
+
.style({ bgColor: "black", p: 16, pt: "max(safe-top, 24px)" })
|
|
825
|
+
.onOpen(() => loadData())
|
|
936
826
|
|
|
937
827
|
screen.open()
|
|
938
828
|
</file>
|
|
939
829
|
|
|
940
|
-
// === EXAMPLE 3:
|
|
830
|
+
// === EXAMPLE 3: Simple app with navigation ===
|
|
941
831
|
<file name="home.ts">
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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" })
|
|
832
|
+
import { detailScreen } from './detail'
|
|
833
|
+
|
|
834
|
+
export const homeScreen = UIScreen([
|
|
835
|
+
UIText("Home").style({ fontSize: 24, fontWeight: 700 }),
|
|
836
|
+
UIButton([
|
|
837
|
+
UIText("Go to detail").style({ color: "white" })
|
|
838
|
+
]).style({ bgColor: "#FF4032", borderRadius: 12, p: 16, rippleColor: "default", onPressed: { opacity: 0.7 } })
|
|
839
|
+
.onClick(() => Router.push(detailScreen))
|
|
840
|
+
]).style({ p: 20, pt: "max(safe-top, 24px)", gap: 16 })
|
|
963
841
|
</file>
|
|
964
|
-
<file name="
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
842
|
+
<file name="detail.ts">
|
|
843
|
+
import chevronLeft from './assets/chevron-left.svg'
|
|
844
|
+
|
|
845
|
+
export const detailScreen = UIScreen([
|
|
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())
|
|
968
854
|
</file>
|
|
969
|
-
<file name="
|
|
970
|
-
|
|
971
|
-
|
|
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)
|
|
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>
|
|
981
859
|
</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
860
|
<file name="main.ts">
|
|
987
|
-
|
|
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
|
-
}
|
|
1037
|
-
|
|
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" })
|
|
861
|
+
import { homeScreen } from './home'
|
|
1047
862
|
|
|
1048
|
-
|
|
863
|
+
Router.init(homeScreen)
|
|
1049
864
|
</file>
|
|
1050
865
|
|
|
1051
|
-
// === EXAMPLE
|
|
866
|
+
// === EXAMPLE 4: UIBottomSheet — persistent map-style sheet with detents ===
|
|
1052
867
|
<file name="main.ts">
|
|
1053
868
|
type Place = { id: number; name: string; distance: string }
|
|
1054
869
|
const places: Place[] = [
|
|
@@ -1057,32 +872,32 @@ const places: Place[] = [
|
|
|
1057
872
|
{ id: 3, name: "Riverside Park", distance: "1.2 km" },
|
|
1058
873
|
]
|
|
1059
874
|
|
|
1060
|
-
const Row = (p: Place) => UIRow(
|
|
875
|
+
const Row = (p: Place) => UIRow([
|
|
1061
876
|
UIText(p.name).style({ color: "#111", fontSize: 16 }),
|
|
1062
877
|
UIText(p.distance).style({ color: "#888", fontSize: 14 })
|
|
1063
|
-
).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
878
|
+
]).style({ px: 16, height: 52, alignItems: "center", justifyContent: "space-between" })
|
|
1064
879
|
|
|
1065
|
-
const sheet = UIBottomSheet(
|
|
1066
|
-
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
880
|
+
const sheet = UIBottomSheet([
|
|
881
|
+
UIColumn([]).style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
|
|
1067
882
|
UIText("Nearby").style({ px: 16, fontWeight: 700, fontSize: 20, mb: 8, color: "black" }),
|
|
1068
883
|
UIScrollable(places.map(Row)).style({ flexGrow: 1 }) // scrolls at the top detent, drags the sheet below it
|
|
1069
|
-
)
|
|
884
|
+
])
|
|
1070
885
|
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim — the map stays interactive
|
|
1071
886
|
.detents([0.25, 0.6, 1]) // collapsed / half / full
|
|
1072
887
|
.dismissible(false) // drag below the lowest detent collapses, never closes
|
|
1073
888
|
.onDetentChange(i => console.log("detent", i))
|
|
1074
889
|
|
|
1075
|
-
const mapScreen = UIScreen(
|
|
890
|
+
const mapScreen = UIScreen([
|
|
1076
891
|
// the map / page content behind the sheet
|
|
1077
|
-
).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
892
|
+
]).onOpen(() => sheet.show()).onClose(() => sheet.hide())
|
|
1078
893
|
|
|
1079
894
|
mapScreen.open()
|
|
1080
895
|
|
|
1081
|
-
// An action sheet is even less: content-sized, no detents() — UIBottomSheet(rows).show(),
|
|
896
|
+
// An action sheet is even less: content-sized, no detents() — UIBottomSheet([ ...rows ]).show(),
|
|
1082
897
|
// scrim and drag-down-to-dismiss included.
|
|
1083
898
|
</file>
|
|
1084
899
|
|
|
1085
|
-
// === EXAMPLE
|
|
900
|
+
// === EXAMPLE 5: UIVirtualizedList — chat (inverted, imperative append) ===
|
|
1086
901
|
<file name="main.ts">
|
|
1087
902
|
type Msg = { id: string; text: string; mine: boolean }
|
|
1088
903
|
|
|
@@ -1094,41 +909,37 @@ const list = UIVirtualizedList<Msg>({
|
|
|
1094
909
|
keyOf: m => m.id,
|
|
1095
910
|
estimatedHeight: m => 44 + Math.ceil(m.text.length / 34) * 20,
|
|
1096
911
|
inverted: true, // newest at the bottom
|
|
1097
|
-
render: m => UIRow(
|
|
1098
|
-
UIRow(
|
|
912
|
+
render: m => UIRow([
|
|
913
|
+
UIRow([
|
|
1099
914
|
UIText(m.text).style({ color: m.mine ? "white" : "#111" })
|
|
1100
|
-
).style({
|
|
915
|
+
]).style({
|
|
1101
916
|
bgColor: m.mine ? "#FF4032" : "#EEE",
|
|
1102
917
|
px: 12, py: 8, borderRadius: 16, maxWidth: "75%"
|
|
1103
918
|
})
|
|
1104
|
-
).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
919
|
+
]).style({ px: 12, py: 4, justifyContent: m.mine ? "flex-end" : "flex-start" })
|
|
1105
920
|
}).style({ flexGrow: 1 })
|
|
1106
921
|
|
|
1107
|
-
let input:
|
|
1108
|
-
let sendBtn: UIButton
|
|
922
|
+
let input: UIInput
|
|
1109
923
|
|
|
1110
924
|
const send = () => {
|
|
1111
925
|
const text = input.value.trim()
|
|
1112
926
|
if (!text) return
|
|
1113
927
|
list.append({ id: idOf(), text, mine: true }) // O(1); inverted list auto-scrolls to it
|
|
1114
928
|
input.value = ""
|
|
1115
|
-
sendBtn.style.opacity = 0.4
|
|
1116
929
|
}
|
|
1117
930
|
|
|
1118
|
-
const screen = UIScreen(
|
|
931
|
+
const screen = UIScreen([
|
|
1119
932
|
list,
|
|
1120
|
-
UIRow(
|
|
1121
|
-
input =
|
|
1122
|
-
|
|
1123
|
-
|
|
933
|
+
UIRow([
|
|
934
|
+
input = UIInput().style({
|
|
935
|
+
flexGrow: 1, height: 40, px: 16, borderRadius: 20,
|
|
936
|
+
bgColor: "#F0F0F0", placeholder: "Message...", placeholderColor: "#999"
|
|
1124
937
|
}),
|
|
1125
|
-
|
|
1126
|
-
.style({
|
|
938
|
+
UIButton([ UIText("Send").style({ color: "white" }) ])
|
|
939
|
+
.style({ height: 40, px: 16, justifyContent: "center", bgColor: "#FF4032", borderRadius: 20 })
|
|
1127
940
|
.onClick(send)
|
|
1128
|
-
).style({ p: 8,
|
|
1129
|
-
).style({ bgColor: "white" })
|
|
1130
|
-
|
|
1131
|
-
input.onChange(v => { sendBtn.style.opacity = v.trim() ? 1 : 0.4 }) // direct style write — the hot-path form
|
|
941
|
+
]).style({ p: 8, gap: 8, alignItems: "center" })
|
|
942
|
+
]).style({ bgColor: "white" })
|
|
1132
943
|
|
|
1133
944
|
screen.open()
|
|
1134
945
|
</file>
|