@handamade/psi-tokens 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acme.css CHANGED
@@ -40,6 +40,7 @@
40
40
  --psi-border-faint: oklch(from var(--psi-fg-primary) l c h / 0.08);
41
41
  --psi-border-neutral: oklch(from var(--psi-fg-primary) l c h / 0.15);
42
42
  --psi-border-strong: oklch(from var(--psi-fg-primary) l c h / 0.3);
43
+ --psi-border-control: oklch(from var(--psi-fg-primary) l c h / 0.55);
43
44
  --psi-border-focus: var(--psi-fg-accent);
44
45
  --psi-scrim-soft: oklch(from var(--psi-bg-primary) l c h / 0.25);
45
46
  --psi-scrim-medium: oklch(from var(--psi-bg-primary) l c h / 0.35);
@@ -2,9 +2,11 @@
2
2
  export const checkboxVars = {
3
3
  fg: "var(--psi-fg-primary)",
4
4
  "box-bg": "var(--psi-bg-primary)",
5
- "box-border": "var(--psi-border-neutral)",
5
+ "box-border": "var(--psi-border-control)",
6
6
  "box-bg-checked": "var(--psi-fill-accent)",
7
7
  "box-border-checked": "var(--psi-fill-accent)",
8
+ // D108: an unchecked, aria-invalid box, in the error colour Input uses.
9
+ "box-border-error": "var(--psi-fg-danger)",
8
10
  // D56: tracks a sharper theme down, never rounder than radius-4 — the box
9
11
  // is ~16px, so the 8px control default would read as a circle.
10
12
  "box-radius": "min(var(--psi-control-radius), var(--psi-radius-4))",
@@ -2,9 +2,10 @@
2
2
  :where(:root, [data-psi-theme]) {
3
3
  --psi-checkbox-fg: var(--psi-fg-primary);
4
4
  --psi-checkbox-box-bg: var(--psi-bg-primary);
5
- --psi-checkbox-box-border: var(--psi-border-neutral);
5
+ --psi-checkbox-box-border: var(--psi-border-control);
6
6
  --psi-checkbox-box-bg-checked: var(--psi-fill-accent);
7
7
  --psi-checkbox-box-border-checked: var(--psi-fill-accent);
8
+ --psi-checkbox-box-border-error: var(--psi-fg-danger);
8
9
  --psi-checkbox-box-radius: min(var(--psi-control-radius), var(--psi-radius-4));
9
10
  --psi-checkbox-check-fg: var(--psi-fg-on-accent);
10
11
  --psi-checkbox-focus-ring: var(--psi-border-focus);
@@ -0,0 +1,5 @@
1
+ /** RadioGroup / Radio component tokens (--psi-radio-group-*) — D104. One
2
+ * module serves both, so one prefix. The checkbox's recipe on a circle: the
3
+ * checked circle fills with the accent and shows a dot in fg-on-accent,
4
+ * exactly the checkbox's checked pair, already gated. No new contrast pair. */
5
+ export declare const radioGroupVars: Record<string, string>;
@@ -0,0 +1,15 @@
1
+ /** RadioGroup / Radio component tokens (--psi-radio-group-*) — D104. One
2
+ * module serves both, so one prefix. The checkbox's recipe on a circle: the
3
+ * checked circle fills with the accent and shows a dot in fg-on-accent,
4
+ * exactly the checkbox's checked pair, already gated. No new contrast pair. */
5
+ export const radioGroupVars = {
6
+ fg: "var(--psi-fg-primary)",
7
+ "circle-bg": "var(--psi-bg-primary)",
8
+ "circle-border": "var(--psi-border-control)",
9
+ "circle-bg-checked": "var(--psi-fill-accent)",
10
+ "circle-border-checked": "var(--psi-fill-accent)",
11
+ // D108: an unchecked, aria-invalid circle, in the error colour Input uses.
12
+ "circle-border-error": "var(--psi-fg-danger)",
13
+ "dot-fg": "var(--psi-fg-on-accent)",
14
+ "focus-ring": "var(--psi-border-focus)",
15
+ };
@@ -0,0 +1,12 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-radio-group-fg: var(--psi-fg-primary);
4
+ --psi-radio-group-circle-bg: var(--psi-bg-primary);
5
+ --psi-radio-group-circle-border: var(--psi-border-control);
6
+ --psi-radio-group-circle-bg-checked: var(--psi-fill-accent);
7
+ --psi-radio-group-circle-border-checked: var(--psi-fill-accent);
8
+ --psi-radio-group-circle-border-error: var(--psi-fg-danger);
9
+ --psi-radio-group-dot-fg: var(--psi-fg-on-accent);
10
+ --psi-radio-group-focus-ring: var(--psi-border-focus);
11
+ }
12
+ }
@@ -15,6 +15,7 @@ import { switchVars } from "./switch.js";
15
15
  import { tableVars } from "./table.js";
