@arun-dev/tokens 0.29.0 → 0.30.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/dist/createBrand.cjs +94 -3
- package/dist/createBrand.d.cts +13 -1
- package/dist/createBrand.d.ts +13 -1
- package/dist/createBrand.js +92 -3
- package/package.json +1 -1
- package/src/brands/default/semantic.css +4 -4
- package/src/createBrand.ts +136 -3
package/dist/createBrand.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/createBrand.d.cts
CHANGED
|
@@ -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 };
|
package/dist/createBrand.d.ts
CHANGED
|
@@ -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 };
|
package/dist/createBrand.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.30.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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
package/src/createBrand.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|