@dowel-ui/themes 0.4.0 → 0.7.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/src/colour.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * OKLCH → sRGB, and WCAG contrast.
3
+ *
4
+ * The design tokens are authored in OKLCH because its lightness is
5
+ * perceptually even. WCAG contrast, however, is defined on sRGB relative
6
+ * luminance — so checking the palette means actually converting it rather than
7
+ * eyeballing the lightness numbers, which are not the same thing.
8
+ */
9
+
10
+ export interface Rgb {
11
+ r: number;
12
+ g: number;
13
+ b: number;
14
+ }
15
+
16
+ /** Parses `oklch(L C H)` or `oklch(L C H / A)`. L may be a percentage. */
17
+ export function parseOklch(
18
+ value: string,
19
+ ): { l: number; c: number; h: number; alpha: number } | undefined {
20
+ const match = /^oklch\(\s*([\d.%]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+)\s*)?\)$/.exec(
21
+ value.trim(),
22
+ );
23
+ if (!match) return undefined;
24
+
25
+ const rawL = match[1] ?? "0";
26
+ const l = rawL.endsWith("%") ? Number.parseFloat(rawL) / 100 : Number.parseFloat(rawL);
27
+
28
+ return {
29
+ l,
30
+ c: Number.parseFloat(match[2] ?? "0"),
31
+ h: Number.parseFloat(match[3] ?? "0"),
32
+ alpha: match[4] === undefined ? 1 : Number.parseFloat(match[4]),
33
+ };
34
+ }
35
+
36
+ /** Linear-light sRGB, before gamma encoding. Luminance is defined on these. */
37
+ export function oklchToLinearRgb(l: number, c: number, h: number): Rgb {
38
+ const hRad = (h * Math.PI) / 180;
39
+ const a = c * Math.cos(hRad);
40
+ const bb = c * Math.sin(hRad);
41
+
42
+ const lCube = (l + 0.3963377774 * a + 0.2158037573 * bb) ** 3;
43
+ const mCube = (l - 0.1055613458 * a - 0.0638541728 * bb) ** 3;
44
+ const sCube = (l - 0.0894841775 * a - 1.291485548 * bb) ** 3;
45
+
46
+ return {
47
+ r: 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube,
48
+ g: -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube,
49
+ b: -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube,
50
+ };
51
+ }
52
+
53
+ function clamp(value: number): number {
54
+ return Math.min(1, Math.max(0, value));
55
+ }
56
+
57
+ /** WCAG 2.x relative luminance. */
58
+ export function luminance(rgb: Rgb): number {
59
+ return 0.2126 * clamp(rgb.r) + 0.7152 * clamp(rgb.g) + 0.0722 * clamp(rgb.b);
60
+ }
61
+
62
+ /**
63
+ * Composites a translucent colour over an opaque one.
64
+ *
65
+ * Several tokens are alpha values over a surface — an overlay, a tinted alert
66
+ * background. Measuring them without compositing would report the contrast of a
67
+ * colour nobody ever sees.
68
+ */
69
+ export function composite(foreground: Rgb, alpha: number, background: Rgb): Rgb {
70
+ return {
71
+ r: foreground.r * alpha + background.r * (1 - alpha),
72
+ g: foreground.g * alpha + background.g * (1 - alpha),
73
+ b: foreground.b * alpha + background.b * (1 - alpha),
74
+ };
75
+ }
76
+
77
+ export function contrastRatio(a: Rgb, b: Rgb): number {
78
+ const la = luminance(a);
79
+ const lb = luminance(b);
80
+ const lighter = Math.max(la, lb);
81
+ const darker = Math.min(la, lb);
82
+ return (lighter + 0.05) / (darker + 0.05);
83
+ }
84
+
85
+ /** `oklch(...)` string to linear RGB, composited over `over` if translucent. */
86
+ export function resolveColour(value: string, over?: Rgb): Rgb | undefined {
87
+ const parsed = parseOklch(value);
88
+ if (!parsed) return undefined;
89
+
90
+ const rgb = oklchToLinearRgb(parsed.l, parsed.c, parsed.h);
91
+ if (parsed.alpha >= 1 || !over) return rgb;
92
+ return composite(rgb, parsed.alpha, over);
93
+ }
94
+
95
+ /** Gamma-encoded sRGB, 0–255, from linear-light sRGB. */
96
+ export function encodeSrgb(rgb: Rgb): { r: number; g: number; b: number } {
97
+ const encode = (channel: number) => {
98
+ const value = clamp(channel);
99
+ const encoded = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
100
+ return Math.round(encoded * 255);
101
+ };
102
+
103
+ return { r: encode(rgb.r), g: encode(rgb.g), b: encode(rgb.b) };
104
+ }
105
+
106
+ /** Linear-light sRGB from gamma-encoded channels, each 0–255. */
107
+ export function decodeSrgb(r: number, g: number, b: number): Rgb {
108
+ const decode = (channel: number) => {
109
+ const value = channel / 255;
110
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
111
+ };
112
+
113
+ return { r: decode(r), g: decode(g), b: decode(b) };
114
+ }
115
+
116
+ export interface Oklch {
117
+ l: number;
118
+ c: number;
119
+ h: number;
120
+ }
121
+
122
+ /**
123
+ * Linear-light sRGB to OKLCH.
124
+ *
125
+ * The inverse of `oklchToLinearRgb`, needed because people pick colours as hex
126
+ * and the tokens are authored in OKLCH. Round-tripping through this is lossy
127
+ * only where the input is outside the OKLCH gamut the tokens use, which a hex
128
+ * value from a colour input never is.
129
+ */
130
+ export function linearRgbToOklch(rgb: Rgb): Oklch {
131
+ const lCube = 0.4122214708 * rgb.r + 0.5363325363 * rgb.g + 0.0514459929 * rgb.b;
132
+ const mCube = 0.2119034982 * rgb.r + 0.6806995451 * rgb.g + 0.1073969566 * rgb.b;
133
+ const sCube = 0.0883024619 * rgb.r + 0.2817188376 * rgb.g + 0.6299787005 * rgb.b;
134
+
135
+ const l_ = Math.cbrt(lCube);
136
+ const m_ = Math.cbrt(mCube);
137
+ const s_ = Math.cbrt(sCube);
138
+
139
+ const l = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
140
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
141
+ const b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
142
+
143
+ const c = Math.sqrt(a * a + b * b);
144
+ // atan2 returns (-180, 180]; hues are conventionally [0, 360).
145
+ const h = c < 1e-6 ? 0 : ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360;
146
+
147
+ return { l, c, h };
148
+ }
149
+
150
+ /** `#rrggbb` (or `#rgb`) to OKLCH. Undefined for anything else. */
151
+ export function hexToOklch(hex: string): Oklch | undefined {
152
+ const value = hex.trim().replace(/^#/, "");
153
+ const full =
154
+ value.length === 3
155
+ ? value
156
+ .split("")
157
+ .map((digit) => digit + digit)
158
+ .join("")
159
+ : value;
160
+
161
+ if (!/^[0-9a-f]{6}$/i.test(full)) return undefined;
162
+
163
+ return linearRgbToOklch(
164
+ decodeSrgb(
165
+ Number.parseInt(full.slice(0, 2), 16),
166
+ Number.parseInt(full.slice(2, 4), 16),
167
+ Number.parseInt(full.slice(4, 6), 16),
168
+ ),
169
+ );
170
+ }
171
+
172
+ export function oklchToHex({ l, c, h }: Oklch): string {
173
+ const { r, g, b } = encodeSrgb(oklchToLinearRgb(l, c, h));
174
+ const pair = (channel: number) => channel.toString(16).padStart(2, "0");
175
+ return `#${pair(r)}${pair(g)}${pair(b)}`;
176
+ }
177
+
178
+ /** An OKLCH triple as the tokens write it. */
179
+ export function formatOklch({ l, c, h }: Oklch): string {
180
+ const round = (value: number, places: number) => Number(value.toFixed(places)).toString();
181
+ return `oklch(${round(l, 3)} ${round(c, 3)} ${round(h, 1)})`;
182
+ }
package/src/figma.ts ADDED
@@ -0,0 +1,287 @@
1
+ import { encodeSrgb, oklchToLinearRgb, parseOklch, formatOklch } from "./colour";
2
+ import type { DerivedPreset, PresetMode } from "./preset";
3
+
4
+ /**
5
+ * The tokens, in the shape a design tool reads.
6
+ *
7
+ * The CSS is the source of truth and stays that way — nothing here is a second
8
+ * palette to keep in step. This reads the same `tokens.css`, `base.css` and
9
+ * preset files the components consume, resolves every `var()` the way a
10
+ * browser would, and writes the result as W3C Design Tokens (DTCG): the format
11
+ * Tokens Studio for Figma imports directly, and the one every other design
12
+ * tool is converging on.
13
+ *
14
+ * Colours come out as sRGB hex. Figma has no OKLCH; converting here, with the
15
+ * same maths the contrast audit uses, means the swatch a designer sees is the
16
+ * colour a user gets — rather than whatever a tool makes of an `oklch()` string
17
+ * it cannot parse.
18
+ */
19
+
20
+ /** A flat map of custom property name (without the leading `--`) to raw value. */
21
+ export type Declarations = Record<string, string>;
22
+
23
+ /** A W3C Design Tokens document: nested groups whose leaves carry `$type` and `$value`. */
24
+ export interface DesignToken {
25
+ $type: "color" | "dimension" | "fontFamily" | "number";
26
+ $value: string | number | string[];
27
+ $description?: string;
28
+ }
29
+
30
+ export interface DesignTokenGroup {
31
+ [key: string]: DesignToken | DesignTokenGroup | string | undefined;
32
+ $description?: string;
33
+ }
34
+
35
+ /** Strips block comments, which otherwise hide `--x: y;` pairs inside them from the parser. */
36
+ function stripComments(css: string): string {
37
+ return css.replace(/\/\*[\s\S]*?\*\//g, "");
38
+ }
39
+
40
+ /**
41
+ * The declarations inside the first block whose selector is exactly `selector`.
42
+ *
43
+ * Exact, not substring: `.dark` must not match `.dark[data-theme="ocean"]`, and
44
+ * `:root` must not match `:root[dir="rtl"]`. Whitespace inside a value is
45
+ * collapsed, because a font stack written over four lines is one value.
46
+ */
47
+ export function parseTokenCss(css: string, selector: string): Declarations {
48
+ const clean = stripComments(css);
49
+ const declarations: Declarations = {};
50
+
51
+ let searchFrom = 0;
52
+ for (;;) {
53
+ const brace = clean.indexOf("{", searchFrom);
54
+ if (brace === -1) break;
55
+
56
+ // The selector is whatever sits between the previous `}` (or `;`, or the
57
+ // start) and this `{`.
58
+ const start = Math.max(
59
+ clean.lastIndexOf("}", brace),
60
+ clean.lastIndexOf(";", brace),
61
+ clean.lastIndexOf("{", brace - 1),
62
+ );
63
+ const candidate = clean.slice(start + 1, brace).trim();
64
+ const end = clean.indexOf("}", brace);
65
+ if (end === -1) break;
66
+
67
+ if (candidate === selector) {
68
+ const body = clean.slice(brace + 1, end);
69
+ for (const match of body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) {
70
+ const name = match[1];
71
+ const value = match[2];
72
+ if (name && value) declarations[name] = value.replace(/\s+/g, " ").trim();
73
+ }
74
+ return declarations;
75
+ }
76
+
77
+ searchFrom = end + 1;
78
+ }
79
+
80
+ return declarations;
81
+ }
82
+
83
+ /**
84
+ * Resolves `var(--x)` references the way the cascade would.
85
+ *
86
+ * `scopes` are searched in order, so a mode's own declarations shadow the root's
87
+ * and the root's shadow the raw scale — which is exactly what `.dark { --x }`
88
+ * over `:root { --x }` over `@theme { --x }` means. A fallback inside the
89
+ * `var()` is used when nothing defines the name, and an unresolvable reference
90
+ * is left as written rather than silently dropped.
91
+ */
92
+ export function resolveReferences(value: string, scopes: Declarations[]): string {
93
+ let current = value;
94
+ // Bounded, so a cycle terminates with the reference left in place rather
95
+ // than hanging the build.
96
+ for (let depth = 0; depth < 16; depth += 1) {
97
+ const next = current.replace(
98
+ /var\(\s*--([\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\))?[^()]*))?\)/g,
99
+ (whole, name: string, fallback: string | undefined) => {
100
+ for (const scope of scopes) {
101
+ const found = scope[name];
102
+ if (found !== undefined) return found;
103
+ }
104
+ return fallback?.trim() ?? whole;
105
+ },
106
+ );
107
+ if (next === current) return current;
108
+ current = next;
109
+ }
110
+ return current;
111
+ }
112
+
113
+ /** `oklch(...)` to `#rrggbb`, or `#rrggbbaa` when it carries alpha. */
114
+ export function cssColourToHex(value: string): string | undefined {
115
+ const parsed = parseOklch(value.trim());
116
+ if (!parsed) return undefined;
117
+
118
+ const { r, g, b } = encodeSrgb(oklchToLinearRgb(parsed.l, parsed.c, parsed.h));
119
+ const pair = (channel: number) => channel.toString(16).padStart(2, "0");
120
+ const rgb = `#${pair(r)}${pair(g)}${pair(b)}`;
121
+ return parsed.alpha >= 1 ? rgb : `${rgb}${pair(Math.round(parsed.alpha * 255))}`;
122
+ }
123
+
124
+ const ROOT_FONT_PX = 16;
125
+
126
+ /**
127
+ * A length in px, for the values the scale is written in.
128
+ *
129
+ * Handles the two shapes the tokens use: a plain `rem`/`px`, and the radius
130
+ * ladder's `calc(<rem> * var(--radius-scale, 1))`, which is resolved with the
131
+ * given scale so an exported theme carries the corner radius it was designed
132
+ * with rather than a formula Figma cannot evaluate.
133
+ */
134
+ export function cssLengthToPx(value: string, radiusScale = 1): number | undefined {
135
+ const trimmed = value.trim();
136
+
137
+ const calc = /^calc\(\s*([\d.]+)rem\s*\*\s*var\(--radius-scale(?:,\s*[\d.]+)?\)\s*\)$/.exec(
138
+ trimmed,
139
+ );
140
+ if (calc?.[1]) return round(Number.parseFloat(calc[1]) * ROOT_FONT_PX * radiusScale);
141
+
142
+ const rem = /^([\d.]+)rem$/.exec(trimmed);
143
+ if (rem?.[1]) return round(Number.parseFloat(rem[1]) * ROOT_FONT_PX);
144
+
145
+ const px = /^([\d.]+)px$/.exec(trimmed);
146
+ if (px?.[1]) return round(Number.parseFloat(px[1]));
147
+
148
+ return undefined;
149
+ }
150
+
151
+ function round(value: number): number {
152
+ return Math.round(value * 100) / 100;
153
+ }
154
+
155
+ function px(value: number): string {
156
+ return `${String(value)}px`;
157
+ }
158
+
159
+ /** The four tokens a derived preset owns, as declarations, per mode. */
160
+ export function presetDeclarations(preset: DerivedPreset): {
161
+ light: Declarations;
162
+ dark: Declarations;
163
+ } {
164
+ const mode = (entry: PresetMode): Declarations => ({
165
+ primary: formatOklch(entry.primary),
166
+ "primary-hover": formatOklch(entry.primaryHover),
167
+ "primary-active": formatOklch(entry.primaryActive),
168
+ "primary-foreground": formatOklch(entry.primaryForeground),
169
+ });
170
+ return { light: mode(preset.light), dark: mode(preset.dark) };
171
+ }
172
+
173
+ export interface DesignTokensInput {
174
+ /** Named in the document, e.g. "ocean" or a studio preset's slug. */
175
+ name: string;
176
+ /** The `@theme` block of tokens.css: the raw scales. */
177
+ scale: Declarations;
178
+ /** The `:root` block of base.css. */
179
+ light: Declarations;
180
+ /** The `.dark` block of base.css. */
181
+ dark: Declarations;
182
+ /** A preset's overrides, layered over `light` and `dark`. */
183
+ preset?: { light: Declarations; dark: Declarations };
184
+ /** The `--radius-scale` the theme was designed at. */
185
+ radiusScale?: number;
186
+ }
187
+
188
+ function colourGroup(
189
+ declarations: Declarations,
190
+ scopes: Declarations[],
191
+ filter: (name: string) => boolean,
192
+ ): DesignTokenGroup {
193
+ const group: DesignTokenGroup = {};
194
+ for (const [name, raw] of Object.entries(declarations)) {
195
+ if (!filter(name)) continue;
196
+ const hex = cssColourToHex(resolveReferences(raw, scopes));
197
+ if (hex) group[name] = { $type: "color", $value: hex };
198
+ }
199
+ return group;
200
+ }
201
+
202
+ /** `--color-neutral-500` → `neutral.500`, nested. */
203
+ function scaleColours(scale: Declarations): DesignTokenGroup {
204
+ const group: DesignTokenGroup = {};
205
+ for (const [name, raw] of Object.entries(scale)) {
206
+ const match = /^color-([a-z]+)-(\d+)$/.exec(name);
207
+ if (!match) continue;
208
+ const [, family, step] = match;
209
+ if (!family || !step) continue;
210
+ const hex = cssColourToHex(raw);
211
+ if (!hex) continue;
212
+ const familyGroup = (group[family] ??= {}) as DesignTokenGroup;
213
+ familyGroup[step] = { $type: "color", $value: hex };
214
+ }
215
+ return group;
216
+ }
217
+
218
+ function radii(scale: Declarations, radiusScale: number): DesignTokenGroup {
219
+ const group: DesignTokenGroup = {};
220
+ for (const [name, raw] of Object.entries(scale)) {
221
+ const match = /^radius-([\w]+)$/.exec(name);
222
+ if (!match?.[1]) continue;
223
+ const length = cssLengthToPx(raw, radiusScale);
224
+ if (length !== undefined) group[match[1]] = { $type: "dimension", $value: px(length) };
225
+ }
226
+ return group;
227
+ }
228
+
229
+ function typography(scale: Declarations): DesignTokenGroup {
230
+ const family: DesignTokenGroup = {};
231
+ for (const [name, raw] of Object.entries(scale)) {
232
+ const match = /^font-([a-z]+)$/.exec(name);
233
+ if (!match?.[1]) continue;
234
+ family[match[1]] = {
235
+ $type: "fontFamily",
236
+ $value: raw.split(",").map((entry) => entry.trim().replace(/^["']|["']$/g, "")),
237
+ };
238
+ }
239
+
240
+ const size: DesignTokenGroup = {};
241
+ for (const [name, raw] of Object.entries(scale)) {
242
+ const step = /^text-([\w]+)$/.exec(name)?.[1];
243
+ if (!step) continue;
244
+ const fontSize = cssLengthToPx(raw);
245
+ if (fontSize === undefined) continue;
246
+ const entry: DesignTokenGroup = { size: { $type: "dimension", $value: px(fontSize) } };
247
+ const lineHeight = scale[`text-${step}--line-height`];
248
+ const lineHeightPx = lineHeight === undefined ? undefined : cssLengthToPx(lineHeight);
249
+ if (lineHeightPx !== undefined) {
250
+ entry.lineHeight = { $type: "dimension", $value: px(lineHeightPx) };
251
+ }
252
+ size[step] = entry;
253
+ }
254
+
255
+ return { family, text: size };
256
+ }
257
+
258
+ /**
259
+ * The whole theme, as a design-tokens document.
260
+ *
261
+ * Three sets: `core` (the raw scales, the same in both modes), `light` and
262
+ * `dark` (the semantic colours, resolved). A designer enables `core` plus one
263
+ * mode, which mirrors exactly how the CSS composes.
264
+ */
265
+ export function toDesignTokens(input: DesignTokensInput): DesignTokenGroup {
266
+ const radiusScale = input.radiusScale ?? 1;
267
+ const light: Declarations = { ...input.light, ...input.preset?.light };
268
+ const dark: Declarations = { ...input.dark, ...input.preset?.dark };
269
+
270
+ const semantic = (name: string) => name !== "radius-scale" && !name.startsWith("color-");
271
+
272
+ return {
273
+ $description: `Dowel design tokens, preset "${input.name}". Generated from the CSS the components use; colours are sRGB hex converted from OKLCH.`,
274
+ core: {
275
+ color: scaleColours(input.scale),
276
+ radius: radii(input.scale, radiusScale),
277
+ font: typography(input.scale),
278
+ },
279
+ light: {
280
+ color: colourGroup(light, [light, input.scale], semantic),
281
+ },
282
+ dark: {
283
+ // Dark declares only what differs; the rest inherits from the root.
284
+ color: colourGroup({ ...light, ...dark }, [dark, light, input.scale], semantic),
285
+ },
286
+ };
287
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,46 @@
1
+ export {
2
+ checkPreset,
3
+ derivePreset,
4
+ foregroundFor,
5
+ formatPreset,
6
+ slugify,
7
+ TEXT_MINIMUM,
8
+ type ContrastCheck,
9
+ type DerivedPreset,
10
+ type DeriveOptions,
11
+ type PresetMode,
12
+ } from "./preset";
13
+
14
+ export {
15
+ cssColourToHex,
16
+ cssLengthToPx,
17
+ parseTokenCss,
18
+ presetDeclarations,
19
+ resolveReferences,
20
+ toDesignTokens,
21
+ type Declarations,
22
+ type DesignToken,
23
+ type DesignTokenGroup,
24
+ type DesignTokensInput,
25
+ } from "./figma";
26
+
27
+ export {
28
+ composite,
29
+ contrastRatio,
30
+ decodeSrgb,
31
+ encodeSrgb,
32
+ formatOklch,
33
+ hexToOklch,
34
+ linearRgbToOklch,
35
+ luminance,
36
+ oklchToHex,
37
+ oklchToLinearRgb,
38
+ parseOklch,
39
+ resolveColour,
40
+ type Oklch,
41
+ type Rgb,
42
+ } from "./colour";
43
+
1
44
  /**
2
45
  * Typed surface of the theme layer. The CSS is the implementation; these
3
46
  * constants exist so theme switchers, the docs playground and the future CLI
package/src/preset.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { contrastRatio, formatOklch, oklchToLinearRgb, type Oklch } from "./colour";
2
+
3
+ /**
4
+ * Deriving a theme preset from a single colour.
5
+ *
6
+ * A preset in this system reassigns four tokens per mode and inherits
7
+ * everything else, so building one is not a palette exercise — it is picking a
8
+ * primary and then answering three questions the shipped presets already
9
+ * answer: what it looks like pressed, what it looks like in dark mode, and what
10
+ * text can be read on it.
11
+ *
12
+ * The deltas below are read off the presets that ship. They are not arbitrary:
13
+ * every one of those passes the contrast audit in both modes, so starting from
14
+ * the same relationships means a derived preset starts somewhere that works.
15
+ */
16
+
17
+ /** Lightness step from the base colour to its hover state, in light mode. */
18
+ const LIGHT_HOVER_DELTA = -0.045;
19
+ /** And to its active state, which is a press and reads as further down. */
20
+ const LIGHT_ACTIVE_DELTA = -0.083;
21
+
22
+ /**
23
+ * Dark mode raises lightness and drops chroma.
24
+ *
25
+ * A colour that reads as saturated on white reads as glaring on near-black, and
26
+ * one dark enough to sit on white disappears into the background.
27
+ */
28
+ const DARK_LIGHTNESS_DELTA = 0.115;
29
+ const DARK_CHROMA_DELTA = -0.015;
30
+ const DARK_HOVER_DELTA = 0.045;
31
+ const DARK_ACTIVE_DELTA = -0.045;
32
+
33
+ /** The near-white the shipped presets use for text on a saturated colour. */
34
+ const LIGHT_FOREGROUND: Oklch = { l: 0.985, c: 0.002, h: 265 };
35
+
36
+ /** WCAG 2.2 AA for normal text; a button label is normal text. */
37
+ export const TEXT_MINIMUM = 4.5;
38
+
39
+ function clampLightness(value: number): number {
40
+ return Math.min(0.99, Math.max(0.01, value));
41
+ }
42
+
43
+ function ratio(a: Oklch, b: Oklch): number {
44
+ return contrastRatio(oklchToLinearRgb(a.l, a.c, a.h), oklchToLinearRgb(b.l, b.c, b.h));
45
+ }
46
+
47
+ /**
48
+ * Text for a saturated background: near-white, or a dark tint of its own hue.
49
+ *
50
+ * Whichever reads better, rather than always white. A light primary — amber,
51
+ * lime — cannot carry white text at 4.5:1 no matter how it is nudged, and the
52
+ * shipped `amber` preset is dark-on-light for exactly this reason.
53
+ */
54
+ export function foregroundFor(background: Oklch): Oklch {
55
+ const dark: Oklch = { l: 0.155, c: 0.03, h: background.h };
56
+ return ratio(LIGHT_FOREGROUND, background) >= ratio(dark, background)
57
+ ? LIGHT_FOREGROUND
58
+ : dark;
59
+ }
60
+
61
+ export interface PresetMode {
62
+ primary: Oklch;
63
+ primaryHover: Oklch;
64
+ primaryActive: Oklch;
65
+ primaryForeground: Oklch;
66
+ }
67
+
68
+ export interface DerivedPreset {
69
+ light: PresetMode;
70
+ dark: PresetMode;
71
+ }
72
+
73
+ export interface DeriveOptions {
74
+ /**
75
+ * Lightness of the dark-mode primary.
76
+ *
77
+ * Overridable because it is the one derived value with no single right
78
+ * answer: how bright a brand reads on near-black is a judgement about the
79
+ * brand, not about contrast.
80
+ */
81
+ darkLightness?: number;
82
+ }
83
+
84
+ export function derivePreset(input: Oklch, options: DeriveOptions = {}): DerivedPreset {
85
+ // Clamped on the way in as well as on the way out. Every value this returns
86
+ // is one it is responsible for, including the one it was handed: a preset
87
+ // built on pure black is not a preset anyone can use, and passing it through
88
+ // untouched would mean the only unusable value in the output is the one that
89
+ // was never checked.
90
+ const primary: Oklch = { ...input, l: clampLightness(input.l), c: Math.max(0, input.c) };
91
+
92
+ const light: PresetMode = {
93
+ primary,
94
+ primaryHover: { ...primary, l: clampLightness(primary.l + LIGHT_HOVER_DELTA) },
95
+ primaryActive: { ...primary, l: clampLightness(primary.l + LIGHT_ACTIVE_DELTA) },
96
+ primaryForeground: foregroundFor(primary),
97
+ };
98
+
99
+ const darkPrimary: Oklch = {
100
+ l: clampLightness(options.darkLightness ?? primary.l + DARK_LIGHTNESS_DELTA),
101
+ c: Math.max(0, primary.c + DARK_CHROMA_DELTA),
102
+ h: primary.h,
103
+ };
104
+
105
+ const dark: PresetMode = {
106
+ primary: darkPrimary,
107
+ primaryHover: { ...darkPrimary, l: clampLightness(darkPrimary.l + DARK_HOVER_DELTA) },
108
+ primaryActive: { ...darkPrimary, l: clampLightness(darkPrimary.l + DARK_ACTIVE_DELTA) },
109
+ primaryForeground: foregroundFor(darkPrimary),
110
+ };
111
+
112
+ return { light, dark };
113
+ }
114
+
115
+ export interface ContrastCheck {
116
+ label: string;
117
+ ratio: number;
118
+ minimum: number;
119
+ passes: boolean;
120
+ }
121
+
122
+ /**
123
+ * The pairs a derived preset is responsible for.
124
+ *
125
+ * Only these four per mode: every other pair in the system is inherited from
126
+ * the base tokens, which the audit already covers. Reporting the inherited ones
127
+ * would be reporting on something the person cannot change from here.
128
+ */
129
+ export function checkPreset(preset: DerivedPreset): ContrastCheck[] {
130
+ const checks: ContrastCheck[] = [];
131
+
132
+ for (const [mode, values] of [
133
+ ["Light", preset.light],
134
+ ["Dark", preset.dark],
135
+ ] as const) {
136
+ for (const [state, background] of [
137
+ ["primary", values.primary],
138
+ ["primary-hover", values.primaryHover],
139
+ ["primary-active", values.primaryActive],
140
+ ] as const) {
141
+ checks.push({
142
+ label: `${mode}: primary-foreground on ${state}`,
143
+ ratio: ratio(values.primaryForeground, background),
144
+ minimum: TEXT_MINIMUM,
145
+ passes: ratio(values.primaryForeground, background) >= TEXT_MINIMUM,
146
+ });
147
+ }
148
+ }
149
+
150
+ return checks;
151
+ }
152
+
153
+ function block(selector: string, mode: PresetMode): string {
154
+ return [
155
+ `${selector} {`,
156
+ ` --primary: ${formatOklch(mode.primary)};`,
157
+ ` --primary-hover: ${formatOklch(mode.primaryHover)};`,
158
+ ` --primary-active: ${formatOklch(mode.primaryActive)};`,
159
+ ` --primary-foreground: ${formatOklch(mode.primaryForeground)};`,
160
+ `}`,
161
+ ].join("\n");
162
+ }
163
+
164
+ /**
165
+ * The preset as a stylesheet, in the same shape as the ones that ship.
166
+ *
167
+ * Deliberately the same file format rather than a bespoke export: what comes
168
+ * out of here can be dropped into `packages/themes/src/presets/` unchanged, and
169
+ * is then covered by the same audit as everything else.
170
+ */
171
+ export function formatPreset(name: string, preset: DerivedPreset): string {
172
+ return [
173
+ `/* Theme preset: ${name}.`,
174
+ ` * Apply with data-theme="${name}" on the <html> element. Only the brand-carrying`,
175
+ ` * tokens are reassigned; every neutral, radius and motion token is inherited. */`,
176
+ "",
177
+ block(`[data-theme="${name}"]`, preset.light),
178
+ "",
179
+ block(`.dark[data-theme="${name}"]`, preset.dark),
180
+ "",
181
+ ].join("\n");
182
+ }
183
+
184
+ /** A name usable as a `data-theme` value. */
185
+ export function slugify(name: string): string {
186
+ const slug = name
187
+ .toLowerCase()
188
+ .replace(/[^a-z0-9]+/g, "-")
189
+ .replace(/^-+|-+$/g, "");
190
+ return slug.length > 0 ? slug : "custom";
191
+ }