16
16
  import { tagVars } from "./tag.js";
17
17
  import { toolbarVars } from "./toolbar.js";
18
+ import { textVars } from "./text.js";
18
19
  import { tooltipVars } from "./tooltip.js";
19
20
  import { cardVars } from "./card.js";
20
21
  import { controlVars } from "./control.js";
@@ -27,6 +28,8 @@ import { toastVars } from "./toast.js";
27
28
  import { tabsVars } from "./tabs.js";
28
29
  import { fieldVars } from "./field.js";
29
30
  import { descriptionListVars } from "./description-list.js";
31
+ import { textareaVars } from "./textarea.js";
32
+ import { radioGroupVars } from "./radio-group.js";
30
33
  export const componentVars = {
31
34
  "app-shell": appShellVars,
32
35
  banner: bannerVars,
@@ -42,6 +45,7 @@ export const componentVars = {
42
45
  menu: menuVars,
43
46
  navbar: navbarVars,
44
47
  panel: panelVars,
48
+ "radio-group": radioGroupVars,
45
49
  select: selectVars,
46
50
  skeleton: skeletonVars,
47
51
  "nav-tree": navTreeVars,
@@ -52,7 +56,9 @@ export const componentVars = {
52
56
  tabs: tabsVars,
53
57
  "description-list": descriptionListVars,
54
58
  tag: tagVars,
59
+ textarea: textareaVars,
55
60
  toolbar: toolbarVars,
61
+ text: textVars,
56
62
  toast: toastVars,
57
63
  tooltip: tooltipVars,
58
64
  };
@@ -0,0 +1,6 @@
1
+ /** Text component tokens (--psi-text-fg*) — D109. Pure indirection onto the
2
+ * semantic layer. fgPrimary and fgSecondary are gated at 4.5 on every
3
+ * surface in wcagAAPairs, so no new contrast pair. The prefix shares
4
+ * `--psi-text-` with the type styles (`--psi-text-14-20-regular`); no type
5
+ * style is named `fg`, so the two cannot collide. */
6
+ export declare const textVars: Record<string, string>;
@@ -0,0 +1,9 @@
1
+ /** Text component tokens (--psi-text-fg*) — D109. Pure indirection onto the
2
+ * semantic layer. fgPrimary and fgSecondary are gated at 4.5 on every
3
+ * surface in wcagAAPairs, so no new contrast pair. The prefix shares
4
+ * `--psi-text-` with the type styles (`--psi-text-14-20-regular`); no type
5
+ * style is named `fg`, so the two cannot collide. */
6
+ export const textVars = {
7
+ fg: "var(--psi-fg-primary)",
8
+ "fg-secondary": "var(--psi-fg-secondary)",
9
+ };
@@ -0,0 +1,6 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-text-fg: var(--psi-fg-primary);
4
+ --psi-text-fg-secondary: var(--psi-fg-secondary);
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ /** Textarea component tokens (--psi-textarea-*) — D104. Pure aliases of the
2
+ * input's semantics and the 32px value ramp, so a multi-line field reads as
3
+ * the same control family as Input. Every value is an existing gated
4
+ * semantic; counter-fg is fg-secondary, gated at 4.5 on every surface. No
5
+ * size ramp: a textarea's height is its rows, not a control size. */
6
+ export declare const textareaVars: Record<string, string>;
@@ -0,0 +1,18 @@
1
+ /** Textarea component tokens (--psi-textarea-*) — D104. Pure aliases of the
2
+ * input's semantics and the 32px value ramp, so a multi-line field reads as
3
+ * the same control family as Input. Every value is an existing gated
4
+ * semantic; counter-fg is fg-secondary, gated at 4.5 on every surface. No
5
+ * size ramp: a textarea's height is its rows, not a control size. */
6
+ export const textareaVars = {
7
+ bg: "var(--psi-bg-primary)",
8
+ fg: "var(--psi-fg-primary)",
9
+ placeholder: "var(--psi-fg-tertiary)",
10
+ border: "var(--psi-border-neutral)",
11
+ "border-hover": "var(--psi-border-strong)",
12
+ "border-error": "var(--psi-fg-danger)",
13
+ "focus-ring": "var(--psi-border-focus)",
14
+ radius: "var(--psi-control-radius)",
15
+ "padding-inline": "var(--psi-control-value-32-padding-inline)",
16
+ font: "var(--psi-control-value-32-font)",
17
+ "counter-fg": "var(--psi-fg-secondary)",
18
+ };
@@ -0,0 +1,15 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-textarea-bg: var(--psi-bg-primary);
4
+ --psi-textarea-fg: var(--psi-fg-primary);
5
+ --psi-textarea-placeholder: var(--psi-fg-tertiary);
6
+ --psi-textarea-border: var(--psi-border-neutral);
7
+ --psi-textarea-border-hover: var(--psi-border-strong);
8
+ --psi-textarea-border-error: var(--psi-fg-danger);
9
+ --psi-textarea-focus-ring: var(--psi-border-focus);
10
+ --psi-textarea-radius: var(--psi-control-radius);
11
+ --psi-textarea-padding-inline: var(--psi-control-value-32-padding-inline);
12
+ --psi-textarea-font: var(--psi-control-value-32-font);
13
+ --psi-textarea-counter-fg: var(--psi-fg-secondary);
14
+ }
15
+ }
@@ -100,9 +100,10 @@
100
100
  :where(:root, [data-psi-theme]) {
101
101
  --psi-checkbox-fg: var(--psi-fg-primary);
102
102
  --psi-checkbox-box-bg: var(--psi-bg-primary);
103
- --psi-checkbox-box-border: var(--psi-border-neutral);
103
+ --psi-checkbox-box-border: var(--psi-border-control);
104
104
  --psi-checkbox-box-bg-checked: var(--psi-fill-accent);
105
105
  --psi-checkbox-box-border-checked: var(--psi-fill-accent);
106
+ --psi-checkbox-box-border-error: var(--psi-fg-danger);
106
107
  --psi-checkbox-box-radius: min(var(--psi-control-radius), var(--psi-radius-4));
107
108
  --psi-checkbox-check-fg: var(--psi-fg-on-accent);
108
109
  --psi-checkbox-focus-ring: var(--psi-border-focus);
@@ -257,6 +258,19 @@
257
258
  }
