lecodes-sdk 1.2.0 → 2.0.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 (260) hide show
  1. package/README.md +5 -2
  2. package/dist/editor.d.ts +12 -0
  3. package/dist/global.d.ts +4 -8
  4. package/dist/host.d.ts +2 -3
  5. package/dist/types/animate/tween/Animation.d.ts +0 -3
  6. package/dist/types/animate/tween/animateValue.d.ts +4 -2
  7. package/dist/types/animate/tween/easing.d.ts +8 -0
  8. package/dist/types/animate/tween/spec.d.ts +15 -7
  9. package/dist/types/audio/audio.d.ts +2 -1
  10. package/dist/types/canvas/Canvas.d.ts +40 -108
  11. package/dist/types/canvas/gen/cssColor.d.ts +17 -0
  12. package/dist/types/canvas/gen/recorder.d.ts +118 -0
  13. package/dist/types/canvas/gen/spec.d.ts +144 -0
  14. package/dist/types/core/color.d.ts +3 -1
  15. package/dist/types/core/pins.d.ts +18 -0
  16. package/dist/types/g2/Node2D.d.ts +5 -8
  17. package/dist/types/g2/Scene2D.d.ts +6 -2
  18. package/dist/types/gl/Foliage.d.ts +30 -7
  19. package/dist/types/gl/Light.d.ts +8 -0
  20. package/dist/types/gl/Lightmap.d.ts +13 -2
  21. package/dist/types/gl/Material.d.ts +17 -3
  22. package/dist/types/gl/Model.d.ts +6 -2
  23. package/dist/types/gl/Node.d.ts +3 -6
  24. package/dist/types/gl/Scene.d.ts +41 -13
  25. package/dist/types/gl/Texture.d.ts +1 -1
  26. package/dist/types/gl/animation/Locomotion.d.ts +8 -1
  27. package/dist/types/inject.d.ts +11 -11
  28. package/dist/types/inject.editor.d.ts +1 -0
  29. package/dist/types/net/core.d.ts +7 -0
  30. package/dist/types/plugin.d.ts +76 -0
  31. package/dist/types/plugins/gen/camera/sdk/camera.d.ts +24 -0
  32. package/dist/types/plugins/gen/camera/sdk/camera.gen.d.ts +25 -0
  33. package/dist/types/plugins/{geolocation.d.ts → gen/geolocation/sdk/geolocation.d.ts} +2 -20
  34. package/dist/types/plugins/gen/geolocation/sdk/geolocation.gen.d.ts +31 -0
  35. package/dist/types/plugins/{map.d.ts → gen/map/sdk/map.d.ts} +9 -50
  36. package/dist/types/plugins/gen/map/sdk/map.gen.d.ts +53 -0
  37. package/dist/types/plugins/gen/push/sdk/push.d.ts +23 -0
  38. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +35 -0
  39. package/dist/types/plugins/{qr.d.ts → gen/qr-scanner/sdk/qr-scanner.d.ts} +2 -3
  40. package/dist/types/plugins/gen/qr-scanner/sdk/qr-scanner.gen.d.ts +15 -0
  41. package/dist/types/runtime/app.d.ts +9 -2
  42. package/dist/types/runtime/fetch.d.ts +2 -0
  43. package/dist/types/runtime/input.d.ts +1 -1
  44. package/dist/types/runtime/media.d.ts +6 -10
  45. package/dist/types/runtime/misc.d.ts +4 -1
  46. package/dist/types/runtime/net.d.ts +3 -2
  47. package/dist/types/runtime/touch.d.ts +32 -0
  48. package/dist/types/scene/defineScene.d.ts +43 -2
  49. package/dist/types/scene/editor.d.ts +52 -0
  50. package/dist/types/scene/gizmos.d.ts +7 -4
  51. package/dist/types/ui/NativeView.d.ts +6 -4
  52. package/dist/types/ui/UI.d.ts +1 -1
  53. package/dist/types/ui/UIBottomSheet.d.ts +6 -12
  54. package/dist/types/ui/UIButton.d.ts +12 -14
  55. package/dist/types/ui/UIContainer.d.ts +0 -6
  56. package/dist/types/ui/UIImage.d.ts +1 -4
  57. package/dist/types/ui/UIInput.d.ts +8 -24
  58. package/dist/types/ui/UIModal.d.ts +0 -2
  59. package/dist/types/ui/UINode.d.ts +70 -56
  60. package/dist/types/ui/UIPager.d.ts +28 -27
  61. package/dist/types/ui/UIPopover.d.ts +0 -2
  62. package/dist/types/ui/UIScreen.d.ts +12 -17
  63. package/dist/types/ui/UIScrollable.d.ts +1 -4
  64. package/dist/types/ui/UIText.d.ts +0 -2
  65. package/dist/types/ui/UIVideo.d.ts +3 -5
  66. package/dist/types/ui/UIVirtualizedList.d.ts +14 -16
  67. package/dist/types/ui/UIWidget.d.ts +10 -9
  68. package/dist/types/ui/colorKeys.gen.d.ts +9 -0
  69. package/dist/types/ui/presentable.d.ts +46 -32
  70. package/dist/types/ui/router.d.ts +18 -7
  71. package/dist/types/ui/styleColor.d.ts +1 -0
  72. package/dist/types/ui/transitions.d.ts +18 -0
  73. package/dist/types/ui/tree.d.ts +75 -0
  74. package/dist/types/version.d.ts +10 -0
  75. package/dist/types.json +1 -1
  76. package/package.json +12 -3
  77. package/prompts/2d.md +2 -6
  78. package/prompts/3d.md +1 -5
  79. package/prompts/README.md +1 -1
  80. package/prompts/canvas.md +9 -8
  81. package/prompts/compose.ts +1 -1
  82. package/prompts/core.md +3 -3
  83. package/prompts/design.md +1 -1
  84. package/prompts/dist/2d-game.md +473 -239
  85. package/prompts/dist/3d-app.md +553 -205
  86. package/prompts/dist/ar-app.md +435 -202
  87. package/prompts/dist/design.md +113 -95
  88. package/prompts/dist/ui-app.md +386 -170
  89. package/prompts/ui-design.md +2 -3
  90. package/prompts/ui.md +45 -37
  91. package/src/animate/tween/Animation.ts +34 -150
  92. package/src/animate/tween/Timeline.ts +175 -175
  93. package/src/animate/tween/animateValue.ts +6 -3
  94. package/src/animate/tween/easing.ts +10 -3
  95. package/src/animate/tween/spec.ts +41 -15
  96. package/src/audio/Sound.ts +3 -3
  97. package/src/audio/audio.ts +2 -1
  98. package/src/bridges/2d.d.ts +317 -0
  99. package/src/bridges/app.d.ts +91 -0
  100. package/src/bridges/audio.d.ts +97 -0
  101. package/src/bridges/canvas.d.ts +79 -0
  102. package/src/bridges/device.d.ts +72 -0
  103. package/src/bridges/fetch.d.ts +80 -0
  104. package/src/bridges/files.d.ts +70 -0
  105. package/src/bridges/gl.d.ts +1133 -0
  106. package/src/bridges/input.d.ts +72 -0
  107. package/src/bridges/media.d.ts +55 -0
  108. package/src/bridges/nav.d.ts +71 -0
  109. package/src/bridges/net.d.ts +52 -0
  110. package/src/bridges/service.d.ts +52 -0
  111. package/src/bridges/socket.d.ts +31 -0
  112. package/src/bridges/storage.d.ts +33 -0
  113. package/src/bridges/tree.d.ts +301 -0
  114. package/src/bridges/types.d.ts +49 -0
  115. package/src/canvas/Canvas.ts +114 -159
  116. package/src/canvas/gen/cssColor.ts +224 -0
  117. package/src/canvas/gen/recorder.ts +212 -0
  118. package/src/canvas/gen/spec.ts +201 -0
  119. package/src/chisel.ts +193 -0
  120. package/src/compile/assetMacro.ts +1 -1
  121. package/src/compile/bundler.ts +11 -2
  122. package/src/compile/compileProject.ts +43 -4
  123. package/src/compile/fontMacro.ts +3 -4
  124. package/src/compile/header.ts +26 -5
  125. package/src/compile/index.ts +3 -1
  126. package/src/compile/liteMaterial.ts +1 -1
  127. package/src/compile/sceneEditor.ts +11 -26
  128. package/src/core/color.ts +73 -30
  129. package/src/core/pins.ts +51 -0
  130. package/src/core/signals.ts +8 -1
  131. package/src/g2/CharacterController2D.ts +3 -3
  132. package/src/g2/Node2D.ts +57 -39
  133. package/src/g2/Physics2D.ts +2 -2
  134. package/src/g2/Scene2D.ts +35 -23
  135. package/src/g2/Texture2D.ts +1 -1
  136. package/src/g2/loop.ts +4 -4
  137. package/src/gl/CameraPlace.ts +52 -52
  138. package/src/gl/Foliage.ts +72 -17
  139. package/src/gl/Geometry.ts +1 -2
  140. package/src/gl/Light.ts +10 -0
  141. package/src/gl/Lightmap.ts +45 -29
  142. package/src/gl/Material.ts +95 -51
  143. package/src/gl/Mesh.ts +120 -120
  144. package/src/gl/Model.ts +21 -16
  145. package/src/gl/Node.ts +91 -24
  146. package/src/gl/Particles.ts +1 -1
  147. package/src/gl/Scene.ts +100 -44
  148. package/src/gl/Texture.ts +8 -7
  149. package/src/gl/animation/AnimationClip.ts +1 -1
  150. package/src/gl/animation/DynamicBone.ts +482 -482
  151. package/src/gl/animation/Locomotion.ts +8 -3
  152. package/src/gl/nav/NavMesh.ts +3 -4
  153. package/src/gl/physics/Physics.ts +2 -2
  154. package/src/gl/physics/physicsEvents.ts +3 -3
  155. package/src/gl/scenarios.ts +291 -291
  156. package/src/gl/terrain/Terrain.ts +4 -5
  157. package/src/gl/touch.ts +14 -15
  158. package/src/host.d.ts +2 -3
  159. package/src/inject.editor.ts +7 -0
  160. package/src/inject.ts +13 -16
  161. package/src/net/core.ts +6 -5
  162. package/src/net/index.ts +1 -1
  163. package/src/net/replication.ts +1 -1
  164. package/src/plugin.ts +191 -0
  165. package/src/plugins/gen/camera/contract.d.ts +27 -0
  166. package/src/plugins/gen/camera/sdk/camera.gen.ts +46 -0
  167. package/src/plugins/gen/camera/sdk/camera.ts +57 -0
  168. package/src/plugins/gen/geolocation/contract.d.ts +50 -0
  169. package/src/plugins/gen/geolocation/sdk/geolocation.gen.ts +54 -0
  170. package/src/plugins/{geolocation.ts → gen/geolocation/sdk/geolocation.ts} +22 -43
  171. package/src/plugins/gen/map/contract.d.ts +144 -0
  172. package/src/plugins/gen/map/sdk/map.gen.ts +88 -0
  173. package/src/plugins/{map.ts → gen/map/sdk/map.ts} +68 -102
  174. package/src/plugins/gen/push/contract.d.ts +61 -0
  175. package/src/plugins/gen/push/sdk/push.gen.ts +60 -0
  176. package/src/plugins/gen/push/sdk/push.ts +105 -0
  177. package/src/plugins/gen/qr-scanner/contract.d.ts +16 -0
  178. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.gen.ts +29 -0
  179. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.ts +52 -0
  180. package/src/plugins/permission.ts +5 -4
  181. package/src/runtime/app.ts +20 -9
  182. package/src/runtime/appEvents.ts +5 -4
  183. package/src/runtime/channel.ts +18 -15
  184. package/src/runtime/clipboard.ts +4 -3
  185. package/src/runtime/datetime.ts +2 -1
  186. package/src/runtime/device.ts +17 -15
  187. package/src/runtime/fetch.ts +30 -20
  188. package/src/runtime/files.ts +16 -15
  189. package/src/runtime/input.ts +12 -10
  190. package/src/runtime/media.ts +50 -46
  191. package/src/runtime/misc.ts +7 -3
  192. package/src/runtime/net.ts +8 -7
  193. package/src/runtime/rpc.ts +1 -3
  194. package/src/runtime/service.ts +19 -14
  195. package/src/runtime/share.ts +4 -3
  196. package/src/runtime/storage.ts +6 -4
  197. package/src/runtime/touch.ts +32 -0
  198. package/src/scene/defineScene.ts +61 -365
  199. package/src/scene/editor.ts +408 -0
  200. package/src/scene/editorPlugins.ts +3 -3
  201. package/src/scene/gizmos.ts +15 -9
  202. package/src/server/db/marci/query.ts +1 -1
  203. package/src/server/host.ts +1 -1
  204. package/src/server/runtime.ts +1 -1
  205. package/src/ui/NativeView.ts +72 -25
  206. package/src/ui/UI.ts +3 -3
  207. package/src/ui/UIBottomSheet.ts +16 -17
  208. package/src/ui/UIButton.ts +54 -16
  209. package/src/ui/UIContainer.ts +0 -6
  210. package/src/ui/UIImage.ts +34 -30
  211. package/src/ui/UIInput.ts +29 -37
  212. package/src/ui/UIModal.ts +1 -3
  213. package/src/ui/UINode.ts +347 -297
  214. package/src/ui/UIPager.ts +93 -78
  215. package/src/ui/UIPopover.ts +0 -2
  216. package/src/ui/UIScreen.ts +41 -36
  217. package/src/ui/UIScrollable.ts +19 -13
  218. package/src/ui/UISpacer.ts +1 -1
  219. package/src/ui/UITabs.ts +8 -6
  220. package/src/ui/UIText.ts +7 -17
  221. package/src/ui/UIVideo.ts +30 -27
  222. package/src/ui/UIVirtualizedList.ts +58 -59
  223. package/src/ui/UIWidget.ts +38 -18
  224. package/src/ui/colorKeys.gen.ts +37 -0
  225. package/src/ui/fonts.ts +2 -2
  226. package/src/ui/presentable.ts +59 -42
  227. package/src/ui/router.ts +52 -35
  228. package/src/ui/styleColor.ts +56 -0
  229. package/src/ui/theme.ts +7 -6
  230. package/src/ui/transitions.ts +249 -0
  231. package/src/ui/tree.ts +346 -0
  232. package/src/version.ts +24 -0
  233. package/tests/helpers/engineWorld.ts +11 -0
  234. package/tests/helpers/fakeTree.ts +353 -0
  235. package/tests/helpers/hostStubs.ts +31 -0
  236. package/tests/helpers/index.ts +14 -0
  237. package/tests/helpers/memoryMarci.ts +124 -0
  238. package/tests/helpers/phases.ts +23 -0
  239. package/tests/helpers/preload.ts +18 -0
  240. package/tests/helpers/stubApp.ts +2 -0
  241. package/tests/helpers/stubDevice.ts +2 -0
  242. package/tests/helpers/stubFetch.ts +2 -0
  243. package/tests/helpers/stubInput.ts +2 -0
  244. package/dist/inject.js +0 -4629
  245. package/dist/types/core/registry.d.ts +0 -7
  246. package/dist/types/plugins/camera.d.ts +0 -25
  247. package/dist/types/plugins/push.d.ts +0 -46
  248. package/src/bridges.d.ts +0 -1769
  249. package/src/compile/__tests__/assetIconMacro.test.ts +0 -219
  250. package/src/compile/__tests__/assetMacro.test.ts +0 -100
  251. package/src/compile/__tests__/assetName.test.ts +0 -55
  252. package/src/compile/__tests__/compile.test.ts +0 -310
  253. package/src/compile/__tests__/detectEntry.test.ts +0 -151
  254. package/src/compile/__tests__/fontMacro.test.ts +0 -199
  255. package/src/compile/__tests__/serverSplit.test.ts +0 -27
  256. package/src/core/__tests__/stateMachine.test.ts +0 -132
  257. package/src/core/registry.ts +0 -23
  258. package/src/plugins/camera.ts +0 -81
  259. package/src/plugins/push.ts +0 -132
  260. package/src/plugins/qr.ts +0 -73
