@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.
@@ -0,0 +1,226 @@
1
+ import { converter, parse } from "culori";
2
+ //#region src/contrast.ts
3
+ /**
4
+ * WCAG contrast, and the eleven pairs a theme is judged on.
5
+ *
6
+ * A generator that only derived colours would be a colour toy: the reason to
7
+ * compute a palette instead of picking one is that the computer can then *check*
8
+ * it, on every build, against the one part of visual design that is not a matter
9
+ * of taste. So `createTheme()` never hands back a theme without a report, and
10
+ * the report is the deliverable — a failing pair is information, not an error to
11
+ * swallow.
12
+ *
13
+ * The maths is WCAG 2.x relative luminance (sRGB, linearised, 0.2126/0.7152/
14
+ * 0.0722), deliberately not APCA. APCA is better, and it is also not what
15
+ * anyone's compliance checklist says; a theme that has to survive an audit needs
16
+ * the number the audit will compute.
17
+ */
18
+ const toRgb = converter("rgb");
19
+ /** Non-text and large-text AA: UI components, graphics, status dots. */
20
+ const WCAG_AA_NON_TEXT = 3;
21
+ /** Body-text AA. The floor for anything a reader is expected to read. */
22
+ const WCAG_AA_TEXT = 4.5;
23
+ /** Body-text AAA. What this portfolio holds its primary ink to. */
24
+ const WCAG_AAA_TEXT = 7;
25
+ /** sRGB gamma decode, per WCAG's definition of relative luminance. */
26
+ function linearize(channel) {
27
+ return channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4;
28
+ }
29
+ /** Parse to sRGB, or say which value could not be read. */
30
+ function toSrgb(input, label = "colour") {
31
+ const color = toRgb(parse(input));
32
+ if (color === void 0) throw new TypeError(`${label} is not a colour this can measure: ${JSON.stringify(input)}`);
33
+ return color;
34
+ }
35
+ /** Source-over composite of `over` onto `under`, in (non-linear) sRGB. */
36
+ function composite(over, under) {
37
+ const alpha = over.alpha ?? 1;
38
+ if (alpha >= 1) return over;
39
+ return {
40
+ r: over.r * alpha + under.r * (1 - alpha),
41
+ g: over.g * alpha + under.g * (1 - alpha),
42
+ b: over.b * alpha + under.b * (1 - alpha)
43
+ };
44
+ }
45
+ function luminanceOf(color) {
46
+ return .2126 * linearize(color.r) + .7152 * linearize(color.g) + .0722 * linearize(color.b);
47
+ }
48
+ /**
49
+ * WCAG relative luminance: 0 for black, 1 for white.
50
+ *
51
+ * Alpha is ignored — a translucent colour has no luminance of its own, only one
52
+ * in front of something. {@link contrastRatio} is where that composite happens.
53
+ *
54
+ * @param color - Any CSS colour string.
55
+ * @returns Relative luminance in `[0, 1]`.
56
+ * @throws TypeError if the value is not a colour.
57
+ * @example
58
+ * relativeLuminance("#767676"); // → 0.1845…
59
+ */
60
+ function relativeLuminance(color) {
61
+ return luminanceOf(toSrgb(color));
62
+ }
63
+ /**
64
+ * WCAG contrast ratio between two colours, from 1:1 to 21:1.
65
+ *
66
+ * Symmetric, because a ratio has no direction — the arguments are named for
67
+ * readability, not for order. A **translucent foreground is composited over the
68
+ * background first**, which is not decoration: half this system's `fg-muted`
69
+ * tokens are white-alpha ink, and measuring them raw would report the ratio of
70
+ * pure white and pass everything.
71
+ *
72
+ * @param foreground - The ink. Composited over `background` if it has alpha.
73
+ * @param background - The ground. Its own alpha is ignored — nothing is behind it.
74
+ * @returns The ratio, `>= 1`.
75
+ * @throws TypeError naming whichever value could not be read.
76
+ * @example
77
+ * contrastRatio("#767676", "#ffffff"); // → 4.5422…
78
+ */
79
+ function contrastRatio(foreground, background) {
80
+ const ground = toSrgb(background, "background");
81
+ const [high, low] = [luminanceOf(composite(toSrgb(foreground, "foreground"), ground)), luminanceOf(ground)].toSorted((a, b) => b - a);
82
+ return (high + .05) / (low + .05);
83
+ }
84
+ /**
85
+ * The eleven pairs that decide whether a theme is usable, and the nine that
86
+ * warn about it.
87
+ *
88
+ * Not every pair in the palette: the ones a reader's comprehension actually
89
+ * depends on. Primary ink is held to AAA because it carries the prose; secondary
90
+ * ink and accent ink to AA because they carry labels; the five status colours
91
+ * and the live hue to the non-text AA floor because they are read as *signals*
92
+ * — a dot, a rim, a bar — never as body copy.
93
+ *
94
+ * `danger` and `warn` each appear twice, and they are the only tones that do.
95
+ * `ok`, `busy`, `info` and `stream` are *only* ever signals, so the non-text
96
+ * floor is the whole truth about them. The red and the amber are also grounds:
97
+ * they are the two tones this system fills a control with, and what they fill is
98
+ * the confirm on something irreversible and the confirm on something merely
99
+ * consequential. Text on either fill is text, so its ink is held to the text
100
+ * floor against the tone it sits on rather than against the page.
101
+ *
102
+ * `stream` is measured for the same reason `ok` is: `createTheme()` darkens it
103
+ * to this floor when it has to invent a light block, and a report that did not
104
+ * check what the derivation targets would be checking the wrong thing.
105
+ */
106
+ /**
107
+ * Every ground a control can paint a ring over.
108
+ *
109
+ * Module-local on purpose. It is the same set the preset gate has always walked,
110
+ * and it is a fact about where controls sit rather than part of the contract a
111
+ * consumer reads — exporting it would invite a caller to hold their own themes
112
+ * to a list this file is free to grow.
113
+ *
114
+ * None of the eleven required pairs involves `sunken` or any `surface-*` rung,
115
+ * which is the other half of why the ring went unmeasured: a consumer overriding
116
+ * `surfaces`, which `ThemeAnchors` invites, moves the grounds the ring is painted
117
+ * on without moving a single measured pair.
118
+ */
119
+ const RING_GROUNDS = [
120
+ "sunken",
121
+ "bg",
122
+ "surface-1",
123
+ "surface-2",
124
+ "surface-3"
125
+ ];
126
+ const CONTRAST_REQUIREMENTS = [
127
+ {
128
+ id: "fg/bg",
129
+ foreground: "fg",
130
+ background: "bg",
131
+ minimum: 7,
132
+ reason: "Primary ink carries the prose, and this portfolio holds it to AAA."
133
+ },
134
+ {
135
+ id: "fg-muted/bg",
136
+ foreground: "fg-muted",
137
+ background: "bg",
138
+ minimum: WCAG_AA_TEXT,
139
+ reason: "Secondary ink is still text: labels, captions, table headers."
140
+ },
141
+ {
142
+ id: "accent-fg/accent",
143
+ foreground: "accent-fg",
144
+ background: "accent",
145
+ minimum: WCAG_AA_TEXT,
146
+ reason: "The label on a solid accent button has to be readable."
147
+ },
148
+ {
149
+ id: "danger-fg/danger",
150
+ foreground: "danger-fg",
151
+ background: "danger",
152
+ minimum: WCAG_AA_TEXT,
153
+ reason: "The label on a solid destructive fill is read before something irreversible happens."
154
+ },
155
+ {
156
+ id: "warn-fg/warn",
157
+ foreground: "warn-fg",
158
+ background: "warn",
159
+ minimum: WCAG_AA_TEXT,
160
+ reason: "The label on a solid warning fill is read before a consequential press lands."
161
+ },
162
+ ...[
163
+ "ok",
164
+ "busy",
165
+ "warn",
166
+ "danger",
167
+ "info",
168
+ "stream"
169
+ ].map((status) => ({
170
+ id: `${status}/bg`,
171
+ foreground: status,
172
+ background: "bg",
173
+ minimum: 3,
174
+ reason: `The ${status} tone is read as a signal — dot, rim, bar — not as body copy.`
175
+ })),
176
+ ...["focus", "danger"].flatMap((ring) => RING_GROUNDS.filter((ground) => !(ring === "danger" && ground === "bg")).map((ground) => ({
177
+ id: `${ring}/${ground}`,
178
+ foreground: ring,
179
+ background: ground,
180
+ minimum: 3,
181
+ advisory: true,
182
+ reason: ring === "focus" ? "The focus ring is the only thing telling a keyboard user where they are (WCAG 1.4.11)." : "An invalid field's ring replaces its rim, so the ring itself carries the state."
183
+ })))
184
+ ];
185
+ /**
186
+ * Measure every requirement against every block handed in.
187
+ *
188
+ * A pair whose tokens are missing is **skipped**, not failed: the report says
189
+ * what it measured, and a caller checking a partial palette should not be told
190
+ * that a colour it never supplied is unreadable.
191
+ *
192
+ * @param inputs - One entry per block, dark first by convention.
193
+ * @returns The report.
194
+ * @example
195
+ * contrastReport([{ mode: "dark", tokens: { fg: "#fff", bg: "#000" } }]).passes; // → true
196
+ */
197
+ function contrastReport(inputs) {
198
+ const checks = [];
199
+ for (const { mode, tokens, provisional = false } of inputs) for (const requirement of CONTRAST_REQUIREMENTS) {
200
+ const foregroundValue = tokens[requirement.foreground];
201
+ const backgroundValue = tokens[requirement.background];
202
+ if (foregroundValue === void 0 || backgroundValue === void 0) continue;
203
+ const ratio = contrastRatio(foregroundValue, backgroundValue);
204
+ checks.push({
205
+ ...requirement,
206
+ mode,
207
+ foregroundValue,
208
+ backgroundValue,
209
+ ratio,
210
+ passes: ratio >= requirement.minimum,
211
+ provisional
212
+ });
213
+ }
214
+ const missed = checks.filter((check) => !check.passes);
215
+ const failures = missed.filter((check) => check.advisory !== true);
216
+ const advisories = missed.filter((check) => check.advisory === true);
217
+ return {
218
+ passes: failures.length === 0,
219
+ provisional: checks.some((check) => check.provisional),
220
+ checks,
221
+ failures,
222
+ advisories
223
+ };
224
+ }
225
+ //#endregion
226
+ export { CONTRAST_REQUIREMENTS, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport, relativeLuminance };
@@ -0,0 +1,182 @@
1
+ import { COLOR_CONTRACT, ColorToken, ColorToken as ColorToken$1, DENSITIES, Density as Density$1, FONTS, FONT_TOKENS, FontName as FontName$1, FontToken, GEOMETRY_CONTRACT, GeometryToken, GeometryToken as GeometryToken$1, MODES, Mode } from "@cueplusplus/tokens";
2
+ //#region src/contract.d.ts
3
+ /** The manifest format this package reads. Bumped only for a breaking shape change. */
4
+ declare const MANIFEST_SCHEMA_VERSION: 1;
5
+ /**
6
+ * What may be interpolated into `[data-theme="…"]`. The same grammar
7
+ * `@cueplusplus/ui/theming`'s serializer has always used: an attribute selector
8
+ * is the one place a theme name is written into CSS, and a name that could
9
+ * close the quote there is not a name.
10
+ */
11
+ declare const THEME_NAME_PATTERN: RegExp;
12
+ /**
13
+ * The two halves of the shared monospace token, and the resolution of them.
14
+ *
15
+ * `--cue-font-mono` is owned jointly by two axes that land on **different
16
+ * elements**: a provider stamps `data-theme` on a `<div>` inside the `<html>`
17
+ * the pre-paint script stamped `data-font` on, and for an inherited property the
18
+ * nearer declaration wins whatever the stylesheet's order is. So neither axis
19
+ * writes the shared token — the theme publishes `--cue-font-theme-mono`, a
20
+ * pairing publishes `--cue-font-pairing-mono`, and every block that owns a half
21
+ * restates `--cue-font-mono` as the `var()` chain below, pairing first.
22
+ *
23
+ * Three packages write CSS carrying that chain — `tokens`' own build,
24
+ * `theme-tools`' emitter and `@cueplusplus/ui`'s serializer — and none of them
25
+ * imports the others, so until this release each spelled it for itself and
26
+ * nothing compared the three. `tokens` still needs the literal to emit
27
+ * `axes.css`, so it stays the author and publishes the strings in `base.json`;
28
+ * this package re-exports them because it is the one every theme-side consumer
29
+ * already depends on. `tokens/test/fonts.test.mjs` holds the emitted stylesheet
30
+ * to the same values, so the data and the CSS cannot come apart either.
31
+ */
32
+ declare const PAIRING_MONO: string;
33
+ /** The theme axis's half. See {@link PAIRING_MONO}. */
34
+ declare const THEME_MONO: string;
35
+ /** What every block that owns a half resolves `--cue-font-mono` to. See {@link PAIRING_MONO}. */
36
+ declare const RESOLVED_MONO: string;
37
+ /**
38
+ * The theme registry: an empty interface a theme package augments.
39
+ *
40
+ * ```ts
41
+ * declare module "@cueplusplus/theme-base" {
42
+ * interface ThemeRegistry { "foo": typeof manifest }
43
+ * }
44
+ * ```
45
+ *
46
+ * Quoted, as `theme-tools` emits it: `THEME_NAME_PATTERN` allows a hyphen, and
47
+ * `acme-brand` unquoted is not an interface key TypeScript can parse.
48
+ *
49
+ * `ThemeName` is its keys, or `string` in an app that installed no theme —
50
+ * `keyof {}` is `never`, and a `never`-typed prop would make `ThemeProvider`
51
+ * unusable until the first theme arrived, which is the wrong first experience.
52
+ */
53
+ interface ThemeRegistry {}
54
+ type ThemeName = keyof ThemeRegistry extends never ? string : keyof ThemeRegistry;
55
+ /** The density ladder: the five base rungs, plus whatever a theme adds by augmentation. */
56
+ interface DensityRegistry extends Record<Density$1, true> {}
57
+ type Density = keyof DensityRegistry;
58
+ /** The font pairings: the eight base ones, plus whatever a theme adds by augmentation. */
59
+ interface FontRegistry extends Record<FontName$1, true> {}
60
+ type FontName = keyof FontRegistry;
61
+ /** A complete colour block: every token in {@link COLOR_CONTRACT}, resolved. */
62
+ type ColorBlock = Readonly<Record<ColorToken$1, string>>;
63
+ /** A complete geometry block: every token in {@link GEOMETRY_CONTRACT}. */
64
+ type GeometryBlock = Readonly<Record<GeometryToken$1, string>>;
65
+ /** A partial geometry block: only what an override changes. */
66
+ type GeometryPatch = Readonly<Partial<Record<GeometryToken$1, string>>>;
67
+ /** The stacks a pairing names. A pairing must name a sans; the rest are optional. */
68
+ interface FontStacks {
69
+ readonly "font-sans": string;
70
+ readonly "font-mono"?: string;
71
+ readonly "font-display"?: string;
72
+ }
73
+ interface ManifestColors {
74
+ readonly dark: ColorBlock;
75
+ /** Absent exactly when {@link ThemeManifest.supportsLight} is false. */
76
+ readonly light?: ColorBlock;
77
+ }
78
+ interface ManifestDensities {
79
+ /** Rungs this theme adds. Each carries the full geometry contract. */
80
+ readonly adds: Readonly<Record<string, GeometryBlock>>;
81
+ /** Base rungs this theme retunes, scoped to itself. Each carries only what changed. */
82
+ readonly overrides: Readonly<Partial<Record<Density$1, GeometryPatch>>>;
83
+ /** The rung this theme prefers when the consumer names none. Base default when absent. */
84
+ readonly default?: string;
85
+ }
86
+ interface ManifestFontPairings {
87
+ readonly adds: Readonly<Record<string, FontStacks>>;
88
+ readonly overrides: Readonly<Partial<Record<FontName$1, Partial<FontStacks>>>>;
89
+ readonly default?: string;
90
+ }
91
+ interface ManifestContrast {
92
+ /** Every required pair cleared its floor. False is emitted only when every failure is waived. */
93
+ readonly passes: boolean;
94
+ /** Advisory pairs that missed. Reported, never fatal. */
95
+ readonly advisories: number;
96
+ /** Required pairs the theme named as known-bad. Counted, never silent: `passes` stays false. */
97
+ readonly waived: number;
98
+ /** `sha256-…` over the resolved colours; what a production `ThemeProvider` trusts. */
99
+ readonly digest: string;
100
+ }
101
+ /** The runtime's whole view of a theme. CSS paints; this enumerates, validates and types. */
102
+ interface ThemeManifest {
103
+ readonly schemaVersion: typeof MANIFEST_SCHEMA_VERSION;
104
+ /** Matches {@link THEME_NAME_PATTERN}; becomes `data-theme`. */
105
+ readonly name: string;
106
+ readonly package: string;
107
+ /** The `theme-base` range this manifest was resolved against, e.g. `@cueplusplus/theme-base@^1`. */
108
+ readonly extends: string;
109
+ /** `delta`: `theme.css` holds only what differs from base. `complete`: it stands alone. */
110
+ readonly mode: "delta" | "complete";
111
+ readonly supportsLight: boolean;
112
+ readonly colors: ManifestColors;
113
+ /** The theme's overrides of the base stacks (a preset's `font-mono`, typically). */
114
+ readonly fonts: Readonly<Partial<FontStacks>>;
115
+ readonly densities: ManifestDensities;
116
+ readonly fontPairings: ManifestFontPairings;
117
+ readonly contrast: ManifestContrast;
118
+ }
119
+ //#endregion
120
+ //#region src/validate.d.ts
121
+ /** One thing wrong with a manifest, where it is. */
122
+ interface ManifestProblem {
123
+ /** Dotted path from the root, `$` for the root itself. */
124
+ path: string;
125
+ message: string;
126
+ }
127
+ /**
128
+ * Every problem with a manifest, in document order.
129
+ *
130
+ * Hand-written rather than run through a JSON Schema library because this
131
+ * executes inside `ThemeProvider` in development and a schema engine is not a
132
+ * dependency a component library should carry for a check that runs once. The
133
+ * schema file beside it says the same rules in the language other tools read,
134
+ * and `test/validate.test.ts` holds the two to identical verdicts — on the six
135
+ * fixtures, and on a few hundred generated mutations of the valid one.
136
+ *
137
+ * Every rule is a sentence a theme author can act on: the message names the
138
+ * token that is missing, the rung that collides, the field that contradicts.
139
+ *
140
+ * The three names a theme brings — its own, its added rungs, its added pairings
141
+ * — are each held to {@link THEME_NAME_PATTERN}, because each one is written
142
+ * into an attribute selector (`[data-theme="…"]`, `[data-density="…"]`,
143
+ * `[data-font="…"]`) and a name that could close that quote is not a name. That
144
+ * pattern is case-insensitive, so a base rung or pairing is reserved in any
145
+ * case: `Compact` beside `compact` would be two rungs the CSS cannot tell apart.
146
+ */
147
+ declare function validateManifest(value: unknown): ManifestProblem[];
148
+ /** {@link validateManifest}, as a throw. */
149
+ declare function assertManifest(value: unknown): asserts value is ThemeManifest;
150
+ //#endregion
151
+ //#region src/resolve.d.ts
152
+ interface ResolveOptions {
153
+ mode: "dark" | "light";
154
+ density?: string;
155
+ font?: string;
156
+ }
157
+ interface ResolvedTheme {
158
+ colors: ColorBlock;
159
+ geometry: GeometryBlock;
160
+ fonts: Required<FontStacks>;
161
+ /** The rung actually used, after fallback. */
162
+ density: string;
163
+ /** The pairing actually used, after fallback. */
164
+ font: string;
165
+ }
166
+ declare function resolve(manifest: ThemeManifest | null, options: ResolveOptions): ResolvedTheme;
167
+ interface DensityEntry {
168
+ name: string;
169
+ owner: "base" | string;
170
+ geometry: GeometryBlock;
171
+ }
172
+ /** The rungs an app may offer under the active theme: base first, then the theme's additions. */
173
+ declare function resolveDensities(manifests: readonly ThemeManifest[], active: string | null): readonly DensityEntry[];
174
+ interface FontEntry {
175
+ name: string;
176
+ owner: "base" | string;
177
+ stacks: Required<FontStacks>;
178
+ }
179
+ /** The pairings an app may offer under the active theme: the same rule, one axis over. */
180
+ declare function resolveFonts(manifests: readonly ThemeManifest[], active: string | null): readonly FontEntry[];
181
+ //#endregion
182
+ export { COLOR_CONTRACT, ColorBlock, type ColorToken, DENSITIES, Density, type DensityEntry, DensityRegistry, FONTS, FONT_TOKENS, type FontEntry, FontName, FontRegistry, FontStacks, type FontToken, GEOMETRY_CONTRACT, GeometryBlock, GeometryPatch, type GeometryToken, MANIFEST_SCHEMA_VERSION, MODES, ManifestColors, ManifestContrast, ManifestDensities, ManifestFontPairings, type ManifestProblem, type Mode, PAIRING_MONO, RESOLVED_MONO, type ResolveOptions, type ResolvedTheme, THEME_MONO, THEME_NAME_PATTERN, ThemeManifest, ThemeName, ThemeRegistry, assertManifest, resolve, resolveDensities, resolveFonts, validateManifest };