@handamade/psi-tokens 0.18.0 → 0.20.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 (98) hide show
  1. package/README.md +36 -0
  2. package/dist/cli/psi-theme.d.ts +2 -0
  3. package/dist/cli/psi-theme.js +56 -0
  4. package/dist/components/button.d.ts +7 -0
  5. package/dist/components/button.js +76 -0
  6. package/dist/components/card.d.ts +2 -0
  7. package/dist/components/card.js +7 -0
  8. package/dist/components/checkbox.d.ts +2 -0
  9. package/dist/components/checkbox.js +13 -0
  10. package/dist/components/control.d.ts +21 -0
  11. package/dist/components/control.js +59 -0
  12. package/dist/components/description-list.d.ts +8 -0
  13. package/dist/components/description-list.js +13 -0
  14. package/dist/components/dialog.d.ts +6 -0
  15. package/dist/components/dialog.js +13 -0
  16. package/dist/components/field.d.ts +3 -0
  17. package/dist/components/field.js +9 -0
  18. package/dist/components/input.d.ts +2 -0
  19. package/dist/components/input.js +25 -0
  20. package/dist/components/media.d.ts +3 -0
  21. package/dist/components/media.js +5 -0
  22. package/dist/components/menu.d.ts +7 -0
  23. package/dist/components/menu.js +19 -0
  24. package/dist/components/navbar.d.ts +3 -0
  25. package/dist/components/navbar.js +11 -0
  26. package/dist/components/panel.d.ts +5 -0
  27. package/dist/components/panel.js +10 -0
  28. package/dist/components/registry.d.ts +1 -0
  29. package/dist/components/registry.js +46 -0
  30. package/dist/components/select.d.ts +2 -0
  31. package/dist/components/select.js +38 -0
  32. package/dist/components/surface.d.ts +5 -0
  33. package/dist/components/surface.js +10 -0
  34. package/dist/components/switch.d.ts +2 -0
  35. package/dist/components/switch.js +8 -0
  36. package/dist/components/table.d.ts +12 -0
  37. package/dist/components/table.js +30 -0
  38. package/dist/components/tabs.d.ts +14 -0
  39. package/dist/components/tabs.js +26 -0
  40. package/dist/components/tag.d.ts +4 -0
  41. package/dist/components/tag.js +26 -0
  42. package/dist/components/toast.d.ts +19 -0
  43. package/dist/components/toast.js +28 -0
  44. package/dist/components/toolbar.d.ts +13 -0
  45. package/dist/components/toolbar.js +15 -0
  46. package/dist/components/tooltip.d.ts +2 -0
  47. package/dist/components/tooltip.js +7 -0
  48. package/dist/contrast-matrix.d.ts +34 -0
  49. package/dist/contrast-matrix.js +130 -0
  50. package/dist/dsl/builders.d.ts +7 -0
  51. package/dist/dsl/builders.js +25 -0
  52. package/dist/dsl/resolver.d.ts +3 -0
  53. package/dist/dsl/resolver.js +114 -0
  54. package/dist/dsl/types.d.ts +59 -0
  55. package/dist/dsl/types.js +2 -0
  56. package/dist/dsl/validator.d.ts +12 -0
  57. package/dist/dsl/validator.js +97 -0
  58. package/dist/emit/css.d.ts +30 -0
  59. package/dist/emit/css.js +120 -0
  60. package/dist/gamut.d.ts +4 -0
  61. package/dist/gamut.js +44 -0
  62. package/dist/generate/derive.d.ts +20 -0
  63. package/dist/generate/derive.js +232 -0
  64. package/dist/generate/dictionaries.d.ts +18 -0
  65. package/dist/generate/dictionaries.js +46 -0
  66. package/dist/generate/index.d.ts +6 -0
  67. package/dist/generate/index.js +6 -0
  68. package/dist/generate/palette.d.ts +7 -0
  69. package/dist/generate/palette.js +93 -0
  70. package/dist/generate/prompt.d.ts +7 -0
  71. package/dist/generate/prompt.js +62 -0
  72. package/dist/generate/serialize.d.ts +10 -0
  73. package/dist/generate/serialize.js +167 -0
  74. package/dist/generate/solve.d.ts +36 -0
  75. package/dist/generate/solve.js +56 -0
  76. package/dist/generate/types.d.ts +24 -0
  77. package/dist/generate/types.js +48 -0
  78. package/dist/scope-gate.d.ts +15 -0
  79. package/dist/scope-gate.js +87 -0
  80. package/dist/scope-map.json +2 -1
  81. package/dist/scopes.d.ts +6 -0
  82. package/dist/scopes.js +88 -0
  83. package/dist/theme/gate.d.ts +19 -0
  84. package/dist/theme/gate.js +44 -0
  85. package/dist/theme/index.d.ts +16 -0
  86. package/dist/theme/index.js +23 -0
  87. package/dist/themes/customers/acme.d.ts +8 -0
  88. package/dist/themes/customers/acme.js +31 -0
  89. package/dist/themes/customers/ember.d.ts +13 -0
  90. package/dist/themes/customers/ember.js +52 -0
  91. package/dist/themes/customers/index.d.ts +28 -0
  92. package/dist/themes/customers/index.js +23 -0
  93. package/dist/themes/dark.d.ts +2 -0
  94. package/dist/themes/dark.js +55 -0
  95. package/dist/themes/light.d.ts +2 -0
  96. package/dist/themes/light.js +56 -0
  97. package/llms.txt +15 -0
  98. package/package.json +14 -2
