@handamade/psi-tokens 0.19.0 → 0.21.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 (106) hide show
  1. package/README.md +19 -0
  2. package/dist/base.css +3 -0
  3. package/dist/cli/psi-theme.d.ts +2 -0
  4. package/dist/cli/psi-theme.js +56 -0
  5. package/dist/components/app-shell.d.ts +16 -0
  6. package/dist/components/app-shell.js +21 -0
  7. package/dist/components/app-shell.vars.css +8 -0
  8. package/dist/components/banner.d.ts +11 -0
  9. package/dist/components/banner.js +21 -0
  10. package/dist/components/banner.vars.css +13 -0
  11. package/dist/components/button.d.ts +7 -0
  12. package/dist/components/button.js +76 -0
  13. package/dist/components/card.d.ts +2 -0
  14. package/dist/components/card.js +7 -0
  15. package/dist/components/checkbox.d.ts +2 -0
  16. package/dist/components/checkbox.js +13 -0
  17. package/dist/components/control.d.ts +21 -0
  18. package/dist/components/control.js +59 -0
  19. package/dist/components/description-list.d.ts +8 -0
  20. package/dist/components/description-list.js +13 -0
  21. package/dist/components/dialog.d.ts +6 -0
  22. package/dist/components/dialog.js +16 -0
  23. package/dist/components/dialog.vars.css +1 -0
  24. package/dist/components/field.d.ts +3 -0
  25. package/dist/components/field.js +14 -0
  26. package/dist/components/field.vars.css +1 -0
  27. package/dist/components/inline-alert.d.ts +12 -0
  28. package/dist/components/inline-alert.js +27 -0
  29. package/dist/components/inline-alert.vars.css +18 -0
  30. package/dist/components/input.d.ts +2 -0
  31. package/dist/components/input.js +25 -0
  32. package/dist/components/media.d.ts +3 -0
  33. package/dist/components/media.js +5 -0
  34. package/dist/components/menu.d.ts +7 -0
  35. package/dist/components/menu.js +21 -0
  36. package/dist/components/menu.vars.css +1 -0
  37. package/dist/components/nav-tree.d.ts +12 -0
  38. package/dist/components/nav-tree.js +26 -0
  39. package/dist/components/nav-tree.vars.css +12 -0
  40. package/dist/components/navbar.d.ts +3 -0
  41. package/dist/components/navbar.js +14 -0
  42. package/dist/components/navbar.vars.css +1 -0
  43. package/dist/components/navtree.d.ts +12 -0
  44. package/dist/components/navtree.js +26 -0
  45. package/dist/components/navtree.vars.css +12 -0
  46. package/dist/components/panel.d.ts +5 -0
  47. package/dist/components/panel.js +10 -0
  48. package/dist/components/registry.d.ts +1 -0
  49. package/dist/components/registry.js +58 -0
  50. package/dist/components/select.d.ts +2 -0
  51. package/dist/components/select.js +38 -0
  52. package/dist/components/skeleton.d.ts +4 -0
  53. package/dist/components/skeleton.js +7 -0
  54. package/dist/components/skeleton.vars.css +6 -0
  55. package/dist/components/skip-link.d.ts +6 -0
  56. package/dist/components/skip-link.js +12 -0
  57. package/dist/components/skip-link.vars.css +9 -0
  58. package/dist/components/skiplink.d.ts +6 -0
  59. package/dist/components/skiplink.js +12 -0
  60. package/dist/components/skiplink.vars.css +9 -0
  61. package/dist/components/surface.d.ts +5 -0
  62. package/dist/components/surface.js +10 -0
  63. package/dist/components/switch.d.ts +2 -0
  64. package/dist/components/switch.js +8 -0
  65. package/dist/components/table.d.ts +12 -0
  66. package/dist/components/table.js +30 -0
  67. package/dist/components/tabs.d.ts +14 -0
  68. package/dist/components/tabs.js +26 -0
  69. package/dist/components/tag.d.ts +4 -0
  70. package/dist/components/tag.js +26 -0
  71. package/dist/components/toast.d.ts +19 -0
  72. package/dist/components/toast.js +28 -0
  73. package/dist/components/toolbar.d.ts +13 -0
  74. package/dist/components/toolbar.js +15 -0
  75. package/dist/components/tooltip.d.ts +2 -0
  76. package/dist/components/tooltip.js +7 -0
  77. package/dist/components.css +76 -0
  78. package/dist/contrast-matrix.js +8 -0
  79. package/dist/dsl/validator.d.ts +12 -0
  80. package/dist/dsl/validator.js +97 -0
  81. package/dist/dtcg/acme.json +18 -2
  82. package/dist/dtcg/dark.json +18 -2
  83. package/dist/dtcg/ember.json +18 -2
  84. package/dist/dtcg/light.json +18 -2
  85. package/dist/emit/css.d.ts +30 -0
  86. package/dist/emit/css.js +120 -0
  87. package/dist/gamut.d.ts +4 -0
  88. package/dist/gamut.js +44 -0
  89. package/dist/guidance.json +2 -2
  90. package/dist/resolved/acme.json +9 -2
  91. package/dist/resolved/dark.json +9 -2
  92. package/dist/resolved/ember.json +9 -2
  93. package/dist/resolved/light.json +9 -2
  94. package/dist/scope-gate.d.ts +15 -0
  95. package/dist/scope-gate.js +87 -0
  96. package/dist/scope-map.json +108 -3
  97. package/dist/scopes.d.ts +6 -0
  98. package/dist/scopes.js +88 -0
  99. package/dist/theme/gate.d.ts +19 -0
  100. package/dist/theme/gate.js +44 -0
  101. package/dist/theme/index.d.ts +16 -0
  102. package/dist/theme/index.js +23 -0
  103. package/dist/themes/dark.js +2 -2
  104. package/dist/themes/light.js +4 -2
  105. package/llms.txt +12 -0
  106. package/package.json +9 -1
