@arun-dev/tokens 0.29.0 → 0.31.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.
@@ -20,7 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/createBrand.ts
21
21
  var createBrand_exports = {};
22
22
  __export(createBrand_exports, {
23
+ CONTRAST_REQUIREMENTS: () => CONTRAST_REQUIREMENTS,
23
24
  DEFAULT_NEUTRAL: () => DEFAULT_NEUTRAL,
25
+ contrastRatio: () => contrastRatio,
24
26
  createBrand: () => createBrand,
25
27
  generatePaletteFromSeed: () => generatePaletteFromSeed
26
28
  });
@@ -78,6 +80,17 @@ function hslToHex(h, s, l) {
78
80
  };
79
81
  return `#${f(0)}${f(8)}${f(4)}`;
80
82
  }
83
+ function relativeLuminance(hex) {
84
+ const [r, g, b] = hexToRgb(hex).map(
85
+ (v) => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
86
+ );
87
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
88
+ }
89
+ function contrastRatio(a, b) {
90
+ const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
91
+ const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
92
+ return (lighter + 0.05) / (darker + 0.05);
93
+ }
81
94
  var SHADE_LIGHTNESS = {
82
95
  50: 97,
83
96
  100: 93,
@@ -136,7 +149,8 @@ function deriveLight() {
136
149
  "--color-border-strong": "var(--color-neutral-300)",
137
150
  "--color-border-accent": "var(--color-brand-300)",
138
151
  ...STATUS_LIGHT,
139
- "--color-status-info": "var(--color-brand-500)",
152
+ // 600, not 500: the info Badge's text sits on brand-50, and 500 there was 3.99:1.
153
+ "--color-status-info": "var(--color-brand-600)",
140
154
  "--color-status-info-bg": "var(--color-brand-50)"
141
155
  };
142
156
  }
@@ -152,7 +166,8 @@ function deriveDark() {
152
166
  "--color-text-muted": "var(--color-neutral-400)",
153
167
  "--color-text-accent": "var(--color-brand-300)",
154
168
  "--color-text-inverse": "var(--color-neutral-900)",
155
- "--color-text-on-accent": "var(--color-neutral-0)",
169
+ // The accent is light in dark mode, so the text on it is dark.
170
+ "--color-text-on-accent": "var(--color-neutral-950)",
156
171
  "--color-border-default": "var(--color-neutral-700)",
157
172
  "--color-border-subtle": "var(--color-neutral-800)",
158
173
  "--color-border-strong": "var(--color-neutral-600)",
@@ -162,6 +177,80 @@ function deriveDark() {
162
177
  "--color-status-info-bg": "var(--color-brand-950)"
163
178
  };
164
179
  }