258
259
  }
259
260
 
261
+ @layer psi.components {
262
+ :where(:root, [data-psi-theme]) {
263
+ --psi-radio-group-fg: var(--psi-fg-primary);
264
+ --psi-radio-group-circle-bg: var(--psi-bg-primary);
265
+ --psi-radio-group-circle-border: var(--psi-border-control);
266
+ --psi-radio-group-circle-bg-checked: var(--psi-fill-accent);
267
+ --psi-radio-group-circle-border-checked: var(--psi-fill-accent);
268
+ --psi-radio-group-circle-border-error: var(--psi-fg-danger);
269
+ --psi-radio-group-dot-fg: var(--psi-fg-on-accent);
270
+ --psi-radio-group-focus-ring: var(--psi-border-focus);
271
+ }
272
+ }
273
+
260
274
  @layer psi.components {
261
275
  :where(:root, [data-psi-theme]) {
262
276
  --psi-select-bg: var(--psi-bg-primary);
@@ -411,6 +425,22 @@
411
425
  }
412
426
  }
413
427
 
428
+ @layer psi.components {
429
+ :where(:root, [data-psi-theme]) {
430
+ --psi-textarea-bg: var(--psi-bg-primary);
431
+ --psi-textarea-fg: var(--psi-fg-primary);
432
+ --psi-textarea-placeholder: var(--psi-fg-tertiary);
433
+ --psi-textarea-border: var(--psi-border-neutral);
434
+ --psi-textarea-border-hover: var(--psi-border-strong);
435
+ --psi-textarea-border-error: var(--psi-fg-danger);
436
+ --psi-textarea-focus-ring: var(--psi-border-focus);
437
+ --psi-textarea-radius: var(--psi-control-radius);
438
+ --psi-textarea-padding-inline: var(--psi-control-value-32-padding-inline);
439
+ --psi-textarea-font: var(--psi-control-value-32-font);
440
+ --psi-textarea-counter-fg: var(--psi-fg-secondary);
441
+ }
442
+ }
443
+
414
444
  @layer psi.components {
415
445
  :where(:root, [data-psi-theme]) {
416
446
  --psi-toolbar-control-width: 200px;
@@ -418,6 +448,13 @@
418
448
  }
419
449
  }
