@lumencast/compiler 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/src/compile.ts CHANGED
@@ -26,12 +26,22 @@ import type {
26
26
  LSMLAnimateDirective,
27
27
  LSMLAnimateState,
28
28
  LSMLBundle,
29
+ LSMLFill,
29
30
  LSMLKeyframes,
31
+ LSMLMask,
30
32
  LSMLNode,
31
33
  LSMLPath,
32
34
  LSMLRepeat,
33
35
  LSMLText,
34
36
  } from "./lsml-types.js";
37
+ import {
38
+ parseBlendMode,
39
+ parseObjectFit,
40
+ clampGradientTransform,
41
+ MASK_TYPES,
42
+ MASK_OPS,
43
+ } from "./lsml-1_2.js";
44
+ import { checkHostAllowed } from "@lumencast/protocol";
35
45
 
36
46
  /** Structured compile diagnostic (ADR 001 §3.4, issue #34). Per
37
47
  * Bastion R9 it carries node identity + field + static reason and
@@ -53,9 +63,16 @@ export interface CompileOptions {
53
63
  /** Optional warn collector — receives the formatted message plus the
54
64
  * structured diagnostic (additive second argument, issue #34). */
55
65
  onWarn?: (message: string, diagnostic: CompileDiagnostic) => void;
66
+ /** INTERNAL (ADR 002 #F, Bastion T1/T2) — the bundle's
67
+ * `assets.allowedHosts`, threaded down by `compileBundle` so image-fill
68
+ * `src` is host/scheme-gated at lowering (the compiler arm of the
69
+ * double-gate ; the runtime re-gates because live LSDP deltas bypass the
70
+ * compiler). Not part of the caller-facing contract — `compileBundle`
71
+ * always sets it from the bundle. */
72
+ allowedHosts?: readonly string[];
56
73
  }
57
74
 
58
- const SUPPORTED_VERSIONS = new Set(["1.0", "1.1"] as const);
75
+ const SUPPORTED_VERSIONS = new Set(["1.0", "1.1", "1.2"] as const);
59
76
 
60
77
  // --- hard caps (ADR 001 §5.1 R8 + §6 RC#10, threat model Bastion) ------
61
78
  //
@@ -132,8 +149,14 @@ const COMMON_NODE_KEYS: ReadonlySet<string> = new Set([
132
149
  "visible",
133
150
  "opacity",
134
151
  "rotation",
152
+ "flipY",
153
+ "blur",
154
+ "shadow",
135
155
  "sizing",
136
156
  "position",
157
+ // 1.2+ (ADR 002 §3.2) — universal blend mode + typed mask on every node.
158
+ "blendMode",
159
+ "mask",
137
160
  ]);
138
161
 
