@cueplusplus/theme-base 1.0.2

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/dist/index.mjs ADDED
@@ -0,0 +1,393 @@
1
+ import tokensBase from "@cueplusplus/tokens/base.json" with { type: "json" };
2
+ import { COLOR_CONTRACT, DENSITIES, FONTS, FONT_TOKENS, GEOMETRY_CONTRACT, MODES } from "@cueplusplus/tokens";
3
+ //#region src/contract.ts
4
+ /** The manifest format this package reads. Bumped only for a breaking shape change. */
5
+ const MANIFEST_SCHEMA_VERSION = 1;
6
+ /**
7
+ * What may be interpolated into `[data-theme="…"]`. The same grammar
8
+ * `@cueplusplus/ui/theming`'s serializer has always used: an attribute selector
9
+ * is the one place a theme name is written into CSS, and a name that could
10
+ * close the quote there is not a name.
11
+ */
12
+ const THEME_NAME_PATTERN = /^[a-z][a-z0-9-]*$/i;
13
+ /**
14
+ * The two halves of the shared monospace token, and the resolution of them.
15
+ *
16
+ * `--cue-font-mono` is owned jointly by two axes that land on **different
17
+ * elements**: a provider stamps `data-theme` on a `<div>` inside the `<html>`
18
+ * the pre-paint script stamped `data-font` on, and for an inherited property the
19
+ * nearer declaration wins whatever the stylesheet's order is. So neither axis
20
+ * writes the shared token — the theme publishes `--cue-font-theme-mono`, a
21
+ * pairing publishes `--cue-font-pairing-mono`, and every block that owns a half
22
+ * restates `--cue-font-mono` as the `var()` chain below, pairing first.
23
+ *
24
+ * Three packages write CSS carrying that chain — `tokens`' own build,
25
+ * `theme-tools`' emitter and `@cueplusplus/ui`'s serializer — and none of them
26
+ * imports the others, so until this release each spelled it for itself and
27
+ * nothing compared the three. `tokens` still needs the literal to emit
28
+ * `axes.css`, so it stays the author and publishes the strings in `base.json`;
29
+ * this package re-exports them because it is the one every theme-side consumer
30
+ * already depends on. `tokens/test/fonts.test.mjs` holds the emitted stylesheet
31
+ * to the same values, so the data and the CSS cannot come apart either.
32
+ */
33
+ const PAIRING_MONO = tokensBase.fonts.handshake.pairingMono;
34
+ /** The theme axis's half. See {@link PAIRING_MONO}. */
35
+ const THEME_MONO = tokensBase.fonts.handshake.themeMono;
36
+ /** What every block that owns a half resolves `--cue-font-mono` to. See {@link PAIRING_MONO}. */
37
+ const RESOLVED_MONO = tokensBase.fonts.handshake.resolvedMono;
38
+ //#endregion
39
+ //#region src/validate.ts
40
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
41
+ /**
42
+ * What each closed object may carry.
43
+ *
44
+ * `manifest.schema.json` writes `additionalProperties: false` on every object it
45
+ * describes, so this side has to refuse the same keys. A manifest that
46
+ * `ThemeProvider` accepts and a publishing tool rejects is exactly the
47
+ * divergence the two files exist to prevent, and an unknown key is the cheapest
48
+ * way to arrive at one — it is almost always a typo for a field that matters.
49
+ */
50
+ const MANIFEST_FIELDS = [
51
+ "schemaVersion",
52
+ "name",
53
+ "package",
54
+ "extends",
55
+ "mode",
56
+ "supportsLight",
57
+ "colors",
58
+ "fonts",
59
+ "densities",
60
+ "fontPairings",
61
+ "contrast"
62
+ ];
63
+ const COLORS_FIELDS = ["dark", "light"];
64
+ const DENSITIES_FIELDS = [
65
+ "adds",
66
+ "overrides",
67
+ "default"
68
+ ];
69
+ const FONT_PAIRINGS_FIELDS = [
70
+ "adds",
71
+ "overrides",
72
+ "default"
73
+ ];
74
+ const CONTRAST_FIELDS = [
75
+ "passes",
76
+ "advisories",
77
+ "waived",
78
+ "digest"
79
+ ];
80
+ /**
81
+ * Every problem with a manifest, in document order.
82
+ *
83
+ * Hand-written rather than run through a JSON Schema library because this
84
+ * executes inside `ThemeProvider` in development and a schema engine is not a
85
+ * dependency a component library should carry for a check that runs once. The
86
+ * schema file beside it says the same rules in the language other tools read,
87
+ * and `test/validate.test.ts` holds the two to identical verdicts — on the six
88
+ * fixtures, and on a few hundred generated mutations of the valid one.
89
+ *
90
+ * Every rule is a sentence a theme author can act on: the message names the
91
+ * token that is missing, the rung that collides, the field that contradicts.
92
+ *
93
+ * The three names a theme brings — its own, its added rungs, its added pairings
94
+ * — are each held to {@link THEME_NAME_PATTERN}, because each one is written
95
+ * into an attribute selector (`[data-theme="…"]`, `[data-density="…"]`,
96
+ * `[data-font="…"]`) and a name that could close that quote is not a name. That
97
+ * pattern is case-insensitive, so a base rung or pairing is reserved in any
98
+ * case: `Compact` beside `compact` would be two rungs the CSS cannot tell apart.
99
+ */
100
+ function validateManifest(value) {
101
+ const problems = [];
102
+ const fail = (path, message) => problems.push({
103
+ path,
104
+ message
105
+ });
106
+ if (!isObject(value)) return [{
107
+ path: "$",
108
+ message: "a manifest is an object"
109
+ }];
110
+ /** The root's children are named without a prefix, as every other path here is. */
111
+ const onlyFields = (path, block, fields) => {
112
+ for (const key of Object.keys(block)) if (!fields.includes(key)) fail(path === "" ? key : `${path}.${key}`, "not a known field");
113
+ };
114
+ onlyFields("", value, MANIFEST_FIELDS);
115
+ if (value.schemaVersion !== 1) fail("schemaVersion", `expected 1`);
116
+ if (typeof value.name !== "string" || !THEME_NAME_PATTERN.test(value.name)) fail("name", "must match THEME_NAME_PATTERN (/^[a-z][a-z0-9-]*$/i): it becomes data-theme");
117
+ for (const key of ["package", "extends"]) if (typeof value[key] !== "string" || value[key] === "") fail(key, "required string");
118
+ if (value.mode !== "delta" && value.mode !== "complete") fail("mode", "must be \"delta\" or \"complete\"");
119
+ if (typeof value.supportsLight !== "boolean") fail("supportsLight", "required boolean");
120
+ /**
121
+ * Absent and wrong are two different problems, and the message says which.
122
+ *
123
+ * A token that is present but is a number reported as "missing" sends its
124
+ * author looking for a name that is right there — so presence answers at the
125
+ * block, naming the token, and type answers at the token's own path in the
126
+ * same words the geometry and font checks use. The schema already requires a
127
+ * non-empty string in both cases, so the split changes what a reader is told,
128
+ * not what either validator accepts.
129
+ */
130
+ const colorBlock = (path, block) => {
131
+ if (!isObject(block)) return fail(path, "a colour block is an object");
132
+ for (const name of COLOR_CONTRACT) if (!(name in block)) fail(path, `missing colour token: ${name}`);
133
+ for (const [name, token] of Object.entries(block)) if (!COLOR_CONTRACT.includes(name)) fail(`${path}.${name}`, "not a colour token");
134
+ else if (typeof token !== "string" || token === "") fail(`${path}.${name}`, "must be a non-empty string");
135
+ };
136
+ if (!isObject(value.colors)) fail("colors", "required object with dark, and light when supportsLight");
137
+ else {
138
+ onlyFields("colors", value.colors, COLORS_FIELDS);
139
+ colorBlock("colors.dark", value.colors.dark);
140
+ if (value.supportsLight === true && value.colors.light === void 0) fail("colors.light", "supportsLight is true, so a light block is required");
141
+ if (value.supportsLight === false && value.colors.light !== void 0) fail("colors.light", "supportsLight is false, so a light block must be absent");
142
+ if (value.colors.light !== void 0) colorBlock("colors.light", value.colors.light);
143
+ }
144
+ /** The three stacks a pairing may name, each a non-empty string when present. */
145
+ const fontStacks = (path, stacks) => {
146
+ for (const [name, stack] of Object.entries(stacks)) if (!FONT_TOKENS.includes(name)) fail(`${path}.${name}`, "not a known field");
147
+ else if (typeof stack !== "string" || stack === "") fail(`${path}.${name}`, "must be a non-empty string");
148
+ };
149
+ if (!isObject(value.fonts)) fail("fonts", "required object");
150
+ else fontStacks("fonts", value.fonts);
151
+ const geometry = (path, block, complete) => {
152
+ if (!isObject(block)) return fail(path, "a geometry block is an object");
153
+ if (complete) {
154
+ for (const name of GEOMETRY_CONTRACT) if (!(name in block)) fail(path, `missing geometry token: ${name}`);
155
+ }
156
+ for (const [name, token] of Object.entries(block)) if (!GEOMETRY_CONTRACT.includes(name)) fail(`${path}.${name}`, "not a geometry token");
157
+ else if (typeof token !== "string" || token === "") fail(`${path}.${name}`, "must be a non-empty string");
158
+ };
159
+ if (!isObject(value.densities)) fail("densities", "required object with adds and overrides");
160
+ else {
161
+ onlyFields("densities", value.densities, DENSITIES_FIELDS);
162
+ const { adds, overrides } = value.densities;
163
+ if (!isObject(adds)) fail("densities.adds", "required object");
164
+ else for (const [rung, block] of Object.entries(adds)) {
165
+ if (DENSITIES.includes(rung.toLowerCase())) fail(`densities.adds.${rung}`, `${rung} is a base rung (names are case-insensitive); retune it under overrides, do not add it`);
166
+ else if (!THEME_NAME_PATTERN.test(rung)) fail(`densities.adds.${rung}`, "a rung name must match THEME_NAME_PATTERN");
167
+ geometry(`densities.adds.${rung}`, block, true);
168
+ }
169
+ if (!isObject(overrides)) fail("densities.overrides", "required object");
170
+ else for (const [rung, block] of Object.entries(overrides)) {
171
+ if (!DENSITIES.includes(rung)) fail(`densities.overrides.${rung}`, `${rung} is not a base rung; a theme may only override the five`);
172
+ geometry(`densities.overrides.${rung}`, block, false);
173
+ }
174
+ if (value.densities.default !== void 0 && typeof value.densities.default !== "string") fail("densities.default", "must be a rung name");
175
+ }
176
+ if (!isObject(value.fontPairings)) fail("fontPairings", "required object with adds and overrides");
177
+ else {
178
+ onlyFields("fontPairings", value.fontPairings, FONT_PAIRINGS_FIELDS);
179
+ const { adds, overrides } = value.fontPairings;
180
+ if (!isObject(adds)) fail("fontPairings.adds", "required object");
181
+ else for (const [name, stacks] of Object.entries(adds)) {
182
+ if (FONTS.includes(name.toLowerCase())) fail(`fontPairings.adds.${name}`, `${name} is a base pairing (names are case-insensitive); retune it under overrides`);
183
+ else if (!THEME_NAME_PATTERN.test(name)) fail(`fontPairings.adds.${name}`, "a pairing name must match THEME_NAME_PATTERN");
184
+ if (!isObject(stacks) || typeof stacks["font-sans"] !== "string") fail(`fontPairings.adds.${name}`, "every pairing names a sans");
185
+ if (isObject(stacks)) fontStacks(`fontPairings.adds.${name}`, stacks);
186
+ }
187
+ if (!isObject(overrides)) fail("fontPairings.overrides", "required object");
188
+ else for (const [name, stacks] of Object.entries(overrides)) {
189
+ if (!FONTS.includes(name)) fail(`fontPairings.overrides.${name}`, `${name} is not a base pairing`);
190
+ if (!isObject(stacks)) fail(`fontPairings.overrides.${name}`, "a font pairing is an object");
191
+ else fontStacks(`fontPairings.overrides.${name}`, stacks);
192
+ }
193
+ if (value.fontPairings.default !== void 0 && typeof value.fontPairings.default !== "string") fail("fontPairings.default", "must be a pairing name");
194
+ }
195
+ if (!isObject(value.contrast)) fail("contrast", "required object");
196
+ else {
197
+ onlyFields("contrast", value.contrast, CONTRAST_FIELDS);
198
+ if (typeof value.contrast.passes !== "boolean") fail("contrast.passes", "required boolean");
199
+ if (!Number.isInteger(value.contrast.advisories) || value.contrast.advisories < 0) fail("contrast.advisories", "required non-negative integer");
200
+ if (!Number.isInteger(value.contrast.waived) || value.contrast.waived < 0) fail("contrast.waived", "required non-negative integer");
201
+ if (typeof value.contrast.digest !== "string" || !value.contrast.digest.startsWith("sha256-")) fail("contrast.digest", "required \"sha256-…\" string");
202
+ if (typeof value.contrast.passes === "boolean" && Number.isInteger(value.contrast.waived) && value.contrast.waived >= 0) {
203
+ if (value.contrast.passes !== (value.contrast.waived === 0)) fail("contrast.passes", value.contrast.passes ? "cannot be true beside a non-zero contrast.waived: a waived pair is a failed pair that was named, not one that passed" : "cannot be false beside contrast.waived 0: a build refuses every failure no waiver names, so a manifest that fails has waived at least one");
204
+ }
205
+ }
206
+ return problems;
207
+ }
208
+ /** {@link validateManifest}, as a throw. */
209
+ function assertManifest(value) {
210
+ const problems = validateManifest(value);
211
+ if (problems.length > 0) throw new TypeError(`not a valid theme manifest:\n${problems.map((p) => ` ${p.path}: ${p.message}`).join("\n")}`);
212
+ }
213
+ //#endregion
214
+ //#region src/base.dark.tokens.json
215
+ var base_dark_tokens_default = {
216
+ $description: "The blank abstract theme, dark mode: cue's lightness ladder with the chroma removed, one desaturated accent, and cue's status tones kept because signals must stay separable. Derived by scripts/derive-base-palette.mjs; edit the script, not this file.",
217
+ color: {
218
+ "$type": "color",
219
+ "bg": { "$value": "#0a0a0a" },
220
+ "sunken": { "$value": "#000000" },
221
+ "surface-1": { "$value": "#121212" },
222
+ "surface-2": { "$value": "#1a1a1a" },
223
+ "surface-3": { "$value": "#242424" },
224
+ "fg": { "$value": "#f5f5f5" },
225
+ "fg-muted": { "$value": "rgba(255,255,255,0.55)" },
226
+ "fg-subtle": { "$value": "rgba(255,255,255,0.35)" },
227
+ "border": { "$value": "rgba(255,255,255,0.10)" },
228
+ "border-strong": { "$value": "rgba(255,255,255,0.25)" },
229
+ "border-overlay": { "$value": "rgba(255,255,255,0.25)" },
230
+ "accent": { "$value": "#8a93a6" },
231
+ "accent-hover": { "$value": "#9aa3b6" },
232
+ "accent-fg": { "$value": "#0a0a0a" },
233
+ "ok": { "$value": "oklch(0.72 0.17 149)" },
234
+ "busy": { "$value": "oklch(0.78 0.13 220)" },
235
+ "warn": { "$value": "oklch(0.8 0.17 75)" },
236
+ "warn-fg": { "$value": "#000000" },
237
+ "danger": { "$value": "#ff5f57" },
238
+ "danger-fg": { "$value": "#000000" },
239
+ "info": { "$value": "oklch(0.75 0.12 252)" },
240
+ "stream": { "$value": "oklch(0.707 0.165 254.624)" },
241
+ "selection": { "$value": "rgba(255,255,255,0.20)" },
242
+ "focus": { "$value": "#8a93a6" },
243
+ "data-ground": { "$value": "#000000" },
244
+ "scrim": { "$value": "rgba(0,0,0,0.55)" }
245
+ }
246
+ };
247
+ //#endregion
248
+ //#region src/base.light.tokens.json
249
+ var base_light_tokens_default = {
250
+ $description: "The blank abstract theme, light mode: cue's lightness ladder with the chroma removed, one desaturated accent, and cue's status tones kept because signals must stay separable. Derived by scripts/derive-base-palette.mjs; edit the script, not this file.",
251
+ color: {
252
+ "$type": "color",
253
+ "bg": { "$value": "#fcfcfc" },
254
+ "sunken": { "$value": "#f0f0f0" },
255
+ "surface-1": { "$value": "#ffffff" },
256
+ "surface-2": { "$value": "#f6f6f6" },
257
+ "surface-3": { "$value": "#ededed" },
258
+ "fg": { "$value": "#0a0a0a" },
259
+ "fg-muted": { "$value": "rgba(0,0,0,0.55)" },
260
+ "fg-subtle": { "$value": "rgba(0,0,0,0.35)" },
261
+ "border": { "$value": "rgba(0,0,0,0.10)" },
262
+ "border-strong": { "$value": "rgba(0,0,0,0.25)" },
263
+ "border-overlay": { "$value": "rgba(0,0,0,0.25)" },
264
+ "accent": { "$value": "#3f4759" },
265
+ "accent-hover": { "$value": "#333a4a" },
266
+ "accent-fg": { "$value": "#ffffff" },
267
+ "ok": { "$value": "oklch(0.53 0.14 149)" },
268
+ "busy": { "$value": "oklch(0.54 0.1 225)" },
269
+ "warn": { "$value": "oklch(0.55 0.115 70)" },
270
+ "warn-fg": { "$value": "#fcfcfc" },
271
+ "danger": { "$value": "#d03030" },
272
+ "danger-fg": { "$value": "#fcfcfc" },
273
+ "info": { "$value": "oklch(0.55 0.16 258)" },
274
+ "stream": { "$value": "oklch(0.55 0.21 259.815)" },
275
+ "selection": { "$value": "rgba(0,0,0,0.20)" },
276
+ "focus": { "$value": "#3f4759" },
277
+ "data-ground": { "$value": "#000000" },
278
+ "scrim": { "$value": "rgba(0,0,0,0.55)" }
279
+ }
280
+ };
281
+ //#endregion
282
+ //#region src/resolve.ts
283
+ /**
284
+ * Base defaults ⊕ a theme ⊕ a density ⊕ a pairing → one complete token map.
285
+ *
286
+ * Pure, and the one place the layering rule is written down in code rather
287
+ * than in a stylesheet. A manifest's colours are already resolved (the
288
+ * manifest format guarantees it), so colour is a lookup; geometry and fonts
289
+ * are where the layering happens, and they happen in the same order the
290
+ * cascade applies them: base rung, then the theme's override of it, or the
291
+ * theme's added rung whole; base pairing, then the theme's font override
292
+ * underneath it, because a pairing somebody picked wins the mono.
293
+ *
294
+ * `null` is the blank base itself — what an app with no theme registered
295
+ * resolves to, and what a theme's deltas were diffed against. Its palette is
296
+ * read from the two committed DTCG files `build.mjs` also reads, not from
297
+ * `dist/base.json`: the same numbers, with no build to run first.
298
+ */
299
+ /** DTCG in, a flat block out — `build.mjs`'s `block()`, so both read one palette. */
300
+ const block = (tokens) => Object.fromEntries(Object.entries(tokens.color).filter(([name]) => !name.startsWith("$")).map(([name, token]) => [name, token.$value]));
301
+ const BLANK = {
302
+ dark: block(base_dark_tokens_default),
303
+ light: block(base_light_tokens_default)
304
+ };
305
+ const BASE_DENSITIES = tokensBase.densities;
306
+ const BASE_FONTS = tokensBase.fonts;
307
+ const BASE_STACKS = {
308
+ "font-sans": tokensBase.base["font-sans"],
309
+ "font-mono": tokensBase.base["font-mono"],
310
+ "font-display": tokensBase.base["font-display"]
311
+ };
312
+ function pickDensity(manifest, wanted) {
313
+ const known = /* @__PURE__ */ new Set([...DENSITIES, ...Object.keys(manifest?.densities.adds ?? {})]);
314
+ if (wanted !== void 0 && known.has(wanted)) return wanted;
315
+ const preferred = manifest?.densities.default;
316
+ if (preferred !== void 0 && known.has(preferred)) return preferred;
317
+ return tokensBase.defaults.density;
318
+ }
319
+ function pickFont(manifest, wanted) {
320
+ const known = /* @__PURE__ */ new Set([...FONTS, ...Object.keys(manifest?.fontPairings.adds ?? {})]);
321
+ if (wanted !== void 0 && known.has(wanted)) return wanted;
322
+ const preferred = manifest?.fontPairings.default;
323
+ if (preferred !== void 0 && known.has(preferred)) return preferred;
324
+ return tokensBase.defaults.font;
325
+ }
326
+ function geometryFor(manifest, density) {
327
+ const added = manifest?.densities.adds[density];
328
+ if (added !== void 0) return added;
329
+ const base = BASE_DENSITIES[density];
330
+ if (base === void 0) throw new Error(`unknown density: ${density}`);
331
+ const override = manifest?.densities.overrides[density];
332
+ return override === void 0 ? base : {
333
+ ...base,
334
+ ...override
335
+ };
336
+ }
337
+ function fontsFor(manifest, font) {
338
+ const added = manifest?.fontPairings.adds[font];
339
+ const base = BASE_FONTS[font];
340
+ const pairing = added ?? base;
341
+ if (pairing === void 0) throw new Error(`unknown font pairing: ${font}`);
342
+ const override = manifest?.fontPairings.overrides[font] ?? {};
343
+ const themeMono = manifest?.fonts["font-mono"];
344
+ const mono = override["font-mono"] ?? pairing["font-mono"] ?? themeMono ?? BASE_STACKS["font-mono"];
345
+ return {
346
+ "font-sans": override["font-sans"] ?? pairing["font-sans"],
347
+ "font-display": override["font-display"] ?? pairing["font-display"] ?? override["font-sans"] ?? pairing["font-sans"],
348
+ "font-mono": mono
349
+ };
350
+ }
351
+ function resolve(manifest, options) {
352
+ const density = pickDensity(manifest, options.density);
353
+ const font = pickFont(manifest, options.font);
354
+ return {
355
+ colors: manifest === null ? BLANK[options.mode] : options.mode === "light" && manifest.colors.light !== void 0 ? manifest.colors.light : manifest.colors.dark,
356
+ geometry: geometryFor(manifest, density),
357
+ fonts: fontsFor(manifest, font),
358
+ density,
359
+ font
360
+ };
361
+ }
362
+ /** The rungs an app may offer under the active theme: base first, then the theme's additions. */
363
+ function resolveDensities(manifests, active) {
364
+ const manifest = manifests.find((m) => m.name === active) ?? null;
365
+ const base = DENSITIES.map((name) => ({
366
+ name,
367
+ owner: "base",
368
+ geometry: geometryFor(manifest, name)
369
+ }));
370
+ const added = manifest === null ? [] : Object.entries(manifest.densities.adds).map(([name, geometry]) => ({
371
+ name,
372
+ owner: manifest.name,
373
+ geometry
374
+ }));
375
+ return [...base, ...added];
376
+ }
377
+ /** The pairings an app may offer under the active theme: the same rule, one axis over. */
378
+ function resolveFonts(manifests, active) {
379
+ const manifest = manifests.find((m) => m.name === active) ?? null;
380
+ const base = FONTS.map((name) => ({
381
+ name,
382
+ owner: "base",
383
+ stacks: fontsFor(manifest, name)
384
+ }));
385
+ const added = manifest === null ? [] : Object.keys(manifest.fontPairings.adds).map((name) => ({
386
+ name,
387
+ owner: manifest.name,
388
+ stacks: fontsFor(manifest, name)
389
+ }));
390
+ return [...base, ...added];
391
+ }
392
+ //#endregion
393
+ export { COLOR_CONTRACT, DENSITIES, FONTS, FONT_TOKENS, GEOMETRY_CONTRACT, MANIFEST_SCHEMA_VERSION, MODES, PAIRING_MONO, RESOLVED_MONO, THEME_MONO, THEME_NAME_PATTERN, assertManifest, resolve, resolveDensities, resolveFonts, validateManifest };