@lumencast/runtime 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.
Files changed (135) 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-DO7jEkix.js} +3 -3
  16. package/dist/{broadcast-DzZ8TVGZ.js.map → broadcast-DO7jEkix.js.map} +1 -1
  17. package/dist/{control-gbDGvdR0.js → control-BSfl4_cO.js} +4 -4
  18. package/dist/{control-gbDGvdR0.js.map → control-BSfl4_cO.js.map} +1 -1
  19. package/dist/{index-oteiocFe.js → index-Crkij3C4.js} +352 -179
  20. package/dist/index-Crkij3C4.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 +16 -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 +56 -6
  35. package/dist/render/bundle.d.ts.map +1 -1
  36. package/dist/render/bundle.js +86 -5
  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-BT5b-yET.js} +2 -2
  97. package/dist/{status-pill-Cgdl9FtP.js.map → status-pill-BT5b-yET.js.map} +1 -1
  98. package/dist/{test-CAnkHA0n.js → test-_hh1JvAd.js} +4 -4
  99. package/dist/{test-CAnkHA0n.js.map → test-_hh1JvAd.js.map} +1 -1
  100. package/dist/transport/ws.d.ts +5 -0
  101. package/dist/transport/ws.d.ts.map +1 -1
  102. package/dist/transport/ws.js +7 -0
  103. package/dist/transport/ws.js.map +1 -1
  104. package/dist/tree-DBj9SJgs.js +1230 -0
  105. package/dist/tree-DBj9SJgs.js.map +1 -0
  106. package/dist/types.d.ts +26 -0
  107. package/dist/types.d.ts.map +1 -1
  108. package/package.json +5 -4
  109. package/src/animate/frame-coalescer.ts +63 -0
  110. package/src/animate/keyframes.ts +24 -5
  111. package/src/animate/transitions.ts +33 -3
  112. package/src/index.ts +24 -0
  113. package/src/mount.ts +17 -1
  114. package/src/render/bind-animate.tsx +370 -0
  115. package/src/render/bundle.ts +124 -11
  116. package/src/render/color-interp.ts +303 -0
  117. package/src/render/css-color.ts +145 -0
  118. package/src/render/diagnostics.ts +75 -0
  119. package/src/render/fill.tsx +85 -14
  120. package/src/render/filter-clamp.ts +99 -0
  121. package/src/render/keyframe-player.tsx +10 -2
  122. package/src/render/primitives/frame.tsx +47 -7
  123. package/src/render/primitives/image.tsx +6 -2
  124. package/src/render/primitives/index.ts +3 -0
  125. package/src/render/primitives/instance.tsx +14 -15
  126. package/src/render/primitives/shape.tsx +76 -12
  127. package/src/render/primitives/text.tsx +224 -7
  128. package/src/render/prop-allowlist.ts +119 -0
  129. package/src/render/svg-path.ts +215 -0
  130. package/src/render/tree.tsx +41 -6
  131. package/src/transport/ws.ts +8 -0
  132. package/src/types.ts +27 -0
  133. package/dist/index-oteiocFe.js.map +0 -1
  134. package/dist/tree-DVYXwItH.js +0 -512
  135. package/dist/tree-DVYXwItH.js.map +0 -1
@@ -2,41 +2,66 @@ import { motion } from "framer-motion";
2
2
  import type { ReactElement } from "react";
3
3
  import type { PrimitiveProps } from "./index";
4
4
  import { toFramer, mountPlay, resolveTransition } from "../../animate/transitions";
5
- import { parseFills, renderFill } from "../fill";
5
+ import { parseFills, renderFill, sanitizeFills } from "../fill";
6
+ import { parseCssColor, warnRejectedColor } from "../css-color";
7
+ import { parseShapePaths, type SubPath } from "../svg-path";
6
8
 
7
9
  interface StrokeSpec {
8
10
  color?: string;
9
11
  width?: number;
10
12
  }
11
13
 
