@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
package/README.md CHANGED
@@ -35,6 +35,22 @@ All tokens are CSS custom properties: `--psi-bg-primary`, `--psi-fg-secondary`,
35
35
 
36
36
  Run `pnpm new-theme` to scaffold a customer brand theme (palette + slot mapping).
37
37
 
38
+ ## Deriving a brand from a prompt
39
+
40
+ `@handamade/psi-tokens/generate` (D57) derives a brand at runtime instead of scaffolding one by hand:
41
+
42
+ ```ts
43
+ import { parsePrompt, deriveTheme, serializeCustomerTheme } from "@handamade/psi-tokens/generate";
44
+
45
+ const vector = parsePrompt("a calm dark fintech brand"); // deterministic BrandVector
46
+ const pair = deriveTheme(vector); // AA-solved light + dark DerivedTheme pair
47
+ const { filename, source, registration } = serializeCustomerTheme(pair); // customers/<name>.ts
48
+ ```
49
+
50
+ - `parsePrompt` is deterministic — an FNV-1a hash seeds a PRNG, so an unrecognised prompt still derives a coherent brand.
51
+ - `deriveTheme` solves both members of the pair independently to WCAG AA by binary-searching lightness; it never returns a below-AA theme, because it throws instead of returning one if the solver can't clear the matrix.
52
+ - `serializeCustomerTheme` emits `customers/<name>.ts` source in the CLI's own layout (`pnpm new-theme`'s `Palette` + `SlotMap`), extended to a pair: two `SlotMap`s (light and dark) and, unlike the CLI's override-free scaffold, the solved `ThemeDef` overrides each member needs to clear AA — the same kind of hand-tuned correction `acmeOverrides` carries in `customers/acme.ts`, but derived rather than hand-tuned.
53
+
38
54
  ## Machine-readable artifacts
39
55
 
40
56
  For AI and tooling, see [llms.txt](./llms.txt):
@@ -42,6 +58,7 @@ For AI and tooling, see [llms.txt](./llms.txt):
42
58
  - `dist/guidance.json` — Variant intent, usage rules, state derivation
43
59
  - `dist/dtcg/{theme}.json` — W3C DTCG format export
44
60
  - `dist/components/{name}.vars.css` — Component-specific token declarations
61
+ - `dist/generate/` — Compiled `./generate` subpath (`parsePrompt`, `deriveTheme`, `serializeCustomerTheme`, and lower-level helpers)
45
62
 
46
63
  ## Note
47
64
 
@@ -0,0 +1,5 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-toolbar-control-width: 200px;
4
+ }
5
+ }
@@ -328,6 +328,12 @@
328
328
  }
329
329
  }
330
330
 
