@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
@@ -48,6 +48,17 @@ export interface RenderNode {
48
48
  * the primitive applies no `initial` and the prior no-mount-play
49
49
  * behaviour holds (backward compatible). */
50
50
  animate_initial?: Record<string, number | string>;
51
+ /** LSML 1.1 §6.3 — animation targets bound to leaf paths
52
+ * (`bindAnimate`). Keys are the spec property names (`opacity`,
53
+ * `transform.translate`, `transform.scale`, `transform.rotate`,
54
+ * `filter.blur`, `filter.brightness`, plus the kind's colour-typed
55
+ * property per §6.5 : `style.color` / `fill` / `background`) ; values
56
+ * are LeafPaths. The runtime subscribes each path's leaf-grain signal
57
+ * and retargets a Framer motion value on change — continuous
58
+ * interpolation toward the live value, no remount. Deltas are
59
+ * coalesced per frame (one retarget max per rAF per binding,
60
+ * ADR 001 RC#13). */
61
+ animateBindings?: Record<string, string>;
51
62
  /** LSML 1.1 §6.6 — multi-step keyframe sequence played on mount or
52
63
  * whenever `keyframes.key` (LeafPath) changes. Coexists with
53
64
  * `transitions` ; the runtime applies whichever was last triggered
@@ -102,9 +113,11 @@ export interface RenderBundle {
102
113
  external_adapters?: ExternalAdapter[];
103
114
  assets?: Asset[];
104
115
  /** LSML 1.1 §17.3 — capability profiles required for correct rendering.
105
- * Each entry is a `<vendor>.<name>-<version>` string. The runtime
106
- * checks every entry against its supported list ; an unrecognised
107
- * profile raises BUNDLE_INCOMPATIBLE per §17.3.1. */
116
+ * Each entry is an `x-<vendor>.<name>/<version>` string. The runtime
117
+ * checks every behavioural entry against its supported list ; an
118
+ * unrecognised behavioural profile raises BUNDLE_INCOMPATIBLE per
119
+ * §17.3.1. Authoring profiles (`x-<vendor>.authoring/<major>`, §17.5.1)
120
+ * are advisory : ignored at render time, never a rejection cause. */
108
121
  profiles?: string[];
109
122
  }
110
123
 
@@ -123,6 +136,44 @@ export const SUPPORTED_PROFILES: ReadonlySet<string> = new Set<string>([
123
136
  "x-lumencast.color-srgb-1.0",
124
137
  ]);
125
138
 