180
+ var CONTRAST_REQUIREMENTS = [
181
+ // Body text on every page surface.
182
+ { foreground: "--color-text-primary", background: "--color-bg-primary", min: 7 },
183
+ { foreground: "--color-text-primary", background: "--color-bg-secondary", min: 7 },
184
+ { foreground: "--color-text-primary", background: "--color-bg-surface", min: 4.5 },
185
+ { foreground: "--color-text-secondary", background: "--color-bg-primary", min: 7 },
186
+ { foreground: "--color-text-muted", background: "--color-bg-primary", min: 4.5 },
187
+ { foreground: "--color-text-inverse", background: "--color-bg-inverse", min: 7 },
188
+ // The accent as text: links, accent chips, focus outlines.
189
+ { foreground: "--color-text-accent", background: "--color-bg-primary", min: 7 },
190
+ { foreground: "--color-text-accent", background: "--color-bg-secondary", min: 4.5 },
191
+ { foreground: "--color-text-accent", background: "--color-bg-surface", min: 4.5 },
192
+ { foreground: "--color-text-accent", background: "--color-bg-accent", min: 4.5 },
193
+ // The accent as a fill: primary Button, checked Checkbox and Radio, the current page.
194
+ { foreground: "--color-text-on-accent", background: "--color-text-accent", min: 7 },
195
+ // Status tones: a Badge's text on its tint, an Alert's or Toast's accent on the page.
196
+ { foreground: "--color-status-success", background: "--color-status-success-bg", min: 4.5 },
197
+ { foreground: "--color-status-error", background: "--color-status-error-bg", min: 4.5 },
198
+ { foreground: "--color-status-warning", background: "--color-status-warning-bg", min: 4.5 },
199
+ { foreground: "--color-status-info", background: "--color-status-info-bg", min: 4.5 },
200
+ { foreground: "--color-status-info", background: "--color-bg-primary", min: 3 }
201
+ ];
202
+ var BRAND_VAR = /^var\(--color-brand-(\d+)\)$/;
203
+ var NEUTRAL_VAR = /^var\(--color-neutral-(\d+)\)$/;
204
+ function resolveColor(value, palette, neutral) {
205
+ const brand = BRAND_VAR.exec(value);
206
+ if (brand) return palette[Number(brand[1])];
207
+ const gray = NEUTRAL_VAR.exec(value);
208
+ if (gray) return neutral[Number(gray[1])];
209
+ return value;
210
+ }
211
+ function shiftLightness(hex, step) {
212
+ const [h, s, l] = rgbToHsl(...hexToRgb(hex));
213
+ return hslToHex(h, s, Math.min(100, Math.max(0, l + step)));
214
+ }
215
+ var SHADES = Object.keys(SHADE_LIGHTNESS).map(Number);
216
+ function fitContrast(palette, neutral) {
217
+ const fitted = { ...palette };
218
+ const themes = [deriveLight(), deriveDark()];
219
+ for (let pass = 0; pass < 4; pass++) {
220
+ let moved = false;
221
+ for (const theme of themes) {
222
+ for (const { foreground, background, min } of CONTRAST_REQUIREMENTS) {
223
+ const fgValue = theme[foreground] ?? "";
224
+ const bgValue = theme[background] ?? "";
225
+ const fgShade = BRAND_VAR.exec(fgValue)?.[1];
226
+ const bgShade = BRAND_VAR.exec(bgValue)?.[1];
227
+ const shade = Number(fgShade ?? bgShade);
228
+ if (!fgShade && !bgShade) continue;
229
+ const other = resolveColor(fgShade ? bgValue : fgValue, fitted, neutral);
230
+ const step = relativeLuminance(other) > 0.18 ? -1 : 1;
231
+ for (let i = 0; i < 100 && contrastRatio(fitted[shade], other) < min; i++) {
232
+ fitted[shade] = shiftLightness(fitted[shade], step);
233
+ moved = true;
234
+ }
235
+ }
236
+ }
237
+ const middle = SHADES.indexOf(500);
238
+ for (let i = middle + 1; i < SHADES.length; i++) {
239
+ const [before, shade] = [SHADES[i - 1], SHADES[i]];
240
+ for (let n = 0; n < 100 && relativeLuminance(fitted[shade]) >= relativeLuminance(fitted[before]); n++) {
241
+ fitted[shade] = shiftLightness(fitted[shade], -1);
242
+ }
243
+ }
244
+ for (let i = middle - 1; i >= 0; i--) {
245
+ const [after, shade] = [SHADES[i + 1], SHADES[i]];
246
+ for (let n = 0; n < 100 && relativeLuminance(fitted[shade]) <= relativeLuminance(fitted[after]); n++) {
247
+ fitted[shade] = shiftLightness(fitted[shade], 1);
248
+ }
249
+ }
250
+ if (!moved) break;
251
+ }
252
+ return fitted;
253
+ }
165
254
  function toVars(tokens, indent = " ") {
166
255
  return Object.entries(tokens).map(([k, v]) => `${indent}${k}: ${v};`).join("\n");
167
256
  }
@@ -173,7 +262,7 @@ function neutralVars(neutral) {
173
262
  }
