lecodes-viewer 0.19.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 (103) hide show
  1. package/README.md +5 -0
  2. package/dist-embed/VERSION.json +5 -0
  3. package/dist-embed/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  4. package/dist-embed/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  5. package/dist-embed/assets/basis-C64VHDVD.js +1 -0
  6. package/dist-embed/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist-embed/assets/createViewer2d-D12Wy3VF.js +1 -0
  8. package/dist-embed/assets/createViewerLite-CP-WHGLg.js +707 -0
  9. package/dist-embed/assets/creator-gl-BF2CQzeI.wasm +0 -0
  10. package/dist-embed/assets/creator-gl.js-B_0GrQRe.symbols +5379 -0
  11. package/dist-embed/assets/creator-ui-BX5Fvnbt.js +1 -0
  12. package/dist-embed/assets/creator-ui-CzKHLH2Q.wasm +0 -0
  13. package/dist-embed/assets/creator2d-BtQ-1_B0.wasm +0 -0
  14. package/dist-embed/assets/draco-BiISTFcR.js +118 -0
  15. package/dist-embed/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  16. package/dist-embed/assets/host-CWKB1WDd.js +162 -0
  17. package/dist-embed/assets/index-CIK8PnUA.js +2 -0
  18. package/dist-embed/assets/lit-DUE9rhY9.filamat +0 -0
  19. package/dist-embed/assets/loadCreatorUiNode-UAdhjRTd.js +1 -0
  20. package/dist-embed/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  21. package/dist-embed/assets/particles-DxYDmI1O.filamat +0 -0
  22. package/dist-embed/assets/particles-quad-DwYo9yCK.filamat +0 -0
  23. package/dist-embed/assets/shadow-C9xJpqlY.filamat +0 -0
  24. package/dist-embed/assets/uberarchive-uXjRYa9p.bin +0 -0
  25. package/dist-embed/assets/unlit-pe4tFY5b.filamat +0 -0
  26. package/dist-embed/assets/unlit-transparent-D9OyaOBR.filamat +0 -0
  27. package/dist-embed/assets/video-eedvhMvS.filamat +0 -0
  28. package/dist-embed/assets/worker-Caf-yYEI.js +2 -0
  29. package/dist-embed/embed.html +48 -0
  30. package/dist-embed/embed.js +187 -0
  31. package/embed.html +48 -0
  32. package/package.json +35 -0
  33. package/src/assets/banner.png +0 -0
  34. package/src/assets/image2.png +0 -0
  35. package/src/assets/landing.mp4 +0 -0
  36. package/src/assets/neutral_ibl128.ktx +0 -0
  37. package/src/assets/neutral_ibl256.ktx +0 -0
  38. package/src/assets/output.mp4 +0 -0
  39. package/src/assets/output2.mp4 +0 -0
  40. package/src/assets/texture.png +0 -0
  41. package/src/assets/vue.svg +1 -0
  42. package/src/canvas/createCanvasHost.ts +2 -0
  43. package/src/createViewer.ts +103 -0
  44. package/src/embed.ts +515 -0
  45. package/src/host.ts +428 -0
  46. package/src/lite-test.ts +160 -0
  47. package/src/main.ts +77 -0
  48. package/src/sourcemapError.ts +2 -0
  49. package/src/ui/CuiProps.gen.ts +103 -0
  50. package/src/ui/UIScreen.ts +486 -0
  51. package/src/ui/createBareUI.ts +32 -0
  52. package/src/ui/createMeasureSystem.ts +220 -0
  53. package/src/ui/createNodeElement.ts +77 -0
  54. package/src/ui/createPagerSystem.ts +345 -0
  55. package/src/ui/createTouchSystem.ts +2 -0
  56. package/src/ui/createUI.ts +806 -0
  57. package/src/ui/creator-ui.js +14 -0
  58. package/src/ui/creator-ui.wasm +0 -0
  59. package/src/ui/defaultStyle.css +176 -0
  60. package/src/ui/layers.ts +9 -0
  61. package/src/ui/mountCanvasUI.ts +213 -0
  62. package/src/ui/nativeViewSystem.ts +91 -0
  63. package/src/ui/presentPlayer.ts +156 -0
  64. package/src/ui/props-apply.ts +403 -0
  65. package/src/ui/router.ts +2 -0
  66. package/src/ui/safeAreaSpec.ts +3 -0
  67. package/src/ui/shells.ts +37 -0
  68. package/src/ui/toasts.ts +2 -0
  69. package/src/ui/transitions.ts +250 -0
  70. package/src/ui/ui.d.ts +78 -0
  71. package/src/ui/vListSystem.ts +212 -0
  72. package/src/ui/viewScale.ts +2 -0
  73. package/src/utils/animate.ts +2 -0
  74. package/src/utils/createUtils.ts +2 -0
  75. package/src/utils/fetch.ts +3 -0
  76. package/src/utils/fonts.ts +2 -0
  77. package/src/utils/globals.ts +2 -0
  78. package/src/utils/imageNode.ts +260 -0
  79. package/src/utils/openFilePicker.ts +2 -0
  80. package/src/utils/svg.ts +2 -0
  81. package/src/utils/videoNode.ts +2 -0
  82. package/src/utils/ws.ts +2 -0
  83. package/src/viewer/createViewer.ts +993 -0
  84. package/src/viewer/creator-gl.js +14 -0
  85. package/src/viewer/creator-gl.js.symbols +5379 -0
  86. package/src/viewer/creator-gl.wasm +0 -0
  87. package/src/viewer/lit.filamat +0 -0
  88. package/src/viewer/mediaplayer.ts +3 -0
  89. package/src/viewer/particles-quad.filamat +0 -0
  90. package/src/viewer/particles.filamat +0 -0
  91. package/src/viewer/physics.ts +140 -0
  92. package/src/viewer/shadow.filamat +0 -0
  93. package/src/viewer/uberarchive.bin +0 -0
  94. package/src/viewer/unlit-transparent.filamat +0 -0
  95. package/src/viewer/unlit.filamat +0 -0
  96. package/src/viewer/validateGlb.ts +262 -0
  97. package/src/viewer/video.filamat +0 -0
  98. package/src/viewer/wasmCrashSymbolizer.ts +133 -0
  99. package/src/viewer2d/createViewer2d.ts +438 -0
  100. package/src/viewer2d/creator2d.js +14 -0
  101. package/src/viewer2d/creator2d.wasm +0 -0
  102. package/src/viewer2d/sprite2d.ts +80 -0
  103. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,156 @@