12
- /** Rectangle / circle / line. Renders as SVG so stroke + fill behave
13
- * predictably across hosts. Opacity animatable.
14
+ /** Rectangle / circle / line / path. Renders as SVG so stroke + fill
15
+ * behave predictably across hosts. Opacity animatable.
14
16
  *
15
17
  * LSML 1.1 §4.6 + §4.12 add `fills[]` / `strokes[]` arrays as the
16
18
  * preferred way to declare multi-layer fills with linear/radial
17
19
  * gradients. The legacy single `fill` / `stroke` props remain
18
20
  * accepted for 1.0 bundles ; when both are present the array form
19
21
  * wins (the spec forbids mixing, but we tolerate to ease migration).
22
+ *
23
+ * Security (ADR 001 §6 RC#10 + RC#11, issue #30) : every colour that
24
+ * reaches an SVG `fill`/`stroke`/`stop-color` attribute goes through
25
+ * the strict `parseCssColor` gate, and every path `d` goes through
26
+ * `validatePathData` — at EVERY render, because props are wire-
27
+ * drivable live via LSDP deltas (`resolveProps`, tree.tsx).
20
28
  */
21
- export function Shape({ resolved, transitionFor, animateInitial }: PrimitiveProps) {
22
- const kind = (resolved.kind as string | undefined) ?? "rect";
23
- const legacyFill = (resolved.fill as string | undefined) ?? "transparent";
24
- const legacyStroke = (resolved.stroke as string | undefined) ?? "transparent";
29
+ export function Shape({ resolved, nodeId, transitionFor, animateInitial }: PrimitiveProps) {
30
+ // Canonical prop name is `geometry` (LSML §4.6 what the compiler
31
+ // emits) ; `kind` is kept as a fallback for hand-rolled Solar-lineage
32
+ // RenderNodes that predate the compiler.
33
+ const kind =
34
+ (resolved.geometry as string | undefined) ?? (resolved.kind as string | undefined) ?? "rect";
35
+ const legacyFill = safeColor(resolved.fill, "shape.fill", nodeId) ?? "transparent";
36
+ const legacyStroke = safeColor(resolved.stroke, "shape.stroke", nodeId) ?? "transparent";
25
37
  const legacyStrokeWidth = numberOr(resolved.stroke_width, 0);
26
38
  const width = numberOr(resolved.width, 100);
27
39
  const height = numberOr(resolved.height, 100);
28
40
  const radius = numberOr(resolved.radius, 0);
29
41
  const opacity = numberOr(resolved.opacity, 1);
42
+ // LSML §4.6 `ariaLabel` was silently unrendered until issue #34's
43
+ // allowlist audit surfaced it — now forwarded as the SVG label.
44
+ const ariaLabel = typeof resolved.ariaLabel === "string" ? resolved.ariaLabel : undefined;
30
45
 
31
46
  const tx = resolveTransition(transitionFor, ["opacity"], animateInitial);
32
47
  const transition = toFramer(tx);
33
- const play = mountPlay({ opacity }, animateInitial);
48
+ const play = mountPlay({ opacity }, animateInitial, nodeId);
34
49
 
35
50
  // LSML 1.1 §4.6 — `fills[]` is the preferred multi-fill form. Fall
36
- // back to the singular `fill` for 1.0 bundles.
37
- const fills = parseFills(resolved.fills);
51
+ // back to the singular `fill` for 1.0 bundles. Colours are strict-
52
+ // validated (a rejected colour drops its layer, with diagnostic).
53
+ const fills = sanitizeFills(
54
+ parseFills(resolved.fills, "shape.fills", nodeId),
55
+ "shape.fills",
56
+ nodeId,
57
+ );
38
58
  const strokes = parseStrokes(resolved.strokes);
39
59
 
60
+ // LSML 1.1 §4.6 — `geometry:"path"` : validated subpaths, one
61
+ // `<path>` element per entry (ADR 001 §3.2.3). Re-validated at every
62
+ // render — see module header of svg-path.ts (RC#10).
63
+ const subpaths = kind === "path" ? parseShapePaths(resolved, nodeId) : [];
64
+
40
65
  // Each fill compiles to a (defs, ref) pair. We render the shape
41
66
  // outline once per fill, layered top-to-bottom (first entry → on
42
67
  // top, per §4.12). The defs are aggregated for a single <defs>.
@@ -49,7 +74,10 @@ export function Shape({ resolved, transitionFor, animateInitial }: PrimitiveProp
49
74
  // as an additional pass over the same shape outline.
50
75
  const strokeLayers =
51
76
  strokes.length > 0
52
- ? strokes.map((s) => ({ color: s.color ?? "transparent", width: s.width ?? 0 }))
77
+ ? strokes.map((s) => ({
78
+ color: safeColor(s.color, "shape.strokes.color", nodeId) ?? "transparent",
79
+ width: s.width ?? 0,
80
+ }))
53
81
  : [{ color: legacyStroke, width: legacyStrokeWidth }];
54
82
 
55
83
  // Stack order : fillRefs are emitted top-to-bottom per §4.12. SVG
@@ -57,12 +85,36 @@ export function Shape({ resolved, transitionFor, animateInitial }: PrimitiveProp
57
85
  // entry in fills[] ends up rendered last (visually on top).
58
86
  const stackedFills = [...fillRefs].reverse();
59
87
  const stackedStrokes = [...strokeLayers].reverse();
88
+ // For paths, a zero-width / transparent stroke pass would only emit
89
+ // invisible duplicate <path> elements — skip it.
90
+ const effectiveStrokes =
91
+ kind === "path"
92
+ ? stackedStrokes.filter((s) => s.width > 0 && s.color !== "transparent")
93
+ : stackedStrokes;
60
94
 
61
95
  const renderShape = (
62
96
  fill: string,
63
97
  stroke: { color: string; width: number },
64
98
  keyPrefix: string,
65
99
  ): ReactElement => {
100
+ if (kind === "path") {
101
+ // §4.6 — fills and strokes apply to the union of all subpaths ;
102
+ // each subpath keeps its own winding rule (fill-rule).
103
+ return (
104
+ <g key={keyPrefix}>
105
+ {subpaths.map((p: SubPath, i: number) => (
106
+ <path
107
+ key={i}
108
+ d={p.d}
109
+ fillRule={p.fillRule}
110
+ fill={fill}
111
+ stroke={stroke.color}
112
+ strokeWidth={stroke.width}
113
+ />
114
+ ))}
115
+ </g>
116
+ );
117
+ }
66
118
  if (kind === "circle") {
67
119
  return (
68
120
  <circle
@@ -111,6 +163,7 @@ export function Shape({ resolved, transitionFor, animateInitial }: PrimitiveProp
111
163
  width={width}
112
164
  height={height}
113
165
  viewBox={`0 0 ${width} ${height}`}
166
+ {...(ariaLabel !== undefined ? { "aria-label": ariaLabel, role: "img" } : {})}
114
167
  initial={play.initial}
115
168
  animate={play.animate}
116
169
  transition={transition}
@@ -120,11 +173,22 @@ export function Shape({ resolved, transitionFor, animateInitial }: PrimitiveProp
120
173
  {stackedFills.map((ref, i) =>
121
174
  renderShape(ref, { color: "transparent", width: 0 }, `fill-${i}`),
122
175
  )}
123
- {stackedStrokes.map((s, i) => renderShape("none", s, `stroke-${i}`))}
176
+ {effectiveStrokes.map((s, i) => renderShape("none", s, `stroke-${i}`))}
124
177
  </motion.svg>
125
178
  );
126
179
  }
127
180
 
181
+ /** Strict-validate a colour prop (RC#11 — SVG attributes are injection
182
+ * sites too once values are wire-drivable). Non-strings resolve to
183
+ * null silently (absent prop) ; a string that fails the strict grammar
184
+ * is rejected with a diagnostic (value withheld per R9). */
185
+ function safeColor(value: unknown, field: string, nodeId?: string): string | null {
186
+ if (typeof value !== "string") return null;
187
+ const color = parseCssColor(value);
188
+ if (color === null) warnRejectedColor(field, nodeId);
189
+ return color;
190
+ }
191
+
128
192
  function parseStrokes(value: unknown): StrokeSpec[] {
129
193
  if (!Array.isArray(value)) return [];
130
194
  return value.filter(
@@ -1,22 +1,105 @@
1
1
  import { motion } from "framer-motion";
2
2
  import type { PrimitiveProps } from "./index";
3
3
  import { toFramer, mountPlay, resolveTransition } from "../../animate/transitions";
4
+ import { parseCssColor, warnRejectedColor } from "../css-color";
5
+ import { emitDiagnostic } from "../diagnostics";
6
+
7
+ // ── Typography grammars (LSML 1.1 TextStyle, schema.json) ───────────
8
+ // Every typo prop is wire-drivable (static bundle prop OR live LSDP
9
+ // delta via `resolveProps`, tree.tsx) and lands in inline CSS, so each
10
+ // value is validated against the field's spec'd grammar before it may
11
+ // reach the style object. Enum fields go through a closed allowlist
12
+ // (the emitted string is always one of these constants — never the
13
+ // input), numeric fields through finite-number checks. There is NO
14
+ // string passthrough on any of these sites (ADR 001 RC#11 by
15
+ // construction : no untrusted string ever reaches the style object).
16
+ const TEXT_TRANSFORMS = new Set(["none", "uppercase", "lowercase", "capitalize"]);
17
+ const TEXT_DECORATIONS = new Set(["none", "underline", "line-through"]);
18
+ const FONT_STYLES = new Set(["normal", "italic", "oblique"]);
19
+
20
+ // ── Defence-in-depth upper bounds (issue #34, Bastion follow-up on
21
+ // PR #38) ─────────────────────────────────────────────────────────────
22
+ // The numeric typo fields were type-validated but unbounded ; an absurd
23
+ // value pushed by a hostile bundle or live delta could degrade layout /
24
+ // rendering (e.g. a 10⁹-line clamp or a kilometric letter spacing).
25
+ // Policy : a value beyond its cap is REJECTED (diagnostic + omit → CSS
26
+ // initial), consistent with the existing typo grammar gates — NOT
27
+ // clamped, unlike the R8 filter caps where the spec explicitly blesses
28
+ // clamping. Rationale : there is no "nearest sensible rendering" for an
29
+ // absurd typographic value, the author's intent is unknowable ; safe
30
+ // default beats silently-altered output.
31
+ /** Max `maxLines` accepted (Bastion suggested ≤ 1000 on PR #38). */
32
+ export const MAX_MAX_LINES = 1000;
33
+ /** Max unitless `lineHeight` multiplier (100× the font size is already
34
+ * far beyond any broadcast layout). */
35
+ export const MAX_LINE_HEIGHT = 100;
36
+ /** Max |letterSpacing| in px, both directions (±1000 px covers any
37
+ * legitimate broadcast typography). */
38
+ export const MAX_LETTER_SPACING_PX = 1000;
39
+
40
+ // ── fontFamily policy (issue #34, Bastion follow-up on PR #38) ───────
41
+ // Decision : SHAPE validation, not a font allowlist. `fontFamily` is
42
+ // assigned through the React style object (per-property assignment via
43
+ // CSSStyleDeclaration), which cannot break out of the declaration — so
44
+ // the residual risk is malformed CSS, not injection. A font allowlist
45
+ // would couple the runtime to a host-specific font inventory (a spec /
46
+ // RFC matter — flagged for Atlas in the PR), whereas shape validation
47
+ // keeps any legitimate family name working. The grammar accepts
48
+ // comma-separated family lists with optional quotes ; the injection
49
+ // metacharacters (`;` `}` `{` `:` `\` `<` `>` `(` `)`) and `url(` are
50
+ // rejected by construction (none of their characters are allowed).
51
+ // Anchored, single character class with a bounded quantifier — linear
52
+ // time (RC#12).
53
+ const FONT_FAMILY_RE = /^[a-zA-Z0-9 ,.'"_-]{1,256}$/;
54
+
55
+ /** Validate an untrusted `fontFamily` value. Returns the string or
56
+ * `null` on rejection (handled as "omit → inherit", with diagnostic). */
57
+ export function parseFontFamily(value: unknown): string | null {
58
+ if (typeof value !== "string") return null;
59
+ const v = value.trim();
60
+ if (v.length === 0) return null;
61
+ return FONT_FAMILY_RE.test(v) ? v : null;
62
+ }
4
63
 
5
64
  /** Text leaf. Value renders as the displayed string ; style props
6
- * cover size / weight / colour / alignment. Opacity is animated when
7
- * a transition is declared on `opacity` or `value`. An `animate.from`
8
- * makes it mount-play (initial target) on mount. */
9
- export function Text({ resolved, transitionFor, animateInitial }: PrimitiveProps) {
65
+ * cover the full LSML TextStyle (size / font / weight / colour /
66
+ * alignment / lineHeight / letterSpacing / textTransform /
67
+ * textDecoration / fontStyle) plus `maxLines` (§4.4 ellipsis
68
+ * truncation). Opacity is animated when a transition is declared on
69
+ * `opacity` or `value`. An `animate.from` makes it mount-play
70
+ * (initial → target) on mount. */
71
+ export function Text({ resolved, nodeId, transitionFor, animateInitial }: PrimitiveProps) {
10
72
  const value = resolved.value === undefined ? "" : String(resolved.value);
11
73
  const size = (resolved.size as string | number | undefined) ?? "1rem";
12
- const font = resolved.font as string | undefined;
13
74
  const weight = (resolved.weight as number | undefined) ?? 400;
14
- const colour = (resolved.colour as string | undefined) ?? "currentColor";
75
+ // Issue #34 `font` is untrusted and lands in inline CSS : shape-
76
+ // validate (see fontFamily policy above) ; rejected → inherit.
77
+ let font: string | undefined;
78
+ if (resolved.font !== undefined) {
79
+ const parsed = parseFontFamily(resolved.font);
80
+ if (parsed === null) {
81
+ emitDiagnostic(nodeId, "text.font", "rejected fontFamily : outside the family-list grammar");
82
+ } else {
83
+ font = parsed;
84
+ }
85
+ }
86
+ // RC#11 : `colour` is untrusted (static prop OR live LSDP delta) and
87
+ // lands in inline CSS — strict-parse ; rejected → safe default.
88
+ let colour = "currentColor";
89
+ if (resolved.colour !== undefined) {
90
+ const parsed = parseCssColor(resolved.colour);
91
+ if (parsed === null) {
92
+ warnRejectedColor("text.colour", nodeId);
93
+ } else {
94
+ colour = parsed;
95
+ }
96
+ }
15
97
  const align = (resolved.align as string | undefined) ?? "start";
16
98
  const opacity = numberOr(resolved.opacity, 1);
99
+ const typography = resolveTypography(resolved, nodeId);
17
100
 
18
101
  const tx = resolveTransition(transitionFor, ["opacity", "value"], animateInitial);
19
- const play = mountPlay({ opacity }, animateInitial);
102
+ const play = mountPlay({ opacity }, animateInitial, nodeId);
20
103
 
21
104
  return (
22
105
  <motion.span
@@ -29,6 +112,7 @@ export function Text({ resolved, transitionFor, animateInitial }: PrimitiveProps
29
112
  fontWeight: weight,
30
113
  color: colour,
31
114
  textAlign: align as React.CSSProperties["textAlign"],
115
+ ...typography,
32
116
  willChange: "opacity, transform",
33
117
  }}
34
118
  initial={play.initial}
@@ -43,3 +127,136 @@ export function Text({ resolved, transitionFor, animateInitial }: PrimitiveProps
43
127
  function numberOr(v: unknown, fallback: number): number {
44
128
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
45
129
  }
130
+
131
+ /**
132
+ * Resolve the LSML 1.1 TextStyle typography props (`lineHeight`,
133
+ * `letterSpacing`, `textTransform`, `textDecoration`, `fontStyle`) and
134
+ * `maxLines` (§4.4) into a validated React style fragment.
135
+ *
136
+ * Exported for boundary testing : happy-dom drops `-webkit-*`
137
+ * declarations from `CSSStyleDeclaration`, so the line-clamp pattern is
138
+ * asserted on the exact object handed to React's inline style (same
139
+ * approach as `backgroundsToCss` for `color-mix`).
140
+ *
141
+ * Defaults = omit the declaration (inherit / CSS initial). A
142
+ * non-conforming value → R9 diagnostic + omit ; the returned object
143
+ * only ever contains allowlisted constants or validated finite
144
+ * numbers — never the raw input. Numeric fields additionally enforce
145
+ * the defence-in-depth caps above (issue #34).
146
+ */
147
+ export function resolveTypography(
148
+ resolved: Record<string, unknown>,
149
+ nodeId?: string,
150
+ ): React.CSSProperties {
151
+ // schema.json : lineHeight is a unitless multiplier ≥ 0 ;
152
+ // letterSpacing is a number (px) ; the three enums are closed sets.
153
+ const lineHeight = boundedNumber(
154
+ resolved.lineHeight,
155
+ 0,
156
+ MAX_LINE_HEIGHT,
157
+ "text.lineHeight",
158
+ nodeId,
159
+ );
160
+ const letterSpacing = boundedNumber(
161
+ resolved.letterSpacing,
162
+ -MAX_LETTER_SPACING_PX,
163
+ MAX_LETTER_SPACING_PX,
164
+ "text.letterSpacing",
165
+ nodeId,
166
+ );
167
+ const textTransform = enumValue(
168
+ resolved.textTransform,
169
+ TEXT_TRANSFORMS,
170
+ "text.textTransform",
171
+ nodeId,
172
+ );
173
+ const textDecoration = enumValue(
174
+ resolved.textDecoration,
175
+ TEXT_DECORATIONS,
176
+ "text.textDecoration",
177
+ nodeId,
178
+ );
179
+ const fontStyle = enumValue(resolved.fontStyle, FONT_STYLES, "text.fontStyle", nodeId);
180
+ // §4.4 maxLines — truncation with ellipsis after N lines, via the
181
+ // standard line-clamp pattern (display:-webkit-box overrides the
182
+ // base inline-block ; this fragment is spread after it so it wins).
183
+ const maxLines = positiveInteger(resolved.maxLines, MAX_MAX_LINES, "text.maxLines", nodeId);
184
+
185
+ return {
186
+ ...(lineHeight !== undefined ? { lineHeight } : {}),
187
+ // Built from a validated finite number — no string passthrough.
188
+ ...(letterSpacing !== undefined ? { letterSpacing: `${letterSpacing}px` } : {}),
189
+ ...(textTransform !== undefined
190
+ ? { textTransform: textTransform as React.CSSProperties["textTransform"] }
191
+ : {}),
192
+ ...(textDecoration !== undefined ? { textDecoration } : {}),
193
+ ...(fontStyle !== undefined ? { fontStyle } : {}),
194
+ ...(maxLines !== undefined
195
+ ? {
196
+ display: "-webkit-box",
197
+ WebkitBoxOrient: "vertical" as const,
198
+ WebkitLineClamp: maxLines,
199
+ overflow: "hidden",
200
+ textOverflow: "ellipsis",
201
+ }
202
+ : {}),
203
+ };
204
+ }
205
+
206
+ /** Closed-allowlist enum gate. Returns the canonical constant from the
207
+ * allowlist (NEVER the raw input) or `undefined` (field omitted →
208
+ * CSS initial). Non-conforming value → R9 diagnostic, no passthrough. */
209
+ function enumValue(
210
+ v: unknown,
211
+ allow: ReadonlySet<string>,
212
+ field: string,
213
+ nodeId?: string,
214
+ ): string | undefined {
215
+ if (v === undefined) return undefined;
216
+ if (typeof v === "string" && allow.has(v)) return v;
217
+ warnRejectedTypo(field, nodeId);
218
+ return undefined;
219
+ }
220
+
221
+ /** Finite number within [min, max] or omit (R9 diagnostic on a
222
+ * non-conforming or out-of-cap input — rejected, never clamped). */
223
+ function boundedNumber(
224
+ v: unknown,
225
+ min: number,
226
+ max: number,
227
+ field: string,
228
+ nodeId?: string,
229
+ ): number | undefined {
230
+ if (v === undefined) return undefined;
231
+ if (typeof v === "number" && Number.isFinite(v) && v >= min && v <= max) return v;
232
+ warnRejectedTypo(field, nodeId);
233
+ return undefined;
234
+ }
235
+
236
+ /** Integer in [1, max] or omit (schema : maxLines is a line count ;
237
+ * capped per the issue #34 defence-in-depth bounds). */
238
+ function positiveInteger(
239
+ v: unknown,
240
+ max: number,
241
+ field: string,
242
+ nodeId?: string,
243
+ ): number | undefined {
244
+ if (v === undefined) return undefined;
245
+ if (typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max) return v;
246
+ warnRejectedTypo(field, nodeId);
247
+ return undefined;
248
+ }
249
+
250
+ /**
251
+ * Diagnostic for a typo value outside its spec'd grammar or caps.
252
+ * Bastion R9 (ADR 001 §5.1) : the rejected VALUE is never logged nor
253
+ * forwarded — only `node.id` (RC#7), the field name and a static
254
+ * reason. Routed through the structured diagnostics channel.
255
+ */
256
+ function warnRejectedTypo(field: string, nodeId?: string): void {
257
+ emitDiagnostic(
258
+ nodeId,
259
+ field,
260
+ "rejected typography value : outside the field's spec'd grammar or caps",
261
+ );
262
+ }
@@ -0,0 +1,119 @@
1
+ // Per-primitive prop allowlists (ADR 001 §3.4 D4, issue #34).
2
+ //
3
+ // Each primitive declares the exact set of resolved-prop keys it
4
+ // consumes at render time. Any prop reaching the renderer outside the
5
+ // allowlist — whether from a compiled bundle, a hand-rolled RenderNode
6
+ // or a binding key — produces a structured diagnostic (never a silent
7
+ // drop). Values are NEVER inspected nor reported (R9) : the check is
8
+ // purely key-based.
9
+ //
10
+ // Key-based is sufficient for live deltas too : an LSDP delta can only
11
+ // change the VALUE behind an already-declared binding key
12
+ // (`resolveProps`, tree.tsx) — it can never introduce a new prop key.
13
+ // The per-node key set is therefore static, and the check runs once per
14
+ // RenderNode object (WeakSet dedup) instead of once per render.
15
+ //
16
+ // These sets mirror what each primitive's component ACTUALLY reads
17
+ // today. Spec'd fields the renderer does not consume yet (e.g. `text`
18
+ // `format`, `stack` `padding`) are deliberately NOT listed : per the
19
+ // anti-silent-drop policy they must warn until they are implemented.
20
+
21
+ import type { RenderKind, RenderNode } from "./bundle";
22
+ import { emitDiagnostic } from "./diagnostics";
23
+
24
+ /** Universal props consumed by the Tree renderer itself
25
+ * (`UniversalWrapper`, LSML 1.1 §5.4) on every primitive. */
26
+ const UNIVERSAL_PROPS = ["visible", "opacity", "universal_opacity", "rotation", "sizing"] as const;
27
+
28
+ function allow(keys: readonly string[]): ReadonlySet<string> {
29
+ return new Set([...UNIVERSAL_PROPS, ...keys]);
30
+ }
31
+
32
+ /** Resolved-prop keys consumed per primitive (component + wrapper). */
33
+ export const PRIMITIVE_PROP_ALLOWLIST: Readonly<Record<RenderKind, ReadonlySet<string>>> = {
34
+ stack: allow(["direction", "gap", "wrap", "crossGap", "align", "justify"]),
35
+ grid: allow(["cols", "rows", "gap"]),
36
+ frame: allow([
37
+ "x",
38
+ "y",
39
+ "width",
40
+ "height",
41
+ "scale",
42
+ "rotate",
43
+ "background",
44
+ "backgrounds",
45
+ "clipsContent",
46
+ ]),
47
+ text: allow([
48
+ "value",
49
+ "size",
50
+ "font",
51
+ "weight",
52
+ "colour",
53
+ "align",
54
+ "lineHeight",
55
+ "letterSpacing",
56
+ "textTransform",
57
+ "textDecoration",
58
+ "fontStyle",
59
+ "maxLines",
60
+ ]),
61
+ image: allow(["src", "alt", "fit", "position", "width", "height"]),
62
+ shape: allow([
63
+ "geometry",
64
+ "kind",
65
+ "width",
66
+ "height",
67
+ "radius",
68
+ "fill",
69
+ "fills",
70
+ "stroke",
71
+ "stroke_width",
72
+ "strokes",
73
+ "pathData",
74
+ "paths",
75
+ "ariaLabel",
76
+ ]),
77
+ media: allow(["src", "loop", "mute", "autoplay", "fit"]),
78
+ instance: allow(["scene_id", "scene_version", "size", "position"]),
79
+ // `repeat` is dispatched specially by the tree ; its only consumed
80
+ // binding is `items`.
81
+ repeat: new Set(["items"]),
82
+ };
83
+
84
+ function isAllowed(kind: RenderKind, key: string): boolean {
85
+ const allowed = PRIMITIVE_PROP_ALLOWLIST[kind];
86
+ if (allowed === undefined) return true; // unknown kind warns separately (tree.tsx)
87
+ if (allowed.has(key)) return true;
88
+ // `instance` exposes bound sub-scene parameters under `params.*`
89
+ // (LSML §4.9) — the whole namespace is part of its contract.
90
+ if (kind === "instance" && (key === "params" || key.startsWith("params."))) return true;
91
+ return false;
92
+ }
93
+
94
+ // One check per RenderNode object — bundles are immutable once fetched,
95
+ // and a node's key set cannot change live (see module header).
96
+ const checkedNodes = new WeakSet<RenderNode>();
97
+
98
+ /**
99
+ * Audit a node's static props + binding keys against its primitive's
100
+ * allowlist. Every unknown key emits ONE structured diagnostic naming
101
+ * `node.id` + the prop (never the value, R9). Idempotent per node.
102
+ */
103
+ export function checkNodeProps(node: RenderNode): void {
104
+ if (checkedNodes.has(node)) return;
105
+ checkedNodes.add(node);
106
+ const keys = new Set<string>([
107
+ ...Object.keys(node.props ?? {}),
108
+ ...Object.keys(node.bindings ?? {}),
109
+ ]);
110
+ for (const key of keys) {
111
+ if (!isAllowed(node.kind, key)) {
112
+ emitDiagnostic(
113
+ node.id,
114
+ `${node.kind}.${key}`,
115
+ "is not consumed by this primitive's renderer ; the prop is ignored (anti-silent-drop, ADR 001 §3.4)",
116
+ );
117
+ }
118
+ }
119
+ }