@@ -157,7 +157,8 @@
157
157
  "hex": "#00cf77",
158
158
  "formula": "slot(success) → L set(0.75), C cap(0.23)",
159
159
  "scopes": [
160
- "text"
160
+ "text",
161
+ "border"
161
162
  ]
162
163
  },
163
164
  "fgWarning": {
@@ -170,7 +171,8 @@
170
171
  "hex": "#e79d00",
171
172
  "formula": "slot(warning) → L set(0.75), C cap(0.1582)",
172
173
  "scopes": [
173
- "text"
174
+ "text",
175
+ "border"
174
176
  ]
175
177
  },
176
178
  "fgDanger": {
@@ -574,6 +576,11 @@
574
576
  "tooltip": 1100
575
577
  }
576
578
  },
579
+ "focusRing": {
580
+ "width": 2,
581
+ "offset": 2,
582
+ "offsetInset": -2
583
+ },
577
584
  "scopes": {
578
585
  "space": [
579
586
  "gap"
@@ -157,7 +157,8 @@
157
157
  "hex": "#00cf77",
158
158
  "formula": "slot(success) → L set(0.75), C cap(0.23)",
159
159
  "scopes": [
160
- "text"
160
+ "text",
161
+ "border"
161
162
  ]
162
163
  },
163
164
  "fgWarning": {
@@ -170,7 +171,8 @@
170
171
  "hex": "#e79d00",
171
172
  "formula": "slot(warning) → L set(0.75), C cap(0.1582)",
172
173
  "scopes": [
173
- "text"
174
+ "text",
175
+ "border"
174
176
  ]
175
177
  },
176
178
  "fgDanger": {
@@ -574,6 +576,11 @@
574
576
  "tooltip": 1100
575
577
  }
576
578
  },
579
+ "focusRing": {
580
+ "width": 2,
581
+ "offset": 2,
582
+ "offsetInset": -2
583
+ },
577
584
  "scopes": {
578
585
  "space": [
579
586
  "gap"
@@ -157,7 +157,8 @@
157
157
  "hex": "#00703e",
158
158
  "formula": "slot(success) → L set(0.48), C cap(0.1187)",
159
159
  "scopes": [
160
- "text"
160
+ "text",
161
+ "border"
161
162
  ]
162
163
  },
163
164
  "fgWarning": {
@@ -170,7 +171,8 @@
170
171
  "hex": "#7e5400",
171
172
  "formula": "slot(warning) → L set(0.48), C cap(0.1013)",
172
173
  "scopes": [
173
- "text"
174
+ "text",
175
+ "border"
174
176
  ]
175
177
  },
176
178
  "fgDanger": {
@@ -574,6 +576,11 @@
574
576
  "tooltip": 1100
575
577
  }
576
578
  },
