@scattered-light/loomiere 0.0.0-stage → 0.1.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/README.md +60 -2
- package/bin/loomiere.mjs +112 -0
- package/build/build-tokens.mjs +485 -0
- package/exporters/README.md +45 -0
- package/exporters/ag-grid-settings.mjs +177 -0
- package/exporters/ag-grid.mjs +112 -0
- package/exporters/chakra.mjs +630 -0
- package/exporters/component-styles.mjs +207 -0
- package/exporters/dtcg.mjs +31 -0
- package/exporters/figma.mjs +100 -0
- package/exporters/index.mjs +38 -0
- package/exporters/mui.mjs +666 -0
- package/exporters/tailwind.mjs +35 -0
- package/exporters/theme.mjs +93 -0
- package/generator/brand.mjs +328 -0
- package/generator/brand.schema.json +657 -0
- package/generator/color.mjs +130 -0
- package/generator/data-colors.mjs +104 -0
- package/generator/format-brand.mjs +60 -0
- package/generator/generate.mjs +43 -0
- package/generator/pairs.mjs +236 -0
- package/generator/roles.mjs +258 -0
- package/generator/vision.mjs +167 -0
- package/lib/config.mjs +71 -0
- package/lib/contrast.mjs +134 -0
- package/lib/lab.mjs +108 -0
- package/lib/studio.mjs +127 -0
- package/lib/tool-server.mjs +96 -0
- package/package.json +51 -4
- package/templates/brand.json +104 -0
- package/tokens/README.md +219 -0
- package/tokens/component/avatar.json +10 -0
- package/tokens/component/button.json +12 -0
- package/tokens/component/drawer.json +26 -0
- package/tokens/component/navigation-menu.json +13 -0
- package/tokens/component/popover.json +13 -0
- package/tokens/component/preview-card.json +13 -0
- package/tokens/component/slider.json +13 -0
- package/tokens/component/switch.json +41 -0
- package/tokens/component/toast.json +21 -0
- package/tokens/component/toggle.json +9 -0
- package/tokens/component/toolbar.json +9 -0
- package/tokens/component/tooltip.json +9 -0
- package/tokens/foundation/layering.json +57 -0
- package/tokens/foundation/layout.json +75 -0
- package/tokens/foundation/motion.json +41 -0
- package/tokens/foundation/type.json +92 -0
- package/tokens/semantic/accessibility.json +39 -0
- package/tokens/semantic/bar.json +45 -0
- package/tokens/semantic/check.json +57 -0
- package/tokens/semantic/color.json +270 -0
- package/tokens/semantic/content.json +124 -0
- package/tokens/semantic/disclosure.json +49 -0
- package/tokens/semantic/elevation.json +72 -0
- package/tokens/semantic/field.json +77 -0
- package/tokens/semantic/focus.json +36 -0
- package/tokens/semantic/gauge.json +45 -0
- package/tokens/semantic/icon-button.json +33 -0
- package/tokens/semantic/icon.json +30 -0
- package/tokens/semantic/item.json +65 -0
- package/tokens/semantic/modal.json +86 -0
- package/tokens/semantic/motion.json +20 -0
- package/tokens/semantic/notice.json +37 -0
- package/tokens/semantic/popup.json +89 -0
- package/tokens/semantic/scheme.json +11 -0
- package/tokens/semantic/shape.json +54 -0
- package/tokens/semantic/spacing.json +110 -0
- package/tokens/semantic/table.json +73 -0
- package/tokens/semantic/typography.json +312 -0
- package/tools/brand-builder/builder.js +1119 -0
- package/tools/brand-builder/index.html +329 -0
- package/tools/pair-lab/index.html +119 -0
- package/tools/pair-lab/lab.js +249 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color ramps, built the way Leonardo builds them.
|
|
3
|
+
*
|
|
4
|
+
* 1. A palette's key colors (the brand colors) define a path through OKLCH from paper
|
|
5
|
+
* (the lightest color in the system) to ink (the darkest). Chroma and hue are
|
|
6
|
+
* interpolated between keys by lightness, so the ramp bends through every brand color.
|
|
7
|
+
* 2. Each step is placed on that path where its contrast against paper hits a target
|
|
8
|
+
* ratio. Every palette that shares a contrast table has the same contrast at the same
|
|
9
|
+
* step, so `500` means "AA on paper" in every palette.
|
|
10
|
+
* 3. Optionally, keys land exactly: each key replaces the target of the step closest to
|
|
11
|
+
* its own contrast, unless that step is locked (by default 500 stays at 4.5:1).
|
|
12
|
+
* 4. A `tone` tints a palette the way a transparent color layered over a grayscale does.
|
|
13
|
+
* It is how neutrals get their temperature: the tone is blended into the path at an
|
|
14
|
+
* intensity that can change from the light end to the dark end on a log curve.
|
|
15
|
+
*/
|
|
16
|
+
import { clampChroma, formatHex, interpolate, oklch, wcagContrast } from 'culori';
|
|
17
|
+
|
|
18
|
+
const hueOf = (c, fallback = 0) => (c.c > 0.002 && c.h !== undefined ? c.h : fallback);
|
|
19
|
+
const lerpHue = (a, b, t) => (a + (((b - a + 540) % 360) - 180) * t + 360) % 360;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tone as an overlay: `color` at `intensity` over gray. Intensity is a number, or
|
|
23
|
+
* [light, dark] interpolated geometrically along lightness (a log curve), so a tone can
|
|
24
|
+
* be strong in the darks and faint in the lights, or the reverse.
|
|
25
|
+
*/
|
|
26
|
+
function toneAt(tone, l, paperL, inkL) {
|
|
27
|
+
if (!tone) return null;
|
|
28
|
+
const [a, b] = Array.isArray(tone.intensity) ? tone.intensity : [tone.intensity, tone.intensity];
|
|
29
|
+
const t = (paperL - l) / (paperL - inkL);
|
|
30
|
+
const alpha = a > 0 && b > 0 ? a * (b / a) ** t : a + (b - a) * t;
|
|
31
|
+
const gray = { mode: 'oklch', l, c: 0, h: 0 };
|
|
32
|
+
// Mix in Oklab so the blend behaves like a transparent layer, then keep the gray's lightness.
|
|
33
|
+
const mixed = oklch(interpolate([gray, tone.color], 'oklab')(alpha));
|
|
34
|
+
return { c: mixed.c ?? 0, h: hueOf(mixed, oklch(tone.color).h ?? 0) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns L → OKLCH color along the palette's path. */
|
|
38
|
+
export function makePath({ keys, paper, ink, tone }) {
|
|
39
|
+
const p = oklch(paper);
|
|
40
|
+
const i = oklch(ink);
|
|
41
|
+
const ks = keys.map((k) => oklch(k)).sort((a, b) => b.l - a.l);
|
|
42
|
+
const firstHue = hueOf(ks[0] ?? oklch(tone?.color ?? paper));
|
|
43
|
+
const lastHue = hueOf(ks.at(-1) ?? oklch(tone?.color ?? ink));
|
|
44
|
+
const base = (l) => toneAt(tone, l, p.l, i.l) ?? { c: 0, h: firstHue };
|
|
45
|
+
|
|
46
|
+
// Without a tone the ends are paper and ink themselves. With a tone, the ends follow the
|
|
47
|
+
// tone, and keys bend the path away from it only near themselves.
|
|
48
|
+
const pts = [
|
|
49
|
+
{ l: p.l, ...(tone ? base(p.l) : { c: p.c ?? 0, h: hueOf(p, firstHue) }) },
|
|
50
|
+
...ks.map((k) => ({ l: k.l, c: k.c ?? 0, h: hueOf(k, firstHue) })),
|
|
51
|
+
{ l: i.l, ...(tone ? base(i.l) : { c: i.c ?? 0, h: hueOf(i, lastHue) }) },
|
|
52
|
+
];
|
|
53
|
+
if (!tone) {
|
|
54
|
+
pts[0].c = 0;
|
|
55
|
+
pts[0].h = firstHue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (l) => {
|
|
59
|
+
for (let j = 0; j < pts.length - 1; j++) {
|
|
60
|
+
const a = pts[j];
|
|
61
|
+
const b = pts[j + 1];
|
|
62
|
+
if (l <= a.l && l >= b.l) {
|
|
63
|
+
const t = a.l === b.l ? 0 : (a.l - l) / (a.l - b.l);
|
|
64
|
+
let c = a.c + (b.c - a.c) * t;
|
|
65
|
+
let h = lerpHue(a.h, b.h, t);
|
|
66
|
+
if (tone) {
|
|
67
|
+
// Follow the tone, plus whatever offset the neighboring keys ask for.
|
|
68
|
+
const here = base(l);
|
|
69
|
+
const offA = { c: a.c - base(a.l).c, h: ((a.h - base(a.l).h + 540) % 360) - 180 };
|
|
70
|
+
const offB = { c: b.c - base(b.l).c, h: ((b.h - base(b.l).h + 540) % 360) - 180 };
|
|
71
|
+
c = Math.max(0, here.c + offA.c + (offB.c - offA.c) * t);
|
|
72
|
+
h = (here.h + offA.h + (offB.h - offA.h) * t + 360) % 360;
|
|
73
|
+
}
|
|
74
|
+
return clampChroma({ mode: 'oklch', l, c, h }, 'oklch');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { mode: 'oklch', l, c: 0, h: 0 };
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Finds the color on the path whose contrast against paper is `ratio`. It errs on the
|
|
83
|
+
* darker side, after rounding to hex, so a step never falls short of its target.
|
|
84
|
+
*/
|
|
85
|
+
export function atContrast(path, ratio, paper, ink) {
|
|
86
|
+
// A target darker than ink can reach lands on ink itself.
|
|
87
|
+
if (wcagContrast(ink, paper) <= ratio) return formatHex(ink);
|
|
88
|
+
let lo = oklch(ink).l;
|
|
89
|
+
let hi = oklch(paper).l;
|
|
90
|
+
for (let n = 0; n < 40; n++) {
|
|
91
|
+
const mid = (lo + hi) / 2;
|
|
92
|
+
if (wcagContrast(path(mid), paper) >= ratio) lo = mid;
|
|
93
|
+
else hi = mid;
|
|
94
|
+
}
|
|
95
|
+
let hex = formatHex(path(lo));
|
|
96
|
+
for (let l = lo; wcagContrast(hex, paper) < ratio && l > oklch(ink).l; l -= 0.0005) hex = formatHex(path(l));
|
|
97
|
+
return hex;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Builds one palette. `targets` is { step: ratio }. Returns [{ step, hex, ratio, key }].
|
|
102
|
+
* With `exact`, each key replaces the nearest unlocked step's target (compared on a log
|
|
103
|
+
* scale), so the brand color itself appears in the palette. Without it, keys only shape
|
|
104
|
+
* the path, as in Leonardo.
|
|
105
|
+
*/
|
|
106
|
+
export function palette({ keys = [], exact = false, paper, ink, tone, targets, locked = [] }) {
|
|
107
|
+
const path = makePath({ keys, paper, ink, tone });
|
|
108
|
+
const steps = Object.keys(targets);
|
|
109
|
+
const ratios = { ...targets };
|
|
110
|
+
const keyAt = {};
|
|
111
|
+
for (const key of exact ? keys : []) {
|
|
112
|
+
const r = wcagContrast(key, paper);
|
|
113
|
+
let best = null;
|
|
114
|
+
for (const step of steps) {
|
|
115
|
+
if (locked.includes(Number(step)) || keyAt[step]) continue;
|
|
116
|
+
const d = Math.abs(Math.log(targets[step]) - Math.log(r));
|
|
117
|
+
if (!best || d < best.d) best = { step, d };
|
|
118
|
+
}
|
|
119
|
+
if (best) {
|
|
120
|
+
ratios[best.step] = r;
|
|
121
|
+
keyAt[best.step] = key;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return steps.map((step) => ({
|
|
125
|
+
step,
|
|
126
|
+
hex: keyAt[step] ? formatHex(keyAt[step]) : atContrast(path, ratios[step], paper, ink),
|
|
127
|
+
ratio: ratios[step],
|
|
128
|
+
key: keyAt[step] ?? null,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks and fixes for the categorical data colors (brand color.data).
|
|
3
|
+
*
|
|
4
|
+
* Each color is checked against paper (can a bar or line be seen?) and against its
|
|
5
|
+
* neighbors in the series order (can two adjacent series be told apart?), for typical
|
|
6
|
+
* color vision and for the three kinds of color blindness, simulated with Machado et al.
|
|
7
|
+
* (2009) at full strength. nudgeDataColor finds the nearest color that clears every check.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
differenceCiede2000,
|
|
11
|
+
displayable,
|
|
12
|
+
filterGrayscale,
|
|
13
|
+
filterDeficiencyDeuter,
|
|
14
|
+
filterDeficiencyProt,
|
|
15
|
+
filterDeficiencyTrit,
|
|
16
|
+
formatHex,
|
|
17
|
+
oklch,
|
|
18
|
+
wcagContrast,
|
|
19
|
+
} from 'culori';
|
|
20
|
+
|
|
21
|
+
/** ΔE (CIEDE2000) under which two neighbors are hard to tell apart. */
|
|
22
|
+
export const DISTINCT = 10;
|
|
23
|
+
/** WCAG 1.4.11 asks 3:1 for graphics that carry meaning. */
|
|
24
|
+
export const DATA_CONTRAST = 3;
|
|
25
|
+
|
|
26
|
+
export const VISIONS = {
|
|
27
|
+
typical: { label: 'Typical vision', see: (c) => c },
|
|
28
|
+
protan: { label: 'Red-blind (protan)', see: filterDeficiencyProt(1) },
|
|
29
|
+
deutan: { label: 'Green-blind (deutan)', see: filterDeficiencyDeuter(1) },
|
|
30
|
+
tritan: { label: 'Blue-blind (tritan)', see: filterDeficiencyTrit(1) },
|
|
31
|
+
achromat: { label: 'No color (achromat)', see: filterGrayscale(1) },
|
|
32
|
+
};
|
|
33
|
+
/** The everyday checks. Telling every neighbor apart in grayscale is the achromatopsia theme's job. */
|
|
34
|
+
const CHECKED = ['typical', 'protan', 'deutan', 'tritan'];
|
|
35
|
+
|
|
36
|
+
const ΔE = differenceCiede2000();
|
|
37
|
+
|
|
38
|
+
function distance(a, b, vision) {
|
|
39
|
+
const { see } = VISIONS[vision];
|
|
40
|
+
return ΔE(see(a), see(b));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every problem in the list, in order: { i, kind: 'contrast' | 'neighbor', vision?, with?, value }.
|
|
45
|
+
* A neighbor problem belongs to the later color of the two, since that is the one a fix moves.
|
|
46
|
+
*/
|
|
47
|
+
export function checkDataColors(data, paper, goal = DATA_CONTRAST) {
|
|
48
|
+
const problems = [];
|
|
49
|
+
data.forEach(([, hex], i) => {
|
|
50
|
+
const r = wcagContrast(hex, paper);
|
|
51
|
+
if (r < goal) problems.push({ i, kind: 'contrast', value: r });
|
|
52
|
+
if (i === 0) return;
|
|
53
|
+
for (const vision of CHECKED) {
|
|
54
|
+
const d = distance(data[i - 1][1], hex, vision);
|
|
55
|
+
if (d < DISTINCT) problems.push({ i, kind: 'neighbor', vision, with: i - 1, value: d });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
return problems;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which colors a color must stay distinct from: `reach` places either side of it in the
|
|
63
|
+
* series order, as seen with each of `visions`, by at least `distinct`.
|
|
64
|
+
*/
|
|
65
|
+
const ALL = { visions: CHECKED, distinct: DISTINCT, reach: 1 };
|
|
66
|
+
|
|
67
|
+
function passes(hex, data, i, paper, goal, { visions, distinct, reach }) {
|
|
68
|
+
if (wcagContrast(hex, paper) < goal) return false;
|
|
69
|
+
for (let j = i - reach; j <= i + reach; j++) {
|
|
70
|
+
if (j === i || !data[j]) continue;
|
|
71
|
+
for (const vision of visions) if (distance(data[j][1], hex, vision) < distinct) return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when data[i] needs no nudge under these rules. */
|
|
77
|
+
export const dataColorPasses = (data, i, paper, goal = DATA_CONTRAST, rules = ALL) => passes(data[i][1], data, i, paper, goal, rules);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The color closest to data[i] (by ΔE) that passes on paper and stays distinct from its
|
|
81
|
+
* neighbors (by default the one on each side, for every kind of vision). It searches
|
|
82
|
+
* lightness, small hue and chroma shifts, so the color keeps its character. Returns null
|
|
83
|
+
* if nothing nearby passes.
|
|
84
|
+
*/
|
|
85
|
+
export function nudgeDataColor(data, i, paper, goal = DATA_CONTRAST, rules = ALL) {
|
|
86
|
+
const start = oklch(data[i][1]);
|
|
87
|
+
const candidates = [];
|
|
88
|
+
for (let l = 0.2; l <= 0.97; l += 0.01) {
|
|
89
|
+
for (let dh = -48; dh <= 48; dh += 4) {
|
|
90
|
+
for (const k of [1, 0.85, 1.15, 0.7, 1.3]) {
|
|
91
|
+
const c = { mode: 'oklch', l, c: (start.c ?? 0) * k, h: ((start.h ?? 0) + dh + 360) % 360 };
|
|
92
|
+
if (!displayable(c)) continue;
|
|
93
|
+
if (wcagContrast(c, paper) < goal) continue;
|
|
94
|
+
candidates.push(c);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const scored = candidates.map((c) => ({ c, d: ΔE(start, c) })).sort((a, b) => a.d - b.d);
|
|
99
|
+
for (const { c } of scored) {
|
|
100
|
+
const hex = formatHex(c);
|
|
101
|
+
if (passes(hex, data, i, paper, goal, rules)) return hex;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a brand file the way people write them: short objects and lists on one line,
|
|
3
|
+
* long ones broken up, a blank line between top-level sections. Used by the brand builder
|
|
4
|
+
* and the pair lab so a saved file stays readable in a diff.
|
|
5
|
+
*/
|
|
6
|
+
const WIDTH = 120;
|
|
7
|
+
// A flat list or map of plain values (space steps, a contrast table) reads best on one line.
|
|
8
|
+
const FLAT = 200;
|
|
9
|
+
const PACKED = 100;
|
|
10
|
+
const SECTION = 100;
|
|
11
|
+
|
|
12
|
+
const oneLine = (value) => {
|
|
13
|
+
if (Array.isArray(value)) return `[${value.map(oneLine).join(', ')}]`;
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
const entries = Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}: ${oneLine(v)}`);
|
|
16
|
+
return entries.length ? `{ ${entries.join(', ')} }` : '{}';
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function format(value, indent, width = WIDTH) {
|
|
22
|
+
const flat = oneLine(value);
|
|
23
|
+
const isFlatMap = value && typeof value === 'object' && Object.values(value).every((v) => typeof v !== 'object');
|
|
24
|
+
if (isFlatMap) width = Math.max(width, FLAT);
|
|
25
|
+
if (indent.length + flat.length <= width || value === null || typeof value !== 'object') return flat;
|
|
26
|
+
const inner = `${indent} `;
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
// Lists of short items (data colors, steps) pack several to a line.
|
|
29
|
+
if (value.every((item) => oneLine(item).length < 40)) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
let line = '';
|
|
32
|
+
for (const item of value.map(oneLine)) {
|
|
33
|
+
if (line && inner.length + line.length + item.length + 2 > PACKED) {
|
|
34
|
+
lines.push(line);
|
|
35
|
+
line = '';
|
|
36
|
+
}
|
|
37
|
+
line += line ? ` ${item},` : `${item},`;
|
|
38
|
+
}
|
|
39
|
+
lines.push(line);
|
|
40
|
+
return `[\n${lines.map((l) => inner + l).join('\n').replace(/,$/, '')}\n${indent}]`;
|
|
41
|
+
}
|
|
42
|
+
return `[\n${value.map((item) => inner + format(item, inner)).join(',\n')}\n${indent}]`;
|
|
43
|
+
}
|
|
44
|
+
const entries = Object.entries(value).map(([k, v]) => `${inner}${JSON.stringify(k)}: ${format(v, inner)}`);
|
|
45
|
+
return `{\n${entries.join(',\n')}\n${indent}}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Sections that consume the rest of the brand go last: roles pick from the palettes above them. */
|
|
49
|
+
const LAST = ['roles'];
|
|
50
|
+
|
|
51
|
+
export function formatBrand(input) {
|
|
52
|
+
const brand = Object.fromEntries([
|
|
53
|
+
...Object.entries(input).filter(([k]) => !LAST.includes(k)),
|
|
54
|
+
...LAST.filter((k) => k in input).map((k) => [k, input[k]]),
|
|
55
|
+
]);
|
|
56
|
+
const entries = Object.entries(brand).map(([k, v]) => ` ${JSON.stringify(k)}: ${format(v, ' ', SECTION)}`);
|
|
57
|
+
// Blank lines set off each section after the name and description.
|
|
58
|
+
const out = entries.map((line, i) => (i > 0 && typeof Object.values(brand)[i] === 'object' ? `\n${line}` : line));
|
|
59
|
+
return `{\n${out.join(',\n')}\n}\n`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand → Primitive.
|
|
3
|
+
*
|
|
4
|
+
* Reads a brand file (the inputs) and the optional <brand>.overrides.json beside it (exact
|
|
5
|
+
* values pinned on top of the math), and writes DTCG token files to
|
|
6
|
+
* <generatedDir>/tokens/primitive/ plus the brand-specific semantic files (pairs, role scopes,
|
|
7
|
+
* emphasis) to <generatedDir>/tokens/semantic/. Those files are generated: edit the brand
|
|
8
|
+
* file, not them.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { buildPrimitives } from './brand.mjs';
|
|
14
|
+
|
|
15
|
+
const ROLE_FILE_NOTE = 'Generated from roles in the brand file. Edit the brand file (or the Roles step of loomiere studio), not this file.';
|
|
16
|
+
const PAIR_FILE_NOTE = 'Generated from color.pairs in the brand file. Edit the brand file (or use loomiere lab), not this file.';
|
|
17
|
+
|
|
18
|
+
export const overridesFileFor = (brandFile) => brandFile.replace(/\.json$/, '.overrides.json');
|
|
19
|
+
|
|
20
|
+
export function generate(brandFile, generatedDir) {
|
|
21
|
+
const overridesFile = overridesFileFor(brandFile);
|
|
22
|
+
const brand = JSON.parse(fs.readFileSync(brandFile, 'utf8'));
|
|
23
|
+
const overrides = fs.existsSync(overridesFile) ? JSON.parse(fs.readFileSync(overridesFile, 'utf8')) : {};
|
|
24
|
+
|
|
25
|
+
const { files, applied, pairs, problems, roleTokens } = buildPrimitives(brand, overrides);
|
|
26
|
+
if (problems.length) {
|
|
27
|
+
throw new Error(`Brand pairs need AA (4.5:1) text:\n ${problems.join('\n ')}\nPick a passing pair in \`loomiere lab\` or fix color.pairs in the brand file.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const primitiveDir = path.join(generatedDir, 'tokens', 'primitive');
|
|
31
|
+
const semanticDir = path.join(generatedDir, 'tokens', 'semantic');
|
|
32
|
+
fs.mkdirSync(primitiveDir, { recursive: true });
|
|
33
|
+
fs.mkdirSync(semanticDir, { recursive: true });
|
|
34
|
+
for (const [name, content] of Object.entries(files)) {
|
|
35
|
+
fs.writeFileSync(path.join(primitiveDir, `${name}.json`), `${JSON.stringify(content, null, 2)}\n`);
|
|
36
|
+
}
|
|
37
|
+
// Pairs live with the semantic tokens: they are named uses of the palette, with a dark mode.
|
|
38
|
+
fs.writeFileSync(path.join(semanticDir, 'pair.json'), `${JSON.stringify({ $description: PAIR_FILE_NOTE, ...pairs }, null, 2)}\n`);
|
|
39
|
+
for (const name of ['scope', 'emphasis']) {
|
|
40
|
+
fs.writeFileSync(path.join(semanticDir, `${name}.json`), `${JSON.stringify({ $description: ROLE_FILE_NOTE, ...roleTokens[name] }, null, 2)}\n`);
|
|
41
|
+
}
|
|
42
|
+
return { brand, applied, files };
|
|
43
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrast pairs between every palette color.
|
|
3
|
+
*
|
|
4
|
+
* Palettes don't change between modes (dark mode picks different steps), so a pair that
|
|
5
|
+
* passes here passes in every mode. scripts/check-contrast.mjs covers the other half: the
|
|
6
|
+
* semantic pairs as each mode resolves them.
|
|
7
|
+
*
|
|
8
|
+
* WCAG levels: AAA 7:1 and AA 4.5:1 for text, AA-large 3:1 for large text and UI parts.
|
|
9
|
+
*/
|
|
10
|
+
import { formatHex, interpolate, wcagContrast } from 'culori';
|
|
11
|
+
|
|
12
|
+
export const LEVELS = [
|
|
13
|
+
['AAA', 7],
|
|
14
|
+
['AA', 4.5],
|
|
15
|
+
['AA-large', 3],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const levelFor = (ratio) => LEVELS.find(([, min]) => ratio >= min)?.[0] ?? null;
|
|
19
|
+
|
|
20
|
+
/** Flattens the generated color group to [{ path, hex }], skipping data colors and on-colors. */
|
|
21
|
+
export function paletteColors(color) {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const [role, group] of Object.entries(color)) {
|
|
24
|
+
if (group.$value) out.push({ path: `color.${role}`, hex: group.$value });
|
|
25
|
+
else if (role !== 'data') {
|
|
26
|
+
for (const [step, t] of Object.entries(group)) if (t.$value) out.push({ path: `color.${role}.${step}`, hex: t.$value });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* For each color: every other color it reaches at least AA-large with, grouped by level,
|
|
34
|
+
* strongest first. Same-palette partners are included, so "colors on themselves" is the
|
|
35
|
+
* subset whose path shares the role.
|
|
36
|
+
*/
|
|
37
|
+
export function pairTable(colors) {
|
|
38
|
+
const table = {};
|
|
39
|
+
for (const a of colors) {
|
|
40
|
+
const entry = { AAA: [], AA: [], 'AA-large': [] };
|
|
41
|
+
for (const b of colors) {
|
|
42
|
+
if (a === b) continue;
|
|
43
|
+
const ratio = wcagContrast(a.hex, b.hex);
|
|
44
|
+
const level = levelFor(ratio);
|
|
45
|
+
if (level) entry[level].push({ path: b.path, ratio: Number(ratio.toFixed(2)) });
|
|
46
|
+
}
|
|
47
|
+
for (const list of Object.values(entry)) list.sort((x, y) => y.ratio - x.ratio);
|
|
48
|
+
table[a.path] = entry;
|
|
49
|
+
}
|
|
50
|
+
return table;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The text color for each step: the palette's own 50 or darkest step when it passes AA,
|
|
55
|
+
* so text stays in the family. Otherwise paper when it passes (so every 500 takes white
|
|
56
|
+
* text), else ink.
|
|
57
|
+
*/
|
|
58
|
+
export function onColor(role, step, palette, paper, ink) {
|
|
59
|
+
const hex = palette[step].$value;
|
|
60
|
+
const steps = Object.keys(palette);
|
|
61
|
+
const candidates = [steps[0], steps.at(-1)]
|
|
62
|
+
.filter((s) => s !== step)
|
|
63
|
+
.map((s) => ({ ref: `{color.${role}.${s}}`, ratio: wcagContrast(hex, palette[s].$value) }))
|
|
64
|
+
.filter((c) => c.ratio >= 4.5)
|
|
65
|
+
.sort((a, b) => b.ratio - a.ratio);
|
|
66
|
+
if (candidates.length) return candidates[0];
|
|
67
|
+
const vsPaper = wcagContrast(hex, paper);
|
|
68
|
+
const vsInk = wcagContrast(hex, ink);
|
|
69
|
+
return vsPaper >= 4.5 || vsPaper >= vsInk ? { ref: '{color.white}', ratio: vsPaper } : { ref: '{color.black}', ratio: vsInk };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
* Brand pairs: named background/text combinations from the brand file's color.pairs.
|
|
74
|
+
*
|
|
75
|
+
* Each pair becomes tokens (pair.<name>.bg, .fg, .accent, plus derived parts) and a
|
|
76
|
+
* .sl-pair-<name> class that re-points the semantic color tokens inside it, so anything
|
|
77
|
+
* built from semantic tokens takes on the pair. Parts that carry text are checked here
|
|
78
|
+
* and the build stops when one falls short of AA.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/** Parts every pair has: the three a brand names, then four derived from them. */
|
|
82
|
+
export const PAIR_PARTS = {
|
|
83
|
+
bg: 'Background',
|
|
84
|
+
fg: 'Text, icons and strong borders',
|
|
85
|
+
accent: 'Links, buttons and focus rings',
|
|
86
|
+
hover: 'Accent on hover',
|
|
87
|
+
active: 'Accent while pressed',
|
|
88
|
+
subtle: 'Accent wash for highlighted items, selection and mark',
|
|
89
|
+
emphasis: 'Stronger accent wash for selected and pressed items',
|
|
90
|
+
muted: 'Inset background for code',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** The "why" for one part of a resolved pair. */
|
|
94
|
+
export function describePart(part, { parts, sources }, spec) {
|
|
95
|
+
const r = (a, b) => `${wcagContrast(parts[a], parts[b]).toFixed(2)}:1`;
|
|
96
|
+
const label = PAIR_PARTS[part];
|
|
97
|
+
const source = sources[part];
|
|
98
|
+
switch (part) {
|
|
99
|
+
case 'bg':
|
|
100
|
+
return `${label}.`;
|
|
101
|
+
case 'fg':
|
|
102
|
+
return `${label}. ${r('fg', 'bg')} on bg.`;
|
|
103
|
+
case 'accent':
|
|
104
|
+
return spec.accent ? `${label}. ${r('accent', 'bg')} on bg.` : `${label}. The pair names no accent, so this is fg.`;
|
|
105
|
+
case 'hover':
|
|
106
|
+
case 'active':
|
|
107
|
+
return Array.isArray(source)
|
|
108
|
+
? `${label}: accent moved ${100 - source[1]}% toward ${source[2]}. ${r(part, 'bg')} against bg, which is the button label.`
|
|
109
|
+
: `${label}: the accent itself, because moving it would drop the button label below AA.`;
|
|
110
|
+
default:
|
|
111
|
+
return Array.isArray(source)
|
|
112
|
+
? `${label}: ${source[0]} ${source[1]}% into bg. fg on it is ${r('fg', part)}.`
|
|
113
|
+
: `${label}: ${source} itself, because a stronger tint would drop text below AA.`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* How each semantic color token reads a pair. A part name, or [part, percent, part] for
|
|
119
|
+
* color-mix(in oklab, part percent%, part). Semantic color tokens not listed (status,
|
|
120
|
+
* overlay) keep their global values inside a pair.
|
|
121
|
+
*/
|
|
122
|
+
export const PAIR_ROLES = {
|
|
123
|
+
'surface.canvas': 'bg',
|
|
124
|
+
'surface.raised': 'bg',
|
|
125
|
+
'surface.overlay': 'bg',
|
|
126
|
+
'surface.modal': 'bg',
|
|
127
|
+
'surface.tray': 'bg',
|
|
128
|
+
'text.primary': 'fg',
|
|
129
|
+
'text.secondary': 'fg',
|
|
130
|
+
'text.tertiary': 'fg',
|
|
131
|
+
'text.accent': 'accent',
|
|
132
|
+
'text.disabled': ['fg', 45, 'bg'],
|
|
133
|
+
'text.on-primary': 'bg',
|
|
134
|
+
'text.on-tint': 'fg',
|
|
135
|
+
'text.on-tint-strong': 'fg',
|
|
136
|
+
'text.on-inverse': 'bg',
|
|
137
|
+
'text.on-inverse-surface': 'bg',
|
|
138
|
+
'surface.inverse': 'fg',
|
|
139
|
+
'border.subtle': ['fg', 15, 'bg'],
|
|
140
|
+
'border.default': ['fg', 30, 'bg'],
|
|
141
|
+
'border.strong': 'fg',
|
|
142
|
+
'border.accent': 'accent',
|
|
143
|
+
'border.control': ['fg', 50, 'bg'],
|
|
144
|
+
'border.control-hover': 'fg',
|
|
145
|
+
'fill.subtle': ['fg', 12, 'bg'],
|
|
146
|
+
'fill.default': ['fg', 22, 'bg'],
|
|
147
|
+
'fill.strong': ['fg', 35, 'bg'],
|
|
148
|
+
'fill.stronger': ['fg', 45, 'bg'],
|
|
149
|
+
'fill.inverse': 'fg',
|
|
150
|
+
'fill.track': ['fg', 12, 'bg'],
|
|
151
|
+
'interactive.default': 'accent',
|
|
152
|
+
'interactive.hover': 'hover',
|
|
153
|
+
'interactive.active': 'active',
|
|
154
|
+
'interactive.disabled': ['fg', 35, 'bg'],
|
|
155
|
+
'interactive.tint': 'subtle',
|
|
156
|
+
'interactive.tint-hover': 'subtle',
|
|
157
|
+
'interactive.tint-active': 'emphasis',
|
|
158
|
+
'interactive.tint-strong': 'emphasis',
|
|
159
|
+
'focus.ring': 'accent',
|
|
160
|
+
'content.prose-color': 'fg',
|
|
161
|
+
'content.blockquote-border': 'accent',
|
|
162
|
+
'content.blockquote-cite': 'fg',
|
|
163
|
+
'content.code-background': 'muted',
|
|
164
|
+
'content.code-border': ['fg', 30, 'bg'],
|
|
165
|
+
'content.code-text': 'fg',
|
|
166
|
+
'content.field-border': ['fg', 30, 'bg'],
|
|
167
|
+
'content.hr-border': ['fg', 30, 'bg'],
|
|
168
|
+
'content.pre-border': ['fg', 30, 'bg'],
|
|
169
|
+
'content.kbd-background': 'fg',
|
|
170
|
+
'content.kbd-text': 'bg',
|
|
171
|
+
'content.link-color': 'accent',
|
|
172
|
+
'content.link-hover-color': 'hover',
|
|
173
|
+
'content.link-underline-color': ['accent', 50, 'bg'],
|
|
174
|
+
'content.link-underline-hover-color': 'accent',
|
|
175
|
+
'content.list-marker-color': 'accent',
|
|
176
|
+
'content.mark-background': 'subtle',
|
|
177
|
+
'content.selection-background': 'subtle',
|
|
178
|
+
'content.selection-text': 'fg',
|
|
179
|
+
'content.scrollbar-thumb': ['fg', 30, 'bg'],
|
|
180
|
+
'content.scrollbar-thumb-hover': ['fg', 45, 'bg'],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const AA = 4.5;
|
|
184
|
+
|
|
185
|
+
/** color-mix(in oklab, a pct%, b) as a hex. */
|
|
186
|
+
export const mixHex = (a, pct, b) => formatHex(interpolate([b, a], 'oklab')(pct / 100));
|
|
187
|
+
|
|
188
|
+
/** "secondary.600" → "color.secondary.600"; "white", "black", "paper" and "ink" are the ends. */
|
|
189
|
+
export function pairRef(ref) {
|
|
190
|
+
const name = { paper: 'white', ink: 'black' }[ref] ?? ref;
|
|
191
|
+
return `color.${name}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolves one mode of a pair to hexes and works out the derived parts. `hexOf` maps a
|
|
196
|
+
* token path to its hex. Returns { parts: { part: hex }, sources: { part: expression },
|
|
197
|
+
* problems: [message] }, where an expression is a token path or [path, pct, path].
|
|
198
|
+
*/
|
|
199
|
+
export function resolvePair(spec, hexOf) {
|
|
200
|
+
const problems = [];
|
|
201
|
+
const sources = { bg: pairRef(spec.bg), fg: pairRef(spec.fg), accent: pairRef(spec.accent ?? spec.fg) };
|
|
202
|
+
const parts = {};
|
|
203
|
+
for (const part of ['bg', 'fg', 'accent']) {
|
|
204
|
+
parts[part] = hexOf(sources[part]);
|
|
205
|
+
if (!parts[part]) problems.push(`${part} "${spec[part]}" is not a palette color`);
|
|
206
|
+
}
|
|
207
|
+
if (problems.length) return { parts, sources, problems };
|
|
208
|
+
|
|
209
|
+
const fgRatio = wcagContrast(parts.fg, parts.bg);
|
|
210
|
+
if (fgRatio < AA) problems.push(`fg ${spec.fg} on bg ${spec.bg} is ${fgRatio.toFixed(2)}:1, needs ${AA}:1`);
|
|
211
|
+
const accentRatio = wcagContrast(parts.accent, parts.bg);
|
|
212
|
+
if (spec.accent && accentRatio < AA) problems.push(`accent ${spec.accent} on bg ${spec.bg} is ${accentRatio.toFixed(2)}:1, needs ${AA}:1`);
|
|
213
|
+
|
|
214
|
+
// Derived parts fall back to a plain part rather than ever dropping below AA.
|
|
215
|
+
const derive = (part, [a, pct, b], check, fallback) => {
|
|
216
|
+
const hex = mixHex(parts[a], pct, parts[b]);
|
|
217
|
+
if (check(hex) >= AA) {
|
|
218
|
+
parts[part] = hex;
|
|
219
|
+
sources[part] = [a, pct, b];
|
|
220
|
+
} else {
|
|
221
|
+
parts[part] = parts[fallback];
|
|
222
|
+
sources[part] = fallback;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
// The button label is bg, so hover and active must keep AA against bg.
|
|
226
|
+
// Without an accent, accent is fg, so hover and active move toward bg instead.
|
|
227
|
+
const away = spec.accent ? 'fg' : 'bg';
|
|
228
|
+
derive('hover', ['accent', 85, away], (hex) => wcagContrast(hex, parts.bg), 'accent');
|
|
229
|
+
derive('active', ['accent', 70, away], (hex) => wcagContrast(hex, parts.bg), 'accent');
|
|
230
|
+
// fg and accent text sit on these.
|
|
231
|
+
const textOn = (hex) => Math.min(wcagContrast(parts.fg, hex), wcagContrast(parts.accent, hex));
|
|
232
|
+
derive('subtle', ['accent', 12, 'bg'], textOn, 'bg');
|
|
233
|
+
derive('emphasis', ['accent', 22, 'bg'], textOn, 'subtle');
|
|
234
|
+
derive('muted', ['fg', 6, 'bg'], (hex) => wcagContrast(parts.fg, hex), 'bg');
|
|
235
|
+
return { parts, sources, problems, ratios: { fg: fgRatio, accent: accentRatio } };
|
|
236
|
+
}
|