174
263
  function createBrand(input) {
175
264
  const { name, neutral = DEFAULT_NEUTRAL } = input;
176
- const palette = "seed" in input ? generatePaletteFromSeed(input.seed) : input.palette;
265
+ const palette = "seed" in input ? fitContrast(generatePaletteFromSeed(input.seed), neutral) : input.palette;
177
266
  const light = deriveLight();
178
267
  const dark = deriveDark();
179
268
  return `/* ${name} brand \u2014 generated by createBrand (@arun-dev/tokens) */
@@ -204,7 +293,9 @@ ${toVars(light)}
204
293
  }
205
294
  // Annotate the CommonJS export names for ESM import in node:
206
295
  0 && (module.exports = {
296
+ CONTRAST_REQUIREMENTS,
207
297
  DEFAULT_NEUTRAL,
298
+ contrastRatio,
208
299
  createBrand,
209
300
  generatePaletteFromSeed
210
301
  });
@@ -31,6 +31,8 @@ type CreateBrandInput = {
31
31
  neutral?: NeutralPalette;
32
32
  } & (WithPalette | WithSeed);
33
33
  declare const DEFAULT_NEUTRAL: NeutralPalette;
34
+ /** WCAG 2 contrast ratio between two #rrggbb colours, from 1 to 21. */
35
+ declare function contrastRatio(a: string, b: string): number;
34
36
  /**
35
37
  * Generate an 11-step palette from a seed hex color.
36
38
  * Uses HSL color space — perceptually approximate but dependency-free.
@@ -65,10 +67,20 @@ type BrandSemanticContract = {
65
67
  '--color-status-info': string;
66
68
  '--color-status-info-bg': string;
67
69
  };
70
+ /**
71
+ * Every pairing of semantic tokens that @arun-dev/ui draws one on the other, with the WCAG
72
+ * contrast it needs: 7 where the default brand is audited AAA, 4.5 for other text, 3 for
73
+ * icons, accent bars and focus outlines. A brand that skips createBrand should meet these too.
74
+ */
75
+ declare const CONTRAST_REQUIREMENTS: readonly {
76
+ foreground: keyof BrandSemanticContract;
77
+ background: keyof BrandSemanticContract;
78
+ min: number;
79
+ }[];
68
80
  /**
69
81
  * Generate a complete brand CSS string from a palette or seed color.
70
82
  * Write the output to a .css file and import it in your globals.css.
71
83
  */
72
84
  declare function createBrand(input: CreateBrandInput): string;
73
85
 
74
- export { type BrandPalette, type BrandSemanticContract, type CreateBrandInput, DEFAULT_NEUTRAL, type NeutralPalette, createBrand, generatePaletteFromSeed };
86
+ export { type BrandPalette, type BrandSemanticContract, CONTRAST_REQUIREMENTS, type CreateBrandInput, DEFAULT_NEUTRAL, type NeutralPalette, contrastRatio, createBrand, generatePaletteFromSeed };
@@ -31,6 +31,8 @@ type CreateBrandInput = {
31
31
  neutral?: NeutralPalette;
32
32
  } & (WithPalette | WithSeed);
33
33
  declare const DEFAULT_NEUTRAL: NeutralPalette;
34
+ /** WCAG 2 contrast ratio between two #rrggbb colours, from 1 to 21. */
35
+ declare function contrastRatio(a: string, b: string): number;
34
36
  /**
35
37
  * Generate an 11-step palette from a seed hex color.
36
38
  * Uses HSL color space — perceptually approximate but dependency-free.
@@ -65,10 +67,20 @@ type BrandSemanticContract = {
65
67
  '--color-status-info': string;
66
68
  '--color-status-info-bg': string;
67
69
  };
70
+ /**
71
+ * Every pairing of semantic tokens that @arun-dev/ui draws one on the other, with the WCAG
72
+ * contrast it needs: 7 where the default brand is audited AAA, 4.5 for other text, 3 for
73
+ * icons, accent bars and focus outlines. A brand that skips createBrand should meet these too.
74
+ */
75
+ declare const CONTRAST_REQUIREMENTS: readonly {
76
+ foreground: keyof BrandSemanticContract;
77
+ background: keyof BrandSemanticContract;
78
+ min: number;
79
+ }[];
68
80
  /**
69
81
  * Generate a complete brand CSS string from a palette or seed color.
70
82
  * Write the output to a .css file and import it in your globals.css.
71
83
  */
72
84
  declare function createBrand(input: CreateBrandInput): string;
73
85
 
74
- export { type BrandPalette, type BrandSemanticContract, type CreateBrandInput, DEFAULT_NEUTRAL, type NeutralPalette, createBrand, generatePaletteFromSeed };
86
+ export { type BrandPalette, type BrandSemanticContract, CONTRAST_REQUIREMENTS, type CreateBrandInput, DEFAULT_NEUTRAL, type NeutralPalette, contrastRatio, createBrand, generatePaletteFromSeed };
@@ -52,6 +52,17 @@ function hslToHex(h, s, l) {
52
52
  };
53
53
  return `#${f(0)}${f(8)}${f(4)}`;
54
54
  }
