@figurestead/web 0.9.0-alpha.1

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/THIRD_PARTY_NOTICES.md +39 -0
  4. package/TRADEMARKS.md +13 -0
  5. package/package.json +31 -0
  6. package/src/accessibility.js +33 -0
  7. package/src/appearance.js +25 -0
  8. package/src/application-profiles.js +76 -0
  9. package/src/atmosphere.js +71 -0
  10. package/src/canvas-scene.js +323 -0
  11. package/src/clock.js +27 -0
  12. package/src/color-space.js +114 -0
  13. package/src/composition.js +201 -0
  14. package/src/core-renderers.js +33 -0
  15. package/src/create-matrix-plot.js +97 -0
  16. package/src/evidence-coverage.js +80 -0
  17. package/src/export-bundle.js +83 -0
  18. package/src/extensions/temporal/coverage.js +96 -0
  19. package/src/extensions/temporal/index.js +43 -0
  20. package/src/extensions/temporal/observations.js +87 -0
  21. package/src/extensions/temporal/shared.js +172 -0
  22. package/src/figure-layout.js +70 -0
  23. package/src/figure.js +42 -0
  24. package/src/index.js +30 -0
  25. package/src/layout.js +41 -0
  26. package/src/marks.js +218 -0
  27. package/src/motion-plan.js +66 -0
  28. package/src/motion-recipes.js +39 -0
  29. package/src/paper-profile.js +78 -0
  30. package/src/physical-export.js +34 -0
  31. package/src/presentation.js +127 -0
  32. package/src/primitives.js +24 -0
  33. package/src/random.js +26 -0
  34. package/src/registry.js +29 -0
  35. package/src/render-layers.js +35 -0
  36. package/src/renderer-test-kit.js +29 -0
  37. package/src/renderers/line.js +133 -0
  38. package/src/renderers/scatter.js +24 -0
  39. package/src/renderers/shared.js +21 -0
  40. package/src/renderers/strip-summary.js +40 -0
  41. package/src/resolved-scene.js +249 -0
  42. package/src/scales.js +68 -0
  43. package/src/schema.js +356 -0
  44. package/src/series-style.js +59 -0
  45. package/src/svg-export.js +232 -0
  46. package/src/terminal-scene.js +215 -0
  47. package/src/theme-catalog.js +34 -0
  48. package/src/theme-pack.js +268 -0
