lecodes-cli 0.10.4 → 0.11.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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +3117 -1575
  3. package/package.json +1 -1
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetIconMacro.ts +382 -0
  7. package/runtime/sdk/compile/assetMacro.ts +45 -0
  8. package/runtime/sdk/compile/assetName.ts +50 -0
  9. package/runtime/sdk/compile/bundler.ts +244 -0
  10. package/runtime/sdk/compile/compileProject.ts +106 -0
  11. package/runtime/sdk/compile/detectEntry.ts +125 -0
  12. package/runtime/sdk/compile/fontMacro.ts +459 -0
  13. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  14. package/runtime/sdk/compile/header.ts +55 -0
  15. package/runtime/sdk/compile/index.ts +53 -0
  16. package/runtime/sdk/compile/libraryImports.ts +52 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +78 -0
  18. package/runtime/sdk/compile/sfnt.ts +98 -0
  19. package/runtime/sdk/compile/sourcemap.ts +25 -0
  20. package/runtime/sdk/inject.ts +2 -0
  21. package/runtime/sdk/ui/NativeView.ts +3 -0
  22. package/runtime/sdk/ui/UI.ts +9 -1
  23. package/runtime/sdk/ui/UIBottomSheet.ts +10 -16
  24. package/runtime/sdk/ui/UIButton.ts +18 -16
  25. package/runtime/sdk/ui/UIContainer.ts +38 -46
  26. package/runtime/sdk/ui/UIImage.ts +8 -5
  27. package/runtime/sdk/ui/UIInput.ts +6 -6
  28. package/runtime/sdk/ui/UIModal.ts +10 -16
  29. package/runtime/sdk/ui/UINode.ts +105 -36
  30. package/runtime/sdk/ui/UIPager.ts +27 -15
  31. package/runtime/sdk/ui/UIPopover.ts +10 -16
  32. package/runtime/sdk/ui/UIScreen.ts +20 -21
  33. package/runtime/sdk/ui/UIScrollable.ts +37 -29
  34. package/runtime/sdk/ui/UISpacer.ts +2 -0
  35. package/runtime/sdk/ui/UITabs.ts +175 -0
  36. package/runtime/sdk/ui/UIText.ts +5 -0
  37. package/runtime/sdk/ui/UIVideo.ts +5 -0
  38. package/runtime/sdk/ui/UIVirtualizedList.ts +33 -39
  39. package/runtime/sdk/ui/UIWidget.ts +22 -18
  40. package/runtime/sdk/ui/fonts.ts +3 -0
  41. package/runtime/sdk/ui/router.ts +13 -0
  42. package/runtime/sdk/ui/theme.ts +21 -30
  43. package/runtime/sdk-types.json +1 -1
@@ -10,10 +10,19 @@ export interface UINode {
10
10
  readonly class: Classes<this>
11
11
  }
12
12
 
13
+ /** A child slot value: a node, or falsy (conditional rendering — falsy entries are skipped). */
13
14
  export type UINodeChild = UINode | null | undefined | false
14
15
 
16
+ /** A factory child argument: a node (or falsy for conditionals), or an array of them — arrays are
17
+ * flattened one level at the argument position, so `UIColumn(header, items.map(row), footer)`
18
+ * needs no spread. */
19
+ export type UIChildArg = UINodeChild | UINodeChild[]
20
+
21
+ /** A color: CSS-style string (`"#1c1c1e"`, `"rgba(0,0,0,0.5)"`, `"var(--primaryColor)"`) or
22
+ * packed number; `null` clears. */
15
23
  export type Color = number | string | null
16
24
 