55
+ function relativeLuminance(hex) {
56
+ const [r, g, b] = hexToRgb(hex).map(
57
+ (v) => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
58
+ );
59
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
60
+ }
61
+ function contrastRatio(a, b) {
62
+ const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
63
+ const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
64
+ return (lighter + 0.05) / (darker + 0.05);
65
+ }
55
66
  var SHADE_LIGHTNESS = {
56
67
  50: 97,
57
68
  100: 93,
@@ -110,7 +121,8 @@ function deriveLight() {
110
121
  "--color-border-strong": "var(--color-neutral-300)",
111
122
  "--color-border-accent": "var(--color-brand-300)",
112
123
  ...STATUS_LIGHT,
113
- "--color-status-info": "var(--color-brand-500)",
124
+ // 600, not 500: the info Badge's text sits on brand-50, and 500 there was 3.99:1.
125
+ "--color-status-info": "var(--color-brand-600)",
114
126
  "--color-status-info-bg": "var(--color-brand-50)"
115
127
  };
116
128
  }
@@ -126,7 +138,8 @@ function deriveDark() {
126
138
  "--color-text-muted": "var(--color-neutral-400)",
127
139
  "--color-text-accent": "var(--color-brand-300)",
128
140
  "--color-text-inverse": "var(--color-neutral-900)",
129
- "--color-text-on-accent": "var(--color-neutral-0)",
141
+ // The accent is light in dark mode, so the text on it is dark.
142
+ "--color-text-on-accent": "var(--color-neutral-950)",
130
143
  "--color-border-default": "var(--color-neutral-700)",
131
144
  "--color-border-subtle": "var(--color-neutral-800)",
132
145
  "--color-border-strong": "var(--color-neutral-600)",
@@ -136,6 +149,80 @@ function deriveDark() {
136
149
  "--color-status-info-bg": "var(--color-brand-950)"
137
150
  };
138
151
  }