@@ -0,0 +1,268 @@
1
+ import { cloneValue, FiguresteadConfigError } from "./schema.js";
2
+ import { colorContrast } from "./color-space.js";
3
+ import { auditPaperTheme, resolvePaperTheme, themeResolutionForProfile } from "./paper-profile.js";
4
+
5
+ export const THEME_PACK_VERSION = "figurestead.theme-pack/1";
6
+ export const PALETTE_PACK_VERSION = "figurestead.palette-pack/2";
7
+ const COLOR = /^#[0-9a-fA-F]{6}$/;
8
+ const THEME_KEY = /^[a-z][a-z0-9_]*$/;
9
+ const REQUIRED = ["field", "panel", "grid", "spine", "label", "secondary", "faint", "primary", "summaryCore", "warm"];
10
+ const OPTIONAL = ["primaryEdge", "summaryEdge", "seriesEdges"];
11
+ const THEME_FIELDS = new Set(["key", "name", ...REQUIRED, "series", ...OPTIONAL]);
12
+ const AUTHORING_THEME_FIELDS = new Set([
13
+ ...THEME_FIELDS,
14
+ "summary_core", "primary_edge", "summary_edge", "series_edges",
15
+ ]);
16
+ const RUNTIME_THEME_FIELDS = new Set([...THEME_FIELDS, "mode"]);
17
+ const AUTHORING_ALIASES = [
18
+ ["summary_core", "summaryCore"],
19
+ ["primary_edge", "primaryEdge"],
20
+ ["summary_edge", "summaryEdge"],
21
+ ["series_edges", "seriesEdges"],
22
+ ];
23
+ const RUNTIME_MODES = new Set(["paper", "atlas", "talk"]);
24
+
25
+ function hexChannels(value) { return [1, 3, 5].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16)); }
26
+ function mixHex(left, right, amount) {
27
+ const a = hexChannels(left), b = hexChannels(right), t = Math.max(0, Math.min(1, amount));
28
+ return `#${a.map((value, index) => Math.round(value + (b[index] - value) * t).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
29
+ }
30
+
31
+ function object(value, path) {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new FiguresteadConfigError("must be an object", path);
33
+ }
34
+
35
+ function hasOwn(value, key) { return Object.prototype.hasOwnProperty.call(value, key); }
36
+
37
+ function exactFields(value, allowed, path) {
38
+ for (const key of Object.keys(value)) {
39
+ if (!allowed.has(key)) throw new FiguresteadConfigError("is not an allowed field", `${path}.${key}`);
40
+ }
41
+ }
42
+
43
+ function nonEmptyString(value, path) {
44
+ if (typeof value !== "string" || !value.trim()) throw new FiguresteadConfigError("must be a non-empty string", path);
45
+ return value.trim();
46
+ }
47
+
48
+ function color(value, path) {
49
+ if (typeof value !== "string" || !COLOR.test(value)) throw new FiguresteadConfigError("must be a #RRGGBB color", path);
50
+ return value.toUpperCase();
51
+ }
52
+
53
+ function normalizedTheme(key, source, path, { requireKey = false, aliases = false, runtime = false } = {}) {
54
+ object(source, path);
55
+ exactFields(source, runtime ? RUNTIME_THEME_FIELDS : aliases ? AUTHORING_THEME_FIELDS : THEME_FIELDS, path);
56
+ if (aliases) for (const [snake, camel] of AUTHORING_ALIASES) {
57
+ if (hasOwn(source, snake) && hasOwn(source, camel)) throw new FiguresteadConfigError("must use exactly one alias spelling", `${path}.${camel}`);
58
+ }
59
+ if (requireKey && !hasOwn(source, "key")) throw new FiguresteadConfigError("is required", `${path}.key`);
60
+ if (hasOwn(source, "key") && source.key !== key) throw new FiguresteadConfigError("must equal its theme map key", `${path}.key`);
61
+ const name = nonEmptyString(source.name, `${path}.name`);
62
+ const pick = (camel, snake) => aliases && hasOwn(source, snake) ? source[snake] : source[camel];
63
+ const theme = { key, name };
64
+ if (runtime && hasOwn(source, "mode")) {
65
+ if (!RUNTIME_MODES.has(source.mode)) throw new FiguresteadConfigError("must be paper, atlas, or talk", `${path}.mode`);
66
+ theme.mode = source.mode;
67
+ }
68
+ REQUIRED.forEach((token) => {
69
+ const snake = token === "summaryCore" ? "summary_core" : token;
70
+ theme[token] = color(pick(token, snake), `${path}.${aliases && hasOwn(source, snake) ? snake : token}`);
71
+ });
72
+ if (!Array.isArray(source.series) || !source.series.length) throw new FiguresteadConfigError("must be a non-empty color array", `${path}.series`);
73
+ theme.series = source.series.map((item, index) => color(item, `${path}.series[${index}]`));
74
+ for (const [camel, snake] of [["primaryEdge", "primary_edge"], ["summaryEdge", "summary_edge"]]) {
75
+ const value = pick(camel, snake);
76
+ if (value != null) theme[camel] = color(value, `${path}.${aliases && hasOwn(source, snake) ? snake : camel}`);
77
+ }
78
+ const seriesEdges = pick("seriesEdges", "series_edges");
79
+ const seriesEdgesPath = `${path}.${aliases && hasOwn(source, "series_edges") ? "series_edges" : "seriesEdges"}`;
80
+ if (seriesEdges != null) {
81
+ if (!Array.isArray(seriesEdges) || seriesEdges.length !== theme.series.length) throw new FiguresteadConfigError(`must contain exactly ${theme.series.length} colors`, seriesEdgesPath);
82
+ theme.seriesEdges = seriesEdges.map((item, index) => color(item, `${seriesEdgesPath}[${index}]`));
83
+ }
84
+ return theme;
85
+ }
86
+
87
+ function validatePack(input, { authoring }) {
88
+ const path = authoring ? "themeAuthoring" : "themePack";
89
+ object(input, path);
90
+ exactFields(input, new Set(authoring
91
+ ? ["schema_version", "schemaVersion", "name", "themes", "drafts"]
92
+ : ["schemaVersion", "name", "themes", "drafts"]), path);
93
+ if (authoring && hasOwn(input, "schema_version") && hasOwn(input, "schemaVersion")) {
94
+ throw new FiguresteadConfigError("must use exactly one version spelling", `${path}.schemaVersion`);
95
+ }
96
+ const versionKey = authoring && hasOwn(input, "schema_version") ? "schema_version" : "schemaVersion";
97
+ if (!hasOwn(input, versionKey) || input[versionKey] !== THEME_PACK_VERSION) {
98
+ throw new FiguresteadConfigError(`expected ${THEME_PACK_VERSION}`, `${path}.${versionKey}`);
99
+ }
100
+ const name = nonEmptyString(input.name, `${path}.name`);
101
+ object(input.themes, `${path}.themes`);
102
+ const keys = Object.keys(input.themes);
103
+ if (!keys.length) throw new FiguresteadConfigError("must contain at least one active theme", `${path}.themes`);
104
+ for (const key of keys) if (!THEME_KEY.test(key)) throw new FiguresteadConfigError("must match ^[a-z][a-z0-9_]*$", `${path}.themes.${key}`);
105
+ const themes = Object.fromEntries(keys.sort().map((key) => [key, normalizedTheme(
106
+ key, input.themes[key], `${path}.themes.${key}`,
107
+ { requireKey: !authoring, aliases: authoring },
108
+ )]));
109
+ const drafts = input.drafts ?? {}; object(drafts, `${path}.drafts`);
110
+ return { schemaVersion: THEME_PACK_VERSION, name, themes, drafts: cloneValue(drafts) };
111
+ }
112
+
113
+ export function validateAuthoredThemePack(input) {
114
+ return validatePack(input, { authoring: true });
115
+ }
116
+
117
+ export function validateThemePack(input) {
118
+ return validatePack(input, { authoring: false });
119
+ }
120
+
121
+ /** Legacy mapping-only compatibility normalization; not an official file loader. */
122
+ export function normalizeThemePackLenient(input) {
123
+ object(input, "themePackLenient");
124
+ const version = input.schemaVersion ?? input.schema_version;
125
+ if (version !== THEME_PACK_VERSION) throw new FiguresteadConfigError(`expected ${THEME_PACK_VERSION}`, "themePackLenient.schemaVersion");
126
+ object(input.themes, "themePackLenient.themes");
127
+ const keys = Object.keys(input.themes);
128
+ if (!keys.length) throw new FiguresteadConfigError("must contain at least one active theme", "themePackLenient.themes");
129
+ const themes = Object.fromEntries(keys.sort().map((key) => {
130
+ const source = input.themes[key];
131
+ object(source, `themePackLenient.themes.${key}`);
132
+ const value = { ...source, key, name: source.name ?? key };
133
+ for (const [snake, camel] of AUTHORING_ALIASES) if (!hasOwn(value, camel) && hasOwn(value, snake)) value[camel] = value[snake];
134
+ return [key, normalizedTheme(key, Object.fromEntries(Object.entries(value).filter(([field]) => RUNTIME_THEME_FIELDS.has(field))), `themePackLenient.themes.${key}`, { runtime: true })];
135
+ }));
136
+ const drafts = input.drafts ?? {}; object(drafts, "themePackLenient.drafts");
137
+ return { schemaVersion: THEME_PACK_VERSION, name: String(input.name || "Figurestead theme pack"), themes, drafts: cloneValue(drafts) };
138
+ }
139
+
140
+ function normalizeRuntimeTheme(key, source, path) {
141
+ return normalizedTheme(key, source, path, { runtime: true });
142
+ }
143
+
144
+ function canonicalMap(value, path) {
145
+ if (Array.isArray(value)) {
146
+ if (value.length < 5) throw new FiguresteadConfigError("must contain at least five canonical colors", path);
147
+ return Object.fromEntries(value.map((item, index) => [String(index), color(item, `${path}[${index}]`)]));
148
+ }
149
+ object(value, path);
150
+ const entries = Object.entries(value);
151
+ if (entries.length < 5) throw new FiguresteadConfigError("must contain at least five canonical colors", path);
152
+ return Object.fromEntries(entries.map(([key, item]) => [key, color(item, `${path}.${key}`)]));
153
+ }
154
+
155
+ function paletteColor(value, canonical, path) {
156
+ if (typeof value !== "string") throw new FiguresteadConfigError("must be a color or canonical reference", path);
157
+ if (COLOR.test(value)) return color(value, path);
158
+ const key = value.replace(/^\$?canonical\./, "");
159
+ if (!canonical[key]) throw new FiguresteadConfigError("references an unknown canonical color", path);
160
+ return canonical[key];
161
+ }
162
+
163
+ function deriveRole(roles, key, value, derived) {
164
+ if (roles[key]) return roles[key];
165
+ derived.push({ token: key, method: value.method, sources: value.sources });
166
+ return value.color;
167
+ }
168
+
169
+ function compilePalette(key, source, path, mode = "atlas") {
170
+ object(source, path);
171
+ const canonical = canonicalMap(source.canonical, `${path}.canonical`), modeSource = source.modes?.[mode] ?? {};
172
+ object(source.roles ?? {}, `${path}.roles`); object(modeSource, `${path}.modes.${mode}`);
173
+ const raw = { ...(source.roles ?? {}), ...modeSource };
174
+ const roles = Object.fromEntries(Object.entries(raw).map(([role, value]) => [role, paletteColor(value, canonical, `${path}.roles.${role}`)]));
175
+ const modeRoles = Object.fromEntries(Object.entries(modeSource).map(([role, value]) => [role, paletteColor(value, canonical, `${path}.modes.${mode}.${role}`)]));
176
+ const values = Object.values(canonical), field = mode === "paper" ? (modeRoles.field ?? "#FFFFFF") : (roles.field ?? values[0]);
177
+ const panel = mode === "paper" ? (modeRoles.panel ?? "#F7F7F5") : (roles.panel ?? values[1]);
178
+ const primary = roles.primary ?? values[2], label = mode === "paper" ? (modeRoles.label ?? "#1C2422") : (roles.label ?? values[3]);
179
+ const summaryCore = roles.summaryCore ?? values[4], derived = [];
180
+ const theme = {
181
+ key, name: String(source.name ?? key), field, panel, primary, label, summaryCore,
182
+ grid: deriveRole(roles, "grid", { color: mixHex(panel, label, mode === "paper" ? 0.17 : 0.2), method: "mix", sources: ["panel", "label"] }, derived),
183
+ spine: deriveRole(roles, "spine", { color: mixHex(panel, label, 0.34), method: "mix", sources: ["panel", "label"] }, derived),
184
+ secondary: deriveRole(roles, "secondary", { color: mixHex(label, panel, 0.3), method: "mix", sources: ["label", "panel"] }, derived),
185
+ faint: deriveRole(roles, "faint", { color: mixHex(label, field, 0.58), method: "mix", sources: ["label", "field"] }, derived),
186
+ warm: deriveRole(roles, "warm", { color: summaryCore, method: "alias", sources: ["summaryCore"] }, derived),
187
+ };
188
+ const qualitative = source.qualitative ?? values.slice(2);
189
+ if (!Array.isArray(qualitative) || !qualitative.length) throw new FiguresteadConfigError("must be a non-empty array", `${path}.qualitative`);
190
+ theme.series = qualitative.map((item, index) => paletteColor(item, canonical, `${path}.qualitative[${index}]`));
191
+ if (source.edges != null) {
192
+ if (!Array.isArray(source.edges) || source.edges.length !== theme.series.length) throw new FiguresteadConfigError(`must contain exactly ${theme.series.length} colors`, `${path}.edges`);
193
+ theme.seriesEdges = source.edges.map((item, index) => paletteColor(item, canonical, `${path}.edges[${index}]`));
194
+ }
195
+ const normalizedTheme = { ...normalizeRuntimeTheme(key, theme, path), mode };
196
+ const paperResolution = mode === "paper" ? resolvePaperTheme(normalizedTheme) : null;
197
+ return { theme: paperResolution?.theme ?? normalizedTheme, canonical, scales: {
198
+ qualitative: [...theme.series],
199
+ sequential: (source.sequential ?? [panel, primary, summaryCore]).map((item, index) => paletteColor(item, canonical, `${path}.sequential[${index}]`)),
200
+ diverging: (source.diverging ?? [primary, panel, summaryCore]).map((item, index) => paletteColor(item, canonical, `${path}.diverging[${index}]`)),
201
+ }, derived, ...(paperResolution ? { paperReport: paperResolution.report } : {}) };
202
+ }
203
+
204
+ export function validatePalettePack(input) {
205
+ object(input, "palettePack");
206
+ if (input.schemaVersion !== PALETTE_PACK_VERSION) throw new FiguresteadConfigError(`expected ${PALETTE_PACK_VERSION}`, "palettePack.schemaVersion");
207
+ object(input.palettes, "palettePack.palettes");
208
+ const keys = Object.keys(input.palettes).sort();
209
+ if (!keys.length) throw new FiguresteadConfigError("must contain at least one palette", "palettePack.palettes");
210
+ const palettes = Object.fromEntries(keys.map((key) => [key, {
211
+ atlas: compilePalette(key, input.palettes[key], `palettePack.palettes.${key}`, "atlas"),
212
+ paper: compilePalette(key, input.palettes[key], `palettePack.palettes.${key}`, "paper"),
213
+ talk: compilePalette(key, input.palettes[key], `palettePack.palettes.${key}`, "talk"),
214
+ }]));
215
+ return { schemaVersion: PALETTE_PACK_VERSION, name: String(input.name || "Figurestead palette pack"), palettes };
216
+ }
217
+
218
+ export function resolvePalette(pack, key, options = {}) {
219
+ const normalized = validatePalettePack(pack), mode = options.mode ?? "atlas";
220
+ if (!normalized.palettes[key]) throw new FiguresteadConfigError(`unknown palette; choose ${Object.keys(normalized.palettes).join(", ")}`, `palettePack.palettes.${key}`);
221
+ if (!normalized.palettes[key][mode]) throw new FiguresteadConfigError("mode must be paper, atlas, or talk", "palettePack.mode");
222
+ return cloneValue(normalized.palettes[key][mode]);
223
+ }
224
+
225
+ export function resolveTheme(pack, key) {
226
+ const normalized = validateThemePack(pack);
227
+ if (normalized.drafts[key] && !normalized.themes[key]) throw new FiguresteadConfigError("is a disabled draft and cannot be resolved", `themePack.drafts.${key}`);
228
+ if (!normalized.themes[key]) throw new FiguresteadConfigError(`unknown active theme; choose ${Object.keys(normalized.themes).join(", ")}`, `themePack.themes.${key}`);
229
+ return cloneValue(normalized.themes[key]);
230
+ }
231
+
232
+ export function applyTheme(contract, theme) {
233
+ const result = cloneValue(contract);
234
+ result.theme = normalizeRuntimeTheme(theme.key, theme, "theme");
235
+ return result;
236
+ }
237
+
238
+ /** Resolve authored evidence onto the selected application surface. */
239
+ export function themeForProfile(theme, profile = "atlas") {
240
+ const source = normalizeRuntimeTheme(theme.key, theme, "theme");
241
+ return cloneValue(themeResolutionForProfile(source, profile).theme);
242
+ }
243
+
244
+ export async function loadThemePack(url, options = {}) {
245
+ const response = await fetch(url, options);
246
+ if (!response.ok) throw new FiguresteadConfigError(`request returned ${response.status}`, "themePack.url");
247
+ return validateThemePack(await response.json());
248
+ }
249
+
250
+ export function contrastRatio(left, right) {
251
+ return colorContrast(left, right);
252
+ }
253
+
254
+ export function contrastAudit(theme) {
255
+ if (theme.mode === "paper") return auditPaperTheme(theme).findings;
256
+ const findings = [];
257
+ for (const [surfaceName, surface] of [["field", theme.field], ["panel", theme.panel]]) {
258
+ for (const [token, minimum] of [["label", 4.5], ["secondary", 4.5], ["primary", 3], ["summaryCore", 3]]) {
259
+ const ratio = contrastRatio(theme[token], surface);
260
+ if (ratio < minimum) findings.push({ level: "warning", token, surface: surfaceName, ratio: Number(ratio.toFixed(2)), minimum });
261
+ }
262
+ }
263
+ theme.series.forEach((item, index) => {
264
+ const ratio = contrastRatio(item, theme.panel);
265
+ if (ratio < 3 && !theme.seriesEdges?.[index]) findings.push({ level: "warning", token: `series[${index}]`, surface: "panel", ratio: Number(ratio.toFixed(2)), minimum: 3 });
266
+ });
267
+ return findings;
268
+ }