420
450
 
451
+ @layer psi.components {
452
+ :where(:root, [data-psi-theme]) {
453
+ --psi-text-fg: var(--psi-fg-primary);
454
+ --psi-text-fg-secondary: var(--psi-fg-secondary);
455
+ }
456
+ }
457
+
421
458
  @layer psi.components {
422
459
  :where(:root, [data-psi-theme]) {
423
460
  --psi-toast-bg: var(--psi-surface-bg);
@@ -32,3 +32,8 @@ export declare const wcagAAPairs: ContrastPair[];
32
32
  * button/tag label on its variant background ≥ 4.5"). Tint-variant labels are
33
33
  * already covered by the fg-on-tint pairs in wcagAAPairs. */
34
34
  export declare const componentLabelPairs: ContrastPair[];
35
+ /** WCAG 1.4.11 non-text contrast (D106): 3:1 for the visual information that
36
+ * identifies a control. An unchecked checkbox or radio is identified by its
37
+ * outline alone, against its own fill (bgPrimary) and the surface it sits on
38
+ * (bgPrimary on the canvas, bgSecondary on Panel, Dialog and Menu). */
39
+ export declare const nonTextPairs: ContrastPair[];
@@ -136,3 +136,11 @@ export const componentLabelPairs = [
136
136
  { fg: "fgStaticBlack", bg: "fillWarning", minRatio: 4.5 }, // Tag warning (D22)
137
137
  { fg: "fgOnInverted", bg: "bgInverted", minRatio: 4.5 }, // Tooltip label (D46 inversion pair)
138
138
  ];
139
+ /** WCAG 1.4.11 non-text contrast (D106): 3:1 for the visual information that
140
+ * identifies a control. An unchecked checkbox or radio is identified by its
141
+ * outline alone, against its own fill (bgPrimary) and the surface it sits on
142
+ * (bgPrimary on the canvas, bgSecondary on Panel, Dialog and Menu). */
143
+ export const nonTextPairs = [
144
+ { fg: "borderControl", bg: "bgPrimary", minRatio: 3 },
145
+ { fg: "borderControl", bg: "bgSecondary", minRatio: 3 },
146
+ ];
package/dist/dark.css CHANGED
@@ -40,6 +40,7 @@
40
40
  --psi-border-faint: oklch(from var(--psi-fg-primary) l c h / 0.08);
41
41
  --psi-border-neutral: oklch(from var(--psi-fg-primary) l c h / 0.15);
42
42
  --psi-border-strong: oklch(from var(--psi-fg-primary) l c h / 0.3);
43
+ --psi-border-control: oklch(from var(--psi-fg-primary) l c h / 0.4);
43
44
  --psi-border-focus: var(--psi-fg-accent);
44
45
  --psi-scrim-soft: oklch(from var(--psi-bg-primary) l c h / 0.25);
45
46
  --psi-scrim-medium: oklch(from var(--psi-bg-primary) l c h / 0.35);
@@ -420,6 +420,18 @@
420
420
  }
421
421
  }
422
422
  },