152
+ var CONTRAST_REQUIREMENTS = [
153
+ // Body text on every page surface.
154
+ { foreground: "--color-text-primary", background: "--color-bg-primary", min: 7 },
155
+ { foreground: "--color-text-primary", background: "--color-bg-secondary", min: 7 },
156
+ { foreground: "--color-text-primary", background: "--color-bg-surface", min: 4.5 },
157
+ { foreground: "--color-text-secondary", background: "--color-bg-primary", min: 7 },
158
+ { foreground: "--color-text-muted", background: "--color-bg-primary", min: 4.5 },
159
+ { foreground: "--color-text-inverse", background: "--color-bg-inverse", min: 7 },
160
+ // The accent as text: links, accent chips, focus outlines.
161
+ { foreground: "--color-text-accent", background: "--color-bg-primary", min: 7 },
162
+ { foreground: "--color-text-accent", background: "--color-bg-secondary", min: 4.5 },
163
+ { foreground: "--color-text-accent", background: "--color-bg-surface", min: 4.5 },
164
+ { foreground: "--color-text-accent", background: "--color-bg-accent", min: 4.5 },
165
+ // The accent as a fill: primary Button, checked Checkbox and Radio, the current page.
166
+ { foreground: "--color-text-on-accent", background: "--color-text-accent", min: 7 },
167
+ // Status tones: a Badge's text on its tint, an Alert's or Toast's accent on the page.
168
+ { foreground: "--color-status-success", background: "--color-status-success-bg", min: 4.5 },
169
+ { foreground: "--color-status-error", background: "--color-status-error-bg", min: 4.5 },
170
+ { foreground: "--color-status-warning", background: "--color-status-warning-bg", min: 4.5 },
171
+ { foreground: "--color-status-info", background: "--color-status-info-bg", min: 4.5 },
172
+ { foreground: "--color-status-info", background: "--color-bg-primary", min: 3 }
173
+ ];
174
+ var BRAND_VAR = /^var\(--color-brand-(\d+)\)$/;
175
+ var NEUTRAL_VAR = /^var\(--color-neutral-(\d+)\)$/;
176
+ function resolveColor(value, palette, neutral) {
177
+ const brand = BRAND_VAR.exec(value);
178
+ if (brand) return palette[Number(brand[1])];
179
+ const gray = NEUTRAL_VAR.exec(value);
180
+ if (gray) return neutral[Number(gray[1])];
181
+ return value;
182
+ }
183
+ function shiftLightness(hex, step) {
184
+ const [h, s, l] = rgbToHsl(...hexToRgb(hex));
185
+ return hslToHex(h, s, Math.min(100, Math.max(0, l + step)));
186
+ }
187
+ var SHADES = Object.keys(SHADE_LIGHTNESS).map(Number);
188
+ function fitContrast(palette, neutral) {
189
+ const fitted = { ...palette };
190
+ const themes = [deriveLight(), deriveDark()];
191
+ for (let pass = 0; pass < 4; pass++) {
192
+ let moved = false;
193
+ for (const theme of themes) {
194
+ for (const { foreground, background, min } of CONTRAST_REQUIREMENTS) {
195
+ const fgValue = theme[foreground] ?? "";
196
+ const bgValue = theme[background] ?? "";
197
+ const fgShade = BRAND_VAR.exec(fgValue)?.[1];
198
+ const bgShade = BRAND_VAR.exec(bgValue)?.[1];
199
+ const shade = Number(fgShade ?? bgShade);
200
+ if (!fgShade && !bgShade) continue;
201
+ const other = resolveColor(fgShade ? bgValue : fgValue, fitted, neutral);
202
+ const step = relativeLuminance(other) > 0.18 ? -1 : 1;
203
+ for (let i = 0; i < 100 && contrastRatio(fitted[shade], other) < min; i++) {
204
+ fitted[shade] = shiftLightness(fitted[shade], step);
205
+ moved = true;
206
+ }
207
+ }
208
+ }
209
+ const middle = SHADES.indexOf(500);
210
+ for (let i = middle + 1; i < SHADES.length; i++) {
211
+ const [before, shade] = [SHADES[i - 1], SHADES[i]];
212
+ for (let n = 0; n < 100 && relativeLuminance(fitted[shade]) >= relativeLuminance(fitted[before]); n++) {
213
+ fitted[shade] = shiftLightness(fitted[shade], -1);
214
+ }
215
+ }
216
+ for (let i = middle - 1; i >= 0; i--) {
217
+ const [after, shade] = [SHADES[i + 1], SHADES[i]];
218
+ for (let n = 0; n < 100 && relativeLuminance(fitted[shade]) <= relativeLuminance(fitted[after]); n++) {
219
+ fitted[shade] = shiftLightness(fitted[shade], 1);
220
+ }
221
+ }
222
+ if (!moved) break;
223
+ }
224
+ return fitted;
225
+ }
139
226
  function toVars(tokens, indent = " ") {
140
227
  return Object.entries(tokens).map(([k, v]) => `${indent}${k}: ${v};`).join("\n");
141
228
  }
@@ -147,7 +234,7 @@ function neutralVars(neutral) {
147
234
  }
148
235
  function createBrand(input) {
149
236
  const { name, neutral = DEFAULT_NEUTRAL } = input;
150
- const palette = "seed" in input ? generatePaletteFromSeed(input.seed) : input.palette;
237
+ const palette = "seed" in input ? fitContrast(generatePaletteFromSeed(input.seed), neutral) : input.palette;
151
238
  const light = deriveLight();
152
239
  const dark = deriveDark();
153
240
  return `/* ${name} brand \u2014 generated by createBrand (@arun-dev/tokens) */
@@ -177,7 +264,9 @@ ${toVars(light)}
177
264
  `;
178
265
  }
179
266
  export {
267
+ CONTRAST_REQUIREMENTS,
180
268
  DEFAULT_NEUTRAL,
269
+ contrastRatio,
181
270
  createBrand,
182
271
  generatePaletteFromSeed
183
272
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arun-dev/tokens",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Pure-CSS design tokens with white-label brand generation — primitives, brand palettes, semantic layers, and the createBrand() generator",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  --color-status-error-bg: #fef2f2;
35
35
  --color-status-warning: #b45309;
36
36
  --color-status-warning-bg: #fffbeb;
37
- --color-status-info: var(--color-brand-500);
37
+ --color-status-info: var(--color-brand-600); /* 5.62:1 on brand-50 */
38
38
  --color-status-info-bg: var(--color-brand-50);
39
39
  }
40
40
 
