@handamade/psi-tokens 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +36 -0
  2. package/dist/cli/psi-theme.d.ts +2 -0
  3. package/dist/cli/psi-theme.js +56 -0
  4. package/dist/components/button.d.ts +7 -0
  5. package/dist/components/button.js +76 -0
  6. package/dist/components/card.d.ts +2 -0
  7. package/dist/components/card.js +7 -0
  8. package/dist/components/checkbox.d.ts +2 -0
  9. package/dist/components/checkbox.js +13 -0
  10. package/dist/components/control.d.ts +21 -0
  11. package/dist/components/control.js +59 -0
  12. package/dist/components/description-list.d.ts +8 -0
  13. package/dist/components/description-list.js +13 -0
  14. package/dist/components/dialog.d.ts +6 -0
  15. package/dist/components/dialog.js +13 -0
  16. package/dist/components/field.d.ts +3 -0
  17. package/dist/components/field.js +9 -0
  18. package/dist/components/input.d.ts +2 -0
  19. package/dist/components/input.js +25 -0
  20. package/dist/components/media.d.ts +3 -0
  21. package/dist/components/media.js +5 -0
  22. package/dist/components/menu.d.ts +7 -0
  23. package/dist/components/menu.js +19 -0
  24. package/dist/components/navbar.d.ts +3 -0
  25. package/dist/components/navbar.js +11 -0
  26. package/dist/components/panel.d.ts +5 -0
  27. package/dist/components/panel.js +10 -0
  28. package/dist/components/registry.d.ts +1 -0
  29. package/dist/components/registry.js +46 -0
  30. package/dist/components/select.d.ts +2 -0
  31. package/dist/components/select.js +38 -0
  32. package/dist/components/surface.d.ts +5 -0
  33. package/dist/components/surface.js +10 -0
  34. package/dist/components/switch.d.ts +2 -0
  35. package/dist/components/switch.js +8 -0
  36. package/dist/components/table.d.ts +12 -0
  37. package/dist/components/table.js +30 -0
  38. package/dist/components/tabs.d.ts +14 -0
  39. package/dist/components/tabs.js +26 -0
  40. package/dist/components/tag.d.ts +4 -0
  41. package/dist/components/tag.js +26 -0
  42. package/dist/components/toast.d.ts +19 -0
  43. package/dist/components/toast.js +28 -0
  44. package/dist/components/toolbar.d.ts +13 -0
  45. package/dist/components/toolbar.js +15 -0
  46. package/dist/components/tooltip.d.ts +2 -0
  47. package/dist/components/tooltip.js +7 -0
  48. package/dist/contrast-matrix.d.ts +34 -0
  49. package/dist/contrast-matrix.js +130 -0
  50. package/dist/dsl/builders.d.ts +7 -0
  51. package/dist/dsl/builders.js +25 -0
  52. package/dist/dsl/resolver.d.ts +3 -0
  53. package/dist/dsl/resolver.js +114 -0
  54. package/dist/dsl/types.d.ts +59 -0
  55. package/dist/dsl/types.js +2 -0
  56. package/dist/dsl/validator.d.ts +12 -0
  57. package/dist/dsl/validator.js +97 -0
  58. package/dist/emit/css.d.ts +30 -0
  59. package/dist/emit/css.js +120 -0
  60. package/dist/gamut.d.ts +4 -0
  61. package/dist/gamut.js +44 -0
  62. package/dist/generate/derive.d.ts +20 -0
  63. package/dist/generate/derive.js +232 -0
  64. package/dist/generate/dictionaries.d.ts +18 -0
  65. package/dist/generate/dictionaries.js +46 -0
  66. package/dist/generate/index.d.ts +6 -0
  67. package/dist/generate/index.js +6 -0
  68. package/dist/generate/palette.d.ts +7 -0
  69. package/dist/generate/palette.js +93 -0
  70. package/dist/generate/prompt.d.ts +7 -0
  71. package/dist/generate/prompt.js +62 -0
  72. package/dist/generate/serialize.d.ts +10 -0
  73. package/dist/generate/serialize.js +167 -0
  74. package/dist/generate/solve.d.ts +36 -0
  75. package/dist/generate/solve.js +56 -0
  76. package/dist/generate/types.d.ts +24 -0
  77. package/dist/generate/types.js +48 -0
  78. package/dist/scope-gate.d.ts +15 -0
  79. package/dist/scope-gate.js +87 -0
  80. package/dist/scope-map.json +2 -1
  81. package/dist/scopes.d.ts +6 -0
  82. package/dist/scopes.js +88 -0
  83. package/dist/theme/gate.d.ts +19 -0
  84. package/dist/theme/gate.js +44 -0
  85. package/dist/theme/index.d.ts +16 -0
  86. package/dist/theme/index.js +23 -0
  87. package/dist/themes/customers/acme.d.ts +8 -0
  88. package/dist/themes/customers/acme.js +31 -0
  89. package/dist/themes/customers/ember.d.ts +13 -0
  90. package/dist/themes/customers/ember.js +52 -0
  91. package/dist/themes/customers/index.d.ts +28 -0
  92. package/dist/themes/customers/index.js +23 -0
  93. package/dist/themes/dark.d.ts +2 -0
  94. package/dist/themes/dark.js +55 -0
  95. package/dist/themes/light.d.ts +2 -0
  96. package/dist/themes/light.js +56 -0
  97. package/llms.txt +15 -0
  98. package/package.json +14 -2