139
162
  /** Keys `compileRepeat` consumes. `scope` names the iteration scope the
@@ -151,7 +174,7 @@ const REPEAT_NODE_KEYS: ReadonlySet<string> = new Set([
151
174
  const KIND_NODE_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {
152
175
  stack: new Set(["direction", "gap", "align", "justify", "padding", "rtl"]),
153
176
  grid: new Set(["columns", "rows", "gap", "padding"]),
154
- frame: new Set(["size", "position", "background", "backgrounds", "clipsContent"]),
177
+ frame: new Set(["size", "position", "background", "backgrounds", "clipsContent", "cornerRadius"]),
155
178
  text: new Set(["style", "format", "maxLines"]),
156
179
  image: new Set(["alt", "size", "fit"]),
157
180
  shape: new Set([
@@ -194,6 +217,10 @@ const BUNDLE_KEYS: ReadonlySet<string> = new Set([
194
217
  "layout",
195
218
  "operator_inputs",
196
219
  "external_adapters",
220
+ // ADR 002 #F — `assets` (incl. `allowedHosts`, Bastion T1/T6) is now
221
+ // consumed : it drives the image-fill host gate at lowering AND is
222
+ // forwarded verbatim into the RenderBundle so the runtime can re-gate.
223
+ "assets",
197
224
  ]);
198
225
 
199
226
  const NOT_LOWERED =
@@ -225,9 +252,17 @@ export function compileBundle(lsml: LSMLBundle, options: CompileOptions = {}): R
225
252
  );
226
253
  }
227
254
  auditBundleKeys(lsml, options);
255
+ // ADR 002 #F / Bastion T1+T2+T6 — thread `assets.allowedHosts` down so the
256
+ // compiler arm of the double-gate can host/scheme-check every image-fill
257
+ // `src` at lowering, and FORWARD `assets` verbatim into the bundle so the
258
+ // runtime arm has the allowlist to re-gate live LSDP deltas. `emit_lsml.go`
259
+ // (Orion) likewise preserves `assets.allowedHosts` (T6) — the compiler
260
+ // never fabricates or strips it.
261
+ const opts: CompileOptions = { ...options, allowedHosts: lsml.assets?.allowedHosts };
228
262
  return {
229
263
  scene_version: lsml.scene_version,
230
- root: compileNode(lsml.layout, options),
264
+ root: compileNode(lsml.layout, opts),
265
+ ...(lsml.assets !== undefined ? { assets: lsml.assets } : {}),
231
266
  // LSML 1.1 §17.3 — forward `profiles[]` verbatim so the runtime applies
232
267
  // the same gating rule (§17.3.1 hard rejection for unsupported
233
268
  // behavioural profiles, §17.5.1 advisory pass-through for authoring
@@ -303,10 +338,14 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
303
338
  if (node.background !== undefined) props["background"] = node.background;
304
339
  // 1.1 §4.3 + §4.12 — stacked backgrounds (frame.tsx reads
305
340
  // `resolved.backgrounds`; array form wins over legacy `background`).
306
- if (node.backgrounds !== undefined) props["backgrounds"] = node.backgrounds;
341
+ if (node.backgrounds !== undefined)
342
+ props["backgrounds"] = lowerFills(node.backgrounds, node.id, "backgrounds", opts);
307
343
  // 1.1 §4.3 — clip children to the frame bounds. The spec default
308
344
  // (`true`) is applied runtime-side ; only explicit values forward.
309
345
  if (node.clipsContent !== undefined) props["clipsContent"] = node.clipsContent;
346
+ // Rounded container (pills, picto square). Canonical RenderNode name is
347
+ // `radius` (frame.tsx reads it as `border-radius`).
348
+ if (node.cornerRadius !== undefined) props["radius"] = node.cornerRadius;
310
349
  break;
311
350
 
312
351
  case "text":
@@ -345,7 +384,7 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
345
384
  }
346
385
  if (node.fill !== undefined) props["fill"] = node.fill;
347
386
  // 1.1 §4.6 + §4.12 — stacked fills (shape.tsx reads `resolved.fills`).
348
- if (node.fills !== undefined) props["fills"] = node.fills;
387
+ if (node.fills !== undefined) props["fills"] = lowerFills(node.fills, node.id, "fills", opts);
349
388
  // Single stroke lowers to the flat props shape.tsx consumes
350
389
  // (`stroke` = colour string, `stroke_width` = number). The previous
351
390
  // object forward was silently unrenderable.
@@ -399,6 +438,9 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
399
438
  if (node.visible !== undefined) props["visible"] = node.visible;
400
439
  if (node.opacity !== undefined) props["opacity"] = node.opacity;
401
440
  if (node.rotation !== undefined) props["rotation"] = node.rotation;
441
+ if (node.flipY !== undefined) props["flipY"] = node.flipY;
442
+ if (node.blur !== undefined) props["blur"] = node.blur;
443
+ if (node.shadow !== undefined) props["shadow"] = node.shadow;
402
444
  if (node.sizing !== undefined) props["sizing"] = node.sizing;
403
445
  if (node.position !== undefined && props["x"] === undefined && props["y"] === undefined) {
404
446
  // Frame's case above already sets x/y from `position` ; the universal
@@ -410,6 +452,22 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
410
452
  for (const [k, v] of Object.entries(node.bindUniversal)) bindings[k] = v;
411
453
  }
412
454
 
455
+ // 1.2+ universal effects (ADR 002 §3.2 ; Bastion T4). Closed enums :
456
+ // a value outside the allowlist is diagnosed and OMITTED, never
457
+ // forwarded. The runtime re-validates on the live path (#D/#E).
458
+ if (node.blendMode !== undefined) {
459
+ const mode = parseBlendMode(node.blendMode);
460
+ if (mode === null) {
461
+ warn(opts, node.id, "blendMode", "is not a recognised mix-blend-mode (ADR 002 §3.2)");
462
+ } else {
463
+ props["blendMode"] = mode;
464
+ }
465
+ }
466
+ if (node.mask !== undefined) {
467
+ const mask = lowerMask(node.mask, node.id, opts);
468
+ if (mask !== null) props["mask"] = mask;
469
+ }
470
+
413
471
  const children = node.children?.map((c) => compileNode(c, opts));
414
472
 
415
473
  const out: RenderNode = { kind: node.kind };
@@ -668,6 +726,146 @@ function lowerPaths(
668
726
  });
669
727
  }
670
728
 
729
+ // --- LSML 1.2 fill + mask lowering (ADR 002 §3.2 ; Bastion T4) ----------
730
+ //
731
+ // Closed-enum + bounded-float gate at lowering. A bad enum value or a
732
+ // malformed gradient transform is diagnosed and the offending FIELD is
733
+ // omitted — the rest of the fill is preserved. Host/scheme allowlist of an
734
+ // image-fill `src` (T1/T2) is enforced HERE (#F) against the threaded
735
+ // `opts.allowedHosts` — the compiler arm of the double-gate. A rejected
736
+ // `src` drops the WHOLE image-fill (never a passthrough URL) with an
737
+ // R9-clean diagnostic. The runtime re-gates because live LSDP deltas bypass
738
+ // the compiler entirely.
739
+
740
+ /** Lower a `fills[]` / `backgrounds[]` array, validating 1.2 image-fill and
741
+ * gradient-transform fields. An image-fill whose `src` fails the host /
742
+ * scheme allowlist is dropped (T1/T2). Unknown fill kinds and stop arrays
743
+ * pass through unchanged (they are gated elsewhere). */
744
+ function lowerFills(
745
+ fills: LSMLFill[],
746
+ nodeId: string | undefined,
747
+ field: string,
748
+ opts: CompileOptions,
749
+ ): LSMLFill[] {
750
+ return fills.flatMap((fill, i) => {
751
+ // #L (ADR 002 A2.2) — per-fill `blendMode`, revalidated against the closed
752
+ // enum (T4 compiler arm ; the runtime re-validates independently). A value
753
+ // outside `LSMLBlendMode` is diagnosed + omitted, never passthrough. The
754
+ // helper re-applies the validated mode (or drops it) on whatever the branch
755
+ // below emits, so the same gate covers every Fill variant.
756
+ const applyBlend = <T extends LSMLFill>(out: T): T => {
757
+ if (fill.blendMode === undefined) return out;
758
+ const mode = parseBlendMode(fill.blendMode);
759
+ if (mode === null) {
760
+ warn(
761
+ opts,
762
+ nodeId,
763
+ `${field}[${i}].blendMode`,
764
+ "is not a recognised mix-blend-mode (ADR 002 §3.2)",
765
+ );
766
+ const { blendMode: _drop, ...rest } = out;
767
+ return rest as T;
768
+ }
769
+ return { ...out, blendMode: mode };
770
+ };
771
+ if (fill.kind === "image") {
772
+ // T1/T2 — gate `src` before anything else ; a rejected host/scheme
773
+ // drops the entire image-fill. R9 : the diagnostic carries the static
774
+ // reason, never the URL.
775
+ const decision = checkHostAllowed(fill.src, opts.allowedHosts);
776
+ if (!decision.allowed) {
777
+ warn(
778
+ opts,
779
+ nodeId,
780
+ `${field}[${i}].src`,
781
+ `image-fill src rejected by host/scheme allowlist : ${decision.reason} (ADR 002 §3.2, Bastion T1/T2)`,
782
+ );
783
+ return [];
784
+ }
785
+ const out = { ...fill };
786
+ if (fill.objectFit !== undefined) {
787
+ const fit = parseObjectFit(fill.objectFit);
788
+ if (fit === null) {
789
+ warn(
790
+ opts,
791
+ nodeId,
792
+ `${field}[${i}].objectFit`,
793
+ "is not a recognised object-fit (ADR 002 §3.2)",
794
+ );
795
+ delete out.objectFit;
796
+ } else {
797
+ out.objectFit = fit;
798
+ }
799
+ }
800
+ if (fill.transform !== undefined) {
801
+ const t = clampGradientTransform(fill.transform);
802
+ if (t === null) {
803
+ warn(opts, nodeId, `${field}[${i}].transform`, "is not 6 finite floats (ADR 002 §3.2)");
804
+ delete out.transform;
805
+ } else {
806
+ out.transform = t;
807
+ }
808
+ }
809
+ return applyBlend(out);
810
+ }
811
+ if (fill.kind === "linear-gradient" || fill.kind === "radial-gradient") {
812
+ if (fill.transform === undefined) return applyBlend(fill);
813
+ const t = clampGradientTransform(fill.transform);
814
+ if (t === null) {
815
+ warn(opts, nodeId, `${field}[${i}].transform`, "is not 6 finite floats (ADR 002 §3.2)");
816
+ const { transform: _drop, ...rest } = fill;
817
+ return applyBlend(rest);
818
+ }
819
+ return applyBlend({ ...fill, transform: t });
820
+ }
821
+ return applyBlend(fill);
822
+ });
823
+ }
824
+
825
+ /** Lower a typed `mask` (ADR 002 §3.2). Closed enums on `type` / `op` ; a bad
826
+ * enum or a malformed `source` discriminant drops the whole mask (it cannot
827
+ * be partially honoured). NEVER forwards a free SVG string — `source` is a
828
+ * typed discriminated union by construction (Bastion T3). */
829
+ function lowerMask(
830
+ mask: LSMLMask,
831
+ nodeId: string | undefined,
832
+ opts: CompileOptions,
833
+ ): LSMLMask | null {
834
+ if (typeof mask !== "object" || mask === null) {
835
+ warn(opts, nodeId, "mask", "is not a mask object (ADR 002 §3.2)");
836
+ return null;
837
+ }
838
+ if (!MASK_TYPES.has(mask.type as string)) {
839
+ warn(opts, nodeId, "mask.type", "is not alpha|luminance (ADR 002 §3.2)");
840
+ return null;
841
+ }
842
+ if (!MASK_OPS.has(mask.op as string)) {
843
+ warn(opts, nodeId, "mask.op", "is not intersect|subtract|union (ADR 002 §3.2)");
844
+ return null;
845
+ }
846
+ const src = mask.source;
847
+ const validSource =
848
+ typeof src === "object" &&
849
+ src !== null &&
850
+ ((src.kind === "shape" && typeof (src as { ref?: unknown }).ref === "string") ||
851
+ (src.kind === "image" && typeof (src as { src?: unknown }).src === "string") ||
852
+ // group/frame container source (ADR 002 A4.3) — same closed-enum, same
853
+ // `ref` shape as `shape` ; the runtime composites its visible children.
854
+ (src.kind === "group" && typeof (src as { ref?: unknown }).ref === "string"));
855
+ if (!validSource) {
856
+ warn(opts, nodeId, "mask.source", "is not a typed shape|image|group source (ADR 002 §3.2)");
857
+ return null;
858
+ }
859
+ // Re-emit only the typed fields, dropping any extraneous keys.
860
+ return {
861
+ source: src,
862
+ type: mask.type,
863
+ op: mask.op,
864
+ ...(mask.position !== undefined ? { position: mask.position } : {}),
865
+ ...(mask.size !== undefined ? { size: mask.size } : {}),
866
+ };
867
+ }
868
+
671
869
  // --- filter lowering (ADR 001 §5.1 R8 — hard clamps, non-optional) -----
