@lumencast/runtime 0.6.0 → 0.8.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 (133) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/broadcast-Gcd-dmC7.js +12 -0
  3. package/dist/broadcast-Gcd-dmC7.js.map +1 -0
  4. package/dist/control-C5TfClga.js +17 -0
  5. package/dist/control-C5TfClga.js.map +1 -0
  6. package/dist/{index-Crkij3C4.js → index-N-VqrIxN.js} +305 -210
  7. package/dist/index-N-VqrIxN.js.map +1 -0
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.html +1 -1
  11. package/dist/index.js +13 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lumencast.js +14 -9
  14. package/dist/modes/broadcast.d.ts.map +1 -1
  15. package/dist/modes/broadcast.js +6 -1
  16. package/dist/modes/broadcast.js.map +1 -1
  17. package/dist/modes/control.d.ts.map +1 -1
  18. package/dist/modes/control.js +6 -1
  19. package/dist/modes/control.js.map +1 -1
  20. package/dist/modes/test.d.ts.map +1 -1
  21. package/dist/modes/test.js +2 -1
  22. package/dist/modes/test.js.map +1 -1
  23. package/dist/render/allowed-hosts.d.ts +41 -0
  24. package/dist/render/allowed-hosts.d.ts.map +1 -0
  25. package/dist/render/allowed-hosts.js +88 -0
  26. package/dist/render/allowed-hosts.js.map +1 -0
  27. package/dist/render/asset-resolve.d.ts +27 -0
  28. package/dist/render/asset-resolve.d.ts.map +1 -0
  29. package/dist/render/asset-resolve.js +86 -0
  30. package/dist/render/asset-resolve.js.map +1 -0
  31. package/dist/render/blend-mode.d.ts +7 -0
  32. package/dist/render/blend-mode.d.ts.map +1 -0
  33. package/dist/render/blend-mode.js +49 -0
  34. package/dist/render/blend-mode.js.map +1 -0
  35. package/dist/render/bundle.d.ts +9 -1
  36. package/dist/render/bundle.d.ts.map +1 -1
  37. package/dist/render/bundle.js.map +1 -1
  38. package/dist/render/fill.d.ts +36 -3
  39. package/dist/render/fill.d.ts.map +1 -1
  40. package/dist/render/fill.js +222 -23
  41. package/dist/render/fill.js.map +1 -1
  42. package/dist/render/headless.d.ts +39 -0
  43. package/dist/render/headless.d.ts.map +1 -0
  44. package/dist/render/headless.js +83 -0
  45. package/dist/render/headless.js.map +1 -0
  46. package/dist/render/mask.d.ts +87 -0
  47. package/dist/render/mask.d.ts.map +1 -0
  48. package/dist/render/mask.js +243 -0
  49. package/dist/render/mask.js.map +1 -0
  50. package/dist/render/primitives/frame.d.ts.map +1 -1
  51. package/dist/render/primitives/frame.js +91 -5
  52. package/dist/render/primitives/frame.js.map +1 -1
  53. package/dist/render/primitives/grid.d.ts +1 -1
  54. package/dist/render/primitives/grid.d.ts.map +1 -1
  55. package/dist/render/primitives/grid.js +4 -1
  56. package/dist/render/primitives/grid.js.map +1 -1
  57. package/dist/render/primitives/image.d.ts +8 -1
  58. package/dist/render/primitives/image.d.ts.map +1 -1
  59. package/dist/render/primitives/image.js +17 -3
  60. package/dist/render/primitives/image.js.map +1 -1
  61. package/dist/render/primitives/index.d.ts +7 -0
  62. package/dist/render/primitives/index.d.ts.map +1 -1
  63. package/dist/render/primitives/index.js.map +1 -1
  64. package/dist/render/primitives/media.d.ts +11 -2
  65. package/dist/render/primitives/media.d.ts.map +1 -1
  66. package/dist/render/primitives/media.js +14 -3
  67. package/dist/render/primitives/media.js.map +1 -1
  68. package/dist/render/primitives/shape.d.ts.map +1 -1
  69. package/dist/render/primitives/shape.js +29 -26
  70. package/dist/render/primitives/shape.js.map +1 -1
  71. package/dist/render/primitives/stack.d.ts +1 -1
  72. package/dist/render/primitives/stack.d.ts.map +1 -1
  73. package/dist/render/primitives/stack.js +5 -1
  74. package/dist/render/primitives/stack.js.map +1 -1
  75. package/dist/render/primitives/text.d.ts.map +1 -1
  76. package/dist/render/primitives/text.js +0 -1
  77. package/dist/render/primitives/text.js.map +1 -1
  78. package/dist/render/prop-allowlist.d.ts.map +1 -1
  79. package/dist/render/prop-allowlist.js +25 -2
  80. package/dist/render/prop-allowlist.js.map +1 -1
  81. package/dist/render/shape-geometry.d.ts +81 -0
  82. package/dist/render/shape-geometry.d.ts.map +1 -0
  83. package/dist/render/shape-geometry.js +199 -0
  84. package/dist/render/shape-geometry.js.map +1 -0
  85. package/dist/render/shape-index.d.ts +28 -0
  86. package/dist/render/shape-index.d.ts.map +1 -0
  87. package/dist/render/shape-index.js +77 -0
  88. package/dist/render/shape-index.js.map +1 -0
  89. package/dist/render/tree.d.ts.map +1 -1
  90. package/dist/render/tree.js +175 -3
  91. package/dist/render/tree.js.map +1 -1
  92. package/dist/render/universal-wrapper.d.ts +27 -1
  93. package/dist/render/universal-wrapper.d.ts.map +1 -1
  94. package/dist/render/universal-wrapper.js +98 -22
  95. package/dist/render/universal-wrapper.js.map +1 -1
  96. package/dist/{status-pill-BT5b-yET.js → status-pill-BaLQoIDl.js} +2 -2
  97. package/dist/{status-pill-BT5b-yET.js.map → status-pill-BaLQoIDl.js.map} +1 -1
  98. package/dist/{test-_hh1JvAd.js → test-CA30C2By.js} +51 -51
  99. package/dist/{test-_hh1JvAd.js.map → test-CA30C2By.js.map} +1 -1
  100. package/dist/tree-1coZ32nd.js +1777 -0
  101. package/dist/tree-1coZ32nd.js.map +1 -0
  102. package/package.json +6 -5
  103. package/src/index.ts +24 -0
  104. package/src/modes/broadcast.tsx +12 -1
  105. package/src/modes/control.tsx +10 -1
  106. package/src/modes/test.tsx +4 -1
  107. package/src/render/allowed-hosts.tsx +100 -0
  108. package/src/render/asset-resolve.ts +97 -0
  109. package/src/render/blend-mode.ts +50 -0
  110. package/src/render/bundle.ts +6 -1
  111. package/src/render/fill.tsx +266 -24
  112. package/src/render/headless.tsx +129 -0
  113. package/src/render/mask.tsx +389 -0
  114. package/src/render/primitives/frame.tsx +101 -5
  115. package/src/render/primitives/grid.tsx +4 -1
  116. package/src/render/primitives/image.tsx +17 -3
  117. package/src/render/primitives/index.ts +7 -0
  118. package/src/render/primitives/media.tsx +14 -3
  119. package/src/render/primitives/shape.tsx +39 -75
  120. package/src/render/primitives/stack.tsx +5 -1
  121. package/src/render/primitives/text.tsx +0 -1
  122. package/src/render/prop-allowlist.ts +25 -2
  123. package/src/render/shape-geometry.tsx +315 -0
  124. package/src/render/shape-index.tsx +90 -0
  125. package/src/render/tree.tsx +214 -12
  126. package/src/render/universal-wrapper.tsx +128 -21
  127. package/dist/broadcast-DO7jEkix.js +0 -11
  128. package/dist/broadcast-DO7jEkix.js.map +0 -1
  129. package/dist/control-BSfl4_cO.js +0 -16
  130. package/dist/control-BSfl4_cO.js.map +0 -1
  131. package/dist/index-Crkij3C4.js.map +0 -1
  132. package/dist/tree-DBj9SJgs.js +0 -1230
  133. package/dist/tree-DBj9SJgs.js.map +0 -1