@@ -0,0 +1,97 @@
1
+ import { isKnownScopeEntry } from "../scopes.js";
2
+ // ── Validation error ───────────────────────────────────────────────
3
+ export class ValidationError extends Error {
4
+ path;
5
+ constructor(message, path = []) {
6
+ super(message);
7
+ this.name = "ValidationError";
8
+ this.path = path;
9
+ }
10
+ }
11
+ // ── Cycle detection ────────────────────────────────────────────────
12
+ function detectCycles(theme) {
13
+ const visited = new Set();
14
+ const inStack = new Set();
15
+ function dfs(name, chain) {
16
+ if (inStack.has(name)) {
17
+ const cycleStart = chain.indexOf(name);
18
+ const cycle = chain.slice(cycleStart).concat(name);
19
+ throw new ValidationError(`Circular reference detected: ${cycle.join(" → ")}`, cycle);
20
+ }
21
+ if (visited.has(name))
22
+ return;
23
+ inStack.add(name);
24
+ chain.push(name);
25
+ const token = theme[name];
26
+ if (token?.from.type === "ref") {
27
+ dfs(token.from.name, chain);
28
+ }
29
+ chain.pop();
30
+ inStack.delete(name);
31
+ visited.add(name);
32
+ }
33
+ for (const name of Object.keys(theme)) {
34
+ dfs(name, []);
35
+ }
36
+ }
37
+ // ── Main validator ─────────────────────────────────────────────────
38
+ export function validate(theme, slots, existingNames = []) {
39
+ const slotNames = new Set(Object.keys(slots));
40
+ const tokenNames = new Set(Object.keys(theme));
41
+ // Check for duplicate names against existingNames
42
+ for (const name of tokenNames) {
43
+ if (existingNames.includes(name)) {
44
+ throw new ValidationError(`Duplicate token name: "${name}" already exists`, [name]);
45
+ }
46
+ }
47
+ // Validate each token's source
48
+ for (const [name, token] of Object.entries(theme)) {
49
+ const { from } = token;
50
+ if (from.type === "slot") {
51
+ if (!slotNames.has(from.name)) {
52
+ throw new ValidationError(`Unknown slot "${from.name}" in token "${name}"`, [name]);
53
+ }
54
+ }
55
+ else if (from.type === "ref") {
56
+ if (!tokenNames.has(from.name)) {
57
+ throw new ValidationError(`Unknown ref "${from.name}" in token "${name}"`, [name]);
58
+ }
59
+ }
60
+ for (const entry of token.scopes ?? []) {
61
+ if (!isKnownScopeEntry(entry)) {
62
+ throw new ValidationError(`Unknown scope entry "${entry}" in token "${name}" — not a known CSS property or declared group`, [name]);
63
+ }
64
+ }
65
+ }
66
+ // Detect cycles in ref chains
67
+ detectCycles(theme);
68
+ }
69
+ /** D46: the same token name must carry identical scopes in every theme —
70
+ * scopes are a property of the token's meaning, not of a theme. */
71
+ export function validateScopeConsistency(themes) {
72
+ const seen = new Map();
73
+ for (const [themeName, theme] of Object.entries(themes)) {
74
+ for (const [name, def] of Object.entries(theme)) {
75
+ const key = JSON.stringify(def.scopes ?? null);
76
+ const prior = seen.get(name);
77
+ if (prior === undefined) {
78
+ seen.set(name, { theme: themeName, scopes: key });
79
+ }
80
+ else if (prior.scopes !== key) {
81
+ throw new ValidationError(`Scope drift for token "${name}": ${prior.theme} declares ${prior.scopes}, ${themeName} declares ${key}`, [name]);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ // Both gates resolve --psi-* names scale-first, so a semantic token whose
87
+ // kebab name starts with a scale-family prefix would be silently shadowed.
88
+ const SCALE_FAMILY_PREFIX = /^(space|size|radius|text|font|duration|ease|z)-/;
89
+ /** D46 follow-up (HAN-21): reject semantic token kebab names that a
90
+ * scale-family lookup would shadow before the semantic lookup runs. */
91
+ export function validateNoScalePrefixShadow(kebabNames) {
92
+ for (const name of kebabNames) {
93
+ if (SCALE_FAMILY_PREFIX.test(name)) {
94
+ throw new ValidationError(`Semantic token "${name}" starts with a scale-family prefix — it would be shadowed by the scale lookup in the build and stylelint gates`, [name]);
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,30 @@
1
+ import type { ChannelOp, Palette, SlotMap, ThemeDef, TokenDef } from "../dsl/types.js";
2
+ import type { BrandFonts } from "../themes/customers/index.js";
3
+ export declare function camelToKebab(str: string): string;
4
+ export declare function tokenVar(name: string): string;
5
+ export declare function paletteVar(name: string): string;
6
+ /**
7
+ * Convert a ChannelOp to its CSS representation within oklch(from ...).
8
+ * - set → literal value
9
+ * - delta → calc(channel +/- value)
10
+ * - cap → min(channel, value)
11
+ */
12
+ export declare function opToCSS(channel: string, op: ChannelOp): string;
13
+ /**
14
+ * Assemble the full oklch(from var(...) ...) expression for a token.
15
+ *
16
+ * Slot-sourced token with ops:
17
+ * oklch(from var(--psi-palette-sapphire) 0.65 min(c, 0.23) h)
18
+ *
19
+ * Ref-sourced token with alpha:
20
+ * oklch(from var(--psi-fg-primary) l c h / 0.7)
21
+ *
22
+ * No-op (just a slot reference, no ops, no alpha):
23
+ * var(--psi-palette-platinum)
24
+ */
25
+ export declare function tokenToLiveCSS(def: TokenDef, slots: SlotMap): string;
26
+ export declare function emitBaseCSS(palette: Palette): string;
27
+ export declare function emitThemeCSS(themeName: string, theme: ThemeDef, palette: Palette, slots: SlotMap, opts?: {
28
+ fonts?: BrandFonts;
29
+ componentOverrides?: Record<string, string>;
30
+ }): string;
@@ -0,0 +1,120 @@
1
+ // ── Helpers ────────────────────────────────────────────────────────
2
+ export function camelToKebab(str) {
3
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
4
+ }
5
+ export function tokenVar(name) {
6
+ return `--psi-${camelToKebab(name)}`;
7
+ }
8
+ export function paletteVar(name) {
9
+ return `--psi-palette-${name}`;
10
+ }
11
+ /**
12
+ * Convert a ChannelOp to its CSS representation within oklch(from ...).
13
+ * - set → literal value
14
+ * - delta → calc(channel +/- value)
15
+ * - cap → min(channel, value)
16
+ */
17
+ export function opToCSS(channel, op) {
18
+ switch (op.op) {
19
+ case "set":
20
+ return `${op.value}`;
21
+ case "delta": {
22
+ const sign = op.value >= 0 ? "+" : "-";
23
+ const abs = Math.abs(op.value);
24
+ return `calc(${channel} ${sign} ${abs})`;
25
+ }
26
+ case "cap":
27
+ return `min(${channel}, ${op.value})`;
28
+ }
29
+ }
30
+ /**
31
+ * Assemble the full oklch(from var(...) ...) expression for a token.
32
+ *
33
+ * Slot-sourced token with ops:
34
+ * oklch(from var(--psi-palette-sapphire) 0.65 min(c, 0.23) h)
35
+ *
36
+ * Ref-sourced token with alpha:
37
+ * oklch(from var(--psi-fg-primary) l c h / 0.7)
38
+ *
39
+ * No-op (just a slot reference, no ops, no alpha):
40
+ * var(--psi-palette-platinum)
41
+ */
42
+ export function tokenToLiveCSS(def, slots) {
43
+ const hasOps = def.l || def.c || def.h;
44
+ const hasAlpha = def.alpha !== undefined;
45
+ // Determine the source CSS var
46
+ let sourceVar;
47
+ if (def.from.type === "slot") {
48
+ const paletteName = slots[def.from.name];
49
+ sourceVar = `var(${paletteVar(paletteName)})`;
50
+ }
51
+ else {
52
+ sourceVar = `var(${tokenVar(def.from.name)})`;
53
+ }
54
+ // If no operations and no alpha, just reference the var directly
55
+ if (!hasOps && !hasAlpha) {
56
+ return sourceVar;
57
+ }
58
+ // Build oklch(from ...) expression
59
+ const lPart = def.l ? opToCSS("l", def.l) : "l";
60
+ const cPart = def.c ? opToCSS("c", def.c) : "c";
61
+ const hPart = def.h ? opToCSS("h", def.h) : "h";
62
+ let expr = `oklch(from ${sourceVar} ${lPart} ${cPart} ${hPart}`;
63
+ if (hasAlpha) {
64
+ expr += ` / ${def.alpha}`;
65
+ }
66
+ expr += `)`;
67
+ return expr;
68
+ }
69
+ // ── Base CSS (palette) ────────────────────────────────────────────
70
+ export function emitBaseCSS(palette) {
71
+ const lines = [];
72
+ lines.push("@layer psi.base, psi.theme, psi.components, psi.utilities;");
73
+ lines.push("");
74
+ lines.push("@layer psi.base {");
75
+ lines.push(" :root {");
76
+ for (const [name, entry] of Object.entries(palette)) {
77
+ lines.push(` ${paletteVar(name)}: oklch(${entry.l} ${entry.c} ${entry.h});`);
78
+ }
79
+ lines.push(" }");
80
+ lines.push("}");
81
+ return lines.join("\n") + "\n";
82
+ }
83
+ // ── Theme CSS (semantic tokens) ───────────────────────────────────
84
+ export function emitThemeCSS(themeName, theme, palette, slots, opts) {
85
+ const lines = [];
86
+ // Determine selector
87
+ const selector = themeName === "light"
88
+ ? `:root, [data-psi-theme="light"]`
89
+ : `[data-psi-theme="${themeName}"]`;
90
+ lines.push("@layer psi.theme {");
91
+ lines.push(` ${selector} {`);
92
+ // Scope this theme's own palette vars inside its selector block so a
93
+ // customer brand's colors ship only with that theme, and two customers
94
+ // that happen to reuse an anchor name can't collide in a shared :root.
95
+ for (const [name, entry] of Object.entries(palette)) {
96
+ lines.push(` ${paletteVar(name)}: oklch(${entry.l} ${entry.c} ${entry.h});`);
97
+ }
98
+ for (const [name, def] of Object.entries(theme)) {
99
+ const cssVar = tokenVar(name);
100
+ const value = tokenToLiveCSS(def, slots);
101
+ lines.push(` ${cssVar}: ${value};`);
102
+ }
103
+ if (opts?.fonts) {
104
+ for (const [role, stack] of Object.entries(opts.fonts)) {
105
+ lines.push(` --psi-font-${role}: ${stack};`);
106
+ }
107
+ }
108
+ lines.push(" }");
109
+ lines.push("}");
110
+ if (opts?.componentOverrides && Object.keys(opts.componentOverrides).length > 0) {
111
+ lines.push("@layer psi.components {");
112
+ lines.push(` [data-psi-theme="${themeName}"] {`);
113
+ for (const [name, value] of Object.entries(opts.componentOverrides)) {
114
+ lines.push(` --psi-${name}: ${value};`);
115
+ }
116
+ lines.push(" }");
117
+ lines.push("}");
118
+ }
119
+ return lines.join("\n") + "\n";
120
+ }
@@ -0,0 +1,4 @@
1
+ import type { Palette, SlotMap, ThemeDef } from "./dsl/types.js";
2
+ import type { ResolvedTheme } from "./dsl/resolver.js";
3
+ /** Warn when sRGB clamping moved a resolved value by ΔE2000 > 2 (D19). */
4
+ export declare function gamutWarnings(resolved: ResolvedTheme, theme: ThemeDef, palette: Palette, slots: SlotMap): string[];
package/dist/gamut.js ADDED
@@ -0,0 +1,44 @@
1
+ import { differenceCiede2000 } from "culori";
2
+ const dE = differenceCiede2000();
3
+ function applyOp(base, op) {
4
+ if (!op)
5
+ return base;
6
+ if (op.op === "set")
7
+ return op.value;
8
+ if (op.op === "delta")
9
+ return base + op.value;
10
+ return Math.min(base, op.value); // cap
11
+ }
12
+ /** Warn when sRGB clamping moved a resolved value by ΔE2000 > 2 (D19). */
13
+ export function gamutWarnings(resolved, theme, palette, slots) {
14
+ const warnings = [];
15
+ const raw = new Map();
16
+ // Recomputes pre-clamp OKLCH values by replaying token operations WITHOUT sRGB clamping.
17
+ // Unlike the resolver (which clamps at each step), this intentionally propagates unclamped values
18
+ // through ref chains so the warning reflects cumulative drift from authored intent. Do not "fix" this
19
+ // to match the resolver's clamping behavior — the unclamped result is required to diagnose palette issues.
20
+ function rawOf(name) {
21
+ const hit = raw.get(name);
22
+ if (hit)
23
+ return hit;
24
+ const def = theme[name];
25
+ const base = def.from.type === "slot"
26
+ ? palette[slots[def.from.name]]
27
+ : rawOf(def.from.name);
28
+ const val = {
29
+ l: applyOp(base.l, def.l),
30
+ c: applyOp(base.c, def.c),
31
+ h: applyOp(base.h, def.h),
32
+ };
33
+ raw.set(name, val);
34
+ return val;
35
+ }
36
+ for (const name of Object.keys(theme)) {
37
+ const pre = { mode: "oklch", ...rawOf(name) };
38
+ const post = { mode: "oklch", ...resolved[name].oklch };
39
+ const d = dE(pre, post);
40
+ if (d > 2)
41
+ warnings.push(`${name}: clamped ΔE ${d.toFixed(1)} (${JSON.stringify(rawOf(name))} → hex ${resolved[name].hex}) — adjust the palette anchor`);
42
+ }
43
+ return warnings;
44
+ }
@@ -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
+ }
@@ -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
+ };