672
870
 
673
871
  /** Lower an LSML `filter` state (`{ blur?, brightness? }`) to the CSS
package/src/index.ts CHANGED
@@ -14,6 +14,17 @@ export {
14
14
  type CompileDiagnostic,
15
15
  } from "./compile.js";
16
16
  export { canonicalize, hashBundle, ZERO_HASH } from "./canonicalize.js";
17
+
18
+ export {
19
+ BLEND_MODES,
20
+ OBJECT_FITS,
21
+ MASK_TYPES,
22
+ MASK_OPS,
23
+ MAX_GRADIENT_TRANSFORM_ABS,
24
+ parseBlendMode,
25
+ parseObjectFit,
26
+ clampGradientTransform,
27
+ } from "./lsml-1_2.js";
17
28
  export type {
18
29
  LSMLBundle,
19
30
  LSMLNode,
@@ -22,6 +33,10 @@ export type {
22
33
  LSMLAnimateDirective,
23
34
  LSMLFill,
24
35
  LSMLFillStop,
36
+ LSMLBlendMode,
37
+ LSMLObjectFit,
38
+ LSMLMask,
39
+ LSMLGradientTransform,
25
40
  LSMLStroke,
26
41
  LSMLPath,
27
42
  LSMLKeyframes,
@@ -0,0 +1,101 @@
1
+ // LSML 1.2 closed-enum parsers + bounded gradient-transform clamp
2
+ // (ADR 002 #C ; Bastion condition T4). Compiler half of the double-gate :
3
+ // the runtime re-validates the same values on the live path (#D/#E/#F wire
4
+ // `mix-blend-mode`/`object-fit`/`mask`/`gradientTransform` at render). This
5
+ // module mirrors the `css-color.ts` / `filter-clamp.ts` contract :
6
+ //
7
+ // - a value outside the closed enum returns `null` → caller emits a
8
+ // diagnostic and OMITS the field. NEVER passthrough of the raw value.
9
+ // - a gradient transform is 6 finite floats, each clamped to a bounded
10
+ // range. A malformed transform returns `null` → omitted, never a free
11
+ // string interpolated into SVG.
12
+ //
13
+ // None of these helpers throws, logs, or echoes the offending value — the
14
+ // caller attaches a static reason to a diagnostic (Bastion R9).
15
+
16
+ import type { LSMLBlendMode, LSMLObjectFit, LSMLGradientTransform } from "./lsml-types.js";
17
+
18
+ /** Closed `mix-blend-mode` allowlist (ADR 002 §3.2 — Figma minus
19
+ * `PASS_THROUGH`). The single source of truth for the compiler. */
20
+ export const BLEND_MODES: ReadonlySet<LSMLBlendMode> = new Set([
21
+ "normal",
22
+ "multiply",
23
+ "screen",
24
+ "overlay",
25
+ "darken",
26
+ "lighten",
27
+ "color-dodge",
28
+ "color-burn",
29
+ "hard-light",
30
+ "soft-light",
31
+ "difference",
32
+ "exclusion",
33
+ "hue",
34
+ "saturation",
35
+ "color",
36
+ "luminosity",
37
+ ]);
38
+
39
+ /** Closed `object-fit` allowlist (ADR 002 §3.2). */
40
+ export const OBJECT_FITS: ReadonlySet<LSMLObjectFit> = new Set([
41
+ "cover",
42
+ "contain",
43
+ "fill",
44
+ "none",
45
+ "scale-down",
46
+ ]);
47
+
48
+ /** Closed `mask.type` allowlist. */
49
+ export const MASK_TYPES: ReadonlySet<string> = new Set(["alpha", "luminance"]);
50
+
51
+ /** Closed `mask.op` allowlist. */
52
+ export const MASK_OPS: ReadonlySet<string> = new Set(["intersect", "subtract", "union"]);
53
+
54
+ /**
55
+ * Validate a `blendMode` against the closed enum. Returns the value when it
56
+ * is a recognised mode, else `null` (caller omits + diagnoses). Never
57
+ * passthrough.
58
+ */
59
+ export function parseBlendMode(value: unknown): LSMLBlendMode | null {
60
+ return typeof value === "string" && BLEND_MODES.has(value as LSMLBlendMode)
61
+ ? (value as LSMLBlendMode)
62
+ : null;
63
+ }
64
+
65
+ /**
66
+ * Validate an `objectFit` against the closed enum. Returns the value or
67
+ * `null` (caller omits + diagnoses). Never passthrough.
68
+ */
69
+ export function parseObjectFit(value: unknown): LSMLObjectFit | null {
70
+ return typeof value === "string" && OBJECT_FITS.has(value as LSMLObjectFit)
71
+ ? (value as LSMLObjectFit)
72
+ : null;
73
+ }
74
+
75
+ /** Bound on each affine component (anti-DoS ; a gradient transform is purely
76
+ * cosmetic, no legitimate value approaches this). Mirrors the spirit of the
77
+ * filter caps : finite and bounded, never a free string. */
78
+ export const MAX_GRADIENT_TRANSFORM_ABS = 1e6;
79
+
80
+ /**
81
+ * Validate + clamp a gradient `transform` to 6 finite, bounded floats. The
82
+ * input must be an array of exactly 6 numbers ; each non-finite or
83
+ * out-of-range component fails the whole transform (returns `null` → omit,
84
+ * fall back to `angle_deg`). Components within range are clamped to
85
+ * `[-MAX_GRADIENT_TRANSFORM_ABS, +MAX_GRADIENT_TRANSFORM_ABS]`. A `-0`
86
+ * normalises to `0`. NEVER returns a string ; SVG `gradientTransform` is
87
+ * built numerically by the runtime (#D), never interpolated from author text.
88
+ */
89
+ export function clampGradientTransform(value: unknown): LSMLGradientTransform | null {
90
+ if (!Array.isArray(value) || value.length !== 6) return null;
91
+ const out = new Array<number>(6);
92
+ for (let i = 0; i < 6; i++) {
93
+ const c = value[i];
94
+ if (typeof c !== "number" || !Number.isFinite(c)) return null;
95
+ let clamped = c;
96
+ if (clamped > MAX_GRADIENT_TRANSFORM_ABS) clamped = MAX_GRADIENT_TRANSFORM_ABS;
97
+ else if (clamped < -MAX_GRADIENT_TRANSFORM_ABS) clamped = -MAX_GRADIENT_TRANSFORM_ABS;
98
+ out[i] = Object.is(clamped, -0) ? 0 : clamped;
99
+ }
100
+ return out as LSMLGradientTransform;
101
+ }
package/src/lsml-types.ts CHANGED
@@ -9,6 +9,69 @@
9
9
  // - Multi-fill `fills[]` on `shape` (§4.6 + §4.12)