@@ -52,7 +52,7 @@
52
52
  --color-text-muted: var(--color-neutral-400); /* 7.13:1 on neutral-900 (AAA, palette nudged) */
53
53
  --color-text-accent: var(--color-brand-300); /* 8.97:1 on neutral-900 (AAA) */
54
54
  --color-text-inverse: var(--color-neutral-900);
55
- --color-text-on-accent: var(--color-neutral-0);
55
+ --color-text-on-accent: var(--color-neutral-950); /* 10.12:1 on brand-300 (AAA) */
56
56
 
57
57
  --color-border-default: var(--color-neutral-700);
58
58
  --color-border-subtle: var(--color-neutral-800);
@@ -83,7 +83,7 @@
83
83
  --color-text-muted: var(--color-neutral-400); /* 7.13:1 on neutral-900 (AAA, palette nudged) */
84
84
  --color-text-accent: var(--color-brand-300); /* 8.97:1 on neutral-900 (AAA) */
85
85
  --color-text-inverse: var(--color-neutral-900);
86
- --color-text-on-accent: var(--color-neutral-0);
86
+ --color-text-on-accent: var(--color-neutral-950); /* 10.12:1 on brand-300 (AAA) */
87
87
 
88
88
  --color-border-default: var(--color-neutral-700);
89
89
  --color-border-subtle: var(--color-neutral-800);
@@ -126,7 +126,7 @@
126
126
  --color-status-error-bg: #fef2f2;
127
127
  --color-status-warning: #b45309;
128
128
  --color-status-warning-bg: #fffbeb;
129
- --color-status-info: var(--color-brand-500);
129
+ --color-status-info: var(--color-brand-600); /* 5.62:1 on brand-50 */
130
130
  --color-status-info-bg: var(--color-brand-50);
131
131
  }
132
132
 
@@ -0,0 +1,15 @@
1
+ /* HoverCard component tokens — the card that previews a link on hover or focus.
2
+ Override any of these to restyle hover cards without moving other components. */
3
+ :root {
4
+ --hover-card-offset: var(--space-2xs);
5
+ --hover-card-width: 20rem;
6
+ --hover-card-padding: var(--space-sm);
7
+ --hover-card-radius: var(--radius-md);
8
+ --hover-card-bg: var(--color-bg-primary);
9
+ --hover-card-color: var(--color-text-primary);
10
+ --hover-card-border-width: var(--space-px);
11
+ --hover-card-border-color: var(--color-border-default);
12
+ --hover-card-shadow: var(--shadow-lg);
13
+ --hover-card-font-size: var(--text-sm);
14
+ --hover-card-duration: var(--duration-fast);
15
+ }
@@ -40,3 +40,4 @@
40
40
  @import './components/heading.css';
41
41
  @import './components/paragraph.css';
42
42
  @import './components/drawer.css';
43
+ @import './components/hover-card.css';
@@ -96,6 +96,21 @@ function hslToHex(h: number, s: number, l: number): string {
96
96
  return `#${f(0)}${f(8)}${f(4)}`;
97
97
  }
98
98
 
99
+ /** WCAG 2 relative luminance of a #rrggbb colour. */
100
+ function relativeLuminance(hex: string): number {
101
+ const [r, g, b] = hexToRgb(hex).map((v) =>
102
+ v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4,
103
+ ) as [number, number, number];
104
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
105
+ }
106
+
107
+ /** WCAG 2 contrast ratio between two #rrggbb colours, from 1 to 21. */
108
+ export function contrastRatio(a: string, b: string): number {
109
+ const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
110
+ const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
111
+ return (lighter + 0.05) / (darker + 0.05);
112
+ }
113
+
99
114
  /** Lightness targets for each shade step */
100
115
  const SHADE_LIGHTNESS: Record<Shade, number> = {
101
116
  50: 97,
@@ -206,7 +221,8 @@ function deriveLight(): Record<string, string> {
206
221
  '--color-border-strong': 'var(--color-neutral-300)',
207
222
  '--color-border-accent': 'var(--color-brand-300)',
208
223
  ...STATUS_LIGHT,
209
- '--color-status-info': 'var(--color-brand-500)',
224
+ // 600, not 500: the info Badge's text sits on brand-50, and 500 there was 3.99:1.
225
+ '--color-status-info': 'var(--color-brand-600)',
210
226
  '--color-status-info-bg': 'var(--color-brand-50)',
211
227
  };
212
228
  }
