@lumencast/runtime 0.3.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.
- package/README.md +57 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/animate/frame-coalescer.d.ts +13 -0
- package/dist/animate/frame-coalescer.d.ts.map +1 -0
- package/dist/animate/frame-coalescer.js +46 -0
- package/dist/animate/frame-coalescer.js.map +1 -0
- package/dist/animate/keyframes.d.ts +1 -1
- package/dist/animate/keyframes.d.ts.map +1 -1
- package/dist/animate/keyframes.js +20 -6
- package/dist/animate/keyframes.js.map +1 -1
- package/dist/animate/transitions.d.ts +33 -1
- package/dist/animate/transitions.d.ts.map +1 -1
- package/dist/animate/transitions.js +78 -3
- package/dist/animate/transitions.js.map +1 -1
- package/dist/{broadcast-B82fQPph.js → broadcast-3vYij4k-.js} +3 -3
- package/dist/{broadcast-B82fQPph.js.map → broadcast-3vYij4k-.js.map} +1 -1
- package/dist/{control-DIfwMYRb.js → control-BFNkY7-6.js} +4 -4
- package/dist/{control-DIfwMYRb.js.map → control-BFNkY7-6.js.map} +1 -1
- package/dist/{index-BFZXQAD7.js → index-CyOlpZAL.js} +318 -145
- package/dist/index-CyOlpZAL.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/lumencast.js +9 -2
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +11 -1
- package/dist/mount.js.map +1 -1
- package/dist/render/bind-animate.d.ts +40 -0
- package/dist/render/bind-animate.d.ts.map +1 -0
- package/dist/render/bind-animate.js +329 -0
- package/dist/render/bind-animate.js.map +1 -0
- package/dist/render/bundle.d.ts +48 -6
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js +71 -4
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/color-interp.d.ts +18 -0
- package/dist/render/color-interp.d.ts.map +1 -0
- package/dist/render/color-interp.js +303 -0
- package/dist/render/color-interp.js.map +1 -0
- package/dist/render/css-color.d.ts +16 -0
- package/dist/render/css-color.d.ts.map +1 -0
- package/dist/render/css-color.js +130 -0
- package/dist/render/css-color.js.map +1 -0
- package/dist/render/diagnostics.d.ts +26 -0
- package/dist/render/diagnostics.d.ts.map +1 -0
- package/dist/render/diagnostics.js +58 -0
- package/dist/render/diagnostics.js.map +1 -0
- package/dist/render/fill.d.ts +15 -3
- package/dist/render/fill.d.ts.map +1 -1
- package/dist/render/fill.js +81 -14
- package/dist/render/fill.js.map +1 -1
- package/dist/render/filter-clamp.d.ts +35 -0
- package/dist/render/filter-clamp.d.ts.map +1 -0
- package/dist/render/filter-clamp.js +90 -0
- package/dist/render/filter-clamp.js.map +1 -0
- package/dist/render/keyframe-player.d.ts +4 -1
- package/dist/render/keyframe-player.d.ts.map +1 -1
- package/dist/render/keyframe-player.js +2 -2
- package/dist/render/keyframe-player.js.map +1 -1
- package/dist/render/primitives/frame.d.ts +16 -1
- package/dist/render/primitives/frame.d.ts.map +1 -1
- package/dist/render/primitives/frame.js +44 -13
- package/dist/render/primitives/frame.js.map +1 -1
- package/dist/render/primitives/image.d.ts +1 -1
- package/dist/render/primitives/image.d.ts.map +1 -1
- package/dist/render/primitives/image.js +8 -5
- package/dist/render/primitives/image.js.map +1 -1
- package/dist/render/primitives/index.d.ts +3 -0
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js.map +1 -1
- package/dist/render/primitives/instance.d.ts +1 -1
- package/dist/render/primitives/instance.d.ts.map +1 -1
- package/dist/render/primitives/instance.js +10 -13
- package/dist/render/primitives/instance.js.map +1 -1
- package/dist/render/primitives/shape.d.ts +9 -3
- package/dist/render/primitives/shape.d.ts.map +1 -1
- package/dist/render/primitives/shape.js +58 -14
- package/dist/render/primitives/shape.js.map +1 -1
- package/dist/render/primitives/text.d.ts +35 -4
- package/dist/render/primitives/text.d.ts.map +1 -1
- package/dist/render/primitives/text.js +181 -9
- package/dist/render/primitives/text.js.map +1 -1
- package/dist/render/prop-allowlist.d.ts +10 -0
- package/dist/render/prop-allowlist.d.ts.map +1 -0
- package/dist/render/prop-allowlist.js +112 -0
- package/dist/render/prop-allowlist.js.map +1 -0
- package/dist/render/svg-path.d.ts +35 -0
- package/dist/render/svg-path.d.ts.map +1 -0
- package/dist/render/svg-path.js +211 -0
- package/dist/render/svg-path.js.map +1 -0
- package/dist/render/tree.d.ts.map +1 -1
- package/dist/render/tree.js +30 -5
- package/dist/render/tree.js.map +1 -1
- package/dist/{status-pill-DNHbHdag.js → status-pill-DIpXc5du.js} +2 -2
- package/dist/{status-pill-DNHbHdag.js.map → status-pill-DIpXc5du.js.map} +1 -1
- package/dist/{test-Dp0QrKYM.js → test-ByRec1kd.js} +4 -4
- package/dist/{test-Dp0QrKYM.js.map → test-ByRec1kd.js.map} +1 -1
- package/dist/tree-D5wYHpPu.js +1230 -0
- package/dist/tree-D5wYHpPu.js.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/animate/frame-coalescer.ts +63 -0
- package/src/animate/keyframes.ts +24 -5
- package/src/animate/transitions.ts +85 -3
- package/src/index.ts +24 -0
- package/src/mount.ts +12 -1
- package/src/render/bind-animate.tsx +370 -0
- package/src/render/bundle.ts +102 -10
- package/src/render/color-interp.ts +303 -0
- package/src/render/css-color.ts +145 -0
- package/src/render/diagnostics.ts +75 -0
- package/src/render/fill.tsx +85 -14
- package/src/render/filter-clamp.ts +99 -0
- package/src/render/keyframe-player.tsx +10 -2
- package/src/render/primitives/frame.tsx +53 -14
- package/src/render/primitives/image.tsx +8 -4
- package/src/render/primitives/index.ts +3 -0
- package/src/render/primitives/instance.tsx +14 -15
- package/src/render/primitives/shape.tsx +78 -14
- package/src/render/primitives/text.tsx +226 -9
- package/src/render/prop-allowlist.ts +119 -0
- package/src/render/svg-path.ts +215 -0
- package/src/render/tree.tsx +41 -6
- package/src/types.ts +27 -0
- package/dist/index-BFZXQAD7.js.map +0 -1
- package/dist/tree-x5Qd9Kq0.js +0 -508
- package/dist/tree-x5Qd9Kq0.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
|
+
}
|
package/src/render/fill.tsx
CHANGED
|
@@ -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(${
|
|
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 =
|
|
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
|
-
/**
|
|
154
|
-
|
|
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({
|
|
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
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { motion } from "framer-motion";
|
|
2
2
|
import type { CSSProperties } from "react";
|
|
3
3
|
import type { PrimitiveProps } from "./index";
|
|
4
|
-
import { toFramer, mountPlay } from "../../animate/transitions";
|
|
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({
|
|
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,18 +34,23 @@ 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
|
-
|
|
28
|
-
const
|
|
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.
|
|
32
|
-
const tx =
|
|
33
|
-
transitionFor
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
transitionFor("y");
|
|
49
|
+
const tx = resolveTransition(
|
|
50
|
+
transitionFor,
|
|
51
|
+
["opacity", "scale", "rotate", "x", "y"],
|
|
52
|
+
animateInitial,
|
|
53
|
+
);
|
|
38
54
|
|
|
39
55
|
const style: CSSProperties = {
|
|
40
56
|
position: "absolute",
|
|
@@ -43,14 +59,19 @@ export function Frame({ resolved, transitionFor, animateInitial, children }: Pri
|
|
|
43
59
|
width,
|
|
44
60
|
height,
|
|
45
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" } : {}),
|
|
46
67
|
};
|
|
47
68
|
if (backgrounds.length > 0) {
|
|
48
|
-
Object.assign(style, backgroundsToCss(backgrounds));
|
|
49
|
-
} else if (legacyBackground !== undefined) {
|
|
69
|
+
Object.assign(style, backgroundsToCss(backgrounds, nodeId));
|
|
70
|
+
} else if (legacyBackground !== undefined && legacyBackground !== null) {
|
|
50
71
|
style.background = legacyBackground;
|
|
51
72
|
}
|
|
52
73
|
|
|
53
|
-
const play = mountPlay({ opacity, x, y, scale, rotate }, animateInitial);
|
|
74
|
+
const play = mountPlay({ opacity, x, y, scale, rotate }, animateInitial, nodeId);
|
|
54
75
|
|
|
55
76
|
return (
|
|
56
77
|
<motion.div
|
|
@@ -64,6 +85,24 @@ export function Frame({ resolved, transitionFor, animateInitial, children }: Pri
|
|
|
64
85
|
);
|
|
65
86
|
}
|
|
66
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
|
+
|
|
67
106
|
function numberOr(v: unknown, fallback: number): number {
|
|
68
107
|
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
69
108
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { motion } from "framer-motion";
|
|
2
2
|
import type { PrimitiveProps } from "./index";
|
|
3
|
-
import { toFramer, mountPlay } from "../../animate/transitions";
|
|
3
|
+
import { toFramer, mountPlay, resolveTransition } from "../../animate/transitions";
|
|
4
4
|
|
|
5
5
|
/** Image leaf. `src`, `fit` (cover/contain/fill), `position`,
|
|
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);
|
|
@@ -18,12 +21,13 @@ export function Image({ resolved, transitionFor, animateInitial }: PrimitiveProp
|
|
|
18
21
|
const width = dimOr(resolved.width, "100%");
|
|
19
22
|
const height = dimOr(resolved.height, "100%");
|
|
20
23
|
|
|
21
|
-
const tx = transitionFor
|
|
22
|
-
const play = mountPlay({ opacity }, animateInitial);
|
|
24
|
+
const tx = resolveTransition(transitionFor, ["opacity", "src"], 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
|
-
|
|
33
|
-
|
|
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
|
|
39
|
+
// One-time diagnostic per (sceneId,version) so authors know the
|
|
39
40
|
// scaffold limitation.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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;
|