10
10
  // - Stacked `backgrounds[]` on `frame` (§4.3)
11
11
  // - Bundle-level `$schema`, `profiles[]` (§17.3)
12
+ //
13
+ // 1.2 additions (additive over 1.1 — a 1.1 bundle stays valid ; ADR 002 §3.2) :
14
+ // - `blendMode` on every primitive (closed enum → CSS `mix-blend-mode`)
15
+ // - `mask` on every primitive (typed fields, never a free SVG string)
16
+ // - first-class image-fill variant on `LSMLFill` (`{ kind: "image"; … }`)
17
+ // with a closed `objectFit` enum
18
+ // - gradient `transform` (6 finite, bounded floats — never a free string)
19
+
20
+ /** 1.2+ — CSS `mix-blend-mode` value, restricted to the closed set faithful
21
+ * to Figma minus `PASS_THROUGH` (ADR 002 §3.2 ; Bastion T4). A value outside
22
+ * this set is a diagnostic + omission at the compiler, never passthrough. */
23
+ export type LSMLBlendMode =
24
+ | "normal"
25
+ | "multiply"
26
+ | "screen"
27
+ | "overlay"
28
+ | "darken"
29
+ | "lighten"
30
+ | "color-dodge"
31
+ | "color-burn"
32
+ | "hard-light"
33
+ | "soft-light"
34
+ | "difference"
35
+ | "exclusion"
36
+ | "hue"
37
+ | "saturation"
38
+ | "color"
39
+ | "luminosity"
40
+ | "plus-lighter";
41
+
42
+ /** 1.2+ — how an image-fill / image source is fitted into its box
43
+ * (closed enum → CSS `object-fit` ; ADR 002 §3.2 ; Bastion T4). */
44
+ export type LSMLObjectFit = "cover" | "contain" | "fill" | "none" | "scale-down";
45
+
46
+ /** 1.2+ — masking model (LSML §4.x, ADR 002 §3.2). A node carries a typed
47
+ * `mask` whose fields are ALL typed — there is deliberately NO free-form SVG
48
+ * string anywhere in this shape (Bastion T3 : the runtime builds `<mask>` /
49
+ * `<clipPath>` from these fields, never from author markup). */
50
+ export interface LSMLMask {
51
+ /** What provides the mask coverage. A reference to a sibling shape (by id),
52
+ * an image asset URL, or a reference to a sibling GROUP/FRAME container (by
53
+ * id) whose visible children's geometry is composited (ADR 002 A4.3). A
54
+ * `kind: "image"` source is re-gated by the host/scheme allowlist (T1/T2)
55
+ * before it reaches the DOM. */
56
+ source:
57
+ | { kind: "shape"; ref: string }
58
+ | { kind: "image"; src: string }
59
+ | { kind: "group"; ref: string };
60
+ /** Whether the mask reads the source's alpha channel or its luminance. */
61
+ type: "alpha" | "luminance";
62
+ /** Boolean composition op against the masked content. */
63
+ op: "intersect" | "subtract" | "union";
64
+ /** Optional placement of the mask source within the masked box. */
65
+ position?: { x: number; y: number };
66
+ /** Optional explicit size of the mask source. */
67
+ size?: { w: number; h: number };
68
+ }
69
+
70
+ /** 1.2+ — a gradient `transform` : the 6 floats of an affine 2×3 matrix
71
+ * `[a, b, c, d, e, f]` (ADR 002 §3.2 ; Bastion T4). Carried as typed
72
+ * numbers, never a free string ; the compiler clamps each component to a
73
+ * finite, bounded value before it reaches `gradientTransform` SVG. */
74
+ export type LSMLGradientTransform = [number, number, number, number, number, number];
12
75
 