1
+ // Destination transition player (docs/navigation-presentable-plan.md, phase 2 — web reference).
2
+ // Named transitions are predefined TransitionSpecs; both shapes play through element.animate(),
3
+ // so a custom spec from the SDK needs no extra machinery.
4
+ //
5
+ // The static rule (mirrors the Android SurfaceView constraint so apps feel the same): a side
6
+ // marked `static` (the scene canvas) never animates — only the screen side moves. Screens are
7
+ // position:absolute so they always paint above the in-flow canvas, which makes the degradations
8
+ // coherent: entering a scene slides the old screen away to reveal it; leaving a scene slides the
9
+ // new screen in over it.
10
+
11
+ export type SpecTransform = { x?: number | string, y?: number | string, scale?: number, opacity?: number }
12
+ export type TransitionSpecLike = {
13
+ duration?: number
14
+ easing?: [number, number, number, number]
15
+ incoming?: { from?: SpecTransform }
16
+ outgoing?: { to?: SpecTransform }
17
+ dim?: number
18
+ onTop?: "incoming" | "outgoing"
19
+ }
20
+
21
+ const STACK_EASING: [number, number, number, number] = [0.2, 0, 0, 1]
22
+
23
+ const NAMED: Record<string, TransitionSpecLike> = {
24
+ "none": {},
25
+ "fade": { duration: 280, incoming: { from: { opacity: 0 } } },
26
+ "slide-from-right": { duration: 400, incoming: { from: { x: "100%" } }, outgoing: { to: { x: "-30%" } } },
27
+ "slide-from-left": { duration: 400, incoming: { from: { x: "-100%" } }, outgoing: { to: { x: "30%" } } },
28
+ "slide-from-top": { duration: 400, incoming: { from: { y: "-100%" } }, outgoing: { to: { y: "30%" } } },
29
+ "slide-from-bottom": { duration: 400, incoming: { from: { y: "100%" } }, outgoing: { to: { y: "-30%" } } },
30
+ "zoom": { duration: 400, easing: [0.33, 1.2, 0.62, 1], incoming: { from: { scale: 0.985 } } },
31
+ "zoom-in": { duration: 400, easing: [0.33, 1.2, 0.62, 1], incoming: { from: { scale: 1.08, opacity: 0 } }, onTop: "incoming" },
32
+ "zoom-out": { duration: 420, outgoing: { to: { scale: 0.92, opacity: 0 } }, onTop: "outgoing" },
33
+ "push": { duration: 350, easing: STACK_EASING, incoming: { from: { x: "100%" } }, outgoing: { to: { x: "-25%" } }, dim: 0.3, onTop: "incoming" },
34
+ "pop": { duration: 350, easing: STACK_EASING, incoming: { from: { x: "-25%" } }, outgoing: { to: { x: "100%" } }, dim: 0.3, onTop: "outgoing" },
35
+ }
36
+
37
+ const toTransform = (t: SpecTransform): string => {
38
+ const parts: string[] = []
39
+ if (t.x !== undefined || t.y !== undefined) {
40
+ const px = (v: number | string | undefined) => v === undefined ? "0px" : typeof v === "number" ? v + "px" : v
41
+ parts.push(`translate(${px(t.x)}, ${px(t.y)})`)
42
+ }
43
+ if (t.scale !== undefined) parts.push(`scale(${t.scale})`)
44
+ return parts.length > 0 ? parts.join(" ") : "none"
45
+ }
46
+
47
+ const poseKeyframe = (t: SpecTransform): Keyframe => {
48
+ const kf: Keyframe = { transform: toTransform(t) }
49
+ if (t.opacity !== undefined) kf.opacity = t.opacity
50
+ return kf
51
+ }
52
+ const IDENTITY: Keyframe = { transform: "none", opacity: 1 }
53
+
54
+ // The exit pose for an outgoing side when the incoming one is static (a scene): mirror the
55
+ // incoming entry so the reveal keeps the transition's direction (push's 100%-from-right becomes
56
+ // a full slide-out to the left); pure opacity entries become a fade-out.
57
+ const mirrorExit = (from: SpecTransform): SpecTransform => {
58
+ const neg = (v: number | string | undefined) =>
59
+ v === undefined ? undefined : typeof v === "number" ? -v : v.startsWith("-") ? v.slice(1) : "-" + v
60
+ const out: SpecTransform = {}
61
+ if (from.x !== undefined) out.x = neg(from.x)
62
+ if (from.y !== undefined) out.y = neg(from.y)
63
+ if (from.scale !== undefined) out.scale = 2 - from.scale
64
+ if (from.opacity !== undefined || Object.keys(out).length === 0) out.opacity = 0
65
+ return out
66
+ }
67
+
68
+ type Side = { el: HTMLElement, static?: boolean }
69
+
70
+ export const createPresentPlayer = (container: HTMLElement) => {
71
+ let finishActive: (() => void) | null = null
72
+
73
+ /** Play `transition` between the mounted `incoming` and the still-mounted `outgoing`; calls
74
+ * `onDone` exactly once (after the animation, or immediately for none/unsupported) — the
75
+ * caller physically unmounts the outgoing side there. A new play() snaps the previous
76
+ * transition to its end first. */
77
+ const play = (opts: {
78
+ incoming?: Side
79
+ outgoing?: Side
80
+ transition: string | TransitionSpecLike | null | undefined
81
+ onDone: () => void
82
+ }) => {
83
+ finishActive?.()
84
+
85
+ const spec = typeof opts.transition === "string" ? NAMED[opts.transition] : opts.transition ?? undefined
86
+ const inSide = opts.incoming && !opts.incoming.static ? opts.incoming.el : null
87
+ const outSide = opts.outgoing && !opts.outgoing.static ? opts.outgoing.el : null
88
+
89
+ let inFrom = spec?.incoming?.from
90
+ let outTo = spec?.outgoing?.to
91
+
92
+ // Static-side degradations (see header). A poseless spec ("none") stays instant — the
93
+ // degradations only reshape transitions that actually wanted to move something.
94
+ if (opts.incoming?.static && outSide && (spec?.incoming || spec?.outgoing)) {
95
+ // The incoming scene can't animate: unless this is an exit-shaped transition (onTop:
96
+ // "outgoing" — pop/zoom-out already fully remove the old side), the outgoing screen leaves
97
+ // by the mirror of the entry it was supposed to be covered by.
98
+ if (spec?.onTop !== "outgoing") outTo = inFrom ? mirrorExit(inFrom) : (outTo ?? { opacity: 0 })
99
+ inFrom = undefined
100
+ }
101
+ if (opts.outgoing?.static && inSide && !inFrom && spec && Object.keys(spec).length > 0) {
102
+ inFrom = { opacity: 0 } // something must move; fade the screen in over the scene
103
+ }
104
+
105
+ const hasWork = (inSide && inFrom) || (outSide && outTo)
106
+ if (!spec || !hasWork || typeof (inSide ?? outSide)?.animate !== "function") {
107
+ opts.onDone()
108
+ return
109
+ }
110
+
111
+ const duration = spec.duration ?? 300
112
+ const easing = spec.easing ? `cubic-bezier(${spec.easing.join(",")})` : "cubic-bezier(0.25, 0.1, 0.25, 1)"
113
+ const animations: Animation[] = []
114
+
115
+ // z-order for the duration of the transition (screens only — the static canvas stays in
116
+ // flow at the bottom). Dim sits between the two.
117
+ const onTop = spec.onTop ?? "incoming"
118
+ const topEl = onTop === "incoming" ? inSide : outSide
119
+ const bottomEl = onTop === "incoming" ? outSide : inSide
120
+ if (topEl) topEl.style.zIndex = "3"
121
+ if (bottomEl) bottomEl.style.zIndex = "1"
122
+
123
+ let dimEl: HTMLElement | null = null
124
+ if (spec.dim && spec.dim > 0) {
125
+ dimEl = document.createElement("div")
126
+ dimEl.style.cssText = "position:absolute;inset:0;background:#000;pointer-events:none;z-index:2"
127
+ container.appendChild(dimEl)
128
+ // Entering (push): the under layer darkens; exiting (pop): it un-darkens.
129
+ const [a, b] = onTop === "incoming" ? [0, spec.dim] : [spec.dim, 0]
130
+ animations.push(dimEl.animate([{ opacity: a }, { opacity: b }], { duration, easing }))
131
+ }
132
+
133
+ if (inSide && inFrom) {
134
+ animations.push(inSide.animate([poseKeyframe(inFrom), IDENTITY], { duration, easing }))
135
+ }
136
+ if (outSide && outTo) {
137
+ animations.push(outSide.animate([IDENTITY, poseKeyframe(outTo)], { duration, easing, fill: "forwards" }))
138
+ }
139
+
140
+ let done = false
141
+ const cleanup = () => {
142
+ if (done) return
143
+ done = true
144
+ finishActive = null
145
+ for (const a of animations) a.cancel() // drops fill:forwards so a detached-not-freed screen restores clean
146
+ if (topEl) topEl.style.zIndex = ""
147
+ if (bottomEl) bottomEl.style.zIndex = ""
148
+ dimEl?.remove()
149
+ opts.onDone()
150
+ }
151
+ finishActive = cleanup
152
+ Promise.all(animations.map(a => a.finished)).then(cleanup, cleanup)
153
+ }
154
+
155
+ return { play, finish: () => finishActive?.() }
156
+ }
@@ -0,0 +1,403 @@
1
+ // The DOM host's property-application surface (docs/ui-property-registry-plan.md, phase 3).
2
+ //
3
+ // One entry per creator-ui host property, keyed and compiler-checked against the generated
4
+ // registry surface (CuiProps.gen.ts): a missing key, an extra key, a reset handler on a prop
5
+ // that resets through the set channel, or a MISSING reset on a host-reset prop are all type
6
+ // errors. "unsupported" is the explicit opt-out and feeds the parity registry.
7
+ //
8
+ // String-channel values arrive here already UTF8-decoded; float/uint as numbers. The matrix
9
+ // singleton channel (transform) is registered here too.
10
+ import { CUI_HOST_PROPS, type CuiHostPropKey } from "./CuiProps.gen"
11
+ import { cssFontFamily } from "./createMeasureSystem"
12
+ import type { UIScreen } from "./UIScreen"
13
+
14
+ type ValueOf<K extends CuiHostPropKey> =
15
+ (typeof CUI_HOST_PROPS)[K]["channel"] extends "string" ? string : number
16
+
17
+ type Impl<K extends CuiHostPropKey> =
18
+ | "unsupported"
19
+ | ((typeof CUI_HOST_PROPS)[K]["hostReset"] extends true
20
+ ? { set(screenId: number, nodeId: number, value: ValueOf<K>): void
21
+ reset(screenId: number, nodeId: number): void }
22
+ : { set(screenId: number, nodeId: number, value: ValueOf<K>): void
23
+ reset?: never })
24
+
25
+ export interface UIPropsCtx {
26
+ module: any
27
+ root: HTMLElement
28
+ getNode: (screenId: number, nodeId: number) => HTMLElement | undefined
29
+ getScreen: (screenId: number) => UIScreen | undefined
30
+ addStringHandler(key: string, cb: (screenId: number, nodePtr: number, str: number) => void): void
31
+ addFloatHandler(key: string, cb: (screenId: number, nodePtr: number, val: number) => void): void
32
+ addUIntHandler(key: string, cb: (screenId: number, nodePtr: number, val: number) => void): void
33
+ addResetHandler(key: string, cb: (screenId: number, nodePtr: number) => void): void
34
+ addMatrixHandler(cb: (screenId: number, nodePtr: number, matrixPtr: number) => void): void
35
+ addTransformOriginHandler(cb: (screenId: number, nodePtr: number, x: number, y: number, xPercent: number, yPercent: number) => void): void
36
+ }
37
+
38
+ export const registerUIProps = (ctx: UIPropsCtx) => {
39
+ const { module, root, getNode, getScreen } = ctx
40
+
41
+ const hexColor = (val: number) => "#" + (val >>> 0).toString(16).padStart(8, "0")
42
+
43
+ function applyBg(el: HTMLElement) {
44
+ const layers = []
45
+ if ((el as any)._bgGradient) layers.push((el as any)._bgGradient) // сверху
46
+ if ((el as any)._bgImage) layers.push((el as any)._bgImage) // под градиентом
47
+ el.style.backgroundImage = layers.join(", ")
48
+ }
49
+
50
+ // lineClamp / textOverflow. Applied together (and alongside `display`, which the clamp has to
51
+ // take over) because -webkit-line-clamp ALWAYS ellipsizes — there is no CSS opt-out. A hard cut
52
+ // is therefore spelled a different way: Yoga has already sized the box to the clamped line
53
+ // count (see createMeasureSystem), so plain overflow:hidden does it. That makes "clip"
54
+ // approximate on the web — it cuts on the line-height box, not on the glyph as UIKit does.
55
+ const applyTextClamp = (el: HTMLElement) => {
56
+ const clamp = (el as any)._lineClamp as number | undefined
57
+ const declared = (el as any)._display as string | undefined
58
+ const clamped = !!clamp && clamp > 0
59
+ const ellipsis = clamped && (el as any)._textOverflow !== "clip"
60
+
61
+ if (ellipsis) {
62
+ el.style.setProperty("-webkit-box-orient", "vertical")
63
+ el.style.setProperty("-webkit-line-clamp", String(clamp))
64
+ } else {
65
+ el.style.removeProperty("-webkit-box-orient")
66
+ el.style.removeProperty("-webkit-line-clamp")
67
+ }
68
+
69
+ // An explicit `display: none` still wins — hiding a clamped node must keep working.
70
+ el.style.display = declared === "none" ? "none"
71
+ : ellipsis ? "-webkit-box"
72
+ : (declared ?? "")
73
+ }
74
+
75
+ // Style writers shared by the side/corner families (the key doubles as the camelCase CSS prop).
76
+ const setStyle = (prop: string) => (screenId: number, nodeId: number, value: string) => {
77
+ ;(getNode(screenId, nodeId)!.style as any)[prop] = value
78
+ }
79
+ const borderWidth = (side: string) => ({
80
+ set(screenId: number, nodeId: number, val: number) {
81
+ const el = getNode(screenId, nodeId)!
82
+ ;(el.style as any)[`border${side}Width`] = val + "px"
83
+ el.style.boxSizing = "border-box"
84
+ },
85
+ })
86
+ const borderColor = (side: string) => ({
87
+ set(screenId: number, nodeId: number, val: number) {
88
+ const el = getNode(screenId, nodeId)!
89
+ ;(el.style as any)[`border${side}Color`] = hexColor(val)
90
+ ;(el.style as any)[`border${side}Style`] = "solid"
91
+ el.style.boxSizing = "border-box"
92
+ },
93
+ })
94
+ const cornerRadius = (corner: string) => ({
95
+ set(screenId: number, nodeId: number, val: number) {
96
+ ;(getNode(screenId, nodeId)!.style as any)[`border${corner}Radius`] = val + "px"
97
+ },
98
+ })
99
+
100
+ const impl: { [K in CuiHostPropKey]: Impl<K> } = {
101
+ // ---- layout-dual ----
102
+ // `display` is routed through applyTextClamp because an active lineClamp needs to own the
103
+ // property (-webkit-box). Reset dispatches "flex" through this same handler (registry default).
104
+ display: {
105
+ set(screenId, nodeId, value) {
106
+ const el = getNode(screenId, nodeId)!
107
+ ;(el as any)._display = value
108
+ applyTextClamp(el)
109
+ },
110
+ },
111
+ // "visible" maps to "" so the stylesheet (e.g. .scrollable) keeps owning untouched nodes.
112
+ overflow: {
113
+ set(screenId, nodeId, value) {
114
+ getNode(screenId, nodeId)!.style.overflow = value === "visible" ? "" : value
115
+ },
116
+ },
117
+
118
+ // ---- scroll ----
119
+ scrollDirection: {
120
+ set(screenId, nodeId, value) {
121
+ const el = getNode(screenId, nodeId)!
122
+ if (value === 2) el.classList.add("scrollable_horizontal")
123
+ else el.classList.remove("scrollable_horizontal")
124
+ },
125
+ },
126
+ showScrollbar: {
127
+ set(screenId, nodeId, value) {
128
+ ;(getNode(screenId, nodeId)!.style as any).scrollbarWidth = value > 0.5 ? "" : "none"
129
+ },
130
+ },
131
+ overscrollMode: {
132
+ set(screenId, nodeId, value) {
133
+ // "absorb" (rubber-band without chaining) has no exact CSS spelling — contain is closest.
134
+ ;(getNode(screenId, nodeId)!.style as any).overscrollBehavior =
135
+ value === "default" ? "" : value === "none" ? "none" : "contain"
136
+ },
137
+ },
138
+ keyboardDismissMode: "unsupported", // browser owns the virtual keyboard
139
+ snap: "unsupported", // CSS scroll-snap needs per-child align coordination
140
+ refreshControlColor: "unsupported", // no DOM pull-to-refresh control
141
+
142
+ // ---- border ----
143
+ borderWidth: borderWidth(""),
144
+ borderTopWidth: borderWidth("Top"),
145
+ borderRightWidth: borderWidth("Right"),
146
+ borderBottomWidth: borderWidth("Bottom"),
147
+ borderLeftWidth: borderWidth("Left"),
148
+ // Yoga lays these edge variants out, but no host paints them (registry note).
149
+ borderHorizontalWidth: "unsupported",
150
+ borderVerticalWidth: "unsupported",
151
+ borderStartWidth: "unsupported",
152
+ borderEndWidth: "unsupported",
153
+ borderColor: borderColor(""),
154
+ borderTopColor: borderColor("Top"),
155
+ borderRightColor: borderColor("Right"),
156
+ borderBottomColor: borderColor("Bottom"),
157
+ borderLeftColor: borderColor("Left"),
158
+ borderRadius: {
159
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.borderRadius = val + "px" },
160
+ },
161
+ // Per-corner longhands, dispatched by the core's masked `borderRadius` string path ("0 0 20 20").
162
+ borderTopLeftRadius: cornerRadius("TopLeft"),
163
+ borderTopRightRadius: cornerRadius("TopRight"),
164
+ borderBottomRightRadius: cornerRadius("BottomRight"),
165
+ borderBottomLeftRadius: cornerRadius("BottomLeft"),
166
+
167
+ // ---- text ----
168
+ // Theme-kind reset: the core dispatches the theme value through set; only a theme WITHOUT a
169
+ // `color` key lands here — clear the inline value so defaultStyle.css's cascade takes over.
170
+ color: {
171
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.color = hexColor(val) },
172
+ reset(screenId, nodeId) { getNode(screenId, nodeId)!.style.color = "" },
173
+ },
174
+ fontFamily: {
175
+ set(screenId, nodeId, value) {
176
+ getNode(screenId, nodeId)!.style.fontFamily = cssFontFamily(value)
177
+ },
178
+ reset(screenId, nodeId) { getNode(screenId, nodeId)!.style.fontFamily = "" },
179
+ },
180
+ fontSize: {
181
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.fontSize = val + "px" },
182
+ },
183
+ fontWeight: {
184
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.fontWeight = val.toString() },
185
+ },
186
+ fontStyle: {
187
+ set(screenId, nodeId, val) {
188
+ if (val === 0) getNode(screenId, nodeId)!.style.fontStyle = "normal"
189
+ else if (val === 2) getNode(screenId, nodeId)!.style.fontStyle = "italic"
190
+ },
191
+ },
192
+ lineHeight: {
193
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.lineHeight = val + "px" },
194
+ },
195
+ letterSpacing: {
196
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.letterSpacing = val + "px" },
197
+ },
198
+ textAlign: { set: setStyle("textAlign") },
199
+ textDecoration: { set: setStyle("textDecoration") },
200
+ lineClamp: {
201
+ set(screenId, nodeId, val) {
202
+ const el = getNode(screenId, nodeId)!
203
+ ;(el as any)._lineClamp = val
204
+ // `overflow` is a function of the clamp alone, written ONLY here — never from
205
+ // applyTextClamp, which also runs for `display` on non-text nodes and would clear the
206
+ // overflow a scrollable/cell set at build time (UIScreen). Reset dispatches 0 here too.
207
+ el.style.overflow = val > 0 ? "hidden" : ""
208
+ applyTextClamp(el)
209
+ // The clamp changes the node's measured HEIGHT but touches no Yoga style, so nothing marks
210
+ // the leaf dirty on its own — Yoga would reuse the cached full-text measure. The recalc()
211
+ // that updateStyle/mergeStyle run after this handler then picks up the new size.
212
+ module._markNodeDirty(nodeId)
213
+ },
214
+ },
215
+ textOverflow: {
216
+ set(screenId, nodeId, value) {
217
+ const el = getNode(screenId, nodeId)!
218
+ ;(el as any)._textOverflow = value
219
+ applyTextClamp(el)
220
+ },
221
+ },
222
+
223
+ // ---- box / background ----
224
+ backgroundColor: {
225
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.backgroundColor = hexColor(val) },
226
+ },
227
+ backgroundImage: {
228
+ set(screenId, nodeId, value) {
229
+ const el = getNode(screenId, nodeId)!
230
+ // "" is the registry's no-image default — clear the layer (never emit `url()`).
231
+ ;(el as any)._bgImage = value ? `url(${value})` : undefined
232
+ applyBg(el)
233
+ },
234
+ },
235
+ backgroundSize: { set: setStyle("backgroundSize") },
236
+ // The raw validated CSS gradient string, emitted alongside the struct channel for DOM hosts;
237
+ // "" = no gradient (parse failure or removal).
238
+ backgroundGradientStr: {
239
+ set(screenId, nodeId, value) {
240
+ const el = getNode(screenId, nodeId)!
241
+ ;(el as any)._bgGradient = value || undefined
242
+ applyBg(el)
243
+ },
244
+ },
245
+ rippleColor: "unsupported", // no press-ripple system on the DOM host
246
+
247
+ // ---- effects ----
248
+ opacity: {
249
+ set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.opacity = val.toString() },
250
+ },
251
+ pointerEvents: {
252
+ set(screenId, nodeId, value) {
253
+ getNode(screenId, nodeId)!.style.pointerEvents = value === "none" ? "none" : "auto"
254
+ },
255
+ },
256
+
257
+ // ---- image ----
258
+ objectFit: {
259
+ set(screenId, nodeId, value) {
260
+ const el = getNode(screenId, nodeId)!
261
+ // An explicit sourceRect crop overrides objectFit (the frame always fills the box, via
262
+ // background-size on the crop <div>) — ignore objectFit rather than picking a least-bad fit.
263
+ if ((el as any)._sourceRect) return
264
+ if (el.hasAttribute("data-viewbox")) {
265
+ const size = value === "cover" ? "cover" : value === "fill" ? "100% 100%" : "contain"
266
+ const s = el.style as any
267
+ s.webkitMaskSize = s.maskSize = size
268
+ } else {
269
+ el.style.objectFit = value
270
+ }
271
+ },
272
+ },
273
+ tintColor: {
274
+ set(screenId, nodeId, val) {
275
+ const tint = hexColor(val)
276
+ const screen = getScreen(screenId)
277
+ const el = screen?.nodeMap.get(nodeId)!
278
+ if (el instanceof HTMLDivElement) {
279
+ el.style.backgroundColor = tint // already masked div → just recolor
280
+ } else if (el instanceof HTMLImageElement && el.hasAttribute("data-viewbox")) {
281
+ screen!.tintSvgImage(nodeId, tint) // svg img + new tint → swap to masked div
282
+ } else {
283
+ el.style.backgroundColor = tint // non-svg img: harmless, no mask possible
284
+ }
285
+ },
286
+ // Presence prop: no tint = the image's original colors (masked div swaps back to <img>).
287
+ reset(screenId, nodeId) { getScreen(screenId)?.untintSvgImage(nodeId) },
288
+ },
289
+
290
+ // ---- input ----
291
+ placeholder: {
292
+ set(screenId, nodeId, value) {
293
+ ;(getNode(screenId, nodeId) as HTMLInputElement).placeholder = value
294
+ },
295
+ },
296
+ placeholderColor: {
297
+ set(screenId, nodeId, val) {
298
+ ;(getNode(screenId, nodeId) as HTMLInputElement).style
299
+ .setProperty("--placeholder-color", hexColor(val))
300
+ },
301
+ // Platform-default prop: absent = the browser's placeholder gray (defaultStyle.css fallback).
302
+ reset(screenId, nodeId) {
303
+ ;(getNode(screenId, nodeId) as HTMLInputElement).style.removeProperty("--placeholder-color")
304
+ },
305
+ },
306
+ type: {
307
+ set(screenId, nodeId, value) {
308
+ ;(getNode(screenId, nodeId) as HTMLInputElement).type = value
309
+ },
310
+ },
311
+ enterKey: {
312
+ set(screenId, nodeId, value) {
313
+ ;(getNode(screenId, nodeId) as HTMLInputElement).enterKeyHint = value
314
+ },
315
+ },
316
+ autocapitalize: {
317
+ set(screenId, nodeId, value) {
318
+ ;(getNode(screenId, nodeId) as HTMLInputElement).autocapitalize = value
319
+ },
320
+ },
321
+ autocorrect: {
322
+ set(screenId, nodeId, val) {
323
+ getNode(screenId, nodeId)!.setAttribute("autocorrect", val > 0.5 ? "on" : "off")
324
+ },
325
+ },
326
+ maxLength: {
327
+ set(screenId, nodeId, val) {
328
+ const el = getNode(screenId, nodeId) as HTMLInputElement
329
+ if (val > 0) el.maxLength = val
330
+ else el.removeAttribute("maxlength") // registry: 0 = unlimited
331
+ },
332
+ },
333
+ keyboardShrink: "unsupported", // browser owns the visual-viewport policy
334
+ keyboardDismiss: "unsupported",
335
+
336
+ // ---- widget ----
337
+ // overlayColor: a full-screen layer behind the widget that intercepts clicks. It is kept
338
+ // separate from the widget subtree and sits below `.widget` (z-index 10) but above the scene.
339
+ // A pointerup on it fires onOverlayTap. The layer exists for any non-null overlayColor
340
+ // ("transparent" included); only overlayColor: null removes it (presence prop → host reset).
341
+ // Teardown on hide is handled by UIScreen.removeFromHTML.
342
+ overlayColor: {
343
+ set(screenId, _nodeId, val) {
344
+ const screen = getScreen(screenId)
345
+ if (!screen) return
346
+ let overlay = screen.overlayElement
347
+ if (!overlay) {
348
+ overlay = document.createElement("div")
349
+ overlay.style.position = "absolute"
350
+ overlay.style.inset = "0"
351
+ overlay.style.pointerEvents = "auto"
352
+ overlay.style.zIndex = "9"
353
+ overlay.addEventListener("click", () => {
354
+ for (const listener of screen.node.otl ?? []) listener()
355
+ })
356
+ root.appendChild(overlay)
357
+ screen.overlayElement = overlay
358
+ }
359
+ overlay.style.background = hexColor(val)
360
+ },
361
+ reset(screenId) {
362
+ const screen = getScreen(screenId)
363
+ if (!screen) return
364
+ screen.overlayElement?.remove()
365
+ screen.overlayElement = null
366
+ },
367
+ },
368
+ sheetDetents: "unsupported", // docs/bottom-sheet-plan.md owns these — no web sheet yet
369
+ sheetDetent: "unsupported",
370
+ sheetDismissible: "unsupported",
371
+ }
372
+
373
+ // ---- registration (channel-driven; string pointers decode before the impl sees them) --------
374
+ const unsupported: CuiHostPropKey[] = []
375
+ for (const key of Object.keys(CUI_HOST_PROPS) as CuiHostPropKey[]) {
376
+ const entry = impl[key] as { set: Function, reset?: Function } | "unsupported"
377
+ if (entry === "unsupported") { unsupported.push(key); continue }
378
+ const { channel } = CUI_HOST_PROPS[key]
379
+ if (channel === "string") {
380
+ ctx.addStringHandler(key, (screenId, nodePtr, str) =>
381
+ entry.set(screenId, nodePtr, module.UTF8ToString(str)))
382
+ } else if (channel === "uint") {
383
+ ctx.addUIntHandler(key, (screenId, nodePtr, val) => entry.set(screenId, nodePtr, val))
384
+ } else {
385
+ ctx.addFloatHandler(key, (screenId, nodePtr, val) => entry.set(screenId, nodePtr, val))
386
+ }
387
+ if (entry.reset) ctx.addResetHandler(key, (screenId, nodePtr) => entry.reset!(screenId, nodePtr))
388
+ }
389
+
390
+ // Singleton matrix channel (transform).
391
+ ctx.addMatrixHandler((screenId, nodePtr, matrixPtr) => {
392
+ const i = matrixPtr >> 2
393
+ const f = module.HEAPF32
394
+ getNode(screenId, nodePtr)!.style.transform =
395
+ `matrix(${f[i + 0]}, ${f[i + 1]}, ${f[i + 3]}, ${f[i + 4]}, ${f[i + 6]}, ${f[i + 7]})`
396
+ })
397
+ // Singleton origin channel (transformOrigin) — the matrix's pivot, per axis % of the box or px.
398
+ ctx.addTransformOriginHandler((screenId, nodePtr, x, y, xPercent, yPercent) => {
399
+ getNode(screenId, nodePtr)!.style.transformOrigin = `${x}${xPercent ? "%" : "px"} ${y}${yPercent ? "%" : "px"}`
400
+ })
401
+
402
+ return { unsupported }
403
+ }
@@ -0,0 +1,2 @@
1
+ // Moved to viewer-lite (genericized over the screen type); re-export shim.
2
+ export * from "lecodes-viewer-lite/src/ui/router"
@@ -0,0 +1,3 @@
1
+ // Lives in the viewer-lite package (the minimal web host owns the shared leaf modules);
2
+ // re-exported so viewer-internal imports keep working with ONE definition.
3
+ export * from "lecodes-viewer-lite/src/ui/safeAreaSpec"
@@ -0,0 +1,37 @@
1
+ // Device shells the runner can emulate for an embedder that drives it by name (the landing's
2
+ // `?shell=` / `lecodes-shell`). "abstract" is the neutral frame with the host's default web insets;
3
+ // "ios"/"android" pin the safe-area insets to that device's real values so a screen that skips
4
+ // safe-top visibly collides with the Dynamic Island / status bar.
5
+ //
6
+ // The values mirror the editor's SHELL_SAFE_AREAS (frontend simulator/shells.ts) and lecodes-design's
7
+ // board shells (`lecodes render --device`) — portrait matches the board; landscape is the simulator's
8
+ // own (the board is portrait-only). This is the runner-owned copy so the catalog lives with the host
9
+ // that applies it; the URL/postMessage driver only needs a shell NAME from the embedder.
10
+
11
+ import type { SafeAreaSpec } from "./safeAreaSpec"
12
+
13
+ export type SimulatorShell = "abstract" | "ios" | "android"
14
+
15
+ /** Ordered shell ids (also the accepted `?shell=` values). */
16
+ export const SIMULATOR_SHELLS: SimulatorShell[] = ["abstract", "ios", "android"]
17
+
18
+ export const SHELL_SAFE_AREAS: Record<SimulatorShell, SafeAreaSpec | undefined> = {
19
+ abstract: undefined,
20
+ // Dynamic Island + home indicator (iPhone 14 Pro+) — clearance zones, breathing room built in.
21
+ // Landscape hides the status bar: symmetric island-side insets + a shallower home indicator.
22
+ ios: { portrait: [59, 0, 34, 0], landscape: [0, 59, 21, 59] },
23
+ // Status bar + 3-button navigation bar — both exact-height system BARS (`comfort-*` ADDS its
24
+ // knob past them instead of flooring). Both stay put in landscape.
25
+ android: {
26
+ portrait: [24, 0, 48, 0],
27
+ landscape: [24, 0, 48, 0],
28
+ portraitBars: ["top", "bottom"],
29
+ landscapeBars: ["top", "bottom"],
30
+ },
31
+ }
32
+
33
+ /** Narrow an untrusted string (URL param / postMessage) to a known shell; anything else → abstract. */
34
+ export const asShell = (raw: unknown): SimulatorShell =>
35
+ typeof raw === "string" && (SIMULATOR_SHELLS as string[]).includes(raw)
36
+ ? (raw as SimulatorShell)
37
+ : "abstract"
@@ -0,0 +1,2 @@
1
+ // Moved to viewer-lite; re-export shim.
2
+ export * from "lecodes-viewer-lite/src/ui/toasts"