@rhombuskit/theme-builder 1.15.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Doug Williamson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @rhombuskit/theme-builder
2
+
3
+ Generate WCAG-AA-validated [RhombusKit](https://rhombuskit.online) themes from a few seed
4
+ colours. Framework-agnostic (no Angular peer) — usable from a build script, a CLI, or the
5
+ interactive [`/theme-builder`](https://rhombuskit.online/theme-builder) page.
6
+
7
+ You give it a brand accent (and, optionally, a neutral); it emits **both** a light and a dark
8
+ theme covering the full 60-token RhombusKit CONTRACT, then re-validates every text/background
9
+ pair for WCAG 2.1 AA and either returns an AA-clean theme or throws — it never returns sub-AA
10
+ output.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm i @rhombuskit/theme-builder @rhombuskit/tokens
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ts
21
+ import { generateTheme, serializeThemeCss, toRegisteredThemes } from '@rhombuskit/theme-builder';
22
+
23
+ const theme = generateTheme({ accent: '#0f766e', name: 'teal' });
24
+
25
+ // Paste-ready [data-theme] CSS over all 60 CONTRACT tokens (light + dark):
26
+ console.log(serializeThemeCss(theme));
27
+
28
+ // Metadata for the theme registry (pair with the CSS above):
29
+ const registered = toRegisteredThemes(theme); // RegisteredTheme[]
30
+ ```
31
+
32
+ The emitted CSS + registration follow the sanctioned custom-theme path in
33
+ [the theming guide](https://rhombuskit.online/theming). A bare `generateTheme()` reproduces the
34
+ built-in `rhombus-light`/`rhombus-dark` packs byte-for-byte.
35
+
36
+ ## License
37
+
38
+ MIT
@@ -0,0 +1,113 @@
1
+ import { SemanticTokenName } from '@rhombuskit/tokens';
2
+
3
+ declare const RUNGS: readonly [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
4
+ type Rung = (typeof RUNGS)[number];
5
+ type FullRamp = Record<Rung, string>;
6
+
7
+ /** A few seed colours in. See the spec for the derivation. */
8
+ interface ThemeSeed {
9
+ /** Brand accent hex. Omitted OR === #7c3aed → shipped violet verbatim. */
10
+ accent?: string;
11
+ /** Neutral/surface base hex. Omitted OR === #64748b (slate-500) → shipped slate verbatim. */
12
+ neutral?: string;
13
+ /** Registry id (default: the OKLCH hue name of the accent, e.g. "indigo"). */
14
+ name?: string;
15
+ /** Human label (default: Title-Case of name). */
16
+ label?: string;
17
+ /** Chroma-envelope multiplier for generated ramps (default 1, clamped [0.5, 1.5]). */
18
+ vividness?: number;
19
+ }
20
+ /** A complete light + dark theme over the 60-token CONTRACT, plus a fidelity report. */
21
+ interface GeneratedTheme {
22
+ name: string;
23
+ label: string;
24
+ light: Record<SemanticTokenName, string>;
25
+ dark: Record<SemanticTokenName, string>;
26
+ report: {
27
+ warnings: string[];
28
+ };
29
+ }
30
+ interface BuildOptions {
31
+ /** Disable the AA-rescue nudge. Only for the red-gate test — a green seed then throws. */
32
+ nudge?: boolean;
33
+ }
34
+ /**
35
+ * Structural match for `@rhombuskit/theme-engine`'s `RegisteredTheme` — declared locally so this
36
+ * package stays free of an Angular/theme-engine dependency. Assignable to `provideRhombusThemes()`.
37
+ */
38
+ interface RegisteredThemeMeta {
39
+ name: string;
40
+ label: string;
41
+ mode: 'light' | 'dark';
42
+ palette: string;
43
+ }
44
+
45
+ /** A dark, hue-preserving tint for the one dark background no ramp step can supply. */
46
+ declare function deepTint(hex: string): string;
47
+ /** Synthesize an 11-rung ramp from a seed: fixed lightness ladder, seed hue + scaled chroma. */
48
+ declare function buildRamp(seedHex: string, Lcurve: readonly number[], Cenv: readonly number[], cap?: readonly number[], vividness?: number): FullRamp;
49
+ /** Generate a complete, AA-clean light + dark theme from a few seed colours. */
50
+ declare function build(seed?: ThemeSeed, opts?: BuildOptions): GeneratedTheme;
51
+
52
+ interface ThemeAAFailure {
53
+ fg: SemanticTokenName;
54
+ bg: SemanticTokenName;
55
+ ratio: number;
56
+ min: number;
57
+ }
58
+ interface ThemeAAResult {
59
+ ok: boolean;
60
+ failures: ThemeAAFailure[];
61
+ }
62
+ type ThemeRecord = Partial<Record<SemanticTokenName, string>>;
63
+ declare function validateThemeAA(record: ThemeRecord): ThemeAAResult;
64
+
65
+ /** Two `[data-theme="<name>"]{…}` blocks (light + `-dark`) over every CONTRACT token. */
66
+ declare function serializeThemeCss(theme: GeneratedTheme): string;
67
+ /** Light + dark `RegisteredTheme` metadata sharing one palette (pair with the CSS above). */
68
+ declare function toRegisteredThemes(theme: GeneratedTheme): RegisteredThemeMeta[];
69
+ /** The `declare module '@rhombuskit/theme-engine'` snippet so `setTheme('<name>')` type-checks. */
70
+ declare function toAugmentation(theme: GeneratedTheme): string;
71
+
72
+ /** [r, g, b] in 0–255, a in 0–1. */
73
+ type Rgba = [number, number, number, number];
74
+ /**
75
+ * Parse `#rgb` / `#rrggbb` / `#rrggbbaa` / `rgb()` / `rgba()` (space- or comma-separated,
76
+ * optional slash or 4th-value alpha). Returns null for var()/color-mix/named/anything else —
77
+ * such values are not contrast-checkable and are skipped by callers.
78
+ */
79
+ declare function parseColor(css: string): Rgba | null;
80
+ /** Normalise any parseable colour to lowercase `#rrggbb` (alpha stripped); null if unparseable. */
81
+ declare function normalizeHex(css: string): string | null;
82
+ /** WCAG 2.1 relative luminance (sRGB linearisation, 0.2126/0.7152/0.0722). */
83
+ declare function relativeLuminance([r, g, b]: readonly [number, number, number]): number;
84
+ /** WCAG contrast ratio (Lhi+0.05)/(Llo+0.05); null if either side is non-concrete. */
85
+ declare function contrastRatio(fg: string, bg: string): number | null;
86
+ /**
87
+ * Composite a (possibly translucent) foreground over a solid background, returning the opaque
88
+ * hex the viewer actually sees. Used before measuring contrast so alpha can never pass vacuously.
89
+ * An opaque foreground is returned unchanged (as hex).
90
+ */
91
+ declare function alphaComposite(fg: string, bg: string): string;
92
+ interface Oklch {
93
+ L: number;
94
+ C: number;
95
+ H: number;
96
+ }
97
+ declare function toOKLCH(hex: string): Oklch;
98
+ /** OKLCH → hex (channels clamped to 0–255; use gamutClampToSrgb first for faithful hues). */
99
+ declare function fromOKLCH(L: number, C: number, H: number): string;
100
+ /**
101
+ * Reduce chroma (holding L and H) until the colour fits inside sRGB, then convert to hex.
102
+ * An already-in-gamut colour is returned exactly. CSS Color 4 chroma reduction by bisection.
103
+ */
104
+ declare function gamutClampToSrgb(L: number, C: number, H: number): string;
105
+
106
+ /** Thrown when a generated theme cannot be made AA-clean — the generator never returns sub-AA. */
107
+ declare class ThemeAAError extends Error {
108
+ readonly mode: 'light' | 'dark';
109
+ readonly failures: ThemeAAFailure[];
110
+ constructor(mode: 'light' | 'dark', failures: ThemeAAFailure[]);
111
+ }
112
+
113
+ export { type BuildOptions, type GeneratedTheme, type Oklch, type RegisteredThemeMeta, type Rgba, ThemeAAError, type ThemeAAFailure, type ThemeAAResult, type ThemeSeed, alphaComposite, buildRamp, contrastRatio, deepTint, fromOKLCH, gamutClampToSrgb, build as generateTheme, normalizeHex, parseColor, relativeLuminance, serializeThemeCss, toAugmentation, toOKLCH, toRegisteredThemes, validateThemeAA };
package/dist/index.mjs ADDED
@@ -0,0 +1,833 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/color-math.ts
5
+ var clampByte = /* @__PURE__ */ __name((n) => Math.min(255, Math.max(0, Math.round(n))), "clampByte");
6
+ var toHex = /* @__PURE__ */ __name((r, g, b) => "#" + [
7
+ r,
8
+ g,
9
+ b
10
+ ].map((c) => clampByte(c).toString(16).padStart(2, "0")).join(""), "toHex");
11
+ function parseColor(css) {
12
+ const s = css.trim();
13
+ let m = /^#([0-9a-f]{3})$/i.exec(s);
14
+ if (m) {
15
+ const h = m[1];
16
+ return [
17
+ parseInt(h[0] + h[0], 16),
18
+ parseInt(h[1] + h[1], 16),
19
+ parseInt(h[2] + h[2], 16),
20
+ 1
21
+ ];
22
+ }
23
+ m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(s);
24
+ if (m) {
25
+ const h = m[1];
26
+ return [
27
+ parseInt(h.slice(0, 2), 16),
28
+ parseInt(h.slice(2, 4), 16),
29
+ parseInt(h.slice(4, 6), 16),
30
+ m[2] === void 0 ? 1 : parseInt(m[2], 16) / 255
31
+ ];
32
+ }
33
+ m = /^rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)(?:\s*[,/]\s*([0-9.]+))?\s*\)$/i.exec(s);
34
+ if (m) {
35
+ return [
36
+ Number(m[1]),
37
+ Number(m[2]),
38
+ Number(m[3]),
39
+ m[4] === void 0 ? 1 : Number(m[4])
40
+ ];
41
+ }
42
+ return null;
43
+ }
44
+ __name(parseColor, "parseColor");
45
+ function normalizeHex(css) {
46
+ const p = parseColor(css);
47
+ return p ? toHex(p[0], p[1], p[2]) : null;
48
+ }
49
+ __name(normalizeHex, "normalizeHex");
50
+ function relativeLuminance([r, g, b]) {
51
+ const channel = /* @__PURE__ */ __name((v) => {
52
+ const c = v / 255;
53
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
54
+ }, "channel");
55
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
56
+ }
57
+ __name(relativeLuminance, "relativeLuminance");
58
+ function contrastRatio(fg, bg) {
59
+ const a = parseColor(fg);
60
+ const b = parseColor(bg);
61
+ if (!a || !b) return null;
62
+ const l1 = relativeLuminance([
63
+ a[0],
64
+ a[1],
65
+ a[2]
66
+ ]);
67
+ const l2 = relativeLuminance([
68
+ b[0],
69
+ b[1],
70
+ b[2]
71
+ ]);
72
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
73
+ }
74
+ __name(contrastRatio, "contrastRatio");
75
+ function alphaComposite(fg, bg) {
76
+ const f = parseColor(fg);
77
+ const b = parseColor(bg);
78
+ if (!f || !b) return fg;
79
+ const a = f[3];
80
+ return toHex(f[0] * a + b[0] * (1 - a), f[1] * a + b[1] * (1 - a), f[2] * a + b[2] * (1 - a));
81
+ }
82
+ __name(alphaComposite, "alphaComposite");
83
+ var linearize = /* @__PURE__ */ __name((c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4), "linearize");
84
+ var delinearize = /* @__PURE__ */ __name((c) => c <= 31308e-7 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055, "delinearize");
85
+ function linearSrgbToOklab(r, g, b) {
86
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
87
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
88
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
89
+ const l_ = Math.cbrt(l);
90
+ const m_ = Math.cbrt(m);
91
+ const s_ = Math.cbrt(s);
92
+ return [
93
+ 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
94
+ 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
95
+ 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
96
+ ];
97
+ }
98
+ __name(linearSrgbToOklab, "linearSrgbToOklab");
99
+ function oklabToLinearSrgb(L, a, b) {
100
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
101
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
102
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
103
+ const l = l_ * l_ * l_;
104
+ const m = m_ * m_ * m_;
105
+ const s = s_ * s_ * s_;
106
+ return [
107
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
108
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
109
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
110
+ ];
111
+ }
112
+ __name(oklabToLinearSrgb, "oklabToLinearSrgb");
113
+ function toOKLCH(hex) {
114
+ const p = parseColor(hex);
115
+ if (!p) throw new Error(`toOKLCH: unparseable colour "${hex}"`);
116
+ const [L, a, b] = linearSrgbToOklab(linearize(p[0] / 255), linearize(p[1] / 255), linearize(p[2] / 255));
117
+ const C = Math.hypot(a, b);
118
+ let H = Math.atan2(b, a) * 180 / Math.PI;
119
+ if (H < 0) H += 360;
120
+ return {
121
+ L,
122
+ C,
123
+ H
124
+ };
125
+ }
126
+ __name(toOKLCH, "toOKLCH");
127
+ function fromOKLCH(L, C, H) {
128
+ const rad = H * Math.PI / 180;
129
+ const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(rad), C * Math.sin(rad));
130
+ return toHex(delinearize(r) * 255, delinearize(g) * 255, delinearize(b) * 255);
131
+ }
132
+ __name(fromOKLCH, "fromOKLCH");
133
+ function inGamut(L, C, H) {
134
+ const rad = H * Math.PI / 180;
135
+ const lin = oklabToLinearSrgb(L, C * Math.cos(rad), C * Math.sin(rad));
136
+ const eps = 1e-4;
137
+ return lin.every((c) => c >= -eps && c <= 1 + eps);
138
+ }
139
+ __name(inGamut, "inGamut");
140
+ function gamutClampToSrgb(L, C, H) {
141
+ if (inGamut(L, C, H)) return fromOKLCH(L, C, H);
142
+ let lo = 0;
143
+ let hi = C;
144
+ for (let i = 0; i < 28; i++) {
145
+ const mid = (lo + hi) / 2;
146
+ if (inGamut(L, mid, H)) lo = mid;
147
+ else hi = mid;
148
+ }
149
+ return fromOKLCH(L, lo, H);
150
+ }
151
+ __name(gamutClampToSrgb, "gamutClampToSrgb");
152
+
153
+ // src/constants.ts
154
+ import { tokens } from "@rhombuskit/tokens";
155
+ var RUNGS = [
156
+ 50,
157
+ 100,
158
+ 200,
159
+ 300,
160
+ 400,
161
+ 500,
162
+ 600,
163
+ 700,
164
+ 800,
165
+ 900,
166
+ 950
167
+ ];
168
+ var prim = tokens.primitives;
169
+ function sourceRamp(family) {
170
+ const ramp = {};
171
+ for (const step of RUNGS) {
172
+ const value = prim[`${family}-${step}`];
173
+ if (value) ramp[step] = value;
174
+ }
175
+ return ramp;
176
+ }
177
+ __name(sourceRamp, "sourceRamp");
178
+ var RAMPS = {
179
+ violet: sourceRamp("violet"),
180
+ slate: sourceRamp("slate"),
181
+ green: sourceRamp("green"),
182
+ amber: sourceRamp("amber"),
183
+ red: sourceRamp("red")
184
+ };
185
+ var L_ACCENT_CURVE = RUNGS.map((r) => toOKLCH(RAMPS.violet[r]).L);
186
+ var CHROMA_ENV = RUNGS.map((r) => toOKLCH(RAMPS.violet[r]).C);
187
+ var L_NEUTRAL_CURVE = RUNGS.map((r) => toOKLCH(RAMPS.slate[r]).L);
188
+ var C_NEUTRAL_CAP = RUNGS.map((r) => toOKLCH(RAMPS.slate[r]).C);
189
+ var BASE = {
190
+ light: tokens.themes["rhombus-light"],
191
+ dark: tokens.themes["rhombus-dark"]
192
+ };
193
+ var CONTRACT_KEYS = Object.keys(BASE.light);
194
+ var TEXT_PAIRS = [
195
+ [
196
+ "--text-primary",
197
+ "--bg"
198
+ ],
199
+ [
200
+ "--text-primary",
201
+ "--surface-0"
202
+ ],
203
+ [
204
+ "--text-primary",
205
+ "--surface-1"
206
+ ],
207
+ [
208
+ "--text-primary",
209
+ "--surface-2"
210
+ ],
211
+ [
212
+ "--text-secondary",
213
+ "--bg"
214
+ ],
215
+ [
216
+ "--text-secondary",
217
+ "--surface-0"
218
+ ],
219
+ [
220
+ "--text-muted",
221
+ "--bg"
222
+ ],
223
+ [
224
+ "--text-muted",
225
+ "--surface-0"
226
+ ],
227
+ [
228
+ "--text-accent",
229
+ "--bg"
230
+ ],
231
+ [
232
+ "--text-accent",
233
+ "--surface-0"
234
+ ],
235
+ [
236
+ "--btn-primary-text",
237
+ "--btn-primary-bg"
238
+ ],
239
+ [
240
+ "--text-on-accent",
241
+ "--btn-primary-bg"
242
+ ],
243
+ [
244
+ "--nav-active-text",
245
+ "--nav-active-bg"
246
+ ],
247
+ [
248
+ "--tooltip-text",
249
+ "--tooltip-bg"
250
+ ],
251
+ [
252
+ "--toast-info-text",
253
+ "--toast-info-bg"
254
+ ],
255
+ [
256
+ "--toast-success-text",
257
+ "--toast-success-bg"
258
+ ],
259
+ [
260
+ "--toast-warning-text",
261
+ "--toast-warning-bg"
262
+ ],
263
+ [
264
+ "--toast-error-text",
265
+ "--toast-error-bg"
266
+ ],
267
+ [
268
+ "--code-keyword",
269
+ "--surface-0"
270
+ ],
271
+ [
272
+ "--code-string",
273
+ "--surface-0"
274
+ ],
275
+ [
276
+ "--code-function",
277
+ "--surface-0"
278
+ ],
279
+ [
280
+ "--code-number",
281
+ "--surface-0"
282
+ ],
283
+ [
284
+ "--code-comment",
285
+ "--surface-0"
286
+ ],
287
+ [
288
+ "--code-punctuation",
289
+ "--surface-0"
290
+ ]
291
+ ];
292
+ var BADGE_PAIRS = [
293
+ [
294
+ "--status-draft-text",
295
+ "--status-draft-bg"
296
+ ],
297
+ [
298
+ "--status-published-text",
299
+ "--status-published-bg"
300
+ ],
301
+ [
302
+ "--status-scheduled-text",
303
+ "--status-scheduled-bg"
304
+ ],
305
+ [
306
+ "--status-archived-text",
307
+ "--status-archived-bg"
308
+ ]
309
+ ];
310
+
311
+ // src/validate.ts
312
+ function solidify(value, bg) {
313
+ if (value === void 0) return void 0;
314
+ const parsed = parseColor(value);
315
+ if (parsed && parsed[3] < 1) return alphaComposite(value, bg);
316
+ return value;
317
+ }
318
+ __name(solidify, "solidify");
319
+ function validateThemeAA(record) {
320
+ const bg = record["--bg"] ?? "#ffffff";
321
+ const failures = [];
322
+ const check = /* @__PURE__ */ __name((pairs, min) => {
323
+ for (const [fgTok, bgTok] of pairs) {
324
+ const fg = solidify(record[fgTok], bg);
325
+ const bgVal = solidify(record[bgTok], bg);
326
+ if (fg === void 0 || bgVal === void 0) continue;
327
+ const ratio = contrastRatio(fg, bgVal);
328
+ if (ratio === null) continue;
329
+ if (ratio < min) failures.push({
330
+ fg: fgTok,
331
+ bg: bgTok,
332
+ ratio,
333
+ min
334
+ });
335
+ }
336
+ }, "check");
337
+ check(TEXT_PAIRS, 4.5);
338
+ check(BADGE_PAIRS, 3);
339
+ return {
340
+ ok: failures.length === 0,
341
+ failures
342
+ };
343
+ }
344
+ __name(validateThemeAA, "validateThemeAA");
345
+
346
+ // src/errors.ts
347
+ var ThemeAAError = class extends Error {
348
+ static {
349
+ __name(this, "ThemeAAError");
350
+ }
351
+ mode;
352
+ failures;
353
+ constructor(mode, failures) {
354
+ super(`Generated ${mode} theme failed WCAG AA on ${failures.length} pair(s): ` + failures.map((f) => `${f.fg} on ${f.bg} = ${f.ratio.toFixed(2)}:1 (needs ${f.min}:1)`).join("; ")), this.mode = mode, this.failures = failures;
355
+ this.name = "ThemeAAError";
356
+ }
357
+ };
358
+
359
+ // src/generate.ts
360
+ var CANON_ACCENT = "#7c3aed";
361
+ var CANON_NEUTRAL = "#64748b";
362
+ var alpha = /* @__PURE__ */ __name((hex, a) => {
363
+ const p = parseColor(hex);
364
+ return `rgb(${p[0]} ${p[1]} ${p[2]} / ${a})`;
365
+ }, "alpha");
366
+ function deepTint(hex) {
367
+ return gamutClampToSrgb(0.2, 0.07, toOKLCH(hex).H);
368
+ }
369
+ __name(deepTint, "deepTint");
370
+ var ACCENT = {
371
+ light: {
372
+ "--text-accent": {
373
+ rung: 600
374
+ },
375
+ "--border-accent": {
376
+ rung: 200
377
+ },
378
+ "--focus-ring": {
379
+ fn: /* @__PURE__ */ __name((r) => alpha(r[600], "0.22"), "fn")
380
+ },
381
+ "--focus-border": {
382
+ rung: 600
383
+ },
384
+ "--btn-primary-bg": {
385
+ rung: 600
386
+ },
387
+ "--btn-primary-hover": {
388
+ rung: 700
389
+ },
390
+ "--nav-active-bg": {
391
+ rung: 50
392
+ },
393
+ "--nav-active-text": {
394
+ rung: 700
395
+ },
396
+ "--switch-track-on": {
397
+ rung: 600
398
+ },
399
+ "--shadow-btn-primary": {
400
+ fn: /* @__PURE__ */ __name((r) => `0 1px 2px 0 ${alpha(r[600], "0.25")}`, "fn")
401
+ },
402
+ "--status-scheduled-bg": {
403
+ rung: 50
404
+ },
405
+ "--status-scheduled-text": {
406
+ rung: 600
407
+ },
408
+ "--code-keyword": {
409
+ rung: 700
410
+ }
411
+ },
412
+ dark: {
413
+ "--text-accent": {
414
+ rung: 400
415
+ },
416
+ "--border-accent": {
417
+ fn: /* @__PURE__ */ __name((r) => alpha(r[400], "0.40"), "fn")
418
+ },
419
+ "--focus-ring": {
420
+ fn: /* @__PURE__ */ __name((r) => alpha(r[400], "0.28"), "fn")
421
+ },
422
+ "--focus-border": {
423
+ rung: 400
424
+ },
425
+ "--btn-primary-bg": {
426
+ rung: 600
427
+ },
428
+ "--btn-primary-hover": {
429
+ rung: 700
430
+ },
431
+ // Solid deep-tint (not the built-in's translucent alpha) so the generated theme also
432
+ // clears a raw-channel contrast guard — matching the shipped community themes, which use a
433
+ // solid dark nav-active-bg for exactly this reason (community-themes.ts). See validate.ts.
434
+ "--nav-active-bg": {
435
+ fn: /* @__PURE__ */ __name((r) => deepTint(r[600]), "fn")
436
+ },
437
+ "--switch-track-on": {
438
+ rung: 500
439
+ },
440
+ "--status-scheduled-bg": {
441
+ fn: /* @__PURE__ */ __name((r) => deepTint(r[600]), "fn")
442
+ },
443
+ "--status-scheduled-text": {
444
+ rung: 400
445
+ },
446
+ "--code-keyword": {
447
+ rung: 300
448
+ }
449
+ }
450
+ };
451
+ var NEUTRAL = {
452
+ light: {
453
+ "--bg": {
454
+ rung: 50
455
+ },
456
+ "--bg-subtle": {
457
+ rung: 100
458
+ },
459
+ "--surface-1": {
460
+ rung: 100
461
+ },
462
+ "--surface-2": {
463
+ rung: 200
464
+ },
465
+ "--surface-3": {
466
+ rung: 300
467
+ },
468
+ "--text-primary": {
469
+ rung: 900
470
+ },
471
+ "--text-secondary": {
472
+ rung: 600
473
+ },
474
+ "--text-muted": {
475
+ rung: 500
476
+ },
477
+ "--text-disabled": {
478
+ rung: 300
479
+ },
480
+ "--border": {
481
+ rung: 200
482
+ },
483
+ "--border-strong": {
484
+ rung: 400
485
+ },
486
+ "--switch-track-off": {
487
+ rung: 300
488
+ },
489
+ "--tooltip-bg": {
490
+ rung: 900
491
+ },
492
+ "--toast-info-bg": {
493
+ rung: 100
494
+ },
495
+ "--toast-info-text": {
496
+ rung: 700
497
+ },
498
+ "--status-archived-bg": {
499
+ rung: 100
500
+ },
501
+ "--status-archived-text": {
502
+ rung: 600
503
+ },
504
+ "--code-comment": {
505
+ rung: 500
506
+ },
507
+ "--code-punctuation": {
508
+ rung: 600
509
+ }
510
+ },
511
+ dark: {
512
+ "--bg": {
513
+ rung: 950
514
+ },
515
+ "--bg-subtle": {
516
+ rung: 900
517
+ },
518
+ "--surface-0": {
519
+ rung: 900
520
+ },
521
+ "--surface-1": {
522
+ rung: 800
523
+ },
524
+ "--surface-2": {
525
+ rung: 700
526
+ },
527
+ "--surface-3": {
528
+ rung: 600
529
+ },
530
+ "--text-primary": {
531
+ rung: 50
532
+ },
533
+ "--text-secondary": {
534
+ rung: 300
535
+ },
536
+ "--text-muted": {
537
+ rung: 400
538
+ },
539
+ "--text-disabled": {
540
+ rung: 600
541
+ },
542
+ "--border": {
543
+ rung: 700
544
+ },
545
+ "--border-strong": {
546
+ rung: 500
547
+ },
548
+ "--switch-track-off": {
549
+ rung: 600
550
+ },
551
+ "--tooltip-bg": {
552
+ rung: 700
553
+ },
554
+ "--toast-info-bg": {
555
+ rung: 800
556
+ },
557
+ "--toast-info-text": {
558
+ rung: 200
559
+ },
560
+ "--status-archived-bg": {
561
+ rung: 800
562
+ },
563
+ "--status-archived-text": {
564
+ rung: 400
565
+ },
566
+ "--code-comment": {
567
+ rung: 400
568
+ },
569
+ "--code-punctuation": {
570
+ rung: 300
571
+ },
572
+ "--nav-active-text": {
573
+ rung: 50
574
+ }
575
+ }
576
+ };
577
+ function applyTable(rec, ramp, table, prov) {
578
+ for (const key of Object.keys(table)) {
579
+ const recipe = table[key];
580
+ if ("rung" in recipe) {
581
+ rec[key] = ramp[recipe.rung];
582
+ if (prov) prov[key] = {
583
+ ramp,
584
+ idx: RUNGS.indexOf(recipe.rung)
585
+ };
586
+ } else {
587
+ rec[key] = recipe.fn(ramp);
588
+ }
589
+ }
590
+ }
591
+ __name(applyTable, "applyTable");
592
+ function overrideAccent(rec, ramp, mode, prov) {
593
+ applyTable(rec, ramp, ACCENT[mode], prov);
594
+ }
595
+ __name(overrideAccent, "overrideAccent");
596
+ function overrideNeutral(rec, ramp, mode, prov) {
597
+ applyTable(rec, ramp, NEUTRAL[mode], prov);
598
+ }
599
+ __name(overrideNeutral, "overrideNeutral");
600
+ function anchorOf(curve, L) {
601
+ let best = 0;
602
+ for (let i = 1; i < curve.length; i++) {
603
+ if (Math.abs(curve[i] - L) < Math.abs(curve[best] - L)) best = i;
604
+ }
605
+ return best;
606
+ }
607
+ __name(anchorOf, "anchorOf");
608
+ function buildRamp(seedHex, Lcurve, Cenv, cap, vividness = 1) {
609
+ const { L: Ls, C: Cs, H: Hs } = toOKLCH(seedHex);
610
+ const anchor = anchorOf(Lcurve, Ls);
611
+ const kC = Cs / (Cenv[anchor] || 1e-6);
612
+ const ramp = {};
613
+ for (let i = 0; i < RUNGS.length; i++) {
614
+ const Li = Lcurve[i];
615
+ let Ci = Cenv[i] * kC * vividness;
616
+ if (cap) Ci = Math.min(Ci, cap[i]);
617
+ ramp[RUNGS[i]] = i === anchor && Math.abs(Li - Ls) < 0.02 ? seedHex : gamutClampToSrgb(Li, Ci, Hs);
618
+ }
619
+ return ramp;
620
+ }
621
+ __name(buildRamp, "buildRamp");
622
+ function nudgeFailing(rec, mode, prov, budget) {
623
+ const dir = mode === "light" ? 1 : -1;
624
+ const bg = rec["--bg"];
625
+ for (const [pairs, min] of [
626
+ [
627
+ TEXT_PAIRS,
628
+ 4.5
629
+ ],
630
+ [
631
+ BADGE_PAIRS,
632
+ 3
633
+ ]
634
+ ]) {
635
+ for (const [fgTok, bgTok] of pairs) {
636
+ const p = prov[fgTok];
637
+ if (!p) continue;
638
+ for (let k = 0; k < budget; k++) {
639
+ const fg = solidify(rec[fgTok], bg);
640
+ const bgVal = solidify(rec[bgTok], bg);
641
+ const ratio = fg && bgVal ? contrastRatio(fg, bgVal) : null;
642
+ if (ratio === null || ratio >= min) break;
643
+ const idx = p.idx + dir;
644
+ if (idx < 0 || idx >= RUNGS.length) break;
645
+ p.idx = idx;
646
+ rec[fgTok] = p.ramp[RUNGS[idx]];
647
+ }
648
+ }
649
+ }
650
+ }
651
+ __name(nudgeFailing, "nudgeFailing");
652
+ var HUE_NAMES = [
653
+ [
654
+ 15,
655
+ "red"
656
+ ],
657
+ [
658
+ 45,
659
+ "orange"
660
+ ],
661
+ [
662
+ 70,
663
+ "amber"
664
+ ],
665
+ [
666
+ 95,
667
+ "yellow"
668
+ ],
669
+ [
670
+ 135,
671
+ "lime"
672
+ ],
673
+ [
674
+ 165,
675
+ "green"
676
+ ],
677
+ [
678
+ 195,
679
+ "teal"
680
+ ],
681
+ [
682
+ 225,
683
+ "cyan"
684
+ ],
685
+ [
686
+ 265,
687
+ "blue"
688
+ ],
689
+ [
690
+ 295,
691
+ "indigo"
692
+ ],
693
+ [
694
+ 330,
695
+ "violet"
696
+ ],
697
+ [
698
+ 350,
699
+ "magenta"
700
+ ],
701
+ [
702
+ 360,
703
+ "red"
704
+ ]
705
+ ];
706
+ var hueName = /* @__PURE__ */ __name((h) => HUE_NAMES.find(([max]) => h < max)?.[1] ?? "custom", "hueName");
707
+ var clampVividness = /* @__PURE__ */ __name((v) => v === void 0 ? 1 : Math.min(1.5, Math.max(0.5, v)), "clampVividness");
708
+ function build(seed = {}, opts = {}) {
709
+ const nudge = opts.nudge !== false;
710
+ const vividness = clampVividness(seed.vividness);
711
+ const warnings = [];
712
+ const accentHex = seed.accent === void 0 ? CANON_ACCENT : normalizeHex(seed.accent);
713
+ if (accentHex === null) throw new Error(`Invalid accent colour: "${seed.accent}"`);
714
+ const neutralHex = seed.neutral === void 0 ? CANON_NEUTRAL : normalizeHex(seed.neutral);
715
+ if (neutralHex === null) throw new Error(`Invalid neutral colour: "${seed.neutral}"`);
716
+ const accentCustom = accentHex !== CANON_ACCENT;
717
+ const neutralCustom = neutralHex !== CANON_NEUTRAL;
718
+ const light = {
719
+ ...BASE.light
720
+ };
721
+ const dark = {
722
+ ...BASE.dark
723
+ };
724
+ const provLight = {};
725
+ const provDark = {};
726
+ if (accentCustom) {
727
+ const A = buildRamp(accentHex, L_ACCENT_CURVE, CHROMA_ENV, void 0, vividness);
728
+ overrideAccent(light, A, "light", provLight);
729
+ overrideAccent(dark, A, "dark", provDark);
730
+ const { L: Ls, C: Cs } = toOKLCH(accentHex);
731
+ const drift = Math.abs(RUNGS.indexOf(600) - anchorOf(L_ACCENT_CURVE, Ls));
732
+ if (drift > 2) {
733
+ warnings.push(`Accent ${accentHex} sits ${drift} rungs from the accessible accent lightness; it was adjusted to meet contrast, so the rendered accent is darker/lighter than the seed.`);
734
+ }
735
+ if (Cs < 0.04) {
736
+ warnings.push(`Accent ${accentHex} has very low chroma and will read close to neutral.`);
737
+ }
738
+ }
739
+ if (neutralCustom) {
740
+ const N = buildRamp(neutralHex, L_NEUTRAL_CURVE, C_NEUTRAL_CAP, C_NEUTRAL_CAP, vividness);
741
+ overrideNeutral(light, N, "light", provLight);
742
+ overrideNeutral(dark, N, "dark", provDark);
743
+ }
744
+ for (const [rec, mode, prov] of [
745
+ [
746
+ light,
747
+ "light",
748
+ provLight
749
+ ],
750
+ [
751
+ dark,
752
+ "dark",
753
+ provDark
754
+ ]
755
+ ]) {
756
+ if (nudge) nudgeFailing(rec, mode, prov, 2);
757
+ const result = validateThemeAA(rec);
758
+ if (!result.ok) throw new ThemeAAError(mode, result.failures);
759
+ }
760
+ const name = seed.name ?? (accentCustom ? hueName(toOKLCH(accentHex).H) : "rhombus");
761
+ const label = seed.label ?? name.charAt(0).toUpperCase() + name.slice(1);
762
+ return {
763
+ name,
764
+ label,
765
+ light,
766
+ dark,
767
+ report: {
768
+ warnings
769
+ }
770
+ };
771
+ }
772
+ __name(build, "build");
773
+
774
+ // src/serialize.ts
775
+ function block(name, values) {
776
+ const lines = CONTRACT_KEYS.map((key) => ` ${key}: ${values[key]};`).join("\n");
777
+ return `[data-theme="${name}"] {
778
+ ${lines}
779
+ }`;
780
+ }
781
+ __name(block, "block");
782
+ function serializeThemeCss(theme) {
783
+ return `${block(theme.name, theme.light)}
784
+
785
+ ${block(`${theme.name}-dark`, theme.dark)}
786
+ `;
787
+ }
788
+ __name(serializeThemeCss, "serializeThemeCss");
789
+ function toRegisteredThemes(theme) {
790
+ return [
791
+ {
792
+ name: theme.name,
793
+ label: theme.label,
794
+ mode: "light",
795
+ palette: theme.name
796
+ },
797
+ {
798
+ name: `${theme.name}-dark`,
799
+ label: theme.label,
800
+ mode: "dark",
801
+ palette: theme.name
802
+ }
803
+ ];
804
+ }
805
+ __name(toRegisteredThemes, "toRegisteredThemes");
806
+ function toAugmentation(theme) {
807
+ return `declare module '@rhombuskit/theme-engine' {
808
+ interface ThemeRegistry {
809
+ '${theme.name}': true;
810
+ '${theme.name}-dark': true;
811
+ }
812
+ }
813
+ `;
814
+ }
815
+ __name(toAugmentation, "toAugmentation");
816
+ export {
817
+ ThemeAAError,
818
+ alphaComposite,
819
+ buildRamp,
820
+ contrastRatio,
821
+ deepTint,
822
+ fromOKLCH,
823
+ gamutClampToSrgb,
824
+ build as generateTheme,
825
+ normalizeHex,
826
+ parseColor,
827
+ relativeLuminance,
828
+ serializeThemeCss,
829
+ toAugmentation,
830
+ toOKLCH,
831
+ toRegisteredThemes,
832
+ validateThemeAA
833
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@rhombuskit/theme-builder",
3
+ "version": "1.15.0",
4
+ "license": "MIT",
5
+ "description": "Generate WCAG-AA-validated RhombusKit token-override themes from a few seed colours — a framework-agnostic seed→theme generator that emits both light and dark packs over the full 60-token CONTRACT.",
6
+ "keywords": [
7
+ "design-tokens",
8
+ "theming",
9
+ "color",
10
+ "oklch",
11
+ "wcag",
12
+ "accessibility",
13
+ "design-system",
14
+ "rhombuskit"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/doug-williamson/RhombusKit.git",
22
+ "directory": "packages/theme-builder"
23
+ },
24
+ "homepage": "https://rhombuskit.online",
25
+ "funding": "https://github.com/sponsors/doug-williamson",
26
+ "bugs": {
27
+ "url": "https://github.com/doug-williamson/RhombusKit/issues"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.mts",
32
+ "import": "./dist/index.mjs",
33
+ "default": "./dist/index.mjs"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "main": "./dist/index.mjs",
38
+ "types": "./dist/index.d.mts",
39
+ "files": [
40
+ "dist"
41
+ ],
42
+ "dependencies": {
43
+ "@rhombuskit/tokens": "1.15.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup"
47
+ }
48
+ }