@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.
Files changed (113) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/broadcast-DUYqvcgo.js +12 -0
  3. package/dist/broadcast-DUYqvcgo.js.map +1 -0
  4. package/dist/control-CL8TWXaE.js +17 -0
  5. package/dist/control-CL8TWXaE.js.map +1 -0
  6. package/dist/{index-Crkij3C4.js → index-C6viWFcT.js} +42 -26
  7. package/dist/index-C6viWFcT.js.map +1 -0
  8. package/dist/index.html +1 -1
  9. package/dist/lumencast.js +1 -1
  10. package/dist/modes/broadcast.d.ts.map +1 -1
  11. package/dist/modes/broadcast.js +6 -1
  12. package/dist/modes/broadcast.js.map +1 -1
  13. package/dist/modes/control.d.ts.map +1 -1
  14. package/dist/modes/control.js +6 -1
  15. package/dist/modes/control.js.map +1 -1
  16. package/dist/modes/test.d.ts.map +1 -1
  17. package/dist/modes/test.js +2 -1
  18. package/dist/modes/test.js.map +1 -1
  19. package/dist/render/allowed-hosts.d.ts +41 -0
  20. package/dist/render/allowed-hosts.d.ts.map +1 -0
  21. package/dist/render/allowed-hosts.js +88 -0
  22. package/dist/render/allowed-hosts.js.map +1 -0
  23. package/dist/render/blend-mode.d.ts +7 -0
  24. package/dist/render/blend-mode.d.ts.map +1 -0
  25. package/dist/render/blend-mode.js +49 -0
  26. package/dist/render/blend-mode.js.map +1 -0
  27. package/dist/render/bundle.d.ts +9 -1
  28. package/dist/render/bundle.d.ts.map +1 -1
  29. package/dist/render/bundle.js.map +1 -1
  30. package/dist/render/fill.d.ts +36 -3
  31. package/dist/render/fill.d.ts.map +1 -1
  32. package/dist/render/fill.js +222 -23
  33. package/dist/render/fill.js.map +1 -1
  34. package/dist/render/mask.d.ts +87 -0
  35. package/dist/render/mask.d.ts.map +1 -0
  36. package/dist/render/mask.js +243 -0
  37. package/dist/render/mask.js.map +1 -0
  38. package/dist/render/primitives/frame.d.ts.map +1 -1
  39. package/dist/render/primitives/frame.js +91 -5
  40. package/dist/render/primitives/frame.js.map +1 -1
  41. package/dist/render/primitives/grid.d.ts +1 -1
  42. package/dist/render/primitives/grid.d.ts.map +1 -1
  43. package/dist/render/primitives/grid.js +4 -1
  44. package/dist/render/primitives/grid.js.map +1 -1
  45. package/dist/render/primitives/image.d.ts +8 -1
  46. package/dist/render/primitives/image.d.ts.map +1 -1
  47. package/dist/render/primitives/image.js +17 -3
  48. package/dist/render/primitives/image.js.map +1 -1
  49. package/dist/render/primitives/index.d.ts +7 -0
  50. package/dist/render/primitives/index.d.ts.map +1 -1
  51. package/dist/render/primitives/index.js.map +1 -1
  52. package/dist/render/primitives/shape.d.ts.map +1 -1
  53. package/dist/render/primitives/shape.js +29 -26
  54. package/dist/render/primitives/shape.js.map +1 -1
  55. package/dist/render/primitives/stack.d.ts +1 -1
  56. package/dist/render/primitives/stack.d.ts.map +1 -1
  57. package/dist/render/primitives/stack.js +5 -1
  58. package/dist/render/primitives/stack.js.map +1 -1
  59. package/dist/render/primitives/text.d.ts.map +1 -1
  60. package/dist/render/primitives/text.js +0 -1
  61. package/dist/render/primitives/text.js.map +1 -1
  62. package/dist/render/prop-allowlist.d.ts.map +1 -1
  63. package/dist/render/prop-allowlist.js +25 -2
  64. package/dist/render/prop-allowlist.js.map +1 -1
  65. package/dist/render/shape-geometry.d.ts +81 -0
  66. package/dist/render/shape-geometry.d.ts.map +1 -0
  67. package/dist/render/shape-geometry.js +199 -0
  68. package/dist/render/shape-geometry.js.map +1 -0
  69. package/dist/render/shape-index.d.ts +28 -0
  70. package/dist/render/shape-index.d.ts.map +1 -0
  71. package/dist/render/shape-index.js +77 -0
  72. package/dist/render/shape-index.js.map +1 -0
  73. package/dist/render/tree.d.ts.map +1 -1
  74. package/dist/render/tree.js +175 -3
  75. package/dist/render/tree.js.map +1 -1
  76. package/dist/render/universal-wrapper.d.ts +27 -1
  77. package/dist/render/universal-wrapper.d.ts.map +1 -1
  78. package/dist/render/universal-wrapper.js +98 -22
  79. package/dist/render/universal-wrapper.js.map +1 -1
  80. package/dist/{status-pill-BT5b-yET.js → status-pill-jJT54n07.js} +2 -2
  81. package/dist/{status-pill-BT5b-yET.js.map → status-pill-jJT54n07.js.map} +1 -1
  82. package/dist/{test-_hh1JvAd.js → test-84XodL1c.js} +51 -51
  83. package/dist/{test-_hh1JvAd.js.map → test-84XodL1c.js.map} +1 -1
  84. package/dist/tree-BIimahCf.js +1777 -0
  85. package/dist/tree-BIimahCf.js.map +1 -0
  86. package/package.json +4 -4
  87. package/src/modes/broadcast.tsx +12 -1
  88. package/src/modes/control.tsx +10 -1
  89. package/src/modes/test.tsx +4 -1
  90. package/src/render/allowed-hosts.tsx +100 -0
  91. package/src/render/blend-mode.ts +50 -0
  92. package/src/render/bundle.ts +6 -1
  93. package/src/render/fill.tsx +266 -24
  94. package/src/render/mask.tsx +389 -0
  95. package/src/render/primitives/frame.tsx +101 -5
  96. package/src/render/primitives/grid.tsx +4 -1
  97. package/src/render/primitives/image.tsx +17 -3
  98. package/src/render/primitives/index.ts +7 -0
  99. package/src/render/primitives/shape.tsx +39 -75
  100. package/src/render/primitives/stack.tsx +5 -1
  101. package/src/render/primitives/text.tsx +0 -1
  102. package/src/render/prop-allowlist.ts +25 -2
  103. package/src/render/shape-geometry.tsx +315 -0
  104. package/src/render/shape-index.tsx +90 -0
  105. package/src/render/tree.tsx +214 -12
  106. package/src/render/universal-wrapper.tsx +128 -21
  107. package/dist/broadcast-DO7jEkix.js +0 -11
  108. package/dist/broadcast-DO7jEkix.js.map +0 -1
  109. package/dist/control-BSfl4_cO.js +0 -16
  110. package/dist/control-BSfl4_cO.js.map +0 -1
  111. package/dist/index-Crkij3C4.js.map +0 -1
  112. package/dist/tree-DBj9SJgs.js +0 -1230
  113. package/dist/tree-DBj9SJgs.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumencast/runtime",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Browser runtime for Lumencast — mount(), LSDP/1 transport, leaf-grain store, LSML render, animations, overlays.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "framer-motion": "^12.0.0",