@@ -4,45 +4,61 @@
4
4
  // longer overlays a scene, it replaces it (with a transition); UI over a scene goes through
5
5
  // `UIWidget.attachTo(scene)`.
6
6
  //
7
+ // A destination is addressed by (kind, id): a screen / video by its node id, a scene by its
8
+ // scene id, a native view by its viewId (`_dest()`). The runtime reports the lifecycle back through
9
+ // the tree emitter (TREE_EVENT_OPEN / CLOSE / BACK) — see ui/tree.ts resolveDest.
10
+ //
7
11
  // This module is dependency-free on purpose: gl/ and g2/ import it, so it must not pull any UI
8
12
  // element code into engine-only bundles.
9
13
 
10
- /** Built-in transition catalog. `push`/`pop` are the stacked-navigation pair (slide + parallax +
11
- * dim + edge shadow); the rest are simple two-view moves. */
14
+ import type { EasingInput } from "../animate/tween/easing"
15
+
16
+ /** The built-in transitions (ui/transitions.ts holds what each one is). `push` / `pop` are the
17
+ * stacked-navigation pair — the incoming screen slides over the other one, which drifts and dims;
18
+ * the rest are two-screen moves. */
12
19
  export type TransitionName =
13
20
  | "push" | "pop"
14
21
  | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom"