@@ -0,0 +1,389 @@
1
+ // Typed mask builder (LSML 1.2 §4.x, ADR 002 §3.2 #E ; Bastion T1/T2/T3/T4).
2
+ //
3
+ // A node may carry a typed `mask` whose fields are ALL typed — there is
4
+ // deliberately NO free-form SVG string anywhere in the shape. This module
5
+ // turns those fields into a real `<mask>` / `<clipPath>` SVG element built
6
+ // element-by-element with React, and a CSS reference (`mask`/`clip-path`)
7
+ // the Tree applies to the masked subtree's wrapper.
8
+ //
9
+ // ── Security contract ────────────────────────────────────────────────
10
+ // - T3 — zero arbitrary SVG markup from the bundle. Every node of the
11
+ // `<mask>` is constructed here from typed fields ; no raw-HTML injection
12
+ // React escape hatch is ever used on this path, so `<script>` /
13
+ // `<foreignObject>` / event-handlers are structurally impossible to emit —
14
+ // a `mask.source` that tries to smuggle markup is treated as a plain string
15
+ // and lands on a `<use href>` / `<image href>` value or is rejected
16
+ // outright, never parsed as markup.
17
+ // - T4 — `mask.type` / `mask.op` are RE-VALIDATED against the closed runtime
18
+ // enum (defence in depth ; the compiler already gated them, but live LSDP
19
+ // deltas bypass the compiler). Out-of-enum → diagnostic + the mask is
20
+ // omitted, never passthrough.
21
+ // - T1/T2 — an image `mask.source` URL passes `checkHostAllowed(src,
22
+ // allowedHosts)` BEFORE it reaches the `<image href>` ; rejection → a
23
+ // diagnostic carrying only a STATIC reason (never the URL, R9) and the
24
+ // whole mask is omitted.
25
+ //
26
+ // The builder is pure : given the typed mask + allowedHosts it returns either
27
+ // `null` (omit — render the subtree unmasked) or a `{ def, style }` pair. It
28
+ // NEVER throws and NEVER echoes a rejected value.
29
+
30
+ import type { ReactElement, CSSProperties } from "react";
31
+ import { checkHostAllowed } from "@lumencast/protocol";
32
+ import { emitDiagnostic } from "./diagnostics";
33
+
34
+ /** Resolve a shape `mask.source.ref` to its inlined mask-coverage geometry
35
+ * (#K). The Tree supplies this from its one-pass `id → shape` index ; it
36
+ * returns `null` for a PENDING ref (id absent from the index) so the mask is
37
+ * omitted, never crashing. The resolver inlines ONLY the referenced shape's
38
+ * geometry — never its own mask (anti-cycle, profondeur = 1). */
39
+ export type ShapeRefResolver = (ref: string) => ReactElement | null;
40
+
41
+ /** Closed `mask.type` allowlist — runtime half of the double-gate (T4).
42
+ * Mirrors `@lumencast/compiler` `MASK_TYPES` ; kept local so the runtime
43
+ * has no compile-time dependency on the compiler package. */
44
+ const MASK_TYPES = new Set(["alpha", "luminance"]);
45
+
46
+ /** Feather pad (px). The group/shape mask wrapper's `overflow:hidden` box is
47
+ * grown by this on every side (tree.tsx, `inset:-PAD`) and the coverage is
48
+ * shifted back by the same amount (buildMask) so a BLURRED mask rim isn't
49
+ * re-cut into a hard square at the box edge. Generous enough for the
50
+ * bg-texture ellipse's ~3σ (53.88 CSS sigma) feather. Sharp masks are
51
+ * unaffected (their alpha-0 region simply sits inside the grown box). */
52
+ export const MASK_FEATHER_PAD = 180;
53
+
54
+ /** Closed `mask.op` allowlist — runtime half of the double-gate (T4). */
55
+ const MASK_OPS = new Set(["intersect", "subtract", "union"]);
56
+
57
+ /** A typed mask source, the only shapes the builder accepts. Anything else
58
+ * (string, missing discriminant, extra markup) is rejected. A `group` source
59
+ * (#O) references a GROUP/FRAME container by id, composited downstream. */
60
+ export type MaskSource =
61
+ | { kind: "shape"; ref: string }
62
+ | { kind: "image"; src: string; srcRect?: { x: number; y: number; w: number; h: number } }
63
+ | { kind: "group"; ref: string };
64
+
65
+ /** The typed mask spec as it reaches the runtime (compiler-lowered or a live
66
+ * LSDP delta). All fields are re-validated here — nothing is trusted. */
67
+ export interface MaskSpec {
68
+ source: MaskSource;
69
+ type: "alpha" | "luminance";
70
+ op: "intersect" | "subtract" | "union";
71
+ position?: { x: number; y: number };
72
+ size?: { w: number; h: number };
73
+ }
74
+
75
+ /** What a successfully-built mask contributes to the render. */
76
+ export interface BuiltMask {
77
+ /** The `<mask>` element to drop into the masked element's SVG `<defs>`. */
78
+ def: ReactElement;
79
+ /** Inline style applying the mask to the masked subtree's wrapper. */
80
+ style: CSSProperties;
81
+ /** The generated mask id (for `url(#…)` wiring and test assertions). */
82
+ id: string;
83
+ /** True when the coverage is FEATHERED (a blurred edge) : the masked wrapper
84
+ * must grow by MASK_FEATHER_PAD (tree.tsx) so the soft rim isn't re-cut into a
85
+ * square, and the coverage here is pre-shifted by the same pad. A sharp mask
86
+ * leaves this false and skips the pad entirely (no structural change). */
87
+ feather: boolean;
88
+ }
89
+
90
+ let maskIdSeq = 0;
91
+ function nextMaskId(): string {
92
+ maskIdSeq = (maskIdSeq + 1) % 1_000_000;
93
+ return `lumen-mask-${maskIdSeq.toString(36)}`;
94
+ }
95
+
96
+ /** Sanitise a shape `ref` to a safe SVG id token. A legitimate ref is a
97
+ * compiler-assigned node id (`[A-Za-z0-9_:-]`). Anything carrying markup
98
+ * characters (`<`, `"`, `#`, whitespace, `(`) is rejected — it can only be
99
+ * an injection attempt, and there is no legitimate id that needs them. */
100
+ function safeIdRef(ref: string): string | null {
101
+ return /^[A-Za-z0-9_:-]+$/.test(ref) ? ref : null;
102
+ }
103
+
104
+ function finite(v: unknown): v is number {
105
+ return typeof v === "number" && Number.isFinite(v);
106
+ }
107
+
108
+ /**
109
+ * Validate a loose `mask` value into a strict {@link MaskSpec}, or `null`.
110
+ * Re-runs the closed-enum gates (T4) and the source-shape discriminant ;
111
+ * a malformed mask is dropped whole (it cannot be partially honoured).
112
+ */
113
+ export function parseMaskSpec(value: unknown, nodeId: string | undefined): MaskSpec | null {
114
+ if (typeof value !== "object" || value === null) return null;
115
+ const m = value as Record<string, unknown>;
116
+
117
+ if (typeof m.type !== "string" || !MASK_TYPES.has(m.type)) {
118
+ emitDiagnostic(nodeId, "mask.type", "is not alpha|luminance ; mask omitted (ADR 002 §3.2, T4)");
119
+ return null;
120
+ }
121
+ if (typeof m.op !== "string" || !MASK_OPS.has(m.op)) {
122
+ emitDiagnostic(
123
+ nodeId,
124
+ "mask.op",
125
+ "is not intersect|subtract|union ; mask omitted (ADR 002 §3.2, T4)",
126
+ );
127
+ return null;
128
+ }
129
+
130
+ const src = m.source;
131
+ if (typeof src !== "object" || src === null) {
132
+ emitDiagnostic(nodeId, "mask.source", "is not a typed shape|image source ; mask omitted (T3)");
133
+ return null;
134
+ }
135
+ const s = src as Record<string, unknown>;
136
+ let source: MaskSource;
137
+ if (s.kind === "shape" && typeof s.ref === "string") {
138
+ source = { kind: "shape", ref: s.ref };
139
+ } else if (s.kind === "image" && typeof s.src === "string") {
140
+ // Preserve the mask source's box (`srcRect`: offset from THIS node + size)
141
+ // when present — it places/sizes the CSS mask to the source raster, shared
142
+ // across siblings of different boxes (the caramel halo + drift fix).
143
+ const sr = s.srcRect as { x?: unknown; y?: unknown; w?: unknown; h?: unknown } | undefined;
144
+ source =
145
+ sr && finite(sr.x) && finite(sr.y) && finite(sr.w) && finite(sr.h)
146
+ ? { kind: "image", src: s.src, srcRect: { x: sr.x, y: sr.y, w: sr.w, h: sr.h } }
147
+ : { kind: "image", src: s.src };
148
+ } else if (s.kind === "group" && typeof s.ref === "string") {
149
+ source = { kind: "group", ref: s.ref };
150
+ } else {
151
+ emitDiagnostic(
152
+ nodeId,
153
+ "mask.source",
154
+ "is not a typed shape|image|group source ; mask omitted (T3)",
155
+ );
156
+ return null;
157
+ }
158
+
159
+ const spec: MaskSpec = { source, type: m.type as MaskSpec["type"], op: m.op as MaskSpec["op"] };
160
+
161
+ const pos = m.position as { x?: unknown; y?: unknown } | undefined;
162
+ if (pos && finite(pos.x) && finite(pos.y)) spec.position = { x: pos.x, y: pos.y };
163
+
164
+ const size = m.size as { w?: unknown; h?: unknown } | undefined;
165
+ if (size && finite(size.w) && finite(size.h)) spec.size = { w: size.w, h: size.h };
166
+
167
+ return spec;
168
+ }
169
+
170
+ /**
171
+ * Build a `<mask>` element + the CSS reference from a typed mask spec.
172
+ * Returns `null` when the mask must be omitted (bad enum, rejected host,
173
+ * unsafe ref) — the caller renders the subtree unmasked.
174
+ *
175
+ * @param mask the typed spec (already enum-checked by parseMaskSpec,
176
+ * but re-checked here so the builder is safe standalone).
177
+ * @param allowedHosts the bundle's `assets.allowedHosts` ; an image source is
178
+ * gated against it (T1/T2) before reaching `<image href>`.
179
+ * @param nodeId for diagnostics (never carries a value, R9).
180
+ * @param resolveShape resolves a shape `mask.source.ref` to its inlined
181
+ * coverage geometry (#K). Omitted / returns `null` ⇒ the
182
+ * shape source is pending → the whole mask is omitted.
183
+ */
184
+ export function buildMask(
185
+ mask: MaskSpec,
186
+ allowedHosts: readonly string[] | undefined,
187
+ nodeId: string | undefined,
188
+ resolveShape?: ShapeRefResolver,
189
+ boxSize?: { w?: number; h?: number },
190
+ feather = false,
191
+ ): BuiltMask | null {
192
+ // T4 — defence in depth : re-validate the enums even though parseMaskSpec
193
+ // already did, so `buildMask` is safe to call on any typed input.
194
+ if (!MASK_TYPES.has(mask.type) || !MASK_OPS.has(mask.op)) {
195
+ emitDiagnostic(nodeId, "mask", "type/op outside the closed enum ; mask omitted (T4)");
196
+ return null;
197
+ }
198
+
199
+ const id = nextMaskId();
200
+
201
+ // The mask content : a single element painted into the mask's luminance
202
+ // (or alpha) channel. Coordinates come from typed numbers only.
203
+ const x = mask.position?.x;
204
+ const y = mask.position?.y;
205
+ const w = mask.size?.w;
206
+ const h = mask.size?.h;
207
+ const geom = {
208
+ ...(finite(x) ? { x } : {}),
209
+ ...(finite(y) ? { y } : {}),
210
+ ...(finite(w) ? { width: w } : {}),
211
+ ...(finite(h) ? { height: h } : {}),
212
+ };
213
+
214
+ let content: ReactElement;
215
+ if (mask.source.kind === "image") {
216
+ // T1/T2 — gate the URL BEFORE it reaches the `<image href>`. A rejected
217
+ // host/scheme omits the whole mask with a static-reason diagnostic.
218
+ const decision = checkHostAllowed(mask.source.src, allowedHosts);
219
+ if (!decision.allowed) {
220
+ emitDiagnostic(
221
+ nodeId,
222
+ "mask.source.src",
223
+ `image host/scheme rejected ; mask omitted (T1/T2 — ${decision.reason ?? "denied"})`,
224
+ );
225
+ return null;
226
+ }
227
+ // `href` is a typed attribute on a constructed element — never markup.
228
+ // For an alpha mask, read the source's own alpha (mask-type:alpha on the
229
+ // <mask>) ; luminance is the SVG default. The image fills the masked box
230
+ // when no explicit geometry is given.
231
+ // External <image> in an SVG <mask> (0×0 SVG) never loads. For `intersect`
232
+ // apply the raster directly as a CSS mask-image. The masked image content is
233
+ // drawn with `object-fit: cover` (Figma scaleMode FILL), so the mask raster
234
+ // — the SAME source image — must `cover` too, else a `Wpx Hpx` (stretch)
235
+ // mask clips a differently-scaled crop and the caramel ribbon shrinks /
236
+ // shifts off its wave. `cover` keeps the alpha aligned with the content.
237
+ if (mask.op === "intersect") {
238
+ const mode = mask.type === "alpha" ? "alpha" : "luminance";
239
+ const url = `url("${mask.source.src}")`;
240
+ // Place + size the mask to the SOURCE raster's box (`srcRect`: offset from
241
+ // this node's box top-left + size), shared by every masked sibling — NOT
242
+ // `cover` of each sibling's box (inflates → orange halo) nor centred
243
+ // (drifts → mask pulled down). The caramel gradient (1146) and 3d-render
244
+ // (930) thus clip to the SAME wave at the SAME spot.
245
+ const rect = (mask.source as { srcRect?: { x: number; y: number; w: number; h: number } })
246
+ .srcRect;
247
+ const usable = rect && finite(rect.x) && finite(rect.y) && finite(rect.w) && finite(rect.h);
248
+ const sizeCss = usable ? `${rect!.w}px ${rect!.h}px` : "cover";
249
+ const posCss = usable ? `${rect!.x}px ${rect!.y}px` : "center";
250
+ return {
251
+ def: <defs key={id} />,
252
+ style: {
253
+ maskImage: url,
254
+ WebkitMaskImage: url,
255
+ maskSize: sizeCss,
256
+ WebkitMaskSize: sizeCss,
257
+ maskRepeat: "no-repeat",
258
+ WebkitMaskRepeat: "no-repeat",
259
+ maskPosition: posCss,
260
+ WebkitMaskPosition: posCss,
261
+ maskMode: mode,
262
+ } as CSSProperties,
263
+ id,
264
+ feather: false,
265
+ };
266
+ }
267
+ const imgGeom =
268
+ Object.keys(geom).length > 0
269
+ ? geom
270
+ : finite(boxSize?.w) && finite(boxSize?.h)
271
+ ? { x: 0, y: 0, width: boxSize.w, height: boxSize.h }
272
+ : { width: "100%", height: "100%" };
273
+ content = <image href={mask.source.src} preserveAspectRatio="none" {...imgGeom} />;
274
+ } else {
275
+ // Shape (#K) or group/frame (#O) source — INLINE the referenced node's
276
+ // resolved coverage geometry into the `<mask>`, built element-by-element
277
+ // (T3 : zero markup). For a `shape` the resolver returns its own outline ;
278
+ // for a `group` it returns the composite of the container's visible
279
+ // children (the resolver routes on the referenced node's kind).
280
+ //
281
+ // The ref is first re-sanitised (defence in depth : a live LSDP delta could
282
+ // smuggle markup chars), then resolved against the Tree's referenceable-node
283
+ // index. A PENDING ref (id absent) → the mask is omitted, sub-tree rendered
284
+ // unmasked (A2.1 : omission, not crash). Anti-cycle is enforced by the
285
+ // resolver inlining ONLY geometry — never any node's own mask.
286
+ const safeRef = safeIdRef(mask.source.ref);
287
+ if (safeRef === null) {
288
+ emitDiagnostic(
289
+ nodeId,
290
+ "mask.source.ref",
291
+ "shape ref is not a safe id token ; mask omitted (T3)",
292
+ );
293
+ return null;
294
+ }
295
+ const resolved = resolveShape?.(safeRef) ?? null;
296
+ if (resolved === null) {
297
+ emitDiagnostic(
298
+ nodeId,
299
+ "mask.source.ref",
300
+ "shape ref does not resolve to an indexed shape ; mask omitted (ADR 002 A2.1 #K)",
301
+ );
302
+ return null;
303
+ }
304
+ // Position/size place the inlined geometry numerically when given,
305
+ // wrapping it in a translated group (typed numbers only, never a string).
306
+ content =
307
+ Object.keys(geom).length > 0 ? (
308
+ <g
309
+ transform={
310
+ finite(geom.x) || finite(geom.y)
311
+ ? `translate(${finite(geom.x) ? geom.x : 0} ${finite(geom.y) ? geom.y : 0})`
312
+ : undefined
313
+ }
314
+ >
315
+ {resolved}
316
+ </g>
317
+ ) : (
318
+ resolved
319
+ );
320
+ }
321
+
322
+ // Feather pad : the mask wrapper's box is grown by MASK_FEATHER_PAD on every
323
+ // side (tree.tsx, `inset:-PAD`) so a BLURRED coverage edge isn't re-cut into a
324
+ // hard square by the wrapper's `overflow:hidden`. The coverage is shifted back
325
+ // by the SAME amount here (userSpaceOnUse), so the mask stays put while the box
326
+ // grows. A sharp mask is unaffected (its alpha-0 region just sits inside the
327
+ // grown box). Applied to the coverage only — never the full-coverage union/
328
+ // subtract rect, which must keep spanning the whole (grown) box.
329
+ if (feather) {
330
+ content = (
331
+ <g key="feather-pad" transform={`translate(${MASK_FEATHER_PAD} ${MASK_FEATHER_PAD})`}>
332
+ {content}
333
+ </g>
334
+ );
335
+ }
336
+
337
+ // `union` widens coverage : a base full-coverage white rect is unioned with
338
+ // the source paint. `subtract` removes the source area from full coverage by
339
+ // painting the source black over a white base. `intersect` (default) keeps
340
+ // only the source's own coverage. All three are expressed by which fixed
341
+ // elements we emit — never by interpolating an author string.
342
+ let inner: ReactElement;
343
+ if (mask.op === "intersect") {
344
+ inner = content;
345
+ } else if (mask.op === "union") {
346
+ inner = (
347
+ <>
348
+ <rect x={0} y={0} width="100%" height="100%" fill="white" />
349
+ {content}
350
+ </>
351
+ );
352
+ } else {
353
+ // subtract : white base, source painted black to carve it out.
354
+ inner = (
355
+ <>
356
+ <rect x={0} y={0} width="100%" height="100%" fill="white" />
357
+ <g style={{ filter: "invert(1)" }}>{content}</g>
358
+ </>
359
+ );
360
+ }
361
+
362
+ const def = (
363
+ <mask
364
+ id={id}
365
+ key={id}
366
+ // `maskContentUnits` (not `maskUnits`) places the coverage in the masked
367
+ // element's user space. The mask REGION is widened to −50%..150% of the
368
+ // masked box (objectBoundingBox units) so a FEATHERED coverage (the
369
+ // bg-texture ellipse blurred 107.76) keeps its soft rim — the default
370
+ // −10%..120% clipped the blur to a hard SQUARE edge. (The prior
371
+ // `maskUnits="userSpaceOnUse"` WITHOUT x/y/width/height shrank the region
372
+ // to the 0×0 defs-svg viewport, hiding every group/shape-masked subtree —
373
+ // the platform-wide bug ; an explicit region is the robust form.)
374
+ maskContentUnits="userSpaceOnUse"
375
+ x="-50%"
376
+ y="-50%"
377
+ width="200%"
378
+ height="200%"
379
+ // T4 — alpha vs luminance is a typed switch (closed enum, never author
380
+ // text ; kebab key so `mask-type` is emitted verbatim across React).
381
+ {...(mask.type === "alpha" && mask.source.kind !== "image" ? { "mask-type": "alpha" } : {})}
382
+ >
383
+ {inner}
384
+ </mask>
385
+ );
386
+
387
+ const ref = `url(#${id})`;
388
+ return { def, style: { mask: ref, WebkitMask: ref }, id, feather };
389
+ }
@@ -2,9 +2,10 @@ import { motion } from "framer-motion";
2
2
  import type { CSSProperties } from "react";
