@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
@@ -2,9 +2,10 @@ import { motion } from "framer-motion";
2
2
  import type { ReactElement } from "react";
3
3
  import type { PrimitiveProps } from "./index";
4
4
  import { toFramer, mountPlay, resolveTransition } from "../../animate/transitions";
5
- import { parseFills, renderFill, sanitizeFills } from "../fill";
5
+ import { parseFills, renderFill, sanitizeFills, gateImageFills } from "../fill";
6
6
  import { parseCssColor, warnRejectedColor } from "../css-color";
7
- import { parseShapePaths, type SubPath } from "../svg-path";
7
+ import { useAllowedHosts } from "../allowed-hosts";
8
+ import { buildShapeOutline } from "../shape-geometry";
8
9
 
9
10
  interface StrokeSpec {
10
11
  color?: string;
@@ -37,7 +38,6 @@ export function Shape({ resolved, nodeId, transitionFor, animateInitial }: Primi
37
38
  const legacyStrokeWidth = numberOr(resolved.stroke_width, 0);
38
39
  const width = numberOr(resolved.width, 100);
39
40
  const height = numberOr(resolved.height, 100);
40
- const radius = numberOr(resolved.radius, 0);
41
41
  const opacity = numberOr(resolved.opacity, 1);
42
42
  // LSML §4.6 `ariaLabel` was silently unrendered until issue #34's
43
43
  // allowlist audit surfaced it — now forwarded as the SVG label.
@@ -50,24 +50,31 @@ export function Shape({ resolved, nodeId, transitionFor, animateInitial }: Primi
50
50
  // LSML 1.1 §4.6 — `fills[]` is the preferred multi-fill form. Fall
51
51
  // back to the singular `fill` for 1.0 bundles. Colours are strict-
52
52
  // validated (a rejected colour drops its layer, with diagnostic).
53
- const fills = sanitizeFills(
54
- parseFills(resolved.fills, "shape.fills", nodeId),
53
+ // LSML 1.2 §3.2 — image-fill `src` is host/scheme-gated (Bastion T1/T2)
54
+ // BEFORE any URL reaches an SVG <image href>. A rejected image-fill is
55
+ // dropped (no passthrough) with an R9-clean diagnostic. Colour fills go
56
+ // through `sanitizeFills` (RC#11) ; image fills pass it through untouched.
57
+ const allowedHosts = useAllowedHosts();
58
+ const fills = gateImageFills(
59
+ sanitizeFills(parseFills(resolved.fills, "shape.fills", nodeId), "shape.fills", nodeId),
60
+ allowedHosts,
55
61
  "shape.fills",
56
62
  nodeId,
57
63
  );
58
64
  const strokes = parseStrokes(resolved.strokes);
59
65
 
60
- // LSML 1.1 §4.6 — `geometry:"path"` : validated subpaths, one
61
- // `<path>` element per entry (ADR 001 §3.2.3). Re-validated at every
62
- // render — see module header of svg-path.ts (RC#10).
63
- const subpaths = kind === "path" ? parseShapePaths(resolved, nodeId) : [];
64
-
65
66
  // Each fill compiles to a (defs, ref) pair. We render the shape
66
67
  // outline once per fill, layered top-to-bottom (first entry → on
67
68
  // top, per §4.12). The defs are aggregated for a single <defs>.
68
69
  const fillRenders = fills.map(renderFill);
69
70
  const allDefs = fillRenders.flatMap((r) => r.defs);
70
- const fillRefs = fillRenders.length > 0 ? fillRenders.map((r) => r.ref) : [legacyFill];
71
+ // #L each fill layer carries its (runtime-revalidated) `mix-blend-mode`,
72
+ // applied on that layer's SVG element, independent of the node-level blend
73
+ // (#D, on the wrapper). Legacy single-fill path carries no per-fill blend.
74
+ const fillLayers: { ref: string; mixBlendMode?: string }[] =
75
+ fillRenders.length > 0
76
+ ? fillRenders.map((r) => ({ ref: r.ref, mixBlendMode: r.mixBlendMode }))
77
+ : [{ ref: legacyFill }];
71
78
 
72
79
  // Strokes : same layered approach, but solid colours only (gradient
73
80
  // strokes are out of scope for §4.6 1.1). Each stroke is rendered
@@ -83,7 +90,7 @@ export function Shape({ resolved, nodeId, transitionFor, animateInitial }: Primi
83
90
  // Stack order : fillRefs are emitted top-to-bottom per §4.12. SVG
84
91
  // paints later siblings on top, so we reverse here so the first
85
92
  // entry in fills[] ends up rendered last (visually on top).
86
- const stackedFills = [...fillRefs].reverse();
93
+ const stackedFills = [...fillLayers].reverse();
87
94
  const stackedStrokes = [...strokeLayers].reverse();
88
95
  // For paths, a zero-width / transparent stroke pass would only emit
89
96
  // invisible duplicate <path> elements — skip it.
@@ -92,71 +99,29 @@ export function Shape({ resolved, nodeId, transitionFor, animateInitial }: Primi
92
99
  ? stackedStrokes.filter((s) => s.width > 0 && s.color !== "transparent")
93
100
  : stackedStrokes;
94
101
 
102
+ // Integration #K × #L — geometry construction is delegated to the single
103
+ // typed outline builder (`shape-geometry.tsx`, ADR 002 A2.1 #K), so a
104
+ // referenced shape's mask coverage is built from the IDENTICAL geometry as
105
+ // its on-screen render. The per-fill `mix-blend-mode` (#L, already runtime-
106
+ // revalidated by `renderFill` against the closed enum) is threaded through
107
+ // the paint argument and applied as inline `style` on the painted layer's
108
+ // SVG element. `undefined` (absent / out-of-enum) → no style key, layer
109
+ // blends `normal` (rétro-compat). Stroke passes never carry a fill blend —
110
+ // and the mask coverage path NEVER carries a blend (a mask is a coverage
111
+ // stencil, not a colour reproduction : `buildMaskCoverageFromShape` omits
112
+ // `mixBlendMode` entirely, #K hypothesis 2).
95
113
  const renderShape = (
96
114
  fill: string,
97
115
  stroke: { color: string; width: number },
98
116
  keyPrefix: string,
99
- ): ReactElement => {
100
- if (kind === "path") {
101
- // §4.6 — fills and strokes apply to the union of all subpaths ;
102
- // each subpath keeps its own winding rule (fill-rule).
103
- return (
104
- <g key={keyPrefix}>
105
- {subpaths.map((p: SubPath, i: number) => (
106
- <path
107
- key={i}
108
- d={p.d}
109
- fillRule={p.fillRule}
110
- fill={fill}
111
- stroke={stroke.color}
112
- strokeWidth={stroke.width}
113
- />
114
- ))}
115
- </g>
116
- );
117
- }
118
- if (kind === "circle") {
119
- return (
120
- <circle
121
- key={keyPrefix}
122
- cx={width / 2}
123
- cy={height / 2}
124
- r={Math.min(width, height) / 2 - stroke.width / 2}
125
- fill={fill}
126
- stroke={stroke.color}
127
- strokeWidth={stroke.width}
128
- />
129
- );
130
- }
131
- if (kind === "line") {
132
- return (
133
- <line
134
- key={keyPrefix}
135
- x1="0"
136
- y1={height / 2}
137
- x2={width}
138
- y2={height / 2}
139
- stroke={stroke.color || fill}
140
- strokeWidth={stroke.width || 1}
141
- />
142
- );
143
- }
144
- // rect default
145
- return (
146
- <rect
147
- key={keyPrefix}
148
- x={stroke.width / 2}
149
- y={stroke.width / 2}
150
- width={Math.max(0, width - stroke.width)}
151
- height={Math.max(0, height - stroke.width)}
152
- rx={radius}
153
- ry={radius}
154
- fill={fill}
155
- stroke={stroke.color}
156
- strokeWidth={stroke.width}
157
- />
117
+ mixBlendMode?: string,
118
+ ): ReactElement =>
119
+ buildShapeOutline(
120
+ resolved,
121
+ { fill, stroke: stroke.color, strokeWidth: stroke.width, mixBlendMode },
122
+ nodeId,
123
+ keyPrefix,
158
124
  );
159
- };
160
125
 
161
126
  return (
162
127
  <motion.svg
@@ -167,11 +132,10 @@ export function Shape({ resolved, nodeId, transitionFor, animateInitial }: Primi
167
132
  initial={play.initial}
168
133
  animate={play.animate}
169
134
  transition={transition}
170
- style={{ willChange: "opacity, transform" }}
171
135
  >
172
136
  {allDefs.length > 0 && <defs>{allDefs}</defs>}
173
- {stackedFills.map((ref, i) =>
174
- renderShape(ref, { color: "transparent", width: 0 }, `fill-${i}`),
137
+ {stackedFills.map((layer, i) =>
138
+ renderShape(layer.ref, { color: "transparent", width: 0 }, `fill-${i}`, layer.mixBlendMode),
175
139
  )}
176
140
  {effectiveStrokes.map((s, i) => renderShape("none", s, `stroke-${i}`))}
177
141
  </motion.svg>
@@ -11,7 +11,7 @@ import type { PrimitiveProps } from "./index";
11
11
  * Mapped to CSS `row-gap` (horizontal stack) or `column-gap`
12
12
  * (vertical stack). Ignored when `wrap` is false.
13
13
  */
14
- export function Stack({ resolved, children }: PrimitiveProps) {
14
+ export function Stack({ resolved, children, establishesContainingBlock }: PrimitiveProps) {
15
15
  const direction = (resolved.direction as string) ?? "vertical";
16
16
  const gap = numberOr(resolved.gap, 0);
17
17
  const wrap = resolved.wrap === true;
@@ -25,6 +25,10 @@ export function Stack({ resolved, children }: PrimitiveProps) {
25
25
  flexDirection: isHorizontal ? "row" : "column",
26
26
  alignItems: align,
27
27
  justifyContent: justify,
28
+ // ADR 002 §3.1 (D1) — establish a containing block when a child is
29
+ // absolutely placed, so its `left/top` resolve against this stack.
30
+ // Untouched for pure auto-layout stacks (RC#2).
31
+ ...(establishesContainingBlock ? { position: "relative" } : {}),
28
32
  };
29
33
 
30
34
  if (wrap) {
@@ -113,7 +113,6 @@ export function Text({ resolved, nodeId, transitionFor, animateInitial }: Primit
113
113
  color: colour,
114
114
  textAlign: align as React.CSSProperties["textAlign"],
115
115
  ...typography,
116
- willChange: "opacity, transform",
117
116
  }}
118
117
  initial={play.initial}
119
118
  animate={play.animate}
@@ -22,8 +22,31 @@ import type { RenderKind, RenderNode } from "./bundle";
22
22
  import { emitDiagnostic } from "./diagnostics";
23
23
 
24
24
  /** Universal props consumed by the Tree renderer itself
25
- * (`UniversalWrapper`, LSML 1.1 §5.4) on every primitive. */
26
- const UNIVERSAL_PROPS = ["visible", "opacity", "universal_opacity", "rotation", "sizing"] as const;
25
+ * (`UniversalWrapper`, LSML 1.1 §5.4) on every primitive.
26
+ *
27
+ * ADR 002 §3.1 (D1) — `x`/`y` (compiler-flattened LSML `position:{x,y}`)
28
+ * and `width`/`height` (flattened `size:{w,h}`) are consumed universally
29
+ * for absolute placement : the Tree reads them into the wrapper's
30
+ * `position`/`size`, so they are honoured on EVERY primitive, not
31
+ * silently dropped. Frame additionally reads them into its own absolute
32
+ * box (it lists them explicitly below too — the union is harmless). */
33
+ const UNIVERSAL_PROPS = [
34
+ "visible",
35
+ "opacity",
36
+ "universal_opacity",
37
+ "rotation",
38
+ "sizing",
39
+ "x",
40
+ "y",
41
+ "width",
42
+ "height",
43
+ // ADR 002 §3.2 (D2 / #D) — `blendMode` is consumed universally by the
44
+ // wrapper (→ CSS `mix-blend-mode`) on every primitive.
45
+ "blendMode",
46
+ // ADR 002 §3.2 (#E) — a typed `mask` is lowered onto EVERY primitive by the
47
+ // compiler and consumed by the Tree (built into a `<mask>` SVG element).
48
+ "mask",
49
+ ] as const;
27
50
 
28
51
  function allow(keys: readonly string[]): ReadonlySet<string> {
29
52
  return new Set([...UNIVERSAL_PROPS, ...keys]);
@@ -0,0 +1,315 @@
1
+ // Typed shape-geometry builder (ADR 002 §3.2 Amendment 2 / A2.1 #K).
2
+ //
3
+ // A single source of truth for turning a `shape` RenderNode's typed geometry
4
+ // props (`geometry`/`kind`, `width`, `height`, `radius`, path `d`) into SVG
5
+ // outline elements — built ELEMENT-BY-ELEMENT with React, never from a markup
6
+ // string. Two call-sites consume it :
7
+ //
8
+ // 1. the `shape` primitive, which paints the outline with its fills/strokes ;
9
+ // 2. `buildMask` (#K), which inlines the RESOLVED geometry of a referenced
10
+ // shape into a `<mask>` as coverage paint (white) — replacing the former
11
+ // `<use href="#id">` that relied on a sibling being defs-resolvable.
12
+ //
13
+ // ── Security / structural contract ───────────────────────────────────
14
+ // - T3 — zero arbitrary SVG markup. Every element here is a constructed
15
+ // React node ; no raw-HTML React escape hatch is ever used on this path.
16
+ // A path `d` still flows through `validatePathData` (svg-path.ts).
17
+ // - Anti-cycle (A2.1, Bastion condition) — this builder reads ONLY the
18
+ // node's own geometry props. It never reads `node.mask`, never descends
19
+ // into `node.children`, and never re-enters the mask builder. Resolving a
20
+ // `mask.source.ref` to a shape therefore inlines that shape's geometry and
21
+ // NOTHING else : a `mask → shape (that itself carries a mask) → …` chain is
22
+ // structurally cut at depth 1, so no unbounded recursion / DoS is possible.
23
+
24
+ import type { CSSProperties, ReactElement } from "react";
25
+ import type { RenderNode } from "./bundle";
26
+ import { parseShapePaths, type SubPath } from "./svg-path";
27
+ import { emitDiagnostic } from "./diagnostics";
28
+
29
+ /** The geometry kind, read from `geometry` (compiler) or `kind` (legacy). */
30
+ function geometryKind(props: Record<string, unknown>): string {
31
+ return (props.geometry as string | undefined) ?? (props.kind as string | undefined) ?? "rect";
32
+ }
33
+
34
+ function numberOr(v: unknown, fallback: number): number {
35
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
36
+ }
37
+
38
+ /**
39
+ * Build the outline of a `shape` node as SVG elements painted with the given
40
+ * `fill` and `stroke`. Used by the `shape` primitive (per-fill layering) and,
41
+ * with a fixed coverage paint, by the mask builder (#K).
42
+ *
43
+ * Integration #L — `paint.mixBlendMode` carries a per-fill `mix-blend-mode`
44
+ * that has ALREADY been revalidated against the closed enum by `renderFill`
45
+ * (double-gate T4). It is applied as an inline `style` on the painted layer's
46
+ * SVG element. It is `undefined` for stroke passes and ALWAYS `undefined` for
47
+ * mask coverage (`buildMaskCoverageFromShape` never sets it) — a mask is a
48
+ * coverage stencil, never a colour/blend reproduction (#K hypothesis 2). No
49
+ * value other than an enum-validated keyword can reach this style key.
50
+ *
51
+ * `nodeId` is for path-validation diagnostics only (never a value, R9).
52
+ */
53
+ export function buildShapeOutline(
54
+ props: Record<string, unknown>,
55
+ paint: { fill: string; stroke?: string; strokeWidth?: number; mixBlendMode?: string },
56
+ nodeId: string | undefined,
57
+ keyPrefix = "geom",
58
+ ): ReactElement {
59
+ const kind = geometryKind(props);
60
+ const width = numberOr(props.width, 100);
61
+ const height = numberOr(props.height, 100);
62
+ const radius = numberOr(props.radius, 0);
63
+ const stroke = paint.stroke ?? "none";
64
+ const strokeWidth = paint.strokeWidth ?? 0;
65
+ // #L — only an enum-revalidated keyword reaches here ; absent → no style key
66
+ // (layer blends `normal`, rétro-compat). Mask coverage never passes one.
67
+ const style: CSSProperties | undefined =
68
+ paint.mixBlendMode !== undefined
69
+ ? ({ mixBlendMode: paint.mixBlendMode } as CSSProperties)
70
+ : undefined;
71
+
72
+ if (kind === "path") {
73
+ const subpaths = parseShapePaths(props, nodeId);
74
+ return (
75
+ <g key={keyPrefix} style={style}>
76
+ {subpaths.map((p: SubPath, i: number) => (
77
+ <path
78
+ key={i}
79
+ d={p.d}
80
+ fillRule={p.fillRule}
81
+ fill={paint.fill}
82
+ stroke={stroke}
83
+ strokeWidth={strokeWidth}
84
+ />
85
+ ))}
86
+ </g>
87
+ );
88
+ }
89
+ if (kind === "circle") {
90
+ // A Figma ELLIPSE node lowers to `circle`, but its box is often NON-square
91
+ // (the bg-shine glows are 699×428, 955×586…). Render an <ellipse> with the
92
+ // per-axis radii so the shape keeps its real size — a circle (w===h) is the
93
+ // degenerate case. Rendering a `min(w,h)` circle shrank the glows → the warm
94
+ // wash came out too dark and the wrong shape.
95
+ return (
96
+ <ellipse
97
+ key={keyPrefix}
98
+ style={style}
99
+ cx={width / 2}
100
+ cy={height / 2}
101
+ rx={Math.max(0, width / 2 - strokeWidth / 2)}
102
+ ry={Math.max(0, height / 2 - strokeWidth / 2)}
103
+ fill={paint.fill}
104
+ stroke={stroke}
105
+ strokeWidth={strokeWidth}
106
+ />
107
+ );
108
+ }
109
+ if (kind === "line") {
110
+ return (
111
+ <line
112
+ key={keyPrefix}
113
+ style={style}
114
+ x1="0"
115
+ y1={height / 2}
116
+ x2={width}
117
+ y2={height / 2}
118
+ stroke={stroke !== "none" ? stroke : paint.fill}
119
+ strokeWidth={strokeWidth || 1}
120
+ />
121
+ );
122
+ }
123
+ // rect default
124
+ return (
125
+ <rect
126
+ key={keyPrefix}
127
+ style={style}
128
+ x={strokeWidth / 2}
129
+ y={strokeWidth / 2}
130
+ width={Math.max(0, width - strokeWidth)}
131
+ height={Math.max(0, height - strokeWidth)}
132
+ rx={radius}
133
+ ry={radius}
134
+ fill={paint.fill}
135
+ stroke={stroke}
136
+ strokeWidth={strokeWidth}
137
+ />
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Build a referenced shape's geometry as mask COVERAGE paint (#K).
143
+ *
144
+ * Resolves to a white-painted outline (the default mask luminance paint) of
145
+ * the referenced `shape` node — inlined into the `<mask>`. Returns `null` when
146
+ * the node is not a paintable shape (defensive : the index only stores shapes,
147
+ * but a live delta could mutate one), so the caller omits the mask.
148
+ *
149
+ * Anti-cycle : reads only `node.props` geometry — never the node's own `mask`
150
+ * or children (profondeur de résolution = 1, A2.1 invariant).
151
+ */
152
+ export function buildMaskCoverageFromShape(
153
+ node: RenderNode,
154
+ nodeId: string | undefined,
155
+ ): ReactElement | null {
156
+ if (node.kind !== "shape") return null;
157
+ const props = node.props ?? {};
158
+ // White coverage : the SVG mask default paints luminance from white = full
159
+ // coverage. We deliberately ignore the shape's own fills/strokes — a mask is
160
+ // a coverage stencil, not a colour reproduction (A2.1 : inline the geometry).
161
+ return buildShapeOutline(props, { fill: "white" }, nodeId, "mask-cover");
162
+ }
163
+
164
+ /** Default cap on the number of direct children composited into a group mask
165
+ * (A4.4 budget T5). A container with more visible resolvable children is
166
+ * TRUNCATED at this count with a diagnostic — never an unbounded build. The
167
+ * real `817:2011` has 4 children ; the cap is generous yet bounded. */
168
+ export const GROUP_MASK_MAX_CHILDREN = 64;
169
+
170
+ /** Default cap on container-descent depth (A4.4 anti-cycle). `1` = a group's
171
+ * direct children may themselves be one level of sub-container ; below that a
172
+ * sub-container contributes nothing (diagnostic), so a `group → group → …`
173
+ * chain can never recurse without bound. We NEVER read a node's own `mask`
174
+ * during descent (a `mask → group → … → mask` cycle is structurally cut). */
175
+ export const GROUP_MASK_MAX_DEPTH = 1;
176
+
177
+ /** True iff a child node is excluded from the composite (`visible:false`).
178
+ * `visible` lives in the node's static props (compiler-flattened), mirroring
179
+ * the Tree's universal extraction (`resolved.visible`). */
180
+ function isHidden(node: RenderNode): boolean {
181
+ return (node.props as { visible?: unknown } | undefined)?.visible === false;
182
+ }
183
+
184
+ function numProp(props: Record<string, unknown> | undefined, key: string): number {
185
+ const v = props?.[key];
186
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
187
+ }
188
+
189
+ /**
190
+ * Composite the mask COVERAGE of a GROUP/FRAME container's VISIBLE children
191
+ * into a single typed `<g>` (#O, ADR 002 A4.3/A4.4).
192
+ *
193
+ * The coverage is the **union** of the white outlines of every visible direct
194
+ * child of geometry-resolvable kind — union being the native behaviour of
195
+ * stacking white coverages in one `<mask>` (SVG alpha cumulates). Each child's
196
+ * geometry is translated by its own `x`/`y` so the union lands in the
197
+ * container's coordinate space.
198
+ *
199
+ * Invariants (A4.4) :
200
+ * - **visible-only** : `visible:false` children do not contribute.
201
+ * - **anti-cycle, depth = 1** : a direct child that is itself a container is
202
+ * descended at most `maxDepth` levels (default 1). We read ONLY geometry —
203
+ * never any node's own `mask` — so a `mask → group → … → mask` chain is
204
+ * structurally cut and no recursion through masks is possible.
205
+ * - **budget T5** : at most `maxChildren` direct children are composited ;
206
+ * beyond the cap the remainder is dropped with a static-reason diagnostic
207
+ * (R9 — never the id value), never an unbounded composite / freeze.
208
+ * - **omission, not crash** : a container with no visible resolvable child
209
+ * returns `null` so the caller omits the mask (no throw).
210
+ *
211
+ * @param nodeId for diagnostics only (never a value, R9).
212
+ * @param maxDepth container-descent cap (default {@link GROUP_MASK_MAX_DEPTH}).
213
+ * @param maxChildren per-container child cap (default {@link GROUP_MASK_MAX_CHILDREN}).
214
+ */
215
+ export function buildMaskCoverageFromGroup(
216
+ node: RenderNode,
217
+ nodeId: string | undefined,
218
+ maxDepth: number = GROUP_MASK_MAX_DEPTH,
219
+ maxChildren: number = GROUP_MASK_MAX_CHILDREN,
220
+ ): ReactElement | null {
221
+ if (node.kind !== "frame") return null;
222
+ const parts = collectCoverage(node, nodeId, maxDepth, maxChildren, "grp");
223
+ if (parts.length === 0) return null;
224
+ return <g key="mask-group-cover">{parts}</g>;
225
+ }
226
+
227
+ /** True when a group-mask source carries a LAYER_BLUR on any (depth-bounded)
228
+ * visible geometry child — the FEATHERED case (e.g. the bg-texture ellipse
229
+ * blurred 107.76) whose soft rim needs the wrapper feather pad (mask.tsx /
230
+ * tree.tsx). A sharp source returns false so the pad is skipped entirely — no
231
+ * extra wrapper, no structural change to ordinary masks. Mirrors the blur
232
+ * detection + descent bounds of `collectCoverage`. */
233
+ export function coverageIsFeathered(
234
+ node: RenderNode,
235
+ depth: number = GROUP_MASK_MAX_DEPTH,
236
+ ): boolean {
237
+ if (node.kind !== "frame") return false;
238
+ for (const child of (node.children ?? []) as RenderNode[]) {
239
+ if (isHidden(child)) continue;
240
+ if (numProp(child.props, "blur") > 0) return true;
241
+ if (child.kind === "frame" && depth > 0 && coverageIsFeathered(child, depth - 1)) return true;
242
+ }
243
+ return false;
244
+ }
245
+
246
+ /** Recursive (depth-bounded) collector : returns the white coverage elements
247
+ * of `node`'s visible direct children, translating each by its own `x`/`y`.
248
+ * A child container is descended only while `depth > 0`. NEVER reads a node's
249
+ * `mask`. */
250
+ function collectCoverage(
251
+ node: RenderNode,
252
+ nodeId: string | undefined,
253
+ depth: number,
254
+ maxChildren: number,
255
+ keyPrefix: string,
256
+ ): ReactElement[] {
257
+ const children = node.children ?? [];
258
+ const out: ReactElement[] = [];
259
+ let composited = 0;
260
+ for (let i = 0; i < children.length; i++) {
261
+ const child = children[i] as RenderNode;
262
+ if (isHidden(child)) continue;
263
+ if (composited >= maxChildren) {
264
+ emitDiagnostic(
265
+ nodeId,
266
+ "mask.source.ref",
267
+ `group mask exceeds the ${maxChildren}-child composite cap ; remainder truncated (ADR 002 A4.4 T5)`,
268
+ );
269
+ break;
270
+ }
271
+ let part: ReactElement | null = null;
272
+ if (child.kind === "shape") {
273
+ part = buildShapeOutline(child.props ?? {}, { fill: "white" }, child.id, `${keyPrefix}-${i}`);
274
+ } else if (child.kind === "frame" && depth > 0) {
275
+ // Bounded container descent (anti-cycle) — geometry only, never `mask`.
276
+ const sub = collectCoverage(child, nodeId, depth - 1, maxChildren, `${keyPrefix}-${i}`);
277
+ if (sub.length > 0) part = <g key={`${keyPrefix}-${i}`}>{sub}</g>;
278
+ }
279
+ // A non-geometry child (text/image/instance) or a too-deep sub-container
280
+ // contributes nothing — the mask is a coverage stencil over geometry only.
281
+ if (part === null) continue;
282
+ // A LAYER_BLUR on the coverage shape FEATHERS the mask edge — the bg-texture
283
+ // mask is a single ellipse blurred 107.76 (radius), so the WP tiles fade out
284
+ // softly instead of being cut by a hard circular edge. Apply it via an SVG
285
+ // `<feGaussianBlur>` (radius ≈ 2× the CSS sigma) with a WIDE filter region :
286
+ // a plain CSS `filter:blur()` on an SVG element clips to the default
287
+ // −10%..120% box, so the feathered ellipse re-appeared with a hard SQUARE
288
+ // edge. The wide region (−120%..340%) lets the soft rim spread unclipped.
289
+ const childBlur = numProp(child.props, "blur");
290
+ if (childBlur > 0) {
291
+ const bf = `lumen-mcov-blur-${nodeId ?? "x"}-${keyPrefix}-${i}`;
292
+ part = (
293
+ <g key={`${keyPrefix}-b-${i}`}>
294
+ <filter id={bf} x="-120%" y="-120%" width="340%" height="340%">
295
+ <feGaussianBlur stdDeviation={childBlur / 2} />
296
+ </filter>
297
+ <g filter={`url(#${bf})`}>{part}</g>
298
+ </g>
299
+ );
300
+ }
301
+ const x = numProp(child.props, "x");
302
+ const y = numProp(child.props, "y");
303
+ out.push(
304
+ x !== 0 || y !== 0 ? (
305
+ <g key={`${keyPrefix}-t-${i}`} transform={`translate(${x} ${y})`}>
306
+ {part}
307
+ </g>
308
+ ) : (
309
+ part
310
+ ),
311
+ );
312
+ composited++;
313
+ }
314
+ return out;
315
+ }
@@ -0,0 +1,90 @@
1
+ // Render-side shape index (ADR 002 §3.2 Amendment 2 / A2.1 #K).
2
+ //
3
+ // A `mask.source.kind:"shape"` references a shape primitive by its stable `id`
4
+ // (mapper-assigned `fig-<safeIdRef(figmaNodeId)>`, see lumencast-figma). At
5
+ // render time the `<mask>` must INLINE the referenced shape's resolved geometry
6
+ // (the former `<use href="#id">` relied on a defs-resolvable sibling that does
7
+ // not exist in the runtime's flat tree). This module builds, in ONE pass over
8
+ // the bundle root, an index `id → RenderNode` of every referenceable shape, and
9
+ // threads it to the Tree via a plain React context (the bundle is immutable and
10
+ // content-addressed, so a mount-stable context is the right tool).
11
+ //
12
+ // ── Invariants (A2.1 / A4.3) ─────────────────────────────────────────
13
+ // - A `kind:"shape"` node carrying an `id` (shape-source target, #K) OR a
14
+ // `kind:"frame"` node carrying an `id` (group/frame-source target, #O —
15
+ // a GROUP and a FRAME container both lower to `frame`) is indexed. The
16
+ // mapper only emits `id` on actually-referenced nodes (no inflation).
17
+ // - Uniqueness : two nodes claiming the same `id` is a build-time defect. The
18
+ // FIRST occurrence wins and a diagnostic is emitted for each collision
19
+ // (never the id value beyond the field tag — R9-clean field only).
20
+ // - The index is read-only ; resolution is a pure in-memory lookup (A2.4 : no
21
+ // new surface, no URL, no fetch).
22
+
23
+ import { createContext, useContext, type ReactNode } from "react";
24
+ import type { RenderNode } from "./bundle";
25
+ import { emitDiagnostic } from "./diagnostics";
26
+
27
+ export type ShapeIndex = ReadonlyMap<string, RenderNode>;
28
+
29
+ const EMPTY: ShapeIndex = new Map();
30
+ const ShapeIndexCtx = createContext<ShapeIndex>(EMPTY);
31
+
32
+ /**
33
+ * Walk the bundle tree once and index every referenceable node — a
34
+ * `kind:"shape"` (shape-source, #K) or a `kind:"frame"` (group/frame-source,
35
+ * #O) — that carries an `id`. Collisions keep the first occurrence and
36
+ * diagnose the rest.
37
+ *
38
+ * The walk descends `children` only (the render tree's structural edges) ; it
39
+ * never reads `node.mask`, so building the index can never trigger mask
40
+ * resolution (anti-cycle is enforced at the builder, but the index walk is
41
+ * independent of masks entirely).
42
+ */
43
+ export function buildShapeIndex(root: RenderNode | undefined): ShapeIndex {
44
+ if (!root) return EMPTY;
45
+ const index = new Map<string, RenderNode>();
46
+ const stack: RenderNode[] = [root];
47
+ while (stack.length > 0) {
48
+ const node = stack.pop() as RenderNode;
49
+ const referenceable = node.kind === "shape" || node.kind === "frame";
50
+ if (referenceable && typeof node.id === "string" && node.id.length > 0) {
51
+ if (index.has(node.id)) {
52
+ emitDiagnostic(
53
+ node.id,
54
+ "id",
55
+ "duplicate shape id ; first occurrence kept, later ones ignored (ADR 002 A2.1 #K)",
56
+ );
57
+ } else {
58
+ index.set(node.id, node);
59
+ }
60
+ }
61
+ // Push children in REVERSE so the LIFO stack pops them in document order :
62
+ // "first occurrence wins" on a duplicate id must follow the bundle's order.
63
+ const children = node.children;
64
+ if (children) {
65
+ for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
66
+ }
67
+ }
68
+ return index;
69
+ }
70
+
71
+ /**
72
+ * Provide a prebuilt shape index to the render subtree. Mounted ONCE at the
73
+ * render root by each mode, wrapping `<Tree>`. Accepts the index directly so
74
+ * the (cheap, one-pass) build happens once per bundle rather than per render.
75
+ */
76
+ export function ShapeIndexProvider({
77
+ index,
78
+ children,
79
+ }: {
80
+ index: ShapeIndex;
81
+ children: ReactNode;
82
+ }) {
83
+ return <ShapeIndexCtx.Provider value={index}>{children}</ShapeIndexCtx.Provider>;
84
+ }
85
+
86
+ /** Read the active shape index. Empty map when no provider is mounted (a
87
+ * pending ref then resolves to "not found" → mask omitted, never a crash). */
88
+ export function useShapeIndex(): ShapeIndex {
89
+ return useContext(ShapeIndexCtx);
90
+ }