@lumencast/compiler 0.4.0 → 0.6.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/compile.js CHANGED
@@ -21,13 +21,160 @@
21
21
  // surfaces `BUNDLE_INCOMPATIBLE` per LSML §15.1 if it can't honour them).
22
22
  // Bundles tagged 2.x are rejected — major bumps require explicit support.
23
23
  const SUPPORTED_VERSIONS = new Set(["1.0", "1.1"]);
24
+ // --- hard caps (ADR 001 §5.1 R8 + §6 RC#10, threat model Bastion) ------
25
+ //
26
+ // Filter clamps — an unbounded `filter` is a compositing DoS in CEF.
27
+ /** Max CSS `blur()` radius emitted by the compiler, in px. */
28
+ export const MAX_FILTER_BLUR_PX = 100;
29
+ /** Max CSS `brightness()` factor emitted by the compiler (spec §6.1
30
+ * explicitly blesses clamping to 4). */
31
+ export const MAX_FILTER_BRIGHTNESS = 4;
32
+ // Path caps — `d` strings are untrusted author input rendered into SVG.
33
+ /** Max size of a single subpath `d` string (8 KiB, RC#10). */
34
+ export const MAX_PATH_SUBPATH_BYTES = 8192;
35
+ /** Max number of subpaths on a single shape (RC#10). */
36
+ export const MAX_PATH_SUBPATHS = 64;
37
+ /** Max number of path commands per subpath (RC#10). Kept below the
38
+ * densest possible command packing within the byte cap (single-letter
39
+ * `Z` spam = 1 command/byte) so the cap is actually reachable. */
40
+ export const MAX_PATH_COMMANDS = 4000;
41
+ /** Emit an anti-silent-drop diagnostic (ADR 001 §3.4). Per Bastion R9 the
42
+ * message carries `node.id` + field + reason and NEVER the offending
43
+ * value (leaf/prop values can carry sensitive on-air content). */
44
+ function warn(opts, nodeId, field, reason) {
45
+ const diagnostic = { nodeId: nodeId ?? "<anon>", field, reason };
46
+ const message = `compiler: node "${diagnostic.nodeId}": field "${field}" ${reason}`;
47
+ if (opts.strict)
48
+ throw new Error(message);
49
+ opts.onWarn?.(message, diagnostic);
50
+ }
51
+ /** Hard compile error — invalid value. Per R9 the message names the node
52
+ * and field but never echoes the value itself. */
53
+ function invalid(nodeId, field, reason) {
54
+ return new Error(`compiler: node "${nodeId ?? "<anon>"}": field "${field}" ${reason}`);
55
+ }
56
+ // --- consumed-key accounting (ADR 001 §3.4 D4, issue #34) --------------
57
+ //
58
+ // Every key present on a source node (or the bundle) and NOT consumed by
59
+ // the lowering below produces an `onWarn` diagnostic — whether the key is
60
+ // spec'd-but-unsupported or an unknown extension. `strict: true` turns
61
+ // every warning into a throw. Exemptions (advisory by construction,
62
+ // §17.5.1) : `metadata` blocks at every level ; authoring profiles are
63
+ // forwarded verbatim, never key-audited.
64
+ //
65
+ // The sets below mirror EXACTLY what `compileNode` / `compileRepeat` /
66
+ // `mapTextStyle` read. When extending the lowering, add the new key here
67
+ // in the same change — a forgotten entry shows up as a spurious warning
68
+ // in the fixture suite, never as a silent drop.
69
+ /** Advisory, never audited (LSML §17.4 / §17.5.1). */
70
+ const EXEMPT_KEYS = new Set(["metadata"]);
71
+ /** Keys the common (non-repeat) lowering path consumes on every node. */
72
+ const COMMON_NODE_KEYS = new Set([
73
+ "kind",
74
+ "id",
75
+ "bind",
76
+ "bindStyle",
77
+ "bindUniversal",
78
+ "bindAnimate",
79
+ "animate",
80
+ "keyframes",
81
+ "children",
82
+ "visible",
83
+ "opacity",
84
+ "rotation",
85
+ "sizing",
86
+ "position",
87
+ ]);
88
+ /** Keys `compileRepeat` consumes. `scope` names the iteration scope the
89
+ * template's bind paths reference — declarative, no lowering needed. */
90
+ const REPEAT_NODE_KEYS = new Set([
91
+ "kind",
92
+ "id",
93
+ "bind",
94
+ "scope",
95
+ "template",
96
+ "stagger_ms",
97
+ ]);
98
+ /** Per-kind keys consumed by the `switch` in `compileNode`. */
99
+ const KIND_NODE_KEYS = {
100
+ stack: new Set(["direction", "gap", "align", "justify", "padding", "rtl"]),
101
+ grid: new Set(["columns", "rows", "gap", "padding"]),
102
+ frame: new Set(["size", "position", "background", "backgrounds", "clipsContent"]),
103
+ text: new Set(["style", "format", "maxLines"]),
104
+ image: new Set(["alt", "size", "fit"]),
105
+ shape: new Set([
106
+ "geometry",
107
+ "size",
108
+ "pathData",
109
+ "paths",
110
+ "fill",
111
+ "fills",
112
+ "stroke",
113
+ "strokes",
114
+ "cornerRadius",
115
+ "ariaLabel",
116
+ ]),
117
+ media: new Set(["kind_hint", "controls", "autoplay", "muted", "loop", "size"]),
118
+ instance: new Set(["scene_id", "scene_version", "size", "fit", "params", "bindParams"]),
119
+ };
120
+ /** Keys `mapTextStyle` consumes inside `text.style`. */
121
+ const TEXT_STYLE_KEYS = new Set([
122
+ "fontSize",
123
+ "fontFamily",
124
+ "fontWeight",
125
+ "color",
126
+ "textAlign",
127
+ "lineHeight",
128
+ "letterSpacing",
129
+ "textTransform",
130
+ "textDecoration",
131
+ "fontStyle",
132
+ ]);
133
+ /** Keys `compileBundle` consumes at the bundle level. */
134
+ const BUNDLE_KEYS = new Set([
135
+ "lsml",
136
+ "$schema",
137
+ "scene_id",
138
+ "scene_version",
139
+ "profiles",
140
+ "layout",
141
+ "operator_inputs",
142
+ "external_adapters",
143
+ ]);
144
+ const NOT_LOWERED = "is not lowered by this compiler ; without this diagnostic the field would drop silently (ADR 001 §3.4)";
145
+ /** Diff a node's present keys against the consumed sets. */
146
+ function auditNodeKeys(node, opts) {
147
+ const allowed = node.kind === "repeat" ? REPEAT_NODE_KEYS : COMMON_NODE_KEYS;
148
+ const kindKeys = node.kind === "repeat" ? undefined : KIND_NODE_KEYS[node.kind];
149
+ for (const key of Object.keys(node)) {
150
+ if (EXEMPT_KEYS.has(key) || allowed.has(key) || kindKeys?.has(key))
151
+ continue;
152
+ warn(opts, node.id, key, NOT_LOWERED);
153
+ }
154
+ }
155
+ /** Diff bundle-level keys (`defaults`, `assets`, `i18n` and unknown
156
+ * extensions warn today — none of them lands in the RenderBundle). */
157
+ function auditBundleKeys(lsml, opts) {
158
+ for (const key of Object.keys(lsml)) {
159
+ if (EXEMPT_KEYS.has(key) || BUNDLE_KEYS.has(key))
160
+ continue;
161
+ warn(opts, "<bundle>", key, NOT_LOWERED);
162
+ }
163
+ }
24
164
  export function compileBundle(lsml, options = {}) {
25
165
  if (!SUPPORTED_VERSIONS.has(lsml.lsml)) {
26
166
  throw new Error(`compiler: LSML version "${lsml.lsml}" is not supported (supported: ${[...SUPPORTED_VERSIONS].join(", ")})`);
27
167
  }
168
+ auditBundleKeys(lsml, options);
28
169
  return {
29
170
  scene_version: lsml.scene_version,
30
171
  root: compileNode(lsml.layout, options),
172
+ // LSML 1.1 §17.3 — forward `profiles[]` verbatim so the runtime applies
173
+ // the same gating rule (§17.3.1 hard rejection for unsupported
174
+ // behavioural profiles, §17.5.1 advisory pass-through for authoring
175
+ // profiles) on the compiled path as on the direct-fetch path (ADR 001
176
+ // §3.2.1, RC#1).
177
+ ...(lsml.profiles !== undefined ? { profiles: [...lsml.profiles] } : {}),
31
178
  ...(lsml.operator_inputs
32
179
  ? {
33
180
  operator_inputs: lsml.operator_inputs.map((oi) => ({
@@ -48,6 +195,10 @@ export function compileBundle(lsml, options = {}) {
48
195
  };
49
196
  }
50
197
  function compileNode(node, opts) {
198
+ // ADR 001 §3.4 (issue #34) — every present key must be consumed by the
199
+ // lowering below, exempt (`metadata`), or diagnosed. Runs for repeat
200
+ // nodes too (their consumed set differs).
201
+ auditNodeKeys(node, opts);
51
202
  if (node.kind === "repeat") {
52
203
  return compileRepeat(node, opts);
53
204
  }
@@ -98,9 +249,17 @@ function compileNode(node, opts) {
98
249
  }
99
250
  if (node.background !== undefined)
100
251
  props["background"] = node.background;
252
+ // 1.1 §4.3 + §4.12 — stacked backgrounds (frame.tsx reads
253
+ // `resolved.backgrounds`; array form wins over legacy `background`).
254
+ if (node.backgrounds !== undefined)
255
+ props["backgrounds"] = node.backgrounds;
256
+ // 1.1 §4.3 — clip children to the frame bounds. The spec default
257
+ // (`true`) is applied runtime-side ; only explicit values forward.
258
+ if (node.clipsContent !== undefined)
259
+ props["clipsContent"] = node.clipsContent;
101
260
  break;
102
261
  case "text":
103
- mapTextStyle(node, props);
262
+ mapTextStyle(node, props, opts);
104
263
  if (node.format !== undefined)
105
264
  props["format"] = node.format;
106
265
  if (node.maxLines !== undefined)
@@ -119,14 +278,40 @@ function compileNode(node, opts) {
119
278
  props["width"] = node.size.w;
120
279
  props["height"] = node.size.h;
121
280
  }
122
- if (node.pathData !== undefined)
281
+ // Path geometry — every `d` string is untrusted author input that
282
+ // ends up in an SVG attribute. Validate at compile per RC#10 (the
283
+ // runtime re-validates live deltas in its own gate — issue #30).
284
+ if (node.pathData !== undefined) {
285
+ validatePathData(node.pathData, node.id, "pathData");
123
286
  props["pathData"] = node.pathData;
287
+ }
288
+ if (node.paths !== undefined) {
289
+ props["paths"] = lowerPaths(node.paths, node.id);
290
+ if (node.pathData !== undefined) {
291
+ // §4.6 declares the two forms mutually exclusive ; we keep
292
+ // both forwarded (runtime prefers `paths`) but surface it.
293
+ warn(opts, node.id, "pathData", "is mutually exclusive with paths[] (LSML §4.6)");
294
+ }
295
+ }
124
296
  if (node.fill !== undefined)
125
297
  props["fill"] = node.fill;
126
- if (node.stroke !== undefined)
127
- props["stroke"] = node.stroke;
298
+ // 1.1 §4.6 + §4.12 — stacked fills (shape.tsx reads `resolved.fills`).
299
+ if (node.fills !== undefined)
300
+ props["fills"] = node.fills;
301
+ // Single stroke lowers to the flat props shape.tsx consumes
302
+ // (`stroke` = colour string, `stroke_width` = number). The previous
303
+ // object forward was silently unrenderable.
304
+ if (node.stroke !== undefined) {
305
+ props["stroke"] = node.stroke.color;
306
+ props["stroke_width"] = node.stroke.width;
307
+ }
308
+ // 1.1 §4.6 — stacked strokes (shape.tsx reads `resolved.strokes`).
309
+ if (node.strokes !== undefined)
310
+ props["strokes"] = node.strokes;
311
+ // Canonical RenderNode name is `radius` (what shape.tsx reads) ;
312
+ // the previous `cornerRadius` forward was silently dropped.
128
313
  if (node.cornerRadius !== undefined)
129
- props["cornerRadius"] = node.cornerRadius;
314
+ props["radius"] = node.cornerRadius;
130
315
  if (node.ariaLabel !== undefined)
131
316
  props["ariaLabel"] = node.ariaLabel;
132
317
  break;
@@ -204,14 +389,37 @@ function compileNode(node, opts) {
204
389
  const transitions = {};
205
390
  if (node.animate.opacity !== undefined)
206
391
  transitions["opacity"] = tx;
207
- if (node.animate.transform?.scale !== undefined)
208
- transitions["scale"] = tx;
392
+ if (node.animate.transform?.scale !== undefined) {
393
+ // Per-axis `[sx, sy]` lowers to framer's `scaleX` / `scaleY`
394
+ // motion keys ; a scalar stays on uniform `scale`.
395
+ if (Array.isArray(node.animate.transform.scale)) {
396
+ transitions["scaleX"] = tx;
397
+ transitions["scaleY"] = tx;
398
+ }
399
+ else {
400
+ transitions["scale"] = tx;
401
+ }
402
+ }
209
403
  if (node.animate.transform?.rotate !== undefined)
210
404
  transitions["rotate"] = tx;
211
405
  if (node.animate.transform?.translate !== undefined) {
212
406
  transitions["x"] = tx;
213
407
  transitions["y"] = tx;
214
408
  }
409
+ // §6.1 — `filter` is animatable ; previously dropped in silence.
410
+ if (node.animate.filter !== undefined)
411
+ transitions["filter"] = tx;
412
+ // §6.3 — a bound animation channel must honour the declared
413
+ // `animate.transition` too : emit a per-prop transition entry for
414
+ // every bindAnimate key so the runtime retargets with the
415
+ // authored timing instead of its default spring.
416
+ if (node.bindAnimate) {
417
+ for (const key of Object.keys(node.bindAnimate)) {
418
+ for (const fk of transitionKeysForBindAnimate(key, node.kind)) {
419
+ transitions[fk] = tx;
420
+ }
421
+ }
422
+ }
215
423
  // Type assertion: RenderNode.transitions matches Transition shape; the
216
424
  // cast keeps the compiler self-contained without re-importing the runtime
217
425
  // Transition type.
@@ -225,27 +433,287 @@ function compileNode(node, opts) {
225
433
  // timing). When no `from` is present, `animate_initial` is omitted
226
434
  // and the prior no-mount-play behaviour is preserved (rétro-compat).
227
435
  if (node.animate.from) {
228
- const initial = lowerAnimateState(node.animate.from);
436
+ const initial = lowerAnimateState(node.animate.from, node.id, opts);
229
437
  if (Object.keys(initial).length > 0) {
230
438
  out.animate_initial = initial;
231
439
  }
232
440
  }
233
441
  }
442
+ // §6.3 `bindAnimate` → RenderNode `animateBindings` (ADR 001 §3.3,
443
+ // issue #33). Validation is HARD : any key outside the animatable set
444
+ // throws at compile (RC#13 — a malformed bindAnimate is an invalid
445
+ // directive, the warn-by-default policy §3.4 does not apply).
446
+ if (node.bindAnimate !== undefined) {
447
+ const lowered = lowerBindAnimate(node.bindAnimate, node.kind, node.id);
448
+ if (Object.keys(lowered).length > 0)
449
+ out.animateBindings = lowered;
450
+ }
451
+ // LSML 1.1 §6.6 — keyframe sequence, lowered to the runtime
452
+ // `Keyframes` shape consumed by KeyframePlayer.
453
+ if (node.keyframes !== undefined) {
454
+ out.keyframes = lowerKeyframes(node.keyframes, node.id, opts);
455
+ }
234
456
  return out;
235
457
  }
458
+ // --- bindAnimate lowering (LSML §6.3 — ADR 001 §3.3, RC#13) ------------
459
+ //
460
+ // `bindAnimate` keys MUST reference animatable properties only : the
461
+ // §6.1 GPU-composited list, plus the node kind's colour-typed property
462
+ // blessed by §6.5 for continuous colour interpolation. Anything else
463
+ // (layout properties, unknown channels) is a HARD compile error — never
464
+ // a warning (RC#13, exception to the §3.4 warn-by-default policy : an
465
+ // invalid directive is not a "spec'd field we don't support yet").
466
+ /** §6.1 animatable property keys accepted on every primitive. */
467
+ export const BIND_ANIMATE_SCALAR_KEYS = new Set([
468
+ "opacity",
469
+ "transform.translate",
470
+ "transform.scale",
471
+ "transform.rotate",
472
+ "filter.blur",
473
+ "filter.brightness",
474
+ ]);
475
+ /** §6.5 colour-typed properties reachable via bindAnimate, per node
476
+ * kind. Kept to the single-colour core props for now ; `fills[].color`
477
+ * / `backgrounds[]` entries are array-typed and stay out of scope
478
+ * (documented in ADR 001 phase B). */
479
+ export const BIND_ANIMATE_COLOR_KEYS = {
480
+ text: "style.color",
481
+ shape: "fill",
482
+ frame: "background",
483
+ };
484
+ function isBindAnimateKeyAllowed(key, kind) {
485
+ if (BIND_ANIMATE_SCALAR_KEYS.has(key))
486
+ return true;
487
+ return BIND_ANIMATE_COLOR_KEYS[kind] === key;
488
+ }
489
+ /** Validate + lower a §6.3 `bindAnimate` map. Keys keep their spec
490
+ * names verbatim (the runtime maps them onto motion channels) ; values
491
+ * must be non-empty LeafPath strings. Throws on any violation (RC#13) ;
492
+ * per R9 the error names the node + key, never the bound value. */
493
+ function lowerBindAnimate(bind, kind, nodeId) {
494
+ const out = {};
495
+ for (const [key, path] of Object.entries(bind)) {
496
+ if (!isBindAnimateKeyAllowed(key, kind)) {
497
+ throw invalid(nodeId, `bindAnimate.${key}`, "is not an animatable property for this primitive (LSML §6.1/§6.5, RC#13)");
498
+ }
499
+ if (typeof path !== "string" || path.length === 0) {
500
+ throw invalid(nodeId, `bindAnimate.${key}`, "must bind a non-empty LeafPath string");
501
+ }
502
+ out[key] = path;
503
+ }
504
+ return out;
505
+ }
506
+ /** The per-prop `transitions` entries a bound channel resolves at the
507
+ * runtime (mirrors the static-target lowering above : translate drives
508
+ * framer `x`/`y`, scale may be per-axis, filter channels share the
509
+ * composed `filter` entry, colour channels use the runtime prop name). */
510
+ function transitionKeysForBindAnimate(key, kind) {
511
+ switch (key) {
512
+ case "opacity":
513
+ return ["opacity"];
514
+ case "transform.translate":
515
+ return ["x", "y"];
516
+ case "transform.scale":
517
+ return ["scale", "scaleX", "scaleY"];
518
+ case "transform.rotate":
519
+ return ["rotate"];
520
+ case "filter.blur":
521
+ case "filter.brightness":
522
+ return ["filter"];
523
+ default:
524
+ // Colour channel — the runtime looks the transition up under the
525
+ // primitive's prop name (text → `colour`, shape → `fill`,
526
+ // frame → `background`).
527
+ if (BIND_ANIMATE_COLOR_KEYS[kind] === key) {
528
+ return [kind === "text" ? "colour" : key];
529
+ }
530
+ return [];
531
+ }
532
+ }
533
+ // --- path validation (ADR 001 §6 RC#10 — compile-side gate) -----------
534
+ //
535
+ // SVG `d` strings are untrusted author input rendered into a DOM
536
+ // attribute. The grammar is ALLOWLISTED : path command letters
537
+ // `MmLlHhVvCcSsQqTtAaZz` plus number/separator characters only. This
538
+ // rejects `url(`, `data:`, `<` and `&` by construction (none of their
539
+ // characters are in the allowlist). Implemented as a single-pass manual
540
+ // scanner — linear time, no regex, no backtracking (anti-ReDoS, RC#12).
541
+ //
542
+ // Known limitation (by design) : the scanner is CHAR-LEVEL, not a number
543
+ // grammar. Malformed numerics built from allowlisted characters (`1.2.3`,
544
+ // `+-+5`, overflow exponents like `1e9999`) pass validation. This is
545
+ // accepted : a syntactically invalid `d` is simply ignored by the
546
+ // browser's SVG path parser (the path does not render), so there is no
547
+ // injection or DoS vector — only the author's own shape failing to draw.
548
+ // Upgrading to a full number grammar would add parser complexity for no
549
+ // security gain.
550
+ const PATH_COMMANDS = new Set("MmLlHhVvCcSsQqTtAaZz");
551
+ function isPathNumberChar(ch) {
552
+ return (ch >= "0" && ch <= "9") || ch === "." || ch === "+" || ch === "-" || ch === ",";
553
+ }
554
+ function isPathWhitespace(ch) {
555
+ return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
556
+ }
557
+ /** Validate one subpath `d` string. Throws on any violation (size cap,
558
+ * command cap, character outside the allowlist). Error messages name
559
+ * the node + field but never echo the string (R9). */
560
+ export function validatePathData(d, nodeId, field) {
561
+ if (typeof d !== "string" || d.length === 0) {
562
+ throw invalid(nodeId, field, "must be a non-empty SVG path string");
563
+ }
564
+ // Allowlisted grammar is ASCII-only, so UTF-16 length === byte length
565
+ // for any string that passes the scan ; checking the cap first keeps
566
+ // the scan itself bounded.
567
+ if (d.length > MAX_PATH_SUBPATH_BYTES) {
568
+ throw invalid(nodeId, field, `exceeds the ${MAX_PATH_SUBPATH_BYTES}-byte subpath cap (RC#10)`);
569
+ }
570
+ let commands = 0;
571
+ for (let i = 0; i < d.length; i++) {
572
+ const ch = d[i];
573
+ if (PATH_COMMANDS.has(ch)) {
574
+ commands++;
575
+ if (commands > MAX_PATH_COMMANDS) {
576
+ throw invalid(nodeId, field, `exceeds the ${MAX_PATH_COMMANDS}-command subpath cap (RC#10)`);
577
+ }
578
+ continue;
579
+ }
580
+ if (isPathNumberChar(ch) || isPathWhitespace(ch))
581
+ continue;
582
+ // Exponent marker — only valid immediately after a digit or dot
583
+ // (e.g. `1e3`, `1.5E-2`). A bare `e`/`E` is rejected.
584
+ if ((ch === "e" || ch === "E") && i > 0) {
585
+ const prev = d[i - 1];
586
+ if ((prev >= "0" && prev <= "9") || prev === ".")
587
+ continue;
588
+ }
589
+ throw invalid(nodeId, field, `contains a character outside the SVG path allowlist (RC#10)`);
590
+ }
591
+ if (commands === 0) {
592
+ throw invalid(nodeId, field, "contains no SVG path command");
593
+ }
594
+ }
595
+ /** Validate + forward `paths[]` (LSML §4.6). Caps the subpath count and
596
+ * validates every `data` string. */
597
+ function lowerPaths(paths, nodeId) {
598
+ if (paths.length === 0) {
599
+ throw invalid(nodeId, "paths", "must contain at least one subpath");
600
+ }
601
+ if (paths.length > MAX_PATH_SUBPATHS) {
602
+ throw invalid(nodeId, "paths", `exceeds the ${MAX_PATH_SUBPATHS}-subpath cap (RC#10)`);
603
+ }
604
+ return paths.map((p, i) => {
605
+ validatePathData(p.data, nodeId, `paths[${i}].data`);
606
+ return {
607
+ data: p.data,
608
+ ...(p.windingRule !== undefined ? { windingRule: p.windingRule } : {}),
609
+ };
610
+ });
611
+ }
612
+ // --- filter lowering (ADR 001 §5.1 R8 — hard clamps, non-optional) -----
613
+ /** Lower an LSML `filter` state (`{ blur?, brightness? }`) to the CSS
614
+ * filter string framer-motion animates. Values are HARD-clamped at
615
+ * lowering : negative / non-finite values are rejected (compile error),
616
+ * `blur` caps at MAX_FILTER_BLUR_PX, `brightness` caps at
617
+ * MAX_FILTER_BRIGHTNESS. Both functions are always emitted so framer
618
+ * can interpolate between structurally-identical filter lists. */
619
+ function lowerFilter(f, nodeId, field, opts) {
620
+ let blur = 0;
621
+ let brightness = 1;
622
+ if (f.blur !== undefined) {
623
+ // `-0 < 0` is false in IEEE-754 — Object.is closes the negative-zero hole.
624
+ if (typeof f.blur !== "number" ||
625
+ !Number.isFinite(f.blur) ||
626
+ f.blur < 0 ||
627
+ Object.is(f.blur, -0)) {
628
+ throw invalid(nodeId, `${field}.blur`, "must be a finite number >= 0 (R8)");
629
+ }
630
+ blur = f.blur;
631
+ if (blur > MAX_FILTER_BLUR_PX) {
632
+ blur = MAX_FILTER_BLUR_PX;
633
+ warn(opts, nodeId, `${field}.blur`, `clamped to the ${MAX_FILTER_BLUR_PX}px cap (R8)`);
634
+ }
635
+ }
636
+ if (f.brightness !== undefined) {
637
+ // Same -0 gate as blur : `brightness(-0)` stringifies to `brightness(0)`,
638
+ // a fully black element slipping past the negative-value rejection (R8).
639
+ if (typeof f.brightness !== "number" ||
640
+ !Number.isFinite(f.brightness) ||
641
+ f.brightness < 0 ||
642
+ Object.is(f.brightness, -0)) {
643
+ throw invalid(nodeId, `${field}.brightness`, "must be a finite number >= 0 (R8)");
644
+ }
645
+ brightness = f.brightness;
646
+ if (brightness > MAX_FILTER_BRIGHTNESS) {
647
+ brightness = MAX_FILTER_BRIGHTNESS;
648
+ warn(opts, nodeId, `${field}.brightness`, `clamped to the ${MAX_FILTER_BRIGHTNESS} cap (R8)`);
649
+ }
650
+ }
651
+ return `blur(${blur}px) brightness(${brightness})`;
652
+ }
653
+ // --- keyframes lowering (LSML §6.6 → runtime Keyframes shape) ----------
654
+ /** Lower a §6.6 keyframe sequence into the shape KeyframePlayer /
655
+ * compileForFramer consume : `transform.translate: [x, y]` →
656
+ * `translateX` / `translateY`, `filter: { blur, brightness }` → clamped
657
+ * CSS string. Per-axis step scale degrades to `sx` with a diagnostic
658
+ * (the runtime keyframe channel is uniform-scale ; per-axis keyframe
659
+ * scale lands with ADR 001 phase B/C). */
660
+ function lowerKeyframes(kf, nodeId, opts) {
661
+ const steps = kf.steps.map((s, i) => {
662
+ const step = { at: s.at };
663
+ if (s.opacity !== undefined)
664
+ step.opacity = s.opacity;
665
+ if (s.filter !== undefined) {
666
+ step.filter = lowerFilter(s.filter, nodeId, `keyframes.steps[${i}].filter`, opts);
667
+ }
668
+ const t = s.transform;
669
+ if (t) {
670
+ const transform = {};
671
+ if (t.scale !== undefined) {
672
+ if (Array.isArray(t.scale)) {
673
+ transform.scale = t.scale[0];
674
+ warn(opts, nodeId, `keyframes.steps[${i}].transform.scale`, "per-axis scale is not supported in keyframes yet ; lowered to the x-axis value");
675
+ }
676
+ else {
677
+ transform.scale = t.scale;
678
+ }
679
+ }
680
+ if (t.rotate !== undefined)
681
+ transform.rotate = t.rotate;
682
+ if (t.translate !== undefined) {
683
+ transform.translateX = t.translate[0];
684
+ transform.translateY = t.translate[1];
685
+ }
686
+ if (Object.keys(transform).length > 0)
687
+ step.transform = transform;
688
+ }
689
+ return step;
690
+ });
691
+ return {
692
+ ...(kf.key !== undefined ? { key: kf.key } : {}),
693
+ steps,
694
+ duration_ms: kf.duration_ms,
695
+ ...(kf.easing !== undefined ? { easing: kf.easing } : {}),
696
+ };
697
+ }
236
698
  /** Lower an `animate.from` (or any LSML animate state) into the flat
237
699
  * framer-motion key space the runtime primitives consume: `opacity`,
238
- * `scale`, `rotate`, `x`, `y`. A scalar `scale` applies uniformly ; a
239
- * `[sx, sy]` pair is collapsed to `sx` (framer takes a single scale on
240
- * the motion components used here). `translate: [x, y]` → `x` / `y`. */
241
- function lowerAnimateState(s) {
700
+ * `scale` (or `scaleX`/`scaleY` for a per-axis `[sx, sy]` pair),
701
+ * `rotate`, `x`, `y`, `filter` (clamped CSS string, R8).
702
+ * `translate: [x, y]` → `x` / `y`. */
703
+ function lowerAnimateState(s, nodeId, opts) {
242
704
  const out = {};
243
705
  if (typeof s.opacity === "number")
244
706
  out["opacity"] = s.opacity;
245
707
  const t = s.transform;
246
708
  if (t) {
247
709
  if (t.scale !== undefined) {
248
- out["scale"] = Array.isArray(t.scale) ? t.scale[0] : t.scale;
710
+ if (Array.isArray(t.scale)) {
711
+ out["scaleX"] = t.scale[0];
712
+ out["scaleY"] = t.scale[1];
713
+ }
714
+ else {
715
+ out["scale"] = t.scale;
716
+ }
249
717
  }
250
718
  if (typeof t.rotate === "number")
251
719
  out["rotate"] = t.rotate;
@@ -254,6 +722,9 @@ function lowerAnimateState(s) {
254
722
  out["y"] = t.translate[1];
255
723
  }
256
724
  }
725
+ if (s.filter !== undefined) {
726
+ out["filter"] = lowerFilter(s.filter, nodeId, "animate.from.filter", opts);
727
+ }
257
728
  return out;
258
729
  }
259
730
  function compileRepeat(node, opts) {
@@ -268,12 +739,28 @@ function compileRepeat(node, opts) {
268
739
  };
269
740
  if (node.id !== undefined)
270
741
  out.id = node.id;
742
+ // LSML 1.1 §6.7 — per-iteration stagger. Negative values are invalid ;
743
+ // the runtime caps the effective delay (STAGGER_CAP_MS) at render.
744
+ if (node.stagger_ms !== undefined) {
745
+ if (typeof node.stagger_ms !== "number" ||
746
+ !Number.isFinite(node.stagger_ms) ||
747
+ node.stagger_ms < 0) {
748
+ throw invalid(node.id, "stagger_ms", "must be a finite number >= 0");
749
+ }
750
+ out.stagger_ms = node.stagger_ms;
751
+ }
271
752
  return out;
272
753
  }
273
- function mapTextStyle(node, props) {
754
+ function mapTextStyle(node, props, opts) {
274
755
  if (!node.style)
275
756
  return;
276
757
  const s = node.style;
758
+ // Nested consumed-key accounting (issue #34) : a `style.*` key outside
759
+ // the TextStyle grammar (e.g. `style.textShadow`) warns, never drops.
760
+ for (const key of Object.keys(s)) {
761
+ if (!TEXT_STYLE_KEYS.has(key))
762
+ warn(opts, node.id, `style.${key}`, NOT_LOWERED);
763
+ }
277
764
  // Solar's Text primitive consumes size/weight/colour (UK), not CSS-style names.
278
765
  if (s.fontSize !== undefined)
279
766
  props["size"] = s.fontSize;
@@ -287,6 +774,16 @@ function mapTextStyle(node, props) {
287
774
  props["align"] = mapTextAlign(s.textAlign);
288
775
  if (s.lineHeight !== undefined)
289
776
  props["lineHeight"] = s.lineHeight;
777
+ // TextStyle 1.1 typography — same names runtime-side (text.tsx
778
+ // validates each value against the field's grammar before render).
779
+ if (s.letterSpacing !== undefined)
780
+ props["letterSpacing"] = s.letterSpacing;
781
+ if (s.textTransform !== undefined)
782
+ props["textTransform"] = s.textTransform;
783
+ if (s.textDecoration !== undefined)
784
+ props["textDecoration"] = s.textDecoration;
785
+ if (s.fontStyle !== undefined)
786
+ props["fontStyle"] = s.fontStyle;
290
787
  }
291
788
  function mapAlign(a) {
292
789
  // LSML uses CSS-grid vocabulary; Solar's Stack consumes flexbox vocabulary.
@@ -330,11 +827,16 @@ function compileAnimate(a) {
330
827
  if (!t)
331
828
  return undefined;
332
829
  if (t.easing === "spring") {
333
- const out = { kind: "spring" };
830
+ const out = {
831
+ kind: "spring",
832
+ };
334
833
  if (t.stiffness !== undefined)
335
834
  out.stiffness = t.stiffness;
336
835
  if (t.damping !== undefined)
337
836
  out.damping = t.damping;
837
+ // §6.2 — spring mass (default 1 runtime-side, ADR 001 phase B).
838
+ if (t.mass !== undefined)
839
+ out.mass = t.mass;
338
840
  return out;
339
841
  }
340
842
  return {