lecodes-sdk 2.2.3 → 2.2.5

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 (61) hide show
  1. package/dist/global.d.ts +3 -1
  2. package/dist/types/animate/tween/spec.d.ts +4 -5
  3. package/dist/types/g2/Scene2D.d.ts +5 -4
  4. package/dist/types/gl/Scene.d.ts +5 -4
  5. package/dist/types/inject.d.ts +1 -1
  6. package/dist/types/server/db/fields.d.ts +6 -3
  7. package/dist/types/server/db/marci/query.d.ts +21 -2
  8. package/dist/types/server/db/types.d.ts +26 -12
  9. package/dist/types/server/errors.d.ts +4 -1
  10. package/dist/types/server/files/models.d.ts +4 -0
  11. package/dist/types/server/inject.d.ts +6 -0
  12. package/dist/types/ui/NativeView.d.ts +3 -3
  13. package/dist/types/ui/UI.d.ts +1 -1
  14. package/dist/types/ui/UILayer.d.ts +25 -18
  15. package/dist/types/ui/UINode.d.ts +8 -1
  16. package/dist/types/ui/UIScreen.d.ts +11 -1
  17. package/dist/types/ui/UIVideo.d.ts +3 -3
  18. package/dist/types/ui/UIVirtualizedList.d.ts +64 -18
  19. package/dist/types/ui/UIWidget.d.ts +24 -15
  20. package/dist/types/ui/presentable.d.ts +7 -7
  21. package/dist/types/version.d.ts +1 -1
  22. package/dist/types.json +1 -1
  23. package/package.json +2 -2
  24. package/prompts/3d-scene-files.md +2 -2
  25. package/prompts/3d-scene.md +1 -1
  26. package/prompts/dist/2d-game.md +13 -13
  27. package/prompts/dist/3d-app.md +21 -18
  28. package/prompts/dist/ar-app.md +13 -13
  29. package/prompts/dist/ui-app.md +13 -13
  30. package/prompts/ui.md +3 -1
  31. package/src/animate/tween/spec.ts +8 -13
  32. package/src/chisel.ts +3 -3
  33. package/src/compile/serverSplit.ts +5 -3
  34. package/src/g2/Scene2D.ts +9 -7
  35. package/src/gl/Scene.ts +9 -7
  36. package/src/inject.ts +1 -1
  37. package/src/server/db/defineDb.ts +17 -21
  38. package/src/server/db/fields.ts +7 -4
  39. package/src/server/db/httpTransport.ts +19 -3
  40. package/src/server/db/marci/query.ts +34 -8
  41. package/src/server/db/types.ts +33 -14
  42. package/src/server/errors.ts +4 -1
  43. package/src/server/files/db.ts +26 -7
  44. package/src/server/files/host.ts +45 -17
  45. package/src/server/files/models.ts +4 -0
  46. package/src/server/host.ts +3 -3
  47. package/src/server/inject.ts +3 -0
  48. package/src/server/runtime.ts +65 -4
  49. package/src/ui/NativeView.ts +11 -9
  50. package/src/ui/UI.ts +1 -1
  51. package/src/ui/UILayer.ts +90 -42
  52. package/src/ui/UINode.ts +33 -11
  53. package/src/ui/UIPopover.ts +3 -3
  54. package/src/ui/UIScreen.ts +50 -2
  55. package/src/ui/UIVideo.ts +11 -9
  56. package/src/ui/UIVirtualizedList.ts +159 -31
  57. package/src/ui/UIWidget.ts +41 -18
  58. package/src/ui/presentable.ts +8 -8
  59. package/src/ui/transitions.ts +1 -1
  60. package/src/version.ts +1 -1
  61. package/tests/helpers/fakeTree.ts +6 -1
@@ -14,7 +14,7 @@ import { AsyncLocalStorage } from "node:async_hooks"
14
14
  import type { ServerManifest } from "../compile/serverSplit"
15
15
  import type { AuthHost } from "./auth/host"
16
16
  import type { AuthState } from "./auth/types"
17
- import type { FilesHost } from "./files/host"
17
+ import type { FilesHost, ResourceReader } from "./files/host"
18
18
  import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
19
19
  import { setRequestProvider, type RequestContext } from "./context"
20
20
  import type { Db } from "./db/types"
