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