25
+ /** Style props every element accepts. */
17
26
  export type BaseStyle = {
18
27
  opacity?: number | `${number}`,
19
28
  transform?: string | null,
@@ -29,6 +38,7 @@ export type BaseStyle = {
29
38
  name?: string,
30
39
  }
31
40
 
41
+ /** Painted-box props: background, gradient, border, radius. */
32
42
  export type DrawableStyle = {
33
43
  backgroundImage?: FetchResponse | File | string | null,
34
44
  bgGradient?: string,
@@ -62,6 +72,7 @@ export type DrawableStyle = {
62
72
  pointerEvents?: "all" | "none",
63
73
  }
64
74
 
75
+ /** Flex-layout props of a container (how it lays out its children). */
65
76
  export type ContainerStyle = {
66
77
  flexDirection?: "row" | "column",
67
78
  justifyContent?: "flex-start" | "center" | "flex-end" | "space-between" | "space-evenly",
@@ -70,6 +81,7 @@ export type ContainerStyle = {
70
81
  flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
71
82
  }
72
83
 
84
+ /** A dimension: a number (logical px) or a CSS-style unit / function string. */
73
85
  type UIValue = number |
74
86
  `${number}px` |
75
87
  `${number}vw` |
@@ -84,6 +96,8 @@ type UIValue = number |
84
96
  `clamp(${string})` |
85
97
  `var(--${string})`
86
98
 
99
+ /** Padding, with `p`/`px`/`pl`… shorthands. `safe-*` insets by the device safe area,
100
+ * `comfort-*` by the theme's comfort knobs (max of both). */
87
101
  export type PaddingStyle = {
88
102
  paddingLeft?: UIValue | "safe-left" | "comfort-left",
89
103
  paddingTop?: UIValue | "safe-top" | "comfort-top",
@@ -102,8 +116,7 @@ export type PaddingStyle = {
102
116
  py?: UIValue | "comfort-y",
103
117
  }
104
118
 
105
-
106
-
119
+ /** Margin, with `m`/`mx`/`ml`… shorthands; `"auto"` centers/pushes like CSS. */
107
120
  export type MarginStyle = {
108
121
  marginLeft?: UIValue | "auto" | "safe-left" | "comfort-left",
109
122
  marginTop?: UIValue | "auto" | "safe-top" | "comfort-top",
@@ -122,6 +135,7 @@ export type MarginStyle = {
122
135
  my?: UIValue | "auto" | "comfort-y",
123
136
  }
124
137
 
138
+ /** Box position (for `position: "absolute"`) and size constraints. */
125
139
  export type PositionStyle = {
126
140
  top?: UIValue | `${number}%` | 'safe-top' | 'comfort-top',
127
141
  left?: UIValue | `${number}%` | 'safe-left' | 'comfort-left',
@@ -137,6 +151,7 @@ export type PositionStyle = {
137
151
  minHeight?: UIValue | `${number}%` | "auto",
138
152
  }
139
153
 
154
+ /** The base style of every element: box + flex-child props. */
140
155
  export type ElementStyle = PaddingStyle & MarginStyle & BaseStyle & PositionStyle & {
141
156
  position?: "absolute" | "static" | "relative",
142
157
  aspectRatio?: number,
@@ -148,6 +163,7 @@ export type ElementStyle = PaddingStyle & MarginStyle & BaseStyle & PositionStyl
148
163
  display?: "none" | "flex"
149
164
  }
150
165
 
166
+ /** Typography props. */
151
167
  export type TextStyle = {
152
168
  textAlign?: "start" | "center" | "end" | "left" | "right"
153
169
  fontFamily?: string,
@@ -189,7 +205,7 @@ function applyBindings(el: any, fns: [string, () => any][]) {
189
205
  }
190
206
  }
191
207
 
192
- // Не надо переписывать эту функцию 10 раз, обрати внимание, что у тебя тут возвращается this
208
+ // NB: returns `this` — `.style()` must chain. Don't rewrite.
193
209
  function styleFunction(this: any, newStyle: any) {
194
210
  // `name` is a JS-node selector (read by hosts off el.name), not a layout property — extract it
195
211
  // exactly like the constructor so `.style({ name })` is equivalent to the first-arg style. Left
@@ -305,26 +321,21 @@ const classHandler: ProxyHandler<any> = {
305
321
  }
306
322
  }
307
323
 
308
- function Style() {
309
-
310
- }
311
-
312
- // Reactive style input: every primitive-valued prop also accepts a `() => value` binding that
313
- // re-applies when a signal it read changes. Nested state blocks (onPressed, onLandscape, …) stay
314
- // static — bindings are extracted at the top level only.
324
+ /** Reactive style input: every primitive-valued prop also accepts a `() => value` binding that
325
+ * re-applies when a signal it read changes. Nested state blocks (onPressed, onLandscape, …) stay
326
+ * static — bindings are extracted at the top level only. */
315
327
  export type Reactive<T> = { [K in keyof T]: NonNullable<T[K]> extends object ? T[K] : T[K] | (() => T[K]) }
316
328
 
317
- // User-defined style classes: any `$`-prefixed key in .style() declares a state block (like
318
- // onPressed, but with any name), toggled from code via the `el.class` proxy. `duration`/`delay`
319
- // make the swap transition. Class state INHERITS down the tree (CSS-`.dark`-on-body style): a
320
- // class set on a node also activates same-name `$` blocks on all descendants within the same
321
- // root — see docs/style-class-cascade-plan.md. `$pressed`/`$focused` are reserved: hosts toggle
322
- // them on press/focus (they beat other classes, lose to onPressed/onFocused), so children can
323
- // react to an ancestor's press. Instantiate with the node's own style `T` (see StyleFn below) — a
324
- // class block accepts everything the node's `.style()` does (e.g. `color` on text, layout on any
325
- // node), exactly like `onLandscape`/`onPortrait`. Narrowing it (it once was `DrawableStyle &
326
- // BaseStyle`) wrongly rejected valid props such as `$active: { color }`. Guarded by
327
- // tests/ui-types.test.ts.
329
+ /** User-defined style classes: any `$`-prefixed key in `.style()` declares a state block toggled
330
+ * from code via the `el.class` proxy; `duration`/`delay` animate the swap. Class state INHERITS
331
+ * down the tree (CSS-`.dark`-on-body style): a class set on a node also activates same-name `$`
332
+ * blocks on all descendants within the same root (docs/style-class-cascade-plan.md).
333
+ * `$pressed`/`$focused` are reserved — hosts toggle them on press/focus (they beat other
334
+ * classes, lose to onPressed/onFocused), so children can react to an ancestor's press.
335
+ *
336
+ * Instantiated with the node's FULL style `T` — a class block accepts everything the node's
337
+ * `.style()` does, like `onLandscape`. Narrowing it wrongly rejects valid props such as
338
+ * `$active: { color }`; guarded by tests/ui-types.test.ts. */
328
339
  export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number } }
329
340
 
330
341
  /** Value accepted by a `el.class` write: a boolean sets the class, a `() => boolean` binds it —
@@ -351,15 +362,9 @@ export type ClassValue = boolean | (() => boolean)
351
362
  * single-key writes are runtime-coerced with `!!`. */
352
363
  export type Classes<R> = ((classes: Record<string, ClassValue>) => R) & { [key: string]: any }
353
364
 
354
- // NB: do NOT annotate `this: R` here. `this` is a parameter position, so it makes R contravariant,
355
- // which makes every node type INVARIANT in its own type params (and in `this`). That is what made
356
- // `UIScreen<false>` unassignable to `UIScreen<boolean>` (a navigator page), and would block any
357
- // UINode subtype from being assignable to its base. Runtime `this` is already fixed by the
358
- // `styleFunction.bind(this)` in the `style` getter — the annotation bought nothing and cost variance.
359
- // R stays only in the (covariant) return, so `.style()` still returns the concrete node type for
360
- // chaining. Guarded by the type tests in tests/ui-types.test-d.ts.
361
- // Orientation state blocks — override props applied only in landscape / portrait (like a `$`-class,
362
- // keyed by device orientation instead of `el.class`). Same full-`T` rule as class blocks.
365
+ /** Orientation state blocks — override props applied only in landscape / portrait (like a
366
+ * `$`-class, keyed by device orientation instead of `el.class`). Same full-`T` rule as class
367
+ * blocks. */
363
368
  export type OrientationStyles<T> = {
364
369
  onLandscape?: T,
365
370
  onPortrait?: T,
@@ -368,11 +373,19 @@ export type OrientationStyles<T> = {
368
373
  onPortait?: T,
369
374
  }
370
375
 
376
+ // NB: do NOT annotate `this: R` on StyleFn. As a parameter it makes R contravariant, turning every
377
+ // node type INVARIANT (UIScreen<false> stopped being assignable to UIScreen<boolean>). Runtime
378
+ // `this` is already fixed by `styleFunction.bind(this)`; R stays covariant in the return, so
379
+ // `.style()` still chains. Guarded by tests/ui-types.test-d.ts.
371
380
  export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)
381
+
382
+ /** The `el.style` surface: callable — `.style({...})` merges and returns the element for
383
+ * chaining — and per-key readable/writable (`el.style.opacity = 0.5`; a `() => value` write
384
+ * installs a reactive binding). */
372
385
  export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
373
386
 
374
- // The options bag of animateTo/animateFrom: the target style plus flat meta keys. Meta keys are
375
- // stripped before the commit into _style and skipped by the hosts' style staging.
387
+ /** The `animateTo`/`animateFrom` surface: target style props plus the flat meta keys below
388
+ * (stripped before the commit into the element's style). */
376
389
  export type AnimateStyle<R, T extends object> = ((style: T & {
377
390
  /** Tween length in **milliseconds** (default 225). */
378
391
  duration?: number,
@@ -397,10 +410,11 @@ const ANIMATE_META = new Set(["duration", "delay", "layer", "commit", "loop", "l
397
410
  export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
398
411
  export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
399
412
 
413
+ /** `onLayout` payload — the node's box in PARENT-relative coordinates. */
400
414
  export type OnLayoutCallback = (layout: { left: number, top: number, width: number, height: number }) => void
401
415
 
402
- // Web-DOMRect shape on purpose (getBoundingClientRect priors must hold): all eight fields, with
403
- // right/bottom/x/y derived SDK-side so hosts only report [left, top, width, height].
416
+ /** Web-DOMRect shape on purpose (getBoundingClientRect priors must hold): all eight fields, with
417
+ * right/bottom/x/y derived SDK-side — hosts only report [left, top, width, height]. */
404
418
  export type BoundingClientRect = {
405
419
  x: number, y: number,
406
420
  left: number, top: number, right: number, bottom: number,
@@ -462,10 +476,10 @@ export class Element<T extends string> {
462
476
  return proxy
463
477
  }
464
478
 
479
+ /** Tween to the target style and commit it (unless `commit: false` or looping — a loop is an
480
+ * effect, not a state change). Meta keys: `duration`/`delay`/`loop`/… — see {@link AnimateStyle}. */
465
481
  animateTo(style: any): this{
466
482
  _creatorUI.animateTo(this._id, style)
467
- // A loop is an effect, not a state change — looping animations never commit. Meta keys
468
- // (duration/delay/…) stay out of the stored style.
469
483
  if (style.commit !== false && !style.loop) {
470
484
  for (const key in style) {
471
485
  if (!ANIMATE_META.has(key)) this._style[key] = style[key]
@@ -473,6 +487,7 @@ export class Element<T extends string> {
473
487
  }
474
488
  return this
475
489
  }
490
+ /** Tween FROM the given style to the element's current one (entrance animations). */
476
491
  animateFrom(style: any): this{
477
492
  _creatorUI.animateFrom(this._id, style)
478
493
  return this
@@ -511,6 +526,7 @@ export class Element<T extends string> {
511
526
  }
512
527
 
513
528
  protected ll?: OnLayoutCallback[]
529
+ /** Observe layout: fires after every host layout pass with the parent-relative box. */
514
530
  onLayout(onLayout: OnLayoutCallback): this {
515
531
  if (this.ll) {
516
532
  this.ll.push(onLayout)
@@ -536,6 +552,8 @@ export class Element<T extends string> {
536
552
 
537
553
  // ---- reactive children --------------------------------------------------------------------------
538
554
 
555
+ /** Reactive children: re-runs when a signal it read changes; the result is reconciled against
556
+ * the mounted children (kept nodes stay mounted, state intact). */
539
557
  export type ChildrenFn = () => UINodeChild[]
540
558
 
541
559
  // The container whose children binding is currently executing. __uiMap uses it as the cache owner;
@@ -580,6 +598,53 @@ export function __uiMap(list: any, render: (item: any, index: number) => any, sl
580
598
  return out
581
599
  }
582
600
 
601
+ // ---- factory argument dispatch ------------------------------------------------------------------
602
+
603
+ /** @internal Shared argument dispatch for the UI factories (`UIRow(...)`, `UIButton(...)`, …).
604
+ * Children are variadic; an array argument is flattened one level, so both the legacy
605
+ * `UIColumn([a, b])` and `UIColumn(header, items.map(row), footer)` work. A lone function
606
+ * argument is a reactive {@link ChildrenFn}. A plain non-node object in first position is the
607
+ * legacy style bag: it is spread over `defaults`, or — when `defaults` is null — stored as-is
608
+ * (the Element constructor mutates it in place, matching the old path). The rest-args array is
609
+ * taken over as the children array when nothing needs flattening — callers must hand it off. */
610
+ export function buildUI<R>(args: any[], defaults: any, make: (style: any, children: any[] | ChildrenFn) => R): R {
611
+ const n = args.length
612
+ const a0 = n > 0 ? args[0] : undefined
613
+ let style: any
614
+ let start = 0
615
+ if (a0 !== null && typeof a0 === "object" && !Array.isArray(a0) && !(a0 instanceof Element)) {
616
+ style = defaults ? { ...defaults, ...a0 } : a0
617
+ start = 1
618
+ } else {
619
+ style = defaults ? { ...defaults } : {}
620
+ }
621
+ if (n === start + 1) {
622
+ const a = args[start]
623
+ // lone function → reactive children; lone array → used as the children array directly
624
+ // (the legacy zero-copy path — nested arrays inside it are NOT flattened)
625
+ if (typeof a === "function" || Array.isArray(a)) return make(style, a)
626
+ }
627
+ // Single pass: reuse the rest-args array as the children array until the first array argument
628
+ // shows up, then copy the prefix once and flatten from there. Falsy entries stay — they are
629
+ // compacted at mount like always.
630
+ let flat: any[] | undefined
631
+ for (let i = start; i < n; i++) {
632
+ const a = args[i]
633
+ if (flat === undefined) {
634
+ if (Array.isArray(a)) {
635
+ flat = args.slice(start, i)
636
+ for (let j = 0; j < a.length; j++) flat.push(a[j])
637
+ }
638
+ } else if (Array.isArray(a)) {
639
+ for (let j = 0; j < a.length; j++) flat.push(a[j])
640
+ } else {
641
+ flat.push(a)
642
+ }
643
+ }
644
+ if (flat !== undefined) return make(style, flat)
645
+ return make(style, start === 0 ? args : args.slice(1))
646
+ }
647
+
583
648
  export class ContainerElement<T extends string> extends Element<T> {
584
649
  children: any[] = []
585
650
 
@@ -641,6 +706,7 @@ export class ContainerElement<T extends string> extends Element<T> {
641
706
  }
642
707
  }
643
708
 
709
+ /** Append children (falsy entries are kept in the array but never mounted). */
644
710
  append(...nodes: UINodeChild[]): this {
645
711
  if (this._id === 0) {
646
712
  this.children.push(...nodes)
@@ -660,6 +726,7 @@ export class ContainerElement<T extends string> extends Element<T> {
660
726
  this.children.push(...nodes)
661
727
  return this
662
728
  }
729
+ /** Insert children at `index` of the children array. */
663
730
  insert(index: number, ...nodes: UINodeChild[]): this {
664
731
  if (this._id === 0) {
665
732
  this.children.splice(index, 0, ...nodes)
@@ -680,6 +747,7 @@ export class ContainerElement<T extends string> extends Element<T> {
680
747
  this.children.splice(index, 0, ...nodes)
681
748
  return this
682
749
  }
750
+ /** Remove (unmount) the given children. */
683
751
  remove(...nodesToDelete: UINode[]): this {
684
752
  const set = new Set(nodesToDelete)
685
753
 
@@ -704,6 +772,7 @@ export class ContainerElement<T extends string> extends Element<T> {
704
772
  return this
705
773
  }
706
774
 
775
+ /** Replace all children — a plain array, or a function for reactive children. */
707
776
  setContent(children: UINodeChild[] | ChildrenFn): this {
708
777
  if (typeof children === "function") {
709
778
  this._bindChildren(children) // createBinding replaces a previous "#children" binding
@@ -104,7 +104,10 @@ export class PagerElement extends ContainerElement<"pager"> {
104
104
  private ncl: ((depth: number) => void)[] = [] // onChange listeners
105
105
 
106
106
  constructor(style: UIPagerStyle | null, tabs: UIScreen[]) {
107
- super("pager", style ?? {}, tabs as any)
107
+ // flexShrink: 1 default (SDK-owned, like UIScrollable's): pages are separate layout roots
108
+ // sized to the pager's box, so yoga sees no shrinkable content — without this a pager in a
109
+ // flex column overflows the screen instead of leaving room for the tab bar.
110
+ super("pager", { flexShrink: 1, ...style }, tabs as any)
108
111
  this._lens = tabs.map(() => 1)
109
112
  for (const s of this.children) this._track(s)
110
113
  }
@@ -294,13 +297,13 @@ const isScreenArg = (v: any): boolean => v != null && typeof v === "object" && v
294
297
  /** Callable factory + ambient statics. The name `UIPager` is a value (this factory) and a type
295
298
  * (the instance interface above) — a standard value/type merge. */
296
299
  interface UIPagerConstructor {
297
- /** One tab — a plain navigation stack rooted at `root` (push/pop, no tab swiping). */
298
- (root: UIScreen): UIPager
299
- /** Sibling tabs, one per screen, in order — swipe (or `select`) between them. Tabs are fixed
300
- * at construction; each starts as its own one-screen stack. */
301
- (tabs: UIScreen[]): UIPager
302
- /** Styled pager + content (a single root screen or an array of tab screens). */
303
- (style: UIPagerStyle, content: UIScreen | UIScreen[]): UIPager
300
+ /** Tabs — each argument is a screen or an array of screens (arrays flatten one level, falsy
301
+ * entries are skipped). One screen → a plain navigation stack rooted at it (push/pop, no tab
302
+ * swiping); several → swipeable sibling tabs, fixed at construction, each starting as its own
303
+ * one-screen stack. */
304
+ (...tabs: (UIScreen | UIScreen[] | false | null | undefined)[]): UIPager
305
+ /** @deprecated Style as the first argument is legacy — use chained `.style({...})`. */
306
+ (style: UIPagerStyle, content?: UIScreen | UIScreen[]): UIPager
304
307
  /** The pager that owns the currently visible screen, or `null` when the visible screen isn't
305
308
  * hosted by one. With pagers nested inside pages, resolves to the **innermost** — so ambient
306
309
  * calls act on the pager the user is actually looking at. */
@@ -316,14 +319,23 @@ interface UIPagerConstructor {
316
319
  }
317
320
 
318
321
  function pagerFactory(...args: any[]): UIPager {
319
- if (args.length === 1) {
320
- const a = args[0]
321
- if (Array.isArray(a)) return new PagerElement(null, a) as unknown as UIPager
322
- if (isScreenArg(a)) return new PagerElement(null, [a]) as unknown as UIPager
323
- return new PagerElement(a, []) as unknown as UIPager
322
+ let style: any = null
323
+ let start = 0
324
+ const a0 = args.length > 0 ? args[0] : undefined
325
+ if (a0 != null && typeof a0 === "object" && !Array.isArray(a0) && !isScreenArg(a0)) {
326
+ style = a0
327
+ start = 1
324
328
  }
325
- const content = args[1]
326
- return new PagerElement(args[0] ?? null, Array.isArray(content) ? content : content ? [content] : []) as unknown as UIPager
329
+ const tabs: any[] = []
330
+ for (let i = start; i < args.length; i++) {
331
+ const a = args[i]
332
+ if (Array.isArray(a)) {
333
+ for (let j = 0; j < a.length; j++) if (a[j]) tabs.push(a[j])
334
+ } else if (a) {
335
+ tabs.push(a)
336
+ }
337
+ }
338
+ return new PagerElement(style, tabs) as unknown as UIPager
327
339
  }
328
340
 
329
341
  /**
@@ -1,7 +1,7 @@
1
1
  import { ModalElement, type UIModal, type UIModalStyle } from "./UIModal"
2
2
  import { Presentable } from "./presentable"
3
3
  import { device } from "../runtime/device"
4
- import type { BoundingClientRect, ChildrenFn, UINodeChild } from "./UINode"
4
+ import { buildUI, type BoundingClientRect, type ChildrenFn, type UIChildArg, type UINodeChild } from "./UINode"
5
5
 
6
6
  export type UIPopoverStyle = UIModalStyle
7
7
 
@@ -87,20 +87,14 @@ export class PopoverElement extends ModalElement {
87
87
  }
88
88
  }
89
89
 
90
- export function UIPopover(): UIPopover;
90
+ const makePopover = (style: any, children: any) => new PopoverElement(style, children)
91
+
92
+ /** Create an anchored popover (see {@link UIPopover}) — create once at module scope, then
93
+ * `show(anchor)` next to an element or `{x, y}` point. Same argument forms as `UIColumn`. */
94
+ export function UIPopover(...children: UIChildArg[]): UIPopover;
91
95
  export function UIPopover(children: UINodeChild[] | ChildrenFn): UIPopover;
92
- export function UIPopover(style: UIPopoverStyle): UIPopover;
93
- export function UIPopover(style: UIPopoverStyle, children: UINodeChild[] | ChildrenFn): UIPopover;
94
- export function UIPopover(...args: [] | [UINodeChild[] | ChildrenFn | UIPopoverStyle] | [UIPopoverStyle, children: UINodeChild[] | ChildrenFn]): UIPopover {
95
- if (args.length === 0) {
96
- return new PopoverElement({}, [])
97
- } else if (args.length === 1) {
98
- if (Array.isArray(args[0]) || typeof args[0] === "function") {
99
- return new PopoverElement({}, args[0])
100
- } else {
101
- return new PopoverElement(args[0], [])
102
- }
103
- } else {
104
- return new PopoverElement(args[0], args[1])
105
- }
96
+ /** @deprecated Style as the first argument is legacy — use chained `.style({...})`. */
97
+ export function UIPopover(style: UIPopoverStyle, children?: UINodeChild[] | ChildrenFn): UIPopover;
98
+ export function UIPopover(...args: any[]): UIPopover {
99
+ return buildUI(args, null, makePopover)
106
100
  }
@@ -1,14 +1,13 @@
1
1
  import { TouchStartEvent } from "../runtime/touch"
2
2
  import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
3
- import { ContainerElement, type AnimateStyle, type BaseStyle, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type Classes, type Style, type UINodeChild, type ChildrenFn, } from "./UINode"
3
+ import { ContainerElement, buildUI, type AnimateStyle, type BaseStyle, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type Classes, type Style, type UIChildArg, type UINodeChild, type ChildrenFn, } from "./UINode"
4
4
 
5
5
  export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle & BaseStyle
6
6
 
7
- // A screen — the root the router/host mounts, sized to its slot. A screen NEVER scrolls: it is
8
- // fixed chrome (header, tab bar) plus, when the content overflows, one `UIScrollable` body child
9
- // (`flexGrow: 1`). Scroll events and pull-to-refresh live on the scroll containers
10
- // (`UIScrollable`, `UIVirtualizedList`) — see docs/scroll-forms-plan.md (the former
11
- // `makeScrollable()` screen mode was removed 2026-07-22).
7
+ /** A screen — the root the router/host mounts, sized to its slot. A screen NEVER scrolls: it is
8
+ * fixed chrome (header, tab bar) plus, when the content overflows, one `UIScrollable` body child
9
+ * (`flexGrow: 1`). Scroll events and pull-to-refresh live on the scroll containers
10
+ * (`UIScrollable`, `UIVirtualizedList`). */
12
11
  export interface UIScreen {
13
12
  readonly type: "screen",
14
13
  style: Style<this, UIScreenStyle>,
@@ -27,9 +26,12 @@ export interface UIScreen {
27
26
 
28
27
  readonly children: UINodeChild[],
29
28
 
29
+ /** Hardware/system back while this screen is current. */
30
30
  onBackPressed(callback: () => void): this
31
31
 
32
+ /** The screen became the visible destination (first open, or revealed by a pop). */
32
33
  onOpen(callback: () => void): this
34
+ /** The screen stopped being visible — closed, replaced, or covered by a push. */
33
35
  onClose(callback: () => void): this
34
36
 
35
37
  /**
@@ -121,20 +123,17 @@ export class ScreenElement extends ContainerElement<"screen"> {
121
123
  }
122
124
  }
123
125
 
124
- export function UIScreen(): UIScreen;
126
+ const makeScreen = (style: any, children: any) => new ScreenElement("screen", style, children)
127
+
128
+ /** @internal Compiler fast path (chisel `flatten_ui` raw lowering) — see UIContainer. */
129
+ export const __UIScreen = (children: UINodeChild[] | ChildrenFn): UIScreen => makeScreen({}, children)
130
+
131
+ /** Create a screen. Present it with `.open()`, or via `Router` / `UIPager`. Same argument forms
132
+ * as `UIColumn`. */
133
+ export function UIScreen(...children: UIChildArg[]): UIScreen;
125
134
  export function UIScreen(children: UINodeChild[] | ChildrenFn): UIScreen;
126
- export function UIScreen(style: UIScreenStyle): UIScreen;
127
- export function UIScreen(style: UIScreenStyle, children: UINodeChild[]): UIScreen;
128
- export function UIScreen(...args: [] | [UINodeChild[] | ChildrenFn | UIScreenStyle] | [UIScreenStyle, children: UINodeChild[] | ChildrenFn]): UIScreen {
129
- if (args.length === 0) {
130
- return new ScreenElement("screen", {}, [])
131
- } else if (args.length === 1) {
132
- if (Array.isArray(args[0]) || typeof args[0] === "function") {
133
- return new ScreenElement("screen", {}, args[0])
134
- } else {
135
- return new ScreenElement("screen", args[0], [])
136
- }
137
- } else {
138
- return new ScreenElement("screen", args[0], args[1])
139
- }
135
+ /** @deprecated Style as the first argument is legacy — use chained `.style({...})`. */
136
+ export function UIScreen(style: UIScreenStyle, children?: UINodeChild[] | ChildrenFn): UIScreen;
137
+ export function UIScreen(...args: any[]): UIScreen {
138
+ return buildUI(args, null, makeScreen)
140
139
  }
@@ -1,19 +1,26 @@
1
1
  import type { UIContainerStyle } from "./UIContainer"
2
- import { type Classes, type Style, type AnimateStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, type Color, ContainerElement } from "./UINode"
3
-
4
- // refreshControlColor tints the native pull-to-refresh spinner (pairs with onRefresh).
5
- // keyboardDismissMode: how a scroll gesture in THIS scrollable dismisses the keyboard.
6
- // "interactive" (default) — dragging over the keyboard slides it away (chat composer feel);
7
- // "scroll" — any drag dismisses the moment it starts (search-results feel);
8
- // "none" — scrolling never dismisses (tap-outside policy still applies).
9
- // snap: paging — when the drag ends, rest on a direct child's boundary along the scroll axis
10
- // (a carousel/pager is a horizontal scrollable of full-width children + snap:"start"). The value
11
- // picks where the child sits in the viewport; "none" (default) is free scrolling. Snap targets
12
- // are the children themselves, so item widths can differ (card-carousel-with-peek).
13
- export type ScrollStyle = { showScrollbar?: boolean, overscrollMode?: "none" | "absorb" | "default", refreshControlColor?: Color, keyboardDismissMode?: "interactive" | "scroll" | "none", snap?: "none" | "start" | "center" | "end" }
2
+ import { type Classes, type Style, type AnimateStyle, type UIChildArg, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, type Color, ContainerElement, buildUI } from "./UINode"
3
+
4
+ export type ScrollStyle = {
5
+ showScrollbar?: boolean,
6
+ overscrollMode?: "none" | "absorb" | "default",
7
+ /** Tint of the native pull-to-refresh spinner (pairs with `onRefresh`). */
8
+ refreshControlColor?: Color,
9
+ /** How a scroll gesture in THIS scrollable dismisses the keyboard: `"interactive"` (default) —
10
+ * dragging over the keyboard slides it away (chat feel); `"scroll"` — any drag dismisses
11
+ * immediately; `"none"` — never (tap-outside policy still applies). */
12
+ keyboardDismissMode?: "interactive" | "scroll" | "none",
13
+ /** Paging: when the drag ends, rest on a direct child's boundary along the scroll axis — the
14
+ * value picks where the child sits in the viewport; `"none"` (default) is free scrolling. Snap
15
+ * targets are the children themselves, so item widths can differ (card-carousel-with-peek); a
16
+ * carousel is a horizontal scrollable of full-width children + `snap: "start"`. */
17
+ snap?: "none" | "start" | "center" | "end"
18
+ }
14
19
 
15
20
  export type UIScrollableStyle = UIContainerStyle & ScrollStyle & { scrollDirection?: "horizontal" | "vertical" }
16
21
 
22
+ /** A scroll container — vertical by default, `scrollDirection: "horizontal"` for carousels/chip
23
+ * rows. The scrolling body of a screen (screens themselves never scroll). */
17
24
  export interface UIScrollable {
18
25
  readonly type: "scrollable",
19
26
  style: Style<this, UIScrollableStyle>,
@@ -33,8 +40,11 @@ export interface UIScrollable {
33
40
  * Classes cascade to descendants. See {@link Classes}. */
34
41
  readonly class: Classes<this>
35
42
 
43
+ /** Scroll position changed (px from the start edge). */
36
44
  onScroll(callback: (scrollPosition: number) => void): UIScrollable,
45
+ /** The scroll gesture ended (finger lifted). */
37
46
  onScrollRelease(callback: () => void): UIScrollable,
47
+ /** Pulled past the content edge by `delta` px. */
38
48
  onOverscroll(callback: (delta: number) => void): UIScrollable
39
49
  /** Pull-to-refresh: the spinner stays until the returned promise settles. Attach BEFORE the
40
50
  * node mounts (hosts read it at creation). Vertical only; native hosts — no-op on web. */
@@ -76,22 +86,20 @@ class ScrollableElement extends ContainerElement<"scrollable"> {
76
86
  }
77
87
  }
78
88
 
79
- export function UIScrollable(): UIScrollable
89
+ // flexShrink: 1 default (SDK-owned, like UIRow's flexDirection): without it a scrollable in a
90
+ // flex parent grows to its content height and overflows instead of scrolling.
91
+ const SCROLLABLE_DEFAULTS = { flexShrink: 1 } as const
92
+ const makeScrollable = (style: any, children: any) => new ScrollableElement("scrollable", style, children)
93
+
94
+ /** @internal Compiler fast path (chisel `flatten_ui` raw lowering) — see UIContainer. */
95
+ export const __UIScrollable = (children: UINodeChild[] | ChildrenFn): UIScrollable => makeScrollable({ ...SCROLLABLE_DEFAULTS }, children)
96
+
97
+ /** Create a scroll container (same argument forms as `UIColumn`). As a screen body, pair with
98
+ * `flexGrow: 1`. */
99
+ export function UIScrollable(...children: UIChildArg[]): UIScrollable
80
100
  export function UIScrollable(children: UINodeChild[] | ChildrenFn): UIScrollable
81
- export function UIScrollable(style: UIScrollableStyle): UIScrollable
82
- export function UIScrollable(style: UIScrollableStyle, children: UINodeChild[]): UIScrollable
83
- export function UIScrollable(...args: [] | [UINodeChild[] | ChildrenFn | UIScrollableStyle] | [UIScrollableStyle, children: UINodeChild[] | ChildrenFn]): UIScrollable {
84
- // flexShrink: 1 default (SDK-owned, like UIRow's flexDirection): without it a scrollable in a
85
- // flex parent grows to its content height and overflows instead of scrolling.
86
- if (args.length === 0) {
87
- return new ScrollableElement("scrollable", { flexShrink: 1 }, [])
88
- } else if (args.length === 1) {
89
- if (Array.isArray(args[0]) || typeof args[0] === "function") {
90
- return new ScrollableElement("scrollable", { flexShrink: 1 }, args[0])
91
- } else {
92
- return new ScrollableElement("scrollable", { flexShrink: 1, ...args[0] }, [])
93
- }
94
- } else {
95
- return new ScrollableElement("scrollable", { flexShrink: 1, ...args[0] }, args[1])
96
- }
101
+ /** @deprecated Style as the first argument is legacy — use chained `.style({...})`. */
102
+ export function UIScrollable(style: UIScrollableStyle, children?: UINodeChild[] | ChildrenFn): UIScrollable
103
+ export function UIScrollable(...args: any[]): UIScrollable {
104
+ return buildUI(args, SCROLLABLE_DEFAULTS, makeScrollable)
97
105
  }
@@ -2,6 +2,7 @@ import { Element, type ElementStyle, type Classes, type Style, type StyleFn } fr
2
2
 
3
3
  export type UISpacerStyle = ElementStyle
4
4
 
5
+ /** A flexible gap — `flexGrow: 1` by default, pushes its siblings apart. */
5
6
  export interface UISpacer {
6
7
  readonly type: "spacer",
7
8
  style: Style<this, UISpacerStyle>,
@@ -11,6 +12,7 @@ export interface UISpacer {
11
12
  * Classes cascade to descendants. See {@link Classes}. */
12
13
  readonly class: Classes<this>
13
14
  }
15
+ /** Create a flexible gap (`flexGrow: 1`) — e.g. between a header's title and its actions. */
14
16
  export function UISpacer(): UISpacer
15
17
  export function UISpacer(style: UISpacerStyle): UISpacer
16
18
  export function UISpacer(style?: UISpacerStyle): UISpacer {