423
+ "control": {
424
+ "$type": "color",
425
+ "$value": "#3c28258c",
426
+ "$description": "ref(fgPrimary) → alpha(0.55)",
427
+ "$extensions": {
428
+ "psi": {
429
+ "scopes": [
430
+ "border"
431
+ ]
432
+ }
433
+ }
434
+ },
423
435
  "focus": {
424
436
  "$type": "color",
425
437
  "$value": "#b10b00",
@@ -420,6 +420,18 @@
420
420
  }
421
421
  }
422
422
  },
423
+ "control": {
424
+ "$type": "color",
425
+ "$value": "#e3e8ee66",
426
+ "$description": "ref(fgPrimary) → alpha(0.4)",
427
+ "$extensions": {
428
+ "psi": {
429
+ "scopes": [
430
+ "border"
431
+ ]
432
+ }
433
+ }
434
+ },
423
435
  "focus": {
424
436
  "$type": "color",
425
437
  "$value": "#7daeff",
@@ -420,6 +420,18 @@
420
420
  }
421
421
  }
422
422
  },
423
+ "control": {
424
+ "$type": "color",
425
+ "$value": "#f3ede666",
426
+ "$description": "ref(fgPrimary) → alpha(0.4)",
427
+ "$extensions": {
428
+ "psi": {
429
+ "scopes": [
430
+ "border"
431
+ ]
432
+ }
433
+ }
434
+ },
423
435
  "focus": {
424
436
  "$type": "color",
425
437
  "$value": "#ff885d",
@@ -420,6 +420,18 @@
420
420
  }
421
421
  }
422
422
  },
423
+ "control": {
424
+ "$type": "color",
425
+ "$value": "#222f3c8c",
426
+ "$description": "ref(fgPrimary) → alpha(0.55)",
427
+ "$extensions": {
428
+ "psi": {
429
+ "scopes": [
430
+ "border"
431
+ ]
432
+ }
433
+ }
434
+ },
423
435
  "focus": {
424
436
  "$type": "color",
425
437
  "$value": "#0053c9",
package/dist/ember.css CHANGED
@@ -40,6 +40,7 @@
40
40
  --psi-border-faint: oklch(from var(--psi-fg-primary) l c h / 0.08);
41
41
  --psi-border-neutral: oklch(from var(--psi-fg-primary) l c h / 0.15);
42
42
  --psi-border-strong: oklch(from var(--psi-fg-primary) l c h / 0.3);
43
+ --psi-border-control: oklch(from var(--psi-fg-primary) l c h / 0.4);
43
44
  --psi-border-focus: var(--psi-fg-accent);
44
45
  --psi-scrim-soft: oklch(from var(--psi-bg-primary) l c h / 0.25);
45
46
  --psi-scrim-medium: oklch(from var(--psi-bg-primary) l c h / 0.35);
@@ -1,4 +1,4 @@
1
- import { checkContrast, componentLabelPairs, wcagAAPairs } from "../contrast-matrix.js";
1
+ import { checkContrast, componentLabelPairs, nonTextPairs, wcagAAPairs } from "../contrast-matrix.js";
2
2
  import { resolve } from "../dsl/resolver.js";
3
3
  import { cap, set, slot, token } from "../dsl/builders.js";
4
4
  import { assembleCustomerTheme, } from "../themes/customers/index.js";
@@ -25,7 +25,10 @@ function withAlpha(hex, alpha) {
25
25
  }
26
26
  /**
27
27
  * The gate, and it must be the SAME set `scripts/build.ts` runs over every
28
- * committed theme: `[...wcagAAPairs, ...componentLabelPairs]`, 33 pairs.
28
+ * committed theme: `[...wcagAAPairs, ...componentLabelPairs, ...nonTextPairs]`,
29
+ * 38 pairs (this said 33 until D106; D62 and D86 had grown it). The two
30
+ * non-text pairs (D106) are not solved here: `borderControl` is a fixed alpha
31
+ * of `fgPrimary`, and the sweep test proves it holds.
29
32
  *
30
33
  * Checking only `wcagAAPairs` (28) is what let "a theme derived from any
31
34
  * prompt clears the AA matrix by construction" ship false a third time. The
@@ -40,7 +43,7 @@ function withAlpha(hex, alpha) {
40
43
  * `accentLightness` there for why the fix has to be upstream of this loop
41
44
  * rather than another escalating target inside it).
42
45
  */
43
- const AA_MATRIX = [...wcagAAPairs, ...componentLabelPairs];
46
+ const AA_MATRIX = [...wcagAAPairs, ...componentLabelPairs, ...nonTextPairs];
44
47
  const BASE_TARGET = 4.5;
45
48
  const TARGET_ESCALATION = 0.1;
46
49
  const MAX_OUTER_ATTEMPTS = 40;
@@ -26,7 +26,7 @@ const ACCENT_L = 0.55;
26
26
  * renders white-on-accent at **4.4979:1** — under AA, and the exact failure
27
27
  * the prompt "bold calm app" produced. It cleared CI only because `derive.ts`
28
28
  * checked the 28-pair `wcagAAPairs` while `scripts/build.ts` gates committed
29
- * themes on all 33.
29
+ * themes on the full matrix.
30
30
  *
31
31
  * So the accent is darkened — never lightened — to the lightest value that
32
32
  * still carries a white label at `LABEL_TARGET`, a deliberate margin above
@@ -45,7 +45,7 @@ const ACCENT_L = 0.55;
45
45
  * `fillDanger`, `fgStaticBlack` on `fillWarning`) sit on fixed hues and were
