@handamade/psi-tokens 0.19.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 (64) hide show
  1. package/README.md +19 -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/dsl/validator.d.ts +12 -0
  49. package/dist/dsl/validator.js +97 -0
  50. package/dist/emit/css.d.ts +30 -0
  51. package/dist/emit/css.js +120 -0
  52. package/dist/gamut.d.ts +4 -0
  53. package/dist/gamut.js +44 -0
  54. package/dist/scope-gate.d.ts +15 -0
  55. package/dist/scope-gate.js +87 -0
  56. package/dist/scope-map.json +2 -1
  57. package/dist/scopes.d.ts +6 -0
  58. package/dist/scopes.js +88 -0
  59. package/dist/theme/gate.d.ts +19 -0
  60. package/dist/theme/gate.js +44 -0
  61. package/dist/theme/index.d.ts +16 -0
  62. package/dist/theme/index.js +23 -0
  63. package/llms.txt +7 -0
  64. package/package.json +9 -1
@@ -0,0 +1,30 @@
1
+ /** Table component tokens (--psi-table-*) — D62. Pure indirection, same
2
+ * posture as menu.ts (D53) and panel.ts: the container aliases the shared
3
+ * surface family, row states reuse Menu's item recipe, and per-size geometry
4
+ * aliases the D54/D55 control ramp so a 32px row and a 32px Button are the
5
+ * same 32px from one source. A brand retuning --psi-surface-* or the control
6
+ * ramp gets Table for free.
7
+ *
8
+ * row-bg-selected is --psi-fill-tint-accent, the same wash Button and Tag use
9
+ * for accent-subtle. Note --psi-fill-accent2 does NOT exist; do not write it.
10
+ * Geometry keys carry no bg/fg/border segment, so keyGroup() returns undefined
11
+ * and they stay out of scope-map.json and both D46 gates. */
12
+ export const tableVars = {
13
+ bg: "var(--psi-surface-bg)",
14
+ border: "var(--psi-surface-border)",
15
+ radius: "var(--psi-surface-radius)",
16
+ fg: "var(--psi-fg-primary)",
17
+ "header-fg": "var(--psi-fg-secondary)",
18
+ "cell-border": "var(--psi-border-faint)",
19
+ "row-bg": "transparent",
20
+ "row-bg-hover": "var(--psi-fill-neutral3)",
21
+ "row-bg-selected": "var(--psi-fill-tint-accent)",
22
+ "sort-indicator-fg": "var(--psi-fg-accent)",
23
+ "focus-ring": "var(--psi-border-focus)",
24
+ "32-row-height": "var(--psi-control-32-height)",
25
+ "40-row-height": "var(--psi-control-40-height)",
26
+ "48-row-height": "var(--psi-control-48-height)",
27
+ "32-cell-padding-x": "var(--psi-control-32-padding-inline)",
28
+ "40-cell-padding-x": "var(--psi-control-40-padding-inline)",
29
+ "48-cell-padding-x": "var(--psi-control-48-padding-inline)",
30
+ };
@@ -0,0 +1,14 @@
1
+ /** Tabs component tokens (--psi-tabs-*) — D67. Pure indirection, same posture
2
+ * as menu.ts (D53), table.ts (D62) and toast.ts (D64): colour aliases the
3
+ * semantic layer, geometry aliases the D54/D55 control ramp so a 32px tab and
4
+ * a 32px Button are the same 32px from one source.
5
+ *
6
+ * No new contrast pairs: fgAccent and fgSecondary on bgPrimary are already in
7
+ * wcagAAPairs, and the geometry keys carry no bg/fg/border segment so
8
+ * keyGroup() returns undefined and they stay out of both D46 gates.
9
+ *
10
+ * fg-disabled is deliberately NOT contrast-gated. Disabled text is exempt from
11
+ * WCAG 1.4.3, and gating it would force a contrast that defeats the
12
+ * affordance — the same reasoning behind Menu's item-fg-disabled, which binds
13
+ * the same token. */
14
+ export declare const tabsVars: Record<string, string>;
@@ -0,0 +1,26 @@
1
+ /** Tabs component tokens (--psi-tabs-*) — D67. Pure indirection, same posture
2
+ * as menu.ts (D53), table.ts (D62) and toast.ts (D64): colour aliases the
3
+ * semantic layer, geometry aliases the D54/D55 control ramp so a 32px tab and
4
+ * a 32px Button are the same 32px from one source.
5
+ *
6
+ * No new contrast pairs: fgAccent and fgSecondary on bgPrimary are already in
7
+ * wcagAAPairs, and the geometry keys carry no bg/fg/border segment so
8
+ * keyGroup() returns undefined and they stay out of both D46 gates.
9
+ *
10
+ * fg-disabled is deliberately NOT contrast-gated. Disabled text is exempt from
11
+ * WCAG 1.4.3, and gating it would force a contrast that defeats the
12
+ * affordance — the same reasoning behind Menu's item-fg-disabled, which binds
13
+ * the same token. */
14
+ export const tabsVars = {
15
+ fg: "var(--psi-fg-secondary)",
16
+ "fg-selected": "var(--psi-fg-accent)",
17
+ "fg-disabled": "var(--psi-fg-quaternary)",
18
+ "bg-hover": "var(--psi-fill-neutral3)",
19
+ indicator: "var(--psi-fill-accent)",
20
+ "list-border": "var(--psi-border-faint)",
21
+ "focus-ring": "var(--psi-border-focus)",
22
+ "32-height": "var(--psi-control-32-height)",
23
+ "40-height": "var(--psi-control-40-height)",
24
+ "32-padding-x": "var(--psi-control-32-padding-inline)",
25
+ "40-padding-x": "var(--psi-control-40-padding-inline)",
26
+ };
@@ -0,0 +1,4 @@
1
+ /** Tag component tokens (--psi-tag-*). Solid variants map to fill + foreground
2
+ * pairs; subtle variants add tint fills. Warning label uses fg-static-black
3
+ * (D22) instead of fg-primary for contrast and visual emphasis. */
4
+ export declare const tagVars: Record<string, string>;
@@ -0,0 +1,26 @@
1
+ /** Tag component tokens (--psi-tag-*). Solid variants map to fill + foreground
2
+ * pairs; subtle variants add tint fills. Warning label uses fg-static-black
3
+ * (D22) instead of fg-primary for contrast and visual emphasis. */
4
+ export const tagVars = {
5
+ "neutral-bg": "var(--psi-fill-neutral3)",
6
+ "neutral-fg": "var(--psi-fg-primary)",
7
+ "accent-bg": "var(--psi-fill-accent)",
8
+ "accent-fg": "var(--psi-fg-on-accent)",
9
+ "success-bg": "var(--psi-fill-success)",
10
+ "success-fg": "var(--psi-fg-static-white)",
11
+ "warning-bg": "var(--psi-fill-warning)",
12
+ "warning-fg": "var(--psi-fg-static-black)",
13
+ "danger-bg": "var(--psi-fill-danger)",
14
+ "danger-fg": "var(--psi-fg-static-white)",
15
+ "neutral-subtle-bg": "var(--psi-fill-neutral2)",
16
+ "neutral-subtle-fg": "var(--psi-fg-secondary)",
17
+ "accent-subtle-bg": "var(--psi-fill-tint-accent)",
18
+ "accent-subtle-fg": "var(--psi-fg-accent)",
19
+ "success-subtle-bg": "var(--psi-fill-tint-success)",
20
+ "success-subtle-fg": "var(--psi-fg-success)",
21
+ "warning-subtle-bg": "var(--psi-fill-tint-warning)",
22
+ "warning-subtle-fg": "var(--psi-fg-warning)",
23
+ "danger-subtle-bg": "var(--psi-fill-tint-danger)",
24
+ "danger-subtle-fg": "var(--psi-fg-danger)",
25
+ "focus-ring": "var(--psi-border-focus)",
26
+ };
@@ -0,0 +1,19 @@
1
+ /** Toast component tokens (--psi-toast-*) — D64/D65. Pure indirection, same
2
+ * posture as menu.ts (D53) and table.ts (D62): the card aliases the shared
3
+ * surface family, so a brand retuning --psi-surface-* gets Toast for free.
4
+ *
5
+ * The variant lives in the status icon's colour only — Toast is an elevated
6
+ * surface card, not a tinted one (a stack of three saturated cards is the
7
+ * loudest thing on the screen, and a tint fights the elevated-surface language
8
+ * every other overlay speaks). The tinted alternative was measured and also
9
+ * passes contrast; it is rejected on design grounds, not on the gate.
10
+ *
11
+ * Note there is NO shadow key: --psi-shadow-* does not exist in any form, and
12
+ * neither Menu nor Dialog declares a box-shadow. Psi's elevated surfaces are
13
+ * border + background. Do not write --psi-shadow-overlay.
14
+ *
15
+ * The three semantic icon foregrounds sit on bgSecondary (what --psi-surface-bg
16
+ * resolves to), so contrast-matrix.ts gates fgSuccess/fgWarning/fgDanger there
17
+ * as well as on bgPrimary. Tightest measured margin is 5.87 (light) against a
18
+ * 4.5 floor. */
19
+ export declare const toastVars: Record<string, string>;
@@ -0,0 +1,28 @@
1
+ /** Toast component tokens (--psi-toast-*) — D64/D65. Pure indirection, same
2
+ * posture as menu.ts (D53) and table.ts (D62): the card aliases the shared
3
+ * surface family, so a brand retuning --psi-surface-* gets Toast for free.
4
+ *
5
+ * The variant lives in the status icon's colour only — Toast is an elevated
6
+ * surface card, not a tinted one (a stack of three saturated cards is the
7
+ * loudest thing on the screen, and a tint fights the elevated-surface language
8
+ * every other overlay speaks). The tinted alternative was measured and also
9
+ * passes contrast; it is rejected on design grounds, not on the gate.
10
+ *
11
+ * Note there is NO shadow key: --psi-shadow-* does not exist in any form, and
12
+ * neither Menu nor Dialog declares a box-shadow. Psi's elevated surfaces are
13
+ * border + background. Do not write --psi-shadow-overlay.
14
+ *
15
+ * The three semantic icon foregrounds sit on bgSecondary (what --psi-surface-bg
16
+ * resolves to), so contrast-matrix.ts gates fgSuccess/fgWarning/fgDanger there
17
+ * as well as on bgPrimary. Tightest measured margin is 5.87 (light) against a
18
+ * 4.5 floor. */
19
+ export const toastVars = {
20
+ bg: "var(--psi-surface-bg)",
21
+ border: "var(--psi-surface-border)",
22
+ radius: "var(--psi-surface-radius)",
23
+ fg: "var(--psi-fg-primary)",
24
+ "icon-fg-neutral": "var(--psi-fg-secondary)",
25
+ "icon-fg-success": "var(--psi-fg-success)",
26
+ "icon-fg-warning": "var(--psi-fg-warning)",
27
+ "icon-fg-danger": "var(--psi-fg-danger)",
28
+ };
@@ -0,0 +1,13 @@
1
+ /** Toolbar component tokens (--psi-toolbar-*) — D77.
2
+ *
3
+ * A deliberate default width for Toolbar's direct, unwrapped form-control
4
+ * children (Input/Select used bare, as the filter-toolbar preset does) —
5
+ * without it, their own width:100% resolves as their flex-basis and the
6
+ * row stacks vertically instead of reading as a toolbar. Field-wrapped
7
+ * controls are unaffected: Field has no width of its own, so its flex-basis
8
+ * already comes from content. 200px is a standalone literal, matching
9
+ * Dialog's width={400|560|720} precedent — no shared scale reaches this
10
+ * range (sizeScale tops at 48, spacingScale at 144, both for heights/gaps
11
+ * not component widths).
12
+ */
13
+ export declare const toolbarVars: Record<string, string>;
@@ -0,0 +1,15 @@
1
+ /** Toolbar component tokens (--psi-toolbar-*) — D77.
2
+ *
3
+ * A deliberate default width for Toolbar's direct, unwrapped form-control
4
+ * children (Input/Select used bare, as the filter-toolbar preset does) —
5
+ * without it, their own width:100% resolves as their flex-basis and the
6
+ * row stacks vertically instead of reading as a toolbar. Field-wrapped
7
+ * controls are unaffected: Field has no width of its own, so its flex-basis
8
+ * already comes from content. 200px is a standalone literal, matching
9
+ * Dialog's width={400|560|720} precedent — no shared scale reaches this
10
+ * range (sizeScale tops at 48, spacingScale at 144, both for heights/gaps
11
+ * not component widths).
12
+ */
13
+ export const toolbarVars = {
14
+ "control-width": "200px",
15
+ };
@@ -0,0 +1,2 @@
1
+ /** Tooltip component tokens (--psi-tooltip-*). Inverted colors: dark bg, light fg. */
2
+ export declare const tooltipVars: Record<string, string>;
@@ -0,0 +1,7 @@
1
+ /** Tooltip component tokens (--psi-tooltip-*). Inverted colors: dark bg, light fg. */
2
+ export const tooltipVars = {
3
+ bg: "var(--psi-bg-inverted)",
4
+ fg: "var(--psi-fg-on-inverted)",
5
+ // D56: tracks a sharper theme down, never rounder than radius-6.
6
+ radius: "min(var(--psi-control-radius), var(--psi-radius-6))",
7
+ };
@@ -0,0 +1,12 @@
1
+ import type { SlotMap, ThemeDef } from "./types.js";
2
+ export declare class ValidationError extends Error {
3
+ readonly path: string[];
4
+ constructor(message: string, path?: string[]);
5
+ }
6
+ export declare function validate(theme: ThemeDef, slots: SlotMap, existingNames?: string[]): void;
7
+ /** D46: the same token name must carry identical scopes in every theme —
8
+ * scopes are a property of the token's meaning, not of a theme. */
9
+ export declare function validateScopeConsistency(themes: Record<string, ThemeDef>): void;
10
+ /** D46 follow-up (HAN-21): reject semantic token kebab names that a
11
+ * scale-family lookup would shadow before the semantic lookup runs. */
12
+ export declare function validateNoScalePrefixShadow(kebabNames: readonly string[]): void;
@@ -0,0 +1,97 @@
1
+ import { isKnownScopeEntry } from "../scopes.js";
2
+ // ── Validation error ───────────────────────────────────────────────
3
+ export class ValidationError extends Error {
4
+ path;
5
+ constructor(message, path = []) {
6
+ super(message);
7
+ this.name = "ValidationError";
8
+ this.path = path;
9
+ }
10
+ }
11
+ // ── Cycle detection ────────────────────────────────────────────────
12
+ function detectCycles(theme) {
13
+ const visited = new Set();
14
+ const inStack = new Set();
15
+ function dfs(name, chain) {
16
+ if (inStack.has(name)) {
17
+ const cycleStart = chain.indexOf(name);
18
+ const cycle = chain.slice(cycleStart).concat(name);
19
+ throw new ValidationError(`Circular reference detected: ${cycle.join(" → ")}`, cycle);
20
+ }
21
+ if (visited.has(name))
22
+ return;
23
+ inStack.add(name);
24
+ chain.push(name);
25
+ const token = theme[name];
26
+ if (token?.from.type === "ref") {
27
+ dfs(token.from.name, chain);
28
+ }
29
+ chain.pop();
30
+ inStack.delete(name);
31
+ visited.add(name);
32
+ }
33
+ for (const name of Object.keys(theme)) {
34
+ dfs(name, []);
35
+ }
36
+ }
37
+ // ── Main validator ─────────────────────────────────────────────────
38
+ export function validate(theme, slots, existingNames = []) {
39
+ const slotNames = new Set(Object.keys(slots));
40
+ const tokenNames = new Set(Object.keys(theme));
41
+ // Check for duplicate names against existingNames
42
+ for (const name of tokenNames) {
43
+ if (existingNames.includes(name)) {
44
+ throw new ValidationError(`Duplicate token name: "${name}" already exists`, [name]);
45
+ }
46
+ }
47
+ // Validate each token's source
48
+ for (const [name, token] of Object.entries(theme)) {
49
+ const { from } = token;
50
+ if (from.type === "slot") {
51
+ if (!slotNames.has(from.name)) {
52
+ throw new ValidationError(`Unknown slot "${from.name}" in token "${name}"`, [name]);
53
+ }
54
+ }
55
+ else if (from.type === "ref") {
56
+ if (!tokenNames.has(from.name)) {
57
+ throw new ValidationError(`Unknown ref "${from.name}" in token "${name}"`, [name]);
58
+ }
59
+ }
60
+ for (const entry of token.scopes ?? []) {
61
+ if (!isKnownScopeEntry(entry)) {
62
+ throw new ValidationError(`Unknown scope entry "${entry}" in token "${name}" — not a known CSS property or declared group`, [name]);
63
+ }
64
+ }
65
+ }
66
+ // Detect cycles in ref chains
67
+ detectCycles(theme);
68
+ }
69
+ /** D46: the same token name must carry identical scopes in every theme —
70
+ * scopes are a property of the token's meaning, not of a theme. */
71
+ export function validateScopeConsistency(themes) {
72
+ const seen = new Map();
73
+ for (const [themeName, theme] of Object.entries(themes)) {
74
+ for (const [name, def] of Object.entries(theme)) {
75
+ const key = JSON.stringify(def.scopes ?? null);
76
+ const prior = seen.get(name);
77
+ if (prior === undefined) {
78
+ seen.set(name, { theme: themeName, scopes: key });
79
+ }
80
+ else if (prior.scopes !== key) {
81
+ throw new ValidationError(`Scope drift for token "${name}": ${prior.theme} declares ${prior.scopes}, ${themeName} declares ${key}`, [name]);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ // Both gates resolve --psi-* names scale-first, so a semantic token whose
87
+ // kebab name starts with a scale-family prefix would be silently shadowed.
88
+ const SCALE_FAMILY_PREFIX = /^(space|size|radius|text|font|duration|ease|z)-/;
89
+ /** D46 follow-up (HAN-21): reject semantic token kebab names that a
90
+ * scale-family lookup would shadow before the semantic lookup runs. */
91
+ export function validateNoScalePrefixShadow(kebabNames) {
92
+ for (const name of kebabNames) {
93
+ if (SCALE_FAMILY_PREFIX.test(name)) {
94
+ throw new ValidationError(`Semantic token "${name}" starts with a scale-family prefix — it would be shadowed by the scale lookup in the build and stylelint gates`, [name]);
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,30 @@
1
+ import type { ChannelOp, Palette, SlotMap, ThemeDef, TokenDef } from "../dsl/types.js";
2
+ import type { BrandFonts } from "../themes/customers/index.js";
3
+ export declare function camelToKebab(str: string): string;
4
+ export declare function tokenVar(name: string): string;
5
+ export declare function paletteVar(name: string): string;
6
+ /**
7
+ * Convert a ChannelOp to its CSS representation within oklch(from ...).
8
+ * - set → literal value
9
+ * - delta → calc(channel +/- value)
10
+ * - cap → min(channel, value)
11
+ */
12
+ export declare function opToCSS(channel: string, op: ChannelOp): string;
13
+ /**
14
+ * Assemble the full oklch(from var(...) ...) expression for a token.
15
+ *
16
+ * Slot-sourced token with ops:
17
+ * oklch(from var(--psi-palette-sapphire) 0.65 min(c, 0.23) h)
18
+ *
19
+ * Ref-sourced token with alpha:
20
+ * oklch(from var(--psi-fg-primary) l c h / 0.7)
21
+ *
22
+ * No-op (just a slot reference, no ops, no alpha):
23
+ * var(--psi-palette-platinum)
24
+ */
25
+ export declare function tokenToLiveCSS(def: TokenDef, slots: SlotMap): string;
26
+ export declare function emitBaseCSS(palette: Palette): string;
27
+ export declare function emitThemeCSS(themeName: string, theme: ThemeDef, palette: Palette, slots: SlotMap, opts?: {
28
+ fonts?: BrandFonts;
29
+ componentOverrides?: Record<string, string>;
30
+ }): string;
@@ -0,0 +1,120 @@
1
+ // ── Helpers ────────────────────────────────────────────────────────
2
+ export function camelToKebab(str) {
3
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
4
+ }
5
+ export function tokenVar(name) {
6
+ return `--psi-${camelToKebab(name)}`;
7
+ }
8
+ export function paletteVar(name) {
9
+ return `--psi-palette-${name}`;
10
+ }
11
+ /**
12
+ * Convert a ChannelOp to its CSS representation within oklch(from ...).
13
+ * - set → literal value
14
+ * - delta → calc(channel +/- value)
15
+ * - cap → min(channel, value)
16
+ */
17
+ export function opToCSS(channel, op) {
18
+ switch (op.op) {
19
+ case "set":
20
+ return `${op.value}`;
21
+ case "delta": {
22
+ const sign = op.value >= 0 ? "+" : "-";
23
+ const abs = Math.abs(op.value);
24
+ return `calc(${channel} ${sign} ${abs})`;
25
+ }
26
+ case "cap":
27
+ return `min(${channel}, ${op.value})`;
28
+ }
29
+ }
30
+ /**
31
+ * Assemble the full oklch(from var(...) ...) expression for a token.
32
+ *
33
+ * Slot-sourced token with ops:
34
+ * oklch(from var(--psi-palette-sapphire) 0.65 min(c, 0.23) h)
35
+ *
36
+ * Ref-sourced token with alpha:
37
+ * oklch(from var(--psi-fg-primary) l c h / 0.7)
38
+ *
39
+ * No-op (just a slot reference, no ops, no alpha):
40
+ * var(--psi-palette-platinum)
41
+ */
42
+ export function tokenToLiveCSS(def, slots) {
43
+ const hasOps = def.l || def.c || def.h;
44
+ const hasAlpha = def.alpha !== undefined;
45
+ // Determine the source CSS var
46
+ let sourceVar;
47
+ if (def.from.type === "slot") {
48
+ const paletteName = slots[def.from.name];
49
+ sourceVar = `var(${paletteVar(paletteName)})`;
50
+ }
51
+ else {
52
+ sourceVar = `var(${tokenVar(def.from.name)})`;
53
+ }
54
+ // If no operations and no alpha, just reference the var directly
55
+ if (!hasOps && !hasAlpha) {
56
+ return sourceVar;
57
+ }
58
+ // Build oklch(from ...) expression
59
+ const lPart = def.l ? opToCSS("l", def.l) : "l";
60
+ const cPart = def.c ? opToCSS("c", def.c) : "c";
61
+ const hPart = def.h ? opToCSS("h", def.h) : "h";
62
+ let expr = `oklch(from ${sourceVar} ${lPart} ${cPart} ${hPart}`;
63
+ if (hasAlpha) {
64
+ expr += ` / ${def.alpha}`;
65
+ }
66
+ expr += `)`;
67
+ return expr;
68
+ }
69
+ // ── Base CSS (palette) ────────────────────────────────────────────
70
+ export function emitBaseCSS(palette) {
71
+ const lines = [];
72
+ lines.push("@layer psi.base, psi.theme, psi.components, psi.utilities;");
73
+ lines.push("");
74
+ lines.push("@layer psi.base {");
75
+ lines.push(" :root {");
76
+ for (const [name, entry] of Object.entries(palette)) {
77
+ lines.push(` ${paletteVar(name)}: oklch(${entry.l} ${entry.c} ${entry.h});`);
78
+ }
79
+ lines.push(" }");
80
+ lines.push("}");
81
+ return lines.join("\n") + "\n";
82
+ }
83
+ // ── Theme CSS (semantic tokens) ───────────────────────────────────
84
+ export function emitThemeCSS(themeName, theme, palette, slots, opts) {
85
+ const lines = [];
86
+ // Determine selector
87
+ const selector = themeName === "light"
88
+ ? `:root, [data-psi-theme="light"]`
89
+ : `[data-psi-theme="${themeName}"]`;
90
+ lines.push("@layer psi.theme {");
91
+ lines.push(` ${selector} {`);
92
+ // Scope this theme's own palette vars inside its selector block so a
93
+ // customer brand's colors ship only with that theme, and two customers
94
+ // that happen to reuse an anchor name can't collide in a shared :root.
95
+ for (const [name, entry] of Object.entries(palette)) {
96
+ lines.push(` ${paletteVar(name)}: oklch(${entry.l} ${entry.c} ${entry.h});`);
97
+ }
98
+ for (const [name, def] of Object.entries(theme)) {
99
+ const cssVar = tokenVar(name);
100
+ const value = tokenToLiveCSS(def, slots);
101
+ lines.push(` ${cssVar}: ${value};`);
102
+ }
103
+ if (opts?.fonts) {
104
+ for (const [role, stack] of Object.entries(opts.fonts)) {
105
+ lines.push(` --psi-font-${role}: ${stack};`);
106
+ }
107
+ }
108
+ lines.push(" }");
109
+ lines.push("}");
110
+ if (opts?.componentOverrides && Object.keys(opts.componentOverrides).length > 0) {
111
+ lines.push("@layer psi.components {");
112
+ lines.push(` [data-psi-theme="${themeName}"] {`);
113
+ for (const [name, value] of Object.entries(opts.componentOverrides)) {
114
+ lines.push(` --psi-${name}: ${value};`);
115
+ }
116
+ lines.push(" }");
117
+ lines.push("}");
118
+ }
119
+ return lines.join("\n") + "\n";
120
+ }
@@ -0,0 +1,4 @@
1
+ import type { Palette, SlotMap, ThemeDef } from "./dsl/types.js";
2
+ import type { ResolvedTheme } from "./dsl/resolver.js";
3
+ /** Warn when sRGB clamping moved a resolved value by ΔE2000 > 2 (D19). */
4
+ export declare function gamutWarnings(resolved: ResolvedTheme, theme: ThemeDef, palette: Palette, slots: SlotMap): string[];
package/dist/gamut.js ADDED
@@ -0,0 +1,44 @@
1
+ import { differenceCiede2000 } from "culori";
2
+ const dE = differenceCiede2000();
3
+ function applyOp(base, op) {
4
+ if (!op)
5
+ return base;
6
+ if (op.op === "set")
7
+ return op.value;
8
+ if (op.op === "delta")
9
+ return base + op.value;
10
+ return Math.min(base, op.value); // cap
11
+ }
12
+ /** Warn when sRGB clamping moved a resolved value by ΔE2000 > 2 (D19). */
13
+ export function gamutWarnings(resolved, theme, palette, slots) {
14
+ const warnings = [];
15
+ const raw = new Map();
16
+ // Recomputes pre-clamp OKLCH values by replaying token operations WITHOUT sRGB clamping.
17
+ // Unlike the resolver (which clamps at each step), this intentionally propagates unclamped values
18
+ // through ref chains so the warning reflects cumulative drift from authored intent. Do not "fix" this
19
+ // to match the resolver's clamping behavior — the unclamped result is required to diagnose palette issues.
20
+ function rawOf(name) {
21
+ const hit = raw.get(name);
22
+ if (hit)
23
+ return hit;
24
+ const def = theme[name];
25
+ const base = def.from.type === "slot"
26
+ ? palette[slots[def.from.name]]
27
+ : rawOf(def.from.name);
28
+ const val = {
29
+ l: applyOp(base.l, def.l),
30
+ c: applyOp(base.c, def.c),
31
+ h: applyOp(base.h, def.h),
32
+ };
33
+ raw.set(name, val);
34
+ return val;
35
+ }
36
+ for (const name of Object.keys(theme)) {
37
+ const pre = { mode: "oklch", ...rawOf(name) };
38
+ const post = { mode: "oklch", ...resolved[name].oklch };
39
+ const d = dE(pre, post);
40
+ if (d > 2)
41
+ warnings.push(`${name}: clamped ΔE ${d.toFixed(1)} (${JSON.stringify(rawOf(name))} → hex ${resolved[name].hex}) — adjust the palette anchor`);
42
+ }
43
+ return warnings;
44
+ }
@@ -0,0 +1,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",
@@ -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;