@@ -55,7 +55,7 @@ export const loadServerModules = (
55
55
  // One database per project: the host syncs one schema, so the models of a second `defineDb` would not exist.
56
56
  if (out.dbs.length > 1) {
57
57
  // named by their collections (own enumerable keys): reading `$models` would seal a db that is being refused
58
- const each = out.dbs.map(db => Object.keys(db).filter(k => k !== "auth" && !k.startsWith("$") && k !== "withAuth").map(k => `db.${k}`).join(", ")).join(" | ")
58
+ const each = out.dbs.map(db => Object.keys(db).filter(k => k !== "auth" && !k.startsWith("$") && k !== "withAuth" && k !== "seed").map(k => `db.${k}`).join(", ")).join(" | ")
59
59
  throw new Error(`a project has one database, and defineDb is called ${out.dbs.length} times (${each}) — put every model in one defineDb({...}) and import that db where it is needed`)
60
60
  }
61
61
  return out
@@ -83,6 +83,19 @@ const apiStatus = (e: unknown): number | null =>
83
83
  e && typeof e === "object" && (e as { name?: string }).name === "ApiError" && typeof (e as { status?: unknown }).status === "number"
84
84
  ? (e as { status: number }).status : null
85
85
 
86
+ /** What the app is told when the database refused a write a person could have caused: the engine's own
87
+ * words — `Like.user, Like.post: the value is already taken (by the row with id 7)` — with the status
88
+ * its kind means. A taken unique value, a pointer at no row, a delete other rows forbid: 409; a row that
89
+ * is gone: 404. Anything else the database says (a bad payload, a fault) stays a 500: a mistake of the
90
+ * code, not of the person. Both transports' errors carry `kind` (marcidb-embedded's, the HTTP one's). */
91
+ const DB_ERROR_STATUS: Record<string, number> = { unique: 409, foreign_key: 409, restrict: 409, duplicate_id: 409, not_found: 404 }
92
+ const dbStatus = (e: unknown): number | null => {
93
+ if (!e || typeof e !== "object") return null
94
+ const { name, kind } = e as { name?: unknown, kind?: unknown }
95
+ if ((name !== "MarciEmbeddedError" && name !== "MarciHttpError") || typeof kind !== "string") return null
96
+ return DB_ERROR_STATUS[kind] ?? null
97
+ }
98
+
86
99
  /**
87
100
  * Build the invoker: `invoke(id, args, ctx)` runs endpoint `id` inside an AsyncLocalStorage-scoped
88
101
  * request context (what the `request` global and `db.auth` read) and returns the wire envelope.
@@ -119,7 +132,7 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
119
132
  const result = await als.run(rc, () => fn(...args))
120
133
  return withSession({ ok: true, result: result === undefined ? null : result }, rc)
121
134
  } catch (e) {
122
- const status = apiStatus(e)
135
+ const status = apiStatus(e) ?? dbStatus(e)
123
136
  if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
124
137
  if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
125
138
  onError(id, e)
@@ -161,6 +174,54 @@ export const createFileWriter = (server: LoadedServer, opts: { files?: FilesHost
161
174
  }
162
175
  }
163
176
 
177
+ /** What stops a seed — the project's own doing, in words the project's author can act on. */
178
+ export class SeedError extends Error {
179
+ constructor(message: string) { super(message); this.name = "SeedError" }
180
+ }
181
+
182
+ export type SeedOutcome =
183
+ /** the definition has no seed */
184
+ | { status: "none" }
185
+ /** the database has rows already: the seed did not run */
186
+ | { status: "skipped" }
187
+ /** the seed ran: the rows of every table of the project */
188
+ | { status: "seeded", rows: Record<string, number> }
189
+
190
+ /**
191
+ * The first rows of the project's database: `defineDb(...).seed(fn)` run ONCE, after the host has
192
+ * brought an EMPTY database to the bundle's schema — a check's, a dev backend's, a deploy's first.
193
+ * A database with any row of the project's own tables is left as it is (the seed is the start, not a
194
+ * migration). Inside the seed a file field takes a resource of the project by its path (what a path
195
+ * literal of a shared module is in the server bundle), read through `read` and stored as an upload
196
+ * would be. Throws a `SeedError` with the seed's own words when it fails — the host reports it, and the
197
+ * database is whatever the seed wrote before (the host that wants an all-or-nothing start starts over).
198
+ */
199
+ export const runSeed = async (server: LoadedServer, opts: { files?: FilesHost | null, read?: ResourceReader } = {}): Promise<SeedOutcome> => {
200
+ installProvider()
201
+ const db = server.dbs[0]
202
+ const fn = db?.$seed
203
+ if (!db || !fn) return { status: "none" }
204
+ const names = Object.keys(db.$given)
205
+ const collection = (name: string) => (db as any)[name.charAt(0).toLowerCase() + name.slice(1)]
206
+ for (const name of names) if ((await collection(name).count()) > 0) return { status: "skipped" }
207
+ const rc: RequestContext = { id: "$seed", headers: {} }
208
+ const files = opts.files ?? null
209
+ const read: ResourceReader = opts.read ?? (async () => null)
210
+ const done = files?.enabled ? files.seeding(rc, read) : () => {}
211
+ try {
212
+ await als.run(rc, () => fn(db as any))
213
+ } catch (e) {
214
+ const message = e instanceof Error ? e.message : String(e)
215
+ throw new SeedError(`seed: ${message}`)
216
+ } finally {
217
+ done()
218
+ if (files?.enabled) await als.run(rc, () => files.finish(rc))
219
+ }
220
+ const rows: Record<string, number> = {}
221
+ for (const name of names) rows[name] = await collection(name).count()
222
+ return { status: "seeded", rows }
223
+ }
224
+
164
225
  /**
165
226
  * A subscription to a channel: run its hooks (`authorize`, then `groupBy`) in the subscriber's
166
227
  * request scope. The envelope's `result` is the group the subscriber is in — `null` for a channel
@@ -187,7 +248,7 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
187
248
  if (group !== null && !isChannelGroup(group)) throw new Error(`groupBy answered ${group === undefined ? "nothing" : typeof group} — a group is a string or a number`)
188
249
  return withSession({ ok: true, result: group }, rc)
189
250
  } catch (e) {
190
- const status = apiStatus(e)
251
+ const status = apiStatus(e) ?? dbStatus(e)
191
252
  if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
192
253
  if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
193
254
  onError(channelId, e)
@@ -10,7 +10,7 @@
10
10
  // wrapper convention.
11
11
 
12
12
  import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "../runtime/channel"
13
- import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "./UILayer"
13
+ import { _surfaceWidgets, type LayerElement, type WidgetSet } from "./UILayer"
14
14
  import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
15
15
  import { _transitionId, TRANSITION_NONE } from "./transitions"
16
16
  import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
@@ -46,8 +46,8 @@ export interface NativeView extends UIElementBase<UINativeViewStyle, DrawableSty
46
46
  /** Subscribe to an event the native view emits (`map.on("markerTap", cb)`). */
47
47
  on(event: string, callback: (data?: any) => void): this
48
48
  off(event: string, callback: (data?: any) => void): this
49
- /** The UI over the view when presented: widgets over it (`Presentable.setContent`). */
50
- setContent(content: UIWidgetContent): this
49
+ /** The widgets over the view when it is presented (`Presentable.widgets`): `map.widgets(header, card)`. */
50
+ readonly widgets: WidgetSet<this>
51
51
  /** @internal The destination tuple (Presentable). */
52
52
  _dest(): DestTuple
53
53
  }
@@ -88,13 +88,15 @@ export class NativeViewElement extends Element<"native"> implements Presentable
88
88
 
89
89
  /** @internal */
90
90
  _dest(): DestTuple { return [TREE_DEST_NATIVE, this._viewId, this.viewName, this._paramsJson] }
91
- /** @internal The view's content layer (ui/UILayer.ts), made by the first setContent. */
91
+ /** @internal The view's widget layer (ui/UILayer.ts), made on the first use of `widgets`. */
92
92
  _contentLayer?: LayerElement
93
- /** The UI over the view when it is presented full screen — widgets (a header, a card, a sheet
94
- * over a map), laid out over it; touches outside a widget's box reach the view. */
95
- setContent(content: UIWidgetContent): this {
96
- _setSurfaceContent(this, content)
97
- return this
93
+ /** @internal */
94
+ _widgetSet?: WidgetSet<any>
95
+ /** The widgets over the view while it is presented — a HUD, a dialog, a sheet: `map.widgets(hud)`
96
+ * sets them, `widgets.append` / `.remove` manage them, `widgets.list` reads them. Laid out
97
+ * over it; touches outside a widget's box reach the view. */
98
+ get widgets(): WidgetSet<this> {
99
+ return _surfaceWidgets(this)
98
100
  }
99
101
 
100
102
  open(options?: PresentOptions): void {
package/src/ui/UI.ts CHANGED
@@ -10,7 +10,7 @@ export { UIVideo, type UIVideoStyle } from './UIVideo'
10
10
  export { UIInput, UITextArea, type UIInputStyle } from './UIInput'
11
11
 
12
12
  export { UIWidget, type UIWidgetStyle } from './UIWidget'
13
- export { UIOverlay, type UIWidgetContent } from './UILayer'
13
+ export { type WidgetSet, type UIWidgetContent, type UIWidgetChild } from './UILayer'
14
14
  export { UIModal, type UIModalStyle } from './UIModal'
15
15
  export { UIBottomSheet, type UIBottomSheetStyle } from './UIBottomSheet'
16
16
  export { UIPopover, type UIPopoverStyle, type UIPopoverAnchor } from './UIPopover'
package/src/ui/UILayer.ts CHANGED
@@ -1,37 +1,106 @@
1
- // The LAYER roots widgets live in when they belong to no screen (docs/plans/widgets-plan.md):
1
+ // The WIDGETS of a destination (docs/plans/widgets-plan.md): one face on every destination —
2
2
  //
3
- // · the APP layer (`UIOverlay`): above every destination, always shown — the home of what belongs
4
- // to the app rather than to a place in it: a global loader, a mini-player, a toast with actions.
5
- // `widget.show()` on a widget that has no parent appends it here first.
6
- // · a SURFACE's content layer: a `Scene`, a `Scene2D`, a `NativeView` or a `UIVideo` cannot take
7
- // children, so its widgets (a HUD, a card over a map) go in a layer of its own, shown over it
8
- // while it is presented — `scene.setContent([hud])` fills it.
3
+ // screen.widgets(dialog, sheet) scene.widgets(hud) map.widgets(header, card)
4
+ // x.widgets.append(w) / x.widgets.remove(w) / x.widgets.list
5
+ //
6
+ // A screen keeps its widgets in the tree as the TAIL of its children (the runtime's portal link:
7
+ // never a yoga child, hoisted to the outermost presented root); `children` of a screen reads its
8
+ // content only, `widgets.list` the tail. A surface — a `Scene`, a `Scene2D`, a `NativeView`, a
9
+ // `UIVideo` — cannot take children, so its widgets live in a LAYER root of their own, bound to the
10
+ // destination (`_creatorTree.setLayer`) and shown over it while it is presented. The APP layer is
11
+ // the same kind of root above every destination, always shown: where a widget with no place lands
12
+ // on `show()` (a global loader, a dialog opened from anywhere) — it has no name of its own.
9
13
  //
10
14
  // A layer is a "layer" node of the tree: a pass-through root the runtime lays out at the frame and
11
- // the host paints after the destination; its own box is never hit, only its widgets are. The
12
- // runtime learns which destination a layer belongs to through `_creatorTree.setLayer`.
15
+ // the host paints after the destination; its own box is never hit, only its widgets are.
13
16
  import { ContainerElement, type UINodeChild, type ChildrenFn } from "./UINode"
14
17
  import type { UIWidget } from "./UIWidget"
15
18
  import type { DestTuple } from "./presentable"
16
19
  import { TREE_DEST_NONE, pin, tree } from "./tree"
17
20
 
18
- export type UIWidgetContent = (UIWidget | null | undefined | false)[] | (() => (UIWidget | null | undefined | false)[])
21
+ /** A widget slot: a widget, or a falsy value (skipped). */
22
+ export type UIWidgetChild = UIWidget | null | undefined | false
23
+ /** The widgets of a destination as `widgets()` takes them: a list, or a function for reactive
24
+ * content (re-run when a signal read inside it changes, reconciled against the shown ones). */
25
+ export type UIWidgetContent = UIWidgetChild[] | (() => UIWidgetChild[])
26
+
27
+ /**
28
+ * The widgets of a destination — a dialog, a sheet, a HUD: what floats over it, laid out and
29
+ * painted in its box above everything else. Call it to set them (`screen.widgets(dialog)`, same
30
+ * argument forms as `UIColumn`: widgets, arrays of them, or ONE function for reactive content);
31
+ * `append` / `remove` manage them one at a time; `list` reads them. A widget shows with its
32
+ * destination unless its style says `display: "none"`; `show()` / `hide()` flip that, and
33
+ * `show()` puts it on top of the others.
34
+ *
35
+ * ```ts
36
+ * const home = UIScreen(content).widgets(dialog, sheet)
37
+ * scene.widgets(hud)
38
+ * scene.widgets(() => [hud, paused() && pauseMenu])
39
+ * home.widgets.append(toast); home.widgets.remove(toast)
40
+ * home.widgets.list // [dialog, sheet] — never in `children`
41
+ * ```
42
+ */
43
+ export interface WidgetSet<Owner> {
44
+ (...widgets: (UIWidgetChild | UIWidgetChild[])[]): Owner
45
+ (widgets: UIWidgetContent): Owner
46
+ /** Add widgets at the end (on top). */
47
+ append(...widgets: UIWidgetChild[]): void
48
+ /** Take widgets out (they stay valid while you hold them; a dropped one is freed). */
49
+ remove(...widgets: UIWidgetChild[]): void
50
+ /** The widgets, bottom to top — a snapshot read from the runtime. */
51
+ readonly list: UIWidget[]
52
+ }
53
+
54
+ /** @internal What a widget set drives: a screen (its widget tail) or a surface's layer. */
55
+ export interface WidgetHost {
56
+ _setWidgets(content: UIWidgetContent): void
57
+ _appendWidgets(widgets: UINodeChild[]): void
58
+ _removeWidgets(widgets: UINodeChild[]): void
59
+ _widgetList(): UIWidget[]
60
+ }
61
+
62
+ /** @internal `widgets(...)`'s arguments as content: one function → reactive, one array → the
63
+ * list, otherwise the rest arguments with arrays among them flattened (as the UI factories). */
64
+ export const _widgetContent = (args: any[]): UIWidgetContent => {
65
+ if (args.length === 1 && (typeof args[0] === "function" || Array.isArray(args[0]))) return args[0]
66
+ const flat: UIWidgetChild[] = []
67
+ for (const a of args) {
68
+ if (Array.isArray(a)) for (const w of a) flat.push(w)
69
+ else flat.push(a)
70
+ }
71
+ return flat
72
+ }
73
+
74
+ /** @internal The widget set of `owner` over `host()` (resolved at every call: a surface's layer
75
+ * is made on first use). */
76
+ export const _makeWidgetSet = <O>(owner: O, host: () => WidgetHost): WidgetSet<O> => {
77
+ const set = ((...args: any[]) => { host()._setWidgets(_widgetContent(args)); return owner }) as WidgetSet<O>
78
+ set.append = (...widgets) => { host()._appendWidgets(widgets as UINodeChild[]) }
79
+ set.remove = (...widgets) => { host()._removeWidgets(widgets as UINodeChild[]) }
80
+ Object.defineProperty(set, "list", { get: () => host()._widgetList() })
81
+ return set
82
+ }
19
83
 
20
84
  /** @internal A layer root: a container whose children are widgets. */
21
- export class LayerElement extends ContainerElement<"layer"> {
85
+ export class LayerElement extends ContainerElement<"layer"> implements WidgetHost {
22
86
  constructor() {
23
87
  super("layer", {}, [])
24
88
  }
89
+ _setWidgets(content: UIWidgetContent): void { this.setContent(content as UINodeChild[] | ChildrenFn) }
90
+ _appendWidgets(widgets: UINodeChild[]): void { this.append(...widgets) }
91
+ _removeWidgets(widgets: UINodeChild[]): void { this.remove(...widgets) }
92
+ _widgetList(): UIWidget[] { return this.children as UIWidget[] }
25
93
  }
26
94
 
27
- /** @internal A surface destination that can hold its content layer. */
95
+ /** @internal A surface destination that holds its widgets in a layer of its own. */
28
96
  export interface LayerOwner {
29
97
  _contentLayer?: LayerElement
98
+ _widgetSet?: WidgetSet<any>
30
99
  _dest(): DestTuple
31
100
  }
32
101
 
33
- /** @internal The content layer of a surface destination, made on first use and bound to the
34
- * destination (`setLayer(kind, id, layer)`); the runtime shows it while the surface is presented. */
102
+ /** @internal The layer of a surface destination, made on first use and bound to the destination
103
+ * (`setLayer(kind, id, layer)`); the runtime shows it while the surface is presented. */
35
104
  export const _destLayer = (owner: LayerOwner): LayerElement => {
36
105
  if (!owner._contentLayer) {
37
106
  const layer = new LayerElement()
@@ -43,15 +112,18 @@ export const _destLayer = (owner: LayerOwner): LayerElement => {
43
112
  return owner._contentLayer
44
113
  }
45
114
 
46
- /** @internal `setContent` of a surface: its widgets, as a list or a reactive function. */
47
- export const _setSurfaceContent = (owner: LayerOwner, content: UIWidgetContent): void => {
48
- _destLayer(owner).setContent(content as UINodeChild[] | ChildrenFn)
115
+ /** @internal The `widgets` of a surface: one set per owner, over its layer. */
116
+ export const _surfaceWidgets = <O extends LayerOwner>(owner: O): WidgetSet<O> => {
117
+ if (!owner._widgetSet) owner._widgetSet = _makeWidgetSet(owner, () => _destLayer(owner))
118
+ return owner._widgetSet as WidgetSet<O>
49
119
  }
50
120
 
51
121
  // ---- the app layer ------------------------------------------------------------------------------------
52
122
  let appLayer: LayerElement | null = null
53
123
 
54
- const layer = (): LayerElement => {
124
+ /** @internal The app layer, made on first use: above every destination, always shown — where a
125
+ * widget with no parent lands on `show()` (UIWidget), where a popover with no anchor goes. */
126
+ export const _appLayer = (): LayerElement => {
55
127
  if (!appLayer) {
56
128
  appLayer = new LayerElement()
57
129
  tree().setLayer(TREE_DEST_NONE, 0, appLayer._h.id)
@@ -62,27 +134,3 @@ const layer = (): LayerElement => {
62
134
 
63
135
  /** @internal Tests: a new fake runtime has no app layer of the previous one. */
64
136
  export const _resetOverlay = (): void => { appLayer = null }
65
-
66
- /**
67
- * The app layer: the widgets above EVERY destination, always shown — a global loader, a mini-player
68
- * that follows the user across screens, a toast with actions. Widgets that belong to a place go in
69
- * that place instead: a dialog among its screen's children (`UIScreen(..., dialog)`), a HUD in
70
- * `scene.setContent([hud])`. A widget shown with `show()` while it has no parent lands here.
71
- *
72
- * ```ts
73
- * UIOverlay.append(miniPlayer)
74
- * miniPlayer.hide() // display: none — still here, ready for show()
75
- * ```
76
- */
77
- export const UIOverlay = /*#__PURE__*/ Object.freeze({
78
- /** Add widgets at the end (on top). */
79
- append(...widgets: (UIWidget | null | undefined | false)[]): void { layer().append(...(widgets as UINodeChild[])) },
80
- /** Insert widgets at `index` (0 = the bottom of the layer). */
81
- insert(index: number, ...widgets: (UIWidget | null | undefined | false)[]): void { layer().insert(index, ...(widgets as UINodeChild[])) },
82
- /** Take widgets out of the layer (they stay valid; a dropped one is freed). */
83
- remove(...widgets: (UIWidget | null | undefined | false)[]): void { layer().remove(...(widgets as UINodeChild[])) },
84
- /** Replace the layer's widgets — a list, or a function for reactive content. */
85
- setContent(content: UIWidgetContent): void { layer().setContent(content as UINodeChild[] | ChildrenFn) },
86
- /** The layer's widgets, bottom to top (a snapshot read from the runtime). */
87
- get children(): UIWidget[] { return appLayer ? (appLayer.children as UIWidget[]) : [] },
88
- })
package/src/ui/UINode.ts CHANGED
@@ -41,6 +41,9 @@ export type Color = ColorInput | null
41
41
  /** Style props every element accepts. */
42
42
  export type BaseStyle = {
43
43
  opacity?: number | `${number}`,
44
+ /** A CSS-style list of `translate` / `scale` / `rotate`. A `%` in a translate is a percent of the
45
+ * element's own box (`translate(-50%, -50%)` centres it on its corner), kept as a percent: it
46
+ * follows the box through every layout. */
44
47
  transform?: string | null,
45
48
  /** Pivot of `transform` (CSS `transform-origin` subset): one or two values, each a keyword
46
49
  * (`left`/`center`/`right`, `top`/`center`/`bottom`), a percentage of the box, or a px length
@@ -795,11 +798,11 @@ export function buildUI<R>(args: any[], defaults: any, make: (style: any, childr
795
798
  const acceptChild = (parent: { type: string }, node: any): boolean => {
796
799
  if (!node) return false
797
800
  if (node instanceof Element) {
798
- // A widget belongs to a WINDOW, never to a box (docs/plans/widgets-plan.md decision 9): a
799
- // child of a screen root, a surface's content or the app layer only. The runtime refuses it
800
- // too; this says what to write instead.
801
- if (node.type === "widget" && parent.type !== "screen" && parent.type !== "layer") {
802
- throw new Error(`${parent.type}: a widget cannot be a child of a ${parent.type}. A widget (UIWidget / UIModal / UIBottomSheet / UIPopover) belongs to the window: put it among the screen's children — UIScreen(content, dialog) — or in scene.setContent([...]) / UIOverlay. A floating element of a box is an absolute child: UIBox(...).style({ position: "absolute", ... }), not a widget.`)
801
+ // A widget is never a CHILD (docs/plans/widgets-plan.md decision 9): it is one of the
802
+ // `widgets` of a screen or a surface — a layer's children are widgets, and only a widget set
803
+ // puts one there (acceptWidget). This says what to write instead.
804
+ if (node.type === "widget" && parent.type !== "layer") {
805
+ throw new Error(`${parent.type}: a widget (UIWidget / UIModal / UIBottomSheet / UIPopover) is not a child. It belongs to the widgets of a screen or a surface: UIScreen(content).widgets(dialog), scene.widgets(hud). A floating element of a box is an absolute child: UIBox(...).style({ position: "absolute", ... }), not a widget.`)
803
806
  }
804
807
  return true
805
808
  }
@@ -814,6 +817,16 @@ const acceptChild = (parent: { type: string }, node: any): boolean => {
814
817
  return false
815
818
  }
816
819
 
820
+ /** A widget slot of `widgets(...)` takes a widget (falsy = conditional, skipped silently);
821
+ * anything else is reported and skipped. */
822
+ export const acceptWidget = (owner: { type: string }, node: any): boolean => {
823
+ if (!node) return false
824
+ if (node instanceof Element && node.type === "widget") return true
825
+ const what = node instanceof Element ? `a ${node.type}` : typeof node === "object" ? (node.constructor?.name || "object") : typeof node
826
+ console.error(`${owner.type}.widgets: not a widget (${what}) — skipped. widgets() takes UIWidget / UIModal / UIBottomSheet / UIPopover; content goes among the children`)
827
+ return false
828
+ }
829
+
817
830
  export class ContainerElement<T extends string> extends Element<T> {
818
831
  constructor(type: T, style: any, children: any[] | ChildrenFn) {
819
832
  super(type, style)
@@ -852,13 +865,19 @@ export class ContainerElement<T extends string> extends Element<T> {
852
865
  _reconcile(next: UINodeChild[]) {
853
866
  const target: any[] = []
854
867
  for (const c of next) if (acceptChild(this, c)) target.push(c)
855
- if (this._h.id === 0) return
868
+ this._reconcileList(target, () => this.children, (i, node) => this._insertAt(i, node))
869
+ }
856
870
 
857
- const current: any[] = this.children
871
+ /** @internal The diff of `_reconcile` over any list of this node's children: `current` reads
872
+ * the mounted ones (a screen's content, or its widget tail), `insertAt` mounts one at an index
873
+ * of that list. */
874
+ _reconcileList(target: any[], current: () => any[], insertAt: (index: number, node: any) => void) {
875
+ if (this._h.id === 0) return
876
+ const mounted: any[] = current()
858
877
  const targetSet = new Set(target)
859
- const departed = current.filter(c => !targetSet.has(c))
878
+ const departed = mounted.filter(c => !targetSet.has(c))
860
879
  if (departed.length > 0) this.remove(...departed)
861
- const cur: any[] = departed.length > 0 ? this.children : current
880
+ const cur: any[] = departed.length > 0 ? current() : mounted
862
881
 
863
882
  // `cur` mirrors the mounted order; walk the target and fix mismatches in place
864
883
  for (let i = 0; i < target.length; i++) {
@@ -869,12 +888,15 @@ export class ContainerElement<T extends string> extends Element<T> {
869
888
  this.remove(node) // moved forward — remount at its new position
870
889
  cur.splice(j, 1)
871
890
  }
872
- this._insertAt(i, node)
891
+ insertAt(i, node)
873
892
  cur.splice(i, 0, node)
874
893
  }
875
894
  }
876
895
 
877
- private _insertAt(index: number, node: any): void {
896
+ /** @internal Mount `node` at `index` of the runtime's child list (a screen: its content, then
897
+ * its widgets — tree.cpp treeInsert clamps a content index into the content, a widget's into
898
+ * the tail). */
899
+ _insertAt(index: number, node: any): void {
878
900
  if (this._h.id === 0 || !node || node._h.id === 0) return
879
901
  tree().insert(this._h.id, node._h.id, index)
880
902
  pin(node)
@@ -58,7 +58,7 @@ export class PopoverElement extends ModalElement {
58
58
  }
59
59
  // A menu belongs where it opened: the anchor's root — a screen, a page, a surface's layer.
60
60
  const root = anchorRoot(anchor)
61
- if (root && this.parent !== root) root.append(this)
61
+ if (root && this.parent !== root) root._appendWidgets([this])
62
62
  } else {
63
63
  this._anchorRect = undefined // unanchored show: the user's own style owns the position
64
64
  }
@@ -92,13 +92,13 @@ export class PopoverElement extends ModalElement {
92
92
  // The root an anchor element sits in, when it is a place a widget can belong to: a screen or a
93
93
  // layer that is ATTACHED (presented, in the router, a pager's page, a shown layer — the pins find
94
94
  // it by id); null for a point, a loose subtree, or a screen that is not on any stack.
95
- const anchorRoot = (anchor: UIPopoverAnchor): { append(...nodes: any[]): unknown, type: string } | null => {
95
+ const anchorRoot = (anchor: UIPopoverAnchor): { _appendWidgets(nodes: any[]): void, type: string } | null => {
96
96
  const h = (anchor as any)._h
97
97
  if (!h || typeof h.id !== "number" || h.id === 0) return null
98
98
  let id = h.id as number
99
99
  for (let up = tree().parent(id); up !== 0; up = tree().parent(id)) id = up
100
100
  const el = byId(id) as any
101
- return el && typeof el.append === "function" && (el.type === "screen" || el.type === "layer") ? el : null
101
+ return el && typeof el._appendWidgets === "function" && (el.type === "screen" || el.type === "layer") ? el : null
102
102
  }
103
103
 
104
104
  const makePopover = (style: any, children: any) => new PopoverElement(style, children)
@@ -1,7 +1,10 @@
1
1
  import { TouchStartEvent } from "../runtime/touch"
2
2
  import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
3
3
  import { _transitionId, TRANSITION_NONE } from "./transitions"
4
- import { ContainerElement, buildUI, type BaseStyle, type ContainerStyle, type DrawableStyle, type PaddingStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn, } from "./UINode"
4
+ import { createBinding, disposeBinding } from "../core/signals"
5
+ import { ContainerElement, acceptWidget, buildUI, type BaseStyle, type ContainerStyle, type DrawableStyle, type PaddingStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn, } from "./UINode"
6
+ import { _makeWidgetSet, type UIWidgetContent, type WidgetHost, type WidgetSet } from "./UILayer"
7
+ import type { UIWidget } from "./UIWidget"
5
8
  import { TREE_DEST_SCREEN, TREE_FLAG_BACK, TREE_FLAG_INTERACTIVE, TREE_FLAG_KEEP_ALIVE, pin, tree } from "./tree"
6
9
  import { autofocusIn } from "./autofocus"
7
10
 
@@ -57,16 +60,61 @@ export interface UIScreen extends UIContainerBase<UIScreenStyle, DrawableStyle &
57
60
 
58
61
  /** Touch began on the screen; `ev.track(...)` takes over the rest of the gesture. */
59
62
  onTouchStart(callback: (ev: TouchStartEvent<UIScreen>) => void): this,
63
+
64
+ /** The screen's widgets — a dialog, a sheet, a toast: `UIScreen(content).widgets(dialog)` sets
65
+ * them, `widgets.append` / `.remove` manage them, `widgets.list` reads them. Never among
66
+ * `children`: that is the content. A widget is laid out and painted over the whole screen (on a
67
+ * pager page: over the pager too) and shows with it unless its style says `display: "none"`. */
68
+ readonly widgets: WidgetSet<this>,
60
69
  /** @internal The destination tuple (Presentable). */
61
70
  _dest(): DestTuple
62
71
  }
63
72
 
64
- export class ScreenElement extends ContainerElement<"screen"> {
73
+ export class ScreenElement extends ContainerElement<"screen"> implements WidgetHost {
65
74
  // The per-type default: a screen's background is the theme's `bg` (no theme = none, the host's).
66
75
  constructor(type: "screen", style: any, children: any) {
67
76
  super(type, { bgColor: "var(--bg)", ...style }, children)
68
77
  }
69
78
 
79
+ // ---- content vs widgets: ONE list in the runtime (the content, then the widget tail), two faces here
80
+ /** The content — the children read from the runtime without the widget tail. */
81
+ override get children(): any[] {
82
+ return super.children.filter((c: any) => c.type !== "widget")
83
+ }
84
+ private _widgetSet?: WidgetSet<this>
85
+ get widgets(): WidgetSet<this> {
86
+ if (!this._widgetSet) this._widgetSet = _makeWidgetSet(this, () => this)
87
+ return this._widgetSet
88
+ }
89
+ /** @internal */
90
+ _widgetList(): UIWidget[] {
91
+ return super.children.filter((c: any) => c.type === "widget") as UIWidget[]
92
+ }
93
+ /** @internal The widget tail; an index past every child lands at its end (tree.cpp). */
94
+ _appendWidgets(widgets: UINodeChild[]): void {
95
+ if (this._h.id === 0) return
96
+ for (const w of widgets) if (acceptWidget(this, w)) this._insertAt(tree().children(this._h.id).length, w)
97
+ }
98
+ /** @internal */
99
+ _removeWidgets(widgets: UINodeChild[]): void { this.remove(...widgets) }
100
+ /** @internal `widgets(...)`: the list replaced, or a reactive function bound as the content's is. */
101
+ _setWidgets(content: UIWidgetContent): void {
102
+ if (typeof content === "function") {
103
+ createBinding(this, "#widgets", () => {
104
+ const next = content()
105
+ const target: any[] = []
106
+ for (const w of (Array.isArray(next) ? next : [])) if (acceptWidget(this, w)) target.push(w)
107
+ // a widget's index is counted from the first widget: offset by the content (treeInsert)
108
+ this._reconcileList(target, () => this._widgetList(), (i, node) => this._insertAt(this.children.length + i, node))
109
+ })
110
+ return
111
+ }
112
+ disposeBinding(this, "#widgets")
113
+ const current = this._widgetList()
114
+ if (current.length > 0) this.remove(...current)
115
+ this._appendWidgets(content)
116
+ }
117
+
70
118
  /** @internal */
71
119
  _dest(): DestTuple { return [TREE_DEST_SCREEN, this._h.id, "", ""] }
72
120
 
package/src/ui/UIVideo.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { VideoPlayer } from "../runtime/media"
2
- import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "./UILayer"
2
+ import { _surfaceWidgets, type LayerElement, type WidgetSet } from "./UILayer"
3
3
  import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
4
4
  import { _transitionId, TRANSITION_NONE } from "./transitions"
5
5
  import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
@@ -25,8 +25,8 @@ export interface UIVideo extends UIElementBase<UIVideoStyle, DrawableStyle & Bas
25
25
  onClose(callback: () => void): this
26
26
  /** Hardware/system back while current. */
27
27
  onBack(callback: () => void): this
28
- /** The UI over the video when presented: widgets over it (`Presentable.setContent`). */
29
- setContent(content: UIWidgetContent): this
28
+ /** The widgets over the video when it is presented (`Presentable.widgets`): `video.widgets(controls)`. */
29
+ readonly widgets: WidgetSet<this>
30
30
  /** @internal The destination tuple (Presentable). */
31
31
  _dest(): DestTuple
32
32
  }
@@ -50,13 +50,15 @@ class VideoElement extends Element<"video"> implements Presentable {
50
50
 
51
51
  /** @internal */
52
52
  _dest(): DestTuple { return [TREE_DEST_VIDEO, this._h.id, "", ""] }
53
- /** @internal The video's content layer (ui/UILayer.ts), made by the first setContent. */
53
+ /** @internal The video's widget layer (ui/UILayer.ts), made on the first use of `widgets`. */
54
54
  _contentLayer?: LayerElement
55
- /** The UI over the video when it is presented full screen — widgets (controls, a title),
56
- * laid out over it; touches outside a widget's box reach the video. */
57
- setContent(content: UIWidgetContent): this {
58
- _setSurfaceContent(this, content)
59
- return this
55
+ /** @internal */
56
+ _widgetSet?: WidgetSet<any>
57
+ /** The widgets over the video while it is presented — a HUD, a dialog, a sheet: `video.widgets(hud)`
58
+ * sets them, `widgets.append` / `.remove` manage them, `widgets.list` reads them. Laid out
59
+ * over it; touches outside a widget's box reach the video. */
60
+ get widgets(): WidgetSet<this> {
61
+ return _surfaceWidgets(this)
60
62
  }
61
63
 
62
64
  open(options?: PresentOptions): void {