@lumencast/runtime 0.4.0 → 0.5.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 (130) hide show
  1. package/README.md +57 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/animate/frame-coalescer.d.ts +13 -0
  4. package/dist/animate/frame-coalescer.d.ts.map +1 -0
  5. package/dist/animate/frame-coalescer.js +46 -0
  6. package/dist/animate/frame-coalescer.js.map +1 -0
  7. package/dist/animate/keyframes.d.ts +1 -1
  8. package/dist/animate/keyframes.d.ts.map +1 -1
  9. package/dist/animate/keyframes.js +20 -6
  10. package/dist/animate/keyframes.js.map +1 -1
  11. package/dist/animate/transitions.d.ts +4 -1
  12. package/dist/animate/transitions.d.ts.map +1 -1
  13. package/dist/animate/transitions.js +30 -3
  14. package/dist/animate/transitions.js.map +1 -1
  15. package/dist/{broadcast-DzZ8TVGZ.js → broadcast-3vYij4k-.js} +3 -3
  16. package/dist/{broadcast-DzZ8TVGZ.js.map → broadcast-3vYij4k-.js.map} +1 -1
  17. package/dist/{control-gbDGvdR0.js → control-BFNkY7-6.js} +4 -4
  18. package/dist/{control-gbDGvdR0.js.map → control-BFNkY7-6.js.map} +1 -1
  19. package/dist/{index-oteiocFe.js → index-CyOlpZAL.js} +305 -150
  20. package/dist/index-CyOlpZAL.js.map +1 -0
  21. package/dist/index.d.ts +5 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.html +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/lumencast.js +9 -2
  27. package/dist/mount.d.ts.map +1 -1
  28. package/dist/mount.js +11 -1
  29. package/dist/mount.js.map +1 -1
  30. package/dist/render/bind-animate.d.ts +40 -0
  31. package/dist/render/bind-animate.d.ts.map +1 -0
  32. package/dist/render/bind-animate.js +329 -0
  33. package/dist/render/bind-animate.js.map +1 -0
  34. package/dist/render/bundle.d.ts +48 -6
  35. package/dist/render/bundle.d.ts.map +1 -1
  36. package/dist/render/bundle.js +71 -4
  37. package/dist/render/bundle.js.map +1 -1
  38. package/dist/render/color-interp.d.ts +18 -0
  39. package/dist/render/color-interp.d.ts.map +1 -0
  40. package/dist/render/color-interp.js +303 -0
  41. package/dist/render/color-interp.js.map +1 -0
  42. package/dist/render/css-color.d.ts +16 -0
  43. package/dist/render/css-color.d.ts.map +1 -0
  44. package/dist/render/css-color.js +130 -0
  45. package/dist/render/css-color.js.map +1 -0
  46. package/dist/render/diagnostics.d.ts +26 -0
  47. package/dist/render/diagnostics.d.ts.map +1 -0
  48. package/dist/render/diagnostics.js +58 -0
  49. package/dist/render/diagnostics.js.map +1 -0
  50. package/dist/render/fill.d.ts +15 -3
  51. package/dist/render/fill.d.ts.map +1 -1
  52. package/dist/render/fill.js +81 -14
  53. package/dist/render/fill.js.map +1 -1
  54. package/dist/render/filter-clamp.d.ts +35 -0
  55. package/dist/render/filter-clamp.d.ts.map +1 -0
  56. package/dist/render/filter-clamp.js +90 -0
  57. package/dist/render/filter-clamp.js.map +1 -0
  58. package/dist/render/keyframe-player.d.ts +4 -1
  59. package/dist/render/keyframe-player.d.ts.map +1 -1
  60. package/dist/render/keyframe-player.js +2 -2
  61. package/dist/render/keyframe-player.js.map +1 -1
  62. package/dist/render/primitives/frame.d.ts +16 -1
  63. package/dist/render/primitives/frame.d.ts.map +1 -1
  64. package/dist/render/primitives/frame.js +42 -7
  65. package/dist/render/primitives/frame.js.map +1 -1
  66. package/dist/render/primitives/image.d.ts +1 -1
  67. package/dist/render/primitives/image.d.ts.map +1 -1
  68. package/dist/render/primitives/image.js +6 -3
  69. package/dist/render/primitives/image.js.map +1 -1
  70. package/dist/render/primitives/index.d.ts +3 -0
  71. package/dist/render/primitives/index.d.ts.map +1 -1
  72. package/dist/render/primitives/index.js.map +1 -1
  73. package/dist/render/primitives/instance.d.ts +1 -1
  74. package/dist/render/primitives/instance.d.ts.map +1 -1
  75. package/dist/render/primitives/instance.js +10 -13
  76. package/dist/render/primitives/instance.js.map +1 -1
  77. package/dist/render/primitives/shape.d.ts +9 -3
  78. package/dist/render/primitives/shape.d.ts.map +1 -1
  79. package/dist/render/primitives/shape.js +56 -12
  80. package/dist/render/primitives/shape.js.map +1 -1
  81. package/dist/render/primitives/text.d.ts +35 -4
  82. package/dist/render/primitives/text.d.ts.map +1 -1
  83. package/dist/render/primitives/text.js +179 -7
  84. package/dist/render/primitives/text.js.map +1 -1
  85. package/dist/render/prop-allowlist.d.ts +10 -0
  86. package/dist/render/prop-allowlist.d.ts.map +1 -0
  87. package/dist/render/prop-allowlist.js +112 -0
  88. package/dist/render/prop-allowlist.js.map +1 -0
  89. package/dist/render/svg-path.d.ts +35 -0
  90. package/dist/render/svg-path.d.ts.map +1 -0
  91. package/dist/render/svg-path.js +211 -0
  92. package/dist/render/svg-path.js.map +1 -0
  93. package/dist/render/tree.d.ts.map +1 -1
  94. package/dist/render/tree.js +30 -5
  95. package/dist/render/tree.js.map +1 -1
  96. package/dist/{status-pill-Cgdl9FtP.js → status-pill-DIpXc5du.js} +2 -2
  97. package/dist/{status-pill-Cgdl9FtP.js.map → status-pill-DIpXc5du.js.map} +1 -1
  98. package/dist/{test-CAnkHA0n.js → test-ByRec1kd.js} +4 -4
  99. package/dist/{test-CAnkHA0n.js.map → test-ByRec1kd.js.map} +1 -1
  100. package/dist/tree-D5wYHpPu.js +1230 -0
  101. package/dist/tree-D5wYHpPu.js.map +1 -0
  102. package/dist/types.d.ts +26 -0
  103. package/dist/types.d.ts.map +1 -1
  104. package/package.json +5 -4
  105. package/src/animate/frame-coalescer.ts +63 -0
  106. package/src/animate/keyframes.ts +24 -5
  107. package/src/animate/transitions.ts +33 -3
  108. package/src/index.ts +24 -0
  109. package/src/mount.ts +12 -1
  110. package/src/render/bind-animate.tsx +370 -0
  111. package/src/render/bundle.ts +102 -10
  112. package/src/render/color-interp.ts +303 -0
  113. package/src/render/css-color.ts +145 -0
  114. package/src/render/diagnostics.ts +75 -0
  115. package/src/render/fill.tsx +85 -14
  116. package/src/render/filter-clamp.ts +99 -0
  117. package/src/render/keyframe-player.tsx +10 -2
  118. package/src/render/primitives/frame.tsx +47 -7
  119. package/src/render/primitives/image.tsx +6 -2
  120. package/src/render/primitives/index.ts +3 -0
  121. package/src/render/primitives/instance.tsx +14 -15
  122. package/src/render/primitives/shape.tsx +76 -12
  123. package/src/render/primitives/text.tsx +224 -7
  124. package/src/render/prop-allowlist.ts +119 -0
  125. package/src/render/svg-path.ts +215 -0
  126. package/src/render/tree.tsx +41 -6
  127. package/src/types.ts +27 -0
  128. package/dist/index-oteiocFe.js.map +0 -1
  129. package/dist/tree-DVYXwItH.js +0 -512
  130. package/dist/tree-DVYXwItH.js.map +0 -1