15
22
  | "zoom" | "zoom-in" | "zoom-out" | "fade" | "none"
16
23
 
17
- /** Start/end pose of one side of a custom transition. `x`/`y` are logical px, or a percentage of
18
- * the destination's size (e.g. `"100%"` = one full width to the right). */
19
- export type TransitionTransform = {
20
- x?: number | `${number}%`
21
- y?: number | `${number}%`
22
- scale?: number
23
- opacity?: number
24
+ /**
25
+ * One screen's pose in a transition — the keys of an `animateTo` bag a transition can move, with
26
+ * the side's own timing. An array is a list of keyframes, as there.
27
+ */
28
+ export type TransitionPose = {
29
+ /** A transform list, as in a style — and here a `%` in `translate` is a percent of the screen's
30
+ * own box: `"translateX(100%)"` is one width to the right. */
31
+ transform?: string | string[]
32
+ opacity?: number | number[]
33
+ /** Black over this screen, 0..1: how dark the screen UNDER the other one gets. */
34
+ dim?: number | number[]
35
+ /** ms (default 300). */
36
+ duration?: number
37
+ /** ms to wait; the screen holds its first pose through it. */
38
+ delay?: number
39
+ /** Any curve `animateTo` takes (default `ease-in-out`). */
40
+ easing?: EasingInput
24
41
  }
25
42
 
26
43
  /**
27
- * A custom transition: declarative poses for the incoming/outgoing destination, played natively.
28
- * Serializes onto each platform's existing transition machinery — there is no per-frame JS.
29
- * Fields omitted keep the identity pose. Interactive back-swipe stays system-native (push/pop
30
- * only); a spec applies to non-interactive transitions.
44
+ * A transition between two destinations, described by where each side is when it is NOT at rest:
45
+ * the incoming one comes FROM `enter`, the outgoing one goes TO `exit`. A side left out stays put.
46
+ *
47
+ * ```ts
48
+ * Router.push(page, { transition: { enter: { transform: "translateY(100%)", duration: 400 }, exit: { dim: 0.3 } } })
49
+ * ```
50
+ *
51
+ * A screen's own `transform` / `opacity` are not seen while a transition moves it (put a lasting
52
+ * effect on a child). A scene, a native view, a video never move: next to one of them only the
53
+ * screen's side plays, above it.
31
54
  */
32
55
  export interface TransitionSpec {
33
- /** Duration in ms (default 300). */
34
- duration?: number
35
- /** cubic-bezier control points (default ease-out). */
36
- easing?: [number, number, number, number]
37
- /** The entering destination animates FROM this pose to identity. */
38
- incoming?: { from?: TransitionTransform }
39
- /** The leaving destination animates from identity TO this pose. */
40
- outgoing?: { to?: TransitionTransform }
41
- /** Scrim alpha (0..1) painted over whichever destination is underneath. */
42
- dim?: number
43
- /** Which side stacks on top during the animation (default "incoming"). On Android, transitions
44
- * involving a scene always animate the screen side on top — the scene view itself never moves. */
45
- onTop?: "incoming" | "outgoing"
56
+ /** The incoming destination's start: it moves from this pose to rest. */
57
+ enter?: TransitionPose
58
+ /** The outgoing destination's end: it moves from rest to this pose. */
59
+ exit?: TransitionPose
60
+ /** Which side is above the other while they move (default `"enter"`). */
61
+ onTop?: "enter" | "exit"
46
62
  }
47
63
 
48
64
  export type Transition = TransitionName | TransitionSpec
@@ -54,6 +70,14 @@ export type PresentOptions = {
54
70
  transition?: Transition
55
71
  }
56
72
 
73
+ /** `close()` / `Router.hide()`: the transition the destination leaves with (default "none"). */
74
+ export type DismissOptions = {
75
+ transition?: Transition
76
+ }
77
+
78
+ /** @internal The (kind, id, viewName, paramsJson) tuple the tree bridge addresses a destination by. */
79
+ export type DestTuple = [kind: number, id: number, viewName: string, paramsJson: string]
80
+
57
81
  /**
58
82
  * Anything that can be shown as the app's current destination: a `UIScreen`, a `Scene` /
59
83
  * `ARScene`, a `Scene2D`, a `NativeView`, or a `UIVideo`. One Presentable is visible at a time;
@@ -66,21 +90,26 @@ export type PresentOptions = {
66
90
  export interface Presentable {
67
91
  // Return type covers the implementers: UIScreen → void, Scene2D → this, ARScene → Promise.
68
92
  open(options?: PresentOptions): void | this | Promise<void>
69
- close(): void
93
+ /** Close this destination if it is the one on screen (nothing takes its place). One that is not
94
+ * on screen — already replaced by a `Router.init/push/replace` or another `open()` — is left as
95
+ * it is, so `Router.init(home); scene.close()` keeps `home` visible. */
96
+ close(options?: DismissOptions): void
70
97
  onOpen(callback: () => void): this
71
98
  onClose(callback: () => void): this
72
- onBackPressed(callback: () => void): this
99
+ onBack(callback: () => void): this
73
100
  /** @internal Async work that must finish BEFORE this destination replaces the current one
74
101
  * (camera permission, warm render, session launch — ARScene). The previous destination —
75
102
  * typically a loading screen — stays visible while it runs; `open()` and `Router.push/replace`
76
103
  * await it and skip the swap entirely if it rejects (permission denied, or superseded by
77
104
  * another navigation that happened while preparing). */
78
105
  _prepare?(): Promise<void>
106
+ /** @internal The destination tuple (see DestTuple). */
107
+ _dest(): DestTuple
79
108
  }