@@ -223,7 +239,8 @@ function deriveDark(): Record<string, string> {
223
239
  '--color-text-muted': 'var(--color-neutral-400)',
224
240
  '--color-text-accent': 'var(--color-brand-300)',
225
241
  '--color-text-inverse': 'var(--color-neutral-900)',
226
- '--color-text-on-accent': 'var(--color-neutral-0)',
242
+ // The accent is light in dark mode, so the text on it is dark.
243
+ '--color-text-on-accent': 'var(--color-neutral-950)',
227
244
  '--color-border-default': 'var(--color-neutral-700)',
228
245
  '--color-border-subtle': 'var(--color-neutral-800)',
229
246
  '--color-border-strong': 'var(--color-neutral-600)',
@@ -234,6 +251,119 @@ function deriveDark(): Record<string, string> {
234
251
  };
235
252
  }
236
253
 
254
+ // ─── Contrast ─────────────────────────────────────────────────────────────────
255
+
256
+ /**
257
+ * Every pairing of semantic tokens that @arun-dev/ui draws one on the other, with the WCAG
258
+ * contrast it needs: 7 where the default brand is audited AAA, 4.5 for other text, 3 for
259
+ * icons, accent bars and focus outlines. A brand that skips createBrand should meet these too.
260
+ */
261
+ export const CONTRAST_REQUIREMENTS: readonly {
262
+ foreground: keyof BrandSemanticContract;
263
+ background: keyof BrandSemanticContract;
264
+ min: number;
265
+ }[] = [
266
+ // Body text on every page surface.
267
+ { foreground: '--color-text-primary', background: '--color-bg-primary', min: 7 },
268
+ { foreground: '--color-text-primary', background: '--color-bg-secondary', min: 7 },
269
+ { foreground: '--color-text-primary', background: '--color-bg-surface', min: 4.5 },
270
+ { foreground: '--color-text-secondary', background: '--color-bg-primary', min: 7 },
271
+ { foreground: '--color-text-muted', background: '--color-bg-primary', min: 4.5 },
272
+ { foreground: '--color-text-inverse', background: '--color-bg-inverse', min: 7 },
273
+ // The accent as text: links, accent chips, focus outlines.
274
+ { foreground: '--color-text-accent', background: '--color-bg-primary', min: 7 },
275
+ { foreground: '--color-text-accent', background: '--color-bg-secondary', min: 4.5 },
276
+ { foreground: '--color-text-accent', background: '--color-bg-surface', min: 4.5 },
277
+ { foreground: '--color-text-accent', background: '--color-bg-accent', min: 4.5 },
278
+ // The accent as a fill: primary Button, checked Checkbox and Radio, the current page.
279
+ { foreground: '--color-text-on-accent', background: '--color-text-accent', min: 7 },
280
+ // Status tones: a Badge's text on its tint, an Alert's or Toast's accent on the page.
281
+ { foreground: '--color-status-success', background: '--color-status-success-bg', min: 4.5 },
282
+ { foreground: '--color-status-error', background: '--color-status-error-bg', min: 4.5 },
283
+ { foreground: '--color-status-warning', background: '--color-status-warning-bg', min: 4.5 },
284
+ { foreground: '--color-status-info', background: '--color-status-info-bg', min: 4.5 },
285
+ { foreground: '--color-status-info', background: '--color-bg-primary', min: 3 },
286
+ ];
287
+
288
+ const BRAND_VAR = /^var\(--color-brand-(\d+)\)$/;
289
+ const NEUTRAL_VAR = /^var\(--color-neutral-(\d+)\)$/;
290
+
291
+ /** The colour a semantic value stands for: a brand shade, a neutral shade or a literal. */
292
+ function resolveColor(value: string, palette: BrandPalette, neutral: NeutralPalette): string {
293
+ const brand = BRAND_VAR.exec(value);
294
+ if (brand) return palette[Number(brand[1]) as Shade];
295
+ const gray = NEUTRAL_VAR.exec(value);
296
+ if (gray) return neutral[Number(gray[1]) as NeutralShade];
297
+ return value;
298
+ }
299
+
300
+ /** The same colour, lighter or darker by `step` HSL points; its hue and saturation kept. */
301
+ function shiftLightness(hex: string, step: number): string {
302
+ const [h, s, l] = rgbToHsl(...hexToRgb(hex));
303
+ return hslToHex(h, s, Math.min(100, Math.max(0, l + step)));
304
+ }
305
+
306
+ const SHADES = Object.keys(SHADE_LIGHTNESS).map(Number) as Shade[];
307
+
308
+ /**
309
+ * Moves the shades of a seeded palette until every pairing in CONTRAST_REQUIREMENTS passes in
310
+ * both themes. HSL lightness is not how light a colour looks — a yellow at the 700 step is
311
+ * still bright — so fixed steps alone cannot promise contrast.
312
+ *
313
+ * In each failing pair the brand shade moves, away from the colour it sits on or under: the
314
+ * foreground's when both are brand shades. Hue and saturation stay, so the brand still reads
315
+ * as its seed. The scale is then kept in order, light to dark, by moving its neighbours on.
316
+ */
317
+ function fitContrast(palette: BrandPalette, neutral: NeutralPalette): BrandPalette {
318
+ const fitted = { ...palette };
319
+ const themes = [deriveLight(), deriveDark()];
320
+
321
+ for (let pass = 0; pass < 4; pass++) {
322
+ let moved = false;
323
+ for (const theme of themes) {
324
+ for (const { foreground, background, min } of CONTRAST_REQUIREMENTS) {
325
+ const fgValue = theme[foreground] ?? '';
326
+ const bgValue = theme[background] ?? '';
327
+ const fgShade = BRAND_VAR.exec(fgValue)?.[1];
328
+ const bgShade = BRAND_VAR.exec(bgValue)?.[1];
329
+ const shade = Number(fgShade ?? bgShade) as Shade;
330
+ if (!fgShade && !bgShade) continue;
331
+ const other = resolveColor(fgShade ? bgValue : fgValue, fitted, neutral);
332
+ // Away from the other colour: darker against a light one, lighter against a dark one.
333
+ const step = relativeLuminance(other) > 0.18 ? -1 : 1;
334
+ for (let i = 0; i < 100 && contrastRatio(fitted[shade], other) < min; i++) {
335
+ fitted[shade] = shiftLightness(fitted[shade], step);
336
+ moved = true;
337
+ }
338
+ }
339
+ }
340
+ // Keep the scale in order: each shade darker than the one before it.
341
+ const middle = SHADES.indexOf(500);
342
+ for (let i = middle + 1; i < SHADES.length; i++) {
343
+ const [before, shade] = [SHADES[i - 1] as Shade, SHADES[i] as Shade];
344
+ for (
345
+ let n = 0;
346
+ n < 100 && relativeLuminance(fitted[shade]) >= relativeLuminance(fitted[before]);
347
+ n++
348
+ ) {
349
+ fitted[shade] = shiftLightness(fitted[shade], -1);
350
+ }
351
+ }
352
+ for (let i = middle - 1; i >= 0; i--) {
353
+ const [after, shade] = [SHADES[i + 1] as Shade, SHADES[i] as Shade];
354
+ for (
355
+ let n = 0;
356
+ n < 100 && relativeLuminance(fitted[shade]) <= relativeLuminance(fitted[after]);
357
+ n++
358
+ ) {
359
+ fitted[shade] = shiftLightness(fitted[shade], 1);
360
+ }
361
+ }
362
+ if (!moved) break;
363
+ }
364
+ return fitted;
365
+ }
366
+
237
367
  // ─── CSS generation ───────────────────────────────────────────────────────────
238
368
 
239
369
  function toVars(tokens: Record<string, string>, indent = ' '): string {
@@ -260,7 +390,10 @@ function neutralVars(neutral: NeutralPalette): string {
260
390
  */
261
391
  export function createBrand(input: CreateBrandInput): string {
262
392
  const { name, neutral = DEFAULT_NEUTRAL } = input;
263
- const palette = 'seed' in input ? generatePaletteFromSeed(input.seed) : input.palette;
393
+ // A seeded palette is fitted to the contrast requirements. A palette passed in is the
394
+ // consumer's own choice and is used as given: check it against CONTRAST_REQUIREMENTS.
395
+ const palette =
396
+ 'seed' in input ? fitContrast(generatePaletteFromSeed(input.seed), neutral) : input.palette;
264
397
 
265
398
  const light = deriveLight();
266
399
  const dark = deriveDark();