139
+ // LSML 1.1 §17.5.1 + ADR 001 RC#14 — authoring-profile detection.
140
+ //
141
+ // An authoring profile is advisory : a runtime that does not support it
142
+ // MUST NOT reject the bundle and renders the underlying primitives as if
143
+ // the profile were absent. Detection matches the COMPLETE identifier form
144
+ // `x-<vendor>.authoring/<major>` :
145
+ //
146
+ // - `x-` prefix, then one or more lowercase name segments separated by
147
+ // dots, where `.authoring` is the EXACT TERMINAL segment before `/` ;
148
+ // - `<major>` is a bare integer (no `.minor`) ;
149
+ // - never a substring test : a behavioural profile whose name merely
150
+ // *contains* `.authoring` in a non-terminal position is NOT exempted
151
+ // and keeps §17.3.1 hard-rejection semantics.
152
+ //
153
+ // Anti-ReDoS note : both regexes below are anchored and unambiguous —
154
+ // the character classes exclude the `.` and `/` separators, so there is
155
+ // exactly one possible parse per input (linear time, no backtracking).
156
+ const AUTHORING_NAME_RE = /^x-[a-z0-9-]+(?:\.[a-z0-9-]+)*$/;
157
+ const AUTHORING_MAJOR_RE = /^(?:0|[1-9][0-9]*)$/;
158
+ const AUTHORING_SUFFIX = ".authoring";
159
+
160
+ /** True when `id` has the complete authoring-profile form
161
+ * `x-<vendor>.authoring/<major>` (LSML 1.1 §17.5.1, ADR 001 RC#14).
162
+ * Such profiles are advisory : ignored at render time, never rejected. */
163
+ export function isAuthoringProfile(id: string): boolean {
164
+ const slash = id.indexOf("/");
165
+ if (slash < 0) return false;
166
+ const name = id.slice(0, slash);
167
+ const major = id.slice(slash + 1);
168
+ if (!AUTHORING_MAJOR_RE.test(major)) return false;
169
+ if (!name.endsWith(AUTHORING_SUFFIX)) return false;
170
+ // `name` minus the terminal `.authoring` segment must still be a valid
171
+ // `x-<vendor>[.<segment>...]` prefix — this is what makes `.authoring`
172
+ // a real terminal segment rather than a substring of another one
173
+ // (e.g. `x-evilauthoring/1` or `x-evil.authoring.fx/1` do not match).
174
+ return AUTHORING_NAME_RE.test(name.slice(0, -AUTHORING_SUFFIX.length));
175
+ }
176
+
126
177
  export class BundleIncompatibleError extends Error {
127
178
  public readonly code = "BUNDLE_INCOMPATIBLE" as const;
128
179
  public readonly unsupportedProfiles: string[];
@@ -139,14 +190,33 @@ export class BundleIncompatibleError extends Error {
139
190
 
140
191
  /** Validate a bundle's `profiles[]` against the runtime's supported
141
192
  * set. Throws `BundleIncompatibleError` listing every offending entry
142
- * when at least one is not supported. */
193
+ * when at least one behavioural profile is not supported.
194
+ *
195
+ * Authoring profiles (`x-<vendor>.authoring/<major>`, LSML 1.1 §17.5.1)
196
+ * are advisory and skipped : their absence from the supported set is
197
+ * never a rejection cause. Every other (behavioural) unsupported profile
198
+ * keeps the hard §17.3.1 `BUNDLE_INCOMPATIBLE` rejection.
199
+ *
200
+ * Malformed-shape guard : `bundle` may come from an unchecked
201
+ * `json as RenderBundle` cast on untrusted server JSON
202
+ * (`FetcherImpl.get`). A non-array `profiles` or a non-string entry is
203
+ * therefore reachable at runtime and is rejected as
204
+ * `BundleIncompatibleError` (typed, code BUNDLE_INCOMPATIBLE) — never a
205
+ * raw TypeError. The diagnostic never echoes the malformed value, only a
206
+ * shape placeholder. */
143
207
  export function validateBundleProfiles(
144
208
  bundle: { profiles?: string[] },
145
209
  supported: ReadonlySet<string> = SUPPORTED_PROFILES,
146
210
  ): void {
147
- const profiles = bundle.profiles;
148
- if (!profiles || profiles.length === 0) return;
149
- const missing = profiles.filter((p) => !supported.has(p));
211
+ const profiles: unknown = bundle.profiles;
212
+ if (!profiles) return;
213
+ if (!Array.isArray(profiles)) {
214
+ throw new BundleIncompatibleError(["<malformed: profiles is not an array>"]);
215
+ }
216
+ if (profiles.length === 0) return;
217
+ const missing = profiles
218
+ .filter((p) => typeof p !== "string" || (!isAuthoringProfile(p) && !supported.has(p)))
219
+ .map((p) => (typeof p === "string" ? p : "<malformed: non-string profile entry>"));
150
220
  if (missing.length > 0) {
151
221
  throw new BundleIncompatibleError(missing);
152
222
  }
@@ -162,12 +232,33 @@ export interface BundleFetcher {
162
232
  preload(bundle: RenderBundle): void;
163
233
  }
164
234
 
235
+ /** Resolves the absolute URL of a scene's render bundle. Supplied by the
236
+ * host (`MountOptions.resolveBundleUrl`) when the server is not at the
237
+ * default host-root LSDP/1 layout — e.g. reached through a gateway prefix
238
+ * (`https://gw/orion/api/v1/scenes/{id}/render-bundle?v={hash}`). */
239
+ export type BundleUrlResolver = (sceneId: string, sceneVersion: string) => string;
240
+
165
241
  export interface BundleFetcherOptions {
166
242
  /** Base URL of the server. The fetcher constructs
167
- * `${baseUrl}/lsdp/v1/scenes/{id}/bundle?v={hash}`. */
243
+ * `${baseUrl}/lsdp/v1/scenes/{id}/bundle?v={hash}`. Ignored when
244
+ * `resolveUrl` is provided. */
168
245
  baseUrl: string;
169
- /** Path prefix for bundle resolution. Defaults to `/lsdp/v1/scenes`. */
246
+ /** Path prefix for bundle resolution. Defaults to `/lsdp/v1/scenes`.
247
+ * Ignored when `resolveUrl` is provided. */
170
248
  pathPrefix?: string;
249
+ /** When set, takes full control of URL construction — the host owns the
250
+ * whole URL (base, path prefix and `/bundle` vs `/render-bundle` suffix).
251
+ * Lets a gateway-prefixed server be addressed without changing the
252
+ * host-root default. */
253
+ resolveUrl?: BundleUrlResolver;
254
+ /** Resolve the bearer token used to authenticate each bundle GET. The
255
+ * render-bundle endpoint is auth-gated identically to the LSDP/1 WS
256
+ * subscription, so the fetch carries the same session token as
257
+ * `Authorization: Bearer <token>`. Resolved per request so a token swap
258
+ * (`setToken`) takes effect on the next fetch ; a `LumencastTokenProvider`
259
+ * is awaited. When omitted — or when it resolves to an empty/undefined
260
+ * value — no `Authorization` header is sent (v0.5.0 behaviour). */
261
+ getAuthToken?: () => string | undefined | Promise<string | undefined>;
171
262
  fetchImpl?: typeof fetch;
172
263
  }
173
264
 
@@ -175,14 +266,35 @@ class FetcherImpl implements BundleFetcher {
175
266
  private readonly cache = new Map<string, RenderBundle>();
176
267
  private readonly baseUrl: string;
177
268
  private readonly pathPrefix: string;
269
+ private readonly resolveUrl: BundleUrlResolver | undefined;
270
+ private readonly getAuthToken: BundleFetcherOptions["getAuthToken"];
178
271
  private readonly fetchImpl: typeof fetch;
179
272
 
180
273
  constructor(opts: BundleFetcherOptions) {
181
274
  this.baseUrl = opts.baseUrl.replace(/\/$/, "");
182
275
  this.pathPrefix = (opts.pathPrefix ?? "/lsdp/v1/scenes").replace(/\/$/, "");
276
+ this.resolveUrl = opts.resolveUrl;
277
+ this.getAuthToken = opts.getAuthToken;
183
278
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
184
279
  }
185
280
 
281
+ /** Build the request init carrying the bearer token, if any. Returns
282
+ * `undefined` when no token is available — the fetch stays header-less,
283
+ * preserving v0.5.0 behaviour. */
284
+ private async buildInit(): Promise<RequestInit | undefined> {
285
+ if (!this.getAuthToken) return undefined;
286
+ const token = await this.getAuthToken();
287
+ if (!token) return undefined;
288
+ return { headers: { Authorization: `Bearer ${token}` } };
289
+ }
290
+
291
+ private buildUrl(sceneId: string, sceneVersion: string): string {
292
+ if (this.resolveUrl) {
293
+ return this.resolveUrl(sceneId, sceneVersion);
294
+ }
295
+ return `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(sceneId)}/bundle?v=${encodeURIComponent(sceneVersion)}`;
296
+ }
297
+
186
298
  preload(bundle: RenderBundle): void {
187
299
  // LSML 1.1 §17.3.1 — reject early if any declared profile is
188
300
  // unsupported by this runtime. Authors get an actionable error
@@ -194,8 +306,9 @@ class FetcherImpl implements BundleFetcher {
194
306
  async get(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
195
307
  const cached = this.cache.get(sceneVersion);
196
308
  if (cached) return cached;
197
- const url = `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(sceneId)}/bundle?v=${encodeURIComponent(sceneVersion)}`;
198
- const response = await this.fetchImpl(url);
309
+ const url = this.buildUrl(sceneId, sceneVersion);
310
+ const init = await this.buildInit();
311
+ const response = init ? await this.fetchImpl(url, init) : await this.fetchImpl(url);
199
312
  if (!response.ok) {
200
313
  throw new Error(`bundle fetch failed: ${response.status} ${response.statusText}`);
201
314
  }
@@ -0,0 +1,303 @@
1
+ // sRGB colour interpolation (LSML 1.1 §6.5) — issue #33.
2
+ //
3
+ // Both endpoints of a colour animation are first CANONICALISED through
4
+ // the strict shared parser (`parseCssColor`, css-color.ts — ADR 001
5
+ // RC#11/RC#12, never a raw string), then converted to RGBA channels in
6
+ // [0, 1] and interpolated component-wise :
7
+ //
8
+ // out_c = a_c + t * (b_c - a_c) for c ∈ {r, g, b, a}
9
+ //
10
+ // with `t` produced by the easing curve. The output is serialised back
11
+ // to `rgba()` form — which itself round-trips through `parseCssColor`
12
+ // at the consuming primitive (belt and braces).
13
+ //
14
+ // All conversions are constant-time per value (the parser already
15
+ // bounds inputs to 64 chars) ; the named-colour table is a flat map
16
+ // lookup.
17
+
18
+ import { parseCssColor } from "./css-color";
19
+
20
+ /** RGBA channels, each in [0, 1]. */
21
+ export type Rgba = readonly [number, number, number, number];
22
+
23
+ // CSS Color 4 §6.1 named colours → packed 0xRRGGBB. Kept numeric (not
24
+ // hex strings) to minimise bundle weight ; the set of NAMES here is
25
+ // exactly the set accepted by css-color.ts (`transparent` and
26
+ // `currentcolor` are handled separately — `currentcolor` cannot be
27
+ // interpolated without computed-style context and is rejected).
28
+ const NAMED_RGB: Record<string, number> = {
29
+ aliceblue: 0xf0f8ff,
30
+ antiquewhite: 0xfaebd7,
31
+ aqua: 0x00ffff,
32
+ aquamarine: 0x7fffd4,
33
+ azure: 0xf0ffff,
34
+ beige: 0xf5f5dc,
35
+ bisque: 0xffe4c4,
36
+ black: 0x000000,
37
+ blanchedalmond: 0xffebcd,
38
+ blue: 0x0000ff,
39
+ blueviolet: 0x8a2be2,
40
+ brown: 0xa52a2a,
41
+ burlywood: 0xdeb887,
42
+ cadetblue: 0x5f9ea0,
43
+ chartreuse: 0x7fff00,
44
+ chocolate: 0xd2691e,
45
+ coral: 0xff7f50,
46
+ cornflowerblue: 0x6495ed,
47
+ cornsilk: 0xfff8dc,
48
+ crimson: 0xdc143c,
49
+ cyan: 0x00ffff,
50
+ darkblue: 0x00008b,
51
+ darkcyan: 0x008b8b,
52
+ darkgoldenrod: 0xb8860b,
53
+ darkgray: 0xa9a9a9,
54
+ darkgreen: 0x006400,
55
+ darkgrey: 0xa9a9a9,
56
+ darkkhaki: 0xbdb76b,
57
+ darkmagenta: 0x8b008b,
58
+ darkolivegreen: 0x556b2f,
59
+ darkorange: 0xff8c00,
60
+ darkorchid: 0x9932cc,
61
+ darkred: 0x8b0000,
62
+ darksalmon: 0xe9967a,
63
+ darkseagreen: 0x8fbc8f,
64
+ darkslateblue: 0x483d8b,
65
+ darkslategray: 0x2f4f4f,
66
+ darkslategrey: 0x2f4f4f,
67
+ darkturquoise: 0x00ced1,
68
+ darkviolet: 0x9400d3,
69
+ deeppink: 0xff1493,
70
+ deepskyblue: 0x00bfff,
71
+ dimgray: 0x696969,
72
+ dimgrey: 0x696969,
73
+ dodgerblue: 0x1e90ff,
74
+ firebrick: 0xb22222,
75
+ floralwhite: 0xfffaf0,
76
+ forestgreen: 0x228b22,
77
+ fuchsia: 0xff00ff,
78
+ gainsboro: 0xdcdcdc,
79
+ ghostwhite: 0xf8f8ff,
80
+ gold: 0xffd700,
81
+ goldenrod: 0xdaa520,
82
+ gray: 0x808080,
83
+ green: 0x008000,
84
+ greenyellow: 0xadff2f,
85
+ grey: 0x808080,
86
+ honeydew: 0xf0fff0,
87
+ hotpink: 0xff69b4,
88
+ indianred: 0xcd5c5c,
89
+ indigo: 0x4b0082,
90
+ ivory: 0xfffff0,
91
+ khaki: 0xf0e68c,
92
+ lavender: 0xe6e6fa,
93
+ lavenderblush: 0xfff0f5,
94
+ lawngreen: 0x7cfc00,
95
+ lemonchiffon: 0xfffacd,
96
+ lightblue: 0xadd8e6,
97
+ lightcoral: 0xf08080,
98
+ lightcyan: 0xe0ffff,
99
+ lightgoldenrodyellow: 0xfafad2,
100
+ lightgray: 0xd3d3d3,
101
+ lightgreen: 0x90ee90,
102
+ lightgrey: 0xd3d3d3,
103
+ lightpink: 0xffb6c1,
104
+ lightsalmon: 0xffa07a,
105
+ lightseagreen: 0x20b2aa,
106
+ lightskyblue: 0x87cefa,
107
+ lightslategray: 0x778899,
108
+ lightslategrey: 0x778899,
109
+ lightsteelblue: 0xb0c4de,
110
+ lightyellow: 0xffffe0,
111
+ lime: 0x00ff00,
112
+ limegreen: 0x32cd32,
113
+ linen: 0xfaf0e6,
114
+ magenta: 0xff00ff,
115
+ maroon: 0x800000,
116
+ mediumaquamarine: 0x66cdaa,
117
+ mediumblue: 0x0000cd,
118
+ mediumorchid: 0xba55d3,
119
+ mediumpurple: 0x9370db,
120
+ mediumseagreen: 0x3cb371,
121
+ mediumslateblue: 0x7b68ee,
122
+ mediumspringgreen: 0x00fa9a,
123
+ mediumturquoise: 0x48d1cc,
124
+ mediumvioletred: 0xc71585,
125
+ midnightblue: 0x191970,
126
+ mintcream: 0xf5fffa,
127
+ mistyrose: 0xffe4e1,
128
+ moccasin: 0xffe4b5,
129
+ navajowhite: 0xffdead,
130
+ navy: 0x000080,
131
+ oldlace: 0xfdf5e6,
132
+ olive: 0x808000,
133
+ olivedrab: 0x6b8e23,
134
+ orange: 0xffa500,
135
+ orangered: 0xff4500,
136
+ orchid: 0xda70d6,
137
+ palegoldenrod: 0xeee8aa,
138
+ palegreen: 0x98fb98,
139
+ paleturquoise: 0xafeeee,
140
+ palevioletred: 0xdb7093,
141
+ papayawhip: 0xffefd5,
142
+ peachpuff: 0xffdab9,
143
+ peru: 0xcd853f,
144
+ pink: 0xffc0cb,
145
+ plum: 0xdda0dd,
146
+ powderblue: 0xb0e0e6,
147
+ purple: 0x800080,
148
+ rebeccapurple: 0x663399,
149
+ red: 0xff0000,
150
+ rosybrown: 0xbc8f8f,
151
+ royalblue: 0x4169e1,
152
+ saddlebrown: 0x8b4513,
153
+ salmon: 0xfa8072,
154
+ sandybrown: 0xf4a460,
155
+ seagreen: 0x2e8b57,
156
+ seashell: 0xfff5ee,
157
+ sienna: 0xa0522d,
158
+ silver: 0xc0c0c0,
159
+ skyblue: 0x87ceeb,
160
+ slateblue: 0x6a5acd,
161
+ slategray: 0x708090,
162
+ slategrey: 0x708090,
163
+ snow: 0xfffafa,
164
+ springgreen: 0x00ff7f,
165
+ steelblue: 0x4682b4,
166
+ tan: 0xd2b48c,
167
+ teal: 0x008080,
168
+ thistle: 0xd8bfd8,
169
+ tomato: 0xff6347,
170
+ turquoise: 0x40e0d0,
171
+ violet: 0xee82ee,
172
+ wheat: 0xf5deb3,
173
+ white: 0xffffff,
174
+ whitesmoke: 0xf5f5f5,
175
+ yellow: 0xffff00,
176
+ yellowgreen: 0x9acd32,
177
+ };
178
+
179
+ /**
180
+ * Canonicalise + convert an untrusted colour value to RGBA channels in
181
+ * [0, 1]. The value passes through `parseCssColor` FIRST — anything the
182
+ * strict parser rejects converts to `null` here (never interpolate a
183
+ * raw string, §6.5 step 1 / RC#11). `currentcolor` is also rejected :
184
+ * it has no concrete channels without computed-style context.
185
+ */
186
+ export function cssColorToRgba(value: unknown): Rgba | null {
187
+ const v = parseCssColor(value);
188
+ if (v === null) return null;
189
+
190
+ if (v.startsWith("#")) return hexToRgba(v);
191
+
192
+ if (v.startsWith("rgb")) {
193
+ const body = v.slice(v.indexOf("(") + 1, -1);
194
+ const parts = body.split(",").map((p) => p.trim());
195
+ if (parts.length < 3) return null;
196
+ const pct = parts[0]!.endsWith("%");
197
+ const scale = pct ? 100 : 255;
198
+ const r = channel(parts[0]!, scale);
199
+ const g = channel(parts[1]!, scale);
200
+ const b = channel(parts[2]!, scale);
201
+ const a = parts.length > 3 ? alphaChannel(parts[3]!) : 1;
202
+ if (r === null || g === null || b === null || a === null) return null;
203
+ return [r, g, b, a];
204
+ }
205
+
206
+ if (v.startsWith("hsl")) {
207
+ const body = v.slice(v.indexOf("(") + 1, -1);
208
+ const parts = body.split(",").map((p) => p.trim());
209
+ if (parts.length < 3) return null;
210
+ const h = Number(parts[0]!.replace("deg", ""));
211
+ const s = Number(parts[1]!.replace("%", "")) / 100;
212
+ const l = Number(parts[2]!.replace("%", "")) / 100;
213
+ const a = parts.length > 3 ? alphaChannel(parts[3]!) : 1;
214
+ if (![h, s, l].every(Number.isFinite) || a === null) return null;
215
+ const [r, g, b] = hslToRgb(h, s, l);
216
+ return [r, g, b, a];
217
+ }
218
+
219
+ if (v === "transparent") return [0, 0, 0, 0];
220
+ if (v === "currentcolor") return null;
221
+
222
+ const packed = NAMED_RGB[v];
223
+ if (packed === undefined) return null;
224
+ return [((packed >> 16) & 0xff) / 255, ((packed >> 8) & 0xff) / 255, (packed & 0xff) / 255, 1];
225
+ }
226
+
227
+ function hexToRgba(v: string): Rgba | null {
228
+ const h = v.slice(1);
229
+ if (h.length === 3 || h.length === 4) {
230
+ const r = parseInt(h[0]! + h[0]!, 16);
231
+ const g = parseInt(h[1]! + h[1]!, 16);
232
+ const b = parseInt(h[2]! + h[2]!, 16);
233
+ const a = h.length === 4 ? parseInt(h[3]! + h[3]!, 16) : 255;
234
+ return [r / 255, g / 255, b / 255, a / 255];
235
+ }
236
+ if (h.length === 6 || h.length === 8) {
237
+ const r = parseInt(h.slice(0, 2), 16);
238
+ const g = parseInt(h.slice(2, 4), 16);
239
+ const b = parseInt(h.slice(4, 6), 16);
240
+ const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) : 255;
241
+ return [r / 255, g / 255, b / 255, a / 255];
242
+ }
243
+ return null;
244
+ }
245
+
246
+ function channel(token: string, scale: number): number | null {
247
+ const n = Number(token.replace("%", ""));
248
+ if (!Number.isFinite(n)) return null;
249
+ return clamp01(n / scale);
250
+ }
251
+
252
+ function alphaChannel(token: string): number | null {
253
+ const pct = token.endsWith("%");
254
+ const n = Number(token.replace("%", ""));
255
+ if (!Number.isFinite(n)) return null;
256
+ return clamp01(pct ? n / 100 : n);
257
+ }
258
+
259
+ /** Standard HSL → RGB (CSS Color 4 §7.1). h in degrees, s/l in [0,1]. */
260
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
261
+ const hue = ((h % 360) + 360) % 360;
262
+ const c = (1 - Math.abs(2 * l - 1)) * s;
263
+ const hp = hue / 60;
264
+ const x = c * (1 - Math.abs((hp % 2) - 1));
265
+ let r = 0;
266
+ let g = 0;
267
+ let b = 0;
268
+ if (hp < 1) [r, g, b] = [c, x, 0];
269
+ else if (hp < 2) [r, g, b] = [x, c, 0];
270
+ else if (hp < 3) [r, g, b] = [0, c, x];
271
+ else if (hp < 4) [r, g, b] = [0, x, c];
272
+ else if (hp < 5) [r, g, b] = [x, 0, c];
273
+ else [r, g, b] = [c, 0, x];
274
+ const m = l - c / 2;
275
+ return [clamp01(r + m), clamp01(g + m), clamp01(b + m)];
276
+ }
277
+
278
+ /** Component-wise sRGB lerp (§6.5 step 2). `t` may overshoot (springs) ;
279
+ * channels clamp back into [0, 1] after mixing. */
280
+ export function mixRgba(a: Rgba, b: Rgba, t: number): Rgba {
281
+ return [
282
+ clamp01(a[0] + t * (b[0] - a[0])),
283
+ clamp01(a[1] + t * (b[1] - a[1])),
284
+ clamp01(a[2] + t * (b[2] - a[2])),
285
+ clamp01(a[3] + t * (b[3] - a[3])),
286
+ ];
287
+ }
288
+
289
+ /** Serialise RGBA back to `rgba()` form (§6.5 step 3). The output is
290
+ * always re-accepted by `parseCssColor` (integer channels, alpha with
291
+ * at most 4 decimals). */
292
+ export function serializeRgba(rgba: Rgba): string {
293
+ const r = Math.round(clamp01(rgba[0]) * 255);
294
+ const g = Math.round(clamp01(rgba[1]) * 255);
295
+ const b = Math.round(clamp01(rgba[2]) * 255);
296
+ // 4 decimals max so the alpha token matches the strict grammar.
297
+ const a = Math.round(clamp01(rgba[3]) * 10000) / 10000;
298
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
299
+ }
300
+
301
+ function clamp01(n: number): number {
302
+ return n < 0 ? 0 : n > 1 ? 1 : n;
303
+ }
@@ -0,0 +1,145 @@
1
+ // Strict CSS colour parser — the ONLY gate through which untrusted
2
+ // colour values (bundle props AND live LSDP deltas, see `resolveProps`
3
+ // in tree.tsx) may reach an inline CSS style.
4
+ //
5
+ // ADR 001 §6 RC#11 (CSS strict) + RC#12 (anti-ReDoS), threat model
6
+ // Bastion 2026-06-10, issue #35. Sites #30/#31/#33 must reuse this
7
+ // module — never re-implement colour validation locally.
8
+ //
9
+ // Accepted grammar (LSML 1.1 §6.5 colour forms, nothing more) :
10
+ // - hex : #RGB | #RGBA | #RRGGBB | #RRGGBBAA
11
+ // - rgb() : rgb(R, G, B) | rgba(R, G, B, A) — 0-255 or percentages
12
+ // - hsl() : hsl(H, S%, L%) | hsla(H, S%, L%, A)
13
+ // - named : canonical CSS named colours + `transparent` + `currentcolor`
14
+ // Anything else — including `url(`, `;`, `}`, `expression(`, var(),
15
+ // calc(), whitespace tricks — is REJECTED (null). Never passthrough.
16
+ //
17
+ // ── Linear-time justification (RC#12, written per Bastion) ──────────
18
+ // 1. Inputs longer than MAX_LEN (64) are rejected before any regex
19
+ // runs, so every step below operates on a bounded string.
20
+ // 2. The charset pre-scan is a single O(n) pass over one character
21
+ // class — it rejects `;`, `}`, `:`, `/`, quotes, backslashes and
22
+ // control characters outright, so no later step ever sees them.
23
+ // 3. Every regex is anchored (`^…$`) and built exclusively from
24
+ // literals, character classes and BOUNDED quantifiers ({m,n}, ?).
25
+ // There are no nested unbounded quantifiers ((a+)+ style), no
26
+ // overlapping alternations under a quantifier — i.e. no input can
27
+ // trigger super-linear backtracking. Combined with the 64-char cap,
28
+ // total work is O(64) per value regardless of payload shape.
29
+ // ─────────────────────────────────────────────────────────────────────
30
+
31
+ import { emitDiagnostic } from "./diagnostics";
32
+
33
+ const MAX_LEN = 64;
34
+
35
+ // Single-pass charset allowlist. Only characters that can appear in
36
+ // the accepted grammar. Notably ABSENT : `;` `}` `{` `:` `/` `"` `'`
37
+ // `\` `<` `>` `-` and all control chars — the injection metacharacters.
38
+ const CHARSET_RE = /^[#a-zA-Z0-9(),.% ]{1,64}$/;
39
+
40
+ // hex — 3/4/6/8 hex digits. Alternation of fixed-width character-class
41
+ // runs : strictly linear.
42
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
43
+
44
+ // Number tokens. All quantifiers bounded ; `(?:\.\d{1,4})?` is an
45
+ // optional bounded group — one possible match per position, no
46
+ // ambiguity, no backtracking blow-up.
47
+ const NUM = String.raw`\d{1,3}(?:\.\d{1,4})?`; // 0 … 999.9999
48
+ const ALPHA = String.raw`(?:0|1|0?\.\d{1,4}|${NUM}%)`; // 0-1 or %
49
+ const SP = String.raw`[ ]{0,4}`; // bounded optional spaces
50
+
51
+ // rgb(R, G, B[, A]) — channels all plain numbers or all percentages
52
+ // are both accepted (range-checked numerically after the match).
53
+ const RGB_RE = new RegExp(
54
+ `^rgba?\\(${SP}(${NUM})(%?)${SP},${SP}(${NUM})(%?)${SP},${SP}(${NUM})(%?)${SP}` +
55
+ `(?:,${SP}${ALPHA}${SP})?\\)$`,
56
+ );
57
+
58
+ // hsl(H[deg], S%, L%[, A])
59
+ const HSL_RE = new RegExp(
60
+ `^hsla?\\(${SP}(${NUM})(?:deg)?${SP},${SP}(${NUM})%${SP},${SP}(${NUM})%${SP}` +
61
+ `(?:,${SP}${ALPHA}${SP})?\\)$`,
62
+ );
63
+
64
+ // Canonical CSS named colours (CSS Color 4 §6.1) + the two keywords
65
+ // that behave like colours in every site we render.
66
+ const NAMED = new Set(
67
+ (
68
+ "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue " +
69
+ "blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk " +
70
+ "crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki " +
71
+ "darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen " +
72
+ "darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue " +
73
+ "dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite " +
74
+ "gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki " +
75
+ "lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan " +
76
+ "lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen " +
77
+ "lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen " +
78
+ "linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple " +
79
+ "mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred " +
80
+ "midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab " +
81
+ "orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip " +
82
+ "peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue " +
83
+ "saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue " +
84
+ "slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet " +
85
+ "wheat white whitesmoke yellow yellowgreen transparent currentcolor"
86
+ ).split(" "),
87
+ );
88
+
89
+ /**
90
+ * Validate an untrusted colour value against the strict grammar above.
91
+ *
92
+ * Returns the validated string (trimmed ; named colours lowercased) or
93
+ * `null` on rejection. A `null` MUST be handled as "omit the style /
94
+ * use the primitive's safe default" — never interpolate the raw input.
95
+ */
96
+ export function parseCssColor(value: unknown): string | null {
97
+ if (typeof value !== "string") return null;
98
+ const v = value.trim();
99
+ if (v.length === 0 || v.length > MAX_LEN) return null;
100
+ // Contractual explicit rejects (Bastion RC#11) — redundant with the
101
+ // charset scan below, kept as belt-and-braces so the contract holds
102
+ // even if the grammar is ever extended.
103
+ const lower = v.toLowerCase();
104
+ if (lower.includes("url(") || v.includes(";") || v.includes("}")) return null;
105
+ // Single-pass charset allowlist (kills every CSS metacharacter).
106
+ if (!CHARSET_RE.test(v)) return null;
107
+
108
+ if (v.startsWith("#")) return HEX_RE.test(v) ? v : null;
109
+
110
+ if (lower.startsWith("rgb")) {
111
+ const m = RGB_RE.exec(lower);
112
+ if (!m) return null;
113
+ // Range check : percent channels ≤ 100, plain channels ≤ 255, no mixing.
114
+ const pct = [m[2], m[4], m[6]];
115
+ if (!(pct.every((p) => p === "%") || pct.every((p) => p === ""))) return null;
116
+ const max = pct[0] === "%" ? 100 : 255;
117
+ for (const ch of [m[1], m[3], m[5]]) {
118
+ if (Number(ch) > max) return null;
119
+ }
120
+ return lower;
121
+ }
122
+
123
+ if (lower.startsWith("hsl")) {
124
+ const m = HSL_RE.exec(lower);
125
+ if (!m) return null;
126
+ if (Number(m[1]) > 360 || Number(m[2]) > 100 || Number(m[3]) > 100) return null;
127
+ return lower;
128
+ }
129
+
130
+ return NAMED.has(lower) ? lower : null;
131
+ }
132
+
133
+ /**
134
+ * Diagnostic for a rejected value. Bastion R9 (ADR 001 §5.1) : the
135
+ * rejected VALUE is never logged nor forwarded — only `node.id` (RC#7,
136
+ * issue #34), the field name and a static reason. Routed through the
137
+ * structured diagnostics channel (events, no logs in `broadcast`).
138
+ */
139
+ export function warnRejectedColor(field: string, nodeId?: string): void {
140
+ emitDiagnostic(
141
+ nodeId,
142
+ field,
143
+ "rejected unsafe colour : not a strict hex/rgb()/hsl()/named colour",
144
+ );
145
+ }