37
37
  "react": "^19.0.0",
38
38
  "react-dom": "^19.0.0",
39
- "@lumencast/protocol": "0.6.0"
39
+ "@lumencast/protocol": "0.7.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@playwright/test": "^1.49.1",
@@ -50,8 +50,8 @@
50
50
  "vite-plugin-dts": "^4.5.0",
51
51
  "vitest": "^4.1.5",
52
52
  "ws": "^8.18.0",
53
- "@lumencast/dev-server": "0.6.0",
54
- "@lumencast/server": "0.6.0"
53
+ "@lumencast/dev-server": "0.7.0",
54
+ "@lumencast/server": "0.7.0"
55
55
  },
56
56
  "scripts": {
57
57
  "dev": "vite",
@@ -1,8 +1,19 @@
1
+ import { useMemo } from "react";
1
2
  import { Tree } from "../render/tree";
3
+ import { AllowedHostsProvider, readAllowedHosts } from "../render/allowed-hosts";
4
+ import { ShapeIndexProvider, buildShapeIndex } from "../render/shape-index";
2
5
  import { useLumencastRuntime } from "../overlay/runtime-context";
3
6
 
4
7
  /** Broadcast mode : pure scene render, no UI chrome. */
5
8
  export function BroadcastMode() {
6
9
  const { store, bundle } = useLumencastRuntime();
7
- return <Tree node={bundle.root} store={store} />;
10
+ // ADR 002 A2.1 (#K) — build the `id → shape` index once per bundle.
11
+ const shapeIndex = useMemo(() => buildShapeIndex(bundle.root), [bundle.root]);
12
+ return (
13
+ <AllowedHostsProvider hosts={readAllowedHosts(bundle)}>
14
+ <ShapeIndexProvider index={shapeIndex}>
15
+ <Tree node={bundle.root} store={store} />
16
+ </ShapeIndexProvider>
17
+ </AllowedHostsProvider>
18
+ );
8
19
  }
@@ -1,4 +1,7 @@
1
+ import { useMemo } from "react";
1
2
  import { Tree } from "../render/tree";
3
+ import { AllowedHostsProvider, readAllowedHosts } from "../render/allowed-hosts";
4
+ import { ShapeIndexProvider, buildShapeIndex } from "../render/shape-index";
2
5
  import { ControlPanel } from "../overlay/control";
3
6
  import { StatusPill } from "../overlay/status-pill";
4
7
  import { useLumencastRuntime } from "../overlay/runtime-context";
@@ -7,9 +10,15 @@ import { useLumencastRuntime } from "../overlay/runtime-context";
7
10
  * panel from operator_inputs). */