3
3
  import type { PrimitiveProps } from "./index";
4
4
  import { toFramer, mountPlay, resolveTransition } from "../../animate/transitions";
5
- import { backgroundsToCss, parseFills } from "../fill";
5
+ import { backgroundsToCss, parseFills, gateImageFills } from "../fill";
6
6
  import { parseCssColor, warnRejectedColor } from "../css-color";
7
7
  import { emitDiagnostic } from "../diagnostics";
8
+ import { useAllowedHosts } from "../allowed-hosts";
8
9
 
9
10
  /** Absolute-positioned container with size + transform + opacity.
10
11
  * Animatable on `transform` and `opacity` only — width/height/position
@@ -31,7 +32,20 @@ export function Frame({
31
32
  const height = sizeProp(resolved.height);
32
33
  const opacity = numberOr(resolved.opacity, 1);
33
34
  const scale = numberOr(resolved.scale, 1);
34
- const rotate = numberOr(resolved.rotate, 0);
35
+ // Static `rotation` (LSML §5.4) is applied HERE, on the frame's own box, so it
36
+ // pivots around the frame centre (transform-origin: center). It must NOT go on
37
+ // the UniversalWrapper for a frame : the wrapper carries no position/size for
38
+ // a self-positioning frame, so it collapses to a 0-height box and the rotation
39
+ // pivots around the wrong point (the picto/caramel swung off-place). `rotate`
40
+ // (animated) still wins when present.
41
+ const rotate = numberOr(resolved.rotate, numberOr(resolved.rotation, 0));
42
+ // Mirror (Figma `scaleY(-1)`, from a negative transform determinant). Applied
43
+ // on the frame box like the rotation so it composes correctly.
44
+ const flipY = resolved.flipY === true;
45
+ // Compiler forwards `cornerRadius` → `radius` (compile.ts). A frame can be a
46
+ // rounded container (Figma pills, the rounded picto square) — apply it as
47
+ // `border-radius` so the frame isn't rendered square.
48
+ const radius = numberOr(resolved.radius, 0);
35
49
 
36
50
  // 1.0 single-fill prop — used as fallback when 1.1 `backgrounds[]`
37
51
  // is empty. RC#11 : the value is untrusted (static prop OR live LSDP
@@ -41,7 +55,16 @@ export function Frame({
41
55
  if (rawBackground !== undefined && legacyBackground === null) {
42
56
  warnRejectedColor("frame.background", nodeId);
43
57
  }
44
- const backgrounds = parseFills(resolved.backgrounds, "frame.backgrounds", nodeId);
58
+ // LSML 1.2 §3.2 — image-fill `src` is host/scheme-gated (Bastion T1/T2)
59
+ // BEFORE any URL reaches `background-image`. A rejected image-fill is
60
+ // dropped (no passthrough) with an R9-clean diagnostic.
61
+ const allowedHosts = useAllowedHosts();
62
+ const backgrounds = gateImageFills(
63
+ parseFills(resolved.backgrounds, "frame.backgrounds", nodeId),
64
+ allowedHosts,
65
+ "frame.backgrounds",
66
+ nodeId,
67
+ );
45
68
  const clipsContent = resolveClipsContent(resolved.clipsContent, nodeId);
46
69
 
47
70
  // Pick the most expressive declared transition among the animated
@@ -58,20 +81,41 @@ export function Frame({
58
81
  top: 0,
59
82
  width,
60
83
  height,
61
- willChange: "transform, opacity",
84
+ // NB: NO permanent `will-change`. `will-change: opacity` makes the frame an
85
+ // isolated group (the browser pre-promotes it as if opacity < 1), which
86
+ // CONTAINS any descendant `mix-blend-mode` to the frame's own backdrop — so
87
+ // a screen/hard-light layer (Sunshine, Ruby20) silently stops compositing
88
+ // with the scene below. The hint also belongs only on actively-animating
89
+ // nodes (bind-animate adds it there) ; a static board doesn't need it.
62
90
  // LSML 1.1 §4.3 `clipsContent` (default `true`) — children outside
63
91
  // the frame's `size` are clipped. Static layout property : it never
64
92
  // animates, so it stays off the 0-layout-event hot path (ADR 001
65
93
  // §3.2.5). `false` => omit the declaration (CSS initial = visible).
66
94
  ...(clipsContent ? { overflow: "hidden" } : {}),
95
+ ...(radius > 0 ? { borderRadius: radius } : {}),
67
96
  };
68
97
  if (backgrounds.length > 0) {
69
98
  Object.assign(style, backgroundsToCss(backgrounds, nodeId));
70
99
  } else if (legacyBackground !== undefined && legacyBackground !== null) {
71
100
  style.background = legacyBackground;
72
101
  }
102
+ // Figma DROP_SHADOW / INNER_SHADOW. INNER → CSS `box-shadow: inset` (the
103
+ // square's orange/red rim, on the rotated rounded frame, rotates with it in
104
+ // local space — matches Figma). A no-spread DROP → CSS `filter: drop-shadow`,
105
+ // which casts the shadow from the element's RENDERED CONTENT silhouette, not
106
+ // its own rectangular box : the 5 drop shadows live on the UN-rotated wrapper
107
+ // GROUP, so a plain `box-shadow` would project a sharp axis-aligned 464² rect
108
+ // instead of the rotated (8.63°) rounded (r=111) square held inside. The
109
+ // colour is strict-parsed (RC#11) ; geometry is numeric.
110
+ const { filter: shadowFilter, boxShadow } = buildShadows(resolved.shadow, nodeId);
111
+ if (boxShadow !== undefined) style.boxShadow = boxShadow;
112
+ if (shadowFilter !== undefined) style.filter = shadowFilter;
73
113
 
74
- const play = mountPlay({ opacity, x, y, scale, rotate }, animateInitial, nodeId);
114
+ const play = mountPlay(
115
+ { opacity, x, y, scale, rotate, ...(flipY ? { scaleY: -1 } : {}) },
116
+ animateInitial,
117
+ nodeId,
118
+ );
75
119
 
76
120
  return (
77
121
  <motion.div
@@ -107,6 +151,58 @@ function numberOr(v: unknown, fallback: number): number {
107
151
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
108
152
  }
109
153
 
154
+ /** Build validated shadow CSS from the node's `shadow[]` (each entry:
155
+ * `{ inset?, color, x, y, blur, spread }`). Every colour goes through the
156
+ * strict `parseCssColor` gate (RC#11 : the value is wire-drivable) — a
157
+ * rejected colour drops that layer with a diagnostic, never reaches CSS.
158
+ * Geometry values are coerced to finite numbers.
159
+ *
160
+ * Splits by kind :
161
+ * - INNER (inset) OR any shadow with a non-zero spread → `box-shadow`
162
+ * (inset rim / spread halo — both follow the element's own border box,
163
+ * which for the rotated rounded square IS the right silhouette).
164
+ * - no-spread DROP → `filter: drop-shadow`, cast from the element's rendered
165
+ * CONTENT (so a wrapper group's drop shadow tracks its rotated/rounded
166
+ * child instead of the wrapper's rectangular box). drop-shadow's blur maps
167
+ * to a Gaussian stdDeviation ; box-shadow uses 2×σ, so halve to match.
168
+ * Returns `{}` when nothing usable survives. */
169
+ function buildShadows(value: unknown, nodeId?: string): { filter?: string; boxShadow?: string } {
170
+ if (!Array.isArray(value) || value.length === 0) return {};
171
+ const dropParts: string[] = [];
172
+ const boxParts: string[] = [];
173
+ for (const s of value) {
174
+ if (typeof s !== "object" || s === null) continue;
175
+ const spec = s as {
176
+ inset?: unknown;
177
+ color?: unknown;
178
+ x?: unknown;
179
+ y?: unknown;
180
+ blur?: unknown;
181
+ spread?: unknown;
182
+ };
183
+ const color = typeof spec.color === "string" ? parseCssColor(spec.color) : null;
184
+ if (color === null) {
185
+ warnRejectedColor("frame.shadow.color", nodeId);
186
+ continue;
187
+ }
188
+ const x = numberOr(spec.x, 0);
189
+ const y = numberOr(spec.y, 0);
190
+ const blur = numberOr(spec.blur, 0);
191
+ const spread = numberOr(spec.spread, 0);
192
+ const inset = spec.inset === true;
193
+ if (!inset && spread === 0) {
194
+ dropParts.push(`drop-shadow(${x}px ${y}px ${blur / 2}px ${color})`);
195
+ } else {
196
+ const insetKw = inset ? "inset " : "";
197
+ boxParts.push(`${insetKw}${x}px ${y}px ${blur}px ${spread}px ${color}`);
198
+ }
199
+ }
200
+ const out: { filter?: string; boxShadow?: string } = {};
201
+ if (dropParts.length > 0) out.filter = dropParts.join(" ");
202
+ if (boxParts.length > 0) out.boxShadow = boxParts.join(", ");
203
+ return out;
204
+ }
205
+
110
206
  function sizeProp(v: unknown): number | string | undefined {
111
207
  if (typeof v === "number" && Number.isFinite(v)) return v;
112
208
  if (typeof v === "string" && v.length > 0) return v;
@@ -1,7 +1,7 @@
1
1
  import type { PrimitiveProps } from "./index";
2
2
 
3
3
  /** CSS Grid container with declared rows / cols. */
4
- export function Grid({ resolved, children }: PrimitiveProps) {
4
+ export function Grid({ resolved, children, establishesContainingBlock }: PrimitiveProps) {
5
5
  const cols = (resolved.cols as string) ?? "1fr";
6
6
  const rows = (resolved.rows as string) ?? "auto";
7
7
  const gap = (resolved.gap as number | string | undefined) ?? 0;
@@ -12,6 +12,9 @@ export function Grid({ resolved, children }: PrimitiveProps) {
12
12
  gridTemplateColumns: cols,
13
13
  gridTemplateRows: rows,
14
14
  gap,
15
+ // ADR 002 §3.1 (D1) — establish a containing block for absolutely
16
+ // placed children ; untouched for pure auto-layout grids (RC#2).
17
+ ...(establishesContainingBlock ? { position: "relative" } : {}),
15
18
  }}
16
19
  >
17
20
  {children}
@@ -1,13 +1,22 @@
1
1
  import { motion } from "framer-motion";
2
2
  import type { PrimitiveProps } from "./index";
3
3
  import { toFramer, mountPlay, resolveTransition } from "../../animate/transitions";
4
+ import { gateSrc, useAllowedHosts } from "../allowed-hosts";
4
5
 
5
6
  /** Image leaf. `src`, `fit` (cover/contain/fill), `position`,
6
7
  * `opacity`. Opacity is animated when a transition is declared. When an
7
8
  * `animate.from` is lowered onto the node, it mounts at that state and
8
- * plays to its target on mount (mount-play). */
9
+ * plays to its target on mount (mount-play).
10
+ *
11
+ * Security (Bastion T1/T2, ADR 002 #F) : `src` is untrusted (static prop
12
+ * OR live LSDP delta) and was placed into the DOM with NO host/scheme
13
+ * check until #F (the latent 1.1 hole — `assets.allowedHosts` was declared
14
+ * but never enforced). It now passes `gateSrc` BEFORE reaching the `<img>`
15
+ * — a rejected host/scheme omits the image entirely (no passthrough), with
16
+ * an R9-clean diagnostic. This is the runtime arm of the double-gate. */
9
17
  export function Image({ resolved, nodeId, transitionFor, animateInitial }: PrimitiveProps) {
10
- const src = resolved.src as string | undefined;
18
+ const allowedHosts = useAllowedHosts();
19
+ const src = gateSrc(resolved.src, allowedHosts, "image.src", nodeId);
11
20
  if (!src) return null;
12
21
  // LSML §4.5 `alt` is required and was silently unrendered until
13
22
  // issue #34's allowlist audit surfaced it — now forwarded to the DOM.
@@ -33,7 +42,12 @@ export function Image({ resolved, nodeId, transitionFor, animateInitial }: Primi
33
42
  objectPosition: position,
34
43
  width,
35
44
  height,
36
- willChange: "opacity, transform",
45
+ // NB: NO `will-change` here. Promoting the <img> to its own GPU layer
46
+ // hoists it out of the wrapper's paint buffer, so a `mix-blend-mode`
47
+ // on the wrapper (Sunshine screen, Ruby20 / caramel hard-light) blends
48
+ // an EMPTY box with the backdrop → the blend silently no-ops and the
49
+ // image's contribution (the diagonal light streaks, the warm Ruby) is
50
+ // lost. Static images don't need the compositor hint anyway.
37
51
  }}
38
52
  initial={play.initial}
39
53
  animate={play.animate}
@@ -29,6 +29,13 @@ export interface PrimitiveProps {
29
29
  * element mounts in this state and animates to its rendered target on
30
30
  * mount (mount-play). `undefined` → no `initial` (no mount-play). */
31
31
  animateInitial?: Record<string, number | string>;
32
+ /** ADR 002 §3.1 (D1) — set by the Tree when this node has at least one
33
+ * absolutely positioned child. A layout container (`stack`/`grid`)
34
+ * flips to `position: relative` so its children's `left/top` resolve
35
+ * against it. `frame` is already `position: absolute` (a containing
36
+ * block) and ignores it ; leaf primitives have no children and ignore
37
+ * it too. `false`/absent → no change (pure auto-layout, RC#2). */
38
+ establishesContainingBlock?: boolean;
32
39
  children?: ReactNode;
33
40
  }
34
41
 
@@ -1,10 +1,21 @@
1
1
  import type { PrimitiveProps } from "./index";
2
+ import { gateSrc, useAllowedHosts } from "../allowed-hosts";
2
3
 
3
4
  /** Embedded video. `src`, `loop`, `mute`, `autoplay`. Audio is muted
4
5
  * by default — broadcast audio is Pulsar-side, not from the browser
5
- * source. */
6
- export function Media({ resolved }: PrimitiveProps) {
7
- const src = resolved.src as string | undefined;
6
+ * source.
7
+ *
8
+ * Security (Bastion, ADR 003) : `src` is the media primitive's sole
9
+ * network sink (no `poster` / `<source>` / `<track>` are rendered). Like
10
+ * every other asset leaf (image / image-fill / mask) it MUST pass
11
+ * `gateSrc` BEFORE reaching the `<video>` — otherwise a `kind:"media"`
12
+ * node would make the headless Chromium of `zabrender` emit an
13
+ * off-allowlist request (an SSRF surface). A rejected host/scheme omits
14
+ * the source entirely (no passthrough), with an R9-clean diagnostic
15
+ * ({ nodeId, field, reason } — never the URL). */
16
+ export function Media({ resolved, nodeId }: PrimitiveProps) {
17
+ const allowedHosts = useAllowedHosts();
18
+ const src = gateSrc(resolved.src, allowedHosts, "media.src", nodeId);
8
19
  if (!src) return null;
9
20
  const loop = (resolved.loop as boolean | undefined) ?? true;
10
21
  const mute = (resolved.mute as boolean | undefined) ?? true;