@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.
Files changed (39) hide show
  1. package/README.md +17 -0
  2. package/dist/components/toolbar.vars.css +5 -0
  3. package/dist/components.css +6 -0
  4. package/dist/contrast-matrix.d.ts +34 -0
  5. package/dist/contrast-matrix.js +130 -0
  6. package/dist/dsl/builders.d.ts +7 -0
  7. package/dist/dsl/builders.js +25 -0
  8. package/dist/dsl/resolver.d.ts +3 -0
  9. package/dist/dsl/resolver.js +114 -0
  10. package/dist/dsl/types.d.ts +59 -0
  11. package/dist/dsl/types.js +2 -0
  12. package/dist/generate/derive.d.ts +20 -0
  13. package/dist/generate/derive.js +232 -0
  14. package/dist/generate/dictionaries.d.ts +18 -0
  15. package/dist/generate/dictionaries.js +46 -0
  16. package/dist/generate/index.d.ts +6 -0
  17. package/dist/generate/index.js +6 -0
  18. package/dist/generate/palette.d.ts +7 -0
  19. package/dist/generate/palette.js +93 -0
  20. package/dist/generate/prompt.d.ts +7 -0
  21. package/dist/generate/prompt.js +62 -0
  22. package/dist/generate/serialize.d.ts +10 -0
  23. package/dist/generate/serialize.js +167 -0
  24. package/dist/generate/solve.d.ts +36 -0
  25. package/dist/generate/solve.js +56 -0
  26. package/dist/generate/types.d.ts +24 -0
  27. package/dist/generate/types.js +48 -0
  28. package/dist/themes/customers/acme.d.ts +8 -0
  29. package/dist/themes/customers/acme.js +31 -0
  30. package/dist/themes/customers/ember.d.ts +13 -0
  31. package/dist/themes/customers/ember.js +52 -0
  32. package/dist/themes/customers/index.d.ts +28 -0
  33. package/dist/themes/customers/index.js +23 -0
  34. package/dist/themes/dark.d.ts +2 -0
  35. package/dist/themes/dark.js +55 -0
  36. package/dist/themes/light.d.ts +2 -0
  37. package/dist/themes/light.js +56 -0
  38. package/llms.txt +8 -0
  39. 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,7 @@
1
+ import type { Palette, SlotMap } from "../dsl/types.js";
2
+ import type { BrandVector } from "./types.js";
3
+ export declare function buildBrandPalette(v: BrandVector): {
4
+ palette: Palette;
5
+ lightSlots: SlotMap;
6
+ darkSlots: SlotMap;
7
+ };
@@ -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
+ }