@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.
- package/README.md +36 -0
- package/dist/cli/psi-theme.d.ts +2 -0
- package/dist/cli/psi-theme.js +56 -0
- package/dist/components/button.d.ts +7 -0
- package/dist/components/button.js +76 -0
- package/dist/components/card.d.ts +2 -0
- package/dist/components/card.js +7 -0
- package/dist/components/checkbox.d.ts +2 -0
- package/dist/components/checkbox.js +13 -0
- package/dist/components/control.d.ts +21 -0
- package/dist/components/control.js +59 -0
- package/dist/components/description-list.d.ts +8 -0
- package/dist/components/description-list.js +13 -0
- package/dist/components/dialog.d.ts +6 -0
- package/dist/components/dialog.js +13 -0
- package/dist/components/field.d.ts +3 -0
- package/dist/components/field.js +9 -0
- package/dist/components/input.d.ts +2 -0
- package/dist/components/input.js +25 -0
- package/dist/components/media.d.ts +3 -0
- package/dist/components/media.js +5 -0
- package/dist/components/menu.d.ts +7 -0
- package/dist/components/menu.js +19 -0
- package/dist/components/navbar.d.ts +3 -0
- package/dist/components/navbar.js +11 -0
- package/dist/components/panel.d.ts +5 -0
- package/dist/components/panel.js +10 -0
- package/dist/components/registry.d.ts +1 -0
- package/dist/components/registry.js +46 -0
- package/dist/components/select.d.ts +2 -0
- package/dist/components/select.js +38 -0
- package/dist/components/surface.d.ts +5 -0
- package/dist/components/surface.js +10 -0
- package/dist/components/switch.d.ts +2 -0
- package/dist/components/switch.js +8 -0
- package/dist/components/table.d.ts +12 -0
- package/dist/components/table.js +30 -0
- package/dist/components/tabs.d.ts +14 -0
- package/dist/components/tabs.js +26 -0
- package/dist/components/tag.d.ts +4 -0
- package/dist/components/tag.js +26 -0
- package/dist/components/toast.d.ts +19 -0
- package/dist/components/toast.js +28 -0
- package/dist/components/toolbar.d.ts +13 -0
- package/dist/components/toolbar.js +15 -0
- package/dist/components/tooltip.d.ts +2 -0
- package/dist/components/tooltip.js +7 -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/dsl/validator.d.ts +12 -0
- package/dist/dsl/validator.js +97 -0
- package/dist/emit/css.d.ts +30 -0
- package/dist/emit/css.js +120 -0
- package/dist/gamut.d.ts +4 -0
- package/dist/gamut.js +44 -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/scope-gate.d.ts +15 -0
- package/dist/scope-gate.js +87 -0
- package/dist/scope-map.json +2 -1
- package/dist/scopes.d.ts +6 -0
- package/dist/scopes.js +88 -0
- package/dist/theme/gate.d.ts +19 -0
- package/dist/theme/gate.js +44 -0
- package/dist/theme/index.d.ts +16 -0
- package/dist/theme/index.js +23 -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 +15 -0
- 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;
|
package/dist/emit/css.js
ADDED
|
@@ -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
|
+
}
|
package/dist/gamut.d.ts
ADDED
|
@@ -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";
|