80
109
 
81
110
  // The destination visible right now. Set optimistically by every successful open()/router
82
- // navigation, cleared by close(), and corrected by router onChange (hosts hand the wire desc
83
- // back on every stack change, including back gestures — `_p` resolves it to the SDK instance).
111
+ // navigation, cleared by close(), and corrected by router onChange (the runtime reports the new
112
+ // top on every stack change, including back gestures).
84
113
  let current: Presentable | null = null
85
114
  /** @internal Call whenever the visible destination changes. */
86
115
  export const _setCurrent = (p: Presentable | null): void => { current = p }
@@ -103,15 +132,3 @@ let navEpoch = 0
103
132
  export const _navEpoch = (): number => navEpoch
104
133
  /** @internal Call on every action that changes the visible destination. */
105
134
  export const _bumpNavEpoch = (): void => { navEpoch++ }
106
-
107
- /** @internal Does this host implement the Presentable navigation surface? The SDK falls back to
108
- * the legacy openScreen/openScene bridges when it doesn't (screens keep working; scene/native
109
- * destinations in the Router then throw). */
110
- export const _navSupported = (): boolean =>
111
- typeof _creatorUI !== "undefined" && typeof _creatorUI.openView === "function"
112
-
113
- /** @internal The wire descriptor for a Presentable. Screens (and anything without `_viewDesc`)
114
- * travel as themselves; scenes/native views/video provide a stable descriptor object carrying
115
- * `_p` (the SDK instance) so hosts can hand it back through router onChange / lifecycle. */
116
- export const _descOf = (p: Presentable): object =>
117
- typeof (p as any)._viewDesc === "function" ? (p as any)._viewDesc() : (p as object)
package/src/ui/router.ts CHANGED
@@ -1,13 +1,27 @@
1
- import { _bumpNavEpoch, _descOf, _navSupported, _setCurrent, Presentable, type Transition } from "./presentable"
1
+ import { _bumpNavEpoch, _setCurrent, Presentable, type DismissOptions, type PresentOptions, type Transition } from "./presentable"
2
+ import { _transitionBack, _transitionId, TRANSITION_NONE, TRANSITION_UNSET } from "./transitions"
2
3
  import { _navDefaults } from "./theme"
4
+ import { ensureEmitter, pin, resolveDest, setRouterChangeHandler, tree } from "./tree"
3
5
 
4
6
  type RouterOptions = {
5
7
  showDefaultBackButton?: boolean
6
8
  }
7
9
 