@@ -0,0 +1,75 @@
1
+ // Anti-silent-drop diagnostics channel (ADR 001 §3.4 D4, issue #34).
2
+ //
3
+ // Every render-side diagnostic — rejected colour/filter/path/typography
4
+ // value, unknown prop, unrendered spec'd field — flows through
5
+ // `emitDiagnostic`. The diagnostic is an EVENT, not a console.log :
6
+ // hosts subscribe via `MountOptions.onDiagnostic` (wired by `mount()`)
7
+ // and receive a structured `{ nodeId, field, reason }`. When no handler
8
+ // is registered, the runtime falls back to a DEV-only `console.warn`
9
+ // so authors still see drops during development — and `broadcast`
10
+ // builds stay silent on the console, per the CLAUDE.md "no logs in
11
+ // broadcast" rule.
12
+ //
13
+ // ── Hygiene contract (Bastion R9, ADR 001 §5.1) ─────────────────────
14
+ // A diagnostic NEVER carries the value of a leaf or a prop — only the
15
+ // node id, the field name and a STATIC reason string. Leaf values can
16
+ // hold sensitive on-air content ; they must not transit any diagnostic
17
+ // channel. Callers pass field names and literal reasons exclusively.
18
+ // The R9 sentinel test (r9-sentinel.test.tsx) enforces this end to end,
19
+ // and statically checks that `console.warn` only exists in this module.
20
+ // ─────────────────────────────────────────────────────────────────────
21
+
22
+ /** Placeholder id for nodes that don't declare an `id`. */
23
+ export const ANON_NODE_ID = "<anon>";
24
+
25
+ export interface RenderDiagnostic {
26
+ /** `RenderNode.id` of the node the field belongs to (RC#7), or
27
+ * `ANON_NODE_ID` when the node has none. */
28
+ nodeId: string;
29
+ /** Name of the field/prop concerned (e.g. `text.colour`,
30
+ * `shape.paths.data`, `bindAnimate.opacity`). Never its value (R9). */
31
+ field: string;
32
+ /** Static reason — why the field was rejected or not rendered. */
33
+ reason: string;
34
+ }
35
+
36
+ export type DiagnosticHandler = (diagnostic: RenderDiagnostic) => void;
37
+
38
+ const handlers = new Set<DiagnosticHandler>();
39
+
40
+ /**
41
+ * Register a diagnostics handler (one per `mount()`, plus tests).
42
+ * Returns the unregister function. Multiple concurrent mounts each
43
+ * receive every diagnostic — node ids are bundle-scoped, so a host
44
+ * running several mounts should disambiguate on its side.
45
+ */
46
+ export function addDiagnosticsHandler(handler: DiagnosticHandler): () => void {
47
+ handlers.add(handler);
48
+ return () => {
49
+ handlers.delete(handler);
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Emit one anti-drop diagnostic. `field` and `reason` MUST be static
55
+ * strings / field names — never interpolate a prop or leaf value (R9).
56
+ */
57
+ export function emitDiagnostic(nodeId: string | undefined, field: string, reason: string): void {
58
+ const diagnostic: RenderDiagnostic = { nodeId: nodeId ?? ANON_NODE_ID, field, reason };
59
+ if (handlers.size > 0) {
60
+ for (const handler of handlers) {
61
+ try {
62
+ handler(diagnostic);
63
+ } catch {
64
+ // A host handler that throws must never break the render path.
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ // DEV-only console fallback — broadcast builds log nothing.
70
+ if (import.meta.env.DEV) {
71
+ console.warn(
72
+ `[lumencast] node "${diagnostic.nodeId}": field "${field}" ${reason} (value withheld per R9)`,
73
+ );
74
+ }
75
+ }
@@ -10,6 +10,8 @@
10
10
  // renders on top per §4.12).
11
11
 
12
12
  import type { CSSProperties, ReactElement } from "react";
13
+ import { parseCssColor, warnRejectedColor } from "./css-color";
14
+ import { emitDiagnostic } from "./diagnostics";
13
15
 
14
16
  export interface FillStop {
15
17
  offset: number;
@@ -108,25 +110,39 @@ export function renderFill(fill: Fill): FillRenderResult {
108
110
  /** Compile an array of Fill into a CSS `background-image` value usable
109
111
  * on a `<div>` (frame backgrounds — non-SVG context). Returns the CSS
110
112
  * string + opacity. Stops use percentages in CSS gradient syntax. */
111
- export function backgroundsToCss(fills: Fill[]): CSSProperties {
113
+ export function backgroundsToCss(fills: Fill[], nodeId?: string): CSSProperties {
112
114
  // Per §4.12, fills[0] renders on top — CSS background-image stacks
113
115
  // first → top-most. Match by passing in the same order.
114
- const layers = fills.map(fillToCss).filter(Boolean) as string[];
116
+ const layers = fills.map((f) => fillToCss(f, nodeId)).filter(Boolean) as string[];
115
117
  if (layers.length === 0) return {};
116
118
  return { backgroundImage: layers.join(", ") };
117
119
  }
118
120
 
119
- function fillToCss(fill: Fill): string | null {
121
+ function fillToCss(fill: Fill, nodeId?: string): string | null {
122
+ // RC#11 — every colour interpolated into an inline CSS string MUST
123
+ // pass the strict parser first (fills/stops arrive from untrusted
124
+ // bundles AND live LSDP deltas). A rejected colour drops the whole
125
+ // layer : never passthrough, never a half-built gradient.
120
126
  if (fill.kind === "solid") {
127
+ const color = parseCssColor(fill.color);
128
+ if (color === null) {
129
+ warnRejectedColor("fill.color", nodeId);
130
+ return null;
131
+ }
121
132
  // Wrap solid in linear-gradient so it can stack with other layers.
122
- return `linear-gradient(${fill.color}, ${fill.color})`;
133
+ return `linear-gradient(${color}, ${color})`;
134
+ }
135
+ const safeStops: string[] = [];
136
+ for (const s of fill.stops) {
137
+ const color = parseCssColor(s.color);
138
+ if (color === null) {
139
+ warnRejectedColor("fill.stops.color", nodeId);
140
+ return null;
141
+ }
142
+ const c = s.opacity !== undefined ? cssWithOpacity(color, s.opacity) : color;
143
+ safeStops.push(`${c} ${(s.offset * 100).toFixed(2)}%`);
123
144
  }
124
- const stops = fill.stops
125
- .map((s) => {
126
- const c = s.opacity !== undefined ? cssWithOpacity(s.color, s.opacity) : s.color;
127
- return `${c} ${(s.offset * 100).toFixed(2)}%`;
128
- })
129
- .join(", ");
145
+ const stops = safeStops.join(", ");
130
146
  if (fill.kind === "linear-gradient") {
131
147
  const angle = fill.angle_deg ?? 0;
132
148
  return `linear-gradient(${angle}deg, ${stops})`;
@@ -137,9 +153,13 @@ function fillToCss(fill: Fill): string | null {
137
153
  return `radial-gradient(circle at ${cx}% ${cy}%, ${stops})`;
138
154
  }
139
155
 
156
+ /** Apply a stop opacity to an ALREADY-VALIDATED colour (callers must
157
+ * have run `parseCssColor` first — fillToCss is the single entry).
158
+ * For 6-digit hex we append the alpha byte ; every other accepted
159
+ * form goes through color-mix, which is safe because the interpolated
160
+ * string can only be a strict-grammar colour (RC#11 fix : this used
161
+ * to interpolate the raw, unparsed input). */
140
162
  function cssWithOpacity(color: string, opacity: number): string {
141
- // Best-effort wrapper — for hex/rgb we can append alpha. For
142
- // unrecognised forms, fall back to color-mix.
143
163
  const hex = color.match(/^#([0-9a-f]{6})$/i);
144
164
  if (hex) {
145
165
  const a = Math.round(opacity * 255)
@@ -150,9 +170,60 @@ function cssWithOpacity(color: string, opacity: number): string {
150
170
  return `color-mix(in srgb, ${color} ${opacity * 100}%, transparent)`;
151
171
  }
152
172
 
153
- /** Coerce loose JSON into a Fill array. Returns [] for non-arrays. */
154
- export function parseFills(value: unknown): Fill[] {
173
+ /** Validate every colour carried by a Fill array through the strict
174
+ * parser (RC#11 — issue #30 contractual comment : SVG `fill`/`stroke`
175
+ * attributes and `<stop stop-color>` are injection sites too, since
176
+ * fills arrive from untrusted bundles AND live LSDP deltas). A fill
177
+ * whose solid colour — or ANY gradient stop colour — is rejected drops
178
+ * the whole layer with a diagnostic : never passthrough, never a
179
+ * half-built gradient. Returned fills carry canonicalised colours. */
180
+ export function sanitizeFills(fills: Fill[], field: string, nodeId?: string): Fill[] {
181
+ const out: Fill[] = [];
182
+ for (const fill of fills) {
183
+ if (fill.kind === "solid") {
184
+ const color = parseCssColor(fill.color);
185
+ if (color === null) {
186
+ warnRejectedColor(`${field}.color`, nodeId);
187
+ continue;
188
+ }
189
+ out.push({ ...fill, color });
190
+ continue;
191
+ }
192
+ const stops: FillStop[] = [];
193
+ let rejected = false;
194
+ for (const s of fill.stops ?? []) {
195
+ const color = parseCssColor(s.color);
196
+ if (color === null) {
197
+ warnRejectedColor(`${field}.stops.color`, nodeId);
198
+ rejected = true;
199
+ break;
200
+ }
201
+ stops.push({ ...s, color });
202
+ }
203
+ if (rejected) continue;
204
+ out.push({ ...fill, stops });
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /** Coerce loose JSON into a Fill array. Returns [] for non-arrays.
210
+ * A structurally-valid fill entry whose `kind` is not renderable by
211
+ * this runtime (e.g. `angular-gradient` / `diamond-gradient`, promoted
212
+ * to core by the LSML 1.2 RFC) is dropped WITH a diagnostic — never
213
+ * silently (ADR 001 §3.4, issue #34). */
214
+ export function parseFills(value: unknown, field?: string, nodeId?: string): Fill[] {
155
215
  if (!Array.isArray(value)) return [];
216
+ if (field !== undefined) {
217
+ for (const v of value) {
218
+ if (!isFill(v)) {
219
+ emitDiagnostic(
220
+ nodeId,
221
+ `${field}.kind`,
222
+ "fill kind is not renderable by this runtime ; layer dropped (angular/diamond gradients land with LSML 1.2)",
223
+ );
224
+ }
225
+ }
226
+ }
156
227
  return value.filter(isFill) as Fill[];
157
228
  }
158
229
 
@@ -0,0 +1,99 @@
1
+ // Runtime half of the R8 filter gate (ADR 001 §5.1 R8, issue #42).
2
+ //
3
+ // The compiler clamps `filter` values at lowering (`lowerFilter`,
4
+ // packages/compiler/src/compile.ts) — but a filter value pushed by a
5
+ // LIVE LSDP delta reaches the runtime through `resolveProps` /
6
+ // `animateBindings` without ever passing through the compiler. R8
7
+ // requires the clamp at compile AND at runtime : an unbounded filter is
8
+ // a compositing DoS in CEF. Every filter value that can reach an inline
9
+ // style at render time MUST pass through this module.
10
+ //
11
+ // NOTE on duplication : these caps intentionally mirror the compiler's
12
+ // `MAX_FILTER_BLUR_PX` / `MAX_FILTER_BRIGHTNESS` constants. Unifying
13
+ // them behind a single shared module is tracked by issue #41 (same
14
+ // model as the shared colour module) — do NOT change one side without
15
+ // the other until #41 lands.
16
+ //
17
+ // ── Linear-time justification (RC#12) ────────────────────────────────
18
+ // The string form is validated by a single ANCHORED regex made of
19
+ // literals and bounded quantifiers ({1,7} / {1,4} digit runs, one
20
+ // optional space run) — exactly one possible parse per input, no
21
+ // backtracking blow-up. Inputs longer than MAX_FILTER_STRING_LEN are
22
+ // rejected before the regex runs.
23
+ // ─────────────────────────────────────────────────────────────────────
24
+
25
+ import { emitDiagnostic } from "./diagnostics";
26
+
27
+ /** Max CSS `blur()` radius accepted at runtime, in px (mirror of the
28
+ * compiler cap — see issue #41). */
29
+ export const MAX_FILTER_BLUR_PX = 100;
30
+ /** Max CSS `brightness()` factor accepted at runtime (mirror of the
31
+ * compiler cap — see issue #41 ; spec §6.1 blesses clamping to 4). */
32
+ export const MAX_FILTER_BRIGHTNESS = 4;
33
+
34
+ const MAX_FILTER_STRING_LEN = 64;
35
+
36
+ const CAPS: Record<FilterChannel, number> = {
37
+ blur: MAX_FILTER_BLUR_PX,
38
+ brightness: MAX_FILTER_BRIGHTNESS,
39
+ };
40
+
41
+ export type FilterChannel = "blur" | "brightness";
42
+
43
+ /**
44
+ * Gate one live numeric filter channel (R8 runtime half).
45
+ *
46
+ * Returns the clamped value, or `null` when the value is rejected
47
+ * (non-number, non-finite, negative — including `-0`, which would
48
+ * stringify to an accepted `0`). A `null` MUST be handled as "keep the
49
+ * last known-good value / identity" — never apply the raw input.
50
+ */
51
+ export function clampFilterChannel(channel: FilterChannel, value: unknown): number | null {
52
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
53
+ if (value < 0 || Object.is(value, -0)) return null;
54
+ const cap = CAPS[channel];
55
+ return value > cap ? cap : value;
56
+ }
57
+
58
+ // The ONLY string form the compiler ever emits (`lowerFilter`) :
59
+ // `blur(<n>px) brightness(<n>)`. Anything else — extra functions,
60
+ // `url(`, negative signs, exponents — is rejected by construction
61
+ // (the grammar has no `-`, no `e`, no second parenthesis pair).
62
+ const FILTER_STRING_RE =
63
+ /^blur\((\d{1,7}(?:\.\d{1,4})?)px\) brightness\((\d{1,7}(?:\.\d{1,4})?)\)$/;
64
+
65
+ /** Identity filter — matches the compiler's neutral emission and
66
+ * `INITIAL_IDENTITY.filter` in transitions.ts. */
67
+ export const FILTER_IDENTITY = "blur(0px) brightness(1)";
68
+
69
+ /**
70
+ * Gate a CSS filter STRING reaching framer-motion at runtime
71
+ * (`animate_initial.filter`, keyframe `steps[].filter`). Hand-crafted
72
+ * bundles bypass the compiler clamps, so the runtime re-validates and
73
+ * re-clamps (R8). Returns the safe, clamped canonical string or `null`
74
+ * on rejection — never the raw input.
75
+ */
76
+ export function sanitizeCssFilterString(value: unknown): string | null {
77
+ if (typeof value !== "string") return null;
78
+ if (value.length === 0 || value.length > MAX_FILTER_STRING_LEN) return null;
79
+ const m = FILTER_STRING_RE.exec(value);
80
+ if (!m) return null;
81
+ const blur = clampFilterChannel("blur", Number(m[1]));
82
+ const brightness = clampFilterChannel("brightness", Number(m[2]));
83
+ if (blur === null || brightness === null) return null;
84
+ return `blur(${blur}px) brightness(${brightness})`;
85
+ }
86
+
87
+ /**
88
+ * Diagnostic for a rejected filter value. Bastion R9 (ADR 001 §5.1) :
89
+ * the rejected VALUE is never logged nor forwarded — only `node.id`
90
+ * (RC#7, issue #34), the field name and a static reason. Routed through
91
+ * the structured diagnostics channel (events, no logs in `broadcast`).
92
+ */
93
+ export function warnRejectedFilter(field: string, nodeId?: string): void {
94
+ emitDiagnostic(
95
+ nodeId,
96
+ field,
97
+ "rejected unsafe filter value : outside the R8 caps or not a finite number >= 0",
98
+ );
99
+ }
@@ -22,10 +22,18 @@ import { scopedPath, usePathScope } from "./scope";
22
22
  export interface KeyframePlayerProps {
23
23
  keyframes: Keyframes;
24
24
  store: Store;
25
+ /** `RenderNode.id` of the owning node — threaded into keyframe
26
+ * diagnostics (ADR 001 RC#7, issue #34). */
27
+ nodeId?: string;
25
28
  children: ReactNode;
26
29
  }
27
30
 
28
- export function KeyframePlayer({ keyframes, store, children }: KeyframePlayerProps): ReactNode {
31
+ export function KeyframePlayer({
32
+ keyframes,
33
+ store,
34
+ nodeId,
35
+ children,
36
+ }: KeyframePlayerProps): ReactNode {
29
37
  useSignals();
30
38
  const scope = usePathScope();
31
39
  const staggerDelayMs = useContext(StaggerContext);
@@ -43,7 +51,7 @@ export function KeyframePlayer({ keyframes, store, children }: KeyframePlayerPro
43
51
  }
44
52
  }
45
53
 
46
- const compiled = compileForFramer(keyframes);
54
+ const compiled = compileForFramer(keyframes, nodeId);
47
55
  if (!compiled) {
48
56
  return <>{children}</>;
49
57
  }
@@ -3,6 +3,8 @@ import type { CSSProperties } from "react";
3
3
  import type { PrimitiveProps } from "./index";
4
4
  import { toFramer, mountPlay, resolveTransition } from "../../animate/transitions";
5
5
  import { backgroundsToCss, parseFills } from "../fill";
6
+ import { parseCssColor, warnRejectedColor } from "../css-color";
7
+ import { emitDiagnostic } from "../diagnostics";
6
8
 
7
9
  /** Absolute-positioned container with size + transform + opacity.
8
10
  * Animatable on `transform` and `opacity` only — width/height/position
@@ -12,8 +14,17 @@ import { backgroundsToCss, parseFills } from "../fill";
12
14
  * LSML 1.1 §4.3 + §4.12 add `backgrounds[]` as an alternative to the
13
15
  * legacy `background` (single color). The array form supports stacked
14
16
  * fills with linear / radial gradients ; first entry renders on top.
17
+ *
18
+ * LSML 1.1 §4.3 `clipsContent` (default `true`) clips children outside
19
+ * the frame's bounds via `overflow: hidden` (ADR 001 §3.2.5, RC#5).
15
20
  */
16
- export function Frame({ resolved, transitionFor, animateInitial, children }: PrimitiveProps) {
21
+ export function Frame({
22
+ resolved,
23
+ nodeId,
24
+ transitionFor,
25
+ animateInitial,
26
+ children,
27
+ }: PrimitiveProps) {
17
28
  const x = numberOr(resolved.x, 0);
18
29
  const y = numberOr(resolved.y, 0);
19
30
  const width = sizeProp(resolved.width);
@@ -23,9 +34,15 @@ export function Frame({ resolved, transitionFor, animateInitial, children }: Pri
23
34
  const rotate = numberOr(resolved.rotate, 0);
24
35
 
25
36
  // 1.0 single-fill prop — used as fallback when 1.1 `backgrounds[]`
26
- // is empty.
27
- const legacyBackground = (resolved.background as string | undefined) ?? undefined;
28
- const backgrounds = parseFills(resolved.backgrounds);
37
+ // is empty. RC#11 : the value is untrusted (static prop OR live LSDP
38
+ // delta) and lands in inline CSS strict-parse, never passthrough.
39
+ const rawBackground = resolved.background;
40
+ const legacyBackground = rawBackground === undefined ? undefined : parseCssColor(rawBackground);
41
+ if (rawBackground !== undefined && legacyBackground === null) {
42
+ warnRejectedColor("frame.background", nodeId);
43
+ }
44
+ const backgrounds = parseFills(resolved.backgrounds, "frame.backgrounds", nodeId);
45
+ const clipsContent = resolveClipsContent(resolved.clipsContent, nodeId);
29
46
 
30
47
  // Pick the most expressive declared transition among the animated
31
48
  // bindings (transform / opacity). If none, no animation.
@@ -42,14 +59,19 @@ export function Frame({ resolved, transitionFor, animateInitial, children }: Pri
42
59
  width,
43
60
  height,
44
61
  willChange: "transform, opacity",
62
+ // LSML 1.1 §4.3 `clipsContent` (default `true`) — children outside
63
+ // the frame's `size` are clipped. Static layout property : it never
64
+ // animates, so it stays off the 0-layout-event hot path (ADR 001
65
+ // §3.2.5). `false` => omit the declaration (CSS initial = visible).
66
+ ...(clipsContent ? { overflow: "hidden" } : {}),
45
67
  };
46
68
  if (backgrounds.length > 0) {
47
- Object.assign(style, backgroundsToCss(backgrounds));
48
- } else if (legacyBackground !== undefined) {
69
+ Object.assign(style, backgroundsToCss(backgrounds, nodeId));
70
+ } else if (legacyBackground !== undefined && legacyBackground !== null) {
49
71
  style.background = legacyBackground;
50
72
  }
51
73
 
52
- const play = mountPlay({ opacity, x, y, scale, rotate }, animateInitial);
74
+ const play = mountPlay({ opacity, x, y, scale, rotate }, animateInitial, nodeId);
53
75
 
54
76
  return (
55
77
  <motion.div
@@ -63,6 +85,24 @@ export function Frame({ resolved, transitionFor, animateInitial, children }: Pri
63
85
  );
64
86
  }
65
87
 
88
+ /**
89
+ * Resolve `clipsContent` (LSML 1.1 §4.3, schema default `true`).
90
+ *
91
+ * The prop is wire-drivable (static bundle prop OR live LSDP delta via
92
+ * `resolveProps`, tree.tsx), so a non-boolean is treated as hostile :
93
+ * R9 diagnostic (value withheld) + fall back to the spec default
94
+ * (`true`, i.e. clipped — the safe state for broadcast). The returned
95
+ * value only ever selects between two literal style fragments — no
96
+ * untrusted value can reach inline CSS through this path (RC#11 by
97
+ * construction). Exported for boundary testing.
98
+ */
99
+ export function resolveClipsContent(v: unknown, nodeId?: string): boolean {
100
+ if (v === undefined) return true;
101
+ if (typeof v === "boolean") return v;
102
+ emitDiagnostic(nodeId, "frame.clipsContent", "rejected value : not a boolean");
103
+ return true;
104
+ }
105
+
66
106
  function numberOr(v: unknown, fallback: number): number {
67
107
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
68
108
  }
@@ -6,9 +6,12 @@ import { toFramer, mountPlay, resolveTransition } from "../../animate/transition
6
6
  * `opacity`. Opacity is animated when a transition is declared. When an
7
7
  * `animate.from` is lowered onto the node, it mounts at that state and
8
8
  * plays to its target on mount (mount-play). */
9
- export function Image({ resolved, transitionFor, animateInitial }: PrimitiveProps) {
9
+ export function Image({ resolved, nodeId, transitionFor, animateInitial }: PrimitiveProps) {
10
10
  const src = resolved.src as string | undefined;
11
11
  if (!src) return null;
12
+ // LSML §4.5 `alt` is required and was silently unrendered until
13
+ // issue #34's allowlist audit surfaced it — now forwarded to the DOM.
14
+ const alt = typeof resolved.alt === "string" ? resolved.alt : "";
12
15
  const fit = (resolved.fit as string | undefined) ?? "contain";
13
16
  const position = (resolved.position as string | undefined) ?? "center";
14
17
  const opacity = numberOr(resolved.opacity, 1);
@@ -19,11 +22,12 @@ export function Image({ resolved, transitionFor, animateInitial }: PrimitiveProp
19
22
  const height = dimOr(resolved.height, "100%");
20
23
 
21
24
  const tx = resolveTransition(transitionFor, ["opacity", "src"], animateInitial);
22
- const play = mountPlay({ opacity }, animateInitial);
25
+ const play = mountPlay({ opacity }, animateInitial, nodeId);
23
26
 
24
27
  return (
25
28
  <motion.img
26
29
  src={src}
30
+ alt={alt}
27
31
  style={{
28
32
  objectFit: fit as React.CSSProperties["objectFit"],
29
33
  objectPosition: position,
@@ -19,6 +19,9 @@ import { Instance } from "./instance";
19
19
 
20
20
  export interface PrimitiveProps {
21
21
  resolved: Record<string, unknown>;
22
+ /** `RenderNode.id` of the node being rendered — threaded into every
23
+ * diagnostic the primitive emits (ADR 001 RC#7, issue #34). */
24
+ nodeId?: string;
22
25
  transitionFor: (key: string) => Transition | undefined;
23
26
  /** LSML 1.1 `animate.from` lowered to a flat framer `initial` map
24
27
  * (keys: `opacity`, `scale`, `rotate`, `x`, `y`). When present, a
@@ -22,31 +22,30 @@
22
22
 
23
23
  import type { ReactElement } from "react";
24
24
  import type { PrimitiveProps } from "./index";
25
+ import { emitDiagnostic } from "../diagnostics";
25
26
 
26
27
  const warned = new Set<string>();
27
28
 
28
- export function Instance({ resolved }: PrimitiveProps): ReactElement | null {
29
+ export function Instance({ resolved, nodeId }: PrimitiveProps): ReactElement | null {
29
30
  const sceneId = resolved.scene_id as string | undefined;
30
31
  const sceneVersion = resolved.scene_version as string | undefined;
31
32
  if (!sceneId || !sceneVersion) {
32
- if (import.meta.env.DEV) {
33
- console.warn("[lumencast/instance] missing scene_id or scene_version", resolved);
34
- }
33
+ // Structured diagnostic — never dump `resolved` (R9 : prop values,
34
+ // including bound params, must not transit a diagnostic channel).
35
+ emitDiagnostic(nodeId, "instance.scene_id", "missing scene_id or scene_version ; not rendered");
35
36
  return null;
36
37
  }
37
38
 
38
- // One-time DEV warning per (sceneId,version) so authors know the
39
+ // One-time diagnostic per (sceneId,version) so authors know the
39
40
  // scaffold limitation.
40
- if (import.meta.env.DEV) {
41
- const key = `${sceneId}:${sceneVersion}`;
42
- if (!warned.has(key)) {
43
- warned.add(key);
44
- console.warn(
45
- `[lumencast/instance] scaffold render — async bundle fetch + ` +
46
- `__params.* injection are not yet wired (LSML 1.1 §4.9). ` +
47
- `scene_id=${sceneId}`,
48
- );
49
- }
41
+ const key = `${sceneId}:${sceneVersion}`;
42
+ if (!warned.has(key)) {
43
+ warned.add(key);
44
+ emitDiagnostic(
45
+ nodeId,
46
+ "instance",
47
+ "scaffold render — async bundle fetch + __params.* injection are not yet wired (LSML 1.1 §4.9)",
48
+ );
50
49
  }
51
50
 
52
51
  const size = resolved.size as { w?: number; h?: number } | undefined;