46
46
  * measured at 5.16 / 5.21 / 6.94:1 worst-case across the same sweep, so they
47
47
  * need no equivalent treatment — but the sweep in
48
- * `__tests__/generate-derive.test.ts` gates all 33 pairs, not just this one.
48
+ * `__tests__/generate-derive.test.ts` gates the full matrix, not just this one.
49
49
  */
50
50
  const LABEL_TARGET = 4.65;
51
51
  function accentLightness(c, h) {
package/dist/light.css CHANGED
@@ -40,6 +40,7 @@
40
40
  --psi-border-faint: oklch(from var(--psi-fg-primary) l c h / 0.08);
41
41
  --psi-border-neutral: oklch(from var(--psi-fg-primary) l c h / 0.15);
42
42
  --psi-border-strong: oklch(from var(--psi-fg-primary) l c h / 0.3);
43
+ --psi-border-control: oklch(from var(--psi-fg-primary) l c h / 0.55);
43
44
  --psi-border-focus: var(--psi-fg-accent);
44
45
  --psi-scrim-soft: oklch(from var(--psi-bg-primary) l c h / 0.25);
45
46
  --psi-scrim-medium: oklch(from var(--psi-bg-primary) l c h / 0.35);
@@ -431,6 +431,20 @@
431
431
  "border"
432
432
  ]
433
433
  },
434
+ "borderControl": {
435
+ "name": "borderControl",
436
+ "oklch": {
437
+ "l": 0.3,
438
+ "c": 0.03,
439
+ "h": 30,
440
+ "alpha": 0.55
441
+ },
442
+ "hex": "#3c2825",
443
+ "formula": "ref(fgPrimary) → alpha(0.55)",
444
+ "scopes": [
445
+ "border"
446
+ ]
447
+ },
434
448
  "borderFocus": {
435
449
  "name": "borderFocus",
436
450
  "oklch": {
@@ -431,6 +431,20 @@
431
431
  "border"
432
432
  ]
433
433
  },
434
+ "borderControl": {
435
+ "name": "borderControl",
436
+ "oklch": {
437
+ "l": 0.93,
438
+ "c": 0.01,
439
+ "h": 250,
440
+ "alpha": 0.4
441
+ },
442
+ "hex": "#e3e8ee",
443
+ "formula": "ref(fgPrimary) → alpha(0.4)",
444
+ "scopes": [
445
+ "border"
446
+ ]
447
+ },
434
448
  "borderFocus": {
435
449
  "name": "borderFocus",
436
450
  "oklch": {
@@ -431,6 +431,20 @@
431
431
  "border"
432
432
  ]
433
433
  },