8
11
  export function ControlMode() {
9
12
  const { store, bundle } = useLumencastRuntime();
13
+ // ADR 002 A2.1 (#K) — build the `id → shape` index once per bundle.
14
+ const shapeIndex = useMemo(() => buildShapeIndex(bundle.root), [bundle.root]);
10
15
  return (
11
16
  <>
12
- <Tree node={bundle.root} store={store} />
17
+ <AllowedHostsProvider hosts={readAllowedHosts(bundle)}>
18
+ <ShapeIndexProvider index={shapeIndex}>
19
+ <Tree node={bundle.root} store={store} />
20
+ </ShapeIndexProvider>
21
+ </AllowedHostsProvider>
13
22
  <StatusPill />
14
23
  <ControlPanel />
15
24
  </>
@@ -1,4 +1,5 @@
1
1
  import { Tree } from "../render/tree";
2
+ import { AllowedHostsProvider, readAllowedHosts } from "../render/allowed-hosts";
2
3
  import { ControlPanel } from "../overlay/control";
3
4
  import { TestPanel } from "../overlay/test";
4
5
  import { StatusPill } from "../overlay/status-pill";
@@ -10,7 +11,9 @@ export function TestMode() {
10
11
  const { store, bundle } = useLumencastRuntime();
11
12
  return (
12
13
  <>
13
- <Tree node={bundle.root} store={store} />
14
+ <AllowedHostsProvider hosts={readAllowedHosts(bundle)}>
15
+ <Tree node={bundle.root} store={store} />
16
+ </AllowedHostsProvider>
14
17
  <StatusPill />
15
18
  <ControlPanel />
16
19
  <TestPanel />
@@ -0,0 +1,100 @@
1
+ // Render-side host-allowlist context (LSML 1.2, ADR 002 #E + #F ; Bastion T1/T2).
2
+ //
3
+ // CANONICAL host-gate module for the runtime. There is exactly ONE
4
+ // `AllowedHostsProvider` and ONE allowlist context for the whole render
5
+ // tree. The bundle's `assets.allowedHosts` rides this context from the
6
+ // render root down to every consumer that places an untrusted asset URL
7
+ // into the DOM :
8
+ //
9
+ // - image-fill `src` on frame / shape backgrounds (#F, via `gateImageFills`
10
+ // / `gateSrc` in `fill.tsx`),
11
+ // - the `<img src>` of the `image` primitive (#F — closes the latent 1.1
12
+ // hole where `image.tsx` placed `src` with no host check at all),
13
+ // - a `mask.source`-image `href` (#E, via `checkHostAllowed` in
14
+ // `mask.tsx`, which reads the allowlist off this same context through
15
+ // `tree.tsx`).
16
+ //
17
+ // The underlying decision is ALWAYS delegated to `@lumencast/protocol`'s
18
+ // `checkHostAllowed` / `isHostAllowed` (the #C foundation, single source of
19
+ // truth for host + scheme matching). This module never re-implements that
20
+ // logic ; it only threads the allowlist and adapts it for each call-site.
21
+ //
22
+ // Deny-by-default : a consumer rendered outside any provider, or one whose
23
+ // bundle declares no `allowedHosts`, sees `undefined` — which
24
+ // `checkHostAllowed` treats as "no allowlist → reject every remote host".
25
+ // There is no path by which a missing provider silently re-opens the gate.
26
+ //
27
+ // The context value is a read-only, mount-stable `string[] | undefined`
28
+ // (the allowlist is part of the content-addressed bundle), so a plain React
29
+ // context is the right tool.
30
+
31
+ import { createContext, useContext, type ReactNode } from "react";
32
+ import { checkHostAllowed } from "@lumencast/protocol";
33
+ import { emitDiagnostic } from "./diagnostics";
34
+
35
+ const AllowedHostsCtx = createContext<readonly string[] | undefined>(undefined);
36
+
37
+ /**
38
+ * Provide the bundle's host allowlist to the render subtree. Mounted ONCE at
39
+ * the render root by each mode (broadcast / control / test), wrapping
40
+ * `<Tree>`. The value should come from {@link readAllowedHosts} so the
41
+ * legacy `Asset[]` and the LSML 1.2 object `assets` shapes are both handled
42
+ * and non-string entries are dropped.
43
+ *
44
+ * Prop name is `hosts` (the canonical render-side spelling, #F).
45
+ */
46
+ export function AllowedHostsProvider({
47
+ hosts,
48
+ children,
49
+ }: {
50
+ hosts: readonly string[] | undefined;
51
+ children: ReactNode;
52
+ }) {
53
+ return <AllowedHostsCtx.Provider value={hosts}>{children}</AllowedHostsCtx.Provider>;
54
+ }
55
+
56
+ /** Read the active host allowlist. `undefined` when no provider is mounted —
57
+ * which `checkHostAllowed` treats as deny-by-default (never a passthrough). */
58
+ export function useAllowedHosts(): readonly string[] | undefined {
59
+ return useContext(AllowedHostsCtx);
60
+ }
61
+
62
+ /** Read `assets.allowedHosts` defensively off a render bundle, for the mode
63
+ * that provides the context. The LSML 1.2 compiler forwards `assets` in the
64
+ * object form `{ allowedHosts?: string[] }` (see `RenderBundle.assets`) ;
65
+ * a legacy bundle may still carry the old `Asset[]` form. Either way we
66
+ * extract a `string[]` of hostnames, or `undefined` (deny-by-default). A
67
+ * non-string entry is dropped — it can never match `new URL().hostname`. */
68
+ export function readAllowedHosts(bundle: { assets?: unknown }): readonly string[] | undefined {
69
+ const assets = bundle.assets as { allowedHosts?: unknown } | undefined;
70
+ const list = assets?.allowedHosts;
71
+ if (!Array.isArray(list)) return undefined;
72
+ const hosts = list.filter((h): h is string => typeof h === "string");
73
+ return hosts.length > 0 ? hosts : undefined;
74
+ }
75
+
76
+ /**
77
+ * Gate an asset `src` against the host + scheme allowlist BEFORE it reaches
78
+ * the DOM (Bastion T1/T2). Returns the `src` unchanged when allowed, or
79
+ * `undefined` when rejected — in which case the caller MUST omit the asset
80
+ * (never passthrough). On rejection an R9-clean diagnostic is emitted : it
81
+ * carries only `{ nodeId, field, reason }`, never the URL itself.
82
+ *
83
+ * The decision is delegated to `checkHostAllowed` and is deny-by-default :
84
+ * an absent / empty `allowedHosts` rejects every remote host.
85
+ * `undefined`/non-string/empty `src` resolves to `undefined` (absent asset)
86
+ * WITHOUT a diagnostic — that is a primitive with no source, not a rejected
87
+ * one.
88
+ */
89
+ export function gateSrc(
90
+ src: unknown,
91
+ allowedHosts: readonly string[] | undefined,
92
+ field: string,
93
+ nodeId?: string,
94
+ ): string | undefined {
95
+ if (typeof src !== "string" || src.length === 0) return undefined;
96
+ const decision = checkHostAllowed(src, allowedHosts);
97
+ if (decision.allowed) return src;
98
+ emitDiagnostic(nodeId, field, decision.reason ?? "asset host/scheme rejected");
99
+ return undefined;
100
+ }
@@ -0,0 +1,50 @@
1
+ // Strict `mix-blend-mode` gate — the runtime half of the T4 double-gate
2
+ // (Bastion conditions 1.2, ADR 002 §3.2 / #D).
3
+ //
4
+ // The compiler already validates `blendMode` against its closed enum
5
+ // (`parseBlendMode`, @lumencast/compiler) before emitting the universal
6
+ // prop. This module is the INDEPENDENT runtime gate : a bundle prop OR a
7
+ // live LSDP delta value reaching the wrapper is re-validated here against
8
+ // the same closed allowlist before it may touch an inline CSS style.
9
+ // Anything outside the enum is omitted (never passthrough) — mirroring
10
+ // the `css-color.ts` discipline (self-contained second gate, no untrusted
11
+ // string ever interpolated into CSS).
12
+ //
13
+ // The allowlist is intentionally duplicated rather than imported from the
14
+ // compiler : the runtime does not depend on @lumencast/compiler, and the
15
+ // gate must hold even if a hand-rolled / tampered bundle bypasses the
16
+ // compiler entirely. It is a fixed, finite set of CSS keywords (Figma
17
+ // blend modes minus PASS_THROUGH) — the single source of truth for the
18
+ // CSS value is this closed set.
19
+
20
+ /** Closed `mix-blend-mode` allowlist (ADR 002 §3.2 — Figma minus
21
+ * `PASS_THROUGH`). Mirrors the compiler's `BLEND_MODES`. */
22
+ const BLEND_MODES: ReadonlySet<string> = new Set([
23
+ "normal",
24
+ "multiply",
25
+ "screen",
26
+ "overlay",
27
+ "darken",
28
+ "lighten",
29
+ "color-dodge",
30
+ "color-burn",
31
+ "hard-light",
32
+ "soft-light",
33
+ "difference",
34
+ "exclusion",
35
+ "hue",
36
+ "saturation",
37
+ "color",
38
+ "luminosity",
39
+ // Figma LINEAR_DODGE (add) — exact additive blend, gentler than color-dodge.
40
+ "plus-lighter",
41
+ ]);
42
+
43
+ /**
44
+ * Re-validate a resolved `blendMode` against the closed enum. Returns the
45
+ * CSS `mix-blend-mode` keyword when recognised, else `undefined` (caller
46
+ * omits — the value never reaches the style). Never passthrough.
47
+ */
48
+ export function parseBlendMode(value: unknown): string | undefined {
49
+ return typeof value === "string" && BLEND_MODES.has(value) ? value : undefined;
50
+ }
@@ -111,7 +111,12 @@ export interface RenderBundle {
111
111
  root: RenderNode;
112
112
  operator_inputs?: OperatorInput[];
113
113
  external_adapters?: ExternalAdapter[];
114
- assets?: Asset[];
114
+ /** Bundle-level asset declarations. `allowedHosts` is the host
115
+ * allowlist (LSML 1.2 §3.2, Bastion T1) every image / image-fill `src`
116
+ * is gated against at render BEFORE reaching the DOM. Absent / empty =
117
+ * deny every remote host (deny-by-default). Other keys (`fonts`,
118
+ * `preload`) are forwarded opaquely by the compiler. */
119
+ assets?: { allowedHosts?: string[]; [key: string]: unknown };
115
120
  /** LSML 1.1 §17.3 — capability profiles required for correct rendering.
116
121
  * Each entry is an `x-<vendor>.<name>/<version>` string. The runtime
117
122
  * checks every behavioural entry against its supported list ; an
@@ -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
- // SVG fill-opacity composes with element opacity multiplicatively
57
- // so we apply both consistently.
58
- return { defs: [], ref: fill.color };
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
- // angle_deg : 0 = bottom-to-top per §4.12 (matches CSS `linear-gradient`)
63
- const angle = fill.angle_deg ?? 0;
64
- // Translate angle (degrees from up) to SVG x1/y1/x2/y2 in user space.
65
- const rad = ((angle - 90) * Math.PI) / 180; // 0° → x1=0,y1=1 (bottom-up)
66
- const x1 = 0.5 - 0.5 * Math.cos(rad);
67
- const y1 = 0.5 - 0.5 * Math.sin(rad);
68
- const x2 = 0.5 + 0.5 * Math.cos(rad);
69
- const y2 = 0.5 + 0.5 * Math.sin(rad);
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
- /** Compile an array of Fill into a CSS `background-image` value usable
111
- * on a `<div>` (frame backgrounds non-SVG context). Returns the CSS
112
- * string + opacity. Stops use percentages in CSS gradient syntax. */
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
- const layers = fills.map((f) => fillToCss(f, nodeId)).filter(Boolean) as string[];
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
- return { backgroundImage: layers.join(", ") };
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(${color}, ${color})`;
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
- const angle = fill.angle_deg ?? 0;
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
- return value.filter(isFill) as Fill[];
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
- return k === "solid" || k === "linear-gradient" || k === "radial-gradient";
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
  }