8
- type NavigateOptions = {
9
- /** Transition to play. Defaults: `"push"` for push, `"pop"` for pop; replace follows the
10
- * theme's `replaceTransition` (`"none"` out of the box). */
10
+ export type NavigateOptions = {
11
+ /** Transition to play. Defaults: `"push"` for push; replace follows the theme's
12
+ * `replaceTransition` (`"none"` out of the box); a pop plays the way back the destination it
13
+ * pops remembers. */
14
+ transition?: Transition
15
+ /** The way BACK from the destination — what `Router.pop()` without a transition, the edge swipe
16
+ * and the system's back play when they take it off the stack. Default: the transition it came
17
+ * with, the other way round (a push goes back with a pop, a slide from the bottom leaves
18
+ * downwards; a zoom goes back with a fade). A `replace` that names none keeps the way back of
19
+ * the destination it replaces: it belongs to the place in the stack. */
20
+ popTransition?: Transition
21
+ }
22
+
23
+ export type PopOptions = {
24
+ /** Transition to play instead of the way back the popped destination remembers. */
11
25
  transition?: Transition
12
26
  }
13
27
 
@@ -29,12 +43,12 @@ type Router = {
29
43
  replace(page: Presentable, opts?: NavigateOptions): void | Promise<void>
30
44
  /** Pop the top destination (the programmatic twin of the back gesture); `to` unwinds to a
31
45
  * stack index in one step. */
32
- pop(to?: number, opts?: NavigateOptions): void
46
+ pop(to?: number, opts?: PopOptions): void
33
47
  /** Blank the router's page, keeping the stack — `restore()` brings it back. A destination
34
48
  * opened directly stays visible. */
35
- hide(): void
49
+ hide(opts?: DismissOptions): void
36
50
  /** Re-present the stack top after `hide()` or a direct `open()` suspended the router. */
37
- restore(): void
51
+ restore(opts?: PresentOptions): void
38
52
 
39
53
  /** `"change"` fires on every stack change — pushes, pops, and native back gestures — with the
40
54
  * new top destination. */
@@ -43,42 +57,44 @@ type Router = {
43
57
  }
44
58
 
45
59
  let currentPage: Presentable | null = null
46
- let onChange = (page: any) => {
47
- // Hosts hand back the wire object: the screen itself, or a descriptor carrying `_p`.
48
- currentPage = page && page._p ? page._p : page
49
- // The router's page is the visible destination (hosts fire this on every stack change,
50
- // including back gestures) — keep Presentable.current in sync.
60
+ let hooked = false
61
+ const onChange = (kind: number, id: number) => {
62
+ // The runtime reports the new top on every stack change (pushes, pops, back gestures): resolve
63
+ // it to the SDK instance and keep Presentable.current in sync.
64
+ const page = resolveDest(kind, id) as unknown as Presentable | undefined
65
+ if (!page) return
66
+ currentPage = page
51
67
  _setCurrent(currentPage)
52
68
  for (let callback of (Router as any)._changeListeners) {
53
69
  callback(currentPage)
54
70
  }
55
71
  }
56
72
 
57
- // Non-screen destinations have no legacy bridge to fall back to — surface that clearly instead
58
- // of letting an old host try to build a screen out of a scene descriptor.
59
- const wireDesc = (page: Presentable): object => {
60
- const desc = _descOf(page)
61
- if (desc !== page && !_navSupported()) {
62
- throw new Error("This host does not support scene/native-view navigation (requires _creatorUI.openView)")
63
- }
64
- return desc
65
- }
73
+ // A screen the router holds is attached (pinned) until the runtime retires it (TREE_EVENT_DETACHED).
74
+ // Scenes / native views are not tree elements: their destination registry holds them (`_dest()`).
75
+ const hold = (page: Presentable): void => { if ((page as any)._h) pin(page as any) }
66
76
 
67
77
  export const Router: Router = /*#__PURE__*/ Object.freeze({
68
78
 
69
79
  init(homePage: Presentable, opts: RouterOptions = {}): void {
80
+ ensureEmitter()
81
+ if (!hooked) { hooked = true; setRouterChangeHandler(onChange) }
70
82
  _bumpNavEpoch()
71
- _creatorUI.routerOpen(wireDesc(homePage), onChange, opts.showDefaultBackButton ?? false)
83
+ const [kind, id, name, params] = homePage._dest()
84
+ hold(homePage)
85
+ tree().routerOpen(kind, id, name, params, opts.showDefaultBackButton ?? false)
72
86
  currentPage = homePage
73
87
  _setCurrent(homePage)
74
88
  },
75
89
  push(page: Presentable, opts?: NavigateOptions): void | Promise<void> {
76
- const desc = wireDesc(page) // throws early on unsupported hosts, before any async work
77
90
  const go = () => {
78
91
  _bumpNavEpoch()
79
- // Legacy hosts ignore the extra transition argument.
80
- _creatorUI.routerPush(desc, opts?.transition ?? "push")
81
- _setCurrent(page) // optimistic — host onChange confirms (async on native hosts)
92
+ const [kind, id, name, params] = page._dest()
93
+ hold(page)
94
+ const transition = opts?.transition ?? "push"
95
+ const back = opts?.popTransition !== undefined ? _transitionId(opts.popTransition) : _transitionBack(transition)
96
+ tree().routerPush(kind, id, name, params, _transitionId(transition), back)
97
+ _setCurrent(page) // optimistic — the runtime's change event confirms
82
98
  }
83
99
  // A preparing destination (ARScene) keeps the current page visible until it's ready; a
84
100
  // rejection (camera denied / superseded) propagates to the caller and nothing is pushed.
@@ -86,28 +102,29 @@ export const Router: Router = /*#__PURE__*/ Object.freeze({
86
102
  return prep ? prep.then(go) : go()
87
103
  },
88
104
  replace(page: Presentable, opts?: NavigateOptions): void | Promise<void> {
89
- const desc = wireDesc(page)
90
105
  const go = () => {
91
106
  _bumpNavEpoch()
92
- _creatorUI.routerReplace(desc, opts?.transition ?? _navDefaults.replace)
93
- _setCurrent(page) // optimistic — host onChange confirms (async on native hosts)
107
+ const [kind, id, name, params] = page._dest()
108
+ hold(page)
109
+ tree().routerReplace(kind, id, name, params, _transitionId(opts?.transition ?? _navDefaults.replace), _transitionId(opts?.popTransition))
110
+ _setCurrent(page) // optimistic — the runtime's change event confirms
94
111
  }
95
112
  const prep = page._prepare?.()
96
113
  return prep ? prep.then(go) : go()
97
114
  },
98
- pop(to?: number, opts?: NavigateOptions): void {
115
+ pop(to?: number, opts?: PopOptions): void {
99
116
  _bumpNavEpoch()
100
- _creatorUI.routerPop(to ?? -1, opts?.transition ?? "pop")
117
+ tree().routerPop(to ?? -1, _transitionId(opts?.transition, TRANSITION_UNSET))
101
118
  },
102
- hide() {
119
+ hide(opts?: DismissOptions) {
103
120
  _bumpNavEpoch()
104
- _creatorUI.routerHide()
121
+ tree().routerHide(_transitionId(opts?.transition, TRANSITION_NONE))
105
122
  // hide() only blanks the router's own page — a directly-opened destination stays visible.
106
123
  if (Presentable.current === currentPage) _setCurrent(null)
107
124
  },
108
- restore() {
125
+ restore(opts?: PresentOptions) {
109
126
  _bumpNavEpoch()
110
- _creatorUI.routerRestore()
127
+ tree().routerRestore(_transitionId(opts?.transition, TRANSITION_NONE))
111
128
  _setCurrent(currentPage) // the stack top is visible again
112
129
  },
113
130
 
@@ -0,0 +1,56 @@
1
+ // The UI color wire. The UI core parses CSS colors itself (engines/ui color.h over AnyCanvas's
2
+ // css_color.h — the parser the SDK's `Color` is a twin of), so a color STRING goes over as written:
3
+ // the same grammar, and CSS semantics for junk (an unparseable color is transparent, never an
4
+ // error). The SDK converts only what the core cannot read — a packed `0xRRGGBB` number and
5
+ // `[r, g, b(, a)]` components — to '#rrggbbaa', and a non-color there throws naming the key.
6
+ // The element keeps the app's own value in `_style` (reads answer what the app set); only the wire
7
+ // is converted. Kept out of UINode.ts: its `type Color` would clash with the `Color` value.
8
+ // Bundle rule: this module must reach no CSS parser (numericColorToHex, never `Color`) — the name
9
+ // table would otherwise ride along in every app with a styled color.
10
+
11
+ import { numericColorToHex } from "../core/color"
12
+ import { COLOR_KEYS, COLOR_SHORTHAND_KEYS } from "./colorKeys.gen"
13
+
14
+ /** @internal One color value → its wire form. Pass-throughs: null / undefined (a reset) and every
15
+ * string (CSS colors, `"default"`, `var(…)` — the core reads them all). A number or an array goes
16
+ * through {@link numericColorToHex} and throws, naming the key and the value, when it is not a
17
+ * color. */
18
+ export const wireColor = (key: string, v: unknown): unknown => {
19
+ if (v == null || typeof v === "string") return v
20
+ try {
21
+ return numericColorToHex(v)
22
+ } catch (e) {
23
+ if (e instanceof Error) e.message = `${key}: ${e.message}`
24
+ throw e
25
+ }
26
+ }
27
+
28
+ /** @internal One style write → its wire value: a color key through {@link wireColor}. A shorthand
29
+ * (`background`, `border*`) is a color in one of its forms only: a number or an array under
30
+ * `background` is one; a string stays (the core's shorthand tells a color from a width, an image,
31
+ * a gradient — same grammar) and a number under `border*` is a width. Every other key as is. */
32
+ export const wireValue = (key: string, v: unknown): unknown => {
33
+ if (COLOR_SHORTHAND_KEYS.has(key)) {
34
+ return key === "background" && (typeof v === "number" || Array.isArray(v)) ? wireColor(key, v) : v
35
+ }
36
+ return COLOR_KEYS.has(key) ? wireColor(key, v) : v
37
+ }
38
+
39
+ // A nested state block (a `$class`: `$pressed`, `$landscape`, `$checked`, …): a plain object that is
40
+ // not a host handle — a backgroundImage FetchResponse / File carries `_h` (the runtime's
41
+ // own rule, presentation/tree.cpp mergeStyleLayer).
42
+ const isBlock = (v: unknown): v is Record<string, unknown> =>
43
+ typeof v === "object" && v !== null && !Array.isArray(v) && !("_h" in v)
44
+
45
+ /** @internal A style bag → its wire form: {@link wireValue} per key, nested state blocks
46
+ * recursively. Copy on write: the SAME object comes back when nothing changed. */
47
+ export const wireStyle = <T extends object>(bag: T): T => {
48
+ const src = bag as Record<string, unknown>
49
+ let out: Record<string, unknown> | null = null
50
+ for (const key of Object.keys(src)) {
51
+ const v = src[key]
52
+ const w = isBlock(v) ? wireStyle(v) : wireValue(key, v)
53
+ if (w !== v) (out ??= { ...src })[key] = w
54
+ }
55
+ return (out ?? bag) as T
56
+ }
package/src/ui/theme.ts CHANGED
@@ -27,9 +27,10 @@ const RESERVED = ["safe-top", "safe-bottom", "safe-left", "safe-right", "vw", "v
27
27
  * card.style({ bgColor: T.cardBg }) // T.cardBg === "var(--cardBg)"
28
28
  * ```
29
29
  *
30
- * Strings pass through (colors, fonts, expressions like `"max(safe-top, 24px)"`); numbers are
31
- * lengths (px); `null` removes a key (comfort knobs reset to their defaults). Env names
32
- * (`safe-*`, `vw`/`vh`/…) are host-owned — skipped with a warning. One special key:
30
+ * Strings pass through as written (colors — the core parses CSS —, fonts, expressions like
31
+ * `"max(safe-top, 24px)"`); numbers are lengths (px), so a theme COLOR is always a
32
+ * string (`"#0A84FF"`, `"red"`, never `0x0a84ff`); `null` removes a key (comfort knobs reset to
33
+ * their defaults). Env names (`safe-*`, `vw`/`vh`/…) are host-owned — skipped with a warning. One special key:
33
34
  * `replaceTransition` — the app-wide default transition for `Router.replace` (consumed SDK-side,
34
35
  * never sent to the host table).
35
36
  */
@@ -39,8 +40,9 @@ const setTheme = <T extends Record<string, string | number | Transition | null>>
39
40
  ): ThemeAccessors<Omit<T, "replaceTransition">> => {
40
41
  const accessors: Record<string, string> = {}
41
42
  let send = values as ThemeValues
43
+ const own = () => { if (send === values) send = { ...values } as ThemeValues }
42
44
  const strip = (key: string) => {
43
- if (send === values) send = { ...values } as ThemeValues
45
+ own()
44
46
  delete send[key]
45
47
  }
46
48
  for (const key of Object.keys(values)) {
@@ -56,8 +58,7 @@ const setTheme = <T extends Record<string, string | number | Transition | null>>
56
58
  }
57
59
  accessors[key] = `var(--${key})`
58
60
  }
59
- // Optional during rollout: hosts without the method simply don't theme (values still type).
60
- if (Object.keys(send).length > 0) _creatorUI.setTheme?.(send)
61
+ if (Object.keys(send).length > 0) _creatorTree.setTheme(send)
61
62
  return accessors as ThemeAccessors<Omit<T, "replaceTransition">>
62
63
  }
63
64
 
@@ -0,0 +1,249 @@
1
+ // Screen transitions (docs/plans/transitions-plan.md). Every transition — the built-in ones too —
2
+ // is DATA in the format of presentable.ts and is written HERE: the runtime holds no catalog and
3
+ // knows no name, a host animates nothing of its own. A transition becomes a tween blob whose
4
+ // targets are ROLES, registered once per world (`_creatorTree.defineTransition`); a navigation call
5
+ // carries the id, and the runtime binds the roles to the two screen roots when it plays it — as
6
+ // ordinary tracks on their `transform` / `opacity` / `dim`, which never commit.
7
+ //
8
+ // Depends on the tween builder alone: gl/ and g2/ reach this through their open().
9
+
10
+ import type { Easing } from "../animate/easings"
11
+ import { DEFAULT_EASING, parseEasing, type EasingSpec } from "../animate/tween/easing"
12
+ import { CLOCK_UI, DOM_UI, KIND_FLOAT, KIND_TRANSFORM, buildBlob, parseTransformList, transformLanes, warnOnce, type Keyframe, type Track, type TweenTarget } from "../animate/tween/spec"
13
+ import type { Transition, TransitionName, TransitionPose, TransitionSpec } from "./presentable"
14
+
15
+ /** Wire ids: a registered transition is > 0. */
16
+ export const TRANSITION_NONE = 0
17
+ /** "The caller names none": the runtime plays what the stack entry remembers (a pop), or keeps
18
+ * what the replaced entry remembered (a replace's way back). */
19
+ export const TRANSITION_UNSET = -1
20
+
21
+ /** Who a track moves. The ALONE roles play next to a destination that cannot move (a scene, a
22
+ * native view, a video) or next to nothing (close(), the first open): only the screen plays, above. */
23
+ export const ROLE_ENTER = 0
24
+ export const ROLE_EXIT = 1
25
+ export const ROLE_ENTER_ALONE = 2
26
+ export const ROLE_EXIT_ALONE = 3
27
+
28
+ /** `defineTransition` flags. */
29
+ export const TRANSITION_ENTER_ON_TOP = 1
30
+
31
+ const DEFAULT_DURATION_MS = 300
32
+
33
+ // ---- the catalog ---------------------------------------------------------------------------------
34
+
35
+ // A spring as a curve over 0..1 (the response of a unit step, `omega` = how soon it settles inside
36
+ // the span): what UIKit's `dampingRatio` animators and the old Android SpringInterpolator eased with.
37
+ const spring = (damping: number, omega: number): Easing => (t) => {
38
+ if (t >= 1) return 1
39
+ if (damping < 1) {
40
+ const wd = omega * Math.sqrt(1 - damping * damping)
41
+ return 1 - Math.exp(-damping * omega * t) * (Math.cos(wd * t) + ((damping * omega) / wd) * Math.sin(wd * t))
42
+ }
43
+ const w = omega * damping
44
+ return 1 - Math.exp(-w * t) * (1 + w * t)
45
+ }
46
+
47
+ // A soft ease-out that settles with a slight overshoot: the slides and the base zoom.
48
+ const SETTLE = [0.3, 1.1, 0.5, 1] as const
49
+
50
+ const slide = (enter: string, exit: string): TransitionSpec => ({
51
+ enter: { transform: enter, duration: 400, easing: SETTLE },
52
+ exit: { transform: exit, duration: 400, easing: SETTLE },
53
+ })
54
+
55
+ // The stacked pair: the top screen travels its whole width while the one under it drifts a part
56
+ // of it and dims. The two phone platforms keep their own feel — the numbers of one table.
57
+ const push = (): TransitionSpec => {
58
+ if (_creatorDevice.platform === "android") {
59
+ const t = { duration: 350, easing: [0.2, 0, 0, 1] as const }
60
+ return { enter: { transform: "translateX(100%)", ...t }, exit: { transform: "translateX(-25%)", dim: 0.3, ...t } }
61
+ }
62
+ const t = { duration: 380, easing: spring(1, 9) }
63
+ return { enter: { transform: "translateX(100%)", ...t }, exit: { transform: "translateX(-30%)", dim: 0.12, ...t } }
64
+ }
65
+
66
+ /** What a name is. `none` has no spec. */
67
+ const named = (name: TransitionName): TransitionSpec | null => {
68
+ switch (name) {
69
+ case "push": return push()
70
+ case "pop": return mirrored(push())
71
+ case "slide-from-left": return slide("translateX(-100%)", "translateX(100%)")
72
+ case "slide-from-right": return slide("translateX(100%)", "translateX(-100%)")
73
+ case "slide-from-top": return slide("translateY(-100%)", "translateY(100%)")
74
+ case "slide-from-bottom": return slide("translateY(100%)", "translateY(-100%)")
75
+ // the incoming grows into place above the other one, which only fills the thin edge
76
+ case "zoom":
77
+ case "zoom-in": return { enter: { transform: "scale(0.985)", duration: 400, easing: SETTLE } }
78
+ // the outgoing recedes and dissolves above; the incoming settles from slightly enlarged, never
79
+ // below 1, so no edge is exposed
80
+ case "zoom-out": {
81
+ const t = { duration: 420, easing: spring(0.82, 10) }
82
+ return { onTop: "exit", enter: { transform: "scale(1.06)", ...t }, exit: { transform: "scale(0.92)", opacity: 0, ...t } }
83
+ }
84
+ // a fade-THROUGH: out, then in — two half-transparent screens never show through each other
85
+ case "fade": return {
86
+ onTop: "exit",
87
+ exit: { opacity: 0, duration: 120, easing: [0.42, 0, 1, 1] },
88
+ enter: { opacity: 0, delay: 120, duration: 200, easing: [0, 0, 0.58, 1] },
89
+ }
90
+ default: return null
91
+ }
92
+ }
93
+
94
+ /** The way back of a name. A slide and the stacked pair go back the way they came; a zoom's mirror
95
+ * would end with the top screen vanishing at its start scale, so a zoom goes back with a fade. */
96
+ const BACK: Record<TransitionName, TransitionName> = {
97
+ "push": "pop", "pop": "push",
98
+ "slide-from-left": "slide-from-right", "slide-from-right": "slide-from-left",
99
+ "slide-from-top": "slide-from-bottom", "slide-from-bottom": "slide-from-top",
100
+ "zoom": "fade", "zoom-in": "fade", "zoom-out": "fade", "fade": "fade", "none": "none",
101
+ }
102
+
103
+ // ---- the mirror ----------------------------------------------------------------------------------
104
+
105
+ const backwards = <T>(v: T | T[] | undefined): T | T[] | undefined => (Array.isArray(v) ? v.slice().reverse() : v)
106
+
107
+ const reversedPose = (p: TransitionPose | undefined): TransitionPose | undefined =>
108
+ p && { ...p, transform: backwards(p.transform), opacity: backwards(p.opacity), dim: backwards(p.dim) }
109
+
110
+ /** The way back of a spec: the ROLES swap — the screen that came leaves into its `enter` pose, the
111
+ * one that left comes back from its `exit` pose, the same screen stays on top — and the timing and
112
+ * the curves stay as written. Not a rewind: that would turn an ease-out into a slow start. */
113
+ export const mirrored = (s: TransitionSpec): TransitionSpec => ({
114
+ enter: reversedPose(s.exit),
115
+ exit: reversedPose(s.enter),
116
+ onTop: (s.onTop ?? "enter") === "enter" ? "exit" : "enter",
117
+ })
118
+
119
+ // ---- a spec → tracks ---------------------------------------------------------------------------
120
+
121
+ /** One prop of a side: its keys in the order the side plays them (an incoming side ends at rest
122
+ * after them, an outgoing one starts at rest before them), and what rest is. */
123
+ type Lane = { prop: string, kind: number, sig?: string, keys: number[][], rest: number[] }
124
+ type Side = { lanes: Lane[], atMs: number, durMs: number, easing: EasingSpec }
125
+
126
+ const list = <T>(v: T | T[]): T[] => (Array.isArray(v) ? v : [v])
127
+
128
+ const transformLane = (value: string | string[]): Lane | null => {
129
+ const parsed = list(value).map((v) => parseTransformList(v, true))
130
+ const first = parsed[0]
131
+ if (!first || parsed.some((p) => p === null || p.sig !== first.sig)) {
132
+ warnOnce("transition:transform", "a transition's transform must be a list of translate / scale / rotate, the same functions in every keyframe — ignored")
133
+ return null
134
+ }
135
+ if (first.sig === "") return null
136
+ const rest: number[] = []
137
+ for (const fn of first.sig.split(",")) for (let i = transformLanes(fn); i > 0; i--) rest.push(fn.startsWith("scale") ? 1 : 0)
138
+ return { prop: "transform", kind: KIND_TRANSFORM, sig: first.sig, keys: parsed.map((p) => p!.lanes), rest }
139
+ }
140
+
141
+ const floatLane = (prop: string, value: number | number[], rest: number): Lane =>
142
+ ({ prop, kind: KIND_FLOAT, keys: list(value).map((v) => [v]), rest: [rest] })
143
+
144
+ const sideOf = (pose: TransitionPose | undefined): Side | null => {
145
+ if (!pose) return null
146
+ const lanes: Lane[] = []
147
+ if (pose.transform !== undefined) { const l = transformLane(pose.transform); if (l) lanes.push(l) }
148
+ if (pose.opacity !== undefined) lanes.push(floatLane("opacity", pose.opacity, 1))
149
+ if (pose.dim !== undefined) lanes.push(floatLane("dim", pose.dim, 0))
150
+ if (lanes.length === 0) return null
151
+ return {
152
+ lanes,
153
+ atMs: Math.max(0, pose.delay ?? 0),
154
+ durMs: Math.max(0, pose.duration ?? DEFAULT_DURATION_MS),
155
+ easing: pose.easing === undefined ? DEFAULT_EASING : parseEasing(pose.easing),
156
+ }
157
+ }
158
+
159
+ /** The OTHER side's pose as this screen's own, for a screen that plays alone: it takes the pose of
160
+ * the side that is on top (the one made to cover and to uncover — the side under it only drifts),
161
+ * travelling the other way, so a push still moves everything to the left; its keys run backwards,
162
+ * the direction being the opposite one. No dim: nothing is over it. */
163
+ const borrowed = (side: Side | null): Side | null => {
164
+ if (!side) return null
165
+ const lanes = side.lanes.filter((l) => l.prop !== "dim").map((l) => {
166
+ if (l.kind !== KIND_TRANSFORM) return { ...l, keys: l.keys.slice().reverse() }
167
+ const sign: number[] = []
168
+ for (const fn of l.sig!.split(",")) for (let i = transformLanes(fn); i > 0; i--) sign.push(fn.startsWith("translate") ? -1 : 1)
169
+ return { ...l, keys: l.keys.map((k) => k.map((v, i) => v * sign[i]!)).reverse() }
170
+ })
171
+ return lanes.length > 0 ? { ...side, lanes } : null
172
+ }
173
+
174
+ const roleTarget = {} as TweenTarget // a track's target is its role: nothing of a Track reads this
175
+
176
+ /** `incoming` = the screen moves from its pose to rest; otherwise from rest to its pose. */
177
+ const tracksOf = (side: Side | null, role: number, incoming: boolean): Track[] => {
178
+ if (!side) return []
179
+ const channel = { domain: DOM_UI, id: () => role + 1, value: () => null }
180
+ return side.lanes.map((l) => {
181
+ // explicit keys at both ends: a captured one that fails to read would leave a screen in its pose
182
+ const values = incoming ? [...l.keys, l.rest] : [l.rest, ...l.keys]
183
+ const keys: Keyframe[] = values.map((lanes, i) => ({ t: i / (values.length - 1), easing: side.easing, value: { lanes } }))
184
+ return { target: roleTarget, prop: l.prop, channel, kind: l.kind, lanes: l.rest.length, sig: l.sig, atMs: side.atMs, durMs: side.durMs, commit: false, keys }
185
+ })
186
+ }
187
+
188
+ /** @internal The blob of a spec: `roles[i]` is the role of the blob's target `i`. Null = the spec
189
+ * moves nothing. */
190
+ export const _transitionBlob = (spec: TransitionSpec): { data: Float32Array, strings: string[], roles: number[], flags: number } | null => {
191
+ const enter = sideOf(spec.enter), exit = sideOf(spec.exit)
192
+ const enterOnTop = (spec.onTop ?? "enter") === "enter"
193
+ const tracks = [
194
+ ...tracksOf(enter, ROLE_ENTER, true),
195
+ ...tracksOf(exit, ROLE_EXIT, false),
196
+ ...tracksOf(enterOnTop ? enter : borrowed(exit), ROLE_ENTER_ALONE, true),
197
+ ...tracksOf(enterOnTop ? borrowed(enter) : exit, ROLE_EXIT_ALONE, false),
198
+ ]
199
+ if (tracks.length === 0) return null
200
+ let durationMs = 0
201
+ for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
202
+ const blob = buildBlob({ clock: CLOCK_UI, durationMs, delayMs: 0, iterations: 1, pingPong: false, rate: 1, tracks, calls: [] })
203
+ return { data: blob.data, strings: blob.strings, roles: blob.targets.map((t) => (t as number) - 1), flags: enterOnTop ? TRANSITION_ENTER_ON_TOP : 0 }
204
+ }
205
+
206
+ // ---- the registry (one per world, like the runtime's) -------------------------------------------
207
+
208
+ type Entry = { id: number, back?: number, spec: TransitionSpec | null }
209
+ let byName = new Map<string, Entry>()
210
+ let bySpec = new WeakMap<TransitionSpec, Entry>()
211
+
212
+ const define = (spec: TransitionSpec | null): Entry => {
213
+ const blob = spec && _transitionBlob(spec)
214
+ return { id: blob ? _creatorTree.defineTransition(blob.data, blob.strings, blob.roles, blob.flags) : TRANSITION_NONE, spec }
215
+ }
216
+
217
+ const entryOf = (t: Transition): Entry => {
218
+ if (typeof t === "string") {
219
+ let e = byName.get(t)
220
+ if (!e) {
221
+ if (!(t in BACK)) warnOnce(`transition:${t}`, `"${t}" is not a transition — none plays`)
222
+ byName.set(t, e = define(named(t)))
223
+ }
224
+ return e
225
+ }
226
+ let e = bySpec.get(t)
227
+ if (!e) bySpec.set(t, e = define(t))
228
+ return e
229
+ }
230
+
231
+ /** @internal The wire id of a transition (registered on its first use); nothing named = `unset`. */
232
+ export const _transitionId = (t: Transition | undefined, unset: number = TRANSITION_UNSET): number =>
233
+ t === undefined ? unset : entryOf(t).id
234
+
235
+ /** @internal The wire id of the way back of `t`: the catalog's for a name, the mirror of a spec. */
236
+ export const _transitionBack = (t: Transition): number => {
237
+ const e = entryOf(t)
238
+ if (e.back === undefined) {
239
+ e.back = typeof t === "string" ? entryOf(BACK[t as TransitionName] ?? "none").id
240
+ : e.spec ? define(mirrored(e.spec)).id : TRANSITION_NONE
241
+ }
242
+ return e.back
243
+ }
244
+
245
+ /** @internal Tests: a new fake runtime has no transition of the previous one. */
246
+ export const _resetTransitions = (): void => {
247
+ byName = new Map()
248
+ bySpec = new WeakMap()
249
+ }