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