@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/broadcast-Gcd-dmC7.js +12 -0
- package/dist/broadcast-Gcd-dmC7.js.map +1 -0
- package/dist/control-C5TfClga.js +17 -0
- package/dist/control-C5TfClga.js.map +1 -0
- package/dist/{index-Crkij3C4.js → index-N-VqrIxN.js} +305 -210
- package/dist/index-N-VqrIxN.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/lumencast.js +14 -9
- 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/asset-resolve.d.ts +27 -0
- package/dist/render/asset-resolve.d.ts.map +1 -0
- package/dist/render/asset-resolve.js +86 -0
- package/dist/render/asset-resolve.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/headless.d.ts +39 -0
- package/dist/render/headless.d.ts.map +1 -0
- package/dist/render/headless.js +83 -0
- package/dist/render/headless.js.map +1 -0
- 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/media.d.ts +11 -2
- package/dist/render/primitives/media.d.ts.map +1 -1
- package/dist/render/primitives/media.js +14 -3
- package/dist/render/primitives/media.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-BaLQoIDl.js} +2 -2
- package/dist/{status-pill-BT5b-yET.js.map → status-pill-BaLQoIDl.js.map} +1 -1
- package/dist/{test-_hh1JvAd.js → test-CA30C2By.js} +51 -51
- package/dist/{test-_hh1JvAd.js.map → test-CA30C2By.js.map} +1 -1
- package/dist/tree-1coZ32nd.js +1777 -0
- package/dist/tree-1coZ32nd.js.map +1 -0
- package/package.json +6 -5
- package/src/index.ts +24 -0
- 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/asset-resolve.ts +97 -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/headless.tsx +129 -0
- 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/media.tsx +14 -3
- 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
package/src/render/fill.tsx
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import type { CSSProperties, ReactElement } from "react";
|
|
13
13
|
import { parseCssColor, warnRejectedColor } from "./css-color";
|
|
14
14
|
import { emitDiagnostic } from "./diagnostics";
|
|
15
|
+
import { gateSrc } from "./allowed-hosts";
|
|
16
|
+
import { parseBlendMode } from "./blend-mode";
|
|
15
17
|
|
|
16
18
|
export interface FillStop {
|
|
17
19
|
offset: number;
|
|
@@ -19,13 +21,36 @@ export interface FillStop {
|
|
|
19
21
|
opacity?: number;
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/** LSML 1.2 §3.2 closed `objectFit` enum, re-validated at the RUNTIME (the
|
|
25
|
+
* compiler is the other arm of the double-gate, Bastion T4). These are
|
|
26
|
+
* exactly the legal CSS `object-fit` / `background-size`-mappable values ;
|
|
27
|
+
* anything else is omitted + diagnosed, never passed through to inline CSS.
|
|
28
|
+
* Kept local to the runtime — the runtime must not import from the
|
|
29
|
+
* compiler (the dependency edge points the other way). */
|
|
30
|
+
const OBJECT_FITS = new Set(["cover", "contain", "fill", "none", "scale-down"]);
|
|
31
|
+
|
|
32
|
+
export type ObjectFit = "cover" | "contain" | "fill" | "none" | "scale-down";
|
|
33
|
+
|
|
34
|
+
/** Validate an `objectFit` against the closed enum at render. Returns the
|
|
35
|
+
* value or `undefined` (caller falls back to the default + diagnoses).
|
|
36
|
+
* Never passthrough. */
|
|
37
|
+
export function parseObjectFitRuntime(value: unknown): ObjectFit | undefined {
|
|
38
|
+
return typeof value === "string" && OBJECT_FITS.has(value) ? (value as ObjectFit) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// LSML 1.2 §3.2 (#L) — optional per-fill-layer blend mode. Re-validated at
|
|
42
|
+
// the RUNTIME against the closed enum (`parseBlendMode` from blend-mode.ts,
|
|
43
|
+
// the runtime arm of the T4 double-gate ; the runtime never imports the
|
|
44
|
+
// compiler). Out-of-enum → omitted, never reaches inline CSS. Independent of
|
|
45
|
+
// the node-level blend (#D, applied on the wrapper). Absent = `normal`.
|
|
22
46
|
export type Fill =
|
|
23
|
-
| { kind: "solid"; color: string; opacity?: number }
|
|
47
|
+
| { kind: "solid"; color: string; opacity?: number; blendMode?: string }
|
|
24
48
|
| {
|
|
25
49
|
kind: "linear-gradient";
|
|
26
50
|
angle_deg?: number;
|
|
27
51
|
stops: FillStop[];
|
|
28
52
|
opacity?: number;
|
|
53
|
+
blendMode?: string;
|
|
29
54
|
}
|
|
30
55
|
| {
|
|
31
56
|
kind: "radial-gradient";
|
|
@@ -33,6 +58,18 @@ export type Fill =
|
|
|
33
58
|
radius?: number;
|
|
34
59
|
stops: FillStop[];
|
|
35
60
|
opacity?: number;
|
|
61
|
+
blendMode?: string;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
// LSML 1.2 §3.2 — first-class image-fill. `src` is untrusted and is
|
|
65
|
+
// host/scheme-gated by `gateImageFills` BEFORE this fill is ever
|
|
66
|
+
// rendered (Bastion T1/T2). `objectFit` is the runtime-revalidated
|
|
67
|
+
// closed-enum value (T4).
|
|
68
|
+
kind: "image";
|
|
69
|
+
src: string;
|
|
70
|
+
objectFit?: ObjectFit;
|
|
71
|
+
opacity?: number;
|
|
72
|
+
blendMode?: string;
|
|
36
73
|
};
|
|
37
74
|
|
|
38
75
|
let gradientIdSeq = 0;
|
|
@@ -46,27 +83,68 @@ export interface FillRenderResult {
|
|
|
46
83
|
defs: ReactElement[];
|
|
47
84
|
/** Reference to use as the `fill` attribute on the shape. */
|
|
48
85
|
ref: string;
|
|
86
|
+
/** #L — the per-fill-layer `mix-blend-mode` keyword, re-validated against
|
|
87
|
+
* the closed enum at the runtime (T4) ; `undefined` when absent or
|
|
88
|
+
* out-of-enum (caller omits — never reaches the style). Applied on the
|
|
89
|
+
* fill layer element, independent of the node-level blend (#D). */
|
|
90
|
+
mixBlendMode?: string;
|
|
49
91
|
}
|
|
50
92
|
|
|
51
93
|
/** Compile a Fill into an SVG `<defs>` entry + a `fill="url(#…)"` ref.
|
|
52
94
|
* Solid fills produce no defs and return the colour directly. */
|
|
53
95
|
export function renderFill(fill: Fill): FillRenderResult {
|
|
96
|
+
// #L — re-validate the per-fill blend mode once (runtime T4 arm). An absent
|
|
97
|
+
// or out-of-enum value yields `undefined` → the layer renders `normal`.
|
|
98
|
+
const mixBlendMode = parseBlendMode(fill.blendMode);
|
|
54
99
|
if (fill.kind === "solid") {
|
|
55
|
-
// Solid fill — no defs needed, just hand the colour to fill.
|
|
56
|
-
//
|
|
57
|
-
// so
|
|
58
|
-
|
|
100
|
+
// Solid fill — no defs needed, just hand the colour to fill. A solid fill
|
|
101
|
+
// carries its OWN opacity (Figma per-paint alpha, e.g. the bg-texture tiles
|
|
102
|
+
// at 6% white) ; fold it into the colour so the SVG path actually renders
|
|
103
|
+
// at that alpha instead of full-strength (the tiles came out 16× too bright
|
|
104
|
+
// pre-mask, near-black post-mask).
|
|
105
|
+
const ref = fill.opacity !== undefined ? cssWithOpacity(fill.color, fill.opacity) : fill.color;
|
|
106
|
+
return { defs: [], ref, mixBlendMode };
|
|
107
|
+
}
|
|
108
|
+
if (fill.kind === "image") {
|
|
109
|
+
// LSML 1.2 §3.2 — image-fill on a shape. Rendered as an SVG <pattern>
|
|
110
|
+
// holding a single <image> that fills the object bounding box ;
|
|
111
|
+
// `preserveAspectRatio` reproduces the closed-enum `objectFit`. `src`
|
|
112
|
+
// is pre-gated (T1/T2) by `gateImageFills`, so it is safe to place on
|
|
113
|
+
// the SVG <image href>. No bundle-derived markup is interpolated — only
|
|
114
|
+
// the URL string and closed-enum-derived attribute values.
|
|
115
|
+
const imgId = nextGradientId();
|
|
116
|
+
const par = objectFitToPreserveAspectRatio(fill.objectFit);
|
|
117
|
+
const defs = [
|
|
118
|
+
<pattern key={imgId} id={imgId} patternContentUnits="objectBoundingBox" width="1" height="1">
|
|
119
|
+
<image href={fill.src} width="1" height="1" preserveAspectRatio={par} />
|
|
120
|
+
</pattern>,
|
|
121
|
+
];
|
|
122
|
+
return { defs, ref: `url(#${imgId})`, mixBlendMode };
|
|
59
123
|
}
|
|
60
124
|
const id = nextGradientId();
|
|
61
125
|
if (fill.kind === "linear-gradient") {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
126
|
+
let x1: number, y1: number, x2: number, y2: number;
|
|
127
|
+
// Honour the Figma `gradientTransform` : the gradient axis (offset 0 → 1) is
|
|
128
|
+
// column 0 = (a, b) of the matrix, in the SVG's y-down space. `angle_deg`
|
|
129
|
+
// alone ignored it and mis-oriented the picto/caramel gradients (too red).
|
|
130
|
+
const t = (fill as { transform?: number[] }).transform;
|
|
131
|
+
if (Array.isArray(t) && t.length === 6 && Number.isFinite(t[0]) && Number.isFinite(t[1])) {
|
|
132
|
+
const len = Math.hypot(t[0], t[1]) || 1;
|
|
133
|
+
const an = t[0] / len;
|
|
134
|
+
const bn = t[1] / len;
|
|
135
|
+
x1 = 0.5 - 0.5 * an;
|
|
136
|
+
y1 = 0.5 - 0.5 * bn;
|
|
137
|
+
x2 = 0.5 + 0.5 * an;
|
|
138
|
+
y2 = 0.5 + 0.5 * bn;
|
|
139
|
+
} else {
|
|
140
|
+
// angle_deg : 0 = bottom-to-top per §4.12.
|
|
141
|
+
const angle = fill.angle_deg ?? 0;
|
|
142
|
+
const rad = ((angle - 90) * Math.PI) / 180; // 0° → x1=0,y1=1 (bottom-up)
|
|
143
|
+
x1 = 0.5 - 0.5 * Math.cos(rad);
|
|
144
|
+
y1 = 0.5 - 0.5 * Math.sin(rad);
|
|
145
|
+
x2 = 0.5 + 0.5 * Math.cos(rad);
|
|
146
|
+
y2 = 0.5 + 0.5 * Math.sin(rad);
|
|
147
|
+
}
|
|
70
148
|
const defs = [
|
|
71
149
|
<linearGradient
|
|
72
150
|
key={id}
|
|
@@ -86,7 +164,7 @@ export function renderFill(fill: Fill): FillRenderResult {
|
|
|
86
164
|
))}
|
|
87
165
|
</linearGradient>,
|
|
88
166
|
];
|
|
89
|
-
return { defs, ref: `url(#${id})
|
|
167
|
+
return { defs, ref: `url(#${id})`, mixBlendMode };
|
|
90
168
|
}
|
|
91
169
|
// radial-gradient
|
|
92
170
|
const cx = fill.center?.x ?? 0.5;
|
|
@@ -104,21 +182,108 @@ export function renderFill(fill: Fill): FillRenderResult {
|
|
|
104
182
|
))}
|
|
105
183
|
</radialGradient>,
|
|
106
184
|
];
|
|
107
|
-
return { defs, ref: `url(#${id})
|
|
185
|
+
return { defs, ref: `url(#${id})`, mixBlendMode };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Map a closed-enum `objectFit` to the CSS `background-size` keyword that
|
|
189
|
+
* reproduces the same fit for a `background-image`. `fill`/`none`/`scale-
|
|
190
|
+
* down` have no exact 1:1 `background-size` keyword — we approximate with
|
|
191
|
+
* the nearest safe keyword (all from the closed enum, never free input). */
|
|
192
|
+
function objectFitToBackgroundSize(fit: ObjectFit | undefined): string {
|
|
193
|
+
switch (fit) {
|
|
194
|
+
case "contain":
|
|
195
|
+
case "scale-down":
|
|
196
|
+
return "contain";
|
|
197
|
+
case "none":
|
|
198
|
+
return "auto";
|
|
199
|
+
case "fill":
|
|
200
|
+
return "100% 100%";
|
|
201
|
+
case "cover":
|
|
202
|
+
default:
|
|
203
|
+
return "cover";
|
|
204
|
+
}
|
|
108
205
|
}
|
|
109
206
|
|
|
110
|
-
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
207
|
+
/** Map a closed-enum `objectFit` to the SVG `<image preserveAspectRatio>`
|
|
208
|
+
* value that reproduces the same fit inside a pattern tile. Every returned
|
|
209
|
+
* value is a fixed literal (closed enum → fixed mapping) — never free
|
|
210
|
+
* input reaching an SVG attribute. */
|
|
211
|
+
function objectFitToPreserveAspectRatio(fit: ObjectFit | undefined): string {
|
|
212
|
+
switch (fit) {
|
|
213
|
+
case "contain":
|
|
214
|
+
case "scale-down":
|
|
215
|
+
return "xMidYMid meet";
|
|
216
|
+
case "fill":
|
|
217
|
+
return "none";
|
|
218
|
+
case "none":
|
|
219
|
+
return "xMidYMid meet";
|
|
220
|
+
case "cover":
|
|
221
|
+
default:
|
|
222
|
+
return "xMidYMid slice";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Compile an array of Fill into background CSS usable on a `<div>` (frame
|
|
227
|
+
* backgrounds — non-SVG context). Returns `backgroundImage` plus, when an
|
|
228
|
+
* image-fill is present, the matching `backgroundSize`/`backgroundPosition`/
|
|
229
|
+
* `backgroundRepeat`. Stops use percentages in CSS gradient syntax.
|
|
230
|
+
*
|
|
231
|
+
* Image-fill `src` MUST already be host/scheme-gated (`gateImageFills`) —
|
|
232
|
+
* `backgroundsToCss` assumes the URL is trusted at this point and only
|
|
233
|
+
* CSS-escapes it for safe interpolation into `url("…")`. */
|
|
113
234
|
export function backgroundsToCss(fills: Fill[], nodeId?: string): CSSProperties {
|
|
114
235
|
// Per §4.12, fills[0] renders on top — CSS background-image stacks
|
|
115
236
|
// first → top-most. Match by passing in the same order.
|
|
116
|
-
|
|
237
|
+
// #L — keep each layer's validated blend keyword aligned with its CSS
|
|
238
|
+
// layer (a rejected colour drops the layer → drop its blend too), so
|
|
239
|
+
// `background-blend-mode` stays positionally correct.
|
|
240
|
+
const kept: Fill[] = [];
|
|
241
|
+
const layers: string[] = [];
|
|
242
|
+
for (const f of fills) {
|
|
243
|
+
const css = fillToCss(f, nodeId);
|
|
244
|
+
if (css) {
|
|
245
|
+
layers.push(css);
|
|
246
|
+
kept.push(f);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
117
249
|
if (layers.length === 0) return {};
|
|
118
|
-
|
|
250
|
+
const css: CSSProperties = { backgroundImage: layers.join(", ") };
|
|
251
|
+
// #L — per-fill-layer blend on a frame background uses CSS
|
|
252
|
+
// `background-blend-mode` (one keyword per layer, same order). Each value is
|
|
253
|
+
// re-validated against the closed enum (runtime T4 arm) ; an absent/rejected
|
|
254
|
+
// value falls back to `normal`. Emitted only when at least one layer carries
|
|
255
|
+
// a non-`normal` blend, to keep pre-#L output byte-identical (rétro-compat).
|
|
256
|
+
const blends = kept.map((f) => parseBlendMode(f.blendMode) ?? "normal");
|
|
257
|
+
if (blends.some((b) => b !== "normal")) {
|
|
258
|
+
css.backgroundBlendMode = blends.join(", ");
|
|
259
|
+
}
|
|
260
|
+
// When any layer is an image-fill, drive its sizing from the (already
|
|
261
|
+
// validated) objectFit. A single image-fill is the common cover case ;
|
|
262
|
+
// for the first image-fill we set the background sizing for the whole box.
|
|
263
|
+
const firstImage = fills.find((f) => f.kind === "image") as
|
|
264
|
+
| Extract<Fill, { kind: "image" }>
|
|
265
|
+
| undefined;
|
|
266
|
+
if (firstImage) {
|
|
267
|
+
css.backgroundSize = objectFitToBackgroundSize(firstImage.objectFit);
|
|
268
|
+
css.backgroundPosition = "center";
|
|
269
|
+
css.backgroundRepeat = "no-repeat";
|
|
270
|
+
}
|
|
271
|
+
return css;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** CSS-escape a (already host-gated) URL for safe interpolation into a
|
|
275
|
+
* `url("…")` token — escape backslash and the double-quote that would
|
|
276
|
+
* otherwise break out of the quoted string. */
|
|
277
|
+
function cssUrl(src: string): string {
|
|
278
|
+
return `url("${src.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
|
|
119
279
|
}
|
|
120
280
|
|
|
121
281
|
function fillToCss(fill: Fill, nodeId?: string): string | null {
|
|
282
|
+
if (fill.kind === "image") {
|
|
283
|
+
// `src` is pre-gated (T1/T2) by `gateImageFills` ; only escape it for
|
|
284
|
+
// the CSS string context here.
|
|
285
|
+
return cssUrl(fill.src);
|
|
286
|
+
}
|
|
122
287
|
// RC#11 — every colour interpolated into an inline CSS string MUST
|
|
123
288
|
// pass the strict parser first (fills/stops arrive from untrusted
|
|
124
289
|
// bundles AND live LSDP deltas). A rejected colour drops the whole
|
|
@@ -129,8 +294,12 @@ function fillToCss(fill: Fill, nodeId?: string): string | null {
|
|
|
129
294
|
warnRejectedColor("fill.color", nodeId);
|
|
130
295
|
return null;
|
|
131
296
|
}
|
|
297
|
+
// A solid fill carries its OWN opacity (Figma layer-fill alpha, e.g. a 14%
|
|
298
|
+
// white pill) — apply it like a gradient stop's, else the layer renders
|
|
299
|
+
// fully opaque and hides whatever it overlays.
|
|
300
|
+
const c = fill.opacity !== undefined ? cssWithOpacity(color, fill.opacity) : color;
|
|
132
301
|
// Wrap solid in linear-gradient so it can stack with other layers.
|
|
133
|
-
return `linear-gradient(${
|
|
302
|
+
return `linear-gradient(${c}, ${c})`;
|
|
134
303
|
}
|
|
135
304
|
const safeStops: string[] = [];
|
|
136
305
|
for (const s of fill.stops) {
|
|
@@ -144,7 +313,17 @@ function fillToCss(fill: Fill, nodeId?: string): string | null {
|
|
|
144
313
|
}
|
|
145
314
|
const stops = safeStops.join(", ");
|
|
146
315
|
if (fill.kind === "linear-gradient") {
|
|
147
|
-
|
|
316
|
+
let angle = fill.angle_deg ?? 0;
|
|
317
|
+
// Honour the Figma `gradientTransform` when present : the gradient's main
|
|
318
|
+
// axis (offset 0 → 1) is column 0 = (a, b) of the 2×3 matrix. CSS `Ndeg`
|
|
319
|
+
// measures clockwise from "up" and screen-y points down, so that direction
|
|
320
|
+
// maps to `atan2(a, -b)`. `angle_deg` alone ignored the matrix and rendered
|
|
321
|
+
// the Cover's warm base as a 270° (horizontal) wash instead of the real 180°
|
|
322
|
+
// (warm at top) — leaving the top-right black under the Ruby20 hard-light.
|
|
323
|
+
const t = (fill as { transform?: number[] }).transform;
|
|
324
|
+
if (Array.isArray(t) && t.length === 6 && Number.isFinite(t[0]) && Number.isFinite(t[1])) {
|
|
325
|
+
angle = ((Math.atan2(t[0], -t[1]) * 180) / Math.PI + 360) % 360;
|
|
326
|
+
}
|
|
148
327
|
return `linear-gradient(${angle}deg, ${stops})`;
|
|
149
328
|
}
|
|
150
329
|
// radial-gradient
|
|
@@ -180,6 +359,13 @@ function cssWithOpacity(color: string, opacity: number): string {
|
|
|
180
359
|
export function sanitizeFills(fills: Fill[], field: string, nodeId?: string): Fill[] {
|
|
181
360
|
const out: Fill[] = [];
|
|
182
361
|
for (const fill of fills) {
|
|
362
|
+
// Image-fills carry no colour — they are colour-clean by construction.
|
|
363
|
+
// Their `src` is gated separately (`gateImageFills`, T1/T2) ; pass them
|
|
364
|
+
// through here unchanged so `sanitizeFills` only owns colour validation.
|
|
365
|
+
if (fill.kind === "image") {
|
|
366
|
+
out.push(fill);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
183
369
|
if (fill.kind === "solid") {
|
|
184
370
|
const color = parseCssColor(fill.color);
|
|
185
371
|
if (color === null) {
|
|
@@ -224,11 +410,67 @@ export function parseFills(value: unknown, field?: string, nodeId?: string): Fil
|
|
|
224
410
|
}
|
|
225
411
|
}
|
|
226
412
|
}
|
|
227
|
-
|
|
413
|
+
// Image-fill `objectFit` is re-validated against the closed enum here
|
|
414
|
+
// (Bastion T4 runtime arm) : a hostile / unknown value is dropped with a
|
|
415
|
+
// diagnostic and the fill falls back to the default fit — never passed
|
|
416
|
+
// through to inline CSS. `src` is NOT gated here (it needs the host
|
|
417
|
+
// allowlist) — `gateImageFills` does that downstream, before render.
|
|
418
|
+
return value.filter(isFill).map((v) => {
|
|
419
|
+
let fill = v as Fill;
|
|
420
|
+
// #L — re-validate a per-fill `blendMode` against the closed enum (runtime
|
|
421
|
+
// T4 arm). An out-of-enum value is diagnosed + stripped (the layer falls
|
|
422
|
+
// back to `normal`), never passed through to inline CSS. Applies to every
|
|
423
|
+
// fill kind.
|
|
424
|
+
if (fill.blendMode !== undefined && parseBlendMode(fill.blendMode) === undefined) {
|
|
425
|
+
emitDiagnostic(
|
|
426
|
+
nodeId,
|
|
427
|
+
field !== undefined ? `${field}.blendMode` : "fill.blendMode",
|
|
428
|
+
"is not a recognised mix-blend-mode ; falling back to normal (ADR 002 §3.2)",
|
|
429
|
+
);
|
|
430
|
+
const { blendMode: _drop, ...rest } = fill;
|
|
431
|
+
fill = rest;
|
|
432
|
+
}
|
|
433
|
+
if (fill.kind !== "image") return fill;
|
|
434
|
+
if (fill.objectFit === undefined) return fill;
|
|
435
|
+
const fit = parseObjectFitRuntime(fill.objectFit);
|
|
436
|
+
if (fit === undefined) {
|
|
437
|
+
emitDiagnostic(
|
|
438
|
+
nodeId,
|
|
439
|
+
field !== undefined ? `${field}.objectFit` : "fill.objectFit",
|
|
440
|
+
"is not a recognised object-fit ; falling back to default (ADR 002 §3.2)",
|
|
441
|
+
);
|
|
442
|
+
const { objectFit: _drop, ...rest } = fill;
|
|
443
|
+
return rest;
|
|
444
|
+
}
|
|
445
|
+
return { ...fill, objectFit: fit };
|
|
446
|
+
});
|
|
228
447
|
}
|
|
229
448
|
|
|
230
449
|
function isFill(v: unknown): v is Fill {
|
|
231
450
|
if (typeof v !== "object" || v === null) return false;
|
|
232
451
|
const k = (v as { kind?: unknown }).kind;
|
|
233
|
-
|
|
452
|
+
if (k === "solid" || k === "linear-gradient" || k === "radial-gradient") return true;
|
|
453
|
+
// An image-fill must carry a string `src` to be structurally valid ; a
|
|
454
|
+
// malformed image entry is dropped like any other unrenderable fill.
|
|
455
|
+
return k === "image" && typeof (v as { src?: unknown }).src === "string";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Drop every image-fill whose `src` fails the host/scheme allowlist
|
|
460
|
+
* (Bastion T1/T2), BEFORE any image-fill reaches the DOM. A rejected
|
|
461
|
+
* image-fill is omitted entirely (never a passthrough URL) with an
|
|
462
|
+
* R9-clean diagnostic emitted by `gateSrc`. Non-image fills pass through
|
|
463
|
+
* untouched. Call this once, after `parseFills`, with the active
|
|
464
|
+
* `allowedHosts` from `useAllowedHosts()`.
|
|
465
|
+
*/
|
|
466
|
+
export function gateImageFills(
|
|
467
|
+
fills: Fill[],
|
|
468
|
+
allowedHosts: readonly string[] | undefined,
|
|
469
|
+
field: string,
|
|
470
|
+
nodeId?: string,
|
|
471
|
+
): Fill[] {
|
|
472
|
+
return fills.filter((fill) => {
|
|
473
|
+
if (fill.kind !== "image") return true;
|
|
474
|
+
return gateSrc(fill.src, allowedHosts, `${field}.src`, nodeId) !== undefined;
|
|
475
|
+
});
|
|
234
476
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Public headless render entry — render an already-compiled `RenderBundle`
|
|
2
|
+
// into a live DOM node, no WebSocket, ready when layout + fonts have settled
|
|
3
|
+
// (ADR 003 §3.1). The host (Playwright / Chromium / a CEF offscreen surface)
|
|
4
|
+
// screenshots `target` once `ready` resolves. The runtime does DOM + readiness
|
|
5
|
+
// ONLY — no screenshot, no fetch (ADR 003 D5/D3).
|
|
6
|
+
//
|
|
7
|
+
// This is the zero-loss harness (ADR 002 #J) generalised: it mounts the EXACT
|
|
8
|
+
// production seam —
|
|
9
|
+
// LumencastRuntimeProvider{ mode:"broadcast", status:"live" } > BroadcastMode
|
|
10
|
+
// — into a real `createRoot(target)`, NOT `renderToStaticMarkup` (which yields
|
|
11
|
+
// unlaid-out markup: unmeasured fonts, uncomposited masks → an infidel PNG,
|
|
12
|
+
// ADR 003 §3.1). `BroadcastMode` is dynamically imported so the headless
|
|
13
|
+
// function adds no weight to the eager `mount`/broadcast path (ADR 003 §4,
|
|
14
|
+
// RC6); the heavy render code already lives in the broadcast/tree chunks.
|
|
15
|
+
//
|
|
16
|
+
// Asset resolution is the HOST's job, done in the bundle BEFORE this call
|
|
17
|
+
// (ADR 003 §3.2): the runtime renders the bundle as-is, gating every remaining
|
|
18
|
+
// `src` through the unchanged deny-by-default host-allow gate inside
|
|
19
|
+
// `BroadcastMode` (`AllowedHostsProvider`). A `src` on a host not in the
|
|
20
|
+
// bundle's `allowedHosts` is omitted + a diagnostic is emitted — never faked
|
|
21
|
+
// (ADR 002 borne, D4). Use `render/asset-resolve` helpers to pre-resolve.
|
|
22
|
+
|
|
23
|
+
import { StrictMode } from "react";
|
|
24
|
+
import { createRoot } from "react-dom/client";
|
|
25
|
+
import { createStore } from "../state/store.js";
|
|
26
|
+
import { LumencastRuntimeProvider } from "../overlay/runtime-context.js";
|
|
27
|
+
import { addDiagnosticsHandler, type DiagnosticHandler } from "./diagnostics.js";
|
|
28
|
+
import type { RenderBundle } from "./bundle.js";
|
|
29
|
+
|
|
30
|
+
/** Default stage size — the Figma 817:3 cover frame, the SSIM reference. */
|
|
31
|
+
const DEFAULT_STAGE = { width: 1920, height: 1080 } as const;
|
|
32
|
+
|
|
33
|
+
export interface HeadlessRenderOptions {
|
|
34
|
+
/** Already-compiled bundle (via `@lumencast/compiler` on the host side). */
|
|
35
|
+
bundle: RenderBundle;
|
|
36
|
+
/** A live, mounted DOM node. Its size is set from `stage` unless the host
|
|
37
|
+
* has already dimensioned it (see `stage`). */
|
|
38
|
+
target: HTMLElement;
|
|
39
|
+
/** Initial leaf-grain store state (`store.reset(defaults)`) — the bound
|
|
40
|
+
* values the bundle reads (`__lit.*`, score, names…). */
|
|
41
|
+
defaults?: Record<string, unknown>;
|
|
42
|
+
/** Stage dimensions in CSS px. Defaults to 1920×1080. Applied to `target`
|
|
43
|
+
* as `width`/`height`/`position:relative`/`overflow:hidden` so the
|
|
44
|
+
* screenshot frame matches the reference exactly. */
|
|
45
|
+
stage?: { width: number; height: number };
|
|
46
|
+
/** Anti-drop diagnostics channel (ADR 001 §3.4): omitted assets, unhonoured
|
|
47
|
+
* fields surface here as `{ nodeId, field, reason }` (never a value — R9).
|
|
48
|
+
* Wired to the same global channel `mount()` uses. */
|
|
49
|
+
onDiagnostic?: DiagnosticHandler;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface HeadlessRenderHandle {
|
|
53
|
+
/** Resolves after the scene has rendered, two animation frames have passed
|
|
54
|
+
* AND `document.fonts.ready` (ADR 003 §3.3) — i.e. the DOM is laid out and
|
|
55
|
+
* fonts are loaded, so a screenshot taken now is fidelity-faithful. */
|
|
56
|
+
ready: Promise<void>;
|
|
57
|
+
/** Tear down the React root and detach the diagnostics handler. */
|
|
58
|
+
unmount(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const noop = (): void => {};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Render `bundle` into `target` through the production broadcast path and
|
|
65
|
+
* resolve `ready` once it is settled. The runtime performs NO network fetch and
|
|
66
|
+
* takes NO screenshot — it produces a settled live DOM and a readiness signal,
|
|
67
|
+
* nothing more (ADR 003 D5).
|
|
68
|
+
*/
|
|
69
|
+
export function renderBundleHeadless(opts: HeadlessRenderOptions): HeadlessRenderHandle {
|
|
70
|
+
const stage = opts.stage ?? DEFAULT_STAGE;
|
|
71
|
+
const target = opts.target;
|
|
72
|
+
// Pose the stage so the screenshot frame is exact (mirrors harness.html).
|
|
73
|
+
target.style.position ||= "relative";
|
|
74
|
+
target.style.width = `${stage.width}px`;
|
|
75
|
+
target.style.height = `${stage.height}px`;
|
|
76
|
+
target.style.overflow = "hidden";
|
|
77
|
+
|
|
78
|
+
const removeDiagnostics = opts.onDiagnostic
|
|
79
|
+
? addDiagnosticsHandler(opts.onDiagnostic)
|
|
80
|
+
: undefined;
|
|
81
|
+
|
|
82
|
+
const store = createStore();
|
|
83
|
+
store.reset(opts.defaults ?? {});
|
|
84
|
+
|
|
85
|
+
const root = createRoot(target);
|
|
86
|
+
|
|
87
|
+
const ready = new Promise<void>((resolve) => {
|
|
88
|
+
// BroadcastMode is dynamically imported so its (and the tree's) weight is
|
|
89
|
+
// not pulled into the eager `mount` entry chunk (RC6). It is already a
|
|
90
|
+
// separate chunk reused from the broadcast path.
|
|
91
|
+
void import("../modes/broadcast.js").then(({ BroadcastMode }) => {
|
|
92
|
+
root.render(
|
|
93
|
+
<StrictMode>
|
|
94
|
+
<LumencastRuntimeProvider
|
|
95
|
+
value={{
|
|
96
|
+
mode: "broadcast",
|
|
97
|
+
store,
|
|
98
|
+
bundle: opts.bundle,
|
|
99
|
+
status: "live",
|
|
100
|
+
sendInput: noop,
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<BroadcastMode />
|
|
104
|
+
</LumencastRuntimeProvider>
|
|
105
|
+
</StrictMode>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Settle: two animation frames (layout) AND fonts loaded (ADR 003 §3.3).
|
|
109
|
+
// Both must complete before `ready` resolves, so a screenshot taken on
|
|
110
|
+
// `ready` uses the brand glyphs, not the fallback font (no FOUT freeze).
|
|
111
|
+
const framesSettled = new Promise<void>((res) => {
|
|
112
|
+
requestAnimationFrame(() => requestAnimationFrame(() => res()));
|
|
113
|
+
});
|
|
114
|
+
const fontsReady =
|
|
115
|
+
typeof document !== "undefined" && document.fonts
|
|
116
|
+
? document.fonts.ready.then(() => undefined)
|
|
117
|
+
: Promise.resolve();
|
|
118
|
+
void Promise.all([framesSettled, fontsReady]).then(() => resolve());
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
ready,
|
|
124
|
+
unmount() {
|
|
125
|
+
removeDiagnostics?.();
|
|
126
|
+
root.unmount();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|