package/dist/scopes.js ADDED
@@ -0,0 +1,88 @@
1
+ // D46 — token scope vocabulary. Scope entries on a token are either a
2
+ // property-group name (key of PROPERTY_GROUPS) or a concrete CSS property
3
+ // listed in one of the groups. The -bg/-fg/-border suffix convention on
4
+ // component-token keys is normative: SUFFIX_GROUPS is the shipped map.
5
+ // ── Property groups ──────────────────────────────────────────────────
6
+ export const PROPERTY_GROUPS = {
7
+ // background-image (D81): a glyph drawn with CSS gradients — the Select
8
+ // chevron, which a strict CSP forbids as a data: SVG — is foreground ink,
9
+ // the same role fill/stroke play on an SVG glyph.
10
+ text: ["color", "fill", "stroke", "caret-color", "text-decoration-color", "background-image"],
11
+ surface: ["background", "background-color", "accent-color"],
12
+ border: [
13
+ "border", "border-color", "border-top-color", "border-right-color",
14
+ "border-bottom-color", "border-left-color", "outline", "outline-color",
15
+ "box-shadow",
16
+ "border-top", "border-right", "border-bottom", "border-left",
17
+ "border-inline", "border-block",
18
+ // Logical longhands. The group already carried every physical longhand and
19
+ // both logical shorthands, so their absence was an omission rather than a
20
+ // rule: a vertical Tabs list binding --psi-tabs-list-border to
21
+ // border-inline-start is exactly the case the group exists to allow, and
22
+ // the logical form is the one that survives RTL. Found by the D46 gate
23
+ // itself during D67.
24
+ "border-inline-start", "border-inline-end",
25
+ "border-block-start", "border-block-end",
26
+ ],
27
+ gap: [
28
+ "gap", "row-gap", "column-gap",
29
+ "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
30
+ "padding-inline", "padding-block",
31
+ "padding-inline-start", "padding-inline-end", "padding-block-start", "padding-block-end",
32
+ "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
33
+ "margin-inline", "margin-block",
34
+ "margin-inline-start", "margin-inline-end", "margin-block-start", "margin-block-end",
35
+ "inset", "top", "right", "bottom", "left",
36
+ "scroll-margin", "scroll-padding", "translate",
37
+ // edge-distance uses: icon offsets (background-position) and viewport-inset sizing (max-width in calc) are gap-family intent
38
+ "background-position", "max-width",
39
+ ],
40
+ };
41
+ // ── Normative suffix groups ──────────────────────────────────────────
42
+ // Normative component-token suffix → property group (D46).
43
+ export const SUFFIX_GROUPS = {
44
+ fg: "text",
45
+ bg: "surface",
46
+ border: "border",
47
+ };
48
+ // ── Scale scopes ─────────────────────────────────────────────────────
49
+ // Scale-family scopes (D46 "gaps" family). Families not listed are unscoped.
50
+ export const SCALE_SCOPES = {
51
+ space: ["gap"],
52
+ };
53
+ // ── Internal lookup ──────────────────────────────────────────────────
54
+ const KNOWN_PROPERTIES = new Set(Object.values(PROPERTY_GROUPS).flat());
55
+ // ── Query functions ─────────────────────────────────────────────────
56
+ export function isKnownScopeEntry(entry) {
57
+ return entry in PROPERTY_GROUPS || KNOWN_PROPERTIES.has(entry);
58
+ }
59
+ // Expand scope entries (group names or property names) to a deduped
60
+ // property list. Throws on unknown entries (D48 posture).
61
+ export function expandScopes(scopes) {
62
+ const out = new Set();
63
+ for (const entry of scopes) {
64
+ if (entry in PROPERTY_GROUPS) {
65
+ for (const p of PROPERTY_GROUPS[entry])
66
+ out.add(p);
67
+ }
68
+ else if (KNOWN_PROPERTIES.has(entry)) {
69
+ out.add(entry);
70
+ }
71
+ else {
72
+ throw new Error(`unknown scope entry "${entry}" — not a known CSS property or declared group`);
73
+ }
74
+ }
75
+ return [...out];
76
+ }
77
+ // Property group a component-token key declares via its last bg/fg/border
78
+ // segment ("accent-bg-hover" → surface). Undefined for unsuffixed keys
79
+ // ("focus-ring", "radius", "backdrop") — those are not scope-checked.
80
+ export function keyGroup(key) {
81
+ const segments = key.split("-");
82
+ for (let i = segments.length - 1; i >= 0; i--) {
83
+ const group = SUFFIX_GROUPS[segments[i]];
84
+ if (group)
85
+ return group;
86
+ }
87
+ return undefined;
88
+ }
@@ -0,0 +1,19 @@
1
+ import type { Palette, SlotMap, ThemeDef } from "../dsl/types.js";
2
+ import { type ResolvedTheme } from "../dsl/resolver.js";
3
+ import { type ContrastResult } from "../contrast-matrix.js";
4
+ import { type ScopeViolation } from "../scope-gate.js";
5
+ /** Thrown when a theme fails the WCAG AA contrast gate or the D46 scope gate. */
6
+ export declare class ThemeGateError extends Error {
7
+ readonly theme: string;
8
+ readonly contrastFailures: readonly ContrastResult[];
9
+ readonly scopeViolations: readonly ScopeViolation[];
10
+ constructor(theme: string, contrastFailures: readonly ContrastResult[], scopeViolations: readonly ScopeViolation[]);
11
+ }
12
+ export interface GatedTheme {
13
+ resolved: ResolvedTheme;
14
+ /** Non-fatal gamut warnings. */
15
+ warnings: string[];
16
+ }
17
+ /** Validate, resolve and gate one theme. Throws ValidationError on a malformed
18
+ * definition and ThemeGateError on any contrast or scope failure. */
19
+ export declare function gateTheme(name: string, theme: ThemeDef, palette: Palette, slots: SlotMap, componentOverrides?: Record<string, string>): GatedTheme;
@@ -0,0 +1,44 @@
1
+ import { validate } from "../dsl/validator.js";
2
+ import { resolve } from "../dsl/resolver.js";
3
+ import { gamutWarnings } from "../gamut.js";
4
+ import { checkContrast, wcagAAPairs, componentLabelPairs } from "../contrast-matrix.js";
5
+ import { checkScopes, checkOverrideScopes } from "../scope-gate.js";
6
+ import { componentVars } from "../components/registry.js";
7
+ /** Thrown when a theme fails the WCAG AA contrast gate or the D46 scope gate. */
8
+ export class ThemeGateError extends Error {
9
+ theme;
10
+ contrastFailures;
11
+ scopeViolations;
12
+ constructor(theme, contrastFailures, scopeViolations) {
13
+ const parts = [];
14
+ if (contrastFailures.length > 0) {
15
+ parts.push(`${contrastFailures.length} contrast failures: ` +
16
+ contrastFailures.map((f) => `${f.fg} on ${f.bg} ${f.ratio} (need ${f.minRatio})`).join("; "));
17
+ }
18
+ if (scopeViolations.length > 0) {
19
+ parts.push(`${scopeViolations.length} scope violations: ` +
20
+ scopeViolations.map((v) => `--psi-${v.component}-${v.key} (${v.group}) binds ${v.token}`).join("; "));
21
+ }
22
+ super(`${theme} theme failed its gates — ${parts.join(" · ")}`);
23
+ this.theme = theme;
24
+ this.contrastFailures = contrastFailures;
25
+ this.scopeViolations = scopeViolations;
26
+ this.name = "ThemeGateError";
27
+ }
28
+ }
29
+ /** Validate, resolve and gate one theme. Throws ValidationError on a malformed
30
+ * definition and ThemeGateError on any contrast or scope failure. */
31
+ export function gateTheme(name, theme, palette, slots, componentOverrides = {}) {
32
+ validate(theme, slots);
33
+ const resolved = resolve(theme, palette, slots);
34
+ const warnings = gamutWarnings(resolved, theme, palette, slots);
35
+ const contrastFailures = checkContrast(resolved, [...wcagAAPairs, ...componentLabelPairs]).filter((r) => !r.pass);
36
+ const scopeViolations = [
37
+ ...checkScopes(componentVars, theme),
38
+ ...checkOverrideScopes(componentOverrides, componentVars, theme),
39
+ ];
40
+ if (contrastFailures.length > 0 || scopeViolations.length > 0) {
41
+ throw new ThemeGateError(name, contrastFailures, scopeViolations);
42
+ }
43
+ return { resolved, warnings };
44
+ }
@@ -0,0 +1,16 @@
1
+ import { type CustomerTheme } from "../themes/customers/index.js";
2
+ export { ThemeGateError } from "./gate.js";
3
+ export type { CustomerTheme, BrandFonts } from "../themes/customers/index.js";
4
+ export type { Palette, PaletteEntry, SlotMap, ThemeDef, TokenDef } from "../dsl/types.js";
5
+ export { token, set, delta, cap, slot, ref } from "../dsl/builders.js";
6
+ export interface ThemeBuild {
7
+ /** The theme's CSS, scoped to `[data-psi-theme="<name>"]`. Load it after
8
+ * `base.css`; apply it with the attribute on any element. */
9
+ css: string;
10
+ /** Non-fatal gamut warnings. */
11
+ warnings: string[];
12
+ }
13
+ /** Build one theme through the same gates as Psi's shipped themes: the
14
+ * definition is validated, then refused with a ThemeGateError if any WCAG AA
15
+ * contrast pair or D46 token scope fails. */
16
+ export declare function buildThemeCss(name: string, theme: CustomerTheme): ThemeBuild;
@@ -0,0 +1,23 @@
1
+ // D81 — @handamade/psi-tokens/theme: build a gated theme outside the Psi repo.
2
+ // A consumer keeps its brand's definition in its own repository; nothing
3
+ // client-named has to ship in this package.
4
+ import { assembleCustomerTheme } from "../themes/customers/index.js";
5
+ import { emitThemeCSS } from "../emit/css.js";
6
+ import { gateTheme } from "./gate.js";
7
+ export { ThemeGateError } from "./gate.js";
8
+ export { token, set, delta, cap, slot, ref } from "../dsl/builders.js";
9
+ /** Build one theme through the same gates as Psi's shipped themes: the
10
+ * definition is validated, then refused with a ThemeGateError if any WCAG AA
11
+ * contrast pair or D46 token scope fails. */
12
+ export function buildThemeCss(name, theme) {
13
+ if (name === "light" || name === "dark") {
14
+ throw new Error(`"${name}" is a built-in theme name; choose another`);
15
+ }
16
+ const def = assembleCustomerTheme(theme);
17
+ const { warnings } = gateTheme(name, def, theme.palette, theme.slots, theme.componentOverrides);
18
+ const css = emitThemeCSS(name, def, theme.palette, theme.slots, {
19
+ fonts: theme.fonts,
20
+ componentOverrides: theme.componentOverrides,
21
+ });
22
+ return { css, warnings };
23
+ }
@@ -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,2 @@
1
+ import type { ThemeDef } from "../dsl/types.js";
2
+ export declare const darkTheme: ThemeDef;
@@ -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,2 @@
1
+ import type { ThemeDef } from "../dsl/types.js";
2
+ export declare const lightTheme: ThemeDef;
@@ -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,19 @@ 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
+
64
+ ## Building a theme outside this repo (`./theme`, D81)
65
+ - `import { buildThemeCss, ThemeGateError } from "@handamade/psi-tokens/theme"` — `buildThemeCss(name, definition)` returns `{ css, warnings }` for a definition shaped like `customers/<name>.ts` (`palette`, `slots`, optional `base: "light" | "dark"`, `overrides`, `fonts`, `componentOverrides`). It runs the same gates as Psi's own build, through the same code (`src/theme/gate.ts`), and throws `ThemeGateError` (`contrastFailures`, `scopeViolations`) instead of returning a failing theme. The names `light` and `dark` are refused.
66
+ - Use it to keep a client brand in the client's repository: nothing client-named has to ship in this public package.
67
+ - The CSS is scoped to `[data-psi-theme="<name>"]`, never `:root`, so a `base: "dark"` theme can style a sub-tree such as a dark sidebar.
68
+ - `psi-theme <definition-module> --name <name> [--out <file.css>]` is the same build as a bin, for a consumer's build script. The module default-exports the definition. Exit codes: 0 built · 1 refused by a gate · 2 usage error.
69
+ - The token builders (`token`, `set`, `delta`, `cap`, `slot`, `ref`) and the definition types are re-exported from `./theme` for writing `overrides`.
70
+
56
71
  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.18.0",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,19 @@
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
+ },
19
+ "./theme": {
20
+ "types": "./dist/theme/index.d.ts",
21
+ "import": "./dist/theme/index.js"
22
+ },
15
23
  "./guidance.json": "./dist/guidance.json"
16
24
  },
25
+ "bin": {
26
+ "psi-theme": "./dist/cli/psi-theme.js"
27
+ },
17
28
  "files": [
18
29
  "dist",
19
30
  "README.md",
@@ -23,10 +34,11 @@
23
34
  "culori": "^4.0.0"
24
35
  },
25
36
  "devDependencies": {
37
+ "@types/node": "^24.19.0",
26
38
  "tsx": "^4.19.0"
27
39
  },
28
40
  "scripts": {
29
- "build": "tsx scripts/build.ts",
41
+ "build": "tsc -p tsconfig.build.json && tsx scripts/build.ts",
30
42
  "dev": "tsx --watch scripts/build.ts --watch",
31
43
  "new-theme": "tsx scripts/new-theme.ts"
32
44
  }