@lumencast/runtime 0.6.0 → 0.7.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/broadcast-DUYqvcgo.js +12 -0
- package/dist/broadcast-DUYqvcgo.js.map +1 -0
- package/dist/control-CL8TWXaE.js +17 -0
- package/dist/control-CL8TWXaE.js.map +1 -0
- package/dist/{index-Crkij3C4.js → index-C6viWFcT.js} +42 -26
- package/dist/index-C6viWFcT.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/lumencast.js +1 -1
- package/dist/modes/broadcast.d.ts.map +1 -1
- package/dist/modes/broadcast.js +6 -1
- package/dist/modes/broadcast.js.map +1 -1
- package/dist/modes/control.d.ts.map +1 -1
- package/dist/modes/control.js +6 -1
- package/dist/modes/control.js.map +1 -1
- package/dist/modes/test.d.ts.map +1 -1
- package/dist/modes/test.js +2 -1
- package/dist/modes/test.js.map +1 -1
- package/dist/render/allowed-hosts.d.ts +41 -0
- package/dist/render/allowed-hosts.d.ts.map +1 -0
- package/dist/render/allowed-hosts.js +88 -0
- package/dist/render/allowed-hosts.js.map +1 -0
- package/dist/render/blend-mode.d.ts +7 -0
- package/dist/render/blend-mode.d.ts.map +1 -0
- package/dist/render/blend-mode.js +49 -0
- package/dist/render/blend-mode.js.map +1 -0
- package/dist/render/bundle.d.ts +9 -1
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/fill.d.ts +36 -3
- package/dist/render/fill.d.ts.map +1 -1
- package/dist/render/fill.js +222 -23
- package/dist/render/fill.js.map +1 -1
- package/dist/render/mask.d.ts +87 -0
- package/dist/render/mask.d.ts.map +1 -0
- package/dist/render/mask.js +243 -0
- package/dist/render/mask.js.map +1 -0
- package/dist/render/primitives/frame.d.ts.map +1 -1
- package/dist/render/primitives/frame.js +91 -5
- package/dist/render/primitives/frame.js.map +1 -1
- package/dist/render/primitives/grid.d.ts +1 -1
- package/dist/render/primitives/grid.d.ts.map +1 -1
- package/dist/render/primitives/grid.js +4 -1
- package/dist/render/primitives/grid.js.map +1 -1
- package/dist/render/primitives/image.d.ts +8 -1
- package/dist/render/primitives/image.d.ts.map +1 -1
- package/dist/render/primitives/image.js +17 -3
- package/dist/render/primitives/image.js.map +1 -1
- package/dist/render/primitives/index.d.ts +7 -0
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js.map +1 -1
- package/dist/render/primitives/shape.d.ts.map +1 -1
- package/dist/render/primitives/shape.js +29 -26
- package/dist/render/primitives/shape.js.map +1 -1
- package/dist/render/primitives/stack.d.ts +1 -1
- package/dist/render/primitives/stack.d.ts.map +1 -1
- package/dist/render/primitives/stack.js +5 -1
- package/dist/render/primitives/stack.js.map +1 -1
- package/dist/render/primitives/text.d.ts.map +1 -1
- package/dist/render/primitives/text.js +0 -1
- package/dist/render/primitives/text.js.map +1 -1
- package/dist/render/prop-allowlist.d.ts.map +1 -1
- package/dist/render/prop-allowlist.js +25 -2
- package/dist/render/prop-allowlist.js.map +1 -1
- package/dist/render/shape-geometry.d.ts +81 -0
- package/dist/render/shape-geometry.d.ts.map +1 -0
- package/dist/render/shape-geometry.js +199 -0
- package/dist/render/shape-geometry.js.map +1 -0
- package/dist/render/shape-index.d.ts +28 -0
- package/dist/render/shape-index.d.ts.map +1 -0
- package/dist/render/shape-index.js +77 -0
- package/dist/render/shape-index.js.map +1 -0
- package/dist/render/tree.d.ts.map +1 -1
- package/dist/render/tree.js +175 -3
- package/dist/render/tree.js.map +1 -1
- package/dist/render/universal-wrapper.d.ts +27 -1
- package/dist/render/universal-wrapper.d.ts.map +1 -1
- package/dist/render/universal-wrapper.js +98 -22
- package/dist/render/universal-wrapper.js.map +1 -1
- package/dist/{status-pill-BT5b-yET.js → status-pill-jJT54n07.js} +2 -2
- package/dist/{status-pill-BT5b-yET.js.map → status-pill-jJT54n07.js.map} +1 -1
- package/dist/{test-_hh1JvAd.js → test-84XodL1c.js} +51 -51
- package/dist/{test-_hh1JvAd.js.map → test-84XodL1c.js.map} +1 -1
- package/dist/tree-BIimahCf.js +1777 -0
- package/dist/tree-BIimahCf.js.map +1 -0
- package/package.json +4 -4
- package/src/modes/broadcast.tsx +12 -1
- package/src/modes/control.tsx +10 -1
- package/src/modes/test.tsx +4 -1
- package/src/render/allowed-hosts.tsx +100 -0
- package/src/render/blend-mode.ts +50 -0
- package/src/render/bundle.ts +6 -1
- package/src/render/fill.tsx +266 -24
- package/src/render/mask.tsx +389 -0
- package/src/render/primitives/frame.tsx +101 -5
- package/src/render/primitives/grid.tsx +4 -1
- package/src/render/primitives/image.tsx +17 -3
- package/src/render/primitives/index.ts +7 -0
- package/src/render/primitives/shape.tsx +39 -75
- package/src/render/primitives/stack.tsx +5 -1
- package/src/render/primitives/text.tsx +0 -1
- package/src/render/prop-allowlist.ts +25 -2
- package/src/render/shape-geometry.tsx +315 -0
- package/src/render/shape-index.tsx +90 -0
- package/src/render/tree.tsx +214 -12
- package/src/render/universal-wrapper.tsx +128 -21
- package/dist/broadcast-DO7jEkix.js +0 -11
- package/dist/broadcast-DO7jEkix.js.map +0 -1
- package/dist/control-BSfl4_cO.js +0 -16
- package/dist/control-BSfl4_cO.js.map +0 -1
- package/dist/index-Crkij3C4.js.map +0 -1
- package/dist/tree-DBj9SJgs.js +0 -1230
- 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 {
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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 = [...
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
+
}
|