13
76
  export type LSMLPrimitiveKind =
14
77
  | "stack"
@@ -38,16 +101,46 @@ export interface LSMLFillStop {
38
101
  }
39
102
 
40
103
  /** 1.1+ — Fill union used by `shape.fills[]` and `frame.backgrounds[]`
41
- * (LSML §4.12). Discriminated on `kind`. */
104
+ * (LSML §4.12). Discriminated on `kind`.
105
+ *
106
+ * 1.2+ (#L) — each variant may carry an optional `blendMode` (the closed
107
+ * `LSMLBlendMode` enum, no new value introduced) applied as a per-fill-layer
108
+ * `mix-blend-mode`, independent of the node-level blend (`LSMLBaseNode.
109
+ * blendMode`, #D). Absent = `normal` (rétro-compat : a pre-#L bundle is
110
+ * unchanged). Re-validated against the closed enum by both the compiler and
111
+ * the runtime (T4 double-gate) ; out-of-enum → omission, never passthrough. */
42
112
  export type LSMLFill =
43
- | { kind: "solid"; color: string; opacity?: number }
44
- | { kind: "linear-gradient"; angle_deg?: number; stops: LSMLFillStop[]; opacity?: number }
113
+ | { kind: "solid"; color: string; opacity?: number; blendMode?: LSMLBlendMode }
114
+ | {
115
+ kind: "linear-gradient";
116
+ angle_deg?: number;
117
+ /** 1.2+ — full affine gradient transform (6 floats). When present it
118
+ * supersedes `angle_deg` (ADR 002 §3.2). */
119
+ transform?: LSMLGradientTransform;
120
+ stops: LSMLFillStop[];
121
+ opacity?: number;
122
+ blendMode?: LSMLBlendMode;
123
+ }
45
124
  | {
46
125
  kind: "radial-gradient";
47
126
  center?: { x: number; y: number };
48
127
  radius?: number;
128
+ /** 1.2+ — full affine gradient transform (6 floats). */
129
+ transform?: LSMLGradientTransform;
49
130
  stops: LSMLFillStop[];
50
131
  opacity?: number;
132
+ blendMode?: LSMLBlendMode;
133
+ }
134
+ | {
135
+ /** 1.2+ — first-class image-fill (ADR 002 §3.2). Unifies the frame
136
+ * image-background and unblocks the shape image-fill that 1.1 dropped.
137
+ * `src` is host/scheme-allowlist-gated (T1/T2) before the DOM. */
138
+ kind: "image";
139
+ src: string;
140
+ objectFit?: LSMLObjectFit;
141
+ opacity?: number;
142
+ transform?: LSMLGradientTransform;
143
+ blendMode?: LSMLBlendMode;
51
144
  };
