@lumencast/runtime 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -1
- package/dist/animate/transitions.d.ts.map +1 -1
- package/dist/animate/transitions.js +30 -3
- package/dist/animate/transitions.js.map +1 -1
- package/dist/{broadcast-DzZ8TVGZ.js → broadcast-3vYij4k-.js} +3 -3
- package/dist/{broadcast-DzZ8TVGZ.js.map → broadcast-3vYij4k-.js.map} +1 -1
- package/dist/{control-gbDGvdR0.js → control-BFNkY7-6.js} +4 -4
- package/dist/{control-gbDGvdR0.js.map → control-BFNkY7-6.js.map} +1 -1
- package/dist/{index-oteiocFe.js → index-CyOlpZAL.js} +305 -150
- 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 +42 -7
- 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 +6 -3
- 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 +56 -12
- 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 +179 -7
- 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-Cgdl9FtP.js → status-pill-DIpXc5du.js} +2 -2
- package/dist/{status-pill-Cgdl9FtP.js.map → status-pill-DIpXc5du.js.map} +1 -1
- package/dist/{test-CAnkHA0n.js → test-ByRec1kd.js} +4 -4
- package/dist/{test-CAnkHA0n.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 +33 -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 +47 -7
- package/src/render/primitives/image.tsx +6 -2
- package/src/render/primitives/index.ts +3 -0
- package/src/render/primitives/instance.tsx +14 -15
- package/src/render/primitives/shape.tsx +76 -12
- package/src/render/primitives/text.tsx +224 -7
- 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-oteiocFe.js.map +0 -1
- package/dist/tree-DVYXwItH.js +0 -512
- package/dist/tree-DVYXwItH.js.map +0 -1
package/src/render/bundle.ts
CHANGED
|
@@ -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
|
|
106
|
-
* checks every entry against its supported list ; an
|
|
107
|
-
* profile raises BUNDLE_INCOMPATIBLE per
|
|
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
|
|
149
|
-
|
|
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,25 @@ 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;
|
|
171
254
|
fetchImpl?: typeof fetch;
|
|
172
255
|
}
|
|
173
256
|
|
|
@@ -175,14 +258,23 @@ class FetcherImpl implements BundleFetcher {
|
|
|
175
258
|
private readonly cache = new Map<string, RenderBundle>();
|
|
176
259
|
private readonly baseUrl: string;
|
|
177
260
|
private readonly pathPrefix: string;
|
|
261
|
+
private readonly resolveUrl: BundleUrlResolver | undefined;
|
|
178
262
|
private readonly fetchImpl: typeof fetch;
|
|
179
263
|
|
|
180
264
|
constructor(opts: BundleFetcherOptions) {
|
|
181
265
|
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
182
266
|
this.pathPrefix = (opts.pathPrefix ?? "/lsdp/v1/scenes").replace(/\/$/, "");
|
|
267
|
+
this.resolveUrl = opts.resolveUrl;
|
|
183
268
|
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
184
269
|
}
|
|
185
270
|
|
|
271
|
+
private buildUrl(sceneId: string, sceneVersion: string): string {
|
|
272
|
+
if (this.resolveUrl) {
|
|
273
|
+
return this.resolveUrl(sceneId, sceneVersion);
|
|
274
|
+
}
|
|
275
|
+
return `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(sceneId)}/bundle?v=${encodeURIComponent(sceneVersion)}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
186
278
|
preload(bundle: RenderBundle): void {
|
|
187
279
|
// LSML 1.1 §17.3.1 — reject early if any declared profile is
|
|
188
280
|
// unsupported by this runtime. Authors get an actionable error
|
|
@@ -194,7 +286,7 @@ class FetcherImpl implements BundleFetcher {
|
|
|
194
286
|
async get(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
|
|
195
287
|
const cached = this.cache.get(sceneVersion);
|
|
196
288
|
if (cached) return cached;
|
|
197
|
-
const url =
|
|
289
|
+
const url = this.buildUrl(sceneId, sceneVersion);
|
|
198
290
|
const response = await this.fetchImpl(url);
|
|
199
291
|
if (!response.ok) {
|
|
200
292
|
throw new Error(`bundle fetch failed: ${response.status} ${response.statusText}`);
|
|
@@ -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
|
+
}
|