434
+ "borderControl": {
435
+ "name": "borderControl",
436
+ "oklch": {
437
+ "l": 0.949,
438
+ "c": 0.011,
439
+ "h": 72,
440
+ "alpha": 0.4
441
+ },
442
+ "hex": "#f3ede6",
443
+ "formula": "ref(fgPrimary) → alpha(0.4)",
444
+ "scopes": [
445
+ "border"
446
+ ]
447
+ },
434
448
  "borderFocus": {
435
449
  "name": "borderFocus",
436
450
  "oklch": {
@@ -431,6 +431,20 @@
431
431
  "border"
432
432
  ]
433
433
  },
434
+ "borderControl": {
435
+ "name": "borderControl",
436
+ "oklch": {
437
+ "l": 0.3,
438
+ "c": 0.03,
439
+ "h": 250,
440
+ "alpha": 0.55
441
+ },
442
+ "hex": "#222f3c",
443
+ "formula": "ref(fgPrimary) → alpha(0.55)",
444
+ "scopes": [
445
+ "border"
446
+ ]
447
+ },
434
448
  "borderFocus": {
435
449
  "name": "borderFocus",
436
450
  "oklch": {
@@ -173,6 +173,9 @@
173
173
  "border-strong": [
174
174
  "border"
175
175
  ],
176
+ "border-control": [
177
+ "border"
178
+ ],
176
179
  "border-focus": [
177
180
  "border"
178
181
  ],
@@ -349,6 +352,9 @@
349
352
  "checkbox-box-border-checked": [
350
353
  "border"
351
354
  ],
355
+ "checkbox-box-border-error": [
356
+ "border"
357
+ ],
352
358
  "checkbox-check-fg": [
353
359
  "text"
354
360
  ],
@@ -484,6 +490,27 @@
484
490
  "panel-border": [
485
491
  "border"
486
492
  ],
493
+ "radio-group-fg": [
494
+ "text"
495
+ ],
496
+ "radio-group-circle-bg": [
497
+ "surface"
498
+ ],
499
+ "radio-group-circle-border": [
500
+ "border"
501
+ ],
502
+ "radio-group-circle-bg-checked": [
503
+ "surface"
504
+ ],
505
+ "radio-group-circle-border-checked": [
506
+ "border"
507
+ ],
508
+ "radio-group-circle-border-error": [
509
+ "border"
510
+ ],
511
+ "radio-group-dot-fg": [
512
+ "text"
513
+ ],
487
514
  "select-bg": [
488
515
  "surface"
489
516
  ],
@@ -655,6 +682,30 @@
655
682
  "tag-danger-subtle-fg": [
656
683
  "text"
657
684
  ],
685
+ "textarea-bg": [
686
+ "surface"
687
+ ],
688
+ "textarea-fg": [
689
+ "text"
690
+ ],
691
+ "textarea-border": [
692
+ "border"
693
+ ],
694
+ "textarea-border-hover": [
695
+ "border"
696
+ ],
697
+ "textarea-border-error": [
698
+ "border"
699
+ ],
700
+ "textarea-counter-fg": [
701
+ "text"
702
+ ],
703
+ "text-fg": [
704
+ "text"
705
+ ],
706
+ "text-fg-secondary": [
707
+ "text"
708
+ ],
658
709
  "toast-bg": [
659
710
  "surface"
660
711
  ],
@@ -1,7 +1,7 @@
1
1
  import { validate } from "../dsl/validator.js";
2
2
  import { resolve } from "../dsl/resolver.js";
3
3
  import { gamutWarnings } from "../gamut.js";
4
- import { checkContrast, wcagAAPairs, componentLabelPairs } from "../contrast-matrix.js";
4
+ import { checkContrast, wcagAAPairs, componentLabelPairs, nonTextPairs } from "../contrast-matrix.js";
5
5
  import { checkScopes, checkOverrideScopes } from "../scope-gate.js";
6
6
  import { componentVars } from "../components/registry.js";
7
7
  /** Thrown when a theme fails the WCAG AA contrast gate or the D46 scope gate. */
@@ -32,7 +32,7 @@ export function gateTheme(name, theme, palette, slots, componentOverrides = {})
32
32
  validate(theme, slots);
33
33
  const resolved = resolve(theme, palette, slots);
34
34
  const warnings = gamutWarnings(resolved, theme, palette, slots);
35
- const contrastFailures = checkContrast(resolved, [...wcagAAPairs, ...componentLabelPairs]).filter((r) => !r.pass);
35
+ const contrastFailures = checkContrast(resolved, [...wcagAAPairs, ...componentLabelPairs, ...nonTextPairs]).filter((r) => !r.pass);
36
36
  const scopeViolations = [
37
37
  ...checkScopes(componentVars, theme),
38
38
  ...checkOverrideScopes(componentOverrides, componentVars, theme),
@@ -41,6 +41,10 @@ export const darkTheme = {
41
41
  borderFaint: token({ from: ref.fgPrimary, alpha: 0.08, scopes: ["border"] }),
42
42
  borderNeutral: token({ from: ref.fgPrimary, alpha: 0.15, scopes: ["border"] }),
43
43
  borderStrong: token({ from: ref.fgPrimary, alpha: 0.3, scopes: ["border"] }),
44
+ /** D106: the outline that alone identifies an unchecked checkbox or radio,
45
+ * so WCAG 1.4.11 asks 3:1 for it (`nonTextPairs`). borderNeutral measured
46
+ * 1.32-1.48. Alpha chosen by sweep over every derivable theme. */
47
+ borderControl: token({ from: ref.fgPrimary, alpha: 0.4, scopes: ["border"] }),
44
48
  borderFocus: token({ from: ref.fgAccent, scopes: ["border"] }),
45
49
  // Scrims (D32): page-canvas surfaces at alpha — never text pairs, so they
46
50
  // carry no AA gate entries.
@@ -44,6 +44,10 @@ export const lightTheme = {
44
44
  borderFaint: token({ from: ref.fgPrimary, alpha: 0.08, scopes: ["border"] }),
45
45
  borderNeutral: token({ from: ref.fgPrimary, alpha: 0.15, scopes: ["border"] }),
46
46
  borderStrong: token({ from: ref.fgPrimary, alpha: 0.3, scopes: ["border"] }),
47
+ /** D106: the outline that alone identifies an unchecked checkbox or radio,
48
+ * so WCAG 1.4.11 asks 3:1 for it (`nonTextPairs`). borderNeutral measured
49
+ * 1.32-1.48. Alpha chosen by sweep over every derivable theme. */
50
+ borderControl: token({ from: ref.fgPrimary, alpha: 0.55, scopes: ["border"] }),
47
51
  borderFocus: token({ from: ref.fgAccent, scopes: ["border"] }),
48
52
  // Scrims (D32): page-canvas surfaces at alpha — never text pairs, so they
49
53
  // carry no AA gate entries.
@@ -4,6 +4,7 @@ export type TokenName =
4
4
  | "bgInverted"
5
5
  | "bgPrimary"
6
6
  | "bgSecondary"
7
+ | "borderControl"
7
8
  | "borderFaint"
8
9
  | "borderFocus"
9
10
  | "borderNeutral"
package/llms.txt CHANGED
@@ -39,6 +39,7 @@ OKLCH formula-based design tokens. Code is the source of truth; all artifacts ge
39
39
  ## Scrims and borders
40
40
  - --psi-scrim-soft/medium/heavy: bgPrimary-derived translucent overlays (0.25/0.35/0.82 alpha) for text-over-media legibility.
41
41
  - --psi-border-faint: low-contrast hairline border (fgPrimary at 0.08 alpha), used by Card/NavBar and other bordered surfaces.
42
+ - --psi-border-control: the outline that alone identifies an unchecked Checkbox or Radio (fgPrimary at 0.55 alpha in light-based themes, 0.40 in dark-based), gated at 3:1 on bgPrimary and bgSecondary by `nonTextPairs` (WCAG 1.4.11, D106). Bind it, not --psi-border-neutral (1.32-1.48:1), for any control identified by its outline alone. A consumer theme built with buildThemeCss is held to the same 3:1.
42
43
 
43
44
  ## Media tint (--psi-media-tint, .psi-media-tint)
44
45
  - --psi-media-tint is a brand-defined filter chain (D35); apply the .psi-media-tint utility class to media elements to get a tinted-at-rest, true-color-on-hover/focus treatment, transitioning over --psi-duration-450 / --psi-ease-soft.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handamade/psi-tokens",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {