@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.
- package/README.md +17 -0
- package/dist/components/toolbar.vars.css +5 -0
- package/dist/components.css +6 -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,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;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { token, set, delta, cap, slot, ref } from "../../dsl/builders.js";
|
|
2
|
+
/** Dark-first brand (D27) — the portfolio's ember-field identity.
|
|
3
|
+
* Anchors are culori-derived from the shipped portfolio hexes. */
|
|
4
|
+
export const emberPalette = {
|
|
5
|
+
emberCanvas: { l: 0.147, c: 0.004, h: 49 }, // #0c0a09 page
|
|
6
|
+
emberInk: { l: 0.949, c: 0.011, h: 72 }, // #f3ede6 text
|
|
7
|
+
emberAccent: { l: 0.724, c: 0.177, h: 40 }, // #ff7847
|
|
8
|
+
emberEmerald: { l: 0.52, c: 0.19, h: 155 }, // default success anchor values
|
|
9
|
+
emberAmber: { l: 0.75, c: 0.1582, h: 75 }, // default warning anchor values (c retuned in-gamut, D39)
|
|
10
|
+
emberRuby: { l: 0.55, c: 0.22, h: 25 }, // default danger anchor values
|
|
11
|
+
white: { l: 1.0, c: 0, h: 0 },
|
|
12
|
+
black: { l: 0.0, c: 0, h: 0 },
|
|
13
|
+
};
|
|
14
|
+
export const emberSlots = {
|
|
15
|
+
ink: "emberInk",
|
|
16
|
+
canvas: "emberCanvas",
|
|
17
|
+
accent: "emberAccent",
|
|
18
|
+
success: "emberEmerald",
|
|
19
|
+
warning: "emberAmber",
|
|
20
|
+
danger: "emberRuby",
|
|
21
|
+
};
|
|
22
|
+
/** Brand-level font stacks (D29): Archivo for display/sans, IBM Plex for
|
|
23
|
+
* serif/mono. The DS ships no font files — the portfolio site loads these
|
|
24
|
+
* webfonts itself. */
|
|
25
|
+
export const emberFonts = {
|
|
26
|
+
sans: '"Archivo", system-ui, sans-serif',
|
|
27
|
+
serif: '"IBM Plex Serif", Georgia, serif',
|
|
28
|
+
mono: '"IBM Plex Mono", "Courier New", monospace',
|
|
29
|
+
display: '"Archivo", system-ui, sans-serif',
|
|
30
|
+
};
|
|
31
|
+
/** D34 component-token overrides — emitted at [data-psi-theme="ember"] in psi.components. */
|
|
32
|
+
export const emberComponentOverrides = {
|
|
33
|
+
"button-font": "var(--psi-text-mono-15-24-regular)",
|
|
34
|
+
"card-radius": "0",
|
|
35
|
+
"media-tint": "grayscale(1) sepia(0.65) saturate(1.6) hue-rotate(-12deg) brightness(0.88) contrast(1.05)",
|
|
36
|
+
};
|
|
37
|
+
export const emberOverrides = {
|
|
38
|
+
// Inherited dark formulas land off-target (#0e0b07 / #1a150f / #eee6e2);
|
|
39
|
+
// canvas/ink-anchor overrides hit the shipped hexes exactly.
|
|
40
|
+
bgPrimary: token({ from: slot.canvas }), // #0c0a09
|
|
41
|
+
bgSecondary: token({ from: slot.canvas, l: delta(+0.015), c: delta(+0.003) }), // #100d0b
|
|
42
|
+
fgPrimary: token({ from: slot.ink }), // #f3ede6
|
|
43
|
+
// Dark label on the ember accent (D37): white is 2.62:1 on #ff7847. The
|
|
44
|
+
// brand's warm black (l 0.25) scored 6.12:1 — AA-passing but visibly weak
|
|
45
|
+
// on the saturated orange with the mono button face (field report,
|
|
46
|
+
// 2026-07-19). Deepened to l 0.14 → 7.5:1, AAA for normal text; scoped
|
|
47
|
+
// here so fgStaticBlack stays warm everywhere else.
|
|
48
|
+
fgOnAccent: token({ from: ref.fgStaticBlack, l: set(0.14) }),
|
|
49
|
+
// D39: ember's accent hue (40) allows more chroma at l 0.75 than dark.ts's
|
|
50
|
+
// sapphire-tuned cap(0.1286) — carry ember's own in-gamut max. // #ff885d
|
|
51
|
+
fgAccent: token({ from: slot.accent, l: set(0.75), c: cap(0.156) }),
|
|
52
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Palette, SlotMap, ThemeDef } from "../../dsl/types.js";
|
|
2
|
+
/** Brand-level font role assignment (D29). Emitted inside the brand's theme
|
|
3
|
+
* block; the DS never ships font files — consumers load the webfonts. */
|
|
4
|
+
export interface BrandFonts {
|
|
5
|
+
sans?: string;
|
|
6
|
+
serif?: string;
|
|
7
|
+
mono?: string;
|
|
8
|
+
display?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface CustomerTheme {
|
|
11
|
+
palette: Palette;
|
|
12
|
+
slots: SlotMap;
|
|
13
|
+
/** Which default theme the brand's formulas build on (D27). Default: "light". */
|
|
14
|
+
base?: "light" | "dark";
|
|
15
|
+
/** Optional semantic-token formula overrides, merged over the base theme. */
|
|
16
|
+
overrides?: ThemeDef;
|
|
17
|
+
/** Optional brand-level font role assignment (D29). */
|
|
18
|
+
fonts?: BrandFonts;
|
|
19
|
+
/** Component-token overrides (D34) — part of the themeable surface. Keys
|
|
20
|
+
* omit the --psi- prefix, e.g. "card-radius": "0". Must not break a11y,
|
|
21
|
+
* sizing contracts, or interaction states. */
|
|
22
|
+
componentOverrides?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/** Assemble the full semantic ThemeDef for a customer brand (D27). D46: an
|
|
25
|
+
* override inherits the base token's scopes unless it declares its own — a
|
|
26
|
+
* brand retuning fgOnAccent's formula must not silently unscope it. */
|
|
27
|
+
export declare function assembleCustomerTheme(c: CustomerTheme): ThemeDef;
|
|
28
|
+
export declare const customerThemes: Record<string, CustomerTheme>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { lightTheme } from "../light.js";
|
|
2
|
+
import { darkTheme } from "../dark.js";
|
|
3
|
+
import { acmePalette, acmeSlots, acmeOverrides } from "./acme.js";
|
|
4
|
+
import { emberPalette, emberSlots, emberOverrides, emberFonts, emberComponentOverrides } from "./ember.js";
|
|
5
|
+
/** Assemble the full semantic ThemeDef for a customer brand (D27). D46: an
|
|
6
|
+
* override inherits the base token's scopes unless it declares its own — a
|
|
7
|
+
* brand retuning fgOnAccent's formula must not silently unscope it. */
|
|
8
|
+
export function assembleCustomerTheme(c) {
|
|
9
|
+
const base = c.base === "dark" ? darkTheme : lightTheme;
|
|
10
|
+
const merged = { ...base };
|
|
11
|
+
for (const [name, def] of Object.entries(c.overrides ?? {})) {
|
|
12
|
+
const baseScopes = base[name]?.scopes;
|
|
13
|
+
merged[name] = def.scopes === undefined && baseScopes !== undefined
|
|
14
|
+
? { ...def, scopes: baseScopes }
|
|
15
|
+
: def;
|
|
16
|
+
}
|
|
17
|
+
return merged;
|
|
18
|
+
}
|
|
19
|
+
export const customerThemes = {
|
|
20
|
+
acme: { palette: acmePalette, slots: acmeSlots, overrides: acmeOverrides },
|
|
21
|
+
ember: { palette: emberPalette, slots: emberSlots, base: "dark", overrides: emberOverrides, fonts: emberFonts, componentOverrides: emberComponentOverrides },
|
|
22
|
+
// <ds:register — new-theme inserts here, do not remove>
|
|
23
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { token, set, cap, slot, ref } from "../dsl/builders.js";
|
|
2
|
+
export const darkTheme = {
|
|
3
|
+
bgPrimary: token({ from: slot.ink, l: set(0.15), c: set(0.01), scopes: ["surface"] }),
|
|
4
|
+
bgSecondary: token({ from: slot.ink, l: set(0.2), c: set(0.015), scopes: ["surface"] }),
|
|
5
|
+
fgPrimary: token({ from: slot.canvas, l: set(0.93), c: set(0.01), scopes: ["text"] }),
|
|
6
|
+
fgSecondary: token({ from: ref.fgPrimary, alpha: 0.7, scopes: ["text"] }),
|
|
7
|
+
fgTertiary: token({ from: ref.fgPrimary, alpha: 0.5, scopes: ["text"] }),
|
|
8
|
+
fgQuaternary: token({ from: ref.fgPrimary, alpha: 0.3, scopes: ["text"] }),
|
|
9
|
+
fgPrimaryInverted: token({ from: slot.ink, l: set(0.15), scopes: ["text"] }),
|
|
10
|
+
fgStaticWhite: token({ from: slot.canvas, l: set(1.0), c: set(0), scopes: ["text"] }),
|
|
11
|
+
fgStaticBlack: token({ from: slot.ink, l: set(0.25), c: cap(0.03), scopes: ["text"] }),
|
|
12
|
+
/** Label ink on fillAccent (D37). Brands whose accent can't carry white
|
|
13
|
+
* (e.g. ember #ff7847, white = 2.62:1) override this instead of mangling
|
|
14
|
+
* their anchor. */
|
|
15
|
+
fgOnAccent: token({ from: ref.fgStaticWhite, scopes: ["text"] }),
|
|
16
|
+
// D39: caps lowered to each hue's in-gamut max at l 0.75 — the values these
|
|
17
|
+
// tokens were already clamping to, so rendered hex is unchanged. fgSuccess
|
|
18
|
+
// keeps the loose cap: emerald at l 0.75 is already in-gamut.
|
|
19
|
+
fgAccent: token({ from: slot.accent, l: set(0.75), c: cap(0.1286), scopes: ["text"] }),
|
|
20
|
+
fgSuccess: token({ from: slot.success, l: set(0.75), c: cap(0.23), scopes: ["text"] }),
|
|
21
|
+
fgWarning: token({ from: slot.warning, l: set(0.75), c: cap(0.1582), scopes: ["text"] }),
|
|
22
|
+
fgDanger: token({ from: slot.danger, l: set(0.75), c: cap(0.1505), scopes: ["text", "border"] }),
|
|
23
|
+
fillNeutral1: token({ from: slot.ink, l: set(0.18), c: set(0.012), scopes: ["surface"] }),
|
|
24
|
+
fillNeutral2: token({ from: slot.ink, l: set(0.2), c: set(0.015), scopes: ["surface"] }),
|
|
25
|
+
fillNeutral3: token({ from: slot.ink, l: set(0.23), c: set(0.018), scopes: ["surface"] }),
|
|
26
|
+
fillNeutral4: token({ from: slot.ink, l: set(0.26), c: set(0.02), scopes: ["surface"] }),
|
|
27
|
+
fillNeutral5: token({ from: slot.ink, l: set(0.29), c: set(0.022), scopes: ["surface"] }),
|
|
28
|
+
fillNeutral6: token({ from: slot.ink, l: set(0.32), c: set(0.025), scopes: ["surface"] }),
|
|
29
|
+
fillAccent: token({ from: slot.accent, scopes: ["surface", "border"] }),
|
|
30
|
+
// D39: capped at the formula, not the emerald anchor (see light.ts).
|
|
31
|
+
fillSuccess: token({ from: slot.success, c: cap(0.1285), scopes: ["surface"] }),
|
|
32
|
+
fillWarning: token({ from: slot.warning, scopes: ["surface"] }),
|
|
33
|
+
fillDanger: token({ from: slot.danger, scopes: ["surface"] }),
|
|
34
|
+
fillTintAccent: token({ from: ref.fgAccent, alpha: 0.15, scopes: ["surface"] }),
|
|
35
|
+
fillTintSuccess: token({ from: ref.fgSuccess, alpha: 0.15, scopes: ["surface"] }),
|
|
36
|
+
fillTintWarning: token({ from: ref.fgWarning, alpha: 0.15, scopes: ["surface"] }),
|
|
37
|
+
fillTintDanger: token({ from: ref.fgDanger, alpha: 0.15, scopes: ["surface"] }),
|
|
38
|
+
/** D46: the static-white SURFACE (switch thumb) — pure ref, byte-identical
|
|
39
|
+
* with fgStaticWhite; scoped for what it is. */
|
|
40
|
+
fillStaticWhite: token({ from: ref.fgStaticWhite, scopes: ["surface"] }),
|
|
41
|
+
borderFaint: token({ from: ref.fgPrimary, alpha: 0.08, scopes: ["border"] }),
|
|
42
|
+
borderNeutral: token({ from: ref.fgPrimary, alpha: 0.15, scopes: ["border"] }),
|
|
43
|
+
borderStrong: token({ from: ref.fgPrimary, alpha: 0.3, scopes: ["border"] }),
|
|
44
|
+
borderFocus: token({ from: ref.fgAccent, scopes: ["border"] }),
|
|
45
|
+
// Scrims (D32): page-canvas surfaces at alpha — never text pairs, so they
|
|
46
|
+
// carry no AA gate entries.
|
|
47
|
+
scrimSoft: token({ from: ref.bgPrimary, alpha: 0.25, scopes: ["surface"] }),
|
|
48
|
+
scrimMedium: token({ from: ref.bgPrimary, alpha: 0.35, scopes: ["surface"] }),
|
|
49
|
+
scrimHeavy: token({ from: ref.bgPrimary, alpha: 0.82, scopes: ["surface"] }),
|
|
50
|
+
/** D46: the deliberate inversion pair (tooltip). Pure refs — always
|
|
51
|
+
* byte-identical with fgPrimary/bgPrimary in every theme — but scoped
|
|
52
|
+
* for what they ARE: an inverted surface and the label on it. */
|
|
53
|
+
bgInverted: token({ from: ref.fgPrimary, scopes: ["surface"] }),
|
|
54
|
+
fgOnInverted: token({ from: ref.bgPrimary, scopes: ["text"] }),
|
|
55
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { token, set, delta, cap, slot, ref } from "../dsl/builders.js";
|
|
2
|
+
export const lightTheme = {
|
|
3
|
+
bgPrimary: token({ from: slot.canvas, scopes: ["surface"] }),
|
|
4
|
+
bgSecondary: token({ from: slot.canvas, l: delta(+0.03), scopes: ["surface"] }),
|
|
5
|
+
fgPrimary: token({ from: slot.ink, l: set(0.3), c: set(0.03), scopes: ["text"] }),
|
|
6
|
+
fgSecondary: token({ from: ref.fgPrimary, alpha: 0.7, scopes: ["text"] }),
|
|
7
|
+
fgTertiary: token({ from: ref.fgPrimary, alpha: 0.5, scopes: ["text"] }),
|
|
8
|
+
fgQuaternary: token({ from: ref.fgPrimary, alpha: 0.3, scopes: ["text"] }),
|
|
9
|
+
fgPrimaryInverted: token({ from: slot.canvas, scopes: ["text"] }),
|
|
10
|
+
fgStaticWhite: token({ from: slot.canvas, l: set(1.0), c: set(0), scopes: ["text"] }),
|
|
11
|
+
fgStaticBlack: token({ from: slot.ink, l: set(0.25), c: cap(0.03), scopes: ["text"] }),
|
|
12
|
+
/** Label ink on fillAccent (D37). Brands whose accent can't carry white
|
|
13
|
+
* (e.g. ember #ff7847, white = 2.62:1) override this instead of mangling
|
|
14
|
+
* their anchor. */
|
|
15
|
+
fgOnAccent: token({ from: ref.fgStaticWhite, scopes: ["text"] }),
|
|
16
|
+
fgAccent: token({ from: slot.accent, l: set(0.48), c: cap(0.23), scopes: ["text"] }),
|
|
17
|
+
// D39: status-fg caps lowered to each hue's in-gamut max at l 0.48 — the
|
|
18
|
+
// values these tokens were already clamping to, so rendered hex is unchanged.
|
|
19
|
+
fgSuccess: token({ from: slot.success, l: set(0.48), c: cap(0.1187), scopes: ["text"] }),
|
|
20
|
+
fgWarning: token({ from: slot.warning, l: set(0.48), c: cap(0.1013), scopes: ["text"] }),
|
|
21
|
+
fgDanger: token({ from: slot.danger, l: set(0.48), c: cap(0.1946), scopes: ["text", "border"] }),
|
|
22
|
+
fillNeutral1: token({ from: slot.canvas, l: delta(+0.016), c: delta(-0.001), scopes: ["surface"] }),
|
|
23
|
+
fillNeutral2: token({ from: slot.canvas, scopes: ["surface"] }),
|
|
24
|
+
fillNeutral3: token({ from: slot.canvas, l: delta(-0.017), c: delta(+0.001), scopes: ["surface"] }),
|
|
25
|
+
fillNeutral4: token({ from: slot.canvas, l: delta(-0.034), c: delta(+0.002), scopes: ["surface"] }),
|
|
26
|
+
fillNeutral5: token({ from: slot.canvas, l: delta(-0.032), c: delta(+0.003), scopes: ["surface"] }),
|
|
27
|
+
fillNeutral6: token({ from: slot.canvas, l: delta(-0.048), c: delta(+0.004), scopes: ["surface"] }),
|
|
28
|
+
fillAccent: token({ from: slot.accent, scopes: ["surface", "border"] }),
|
|
29
|
+
// D39: capped at the formula, not the emerald anchor — dark fgSuccess reads
|
|
30
|
+
// the same anchor at a different lightness where it IS in-gamut (see
|
|
31
|
+
// default.ts comment on emerald).
|
|
32
|
+
fillSuccess: token({ from: slot.success, c: cap(0.1285), scopes: ["surface"] }),
|
|
33
|
+
fillWarning: token({ from: slot.warning, scopes: ["surface"] }),
|
|
34
|
+
fillDanger: token({ from: slot.danger, scopes: ["surface"] }),
|
|
35
|
+
fillTintAccent: token({ from: ref.fgAccent, alpha: 0.12, scopes: ["surface"] }),
|
|
36
|
+
fillTintSuccess: token({ from: ref.fgSuccess, alpha: 0.12, scopes: ["surface"] }),
|
|
37
|
+
fillTintWarning: token({ from: ref.fgWarning, alpha: 0.12, scopes: ["surface"] }),
|
|
38
|
+
fillTintDanger: token({ from: ref.fgDanger, alpha: 0.12, scopes: ["surface"] }),
|
|
39
|
+
/** D46: the static-white SURFACE (switch thumb) — pure ref, byte-identical
|
|
40
|
+
* with fgStaticWhite; scoped for what it is. */
|
|
41
|
+
fillStaticWhite: token({ from: ref.fgStaticWhite, scopes: ["surface"] }),
|
|
42
|
+
borderFaint: token({ from: ref.fgPrimary, alpha: 0.08, scopes: ["border"] }),
|
|
43
|
+
borderNeutral: token({ from: ref.fgPrimary, alpha: 0.15, scopes: ["border"] }),
|
|
44
|
+
borderStrong: token({ from: ref.fgPrimary, alpha: 0.3, scopes: ["border"] }),
|
|
45
|
+
borderFocus: token({ from: ref.fgAccent, scopes: ["border"] }),
|
|
46
|
+
// Scrims (D32): page-canvas surfaces at alpha — never text pairs, so they
|
|
47
|
+
// carry no AA gate entries.
|
|
48
|
+
scrimSoft: token({ from: ref.bgPrimary, alpha: 0.25, scopes: ["surface"] }),
|
|
49
|
+
scrimMedium: token({ from: ref.bgPrimary, alpha: 0.35, scopes: ["surface"] }),
|
|
50
|
+
scrimHeavy: token({ from: ref.bgPrimary, alpha: 0.82, scopes: ["surface"] }),
|
|
51
|
+
/** D46: the deliberate inversion pair (tooltip). Pure refs — always
|
|
52
|
+
* byte-identical with fgPrimary/bgPrimary in every theme — but scoped
|
|
53
|
+
* for what they ARE: an inverted surface and the label on it. */
|
|
54
|
+
bgInverted: token({ from: ref.fgPrimary, scopes: ["surface"] }),
|
|
55
|
+
fgOnInverted: token({ from: ref.bgPrimary, scopes: ["text"] }),
|
|
56
|
+
};
|
package/llms.txt
CHANGED
|
@@ -53,4 +53,12 @@ OKLCH formula-based design tokens. Code is the source of truth; all artifacts ge
|
|
|
53
53
|
## Component overrides (D34)
|
|
54
54
|
- A brand's theme file may declare `componentOverrides: Record<string, string>` (e.g. ember: button-font → mono, card-radius → 0, media-tint → its filter chain), emitted in the brand's own theme file (e.g. dist/ember.css) inside `@layer psi.components`. Prefer this over forking a component's CSS Module for brand-specific token values.
|
|
55
55
|
|
|
56
|
+
## Deriving a brand from a prompt (`./generate`, D57)
|
|
57
|
+
- `import { parsePrompt, deriveTheme, serializeCustomerTheme } from "@handamade/psi-tokens/generate"` — a runtime subpath (`dist/generate`, compiled from `src/generate` via `tsc`), not a build-time script.
|
|
58
|
+
- `parsePrompt(prompt)` turns free text into a `BrandVector` (hue, chroma word, mode, radius rung, optional fonts, slug name). It is deterministic: an FNV-1a hash of the normalized prompt seeds a PRNG, so an unrecognised prompt still derives a coherent, repeatable vector instead of failing.
|
|
59
|
+
- `deriveTheme(vector)` returns a `DerivedPair` — light and dark members solved independently by binary-searching each text token's lightness until the full WCAG AA matrix (`wcagAAPairs`) passes. The guarantee is about the return value: `deriveTheme` never returns a theme that renders below AA, because it throws instead — when a token can't clear its target at any chroma, when a failure can't be traced to any of the four tokens it solves, when 40 escalation attempts are exhausted, and as a belt-and-braces residual check on the resolved pair.
|
|
60
|
+
- `serializeCustomerTheme(pair)` emits real `customers/<name>.ts` source (a `Palette`, two `SlotMap`s, solved `ThemeDef` overrides for each member, a control-radius `componentOverrides` entry) plus the two registry lines that add it to the customer theme map — `pnpm new-theme`'s single-mode, override-free scaffold extended to a pair, with the solved overrides `acmeOverrides` (`customers/acme.ts`) carries by hand.
|
|
61
|
+
- Lower-level helpers are also exported for callers that need one step in isolation: `fnv1a`, `seededRandom`, `buildBrandPalette`, `hexFor`, `contrastOf`, `solveL`, `isBrandVector`, and the `BrandVector`/`ChromaWord`/`RadiusRung` types plus `CHROMA_WORDS`/`RADIUS_RUNGS` constants.
|
|
62
|
+
- `isBrandVector` is the runtime guard for untrusted input (e.g. the `api/theme.ts` Vercel Function) — narrow before calling `deriveTheme`, never trust a client-supplied vector shape directly.
|
|
63
|
+
|
|
56
64
|
Live MCP access to this data: see packages/mcp/llms.txt (hosted: https://psi.kurkin.de/mcp).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@handamade/psi-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"types": "./dist/types/index.d.ts",
|
|
13
13
|
"import": "./dist/types/index.js"
|
|
14
14
|
},
|
|
15
|
+
"./generate": {
|
|
16
|
+
"types": "./dist/generate/index.d.ts",
|
|
17
|
+
"import": "./dist/generate/index.js"
|
|
18
|
+
},
|
|
15
19
|
"./guidance.json": "./dist/guidance.json"
|
|
16
20
|
},
|
|
17
21
|
"files": [
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
"tsx": "^4.19.0"
|
|
27
31
|
},
|
|
28
32
|
"scripts": {
|
|
29
|
-
"build": "tsx scripts/build.ts",
|
|
33
|
+
"build": "tsc -p tsconfig.build.json && tsx scripts/build.ts",
|
|
30
34
|
"dev": "tsx --watch scripts/build.ts --watch",
|
|
31
35
|
"new-theme": "tsx scripts/new-theme.ts"
|
|
32
36
|
}
|