@lumencast/runtime 0.5.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-CyOlpZAL.js → index-C6viWFcT.js} +216 -182
- 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/mount.d.ts.map +1 -1
- package/dist/mount.js +5 -0
- package/dist/mount.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 +17 -1
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js +15 -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-DIpXc5du.js → status-pill-jJT54n07.js} +2 -2
- package/dist/{status-pill-DIpXc5du.js.map → status-pill-jJT54n07.js.map} +1 -1
- package/dist/{test-ByRec1kd.js → test-84XodL1c.js} +51 -51
- package/dist/{test-ByRec1kd.js.map → test-84XodL1c.js.map} +1 -1
- package/dist/transport/ws.d.ts +5 -0
- package/dist/transport/ws.d.ts.map +1 -1
- package/dist/transport/ws.js +7 -0
- package/dist/transport/ws.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/mount.ts +5 -0
- package/src/render/allowed-hosts.tsx +100 -0
- package/src/render/blend-mode.ts +50 -0
- package/src/render/bundle.ts +28 -2
- 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/src/transport/ws.ts +8 -0
- package/dist/broadcast-3vYij4k-.js +0 -11
- package/dist/broadcast-3vYij4k-.js.map +0 -1
- package/dist/control-BFNkY7-6.js +0 -16
- package/dist/control-BFNkY7-6.js.map +0 -1
- package/dist/index-CyOlpZAL.js.map +0 -1
- package/dist/tree-D5wYHpPu.js +0 -1230
- package/dist/tree-D5wYHpPu.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|