579
+ "focusRing": {
580
+ "width": 2,
581
+ "offset": 2,
582
+ "offsetInset": -2
583
+ },
577
584
  "scopes": {
578
585
  "space": [
579
586
  "gap"
@@ -0,0 +1,15 @@
1
+ import type { ThemeDef } from "./dsl/types.js";
2
+ export interface ScopeViolation {
3
+ component: string;
4
+ key: string;
5
+ group: string;
6
+ token: string;
7
+ scopes: readonly string[];
8
+ }
9
+ /** D46 primary gate: every component token whose key declares a property
10
+ * group (-bg/-fg/-border) may only bind semantic tokens scoped to it. */
11
+ export declare function checkScopes(componentVars: Record<string, Record<string, string>>, theme: ThemeDef): ScopeViolation[];
12
+ /** Same gate for brand componentOverrides ("button-accent-bg": "..."):
13
+ * component resolved by longest known prefix; unknown prefixes are skipped
14
+ * (non-color overrides like media-tint carry no component token shape). */
15
+ export declare function checkOverrideScopes(overrides: Record<string, string>, componentVars: Record<string, Record<string, string>>, theme: ThemeDef): ScopeViolation[];
@@ -0,0 +1,87 @@
1
+ import { PROPERTY_GROUPS, expandScopes, keyGroup } from "./scopes.js";
2
+ const VAR_RE = /var\((--psi-[a-z0-9-]+)/g;
3
+ const SCALE_RE = /^--psi-(space|size|radius|text|font|duration|ease|z)-/;
4
+ const camelToKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
5
+ /** Collect the semantic token names (camelCase) a component-token value
6
+ * ultimately binds, following component-token chains across the whole
7
+ * registry (own family and cross-family, e.g. dialog-bg → surface-bg).
8
+ * Unresolvable --psi- refs are returned as raw var names (prefixed "!")
9
+ * for the caller to flag. */
10
+ function semanticRefs(component, value, all, kebabToName, seen = new Set()) {
11
+ const out = [];
12
+ for (const m of value.matchAll(VAR_RE)) {
13
+ const varName = m[1];
14
+ const bare = varName.slice("--psi-".length);
15
+ if (SCALE_RE.test(varName))
16
+ continue;
17
+ // Component refs resolve through the registry; longest prefix wins.
18
+ const owner = Object.keys(all)
19
+ .filter((c) => bare.startsWith(`${c}-`))
20
+ .sort((a, b) => b.length - a.length)[0];
21
+ if (owner !== undefined) {
22
+ const key = bare.slice(owner.length + 1);
23
+ const guard = `${owner}:${key}`;
24
+ if (seen.has(guard))
25
+ continue;
26
+ seen.add(guard);
27
+ const ownerValue = all[owner][key];
28
+ if (ownerValue === undefined) {
29
+ out.push(`!${varName}`);
30
+ continue;
31
+ }
32
+ out.push(...semanticRefs(owner, ownerValue, all, kebabToName, seen));
33
+ continue;
34
+ }
35
+ const name = kebabToName.get(bare);
36
+ out.push(name ?? `!${varName}`);
37
+ }
38
+ return out;
39
+ }
40
+ function checkOne(component, key, value, all, theme, kebabToName) {
41
+ const group = keyGroup(key);
42
+ if (!group)
43
+ return [];
44
+ const groupProps = new Set(PROPERTY_GROUPS[group]);
45
+ const violations = [];
46
+ for (const name of semanticRefs(component, value, all, kebabToName)) {
47
+ if (name.startsWith("!")) {
48
+ violations.push({ component, key, group, token: name.slice(1), scopes: [] });
49
+ continue;
50
+ }
51
+ const scopes = theme[name]?.scopes;
52
+ if (scopes === undefined)
53
+ continue; // unscoped tokens are valid everywhere
54
+ const allowed = scopes.includes(group) || expandScopes(scopes).some((p) => groupProps.has(p));
55
+ if (!allowed)
56
+ violations.push({ component, key, group, token: name, scopes });
57
+ }
58
+ return violations;
59
+ }
60
+ /** D46 primary gate: every component token whose key declares a property
61
+ * group (-bg/-fg/-border) may only bind semantic tokens scoped to it. */
62
+ export function checkScopes(componentVars, theme) {
63
+ const kebabToName = new Map(Object.keys(theme).map((n) => [camelToKebab(n), n]));
64
+ const violations = [];
65
+ for (const [component, vars] of Object.entries(componentVars)) {
66
+ for (const [key, value] of Object.entries(vars)) {
67
+ violations.push(...checkOne(component, key, value, componentVars, theme, kebabToName));
68
+ }
69
+ }
70
+ return violations;
71
+ }
72
+ /** Same gate for brand componentOverrides ("button-accent-bg": "..."):
73
+ * component resolved by longest known prefix; unknown prefixes are skipped
74
+ * (non-color overrides like media-tint carry no component token shape). */
75
+ export function checkOverrideScopes(overrides, componentVars, theme) {
76
+ const kebabToName = new Map(Object.keys(theme).map((n) => [camelToKebab(n), n]));
77
+ const components = Object.keys(componentVars).sort((a, b) => b.length - a.length);
78
+ const violations = [];
79
+ for (const [fullKey, value] of Object.entries(overrides)) {
80
+ const component = components.find((c) => fullKey.startsWith(`${c}-`));
81
+ if (!component)
82
+ continue;
83
+ const key = fullKey.slice(component.length + 1);
84
+ violations.push(...checkOne(component, key, value, componentVars, theme, kebabToName));
85
+ }
86
+ return violations;
87
+ }
@@ -5,7 +5,8 @@
5
5
  "fill",
6
6
  "stroke",
7
7
  "caret-color",
8
- "text-decoration-color"
8
+ "text-decoration-color",
9
+ "background-image"
9
10
  ],
10
11
  "surface": [
11
12
  "background",
@@ -106,10 +107,12 @@
106
107
  "text"
107
108
  ],
108
109
  "fg-success": [
109
- "text"
110
+ "text",
111
+ "border"
110
112
  ],
111
113
  "fg-warning": [
112
- "text"
114
+ "text",
115
+ "border"
113
116
  ],
114
117
  "fg-danger": [
115
118
  "text",
@@ -190,6 +193,39 @@
190
193
  ]
191
194
  },
192
195
  "component": {
196
+ "app-shell-sidebar-bg": [
197
+ "surface"
198
+ ],
199
+ "app-shell-sidebar-fg": [
200
+ "text"
201
+ ],
202
+ "banner-bg-neutral": [
203
+ "surface"
204
+ ],
205
+ "banner-bg-success": [
206
+ "surface"
207
+ ],
208
+ "banner-bg-warning": [
209
+ "surface"
210
+ ],
211
+ "banner-bg-danger": [
212
+ "surface"
213
+ ],
214
+ "banner-fg": [
215
+ "text"
216
+ ],
217
+ "banner-icon-fg-neutral": [
218
+ "text"
219
+ ],
220
+ "banner-icon-fg-success": [
221
+ "text"
222
+ ],
223
+ "banner-icon-fg-warning": [
224
+ "text"
225
+ ],
226
+ "banner-icon-fg-danger": [
227
+ "text"
228
+ ],
193
229
  "button-accent-bg": [
194
230
  "surface"
195
231
  ],
@@ -340,6 +376,9 @@
340
376
  "field-marker-fg": [
341
377
  "text"
342
378
  ],
379
+ "field-required-text-fg": [
380
+ "text"
381
+ ],
343
382
  "input-bg": [
344
383
  "surface"
345
384
  ],
@@ -355,6 +394,45 @@
355
394
  "input-border-error": [
356
395
  "border"
357
396
  ],
397
+ "inline-alert-bg-neutral": [
398
+ "surface"
399
+ ],
400
+ "inline-alert-bg-success": [
401
+ "surface"
402
+ ],
403
+ "inline-alert-bg-warning": [
404
+ "surface"
405
+ ],
406
+ "inline-alert-bg-danger": [
407
+ "surface"
408
+ ],
409
+ "inline-alert-fg": [
410
+ "text"
411
+ ],
412
+ "inline-alert-icon-fg-neutral": [
413
+ "text"
414
+ ],
415
+ "inline-alert-icon-fg-success": [
416
+ "text"
417
+ ],
418
+ "inline-alert-icon-fg-warning": [
419
+ "text"
420
+ ],
421
+ "inline-alert-icon-fg-danger": [
422
+ "text"
423
+ ],
424
+ "inline-alert-border-neutral": [
425
+ "border"
426
+ ],
427
+ "inline-alert-border-success": [
428
+ "border"
429
+ ],
430
+ "inline-alert-border-warning": [
431
+ "border"
432
+ ],
433
+ "inline-alert-border-danger": [
434
+ "border"
435
+ ],
358
436
  "menu-bg": [
359
437
  "surface"
360
438
  ],
@@ -421,6 +499,33 @@
421
499
  "select-chevron-fg": [
422
500
  "text"
423
501
  ],
502
+ "skeleton-bg": [
503
+ "surface"
504
+ ],
505
+ "nav-tree-item-fg": [
506
+ "text"
507
+ ],
508
+ "nav-tree-item-fg-current": [
509
+ "text"
510
+ ],
511
+ "nav-tree-item-bg-hover": [
512
+ "surface"
513
+ ],
514
+ "nav-tree-item-bg-current": [
515
+ "surface"
516
+ ],
517
+ "nav-tree-group-fg": [
518
+ "text"
519
+ ],
520
+ "skip-link-bg": [
521
+ "surface"
522
+ ],
523
+ "skip-link-fg": [
524
+ "text"
525
+ ],
526
+ "skip-link-border": [
527
+ "border"
528
+ ],
424
529
  "surface-bg": [
425
530
  "surface"
426
531
  ],
@@ -0,0 +1,6 @@
1
+ export declare const PROPERTY_GROUPS: Record<string, readonly string[]>;
2
+ export declare const SUFFIX_GROUPS: Record<string, string>;
3
+ export declare const SCALE_SCOPES: Record<string, readonly string[]>;
4
+ export declare function isKnownScopeEntry(entry: string): boolean;
5
+ export declare function expandScopes(scopes: readonly string[]): readonly string[];
6
+ export declare function keyGroup(key: string): string | undefined;
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
+ }
@@ -17,8 +17,8 @@ export const darkTheme = {
17
17
  // tokens were already clamping to, so rendered hex is unchanged. fgSuccess
18
18
  // keeps the loose cap: emerald at l 0.75 is already in-gamut.
19
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"] }),
20
+ fgSuccess: token({ from: slot.success, l: set(0.75), c: cap(0.23), scopes: ["text", "border"] }),
21
+ fgWarning: token({ from: slot.warning, l: set(0.75), c: cap(0.1582), scopes: ["text", "border"] }),
22
22
  fgDanger: token({ from: slot.danger, l: set(0.75), c: cap(0.1505), scopes: ["text", "border"] }),
23
23
  fillNeutral1: token({ from: slot.ink, l: set(0.18), c: set(0.012), scopes: ["surface"] }),
24
24
  fillNeutral2: token({ from: slot.ink, l: set(0.2), c: set(0.015), scopes: ["surface"] }),
@@ -16,8 +16,10 @@ export const lightTheme = {
16
16
  fgAccent: token({ from: slot.accent, l: set(0.48), c: cap(0.23), scopes: ["text"] }),
17
17
  // D39: status-fg caps lowered to each hue's in-gamut max at l 0.48 — the
18
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"] }),
19
+ // text + border on all three status foregrounds: fgDanger for Input's error
20
+ // border (D46), fgSuccess and fgWarning for InlineAlert's (D86).
21
+ fgSuccess: token({ from: slot.success, l: set(0.48), c: cap(0.1187), scopes: ["text", "border"] }),
22
+ fgWarning: token({ from: slot.warning, l: set(0.48), c: cap(0.1013), scopes: ["text", "border"] }),
21
23
  fgDanger: token({ from: slot.danger, l: set(0.48), c: cap(0.1946), scopes: ["text", "border"] }),
22
24
  fillNeutral1: token({ from: slot.canvas, l: delta(+0.016), c: delta(-0.001), scopes: ["surface"] }),
23
25
  fillNeutral2: token({ from: slot.canvas, scopes: ["surface"] }),
package/llms.txt CHANGED
@@ -26,6 +26,11 @@ OKLCH formula-based design tokens. Code is the source of truth; all artifacts ge
26
26
  - --psi-z-sticky (1), --psi-z-nav (100), --psi-z-overlay (1000), --psi-z-tooltip (1100). sticky is for elements that stack within their own local scroll container (e.g. a sticky table header), not above page chrome.
27
27
  - Breakpoints (sm 560 / md 960) are build-time constants (D31), not CSS vars: `import { breakpoints } from "@handamade/psi-tokens/types"` for any @media-driven JS/CSS-in-JS.
28
28
 
29
+ ## Focus ring (--psi-focus-ring-*)
30
+ - One ring for every focusable part (D82): `outline: var(--psi-focus-ring-width) solid <colour>` on `:focus-visible`, never on `:focus`, never a literal width or offset.
31
+ - --psi-focus-ring-width (2px). --psi-focus-ring-offset (2px) draws the ring outside the control. --psi-focus-ring-offset-inset (-2px) draws it inside, for a control whose outside ring would be clipped or would land on a neighbour: a text field, a tab, a menu item, a row in a list, a panel against the viewport edge.
32
+ - The colour is the component's own --psi-{component}-focus-ring in component CSS, and --psi-border-focus in app CSS. The three geometry tokens are independent px values, so an override on any sub-tree takes effect there; the inset offset is not derived from the width, so override both together.
33
+
29
34
  ## Typography: display, serif, mono
30
35
  - Fluid display tier: --psi-display-{min}-{max}-{weight} (e.g. --psi-display-56-128-black), clamp-based and pixel-true at both endpoints; pair with the matching .psi-display-* utility class for tracking/uppercase.
31
36
  - Serif and mono are first-class combo roles alongside sans: --psi-text-serif-{size}-{lh}-{weight} and --psi-text-mono-{size}-{lh}-{weight}, same naming shape as the sans scale.
@@ -61,4 +66,11 @@ OKLCH formula-based design tokens. Code is the source of truth; all artifacts ge
61
66
  - 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
67
  - `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
68
 
69
+ ## Building a theme outside this repo (`./theme`, D81)
70
+ - `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.
71
+ - Use it to keep a client brand in the client's repository: nothing client-named has to ship in this public package.
72
+ - 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.
73
+ - `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.
74
+ - The token builders (`token`, `set`, `delta`, `cap`, `slot`, `ref`) and the definition types are re-exported from `./theme` for writing `overrides`.
75
+
64
76
  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.19.0",
3
+ "version": "0.21.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,8 +16,15 @@
16
16
  "types": "./dist/generate/index.d.ts",
17
17
  "import": "./dist/generate/index.js"
18
18
  },
19
+ "./theme": {
20
+ "types": "./dist/theme/index.d.ts",
21
+ "import": "./dist/theme/index.js"
22
+ },
19
23
  "./guidance.json": "./dist/guidance.json"
20
24
  },
25
+ "bin": {
26
+ "psi-theme": "./dist/cli/psi-theme.js"
27
+ },
21
28
  "files": [
22
29
  "dist",
23
30
  "README.md",
@@ -27,6 +34,7 @@
27
34
  "culori": "^4.0.0"
28
35
  },
29
36
  "devDependencies": {
37
+ "@types/node": "^24.19.0",
30
38
  "tsx": "^4.19.0"
31
39
  },
32
40
  "scripts": {