@handamade/psi-tokens 0.18.0 → 0.19.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 +17 -0
- package/dist/contrast-matrix.d.ts +34 -0
- package/dist/contrast-matrix.js +130 -0
- package/dist/dsl/builders.d.ts +7 -0
- package/dist/dsl/builders.js +25 -0
- package/dist/dsl/resolver.d.ts +3 -0
- package/dist/dsl/resolver.js +114 -0
- package/dist/dsl/types.d.ts +59 -0
- package/dist/dsl/types.js +2 -0
- package/dist/generate/derive.d.ts +20 -0
- package/dist/generate/derive.js +232 -0
- package/dist/generate/dictionaries.d.ts +18 -0
- package/dist/generate/dictionaries.js +46 -0
- package/dist/generate/index.d.ts +6 -0
- package/dist/generate/index.js +6 -0
- package/dist/generate/palette.d.ts +7 -0
- package/dist/generate/palette.js +93 -0
- package/dist/generate/prompt.d.ts +7 -0
- package/dist/generate/prompt.js +62 -0
- package/dist/generate/serialize.d.ts +10 -0
- package/dist/generate/serialize.js +167 -0
- package/dist/generate/solve.d.ts +36 -0
- package/dist/generate/solve.js +56 -0
- package/dist/generate/types.d.ts +24 -0
- package/dist/generate/types.js +48 -0
- package/dist/themes/customers/acme.d.ts +8 -0
- package/dist/themes/customers/acme.js +31 -0
- package/dist/themes/customers/ember.d.ts +13 -0
- package/dist/themes/customers/ember.js +52 -0
- package/dist/themes/customers/index.d.ts +28 -0
- package/dist/themes/customers/index.js +23 -0
- package/dist/themes/dark.d.ts +2 -0
- package/dist/themes/dark.js +55 -0
- package/dist/themes/light.d.ts +2 -0
- package/dist/themes/light.js +56 -0
- package/llms.txt +8 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -35,6 +35,22 @@ All tokens are CSS custom properties: `--psi-bg-primary`, `--psi-fg-secondary`,
|
|
|
35
35
|
|
|
36
36
|
Run `pnpm new-theme` to scaffold a customer brand theme (palette + slot mapping).
|
|
37
37
|
|
|
38
|
+
## Deriving a brand from a prompt
|
|
39
|
+
|
|
40
|
+
`@handamade/psi-tokens/generate` (D57) derives a brand at runtime instead of scaffolding one by hand:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { parsePrompt, deriveTheme, serializeCustomerTheme } from "@handamade/psi-tokens/generate";
|
|
44
|
+
|
|
45
|
+
const vector = parsePrompt("a calm dark fintech brand"); // deterministic BrandVector
|
|
46
|
+
const pair = deriveTheme(vector); // AA-solved light + dark DerivedTheme pair
|
|
47
|
+
const { filename, source, registration } = serializeCustomerTheme(pair); // customers/<name>.ts
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `parsePrompt` is deterministic — an FNV-1a hash seeds a PRNG, so an unrecognised prompt still derives a coherent brand.
|
|
51
|
+
- `deriveTheme` solves both members of the pair independently to WCAG AA by binary-searching lightness; it never returns a below-AA theme, because it throws instead of returning one if the solver can't clear the matrix.
|
|
52
|
+
- `serializeCustomerTheme` emits `customers/<name>.ts` source in the CLI's own layout (`pnpm new-theme`'s `Palette` + `SlotMap`), extended to a pair: two `SlotMap`s (light and dark) and, unlike the CLI's override-free scaffold, the solved `ThemeDef` overrides each member needs to clear AA — the same kind of hand-tuned correction `acmeOverrides` carries in `customers/acme.ts`, but derived rather than hand-tuned.
|
|
53
|
+
|
|
38
54
|
## Machine-readable artifacts
|
|
39
55
|
|
|
40
56
|
For AI and tooling, see [llms.txt](./llms.txt):
|
|
@@ -42,6 +58,7 @@ For AI and tooling, see [llms.txt](./llms.txt):
|
|
|
42
58
|
- `dist/guidance.json` — Variant intent, usage rules, state derivation
|
|
43
59
|
- `dist/dtcg/{theme}.json` — W3C DTCG format export
|
|
44
60
|
- `dist/components/{name}.vars.css` — Component-specific token declarations
|
|
61
|
+
- `dist/generate/` — Compiled `./generate` subpath (`parsePrompt`, `deriveTheme`, `serializeCustomerTheme`, and lower-level helpers)
|
|
45
62
|
|
|
46
63
|
## Note
|
|
47
64
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResolvedTheme } from "./dsl/resolver.js";
|
|
2
|
+
export interface ContrastPair {
|
|
3
|
+
fg: string;
|
|
4
|
+
bg: string;
|
|
5
|
+
minRatio: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ContrastResult extends ContrastPair {
|
|
8
|
+
ratio: number;
|
|
9
|
+
pass: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Composite a foreground hex color with alpha onto an opaque background hex.
|
|
13
|
+
* Uses sRGB blending (gamma-encoded): mixed = fg * alpha + bg * (1 - alpha).
|
|
14
|
+
* Returns an opaque hex color matching browser rendering.
|
|
15
|
+
*/
|
|
16
|
+
export declare function compositeHex(fgHex: string, alpha: number, bgHex: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Check contrast ratios for a set of fg/bg pairs in a resolved theme.
|
|
19
|
+
*
|
|
20
|
+
* Alpha handling:
|
|
21
|
+
* - bg with alpha (e.g. fillTint*) is composited onto bgPrimary
|
|
22
|
+
* - fg with alpha (e.g. fgSecondary) is composited onto the bg token
|
|
23
|
+
*
|
|
24
|
+
* bgPrimary is used as the base surface for alpha-bg compositing.
|
|
25
|
+
*/
|
|
26
|
+
export declare function checkContrast(resolved: ResolvedTheme, pairs: ContrastPair[]): ContrastResult[];
|
|
27
|
+
/**
|
|
28
|
+
* Standard WCAG AA contrast pairs that every theme must pass.
|
|
29
|
+
*/
|
|
30
|
+
export declare const wcagAAPairs: ContrastPair[];
|
|
31
|
+
/** Solid component-variant labels on their variant backgrounds (spec: "every
|
|
32
|
+
* button/tag label on its variant background ≥ 4.5"). Tint-variant labels are
|
|
33
|
+
* already covered by the fg-on-tint pairs in wcagAAPairs. */
|
|
34
|
+
export declare const componentLabelPairs: ContrastPair[];
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { wcagContrast, formatHex, rgb } from "culori";
|
|
2
|
+
// ── Alpha compositing ─────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Composite a foreground hex color with alpha onto an opaque background hex.
|
|
5
|
+
* Uses sRGB blending (gamma-encoded): mixed = fg * alpha + bg * (1 - alpha).
|
|
6
|
+
* Returns an opaque hex color matching browser rendering.
|
|
7
|
+
*/
|
|
8
|
+
export function compositeHex(fgHex, alpha, bgHex) {
|
|
9
|
+
const f = rgb(fgHex);
|
|
10
|
+
const b = rgb(bgHex);
|
|
11
|
+
if (!f)
|
|
12
|
+
throw new Error(`compositeHex: unparseable color "${fgHex}"`);
|
|
13
|
+
if (!b)
|
|
14
|
+
throw new Error(`compositeHex: unparseable color "${bgHex}"`);
|
|
15
|
+
const hex = formatHex({
|
|
16
|
+
mode: "rgb",
|
|
17
|
+
r: f.r * alpha + b.r * (1 - alpha),
|
|
18
|
+
g: f.g * alpha + b.g * (1 - alpha),
|
|
19
|
+
b: f.b * alpha + b.b * (1 - alpha),
|
|
20
|
+
});
|
|
21
|
+
if (!hex)
|
|
22
|
+
throw new Error(`compositeHex: could not format the blend of "${fgHex}" and "${bgHex}"`);
|
|
23
|
+
return hex;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Composite a foreground color with alpha onto an opaque background.
|
|
27
|
+
* Delegates to compositeHex for sRGB blending.
|
|
28
|
+
*/
|
|
29
|
+
function compositeOnBackground(fg, bg) {
|
|
30
|
+
const alpha = fg.oklch.alpha ?? 1;
|
|
31
|
+
if (alpha >= 1) {
|
|
32
|
+
return fg.hex;
|
|
33
|
+
}
|
|
34
|
+
return compositeHex(fg.hex, alpha, bg.hex);
|
|
35
|
+
}
|
|
36
|
+
// ── Contrast checker ──────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Check contrast ratios for a set of fg/bg pairs in a resolved theme.
|
|
39
|
+
*
|
|
40
|
+
* Alpha handling:
|
|
41
|
+
* - bg with alpha (e.g. fillTint*) is composited onto bgPrimary
|
|
42
|
+
* - fg with alpha (e.g. fgSecondary) is composited onto the bg token
|
|
43
|
+
*
|
|
44
|
+
* bgPrimary is used as the base surface for alpha-bg compositing.
|
|
45
|
+
*/
|
|
46
|
+
export function checkContrast(resolved, pairs) {
|
|
47
|
+
const bgPrimary = resolved.bgPrimary;
|
|
48
|
+
if (!bgPrimary) {
|
|
49
|
+
throw new Error("Theme must include bgPrimary for contrast checking");
|
|
50
|
+
}
|
|
51
|
+
return pairs.map((pair) => {
|
|
52
|
+
const fgToken = resolved[pair.fg];
|
|
53
|
+
const bgToken = resolved[pair.bg];
|
|
54
|
+
if (!fgToken) {
|
|
55
|
+
throw new Error(`Unknown foreground token: "${pair.fg}"`);
|
|
56
|
+
}
|
|
57
|
+
if (!bgToken) {
|
|
58
|
+
throw new Error(`Unknown background token: "${pair.bg}"`);
|
|
59
|
+
}
|
|
60
|
+
// Resolve effective bg: if bg has alpha, composite onto bgPrimary
|
|
61
|
+
const bgAlpha = bgToken.oklch.alpha ?? 1;
|
|
62
|
+
const effectiveBgHex = bgAlpha < 1
|
|
63
|
+
? compositeOnBackground(bgToken, bgPrimary)
|
|
64
|
+
: bgToken.hex;
|
|
65
|
+
// Resolve effective fg: if fg has alpha, composite onto the bg
|
|
66
|
+
const fgAlpha = fgToken.oklch.alpha ?? 1;
|
|
67
|
+
const effectiveFgHex = fgAlpha < 1
|
|
68
|
+
? compositeOnBackground(fgToken, bgToken)
|
|
69
|
+
: fgToken.hex;
|
|
70
|
+
const ratio = wcagContrast(effectiveFgHex, effectiveBgHex);
|
|
71
|
+
return {
|
|
72
|
+
...pair,
|
|
73
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
74
|
+
pass: ratio >= pair.minRatio,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// ── Default contrast pairs ────────────────────────────────────────
|
|
79
|
+
const bgSurfaces = [
|
|
80
|
+
"bgPrimary",
|
|
81
|
+
"bgSecondary",
|
|
82
|
+
"fillNeutral1",
|
|
83
|
+
"fillNeutral2",
|
|
84
|
+
"fillNeutral3",
|
|
85
|
+
"fillNeutral4",
|
|
86
|
+
"fillNeutral5",
|
|
87
|
+
"fillNeutral6",
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Standard WCAG AA contrast pairs that every theme must pass.
|
|
91
|
+
*/
|
|
92
|
+
export const wcagAAPairs = [
|
|
93
|
+
// fgPrimary on all surfaces
|
|
94
|
+
...bgSurfaces.map((bg) => ({ fg: "fgPrimary", bg, minRatio: 4.5 })),
|
|
95
|
+
// fgSecondary on all surfaces
|
|
96
|
+
...bgSurfaces.map((bg) => ({ fg: "fgSecondary", bg, minRatio: 4.5 })),
|
|
97
|
+
// Semantic fg on primary background
|
|
98
|
+
{ fg: "fgAccent", bg: "bgPrimary", minRatio: 4.5 },
|
|
99
|
+
{ fg: "fgSuccess", bg: "bgPrimary", minRatio: 4.5 },
|
|
100
|
+
{ fg: "fgWarning", bg: "bgPrimary", minRatio: 4.5 },
|
|
101
|
+
{ fg: "fgDanger", bg: "bgPrimary", minRatio: 4.5 },
|
|
102
|
+
// Semantic fg on their tint backgrounds
|
|
103
|
+
{ fg: "fgAccent", bg: "fillTintAccent", minRatio: 4.5 },
|
|
104
|
+
{ fg: "fgSuccess", bg: "fillTintSuccess", minRatio: 4.5 },
|
|
105
|
+
{ fg: "fgWarning", bg: "fillTintWarning", minRatio: 4.5 },
|
|
106
|
+
{ fg: "fgDanger", bg: "fillTintDanger", minRatio: 4.5 },
|
|
107
|
+
// Primary text on a selected table row's accent wash (D62). Distinct from
|
|
108
|
+
// the fgAccent/fillTintAccent pair above: a selected row keeps ordinary
|
|
109
|
+
// body text, it does not recolor it to the accent.
|
|
110
|
+
{ fg: "fgPrimary", bg: "fillTintAccent", minRatio: 4.5 },
|
|
111
|
+
// Toast's status icon on the elevated surface (D64/D65). The three pairs
|
|
112
|
+
// above gate these foregrounds on bgPrimary; --psi-surface-bg — what Toast,
|
|
113
|
+
// Menu and Dialog all alias — resolves to bgSecondary, which was ungated for
|
|
114
|
+
// the semantic foregrounds until Toast put an icon there. Held at 4.5 even
|
|
115
|
+
// though an icon is non-text content (WCAG 1.4.11 would allow 3.0): the
|
|
116
|
+
// colours clear 4.5 anyway, and the matrix's posture is a single floor.
|
|
117
|
+
{ fg: "fgSuccess", bg: "bgSecondary", minRatio: 4.5 },
|
|
118
|
+
{ fg: "fgWarning", bg: "bgSecondary", minRatio: 4.5 },
|
|
119
|
+
{ fg: "fgDanger", bg: "bgSecondary", minRatio: 4.5 },
|
|
120
|
+
];
|
|
121
|
+
/** Solid component-variant labels on their variant backgrounds (spec: "every
|
|
122
|
+
* button/tag label on its variant background ≥ 4.5"). Tint-variant labels are
|
|
123
|
+
* already covered by the fg-on-tint pairs in wcagAAPairs. */
|
|
124
|
+
export const componentLabelPairs = [
|
|
125
|
+
{ fg: "fgOnAccent", bg: "fillAccent", minRatio: 4.5 }, // Button/IconButton/Tag accent label, Checkbox check, outline hover (D37)
|
|
126
|
+
{ fg: "fgStaticWhite", bg: "fillDanger", minRatio: 4.5 }, // Button/IconButton/Tag danger
|
|
127
|
+
{ fg: "fgStaticWhite", bg: "fillSuccess", minRatio: 4.5 }, // Tag success
|
|
128
|
+
{ fg: "fgStaticBlack", bg: "fillWarning", minRatio: 4.5 }, // Tag warning (D22)
|
|
129
|
+
{ fg: "fgOnInverted", bg: "bgInverted", minRatio: 4.5 }, // Tooltip label (D46 inversion pair)
|
|
130
|
+
];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CapOp, DeltaOp, SetOp, SlotRef, TokenDef, TokenRef } from "./types.js";
|
|
2
|
+
export declare function set(value: number): SetOp;
|
|
3
|
+
export declare function delta(value: number): DeltaOp;
|
|
4
|
+
export declare function cap(value: number): CapOp;
|
|
5
|
+
export declare const slot: Record<string, SlotRef>;
|
|
6
|
+
export declare const ref: Record<string, TokenRef>;
|
|
7
|
+
export declare function token(def: TokenDef): TokenDef;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ── Channel operation builders ──────────────────────────────────────
|
|
2
|
+
export function set(value) {
|
|
3
|
+
return { op: "set", value };
|
|
4
|
+
}
|
|
5
|
+
export function delta(value) {
|
|
6
|
+
return { op: "delta", value };
|
|
7
|
+
}
|
|
8
|
+
export function cap(value) {
|
|
9
|
+
return { op: "cap", value };
|
|
10
|
+
}
|
|
11
|
+
// ── Source proxies ──────────────────────────────────────────────────
|
|
12
|
+
export const slot = new Proxy({}, {
|
|
13
|
+
get(_target, prop) {
|
|
14
|
+
return { type: "slot", name: prop };
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
export const ref = new Proxy({}, {
|
|
18
|
+
get(_target, prop) {
|
|
19
|
+
return { type: "ref", name: prop };
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
// ── Token builder ───────────────────────────────────────────────────
|
|
23
|
+
export function token(def) {
|
|
24
|
+
return { ...def };
|
|
25
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { formatHex, clampChroma } from "culori";
|
|
2
|
+
// ── Channel operation application ──────────────────────────────────
|
|
3
|
+
function applyOp(base, op) {
|
|
4
|
+
if (!op)
|
|
5
|
+
return base;
|
|
6
|
+
switch (op.op) {
|
|
7
|
+
case "set":
|
|
8
|
+
return op.value;
|
|
9
|
+
case "delta":
|
|
10
|
+
return base + op.value;
|
|
11
|
+
case "cap":
|
|
12
|
+
return Math.min(base, op.value);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// ── Formula string generation ──────────────────────────────────────
|
|
16
|
+
function formatOp(channel, op) {
|
|
17
|
+
return `${channel} ${op.op}(${op.value})`;
|
|
18
|
+
}
|
|
19
|
+
function formatFormula(def) {
|
|
20
|
+
const source = def.from.type === "slot"
|
|
21
|
+
? `slot(${def.from.name})`
|
|
22
|
+
: `ref(${def.from.name})`;
|
|
23
|
+
const ops = [];
|
|
24
|
+
if (def.l)
|
|
25
|
+
ops.push(formatOp("L", def.l));
|
|
26
|
+
if (def.c)
|
|
27
|
+
ops.push(formatOp("C", def.c));
|
|
28
|
+
if (def.h)
|
|
29
|
+
ops.push(formatOp("H", def.h));
|
|
30
|
+
if (def.alpha !== undefined)
|
|
31
|
+
ops.push(`alpha(${def.alpha})`);
|
|
32
|
+
return ops.length > 0 ? `${source} → ${ops.join(", ")}` : source;
|
|
33
|
+
}
|
|
34
|
+
// ── Resolver ───────────────────────────────────────────────────────
|
|
35
|
+
export function resolve(theme, palette, slots) {
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
function resolveToken(name) {
|
|
38
|
+
const cached = cache.get(name);
|
|
39
|
+
if (cached)
|
|
40
|
+
return cached;
|
|
41
|
+
const def = theme[name];
|
|
42
|
+
if (!def) {
|
|
43
|
+
throw new Error(`Unknown token: "${name}"`);
|
|
44
|
+
}
|
|
45
|
+
// Resolve base L/C/H from source
|
|
46
|
+
let baseL;
|
|
47
|
+
let baseC;
|
|
48
|
+
let baseH;
|
|
49
|
+
let baseAlpha;
|
|
50
|
+
if (def.from.type === "slot") {
|
|
51
|
+
const paletteName = slots[def.from.name];
|
|
52
|
+
if (!paletteName) {
|
|
53
|
+
throw new Error(`Unknown slot: "${def.from.name}"`);
|
|
54
|
+
}
|
|
55
|
+
const entry = palette[paletteName];
|
|
56
|
+
if (!entry) {
|
|
57
|
+
throw new Error(`Unknown palette entry: "${paletteName}" (from slot "${def.from.name}")`);
|
|
58
|
+
}
|
|
59
|
+
baseL = entry.l;
|
|
60
|
+
baseC = entry.c;
|
|
61
|
+
baseH = entry.h;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// ref — recursively resolve the referenced token
|
|
65
|
+
const refToken = resolveToken(def.from.name);
|
|
66
|
+
baseL = refToken.oklch.l;
|
|
67
|
+
baseC = refToken.oklch.c;
|
|
68
|
+
baseH = refToken.oklch.h;
|
|
69
|
+
baseAlpha = refToken.oklch.alpha;
|
|
70
|
+
}
|
|
71
|
+
// Apply channel operations
|
|
72
|
+
const l = applyOp(baseL, def.l);
|
|
73
|
+
const c = applyOp(baseC, def.c);
|
|
74
|
+
const h = applyOp(baseH, def.h);
|
|
75
|
+
const alpha = def.alpha ?? baseAlpha;
|
|
76
|
+
// Build OKLCH color for culori
|
|
77
|
+
const oklchColor = {
|
|
78
|
+
mode: "oklch",
|
|
79
|
+
l,
|
|
80
|
+
c,
|
|
81
|
+
h,
|
|
82
|
+
};
|
|
83
|
+
if (alpha !== undefined) {
|
|
84
|
+
oklchColor.alpha = alpha;
|
|
85
|
+
}
|
|
86
|
+
// Gamut-map to sRGB via chroma clamping
|
|
87
|
+
const clamped = clampChroma(oklchColor, "oklch", "rgb");
|
|
88
|
+
// Extract the gamut-mapped OKLCH values
|
|
89
|
+
const clampedL = clamped?.l ?? l;
|
|
90
|
+
const clampedC = clamped?.c ?? 0;
|
|
91
|
+
const clampedH = clamped?.h ?? h;
|
|
92
|
+
// Generate hex (always 6-digit sRGB)
|
|
93
|
+
const hex = formatHex(clamped ?? oklchColor) ?? "#000000";
|
|
94
|
+
const resolved = {
|
|
95
|
+
name,
|
|
96
|
+
oklch: {
|
|
97
|
+
l: clampedL,
|
|
98
|
+
c: clampedC,
|
|
99
|
+
h: clampedH,
|
|
100
|
+
...(alpha !== undefined ? { alpha } : {}),
|
|
101
|
+
},
|
|
102
|
+
hex,
|
|
103
|
+
formula: formatFormula(def),
|
|
104
|
+
...(def.scopes !== undefined ? { scopes: def.scopes } : {}),
|
|
105
|
+
};
|
|
106
|
+
cache.set(name, resolved);
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
|
109
|
+
const result = {};
|
|
110
|
+
for (const name of Object.keys(theme)) {
|
|
111
|
+
result[name] = resolveToken(name);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface SetOp {
|
|
2
|
+
readonly op: "set";
|
|
3
|
+
readonly value: number;
|
|
4
|
+
}
|
|
5
|
+
export interface DeltaOp {
|
|
6
|
+
readonly op: "delta";
|
|
7
|
+
readonly value: number;
|
|
8
|
+
}
|
|
9
|
+
export interface CapOp {
|
|
10
|
+
readonly op: "cap";
|
|
11
|
+
readonly value: number;
|
|
12
|
+
}
|
|
13
|
+
export type ChannelOp = SetOp | DeltaOp | CapOp;
|
|
14
|
+
export interface SlotRef {
|
|
15
|
+
readonly type: "slot";
|
|
16
|
+
readonly name: string;
|
|
17
|
+
}
|
|
18
|
+
export interface TokenRef {
|
|
19
|
+
readonly type: "ref";
|
|
20
|
+
readonly name: string;
|
|
21
|
+
}
|
|
22
|
+
export type TokenSource = SlotRef | TokenRef;
|
|
23
|
+
export interface TokenDef {
|
|
24
|
+
readonly from: TokenSource;
|
|
25
|
+
readonly l?: ChannelOp;
|
|
26
|
+
readonly c?: ChannelOp;
|
|
27
|
+
readonly h?: ChannelOp;
|
|
28
|
+
readonly alpha?: number;
|
|
29
|
+
/** D46: property groups / CSS properties this token may bind to
|
|
30
|
+
* (see src/scopes.ts). Unscoped tokens are valid everywhere. */
|
|
31
|
+
readonly scopes?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
export interface PaletteEntry {
|
|
34
|
+
readonly l: number;
|
|
35
|
+
readonly c: number;
|
|
36
|
+
readonly h: number;
|
|
37
|
+
}
|
|
38
|
+
export type Palette = Record<string, PaletteEntry>;
|
|
39
|
+
export interface SlotMap {
|
|
40
|
+
readonly ink: string;
|
|
41
|
+
readonly canvas: string;
|
|
42
|
+
readonly accent: string;
|
|
43
|
+
readonly success: string;
|
|
44
|
+
readonly warning: string;
|
|
45
|
+
readonly danger: string;
|
|
46
|
+
}
|
|
47
|
+
export type ThemeDef = Record<string, TokenDef>;
|
|
48
|
+
export interface ResolvedToken {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly oklch: {
|
|
51
|
+
readonly l: number;
|
|
52
|
+
readonly c: number;
|
|
53
|
+
readonly h: number;
|
|
54
|
+
readonly alpha?: number;
|
|
55
|
+
};
|
|
56
|
+
readonly hex: string;
|
|
57
|
+
readonly formula: string;
|
|
58
|
+
readonly scopes?: readonly string[];
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ResolvedTheme } from "../dsl/resolver.js";
|
|
2
|
+
import { type CustomerTheme } from "../themes/customers/index.js";
|
|
3
|
+
import type { BrandVector } from "./types.js";
|
|
4
|
+
export interface DerivedTheme {
|
|
5
|
+
mode: "light" | "dark";
|
|
6
|
+
customerTheme: CustomerTheme;
|
|
7
|
+
resolved: ResolvedTheme;
|
|
8
|
+
customProperties: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
export interface DerivedPair {
|
|
11
|
+
vector: BrandVector;
|
|
12
|
+
light: DerivedTheme;
|
|
13
|
+
dark: DerivedTheme;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Derive both members of a brand from one vector, each solved to AA
|
|
17
|
+
* independently (D57). The header toggle then selects between them — there is
|
|
18
|
+
* nothing left to recompute.
|
|
19
|
+
*/
|
|
20
|
+
export declare function deriveTheme(v: BrandVector): DerivedPair;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { checkContrast, componentLabelPairs, wcagAAPairs } from "../contrast-matrix.js";
|
|
2
|
+
import { resolve } from "../dsl/resolver.js";
|
|
3
|
+
import { cap, set, slot, token } from "../dsl/builders.js";
|
|
4
|
+
import { assembleCustomerTheme, } from "../themes/customers/index.js";
|
|
5
|
+
import { buildBrandPalette } from "./palette.js";
|
|
6
|
+
import { hexFor, solveL } from "./solve.js";
|
|
7
|
+
/** camelCase token name → --psi-kebab-case custom property. */
|
|
8
|
+
function cssName(token) {
|
|
9
|
+
return `--psi-${token.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`;
|
|
10
|
+
}
|
|
11
|
+
/** The channels of `hex` with an exact alpha. checkContrast composites
|
|
12
|
+
* tok.hex (gamut-clamped, 8-bit per channel) with the unrounded alpha, so
|
|
13
|
+
* this is the only form that renders precisely what was validated. hex8
|
|
14
|
+
* cannot: it quantizes alpha to 1/255, which moves the composited result a
|
|
15
|
+
* full channel step and drops near-threshold pairs below 4.5 (measured: 384
|
|
16
|
+
* of 1225 sampled prompts had a pair render under AA with hex8). Deliberately
|
|
17
|
+
* not `oklch(l c h / a)` either — the browser would recompute RGB from OKLCH
|
|
18
|
+
* and could land a bit away from `formatHex`'s own rounding, reintroducing
|
|
19
|
+
* the same class of mismatch between what was validated and what renders. */
|
|
20
|
+
function withAlpha(hex, alpha) {
|
|
21
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
22
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
23
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
24
|
+
return `rgb(${r} ${g} ${b} / ${alpha})`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The gate, and it must be the SAME set `scripts/build.ts` runs over every
|
|
28
|
+
* committed theme: `[...wcagAAPairs, ...componentLabelPairs]`, 33 pairs.
|
|
29
|
+
*
|
|
30
|
+
* Checking only `wcagAAPairs` (28) is what let "a theme derived from any
|
|
31
|
+
* prompt clears the AA matrix by construction" ship false a third time. The
|
|
32
|
+
* five-pair delta is `componentLabelPairs` — solid variant labels on their
|
|
33
|
+
* fills — and the first of them, `fgOnAccent` on `fillAccent`, sat right at
|
|
34
|
+
* the threshold: `bold calm app` (hue 146, `vivid`) rendered it at 4.4979:1 in
|
|
35
|
+
* BOTH modes while the console printed "zero failures — by construction", and
|
|
36
|
+
* the `customers/bold-calm-app.ts` that prompt emitted threw in `pnpm build`,
|
|
37
|
+
* which had been checking all 33 the whole time.
|
|
38
|
+
*
|
|
39
|
+
* `buildBrandPalette` is what makes the extra five pass by construction (see
|
|
40
|
+
* `accentLightness` there for why the fix has to be upstream of this loop
|
|
41
|
+
* rather than another escalating target inside it).
|
|
42
|
+
*/
|
|
43
|
+
const AA_MATRIX = [...wcagAAPairs, ...componentLabelPairs];
|
|
44
|
+
const BASE_TARGET = 4.5;
|
|
45
|
+
const TARGET_ESCALATION = 0.1;
|
|
46
|
+
const MAX_OUTER_ATTEMPTS = 40;
|
|
47
|
+
/** The four text tokens this function solves, mapped to the palette slot
|
|
48
|
+
* their colour anchors on. The second element is the SLOT name ("accent"),
|
|
49
|
+
* not the palette key ("brandAccent") — `slot.accent` is what a TokenDef
|
|
50
|
+
* sources from, while the anchor is looked up through `slots` to reach the
|
|
51
|
+
* palette entry. */
|
|
52
|
+
const TEXT_TOKENS = [
|
|
53
|
+
["fgAccent", "accent"],
|
|
54
|
+
["fgSuccess", "success"],
|
|
55
|
+
["fgWarning", "warning"],
|
|
56
|
+
["fgDanger", "danger"],
|
|
57
|
+
];
|
|
58
|
+
/** bg token name → the text token whose colour it derives from (light.ts /
|
|
59
|
+
* dark.ts: `fillTintAccent: token({ from: ref.fgAccent, ... })`, etc). Lets
|
|
60
|
+
* the escalation loop trace a failing pair like `{ fg: "fgPrimary", bg:
|
|
61
|
+
* "fillTintAccent" }` (the D62 selected-row pair) back to the token that
|
|
62
|
+
* actually controls it, even though that token appears nowhere in the pair
|
|
63
|
+
* itself. */
|
|
64
|
+
const TINT_SOURCE = {
|
|
65
|
+
fillTintAccent: "fgAccent",
|
|
66
|
+
fillTintSuccess: "fgSuccess",
|
|
67
|
+
fillTintWarning: "fgWarning",
|
|
68
|
+
fillTintDanger: "fgDanger",
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Per-hue overrides that keep the AA matrix clean, in exactly the shape acme
|
|
72
|
+
* carries by hand (`acmeOverrides`). Text tokens force their own L in the base
|
|
73
|
+
* themes, so the free variables here are that L and the chroma cap.
|
|
74
|
+
*
|
|
75
|
+
* Solving each text token to exactly 4.5:1 against the canvas is not enough:
|
|
76
|
+
* `wcagAAPairs` also gates the SAME foreground against its own tint fill
|
|
77
|
+
* (`fillTintAccent` etc.) — canvas tinted 12–15% toward the very colour being
|
|
78
|
+
* tested — and, for the status tokens, against `bgSecondary`. Both are
|
|
79
|
+
* strictly harder than plain canvas contrast (the tint background is closer
|
|
80
|
+
* in colour to the foreground than the canvas is), so a token solved to land
|
|
81
|
+
* exactly on the canvas threshold lands *under* AA on those pairs — this is
|
|
82
|
+
* what the initial `it.each` sweep caught (5 prompts, both text-on-canvas and
|
|
83
|
+
* text-on-tint failures, e.g. `fgAccent/fillTintAccent 3.88<4.5`).
|
|
84
|
+
*
|
|
85
|
+
* The accept/reject gate for the whole function is the FULL `AA_MATRIX`
|
|
86
|
+
* sweep against the real resolved theme, not a per-token subset: a per-token
|
|
87
|
+
* filter (`p.fg === name`) is blind to cross pairs whose `bg` a token
|
|
88
|
+
* controls indirectly but whose `fg` is something else — e.g. `{ fg:
|
|
89
|
+
* "fgPrimary", bg: "fillTintAccent" }` (D62) is only visible once
|
|
90
|
+
* `fillTintAccent`'s own dependency on `fgAccent` is traced via
|
|
91
|
+
* `TINT_SOURCE`. Solving each token in isolation and declaring victory once
|
|
92
|
+
* IT is happy would leave that pair free to fail with no one watching. Each
|
|
93
|
+
* outer pass solves all four tokens at their current targets, resolves the
|
|
94
|
+
* real theme, and checks every pair in `wcagAAPairs`; any failure escalates
|
|
95
|
+
* the target of whichever token owns it (traced through `TINT_SOURCE` when
|
|
96
|
+
* the failure isn't already `fg === token`) and the whole thing is retried.
|
|
97
|
+
* The loop cannot return successfully while any pair in the matrix fails —
|
|
98
|
+
* it either converges within `MAX_OUTER_ATTEMPTS` or throws.
|
|
99
|
+
*/
|
|
100
|
+
function solveOverrides(palette, slots, mode) {
|
|
101
|
+
const canvas = palette[slots.canvas];
|
|
102
|
+
const canvasHex = hexFor(canvas.l, canvas.c, canvas.h);
|
|
103
|
+
const direction = mode === "light" ? "darker" : "lighter";
|
|
104
|
+
// Solve a single text token's L/chroma against a given canvas-contrast
|
|
105
|
+
// target. Reduces chroma and re-solves when the target is unreachable at
|
|
106
|
+
// the anchor's own chroma — exactly what acme's hand-tuned caps
|
|
107
|
+
// (`acmeOverrides`) do by hand. Reading `.cleared` is not optional:
|
|
108
|
+
// `solveL` returns its most legible available value when it gives up, so
|
|
109
|
+
// ignoring the flag would ship a below-AA colour that looks like a solved
|
|
110
|
+
// one.
|
|
111
|
+
function solveOne(slotName, target) {
|
|
112
|
+
const anchor = palette[slots[slotName]];
|
|
113
|
+
let c = anchor.c;
|
|
114
|
+
let solved = solveL({ c, h: anchor.h, against: canvasHex, target, direction });
|
|
115
|
+
while (!solved.cleared && c > 0) {
|
|
116
|
+
c = Math.max(0, c - 0.008);
|
|
117
|
+
solved = solveL({ c, h: anchor.h, against: canvasHex, target, direction });
|
|
118
|
+
}
|
|
119
|
+
if (!solved.cleared) {
|
|
120
|
+
throw new Error(`solveOverrides: ${slotName} cannot clear ${target}:1 against ${canvasHex} at any chroma`);
|
|
121
|
+
}
|
|
122
|
+
// Deliberately NO `scopes`. `assembleCustomerTheme` inherits the base
|
|
123
|
+
// token's scopes when an override declares none (D46), and the base
|
|
124
|
+
// declarations are not uniform: `fgDanger` is `["text", "border"]` in both
|
|
125
|
+
// light.ts and dark.ts, while the other three are `["text"]`. Hardcoding
|
|
126
|
+
// `["text"]` here silently narrowed fgDanger — invisible in-memory (the
|
|
127
|
+
// console never runs the scope gate) but fatal in the token build, which
|
|
128
|
+
// rejects the emitted `customers/<name>.ts` with
|
|
129
|
+
// `Scope drift for token "fgDanger": light declares ["text","border"],
|
|
130
|
+
// <name> declares ["text"]` before the contrast gate is even reached.
|
|
131
|
+
return token({
|
|
132
|
+
from: slot[slotName],
|
|
133
|
+
l: set(Number(solved.l.toFixed(4))),
|
|
134
|
+
c: cap(Number(c.toFixed(4))),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const targets = {};
|
|
138
|
+
for (const [name] of TEXT_TOKENS)
|
|
139
|
+
targets[name] = BASE_TARGET;
|
|
140
|
+
let overrides = {};
|
|
141
|
+
let lastFailures = [];
|
|
142
|
+
for (let outer = 0; outer < MAX_OUTER_ATTEMPTS; outer++) {
|
|
143
|
+
overrides = {};
|
|
144
|
+
for (const [name, slotName] of TEXT_TOKENS) {
|
|
145
|
+
overrides[name] = solveOne(slotName, targets[name]);
|
|
146
|
+
}
|
|
147
|
+
const trialResolved = resolve(assembleCustomerTheme({ palette, slots, base: mode, overrides }), palette, slots);
|
|
148
|
+
const failures = checkContrast(trialResolved, AA_MATRIX).filter((r) => !r.pass);
|
|
149
|
+
if (failures.length === 0) {
|
|
150
|
+
lastFailures = [];
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
lastFailures = failures.map((f) => `${f.fg}/${f.bg} ${f.ratio}<${f.minRatio}`);
|
|
154
|
+
let escalatedAny = false;
|
|
155
|
+
for (const f of failures) {
|
|
156
|
+
const owner = f.fg in targets ? f.fg : TINT_SOURCE[f.bg];
|
|
157
|
+
if (owner !== undefined && owner in targets) {
|
|
158
|
+
targets[owner] = targets[owner] + TARGET_ESCALATION;
|
|
159
|
+
escalatedAny = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// A failing pair this loop cannot trace to any of the four tokens it
|
|
163
|
+
// controls (fg isn't one of them, bg isn't a tint of one of them) is not
|
|
164
|
+
// something escalating a target will ever fix — retrying would just burn
|
|
165
|
+
// the remaining attempts for no reason, so give up immediately with the
|
|
166
|
+
// specific failures rather than a generic "still fails" after 40 no-op
|
|
167
|
+
// attempts.
|
|
168
|
+
if (!escalatedAny) {
|
|
169
|
+
throw new Error(`solveOverrides: failure(s) outside the four solved tokens' control: ${lastFailures.join(", ")}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (lastFailures.length > 0) {
|
|
173
|
+
throw new Error(`solveOverrides: still fails after ${MAX_OUTER_ATTEMPTS} escalations: ${lastFailures.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
return overrides;
|
|
176
|
+
}
|
|
177
|
+
function deriveMember(v, palette, slots, mode) {
|
|
178
|
+
const customerTheme = {
|
|
179
|
+
palette,
|
|
180
|
+
slots,
|
|
181
|
+
base: mode,
|
|
182
|
+
overrides: solveOverrides(palette, slots, mode),
|
|
183
|
+
...(v.fonts ? { fonts: v.fonts } : {}),
|
|
184
|
+
componentOverrides: { "control-radius": `var(--psi-radius-${v.radius})` },
|
|
185
|
+
};
|
|
186
|
+
const resolved = resolve(assembleCustomerTheme(customerTheme), palette, slots);
|
|
187
|
+
const customProperties = {};
|
|
188
|
+
for (const [name, tok] of Object.entries(resolved)) {
|
|
189
|
+
// tok.hex is always opaque — formatHex discards alpha. Tokens like
|
|
190
|
+
// fillTint* and the scrims carry alpha, and dropping it makes them
|
|
191
|
+
// byte-identical to the token they derive from (e.g. fillTintAccent
|
|
192
|
+
// would render as fgAccent at full strength): checkContrast composites
|
|
193
|
+
// alpha correctly, so the AA sweep would still pass while the shipped
|
|
194
|
+
// custom property renders the guarantee false in the browser. `withAlpha`
|
|
195
|
+
// carries the exact float alpha checkContrast validated against — see its
|
|
196
|
+
// own comment for why hex8 (1/255 alpha quantization) isn't safe here.
|
|
197
|
+
const alpha = tok.oklch.alpha;
|
|
198
|
+
customProperties[cssName(name)] =
|
|
199
|
+
alpha === undefined || alpha >= 1 ? tok.hex : withAlpha(tok.hex, alpha);
|
|
200
|
+
}
|
|
201
|
+
for (const [name, value] of Object.entries(customerTheme.componentOverrides ?? {})) {
|
|
202
|
+
customProperties[`--psi-${name}`] = value;
|
|
203
|
+
}
|
|
204
|
+
return { mode, customerTheme, resolved, customProperties };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Derive both members of a brand from one vector, each solved to AA
|
|
208
|
+
* independently (D57). The header toggle then selects between them — there is
|
|
209
|
+
* nothing left to recompute.
|
|
210
|
+
*/
|
|
211
|
+
export function deriveTheme(v) {
|
|
212
|
+
const { palette, lightSlots, darkSlots } = buildBrandPalette(v);
|
|
213
|
+
const pair = {
|
|
214
|
+
vector: v,
|
|
215
|
+
light: deriveMember(v, palette, lightSlots, "light"),
|
|
216
|
+
dark: deriveMember(v, palette, darkSlots, "dark"),
|
|
217
|
+
};
|
|
218
|
+
// Belt and braces: the solver targets the text pairs directly, but the AA
|
|
219
|
+
// matrix covers more than those — including the five `componentLabelPairs`
|
|
220
|
+
// no target inside `solveOverrides` can move, which is exactly why this
|
|
221
|
+
// check runs over `AA_MATRIX` and not the narrower `wcagAAPairs`. A residual
|
|
222
|
+
// failure means a bug in the solver or the palette anchors, not bad input,
|
|
223
|
+
// so surface it loudly in development.
|
|
224
|
+
for (const member of [pair.light, pair.dark]) {
|
|
225
|
+
const failures = checkContrast(member.resolved, AA_MATRIX).filter((r) => !r.pass);
|
|
226
|
+
if (failures.length > 0) {
|
|
227
|
+
throw new Error(`deriveTheme: ${member.mode} left ${failures.length} AA failure(s): ` +
|
|
228
|
+
failures.map((f) => `${f.fg}/${f.bg} ${f.ratio}<${f.minRatio}`).join(", "));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return pair;
|
|
232
|
+
}
|