52
145
 
53
146
  /** 1.1+ — one stacked stroke layer (LSML §4.6). */
@@ -147,10 +240,32 @@ export interface LSMLBaseNode {
147
240
  opacity?: number;
148
241
  /** 1.1+ — rotation in degrees (LSML §5.4). Defaults to 0. */
149
242
  rotation?: number;
243
+ /** Mirror the node vertically (`scaleY(-1)`) — from a negative Figma
244
+ * transform determinant. Composed with `rotation` at render. */
245
+ flipY?: boolean;
246
+ /** Figma LAYER_BLUR radius (px) → CSS `filter: blur()`. */
247
+ blur?: number;
248
+ /** Figma DROP_SHADOW / INNER_SHADOW → CSS `box-shadow` / `filter: drop-shadow`.
249
+ * Each layer is `{ inset?, color, x, y, blur, spread }` (colour strict-parsed
250
+ * runtime-side, RC#11). The picto square's depth halo + orange/red inner rim. */
251
+ shadow?: Array<{
252
+ inset?: boolean;
253
+ color: string;
254
+ x: number;
255
+ y: number;
256
+ blur: number;
257
+ spread: number;
258
+ }>;
150
259
  /** 1.1+ — per-axis sizing mode (LSML §5.4). */
151
260
  sizing?: { x?: "fixed" | "hug" | "fill"; y?: "fixed" | "hug" | "fill" };
152
261
  /** 1.1+ — universal position relative to parent (LSML §5.4). */
153
262
  position?: { x: number; y: number };
263
+ /** 1.2+ — CSS `mix-blend-mode` (closed enum ; ADR 002 §3.2). A value
264
+ * outside `LSMLBlendMode` is a diagnostic + omission, never passthrough. */
265
+ blendMode?: LSMLBlendMode;
266
+ /** 1.2+ — typed mask spec (ADR 002 §3.2). Built into `<mask>`/`<clipPath>`
267
+ * by the runtime from typed fields — never from author SVG markup. */
268
+ mask?: LSMLMask;
154
269
  /** Open-ended authoring metadata (LSML §17.4). Runtime ignores. */
155
270
  metadata?: Record<string, unknown>;
156
271
  }
@@ -186,6 +301,9 @@ export interface LSMLFrame extends LSMLBaseNode {
186
301
  * default is `true` ; the default is runtime-side, the compiler only
187
302
  * forwards an explicit value. */
188
303
  clipsContent?: boolean;
304
+ /** Figma `cornerRadius` (px) → canonical RenderNode `radius` (border-radius).
305
+ * The rounded picto square (r=111). */
306
+ cornerRadius?: number;
189
307
  }
190
308
 
191
309
  export interface LSMLText extends LSMLBaseNode {
@@ -290,7 +408,7 @@ export interface LSMLOperatorInput {
290
408
  }
291
409
 
292
410
  export interface LSMLBundle {
293
- lsml: "1.0" | "1.1";
411
+ lsml: "1.0" | "1.1" | "1.2";
294
412
  /** 1.1+ — informational schema URL for editor autocomplete (LSML §18.4). */
295
413
  $schema?: string;
296
414
  scene_id: string;