331
+ @layer psi.components {
332
+ :where(:root, [data-psi-theme]) {
333
+ --psi-toolbar-control-width: 200px;
334
+ }
335
+ }
336
+
331
337
  @layer psi.components {
332
338
  :where(:root, [data-psi-theme]) {
333
339
  --psi-toast-bg: var(--psi-surface-bg);
@@ -0,0 +1,34 @@
1
+ import type { ResolvedTheme } from "./dsl/resolver.js";
2
+ export interface ContrastPair {
3
+ fg: string;
4
+ bg: string;
5
+ minRatio: number;
6
+ }
7
+ export interface ContrastResult extends ContrastPair {
8
+ ratio: number;
9
+ pass: boolean;
10
+ }
11
+ /**
12
+ * Composite a foreground hex color with alpha onto an opaque background hex.
13
+ * Uses sRGB blending (gamma-encoded): mixed = fg * alpha + bg * (1 - alpha).
14
+ * Returns an opaque hex color matching browser rendering.
15
+ */
16
+ export declare function compositeHex(fgHex: string, alpha: number, bgHex: string): string;
17
+ /**
18
+ * Check contrast ratios for a set of fg/bg pairs in a resolved theme.
19
+ *
20
+ * Alpha handling:
21
+ * - bg with alpha (e.g. fillTint*) is composited onto bgPrimary
22
+ * - fg with alpha (e.g. fgSecondary) is composited onto the bg token
23
+ *
24
+ * bgPrimary is used as the base surface for alpha-bg compositing.
25
+ */
26
+ export declare function checkContrast(resolved: ResolvedTheme, pairs: ContrastPair[]): ContrastResult[];
27
+ /**
28
+ * Standard WCAG AA contrast pairs that every theme must pass.
29
+ */
30
+ export declare const wcagAAPairs: ContrastPair[];
31
+ /** Solid component-variant labels on their variant backgrounds (spec: "every
32
+ * button/tag label on its variant background ≥ 4.5"). Tint-variant labels are
33
+ * already covered by the fg-on-tint pairs in wcagAAPairs. */
34
+ export declare const componentLabelPairs: ContrastPair[];
@@ -0,0 +1,130 @@
1
+ import { wcagContrast, formatHex, rgb } from "culori";
2
+ // ── Alpha compositing ─────────────────────────────────────────────
3
+ /**
4
+ * Composite a foreground hex color with alpha onto an opaque background hex.
5
+ * Uses sRGB blending (gamma-encoded): mixed = fg * alpha + bg * (1 - alpha).
6
+ * Returns an opaque hex color matching browser rendering.
7
+ */
8
+ export function compositeHex(fgHex, alpha, bgHex) {
9
+ const f = rgb(fgHex);
10
+ const b = rgb(bgHex);
11
+ if (!f)
12
+ throw new Error(`compositeHex: unparseable color "${fgHex}"`);
13
+ if (!b)
14
+ throw new Error(`compositeHex: unparseable color "${bgHex}"`);
15
+ const hex = formatHex({
16
+ mode: "rgb",
17
+ r: f.r * alpha + b.r * (1 - alpha),
18
+ g: f.g * alpha + b.g * (1 - alpha),
19
+ b: f.b * alpha + b.b * (1 - alpha),
20
+ });
21
+ if (!hex)
22
+ throw new Error(`compositeHex: could not format the blend of "${fgHex}" and "${bgHex}"`);
23
+ return hex;
24
+ }
25
+ /**
26
+ * Composite a foreground color with alpha onto an opaque background.
27
+ * Delegates to compositeHex for sRGB blending.
28
+ */
29
+ function compositeOnBackground(fg, bg) {
30
+ const alpha = fg.oklch.alpha ?? 1;
31
+ if (alpha >= 1) {
32
+ return fg.hex;
33
+ }
34
+ return compositeHex(fg.hex, alpha, bg.hex);
35
+ }
36
+ // ── Contrast checker ──────────────────────────────────────────────
37
+ /**
38
+ * Check contrast ratios for a set of fg/bg pairs in a resolved theme.
39
+ *
40
+ * Alpha handling:
41
+ * - bg with alpha (e.g. fillTint*) is composited onto bgPrimary
42
+ * - fg with alpha (e.g. fgSecondary) is composited onto the bg token
43
+ *
44
+ * bgPrimary is used as the base surface for alpha-bg compositing.
45
+ */
46
+ export function checkContrast(resolved, pairs) {
47
+ const bgPrimary = resolved.bgPrimary;
48
+ if (!bgPrimary) {
49
+ throw new Error("Theme must include bgPrimary for contrast checking");
50
+ }
51
+ return pairs.map((pair) => {
52
+ const fgToken = resolved[pair.fg];
53
+ const bgToken = resolved[pair.bg];
54
+ if (!fgToken) {
55
+ throw new Error(`Unknown foreground token: "${pair.fg}"`);
56
+ }
57
+ if (!bgToken) {
58
+ throw new Error(`Unknown background token: "${pair.bg}"`);
59
+ }
60
+ // Resolve effective bg: if bg has alpha, composite onto bgPrimary
61
+ const bgAlpha = bgToken.oklch.alpha ?? 1;
62
+ const effectiveBgHex = bgAlpha < 1
63
+ ? compositeOnBackground(bgToken, bgPrimary)
64
+ : bgToken.hex;
65
+ // Resolve effective fg: if fg has alpha, composite onto the bg
66
+ const fgAlpha = fgToken.oklch.alpha ?? 1;
67
+ const effectiveFgHex = fgAlpha < 1
68
+ ? compositeOnBackground(fgToken, bgToken)
69
+ : fgToken.hex;
70
+ const ratio = wcagContrast(effectiveFgHex, effectiveBgHex);
71
+ return {
72
+ ...pair,
73
+ ratio: Math.round(ratio * 100) / 100,
74
+ pass: ratio >= pair.minRatio,
75
+ };
76
+ });
77
+ }
78
+ // ── Default contrast pairs ────────────────────────────────────────
79
+ const bgSurfaces = [
80
+ "bgPrimary",
81
+ "bgSecondary",
82
+ "fillNeutral1",
83
+ "fillNeutral2",
84
+ "fillNeutral3",
85
+ "fillNeutral4",
86
+ "fillNeutral5",
87
+ "fillNeutral6",
88
+ ];
89
+ /**
90
+ * Standard WCAG AA contrast pairs that every theme must pass.
91
+ */
92
+ export const wcagAAPairs = [
93
+ // fgPrimary on all surfaces
94
+ ...bgSurfaces.map((bg) => ({ fg: "fgPrimary", bg, minRatio: 4.5 })),
95
+ // fgSecondary on all surfaces
96
+ ...bgSurfaces.map((bg) => ({ fg: "fgSecondary", bg, minRatio: 4.5 })),
97
+ // Semantic fg on primary background
98
+ { fg: "fgAccent", bg: "bgPrimary", minRatio: 4.5 },
99
+ { fg: "fgSuccess", bg: "bgPrimary", minRatio: 4.5 },
100
+ { fg: "fgWarning", bg: "bgPrimary", minRatio: 4.5 },
101
+ { fg: "fgDanger", bg: "bgPrimary", minRatio: 4.5 },
102
+ // Semantic fg on their tint backgrounds
103
+ { fg: "fgAccent", bg: "fillTintAccent", minRatio: 4.5 },
104
+ { fg: "fgSuccess", bg: "fillTintSuccess", minRatio: 4.5 },
105
+ { fg: "fgWarning", bg: "fillTintWarning", minRatio: 4.5 },
106
+ { fg: "fgDanger", bg: "fillTintDanger", minRatio: 4.5 },
107
+ // Primary text on a selected table row's accent wash (D62). Distinct from
108
+ // the fgAccent/fillTintAccent pair above: a selected row keeps ordinary
109
+ // body text, it does not recolor it to the accent.
110
+ { fg: "fgPrimary", bg: "fillTintAccent", minRatio: 4.5 },
111
+ // Toast's status icon on the elevated surface (D64/D65). The three pairs
112
+ // above gate these foregrounds on bgPrimary; --psi-surface-bg — what Toast,
113
+ // Menu and Dialog all alias — resolves to bgSecondary, which was ungated for
114
+ // the semantic foregrounds until Toast put an icon there. Held at 4.5 even
115
+ // though an icon is non-text content (WCAG 1.4.11 would allow 3.0): the
116
+ // colours clear 4.5 anyway, and the matrix's posture is a single floor.
117
+ { fg: "fgSuccess", bg: "bgSecondary", minRatio: 4.5 },
118
+ { fg: "fgWarning", bg: "bgSecondary", minRatio: 4.5 },
119
+ { fg: "fgDanger", bg: "bgSecondary", minRatio: 4.5 },
120
+ ];
121
+ /** Solid component-variant labels on their variant backgrounds (spec: "every
122
+ * button/tag label on its variant background ≥ 4.5"). Tint-variant labels are
123
+ * already covered by the fg-on-tint pairs in wcagAAPairs. */
124
+ export const componentLabelPairs = [
125
+ { fg: "fgOnAccent", bg: "fillAccent", minRatio: 4.5 }, // Button/IconButton/Tag accent label, Checkbox check, outline hover (D37)
126
+ { fg: "fgStaticWhite", bg: "fillDanger", minRatio: 4.5 }, // Button/IconButton/Tag danger
127
+ { fg: "fgStaticWhite", bg: "fillSuccess", minRatio: 4.5 }, // Tag success
128
+ { fg: "fgStaticBlack", bg: "fillWarning", minRatio: 4.5 }, // Tag warning (D22)
129
+ { fg: "fgOnInverted", bg: "bgInverted", minRatio: 4.5 }, // Tooltip label (D46 inversion pair)
130
+ ];
@@ -0,0 +1,7 @@
1
+ import type { CapOp, DeltaOp, SetOp, SlotRef, TokenDef, TokenRef } from "./types.js";
2
+ export declare function set(value: number): SetOp;
3
+ export declare function delta(value: number): DeltaOp;
4
+ export declare function cap(value: number): CapOp;
5
+ export declare const slot: Record<string, SlotRef>;
6
+ export declare const ref: Record<string, TokenRef>;
7
+ export declare function token(def: TokenDef): TokenDef;
@@ -0,0 +1,25 @@
1
+ // ── Channel operation builders ──────────────────────────────────────
2
+ export function set(value) {
3
+ return { op: "set", value };
4
+ }
5
+ export function delta(value) {
6
+ return { op: "delta", value };
7
+ }
8
+ export function cap(value) {
9
+ return { op: "cap", value };
10
+ }
11
+ // ── Source proxies ──────────────────────────────────────────────────
12
+ export const slot = new Proxy({}, {
13
+ get(_target, prop) {
14
+ return { type: "slot", name: prop };
15
+ },
16
+ });
17
+ export const ref = new Proxy({}, {
18
+ get(_target, prop) {
19
+ return { type: "ref", name: prop };
20
+ },
21
+ });
22
+ // ── Token builder ───────────────────────────────────────────────────
23
+ export function token(def) {
24
+ return { ...def };
25
+ }
@@ -0,0 +1,3 @@
1
+ import type { Palette, ResolvedToken, SlotMap, ThemeDef } from "./types.js";
2
+ export type ResolvedTheme = Record<string, ResolvedToken>;
3
+ export declare function resolve(theme: ThemeDef, palette: Palette, slots: SlotMap): ResolvedTheme;
@@ -0,0 +1,114 @@
1
+ import { formatHex, clampChroma } from "culori";
2
+ // ── Channel operation application ──────────────────────────────────
3
+ function applyOp(base, op) {
4
+ if (!op)
5
+ return base;
6
+ switch (op.op) {
7
+ case "set":
8
+ return op.value;
9
+ case "delta":
10
+ return base + op.value;
11
+ case "cap":
12
+ return Math.min(base, op.value);
13
+ }
14
+ }
15
+ // ── Formula string generation ──────────────────────────────────────
16
+ function formatOp(channel, op) {
17
+ return `${channel} ${op.op}(${op.value})`;
18
+ }
19
+ function formatFormula(def) {
20
+ const source = def.from.type === "slot"
21
+ ? `slot(${def.from.name})`
22
+ : `ref(${def.from.name})`;
23
+ const ops = [];
24
+ if (def.l)
25
+ ops.push(formatOp("L", def.l));
26
+ if (def.c)
27
+ ops.push(formatOp("C", def.c));
28
+ if (def.h)
29
+ ops.push(formatOp("H", def.h));
30
+ if (def.alpha !== undefined)
31
+ ops.push(`alpha(${def.alpha})`);
32
+ return ops.length > 0 ? `${source} → ${ops.join(", ")}` : source;
33
+ }
34
+ // ── Resolver ───────────────────────────────────────────────────────
35
+ export function resolve(theme, palette, slots) {
36
+ const cache = new Map();
37
+ function resolveToken(name) {
38
+ const cached = cache.get(name);
39
+ if (cached)
40
+ return cached;
41
+ const def = theme[name];
42
+ if (!def) {
43
+ throw new Error(`Unknown token: "${name}"`);
44
+ }
45
+ // Resolve base L/C/H from source
46
+ let baseL;
47
+ let baseC;
48
+ let baseH;
49
+ let baseAlpha;
50
+ if (def.from.type === "slot") {
51
+ const paletteName = slots[def.from.name];
52
+ if (!paletteName) {
53
+ throw new Error(`Unknown slot: "${def.from.name}"`);
54
+ }
55
+ const entry = palette[paletteName];
56
+ if (!entry) {
57
+ throw new Error(`Unknown palette entry: "${paletteName}" (from slot "${def.from.name}")`);
58
+ }
59
+ baseL = entry.l;
60
+ baseC = entry.c;
61
+ baseH = entry.h;
62
+ }
63
+ else {
64
+ // ref — recursively resolve the referenced token
65
+ const refToken = resolveToken(def.from.name);
66
+ baseL = refToken.oklch.l;
67
+ baseC = refToken.oklch.c;
68
+ baseH = refToken.oklch.h;
69
+ baseAlpha = refToken.oklch.alpha;
70
+ }
71
+ // Apply channel operations
72
+ const l = applyOp(baseL, def.l);
73
+ const c = applyOp(baseC, def.c);
74
+ const h = applyOp(baseH, def.h);
75
+ const alpha = def.alpha ?? baseAlpha;
76
+ // Build OKLCH color for culori
77
+ const oklchColor = {
78
+ mode: "oklch",
79
+ l,
80
+ c,
81
+ h,
82
+ };
83
+ if (alpha !== undefined) {
84
+ oklchColor.alpha = alpha;
85
+ }
86
+ // Gamut-map to sRGB via chroma clamping
87
+ const clamped = clampChroma(oklchColor, "oklch", "rgb");
88
+ // Extract the gamut-mapped OKLCH values
89
+ const clampedL = clamped?.l ?? l;
90
+ const clampedC = clamped?.c ?? 0;
91
+ const clampedH = clamped?.h ?? h;
92
+ // Generate hex (always 6-digit sRGB)
93
+ const hex = formatHex(clamped ?? oklchColor) ?? "#000000";
94
+ const resolved = {
95
+ name,
96
+ oklch: {
97
+ l: clampedL,
98
+ c: clampedC,
99
+ h: clampedH,
100
+ ...(alpha !== undefined ? { alpha } : {}),
101
+ },
102
+ hex,
103
+ formula: formatFormula(def),
104
+ ...(def.scopes !== undefined ? { scopes: def.scopes } : {}),
105
+ };
106
+ cache.set(name, resolved);
107
+ return resolved;
108
+ }
109
+ const result = {};
110
+ for (const name of Object.keys(theme)) {
111
+ result[name] = resolveToken(name);
112
+ }
113
+ return result;
114
+ }
@@ -0,0 +1,59 @@
1
+ export interface SetOp {
2
+ readonly op: "set";
3
+ readonly value: number;
4
+ }
5
+ export interface DeltaOp {
6
+ readonly op: "delta";
7
+ readonly value: number;
8
+ }
9
+ export interface CapOp {
10
+ readonly op: "cap";
11
+ readonly value: number;
12
+ }
13
+ export type ChannelOp = SetOp | DeltaOp | CapOp;
14
+ export interface SlotRef {
15
+ readonly type: "slot";
16
+ readonly name: string;
17
+ }
18
+ export interface TokenRef {
19
+ readonly type: "ref";
20
+ readonly name: string;
21
+ }
22
+ export type TokenSource = SlotRef | TokenRef;
23
+ export interface TokenDef {
24
+ readonly from: TokenSource;
25
+ readonly l?: ChannelOp;
26
+ readonly c?: ChannelOp;
27
+ readonly h?: ChannelOp;
28
+ readonly alpha?: number;
29
+ /** D46: property groups / CSS properties this token may bind to
30
+ * (see src/scopes.ts). Unscoped tokens are valid everywhere. */
31
+ readonly scopes?: readonly string[];
32
+ }
33
+ export interface PaletteEntry {
34
+ readonly l: number;
35
+ readonly c: number;
36
+ readonly h: number;
37
+ }
38
+ export type Palette = Record<string, PaletteEntry>;
39
+ export interface SlotMap {
40
+ readonly ink: string;
41
+ readonly canvas: string;
42
+ readonly accent: string;
43
+ readonly success: string;
44
+ readonly warning: string;
45
+ readonly danger: string;
46
+ }
47
+ export type ThemeDef = Record<string, TokenDef>;
48
+ export interface ResolvedToken {
49
+ readonly name: string;
50
+ readonly oklch: {
51
+ readonly l: number;
52
+ readonly c: number;
53
+ readonly h: number;
54
+ readonly alpha?: number;
55
+ };
56
+ readonly hex: string;
57
+ readonly formula: string;
58
+ readonly scopes?: readonly string[];
59
+ }
@@ -0,0 +1,2 @@
1
+ // ── Channel operations ──────────────────────────────────────────────
2
+ export {};
@@ -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;