lecodes-sdk 2.0.7 → 2.0.9

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 (68) hide show
  1. package/dist/global.d.ts +6 -0
  2. package/dist/types/g2/Scene2D.d.ts +4 -0
  3. package/dist/types/gl/Light.d.ts +36 -1
  4. package/dist/types/gl/Scene.d.ts +4 -0
  5. package/dist/types/inject.d.ts +2 -1
  6. package/dist/types/runtime/rpc.d.ts +2 -0
  7. package/dist/types/server/context.d.ts +4 -0
  8. package/dist/types/server/db/defineDb.d.ts +1 -1
  9. package/dist/types/server/db/fields.d.ts +25 -1
  10. package/dist/types/server/db/marci/query.d.ts +39 -2
  11. package/dist/types/server/db/types.d.ts +13 -7
  12. package/dist/types/server/files/db.d.ts +39 -0
  13. package/dist/types/server/files/models.d.ts +131 -0
  14. package/dist/types/server/inject.d.ts +1 -0
  15. package/dist/types/ui/NativeView.d.ts +3 -0
  16. package/dist/types/ui/UI.d.ts +1 -0
  17. package/dist/types/ui/UILayer.d.ts +25 -0
  18. package/dist/types/ui/UIModal.d.ts +20 -16
  19. package/dist/types/ui/UIPopover.d.ts +5 -3
  20. package/dist/types/ui/UIVideo.d.ts +3 -0
  21. package/dist/types/ui/UIWidget.d.ts +25 -24
  22. package/dist/types/ui/presentable.d.ts +10 -0
  23. package/dist/types/ui/transitions.d.ts +2 -1
  24. package/dist/types/ui/tree.d.ts +1 -0
  25. package/dist/types/version.d.ts +1 -1
  26. package/dist/types.json +1 -1
  27. package/package.json +3 -2
  28. package/prompts/3d-scene-files.md +3 -3
  29. package/prompts/3d-scene.md +1 -1
  30. package/prompts/dist/3d-app.md +4 -4
  31. package/src/bridges/gl.d.ts +5 -0
  32. package/src/bridges/tree.d.ts +24 -12
  33. package/src/chisel.ts +1 -1
  34. package/src/compile/libraryImports.ts +9 -0
  35. package/src/compile/serverTypes.ts +6 -0
  36. package/src/g2/Scene2D.ts +10 -0
  37. package/src/gl/Light.ts +78 -2
  38. package/src/gl/Material.ts +3 -0
  39. package/src/gl/Scene.ts +11 -1
  40. package/src/inject.ts +2 -0
  41. package/src/runtime/rpc.ts +15 -3
  42. package/src/server/context.ts +4 -0
  43. package/src/server/db/defineDb.ts +62 -9
  44. package/src/server/db/fields.ts +21 -1
  45. package/src/server/db/httpTransport.ts +9 -1
  46. package/src/server/db/marci/query.ts +71 -4
  47. package/src/server/db/types.ts +16 -5
  48. package/src/server/files/db.ts +182 -0
  49. package/src/server/files/host.ts +496 -0
  50. package/src/server/files/models.ts +116 -0
  51. package/src/server/host.ts +17 -4
  52. package/src/server/inject.ts +1 -0
  53. package/src/server/runtime.ts +41 -0
  54. package/src/server/validate.ts +5 -0
  55. package/src/ui/NativeView.ts +11 -0
  56. package/src/ui/UI.ts +1 -0
  57. package/src/ui/UIBottomSheet.ts +8 -7
  58. package/src/ui/UILayer.ts +88 -0
  59. package/src/ui/UIModal.ts +59 -43
  60. package/src/ui/UINode.ts +9 -1
  61. package/src/ui/UIPopover.ts +23 -9
  62. package/src/ui/UIVideo.ts +11 -0
  63. package/src/ui/UIWidget.ts +70 -44
  64. package/src/ui/presentable.ts +11 -1
  65. package/src/ui/transitions.ts +36 -7
  66. package/src/ui/tree.ts +4 -0
  67. package/src/version.ts +1 -1
  68. package/tests/helpers/fakeTree.ts +7 -3
@@ -14,6 +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
18
  import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
18
19
  import { setRequestProvider, type RequestContext } from "./context"
19
20
  import type { Db } from "./db/types"
@@ -69,6 +70,9 @@ export type InvokerOptions = {
69
70
  onError?: (id: string, error: unknown) => void
70
71
  /** The auth host (./auth/host.ts): resolves the bearer token into `ctx.auth` before each call. Absent = no sessions. */
71
72
  auth?: AuthHost | null
73
+ /** The files host (./files/host.ts): the uploads of a call become `File` arguments, and what the call
74
+ * left unused — or deleted — is cleaned up after it. Absent = a call with files is refused. */
75
+ files?: FilesHost | null
72
76
  }
73
77
 
74
78
  // The bundle's ApiError is a different class object than ours (its own SDK copy) — duck-type it.
@@ -96,6 +100,7 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
96
100
  const onError = opts.onError ?? ((id, e) => console.error(`[server] ${id}:`, e))
97
101
  const params = server.manifest.params as Record<string, ParamSchema[]> | undefined
98
102
  const auth = opts.auth ?? null
103
+ const files = opts.files ?? null
99
104
 
100
105
  return async (id: string, args: unknown[], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
101
106
  const fn = server.endpoints.get(id)
@@ -103,6 +108,9 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
103
108
  if (!Array.isArray(args)) return { ok: false, status: 400, message: "args must be an array" }
104
109
  const rc: RequestContext = { ...ctx, id }
105
110
  try {
111
+ // an upload is an argument like any other once it is adopted: validated, then passed
112
+ if (files) args = await files.adopt(args, rc)
113
+ else if (rc.uploads?.length) return { ok: false, status: 400, message: "this server takes no files" }
106
114
  if (validate && params?.[id]) validateArgs(params[id], args)
107
115
  if (auth) await auth.resolve(rc)
108
116
  const result = await als.run(rc, () => fn(...args))
@@ -113,6 +121,39 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
113
121
  if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
114
122
  onError(id, e)
115
123
  return withSession({ ok: false, status: 500, message: "Internal error" }, rc)
124
+ } finally {
125
+ // in the request's scope: the urls of what the call returned are already made, this is the cleanup
126
+ if (files) await als.run(rc, () => files.finish(rc))
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * A file field of one row written from OUTSIDE the project's code — a data browser's form. It goes
133
+ * the way an endpoint's write does, through the bundle's own db: `args` are `[model, id, key, value]`,
134
+ * the value what an endpoint would write to the field — an upload of this call (`{ "$file": i }`), a
135
+ * file the row already has (`{ url }`, kept), a list of them, or null. So the field's rule (`t.image()`),
136
+ * the quota and the removal of what is no longer listed are the same.
137
+ */
138
+ export const createFileWriter = (server: LoadedServer, opts: { files?: FilesHost | null } = {}) => {
139
+ installProvider()
140
+ const files = opts.files ?? null
141
+ return async (args: unknown[], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
142
+ const refuse = (message: string): InvokeResult => ({ ok: false, status: 400, message })
143
+ if (!files?.enabled) return refuse("this project stores no files — a model needs a t.file() field")
144
+ const rc: RequestContext = { ...ctx, id: "$file" }
145
+ try {
146
+ const [model, id, key, value] = await files.adopt(args, rc)
147
+ const db = server.dbs.find(d => d.$files.some(f => f.model === model && f.key === key))
148
+ if (!db) return refuse(`${model}.${key} is not a file field`)
149
+ const name = model as string
150
+ await als.run(rc, () => (db as any)[name.charAt(0).toLowerCase() + name.slice(1)].update(id, { [key as string]: value }))
151
+ return { ok: true, result: null }
152
+ } catch (e) {
153
+ // whoever asks is the project's developer: the reason as it is
154
+ return { ok: false, status: apiStatus(e) ?? 500, message: (e as Error)?.message ?? String(e) }
155
+ } finally {
156
+ await als.run(rc, () => files.finish(rc))
116
157
  }
117
158
  }
118
159
  }
@@ -12,6 +12,8 @@ export type Schema =
12
12
  | { t: "tuple", items: Schema[], rest?: Schema }
13
13
  | { t: "object", props: Record<string, Schema>, optional?: string[], index?: Schema }
14
14
  | { t: "union", of: Schema[] }
15
+ /** A `File` parameter: an upload of this very call (./files/host.ts) — nothing the app can spell in JSON. */
16
+ | { t: "file" }
15
17
 
16
18
  /** One endpoint parameter: its schema and whether it may be omitted (`?`, default value, rest). */
17
19
  export type ParamSchema = { name: string, schema: Schema, optional?: boolean, rest?: boolean }
@@ -61,6 +63,9 @@ export const check = (schema: Schema, value: unknown, path: string): string | nu
61
63
  }
62
64
  return null
63
65
  }
66
+ case "file":
67
+ return (globalThis as { __lecodesFiles?: { uploaded(v: unknown): string | undefined } }).__lecodesFiles?.uploaded(value)
68
+ ? null : `${path}: expected a file, got ${typeName(value)}`
64
69
  case "union": {
65
70
  const errors: string[] = []
66
71
  for (const s of schema.of) { const e = check(s, value, path); if (!e) return null; errors.push(e) }
@@ -10,6 +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
14
  import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
14
15
  import { _transitionId, TRANSITION_NONE } from "./transitions"
15
16
  import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
@@ -45,6 +46,8 @@ export interface NativeView extends UIElementBase<UINativeViewStyle, DrawableSty
45
46
  /** Subscribe to an event the native view emits (`map.on("markerTap", cb)`). */
46
47
  on(event: string, callback: (data?: any) => void): this
47
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
48
51
  /** @internal The destination tuple (Presentable). */
49
52
  _dest(): DestTuple
50
53
  }
@@ -85,6 +88,14 @@ export class NativeViewElement extends Element<"native"> implements Presentable
85
88
 
86
89
  /** @internal */
87
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. */
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
98
+ }
88
99
 
89
100
  open(options?: PresentOptions): void {
90
101
  if (!tree().isViewSupported(this.viewName)) {
package/src/ui/UI.ts CHANGED
@@ -10,6 +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
14
  export { UIModal, type UIModalStyle } from './UIModal'
14
15
  export { UIBottomSheet, type UIBottomSheetStyle } from './UIBottomSheet'
15
16
  export { UIPopover, type UIPopoverStyle, type UIPopoverAnchor } from './UIPopover'
@@ -67,6 +67,7 @@ export class BottomSheetElement extends ModalElement {
67
67
  // The inherited show/hide pose animates ONLY the scrim: sheet geometry rides the
68
68
  // sheetDetent style key, never the pose system.
69
69
  this._transition = { duration: SHEET_ANIM_MS }
70
+ this._syncPose()
70
71
  this._addFlags(TREE_FLAG_DETENT)
71
72
  }
72
73
 
@@ -97,7 +98,7 @@ export class BottomSheetElement extends ModalElement {
97
98
  setDetent(index: number): void {
98
99
  const clamped = Math.max(0, Math.min(Math.floor(index), this._detents.length - 1))
99
100
  this._detent = clamped
100
- if (!this._open) return // applied by the next show()
101
+ if (!this._shown) return // applied by the next show()
101
102
  this.style({ sheetDetent: clamped } as UIBottomSheetStyle)
102
103
  for (const callback of this._detentListeners) callback(clamped)
103
104
  }
@@ -107,20 +108,20 @@ export class BottomSheetElement extends ModalElement {
107
108
  return this
108
109
  }
109
110
 
110
- show(): void {
111
- if (this._open) return
111
+ override show(): void {
112
+ if (this._shown) return
112
113
  // Before the mount, so the built node carries the target and hosts play the entrance to it.
113
114
  this.style({ sheetDetent: this._detent } as UIBottomSheetStyle)
114
115
  super.show()
115
116
  }
116
117
 
117
- hide(): void {
118
- if (!this._open) return
119
- this.style({ sheetDetent: -1 } as UIBottomSheetStyle) // hosts slide out; unmount follows
118
+ override hide(): void {
119
+ if (!this._shown) return
120
+ this.style({ sheetDetent: -1 } as UIBottomSheetStyle) // hosts slide out; the hide follows
120
121
  super.hide()
121
122
  }
122
123
 
123
- dismissible(enabled: boolean): this {
124
+ override dismissible(enabled: boolean): this {
124
125
  super.dismissible(enabled)
125
126
  this.style({ sheetDismissible: enabled ? 1 : 0 } as UIBottomSheetStyle)
126
127
  return this
@@ -0,0 +1,88 @@
1
+ // The LAYER roots widgets live in when they belong to no screen (docs/plans/widgets-plan.md):
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.
9
+ //
10
+ // 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`.
13
+ import { ContainerElement, type UINodeChild, type ChildrenFn } from "./UINode"
14
+ import type { UIWidget } from "./UIWidget"
15
+ import type { DestTuple } from "./presentable"
16
+ import { TREE_DEST_NONE, pin, tree } from "./tree"
17
+
18
+ export type UIWidgetContent = (UIWidget | null | undefined | false)[] | (() => (UIWidget | null | undefined | false)[])
19
+
20
+ /** @internal A layer root: a container whose children are widgets. */
21
+ export class LayerElement extends ContainerElement<"layer"> {
22
+ constructor() {
23
+ super("layer", {}, [])
24
+ }
25
+ }
26
+
27
+ /** @internal A surface destination that can hold its content layer. */
28
+ export interface LayerOwner {
29
+ _contentLayer?: LayerElement
30
+ _dest(): DestTuple
31
+ }
32
+
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. */
35
+ export const _destLayer = (owner: LayerOwner): LayerElement => {
36
+ if (!owner._contentLayer) {
37
+ const layer = new LayerElement()
38
+ const [kind, id] = owner._dest()
39
+ tree().setLayer(kind, id, layer._h.id)
40
+ pin(layer) // the runtime holds it (its owner's content) — findable by id while it does
41
+ owner._contentLayer = layer
42
+ }
43
+ return owner._contentLayer
44
+ }
45
+
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)
49
+ }
50
+
51
+ // ---- the app layer ------------------------------------------------------------------------------------
52
+ let appLayer: LayerElement | null = null
53
+
54
+ const layer = (): LayerElement => {
55
+ if (!appLayer) {
56
+ appLayer = new LayerElement()
57
+ tree().setLayer(TREE_DEST_NONE, 0, appLayer._h.id)
58
+ pin(appLayer)
59
+ }
60
+ return appLayer
61
+ }
62
+
63
+ /** @internal Tests: a new fake runtime has no app layer of the previous one. */
64
+ 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/UIModal.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { type UIWidget, type UIWidgetStyle, WidgetElement } from "./UIWidget"
2
2
  import { buildUI, type BaseStyle, type ChildrenFn, type Color, type DrawableStyle, type UIChildArg, type UINodeChild } from "./UINode"
3
+ import type { TransitionPose } from "./presentable"
4
+ import { _widgetPoseId } from "./transitions"
5
+ import { tree } from "./tree"
3
6
 
4
7
  export type UIModalStyle = UIWidgetStyle
5
8
 
@@ -16,17 +19,20 @@ const MODAL_ANIM_MS = 200
16
19
 
17
20
  /**
18
21
  * A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
19
- * (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), animates on `show()`/`hide()`
20
- * (a 200 ms fade by default — swap the pose via `transition()`; the exit plays with
21
- * `commit: false`, so the style is intact for the next show), and closes itself on a scrim tap
22
- * or the Android back button (disable via `dismissible(false)`). Everything else is a plain
23
- * widget — same styling, children, touch and `attachTo()` surface; create it **once** at module
24
- * scope and reuse it.
22
+ * (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), animates on `show()`/`hide()` and on
23
+ * a state block flipping its `display` (a 200 ms fade by default — swap the pose via
24
+ * `transition()`; the runtime plays it as tracks that never touch the style), and closes itself
25
+ * on a scrim tap or the Android back button (disable via `dismissible(false)`). Everything else
26
+ * is a plain widget: declare it where it belongs — among its screen's children, hidden until a
27
+ * tap (`display: "none"`), or shown with `show()` from anywhere, which puts a parentless modal in
28
+ * the app layer. A dialog bound to data (its display from a binding) is declared
29
+ * `dismissible(false)` and routes `onOverlayTap` / `onBack` into the data's dismiss, so the data
30
+ * stays what the screen shows.
25
31
  */
26
32
  export interface UIModal extends UIWidget {
27
- /** Mount as an overlay and play the entrance transition. No-op while already open. */
33
+ /** Show and play the entrance transition. No-op while already open. */
28
34
  show(): void,
29
- /** Play the exit transition, then unmount. No-op while closed or already closing. */
35
+ /** Play the exit transition, then hide. No-op while closed or already closing. */
30
36
  hide(): void,
31
37
  /** True from `show()` until `hide()` starts (already false during the exit animation). */
32
38
  readonly isOpen: boolean,
@@ -43,73 +49,82 @@ export interface UIModal extends UIWidget {
43
49
  */
44
50
  transition(hidden: UIModalTransition): this,
45
51
 
46
- /** Called when `show()` mounts the modal. */
52
+ /** Called when the modal opens — `show()`, or a state block flipping its display. */
47
53
  onOpen(callback: () => void): this,
48
- /** Called when the modal starts closing — scrim tap, back button, or a `hide()` call. */
54
+ /** Called when the modal starts closing — scrim tap, back button, a `hide()` call, or a state
55
+ * block flipping its display. */
49
56
  onClose(callback: () => void): this,
50
57
  /** `dismissible(false)` keeps scrim taps and the back button from closing the modal
51
- * (forced-choice dialogs). Default `true`. */
58
+ * (forced-choice dialogs, a dialog whose display is bound to data). Default `true`. */
52
59
  dismissible(enabled: boolean): this,
53
60
  }
54
61
 
55
62
  export class ModalElement extends WidgetElement {
56
- _open = false
57
63
  _dismissible = true
58
64
  _transition: UIModalTransition = { opacity: 0 }
59
- _closeTimer?: ReturnType<typeof setTimeout>
60
65
  readonly _openListeners: (() => void)[] = []
61
66
  readonly _closeListeners: (() => void)[] = []
67
+ private _poseObj?: TransitionPose
68
+ private _poseKey = ""
62
69
 
63
70
  constructor(style: UIModalStyle, children: UINodeChild[] | ChildrenFn) {
64
71
  super("widget", { overlayColor: "rgba(0, 0, 0, 0.5)", ...style }, children)
65
72
  this.onOverlayTap(() => { if (this._dismissible) this.hide() })
66
73
  this.onBack(() => { if (this._dismissible) this.hide() })
74
+ this._syncPose()
67
75
  }
68
76
 
69
- // The full animate argument: hidden pose + duration, plus the scrim fade — only when the
70
- // modal actually has a scrim (overlayColor: null opts out; animating a null scrim would
71
- // create the intercepting layer) and the pose doesn't drive the scrim itself.
72
- private _pose(extra?: Record<string, unknown>): Record<string, unknown> {
73
- const pose: Record<string, unknown> = { duration: MODAL_ANIM_MS, ...this._transition, ...extra }
74
- const overlayColor = (this._style as UIModalStyle).overlayColor
75
- if (pose.overlayColor === undefined && overlayColor !== undefined && overlayColor !== null) {
76
- pose.overlayColor = "transparent"
77
- }
77
+ // The pose the runtime plays on a display flip: the hidden pose + its duration, plus the scrim
78
+ // fade — only when the modal actually has a scrim (overlayColor: null opts out; a track on a
79
+ // null scrim would create the intercepting layer) and the pose doesn't drive the scrim itself.
80
+ private _pose(): TransitionPose {
81
+ const t = this._transition
82
+ const pose: TransitionPose = { duration: t.duration ?? MODAL_ANIM_MS }
83
+ if (t.transform !== undefined) pose.transform = t.transform as string
84
+ if (t.opacity !== undefined) pose.opacity = t.opacity as number
85
+ const scrim = t.overlayColor !== undefined ? t.overlayColor : (this._style as UIModalStyle).overlayColor
86
+ if (t.overlayColor !== undefined) { if (t.overlayColor !== null) pose.overlayColor = String(t.overlayColor) }
87
+ else if (scrim !== undefined && scrim !== null) pose.overlayColor = "transparent"
78
88
  return pose
79
89
  }
80
90
 
81
- private get _duration(): number {
82
- return this._transition.duration ?? MODAL_ANIM_MS
91
+ /** @internal Register the pose with the runtime when it changed (ctor, transition(), a show /
92
+ * hide — the scrim may have been restyled since). */
93
+ _syncPose(): void {
94
+ if (this._h.id === 0) return
95
+ const pose = this._pose()
96
+ const key = JSON.stringify(pose)
97
+ if (key === this._poseKey) return
98
+ this._poseKey = key
99
+ this._poseObj = pose
100
+ tree().setWidgetPose(this._h.id, _widgetPoseId(pose))
83
101
  }
84
102
 
85
103
  transition(hidden: UIModalTransition): this {
86
104
  this._transition = hidden
105
+ this._syncPose()
87
106
  return this
88
107
  }
89
108
 
90
- show(): void {
91
- if (this._open) return
92
- if (this._closeTimer !== undefined) { // reopened mid exit: finish that hide now
93
- clearTimeout(this._closeTimer)
94
- this._closeTimer = undefined
95
- super.hide()
96
- }
97
- this._open = true
109
+ override show(): void {
110
+ if (this._shown) return
111
+ this._syncPose()
98
112
  super.show()
99
- this.animateFrom(this._pose())
100
- for (const callback of this._openListeners) callback()
101
113
  }
102
114
 
103
- hide(): void {
104
- if (!this._open) return
105
- this._open = false
106
- this.animateTo(this._pose({ commit: false }))
107
- this._closeTimer = setTimeout(() => { this._closeTimer = undefined; super.hide() }, this._duration)
108
- for (const callback of this._closeListeners) callback()
115
+ override hide(): void {
116
+ if (!this._shown) return
117
+ this._syncPose()
118
+ super.hide()
109
119
  }
110
120
 
111
121
  get isOpen(): boolean {
112
- return this._open
122
+ return this._shown
123
+ }
124
+
125
+ /** @internal onOpen / onClose follow the intent, whoever flipped it. */
126
+ override _shownChanged(shown: boolean): void {
127
+ for (const callback of (shown ? this._openListeners : this._closeListeners).slice()) callback()
113
128
  }
114
129
 
115
130
  onOpen(callback: () => void): this {
@@ -128,8 +143,9 @@ export class ModalElement extends WidgetElement {
128
143
 
129
144
  const makeModal = (style: any, children: any) => new ModalElement(style, children)
130
145
 
131
- /** Create a modal dialog (see {@link UIModal}) — create once at module scope, then
132
- * `show()`/`hide()`. Same argument forms as `UIColumn`. */
146
+ /** Create a modal dialog (see {@link UIModal}): declare it among its screen's children with
147
+ * `display: "none"` and `show()` it from a tap, or `show()` a parentless one from anywhere.
148
+ * Same argument forms as `UIColumn`. */
133
149
  export function UIModal(...children: UIChildArg[]): UIModal;
134
150
  export function UIModal(children: UINodeChild[] | ChildrenFn): UIModal;
135
151
  export function UIModal(...args: any[]): UIModal {
package/src/ui/UINode.ts CHANGED
@@ -793,7 +793,15 @@ export function buildUI<R>(args: any[], defaults: any, make: (style: any, childr
793
793
  * names the usual cause (both come from code written for the older SDK). */
794
794
  const acceptChild = (parent: { type: string }, node: any): boolean => {
795
795
  if (!node) return false
796
- if (node instanceof Element) return true
796
+ if (node instanceof Element) {
797
+ // A widget belongs to a WINDOW, never to a box (docs/plans/widgets-plan.md decision 9): a
798
+ // child of a screen root, a surface's content or the app layer only. The runtime refuses it
799
+ // too; this says what to write instead.
800
+ if (node.type === "widget" && parent.type !== "screen" && parent.type !== "layer") {
801
+ 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.`)
802
+ }
803
+ return true
804
+ }
797
805
  const hint =
798
806
  typeof node === "function" ? "reactive children are the factory's only argument: UIColumn(() => [ ... ])"
799
807
  // by its backing fields: in a bundle chisel drops the accessors nobody reads (`finished`)
@@ -1,7 +1,7 @@
1
1
  import { ModalElement, type UIModal, type UIModalStyle } from "./UIModal"
2
- import { Presentable } from "./presentable"
3
2
  import { device } from "../runtime/device"
4
3
  import { buildUI, type BoundingClientRect, type ChildrenFn, type UIChildArg, type UINodeChild } from "./UINode"
4
+ import { byId, tree } from "./tree"
5
5
 
6
6
  export type UIPopoverStyle = UIModalStyle
7
7
 
@@ -22,9 +22,11 @@ const VIEWPORT_MARGIN = 8 // minimum distance kept to every screen edge
22
22
  * tooltips; create it **once** at module scope and reuse it.
23
23
  */
24
24
  export interface UIPopover extends UIModal {
25
- /** Position next to `anchor` (an element or an `{x, y}` point), attach to
26
- * `Presentable.current` (unless an owner was set via `attachTo`), and play the entrance
27
- * transition. Without an anchor the popover shows wherever its own style puts it. */
25
+ /** Position next to `anchor` (an element or an `{x, y}` point), put the popover where the
26
+ * anchor is — the anchor's screen, page or surface content, found through the tree, so the
27
+ * menu lands where it was opened and goes with it — and play the entrance transition. A
28
+ * popover with no anchor and no parent goes to the app layer; without an anchor it shows
29
+ * wherever its own style puts it. */
28
30
  show(anchor?: UIPopoverAnchor): void,
29
31
  }
30
32
 
@@ -41,7 +43,7 @@ export class PopoverElement extends ModalElement {
41
43
  this.onLayout(({ width, height }) => this._place(width, height))
42
44
  }
43
45
 
44
- show(anchor?: UIPopoverAnchor): void {
46
+ override show(anchor?: UIPopoverAnchor): void {
45
47
  if (anchor) {
46
48
  const r = "getBoundingClientRect" in anchor
47
49
  ? anchor.getBoundingClientRect()
@@ -54,18 +56,18 @@ export class PopoverElement extends ModalElement {
54
56
  // refines it once the size is known.
55
57
  this.style({ left: r.left, top: r.bottom + ANCHOR_GAP })
56
58
  }
59
+ // A menu belongs where it opened: the anchor's root — a screen, a page, a surface's layer.
60
+ const root = anchorRoot(anchor)
61
+ if (root && this.parent !== root) root.append(this)
57
62
  } else {
58
63
  this._anchorRect = undefined // unanchored show: the user's own style owns the position
59
64
  }
60
- // A menu belongs to the page it opened on: it hides with it and rides its transition. An
61
- // explicit attachTo() owner (e.g. a scene HUD's popover) is respected.
62
- if (!this._owner) this.attachTo(Presentable.current)
63
65
  super.show()
64
66
  }
65
67
 
66
68
  private _place(width: number, height: number): void {
67
69
  const anchor = this._anchorRect
68
- if (!anchor || !this._open) return
70
+ if (!anchor || !this._shown) return
69
71
  const displayWidth = device.width, displayHeight = device.height // 0 on hosts without a size
70
72
  let left = anchor.left
71
73
  let top = anchor.bottom + ANCHOR_GAP
@@ -87,6 +89,18 @@ export class PopoverElement extends ModalElement {
87
89
  }
88
90
  }
89
91
 
92
+ // The root an anchor element sits in, when it is a place a widget can belong to: a screen or a
93
+ // layer that is ATTACHED (presented, in the router, a pager's page, a shown layer — the pins find
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 => {
96
+ const h = (anchor as any)._h
97
+ if (!h || typeof h.id !== "number" || h.id === 0) return null
98
+ let id = h.id as number
99
+ for (let up = tree().parent(id); up !== 0; up = tree().parent(id)) id = up
100
+ const el = byId(id) as any
101
+ return el && typeof el.append === "function" && (el.type === "screen" || el.type === "layer") ? el : null
102
+ }
103
+
90
104
  const makePopover = (style: any, children: any) => new PopoverElement(style, children)
91
105
 
92
106
  /** Create an anchored popover (see {@link UIPopover}) — create once at module scope, then
package/src/ui/UIVideo.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { VideoPlayer } from "../runtime/media"
2
+ import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "./UILayer"
2
3
  import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
3
4
  import { _transitionId, TRANSITION_NONE } from "./transitions"
4
5
  import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
@@ -24,6 +25,8 @@ export interface UIVideo extends UIElementBase<UIVideoStyle, DrawableStyle & Bas
24
25
  onClose(callback: () => void): this
25
26
  /** Hardware/system back while current. */
26
27
  onBack(callback: () => void): this
28
+ /** The UI over the video when presented: widgets over it (`Presentable.setContent`). */
29
+ setContent(content: UIWidgetContent): this
27
30
  /** @internal The destination tuple (Presentable). */
28
31
  _dest(): DestTuple
29
32
  }
@@ -47,6 +50,14 @@ class VideoElement extends Element<"video"> implements Presentable {
47
50
 
48
51
  /** @internal */
49
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. */
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
60
+ }
50
61
 
51
62
  open(options?: PresentOptions): void {
52
63
  if (this._h.id === 0) return