@handamade/psi-tokens 0.17.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/components/toolbar.vars.css +5 -0
- package/dist/components.css +6 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChromaWord, RadiusRung } from "./types.js";
|
|
2
|
+
/** Keyword → accent hue in OKLCH degrees. */
|
|
3
|
+
export declare const HUES: Readonly<Record<string, number>>;
|
|
4
|
+
/** Keyword → chroma character. */
|
|
5
|
+
export declare const CHROMAS: Readonly<Record<string, ChromaWord>>;
|
|
6
|
+
/** Words that imply the brand is shown dark-first, or light-first. */
|
|
7
|
+
export declare const DARK_WORDS: ReadonlySet<string>;
|
|
8
|
+
export declare const LIGHT_WORDS: ReadonlySet<string>;
|
|
9
|
+
/** Keyword → on-scale radius rung (D56). */
|
|
10
|
+
export declare const RADII: Readonly<Record<string, RadiusRung>>;
|
|
11
|
+
/**
|
|
12
|
+
* Font roles by character. Psi ships no font files (D29) — this assigns roles
|
|
13
|
+
* only, and consumers load the webfonts.
|
|
14
|
+
*/
|
|
15
|
+
export declare const FONT_SETS: Readonly<Record<string, {
|
|
16
|
+
display: string;
|
|
17
|
+
sans: string;
|
|
18
|
+
}>>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Keyword → accent hue in OKLCH degrees. */
|
|
2
|
+
export const HUES = {
|
|
3
|
+
ocean: 235, sea: 225, water: 220, sky: 215, lagoon: 200, aqua: 185,
|
|
4
|
+
cyan: 195, teal: 190, midnight: 260, navy: 255, space: 265, cosmic: 280,
|
|
5
|
+
forest: 145, moss: 130, sage: 140, mint: 165, jungle: 150, olive: 105,
|
|
6
|
+
lime: 120, sunset: 35, ember: 30, fire: 28, rust: 30, terracotta: 35,
|
|
7
|
+
desert: 65, sand: 75, gold: 85, amber: 75, honey: 80, mustard: 90,
|
|
8
|
+
violet: 295, grape: 290, lavender: 285, plum: 320, orchid: 310,
|
|
9
|
+
rose: 350, blush: 355, coral: 20, salmon: 25, crimson: 15, wine: 5,
|
|
10
|
+
slate: 240, storm: 245, graphite: 250, cyber: 195, matrix: 140, retro: 40,
|
|
11
|
+
};
|
|
12
|
+
/** Keyword → chroma character. */
|
|
13
|
+
export const CHROMAS = {
|
|
14
|
+
muted: "muted", faded: "muted", minimal: "muted", quiet: "muted",
|
|
15
|
+
misty: "muted", noir: "muted",
|
|
16
|
+
calm: "calm", soft: "calm", gentle: "calm", pastel: "calm", zen: "calm",
|
|
17
|
+
clean: "calm", simple: "calm", elegant: "calm",
|
|
18
|
+
bold: "vivid", punchy: "vivid", loud: "vivid", hot: "vivid",
|
|
19
|
+
vivid: "vivid", saturated: "vivid",
|
|
20
|
+
neon: "electric", electric: "electric", candy: "electric",
|
|
21
|
+
cyber: "electric", circus: "electric",
|
|
22
|
+
};
|
|
23
|
+
/** Words that imply the brand is shown dark-first, or light-first. */
|
|
24
|
+
export const DARK_WORDS = new Set([
|
|
25
|
+
"midnight", "noir", "dark", "night", "space", "cosmic", "cyber", "matrix",
|
|
26
|
+
"storm", "graphite", "eclipse", "shadow", "deep",
|
|
27
|
+
]);
|
|
28
|
+
export const LIGHT_WORDS = new Set([
|
|
29
|
+
"light", "bright", "sunrise", "dawn", "day", "airy", "pastel", "cream",
|
|
30
|
+
"paper", "linen", "snow", "clean",
|
|
31
|
+
]);
|
|
32
|
+
/** Keyword → on-scale radius rung (D56). */
|
|
33
|
+
export const RADII = {
|
|
34
|
+
sharp: 4, brutalist: 4, technical: 4, precise: 4, editorial: 4,
|
|
35
|
+
crisp: 6, modern: 6, tailored: 6,
|
|
36
|
+
soft: 12, friendly: 12, round: 12, playful: 12, whimsy: 12, candy: 12,
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Font roles by character. Psi ships no font files (D29) — this assigns roles
|
|
40
|
+
* only, and consumers load the webfonts.
|
|
41
|
+
*/
|
|
42
|
+
export const FONT_SETS = {
|
|
43
|
+
editorial: { display: "Fraunces", sans: "Inter" },
|
|
44
|
+
technical: { display: "IBM Plex Sans", sans: "IBM Plex Sans" },
|
|
45
|
+
friendly: { display: "Nunito", sans: "Nunito Sans" },
|
|
46
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { parsePrompt, fnv1a, seededRandom } from "./prompt.js";
|
|
2
|
+
export { buildBrandPalette } from "./palette.js";
|
|
3
|
+
export { hexFor, contrastOf, solveL } from "./solve.js";
|
|
4
|
+
export { deriveTheme, type DerivedTheme, type DerivedPair } from "./derive.js";
|
|
5
|
+
export { serializeCustomerTheme } from "./serialize.js";
|
|
6
|
+
export { isBrandVector, CHROMA_WORDS, RADIUS_RUNGS, type BrandVector, type ChromaWord, type RadiusRung, } from "./types.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { parsePrompt, fnv1a, seededRandom } from "./prompt.js";
|
|
2
|
+
export { buildBrandPalette } from "./palette.js";
|
|
3
|
+
export { hexFor, contrastOf, solveL } from "./solve.js";
|
|
4
|
+
export { deriveTheme } from "./derive.js";
|
|
5
|
+
export { serializeCustomerTheme } from "./serialize.js";
|
|
6
|
+
export { isBrandVector, CHROMA_WORDS, RADIUS_RUNGS, } from "./types.js";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { contrastOf, hexFor, solveL } from "./solve.js";
|
|
2
|
+
/** Accent chroma by character. Values sit inside sRGB for most hues; the
|
|
3
|
+
* solver (Task 4) caps per-hue where a given hue cannot carry them. */
|
|
4
|
+
const ACCENT_CHROMA = {
|
|
5
|
+
muted: 0.06,
|
|
6
|
+
calm: 0.10,
|
|
7
|
+
balanced: 0.15,
|
|
8
|
+
vivid: 0.20,
|
|
9
|
+
electric: 0.26,
|
|
10
|
+
};
|
|
11
|
+
/** Status hues are fixed: green reads as success and red as danger regardless
|
|
12
|
+
* of the brand. Only their chroma follows the brand's character. */
|
|
13
|
+
const STATUS_HUES = { success: 155, warning: 75, danger: 25 };
|
|
14
|
+
/** The accent's nominal lightness — kept unless a white label cannot sit on it. */
|
|
15
|
+
const ACCENT_L = 0.55;
|
|
16
|
+
/**
|
|
17
|
+
* `fgOnAccent` is `ref.fgStaticWhite` in both base themes — pure white — and
|
|
18
|
+
* `fillAccent` is `slot.accent` untouched, so `componentLabelPairs`' first
|
|
19
|
+
* entry (`fgOnAccent` on `fillAccent`, the Button/Tag/Checkbox label) is
|
|
20
|
+
* decided entirely here, by the accent anchor's own lightness. Nothing
|
|
21
|
+
* downstream can rescue it: `solveOverrides` only controls the four text
|
|
22
|
+
* tokens, and neither `fgOnAccent` nor `fillAccent` traces to any of them.
|
|
23
|
+
*
|
|
24
|
+
* At a flat `l: 0.55` the pair is not safe by construction. Swept over 360
|
|
25
|
+
* hues × 5 chroma words, the worst case is hue 146 at `vivid` (c 0.20), which
|
|
26
|
+
* renders white-on-accent at **4.4979:1** — under AA, and the exact failure
|
|
27
|
+
* the prompt "bold calm app" produced. It cleared CI only because `derive.ts`
|
|
28
|
+
* checked the 28-pair `wcagAAPairs` while `scripts/build.ts` gates committed
|
|
29
|
+
* themes on all 33.
|
|
30
|
+
*
|
|
31
|
+
* So the accent is darkened — never lightened — to the lightest value that
|
|
32
|
+
* still carries a white label at `LABEL_TARGET`, a deliberate margin above
|
|
33
|
+
* 4.5 so that OKLCH→sRGB rounding and the 4-decimal truncation below cannot
|
|
34
|
+
* eat the guarantee. Most hue/chroma combinations already clear it and keep
|
|
35
|
+
* `l: 0.55` exactly.
|
|
36
|
+
*
|
|
37
|
+
* The alternative — solving `fgOnAccent` as a fifth escalating token — was
|
|
38
|
+
* rejected: a failure on that pair traces to none of the tokens
|
|
39
|
+
* `solveOverrides` escalates, so wiring the pair into the accept gate without
|
|
40
|
+
* this fix converts a rare AA miss into a rare hard throw ("derivation
|
|
41
|
+
* failed"), which is worse. Fixing the anchor makes the pair pass by
|
|
42
|
+
* construction, which is what the headline claim actually says.
|
|
43
|
+
*
|
|
44
|
+
* The three other solid-label pairs (`fgStaticWhite` on `fillSuccess` /
|
|
45
|
+
* `fillDanger`, `fgStaticBlack` on `fillWarning`) sit on fixed hues and were
|
|
46
|
+
* measured at 5.16 / 5.21 / 6.94:1 worst-case across the same sweep, so they
|
|
47
|
+
* need no equivalent treatment — but the sweep in
|
|
48
|
+
* `__tests__/generate-derive.test.ts` gates all 33 pairs, not just this one.
|
|
49
|
+
*/
|
|
50
|
+
const LABEL_TARGET = 4.65;
|
|
51
|
+
function accentLightness(c, h) {
|
|
52
|
+
const white = "#ffffff";
|
|
53
|
+
if (contrastOf(hexFor(ACCENT_L, c, h), white) >= LABEL_TARGET)
|
|
54
|
+
return ACCENT_L;
|
|
55
|
+
// Contrast against white rises monotonically as lightness falls, and black
|
|
56
|
+
// clears any target, so `direction: "darker"` always converges — it returns
|
|
57
|
+
// the LEAST extreme (lightest) L that still clears, keeping the accent as
|
|
58
|
+
// close to the brand's intent as legibility allows.
|
|
59
|
+
const solved = solveL({ c, h, against: white, target: LABEL_TARGET, direction: "darker" });
|
|
60
|
+
// Truncate rather than round: the emitted `customers/<name>.ts` carries this
|
|
61
|
+
// literal, and rounding UP would hand the token build a lightness a hair
|
|
62
|
+
// above the one that was solved.
|
|
63
|
+
return Math.floor(Math.min(ACCENT_L, solved.l) * 10_000) / 10_000;
|
|
64
|
+
}
|
|
65
|
+
export function buildBrandPalette(v) {
|
|
66
|
+
const accentC = ACCENT_CHROMA[v.chroma];
|
|
67
|
+
// Neutrals carry a trace of the brand hue so the pair reads as one family.
|
|
68
|
+
const neutralC = Math.min(0.02, accentC * 0.12);
|
|
69
|
+
const statusC = Math.max(0.14, Math.min(0.22, accentC));
|
|
70
|
+
const palette = {
|
|
71
|
+
brandInkLight: { l: 0.25, c: neutralC, h: v.hue },
|
|
72
|
+
brandInkDark: { l: 0.95, c: neutralC, h: v.hue },
|
|
73
|
+
brandCanvasLight: { l: 0.96, c: neutralC * 0.5, h: v.hue },
|
|
74
|
+
brandCanvasDark: { l: 0.15, c: neutralC * 0.5, h: v.hue },
|
|
75
|
+
brandAccent: { l: accentLightness(accentC, v.hue), c: accentC, h: v.hue },
|
|
76
|
+
brandSuccess: { l: 0.52, c: statusC, h: STATUS_HUES.success },
|
|
77
|
+
brandWarning: { l: 0.75, c: statusC, h: STATUS_HUES.warning },
|
|
78
|
+
brandDanger: { l: 0.55, c: statusC, h: STATUS_HUES.danger },
|
|
79
|
+
white: { l: 1.0, c: 0, h: 0 },
|
|
80
|
+
black: { l: 0.0, c: 0, h: 0 },
|
|
81
|
+
};
|
|
82
|
+
const shared = {
|
|
83
|
+
accent: "brandAccent",
|
|
84
|
+
success: "brandSuccess",
|
|
85
|
+
warning: "brandWarning",
|
|
86
|
+
danger: "brandDanger",
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
palette,
|
|
90
|
+
lightSlots: { ink: "brandInkLight", canvas: "brandCanvasLight", ...shared },
|
|
91
|
+
darkSlots: { ink: "brandInkDark", canvas: "brandCanvasDark", ...shared },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type BrandVector } from "./types.js";
|
|
2
|
+
/** FNV-1a, 32-bit. Seeds the PRNG so unknown prompts still derive something
|
|
3
|
+
* coherent, and the same prompt always derives the same brand. */
|
|
4
|
+
export declare function fnv1a(input: string): number;
|
|
5
|
+
/** Small counter-based PRNG. Deterministic given its seed. */
|
|
6
|
+
export declare function seededRandom(seed: number): () => number;
|
|
7
|
+
export declare function parsePrompt(prompt: string): BrandVector;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { CHROMAS, DARK_WORDS, FONT_SETS, HUES, LIGHT_WORDS, RADII } from "./dictionaries.js";
|
|
2
|
+
import { CHROMA_WORDS, RADIUS_RUNGS } from "./types.js";
|
|
3
|
+
/** FNV-1a, 32-bit. Seeds the PRNG so unknown prompts still derive something
|
|
4
|
+
* coherent, and the same prompt always derives the same brand. */
|
|
5
|
+
export function fnv1a(input) {
|
|
6
|
+
let h = 2166136261;
|
|
7
|
+
for (let i = 0; i < input.length; i++) {
|
|
8
|
+
h ^= input.charCodeAt(i);
|
|
9
|
+
h = Math.imul(h, 16777619);
|
|
10
|
+
}
|
|
11
|
+
return h >>> 0;
|
|
12
|
+
}
|
|
13
|
+
/** Small counter-based PRNG. Deterministic given its seed. */
|
|
14
|
+
export function seededRandom(seed) {
|
|
15
|
+
let t = seed | 0;
|
|
16
|
+
return () => {
|
|
17
|
+
t = (t + 1831565813) | 0;
|
|
18
|
+
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
|
19
|
+
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
|
|
20
|
+
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function slugify(prompt) {
|
|
24
|
+
const slug = prompt
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
.slice(0, 48)
|
|
29
|
+
.replace(/-+$/g, "");
|
|
30
|
+
// A name must start with a letter to be a valid identifier stem.
|
|
31
|
+
return /^[a-z]/.test(slug) ? slug : `brand-${fnv1a(prompt).toString(36)}`;
|
|
32
|
+
}
|
|
33
|
+
function firstMatch(words, table) {
|
|
34
|
+
for (const w of words) {
|
|
35
|
+
const hit = table[w];
|
|
36
|
+
if (hit !== undefined)
|
|
37
|
+
return hit;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
export function parsePrompt(prompt) {
|
|
42
|
+
const normalized = prompt.trim().toLowerCase();
|
|
43
|
+
const words = normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
|
44
|
+
const rand = seededRandom(fnv1a(normalized));
|
|
45
|
+
const hue = firstMatch(words, HUES) ?? Math.floor(rand() * 360);
|
|
46
|
+
const chroma = firstMatch(words, CHROMAS) ??
|
|
47
|
+
CHROMA_WORDS[Math.floor(rand() * CHROMA_WORDS.length)];
|
|
48
|
+
const hasDark = words.some((w) => DARK_WORDS.has(w));
|
|
49
|
+
const hasLight = words.some((w) => LIGHT_WORDS.has(w));
|
|
50
|
+
// A prompt naming both, or neither, leans light — the site's default.
|
|
51
|
+
const mode = hasDark && !hasLight ? "dark" : "light";
|
|
52
|
+
const radius = firstMatch(words, RADII) ?? RADIUS_RUNGS[Math.floor(rand() * RADIUS_RUNGS.length)];
|
|
53
|
+
const fontSet = firstMatch(words, Object.fromEntries(Object.keys(FONT_SETS).map((k) => [k, k])));
|
|
54
|
+
return {
|
|
55
|
+
hue,
|
|
56
|
+
chroma,
|
|
57
|
+
mode,
|
|
58
|
+
radius,
|
|
59
|
+
...(fontSet ? { fonts: FONT_SETS[fontSet] } : {}),
|
|
60
|
+
name: slugify(normalized),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DerivedPair } from "./derive.js";
|
|
2
|
+
/**
|
|
3
|
+
* Emit the `customers/<name>.ts` source for a derived pair, plus the two lines
|
|
4
|
+
* that register it. Shape matches `scripts/new-theme.ts`, extended to a pair.
|
|
5
|
+
*/
|
|
6
|
+
export declare function serializeCustomerTheme(pair: DerivedPair): {
|
|
7
|
+
filename: string;
|
|
8
|
+
source: string;
|
|
9
|
+
registration: string;
|
|
10
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kebab-slug → camelCase identifier stem.
|
|
3
|
+
*
|
|
4
|
+
* The trailing `[^A-Za-z0-9]` strip and the `-+` (rather than `-`) are both
|
|
5
|
+
* load-bearing. `-([a-z0-9])` alone left any hyphen with no following
|
|
6
|
+
* alphanumeric in place: `ident("a--b")` returned `"a-B"` and `ident("ab-")`
|
|
7
|
+
* returned `"ab-"` — a literal hyphen inside what is spliced in as a
|
|
8
|
+
* TypeScript identifier, so the emitted `customers/<name>.ts` would not parse.
|
|
9
|
+
* `parsePrompt`'s slugify cannot produce either shape, but `isBrandVector` and
|
|
10
|
+
* `api/theme.ts` accept a model-supplied name, and that is the untrusted path.
|
|
11
|
+
* Both now reject consecutive and trailing hyphens as well — this collapse is
|
|
12
|
+
* the second line, not the only one.
|
|
13
|
+
*/
|
|
14
|
+
function ident(name) {
|
|
15
|
+
return name
|
|
16
|
+
.replace(/-+([a-z0-9])/g, (_, ch) => ch.toUpperCase())
|
|
17
|
+
.replace(/[^A-Za-z0-9]/g, "");
|
|
18
|
+
}
|
|
19
|
+
function entryLine(name, e) {
|
|
20
|
+
return ` ${name}: { l: ${e.l}, c: ${e.c}, h: ${e.h} },`;
|
|
21
|
+
}
|
|
22
|
+
function slotsLiteral(slots) {
|
|
23
|
+
return Object.entries(slots)
|
|
24
|
+
.map(([k, v]) => ` ${k}: "${v}",`)
|
|
25
|
+
.join("\n");
|
|
26
|
+
}
|
|
27
|
+
function opLiteral(op) {
|
|
28
|
+
return `${op.op}(${op.value})`;
|
|
29
|
+
}
|
|
30
|
+
/** Which of the DSL's builder functions actually appear in the given
|
|
31
|
+
* overrides body text — `set`/`cap` from ChannelOp literals, `slot`/`ref`
|
|
32
|
+
* from `from:` sources, `token` wrapping each entry, `delta` only if a
|
|
33
|
+
* DeltaOp was emitted. `solveOverrides` never emits `delta` or `ref` today
|
|
34
|
+
* (it only sets l/c against a slot anchor), so importing them unconditionally
|
|
35
|
+
* would name two identifiers no emitted file ever uses — an
|
|
36
|
+
* `@typescript-eslint/no-unused-vars` error under this repo's lint config,
|
|
37
|
+
* which is one of the five required gates. */
|
|
38
|
+
function usedBuilders(overridesBody) {
|
|
39
|
+
const builders = ["cap", "delta", "ref", "set", "slot", "token"];
|
|
40
|
+
return builders.filter((b) => new RegExp(`\\b${b}[.(]`).test(overridesBody));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Serialize the solved AA overrides.
|
|
44
|
+
*
|
|
45
|
+
* These are NOT optional decoration. The default theme formulas fail the
|
|
46
|
+
* contrast matrix on most generated palettes — that is the entire reason
|
|
47
|
+
* `solveOverrides` exists — so a `customers/<name>.ts` emitted without them
|
|
48
|
+
* would not merely render differently from the console preview: it would
|
|
49
|
+
* throw in the token build, whose WCAG gate runs over every committed theme.
|
|
50
|
+
* An unbuildable file is not "a real customer theme out".
|
|
51
|
+
*
|
|
52
|
+
* The emitted shape matches `acmeOverrides` in `customers/acme.ts`, which
|
|
53
|
+
* carries exactly this kind of per-hue correction by hand.
|
|
54
|
+
*/
|
|
55
|
+
function overridesLiteral(overrides) {
|
|
56
|
+
return Object.entries(overrides)
|
|
57
|
+
.map(([name, def]) => {
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (def.from.type === "slot")
|
|
60
|
+
parts.push(`from: slot.${def.from.name}`);
|
|
61
|
+
else
|
|
62
|
+
parts.push(`from: ref.${def.from.name}`);
|
|
63
|
+
if (def.l)
|
|
64
|
+
parts.push(`l: ${opLiteral(def.l)}`);
|
|
65
|
+
if (def.c)
|
|
66
|
+
parts.push(`c: ${opLiteral(def.c)}`);
|
|
67
|
+
if (def.h)
|
|
68
|
+
parts.push(`h: ${opLiteral(def.h)}`);
|
|
69
|
+
if (def.alpha !== undefined)
|
|
70
|
+
parts.push(`alpha: ${def.alpha}`);
|
|
71
|
+
if (def.scopes)
|
|
72
|
+
parts.push(`scopes: ${JSON.stringify(def.scopes)}`);
|
|
73
|
+
return ` ${name}: token({ ${parts.join(", ")} }),`;
|
|
74
|
+
})
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The brand's font-role block, in exactly the shape `customers/ember.ts`
|
|
79
|
+
* carries by hand (`emberFonts`). Empty string when the vector names no
|
|
80
|
+
* fonts, so a colour-only brand emits no block and no `BrandFonts` import.
|
|
81
|
+
* `BrandFonts` comes from `./index.js` — a type-only import back into the
|
|
82
|
+
* registry that imports this file, same as ember's, erased at build time.
|
|
83
|
+
*/
|
|
84
|
+
function fontsLiteral(id, fonts) {
|
|
85
|
+
if (!fonts)
|
|
86
|
+
return "";
|
|
87
|
+
const body = Object.entries(fonts)
|
|
88
|
+
.map(([role, stack]) => ` ${role}: ${JSON.stringify(stack)},`)
|
|
89
|
+
.join("\n");
|
|
90
|
+
return `
|
|
91
|
+
/** Brand-level font roles (D29). Psi ships no font files — the consumer loads
|
|
92
|
+
* these webfonts; the roles travel with the theme so the emitted brand is the
|
|
93
|
+
* whole vector, not just its colour half. */
|
|
94
|
+
export const ${id}Fonts: BrandFonts = {
|
|
95
|
+
${body}
|
|
96
|
+
};
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Emit the `customers/<name>.ts` source for a derived pair, plus the two lines
|
|
101
|
+
* that register it. Shape matches `scripts/new-theme.ts`, extended to a pair.
|
|
102
|
+
*/
|
|
103
|
+
export function serializeCustomerTheme(pair) {
|
|
104
|
+
const { vector, light, dark } = pair;
|
|
105
|
+
const id = ident(vector.name);
|
|
106
|
+
const palette = light.customerTheme.palette;
|
|
107
|
+
const radius = `var(--psi-radius-${vector.radius})`;
|
|
108
|
+
// A prompt containing `editorial`, `technical` or `friendly` gives the
|
|
109
|
+
// vector a font-role set (dictionaries.ts FONT_SETS), and `deriveMember`
|
|
110
|
+
// puts it on the CustomerTheme — but it used to stop there: nothing emitted
|
|
111
|
+
// it, so the copied-out source silently dropped half of what the prompt
|
|
112
|
+
// asked for. Not applying fonts in the live preview is fine (the site loads
|
|
113
|
+
// no webfonts); dropping them from the emitted brand is not.
|
|
114
|
+
const fontsBlock = fontsLiteral(id, vector.fonts);
|
|
115
|
+
const lightOverridesBody = overridesLiteral(light.customerTheme.overrides ?? {});
|
|
116
|
+
const darkOverridesBody = overridesLiteral(dark.customerTheme.overrides ?? {});
|
|
117
|
+
const builders = usedBuilders(`${lightOverridesBody}\n${darkOverridesBody}`);
|
|
118
|
+
const buildersImport = builders.length > 0
|
|
119
|
+
? `import { ${builders.join(", ")} } from "../../dsl/builders.js";\n`
|
|
120
|
+
: "";
|
|
121
|
+
const fontsImport = fontsBlock ? `import type { BrandFonts } from "./index.js";\n` : "";
|
|
122
|
+
const source = `${buildersImport}import type { Palette, SlotMap, ThemeDef } from "../../dsl/types.js";
|
|
123
|
+
${fontsImport}
|
|
124
|
+
// Generated by the Psi theme console from the prompt:
|
|
125
|
+
// "${vector.name.replace(/-/g, " ")}"
|
|
126
|
+
// Every pair in the WCAG AA matrix is solved, in both modes, by construction.
|
|
127
|
+
|
|
128
|
+
export const ${id}Palette: Palette = {
|
|
129
|
+
${Object.entries(palette).map(([n, e]) => entryLine(n, e)).join("\n")}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Light member — ink is dark, canvas is light. */
|
|
133
|
+
export const ${id}Slots: SlotMap = {
|
|
134
|
+
${slotsLiteral(light.customerTheme.slots)}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Dark member — the same brand hue, lightness anchors inverted. */
|
|
138
|
+
export const ${id}DarkSlots: SlotMap = {
|
|
139
|
+
${slotsLiteral(dark.customerTheme.slots)}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Solved AA corrections for the light member. Without these the default
|
|
143
|
+
* formulas miss the contrast matrix on this palette and the build throws. */
|
|
144
|
+
export const ${id}Overrides: ThemeDef = {
|
|
145
|
+
${lightOverridesBody}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Solved AA corrections for the dark member. */
|
|
149
|
+
export const ${id}DarkOverrides: ThemeDef = {
|
|
150
|
+
${darkOverridesBody}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
${fontsBlock}
|
|
154
|
+
/** One dial for control shape (D56). */
|
|
155
|
+
export const ${id}ComponentOverrides: Record<string, string> = {
|
|
156
|
+
"control-radius": "${radius}",
|
|
157
|
+
};
|
|
158
|
+
`;
|
|
159
|
+
// BOTH keys are quoted. A kebab slug is not a valid bare object key, and
|
|
160
|
+
// every multi-word prompt produces one. `fonts` is registered on both
|
|
161
|
+
// members — an exported const nothing registers would be dead source, and
|
|
162
|
+
// the brand's typography is not a light-mode-only property.
|
|
163
|
+
const fontsField = fontsBlock ? `fonts: ${id}Fonts, ` : "";
|
|
164
|
+
const registration = ` "${vector.name}": { palette: ${id}Palette, slots: ${id}Slots, overrides: ${id}Overrides, ${fontsField}componentOverrides: ${id}ComponentOverrides },
|
|
165
|
+
"${vector.name}-dark": { palette: ${id}Palette, slots: ${id}DarkSlots, base: "dark", overrides: ${id}DarkOverrides, ${fontsField}componentOverrides: ${id}ComponentOverrides },`;
|
|
166
|
+
return { filename: `${vector.name}.ts`, source, registration };
|
|
167
|
+
}
|