@handamade/psi-tokens 0.20.0 → 0.22.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 (55) hide show
  1. package/dist/base.css +3 -0
  2. package/dist/components/app-shell.d.ts +16 -0
  3. package/dist/components/app-shell.js +21 -0
  4. package/dist/components/app-shell.vars.css +8 -0
  5. package/dist/components/banner.d.ts +11 -0
  6. package/dist/components/banner.js +21 -0
  7. package/dist/components/banner.vars.css +13 -0
  8. package/dist/components/dialog.js +3 -0
  9. package/dist/components/dialog.vars.css +1 -0
  10. package/dist/components/field.d.ts +14 -1
  11. package/dist/components/field.js +20 -1
  12. package/dist/components/field.vars.css +2 -0
  13. package/dist/components/inline-alert.d.ts +12 -0
  14. package/dist/components/inline-alert.js +27 -0
  15. package/dist/components/inline-alert.vars.css +18 -0
  16. package/dist/components/menu.js +2 -0
  17. package/dist/components/menu.vars.css +1 -0
  18. package/dist/components/nav-tree.d.ts +12 -0
  19. package/dist/components/nav-tree.js +26 -0
  20. package/dist/components/nav-tree.vars.css +12 -0
  21. package/dist/components/navbar.js +3 -0
  22. package/dist/components/navbar.vars.css +1 -0
  23. package/dist/components/navtree.d.ts +12 -0
  24. package/dist/components/navtree.js +26 -0
  25. package/dist/components/navtree.vars.css +12 -0
  26. package/dist/components/registry.js +12 -0
  27. package/dist/components/skeleton.d.ts +4 -0
  28. package/dist/components/skeleton.js +7 -0
  29. package/dist/components/skeleton.vars.css +6 -0
  30. package/dist/components/skip-link.d.ts +6 -0
  31. package/dist/components/skip-link.js +12 -0
  32. package/dist/components/skip-link.vars.css +9 -0
  33. package/dist/components/skiplink.d.ts +6 -0
  34. package/dist/components/skiplink.js +12 -0
  35. package/dist/components/skiplink.vars.css +9 -0
  36. package/dist/components/toolbar.d.ts +10 -0
  37. package/dist/components/toolbar.js +11 -0
  38. package/dist/components/toolbar.vars.css +1 -0
  39. package/dist/components.css +78 -0
  40. package/dist/contrast-matrix.js +8 -0
  41. package/dist/dsl/validator.js +1 -1
  42. package/dist/dtcg/acme.json +18 -2
  43. package/dist/dtcg/dark.json +18 -2
  44. package/dist/dtcg/ember.json +18 -2
  45. package/dist/dtcg/light.json +18 -2
  46. package/dist/guidance.json +2 -2
  47. package/dist/resolved/acme.json +9 -2
  48. package/dist/resolved/dark.json +9 -2
  49. package/dist/resolved/ember.json +9 -2
  50. package/dist/resolved/light.json +9 -2
  51. package/dist/scope-map.json +106 -2
  52. package/dist/themes/dark.js +2 -2
  53. package/dist/themes/light.js +4 -2
  54. package/llms.txt +5 -0
  55. package/package.json +1 -1
package/dist/base.css CHANGED
@@ -85,6 +85,9 @@
85
85
  --psi-z-nav: 100;
86
86
  --psi-z-overlay: 1000;
87
87
  --psi-z-tooltip: 1100;
88
+ --psi-focus-ring-width: 2px;
89
+ --psi-focus-ring-offset: 2px;
90
+ --psi-focus-ring-offset-inset: -2px;
88
91
 
89
92
  --psi-font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
90
93
  --psi-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
@@ -0,0 +1,16 @@
1
+ /** AppShell component tokens (--psi-app-shell-*) — D88.
2
+ *
3
+ * sidebar-width is a deliberate default for the sidebar column of the frame.
4
+ * 16rem is a standalone literal, matching --psi-toolbar-control-width: no
5
+ * shared scale reaches this range (sizeScale tops at 48, spacingScale at 144,
6
+ * both for heights and gaps, not column widths). The header's height stays
7
+ * --psi-navbar-height: the NavBar owns it.
8
+ *
9
+ * sidebar-bg, sidebar-fg and focus-ring alias the semantic layer, so under a
10
+ * `data-psi-theme="dark"` sidebar inside a light page they re-resolve dark
11
+ * (component tokens are declared under `:where(:root, [data-psi-theme])`).
12
+ * The theme paints no background of its own: the sidebar paints sidebar-bg.
13
+ *
14
+ * No contrast pair added: fgPrimary on bgPrimary is already gated in every
15
+ * theme, dark included, and the pinned pair count does not move. */
16
+ export declare const appShellVars: Record<string, string>;
@@ -0,0 +1,21 @@
1
+ /** AppShell component tokens (--psi-app-shell-*) — D88.
2
+ *
3
+ * sidebar-width is a deliberate default for the sidebar column of the frame.
4
+ * 16rem is a standalone literal, matching --psi-toolbar-control-width: no
5
+ * shared scale reaches this range (sizeScale tops at 48, spacingScale at 144,
6
+ * both for heights and gaps, not column widths). The header's height stays
7
+ * --psi-navbar-height: the NavBar owns it.
8
+ *
9
+ * sidebar-bg, sidebar-fg and focus-ring alias the semantic layer, so under a
10
+ * `data-psi-theme="dark"` sidebar inside a light page they re-resolve dark
11
+ * (component tokens are declared under `:where(:root, [data-psi-theme])`).
12
+ * The theme paints no background of its own: the sidebar paints sidebar-bg.
13
+ *
14
+ * No contrast pair added: fgPrimary on bgPrimary is already gated in every
15
+ * theme, dark included, and the pinned pair count does not move. */
16
+ export const appShellVars = {
17
+ "sidebar-width": "16rem",
18
+ "sidebar-bg": "var(--psi-bg-primary)",
19
+ "sidebar-fg": "var(--psi-fg-primary)",
20
+ "focus-ring": "var(--psi-border-focus)",
21
+ };
@@ -0,0 +1,8 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-app-shell-sidebar-width: 16rem;
4
+ --psi-app-shell-sidebar-bg: var(--psi-bg-primary);
5
+ --psi-app-shell-sidebar-fg: var(--psi-fg-primary);
6
+ --psi-app-shell-focus-ring: var(--psi-border-focus);
7
+ }
8
+ }
@@ -0,0 +1,11 @@
1
+ /** Banner component tokens (--psi-banner-*) — D86. Pure indirection, same
2
+ * posture as toast.ts (D64) and table.ts (D62).
3
+ *
4
+ * Unlike Toast, a Banner is a tinted surface: it spans the page edge to edge
5
+ * and is meant to be seen, so the variant tints the whole strip. Neutral sits
6
+ * on fillNeutral3, the status variants on the fillTint* family.
7
+ *
8
+ * The body text is fgPrimary on all four surfaces; fgPrimary on each
9
+ * fillTint* is gated at 4.5 in contrast-matrix.ts (D86). The icon foregrounds
10
+ * are not text and ride the status-foreground pairs already gated there. */
11
+ export declare const bannerVars: Record<string, string>;
@@ -0,0 +1,21 @@
1
+ /** Banner component tokens (--psi-banner-*) — D86. Pure indirection, same
2
+ * posture as toast.ts (D64) and table.ts (D62).
3
+ *
4
+ * Unlike Toast, a Banner is a tinted surface: it spans the page edge to edge
5
+ * and is meant to be seen, so the variant tints the whole strip. Neutral sits
6
+ * on fillNeutral3, the status variants on the fillTint* family.
7
+ *
8
+ * The body text is fgPrimary on all four surfaces; fgPrimary on each
9
+ * fillTint* is gated at 4.5 in contrast-matrix.ts (D86). The icon foregrounds
10
+ * are not text and ride the status-foreground pairs already gated there. */
11
+ export const bannerVars = {
12
+ "bg-neutral": "var(--psi-fill-neutral3)",
13
+ "bg-success": "var(--psi-fill-tint-success)",
14
+ "bg-warning": "var(--psi-fill-tint-warning)",
15
+ "bg-danger": "var(--psi-fill-tint-danger)",
16
+ fg: "var(--psi-fg-primary)",
17
+ "icon-fg-neutral": "var(--psi-fg-secondary)",
18
+ "icon-fg-success": "var(--psi-fg-success)",
19
+ "icon-fg-warning": "var(--psi-fg-warning)",
20
+ "icon-fg-danger": "var(--psi-fg-danger)",
21
+ };
@@ -0,0 +1,13 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-banner-bg-neutral: var(--psi-fill-neutral3);
4
+ --psi-banner-bg-success: var(--psi-fill-tint-success);
5
+ --psi-banner-bg-warning: var(--psi-fill-tint-warning);
6
+ --psi-banner-bg-danger: var(--psi-fill-tint-danger);
7
+ --psi-banner-fg: var(--psi-fg-primary);
8
+ --psi-banner-icon-fg-neutral: var(--psi-fg-secondary);
9
+ --psi-banner-icon-fg-success: var(--psi-fg-success);
10
+ --psi-banner-icon-fg-warning: var(--psi-fg-warning);
11
+ --psi-banner-icon-fg-danger: var(--psi-fg-danger);
12
+ }
13
+ }
@@ -10,4 +10,7 @@ export const dialogVars = {
10
10
  backdrop: "var(--psi-scrim-heavy)",
11
11
  fg: "var(--psi-fg-primary)",
12
12
  "title-fg": "var(--psi-fg-primary)",
13
+ // D82 — the ring on the <dialog> and on a drawer's scrolling panel, which
14
+ // are tab stops themselves; the controls inside carry their own.
15
+ "focus-ring": "var(--psi-border-focus)",
13
16
  };
@@ -6,5 +6,6 @@
6
6
  --psi-dialog-backdrop: var(--psi-scrim-heavy);
7
7
  --psi-dialog-fg: var(--psi-fg-primary);
8
8
  --psi-dialog-title-fg: var(--psi-fg-primary);
9
+ --psi-dialog-focus-ring: var(--psi-border-focus);
9
10
  }
10
11
  }
@@ -1,3 +1,16 @@
1
1
  /** Field component tokens (--psi-field-*). Label/message/error colors alias
2
- * gated semantic fg tokens; gap is the label→control→message grid gap (D49). */
2
+ * gated semantic fg tokens; gap is the label→control→message grid gap (D49).
3
+ *
4
+ * label-height (D92) is the label's line box: the line height of
5
+ * --psi-text-14-20-medium, which the label's `font` shorthand sets. A font
6
+ * shorthand exposes no line-height variable to read, and --psi-space-* is
7
+ * scoped to gap/padding/margin, so the height is a standalone 20px literal
8
+ * (the --psi-toolbar-control-width precedent). The label holds it as its
9
+ * min-block-size, and --psi-toolbar-action-offset is derived from it, so a
10
+ * filter row's unlabelled button and its labelled controls read one source.
11
+ * Change it together with the label's font. --psi-toolbar-action-offset
12
+ * resolves from this token and `gap` at the theme root: overriding either on
13
+ * a narrower scope inside a Toolbar align="start" row also needs
14
+ * --psi-toolbar-action-offset set on that same scope, or the row's button
15
+ * keeps the theme-root offset. */
3
16
  export declare const fieldVars: Record<string, string>;
@@ -1,9 +1,28 @@
1
1
  /** Field component tokens (--psi-field-*). Label/message/error colors alias
2
- * gated semantic fg tokens; gap is the label→control→message grid gap (D49). */
2
+ * gated semantic fg tokens; gap is the label→control→message grid gap (D49).
3
+ *
4
+ * label-height (D92) is the label's line box: the line height of
5
+ * --psi-text-14-20-medium, which the label's `font` shorthand sets. A font
6
+ * shorthand exposes no line-height variable to read, and --psi-space-* is
7
+ * scoped to gap/padding/margin, so the height is a standalone 20px literal
8
+ * (the --psi-toolbar-control-width precedent). The label holds it as its
9
+ * min-block-size, and --psi-toolbar-action-offset is derived from it, so a
10
+ * filter row's unlabelled button and its labelled controls read one source.
11
+ * Change it together with the label's font. --psi-toolbar-action-offset
12
+ * resolves from this token and `gap` at the theme root: overriding either on
13
+ * a narrower scope inside a Toolbar align="start" row also needs
14
+ * --psi-toolbar-action-offset set on that same scope, or the row's button
15
+ * keeps the theme-root offset. */
3
16
  export const fieldVars = {
4
17
  "label-fg": "var(--psi-fg-secondary)",
5
18
  "message-fg": "var(--psi-fg-tertiary)",
6
19
  "error-fg": "var(--psi-fg-danger)",
7
20
  "marker-fg": "var(--psi-fg-danger)",
21
+ // D84: the word a required label shows instead of the asterisk. The label's
22
+ // own colour — gated at 4.5 on every surface — not marker-fg, which would
23
+ // make every required label read as an error, and not fg-tertiary, the one
24
+ // foreground the contrast matrix does not gate.
25
+ "required-text-fg": "var(--psi-fg-secondary)",
8
26
  gap: "var(--psi-space-6)",
27
+ "label-height": "20px",
9
28
  };
@@ -4,6 +4,8 @@
4
4
  --psi-field-message-fg: var(--psi-fg-tertiary);
5
5
  --psi-field-error-fg: var(--psi-fg-danger);
6
6
  --psi-field-marker-fg: var(--psi-fg-danger);
7
+ --psi-field-required-text-fg: var(--psi-fg-secondary);
7
8
  --psi-field-gap: var(--psi-space-6);
9
+ --psi-field-label-height: 20px;
8
10
  }
9
11
  }
@@ -0,0 +1,12 @@
1
+ /** InlineAlert component tokens (--psi-inline-alert-*) — D86. Pure
2
+ * indirection, same posture as banner.ts.
3
+ *
4
+ * The same tinted surface as Banner, but it sits inside content, so it adds a
5
+ * 1px border in the variant's colour and a radius. Neutral sits on
6
+ * fillNeutral3 with borderNeutral; the status variants draw the border from
7
+ * the status foreground (fgSuccess and fgWarning carry the `border` scope
8
+ * since D86, so the D46 scope gate accepts these bindings).
9
+ *
10
+ * The body text is fgPrimary on all four surfaces; fgPrimary on each
11
+ * fillTint* is gated at 4.5 in contrast-matrix.ts (D86). */
12
+ export declare const inlineAlertVars: Record<string, string>;
@@ -0,0 +1,27 @@
1
+ /** InlineAlert component tokens (--psi-inline-alert-*) — D86. Pure
2
+ * indirection, same posture as banner.ts.
3
+ *
4
+ * The same tinted surface as Banner, but it sits inside content, so it adds a
5
+ * 1px border in the variant's colour and a radius. Neutral sits on
6
+ * fillNeutral3 with borderNeutral; the status variants draw the border from
7
+ * the status foreground (fgSuccess and fgWarning carry the `border` scope
8
+ * since D86, so the D46 scope gate accepts these bindings).
9
+ *
10
+ * The body text is fgPrimary on all four surfaces; fgPrimary on each
11
+ * fillTint* is gated at 4.5 in contrast-matrix.ts (D86). */
12
+ export const inlineAlertVars = {
13
+ "bg-neutral": "var(--psi-fill-neutral3)",
14
+ "bg-success": "var(--psi-fill-tint-success)",
15
+ "bg-warning": "var(--psi-fill-tint-warning)",
16
+ "bg-danger": "var(--psi-fill-tint-danger)",
17
+ fg: "var(--psi-fg-primary)",
18
+ "icon-fg-neutral": "var(--psi-fg-secondary)",
19
+ "icon-fg-success": "var(--psi-fg-success)",
20
+ "icon-fg-warning": "var(--psi-fg-warning)",
21
+ "icon-fg-danger": "var(--psi-fg-danger)",
22
+ "border-neutral": "var(--psi-border-neutral)",
23
+ "border-success": "var(--psi-fg-success)",
24
+ "border-warning": "var(--psi-fg-warning)",
25
+ "border-danger": "var(--psi-fg-danger)",
26
+ radius: "var(--psi-radius-8)",
27
+ };
@@ -0,0 +1,18 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-inline-alert-bg-neutral: var(--psi-fill-neutral3);
4
+ --psi-inline-alert-bg-success: var(--psi-fill-tint-success);
5
+ --psi-inline-alert-bg-warning: var(--psi-fill-tint-warning);
6
+ --psi-inline-alert-bg-danger: var(--psi-fill-tint-danger);
7
+ --psi-inline-alert-fg: var(--psi-fg-primary);
8
+ --psi-inline-alert-icon-fg-neutral: var(--psi-fg-secondary);
9
+ --psi-inline-alert-icon-fg-success: var(--psi-fg-success);
10
+ --psi-inline-alert-icon-fg-warning: var(--psi-fg-warning);
11
+ --psi-inline-alert-icon-fg-danger: var(--psi-fg-danger);
12
+ --psi-inline-alert-border-neutral: var(--psi-border-neutral);
13
+ --psi-inline-alert-border-success: var(--psi-fg-success);
14
+ --psi-inline-alert-border-warning: var(--psi-fg-warning);
15
+ --psi-inline-alert-border-danger: var(--psi-fg-danger);
16
+ --psi-inline-alert-radius: var(--psi-radius-8);
17
+ }
18
+ }
@@ -16,4 +16,6 @@ export const menuVars = {
16
16
  "item-fg-danger": "var(--psi-fg-danger)",
17
17
  "item-fg-disabled": "var(--psi-fg-quaternary)",
18
18
  "separator-border": "var(--psi-border-faint)",
19
+ // D82 — the ring on a focused item; until then the browser drew its own.
20
+ "focus-ring": "var(--psi-border-focus)",
19
21
  };
@@ -11,5 +11,6 @@
11
11
  --psi-menu-item-fg-danger: var(--psi-fg-danger);
12
12
  --psi-menu-item-fg-disabled: var(--psi-fg-quaternary);
13
13
  --psi-menu-separator-border: var(--psi-border-faint);
14
+ --psi-menu-focus-ring: var(--psi-border-focus);
14
15
  }
15
16
  }
@@ -0,0 +1,12 @@
1
+ /** NavTree component tokens (--psi-nav-tree-*) — D88. Pure indirection onto the
2
+ * semantic layer (same posture as menu.ts and tabs.ts), so every key resolves
3
+ * under a `data-psi-theme="dark"` sidebar inside a light page: component
4
+ * tokens are declared under `:where(:root, [data-psi-theme])`.
5
+ *
6
+ * No new contrast pairs: wcagAAPairs already gates fgPrimary and fgSecondary
7
+ * on bgPrimary and on fillNeutral1-6 in every theme, dark included, which
8
+ * covers the item text on the sidebar, on the hover surface (neutral3) and on
9
+ * the current surface (neutral4). `indicator` and `focus-ring` are non-text
10
+ * accents and `radius` is geometry; none carries a gated pair, so the pinned
11
+ * pair count does not move. */
12
+ export declare const navTreeVars: Record<string, string>;
@@ -0,0 +1,26 @@
1
+ /** NavTree component tokens (--psi-nav-tree-*) — D88. Pure indirection onto the
2
+ * semantic layer (same posture as menu.ts and tabs.ts), so every key resolves
3
+ * under a `data-psi-theme="dark"` sidebar inside a light page: component
4
+ * tokens are declared under `:where(:root, [data-psi-theme])`.
5
+ *
6
+ * No new contrast pairs: wcagAAPairs already gates fgPrimary and fgSecondary
7
+ * on bgPrimary and on fillNeutral1-6 in every theme, dark included, which
8
+ * covers the item text on the sidebar, on the hover surface (neutral3) and on
9
+ * the current surface (neutral4). `indicator` and `focus-ring` are non-text
10
+ * accents and `radius` is geometry; none carries a gated pair, so the pinned
11
+ * pair count does not move. */
12
+ export const navTreeVars = {
13
+ "item-fg": "var(--psi-fg-secondary)",
14
+ "item-fg-current": "var(--psi-fg-primary)",
15
+ "item-bg-hover": "var(--psi-fill-neutral3)",
16
+ "item-bg-current": "var(--psi-fill-neutral4)",
17
+ // The inline-start bar on the current item: weight and surface carry the
18
+ // state too, so colour is never the only signal.
19
+ indicator: "var(--psi-fill-accent)",
20
+ // A group's button reads like its items, one step quieter than the current.
21
+ "group-fg": "var(--psi-fg-secondary)",
22
+ // Alias of the control radius dial: a CSS Module may bind only its own
23
+ // component prefix, and the radius lives outside it.
24
+ radius: "var(--psi-control-radius)",
25
+ "focus-ring": "var(--psi-border-focus)",
26
+ };
@@ -0,0 +1,12 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-nav-tree-item-fg: var(--psi-fg-secondary);
4
+ --psi-nav-tree-item-fg-current: var(--psi-fg-primary);
5
+ --psi-nav-tree-item-bg-hover: var(--psi-fill-neutral3);
6
+ --psi-nav-tree-item-bg-current: var(--psi-fill-neutral4);
7
+ --psi-nav-tree-indicator: var(--psi-fill-accent);
8
+ --psi-nav-tree-group-fg: var(--psi-fg-secondary);
9
+ --psi-nav-tree-radius: var(--psi-control-radius);
10
+ --psi-nav-tree-focus-ring: var(--psi-border-focus);
11
+ }
12
+ }
@@ -8,4 +8,7 @@ export const navbarVars = {
8
8
  "link-fg": "var(--psi-fg-secondary)",
9
9
  "link-fg-hover": "var(--psi-fg-primary)",
10
10
  "focus-ring": "var(--psi-border-focus)",
11
+ // D88 — the side padding of a fluid row; the same gutter .psi-container pads
12
+ // with, reachable from the CSS Module only through a navbar token.
13
+ gutter: "var(--psi-gutter)",
11
14
  };
@@ -7,5 +7,6 @@
7
7
  --psi-navbar-link-fg: var(--psi-fg-secondary);
8
8
  --psi-navbar-link-fg-hover: var(--psi-fg-primary);
9
9
  --psi-navbar-focus-ring: var(--psi-border-focus);
10
+ --psi-navbar-gutter: var(--psi-gutter);
10
11
  }
11
12
  }
@@ -0,0 +1,12 @@
1
+ /** NavTree component tokens (--psi-navtree-*) — D88. Pure indirection onto the
2
+ * semantic layer (same posture as menu.ts and tabs.ts), so every key resolves
3
+ * under a `data-psi-theme="dark"` sidebar inside a light page: component
4
+ * tokens are declared under `:where(:root, [data-psi-theme])`.
5
+ *
6
+ * No new contrast pairs: wcagAAPairs already gates fgPrimary and fgSecondary
7
+ * on bgPrimary and on fillNeutral1-6 in every theme, dark included, which
8
+ * covers the item text on the sidebar, on the hover surface (neutral3) and on
9
+ * the current surface (neutral4). `indicator` and `focus-ring` are non-text
10
+ * accents and `radius` is geometry; none carries a gated pair, so the pinned
11
+ * pair count does not move. */
12
+ export declare const navtreeVars: Record<string, string>;
@@ -0,0 +1,26 @@
1
+ /** NavTree component tokens (--psi-navtree-*) — D88. Pure indirection onto the
2
+ * semantic layer (same posture as menu.ts and tabs.ts), so every key resolves
3
+ * under a `data-psi-theme="dark"` sidebar inside a light page: component
4
+ * tokens are declared under `:where(:root, [data-psi-theme])`.
5
+ *
6
+ * No new contrast pairs: wcagAAPairs already gates fgPrimary and fgSecondary
7
+ * on bgPrimary and on fillNeutral1-6 in every theme, dark included, which
8
+ * covers the item text on the sidebar, on the hover surface (neutral3) and on
9
+ * the current surface (neutral4). `indicator` and `focus-ring` are non-text
10
+ * accents and `radius` is geometry; none carries a gated pair, so the pinned
11
+ * pair count does not move. */
12
+ export const navtreeVars = {
13
+ "item-fg": "var(--psi-fg-secondary)",
14
+ "item-fg-current": "var(--psi-fg-primary)",
15
+ "item-bg-hover": "var(--psi-fill-neutral3)",
16
+ "item-bg-current": "var(--psi-fill-neutral4)",
17
+ // The inline-start bar on the current item: weight and surface carry the
18
+ // state too, so colour is never the only signal.
19
+ indicator: "var(--psi-fill-accent)",
20
+ // A group's button reads like its items, one step quieter than the current.
21
+ "group-fg": "var(--psi-fg-secondary)",
22
+ // Alias of the control radius dial: a CSS Module may bind only its own
23
+ // component prefix, and the radius lives outside it.
24
+ radius: "var(--psi-control-radius)",
25
+ "focus-ring": "var(--psi-border-focus)",
26
+ };
@@ -0,0 +1,12 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-navtree-item-fg: var(--psi-fg-secondary);
4
+ --psi-navtree-item-fg-current: var(--psi-fg-primary);
5
+ --psi-navtree-item-bg-hover: var(--psi-fill-neutral3);
6
+ --psi-navtree-item-bg-current: var(--psi-fill-neutral4);
7
+ --psi-navtree-indicator: var(--psi-fill-accent);
8
+ --psi-navtree-group-fg: var(--psi-fg-secondary);
9
+ --psi-navtree-radius: var(--psi-control-radius);
10
+ --psi-navtree-focus-ring: var(--psi-border-focus);
11
+ }
12
+ }
@@ -1,6 +1,12 @@
1
1
  // Component var registry (D81: moved out of scripts/build.ts) — the D46 scope
2
2
  // gate reads it, in Psi's own build and in a consumer's theme build alike.
3
+ import { bannerVars } from "./banner.js";
4
+ import { inlineAlertVars } from "./inline-alert.js";
3
5
  import { buttonVars } from "./button.js";
6
+ import { skeletonVars } from "./skeleton.js";
7
+ import { skipLinkVars } from "./skip-link.js";
8
+ import { navTreeVars } from "./nav-tree.js";
9
+ import { appShellVars } from "./app-shell.js";
4
10
  import { inputVars } from "./input.js";
5
11
  import { selectVars } from "./select.js";
6
12
  import { surfaceVars } from "./surface.js";
@@ -22,6 +28,8 @@ import { tabsVars } from "./tabs.js";
22
28
  import { fieldVars } from "./field.js";
23
29
  import { descriptionListVars } from "./description-list.js";
24
30
  export const componentVars = {
31
+ "app-shell": appShellVars,
32
+ banner: bannerVars,
25
33
  button: buttonVars,
26
34
  card: cardVars,
27
35
  checkbox: checkboxVars,
@@ -29,11 +37,15 @@ export const componentVars = {
29
37
  dialog: dialogVars,
30
38
  field: fieldVars,
31
39
  input: inputVars,
40
+ "inline-alert": inlineAlertVars,
32
41
  media: mediaVars,
33
42
  menu: menuVars,
34
43
  navbar: navbarVars,
35
44
  panel: panelVars,
36
45
  select: selectVars,
46
+ skeleton: skeletonVars,
47
+ "nav-tree": navTreeVars,
48
+ "skip-link": skipLinkVars,
37
49
  surface: surfaceVars,
38
50
  switch: switchVars,
39
51
  table: tableVars,
@@ -0,0 +1,4 @@
1
+ /** Skeleton component tokens (--psi-skeleton-*) — D86. Pure indirection, same
2
+ * posture as banner.ts. A placeholder carries no text, so there is no
3
+ * foreground pair to gate; it is a quiet neutral shape. */
4
+ export declare const skeletonVars: Record<string, string>;
@@ -0,0 +1,7 @@
1
+ /** Skeleton component tokens (--psi-skeleton-*) — D86. Pure indirection, same
2
+ * posture as banner.ts. A placeholder carries no text, so there is no
3
+ * foreground pair to gate; it is a quiet neutral shape. */
4
+ export const skeletonVars = {
5
+ bg: "var(--psi-fill-neutral4)",
6
+ radius: "var(--psi-radius-4)",
7
+ };
@@ -0,0 +1,6 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-skeleton-bg: var(--psi-fill-neutral4);
4
+ --psi-skeleton-radius: var(--psi-radius-4);
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ /** SkipLink component tokens (--psi-skip-link-*) — D88. Pure indirection onto
2
+ * the shared surface and focus tokens (same posture as menu.ts and
3
+ * navbar.ts): the link shows only when focused, on a surface, so a brand
4
+ * retuning --psi-surface-* gets it for free. radius rides the control radius
5
+ * dial, as Button's does, so the link matches the controls around it. */
6
+ export declare const skipLinkVars: Record<string, string>;
@@ -0,0 +1,12 @@
1
+ /** SkipLink component tokens (--psi-skip-link-*) — D88. Pure indirection onto
2
+ * the shared surface and focus tokens (same posture as menu.ts and
3
+ * navbar.ts): the link shows only when focused, on a surface, so a brand
4
+ * retuning --psi-surface-* gets it for free. radius rides the control radius
5
+ * dial, as Button's does, so the link matches the controls around it. */
6
+ export const skipLinkVars = {
7
+ bg: "var(--psi-surface-bg)",
8
+ fg: "var(--psi-fg-primary)",
9
+ border: "var(--psi-surface-border)",
10
+ radius: "var(--psi-control-radius)",
11
+ "focus-ring": "var(--psi-border-focus)",
12
+ };
@@ -0,0 +1,9 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-skip-link-bg: var(--psi-surface-bg);
4
+ --psi-skip-link-fg: var(--psi-fg-primary);
5
+ --psi-skip-link-border: var(--psi-surface-border);
6
+ --psi-skip-link-radius: var(--psi-control-radius);
7
+ --psi-skip-link-focus-ring: var(--psi-border-focus);
8
+ }
9
+ }
@@ -0,0 +1,6 @@
1
+ /** SkipLink component tokens (--psi-skiplink-*) — D88. Pure indirection onto
2
+ * the shared surface and focus tokens (same posture as menu.ts and
3
+ * navbar.ts): the link shows only when focused, on a surface, so a brand
4
+ * retuning --psi-surface-* gets it for free. radius rides the control radius
5
+ * dial, as Button's does, so the link matches the controls around it. */
6
+ export declare const skiplinkVars: Record<string, string>;
@@ -0,0 +1,12 @@
1
+ /** SkipLink component tokens (--psi-skiplink-*) — D88. Pure indirection onto
2
+ * the shared surface and focus tokens (same posture as menu.ts and
3
+ * navbar.ts): the link shows only when focused, on a surface, so a brand
4
+ * retuning --psi-surface-* gets it for free. radius rides the control radius
5
+ * dial, as Button's does, so the link matches the controls around it. */
6
+ export const skiplinkVars = {
7
+ bg: "var(--psi-surface-bg)",
8
+ fg: "var(--psi-fg-primary)",
9
+ border: "var(--psi-surface-border)",
10
+ radius: "var(--psi-control-radius)",
11
+ "focus-ring": "var(--psi-border-focus)",
12
+ };
@@ -0,0 +1,9 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-skiplink-bg: var(--psi-surface-bg);
4
+ --psi-skiplink-fg: var(--psi-fg-primary);
5
+ --psi-skiplink-border: var(--psi-surface-border);
6
+ --psi-skiplink-radius: var(--psi-control-radius);
7
+ --psi-skiplink-focus-ring: var(--psi-border-focus);
8
+ }
9
+ }
@@ -9,5 +9,15 @@
9
9
  * Dialog's width={400|560|720} precedent — no shared scale reaches this
10
10
  * range (sizeScale tops at 48, spacingScale at 144, both for heights/gaps
11
11
  * not component widths).
12
+ *
13
+ * action-offset (D92) moves an unlabelled button or link onto the control
14
+ * line of a `Toolbar align="start"` row: a Field's control starts one label
15
+ * line and one field gap below the Field's top. It is derived from Field's
16
+ * own tokens rather than restated, so the two cannot drift. The calc()
17
+ * resolves where it is declared, `:where(:root, [data-psi-theme])`: retuning
18
+ * --psi-field-label-height or --psi-field-gap there moves the button too;
19
+ * retuning either on a narrower scope needs this token set on that scope.
20
+ * Bound to margin-block-start in toolbar.module.css (Toolbar CSS may read
21
+ * only --psi-toolbar-* and the scales, so it reads Field through this).
12
22
  */
13
23
  export declare const toolbarVars: Record<string, string>;
@@ -9,7 +9,18 @@
9
9
  * Dialog's width={400|560|720} precedent — no shared scale reaches this
10
10
  * range (sizeScale tops at 48, spacingScale at 144, both for heights/gaps
11
11
  * not component widths).
12
+ *
13
+ * action-offset (D92) moves an unlabelled button or link onto the control
14
+ * line of a `Toolbar align="start"` row: a Field's control starts one label
15
+ * line and one field gap below the Field's top. It is derived from Field's
16
+ * own tokens rather than restated, so the two cannot drift. The calc()
17
+ * resolves where it is declared, `:where(:root, [data-psi-theme])`: retuning
18
+ * --psi-field-label-height or --psi-field-gap there moves the button too;
19
+ * retuning either on a narrower scope needs this token set on that scope.
20
+ * Bound to margin-block-start in toolbar.module.css (Toolbar CSS may read
21
+ * only --psi-toolbar-* and the scales, so it reads Field through this).
12
22
  */
13
23
  export const toolbarVars = {
14
24
  "control-width": "200px",
25
+ "action-offset": "calc(var(--psi-field-label-height) + var(--psi-field-gap))",
15
26
  };
@@ -1,5 +1,6 @@
1
1
  @layer psi.components {
2
2
  :where(:root, [data-psi-theme]) {
3
3
  --psi-toolbar-control-width: 200px;
4
+ --psi-toolbar-action-offset: calc(var(--psi-field-label-height) + var(--psi-field-gap));
4
5
  }
5
6
  }
@@ -1,3 +1,26 @@
1
+ @layer psi.components {
2
+ :where(:root, [data-psi-theme]) {
3
+ --psi-app-shell-sidebar-width: 16rem;
4
+ --psi-app-shell-sidebar-bg: var(--psi-bg-primary);
5
+ --psi-app-shell-sidebar-fg: var(--psi-fg-primary);
6
+ --psi-app-shell-focus-ring: var(--psi-border-focus);
7
+ }
8
+ }
9
+
10
+ @layer psi.components {
11
+ :where(:root, [data-psi-theme]) {
12
+ --psi-banner-bg-neutral: var(--psi-fill-neutral3);
13
+ --psi-banner-bg-success: var(--psi-fill-tint-success);
14
+ --psi-banner-bg-warning: var(--psi-fill-tint-warning);
15
+ --psi-banner-bg-danger: var(--psi-fill-tint-danger);
16
+ --psi-banner-fg: var(--psi-fg-primary);
17
+ --psi-banner-icon-fg-neutral: var(--psi-fg-secondary);
18
+ --psi-banner-icon-fg-success: var(--psi-fg-success);
19
+ --psi-banner-icon-fg-warning: var(--psi-fg-warning);
20
+ --psi-banner-icon-fg-danger: var(--psi-fg-danger);
21
+ }
22
+ }
23
+
1
24
  @layer psi.components {
2
25
  :where(:root, [data-psi-theme]) {
3
26
  --psi-button-accent-bg: var(--psi-fill-accent);
@@ -123,6 +146,7 @@
123
146
  --psi-dialog-backdrop: var(--psi-scrim-heavy);
124
147
  --psi-dialog-fg: var(--psi-fg-primary);
125
148
  --psi-dialog-title-fg: var(--psi-fg-primary);
149
+ --psi-dialog-focus-ring: var(--psi-border-focus);
126
150
  }
127
151
  }
128
152
 
@@ -132,7 +156,9 @@
132
156
  --psi-field-message-fg: var(--psi-fg-tertiary);
133
157
  --psi-field-error-fg: var(--psi-fg-danger);
134
158
  --psi-field-marker-fg: var(--psi-fg-danger);
159
+ --psi-field-required-text-fg: var(--psi-fg-secondary);
135
160
  --psi-field-gap: var(--psi-space-6);
161
+ --psi-field-label-height: 20px;
136
162
  }
137
163
  }
138
164
 
@@ -161,6 +187,25 @@
161
187
  }
162
188
  }
163
189
 
190
+ @layer psi.components {
191
+ :where(:root, [data-psi-theme]) {
192
+ --psi-inline-alert-bg-neutral: var(--psi-fill-neutral3);
193
+ --psi-inline-alert-bg-success: var(--psi-fill-tint-success);
194
+ --psi-inline-alert-bg-warning: var(--psi-fill-tint-warning);
195
+ --psi-inline-alert-bg-danger: var(--psi-fill-tint-danger);
196
+ --psi-inline-alert-fg: var(--psi-fg-primary);
197
+ --psi-inline-alert-icon-fg-neutral: var(--psi-fg-secondary);
198
+ --psi-inline-alert-icon-fg-success: var(--psi-fg-success);
199
+ --psi-inline-alert-icon-fg-warning: var(--psi-fg-warning);
200
+ --psi-inline-alert-icon-fg-danger: var(--psi-fg-danger);
201
+ --psi-inline-alert-border-neutral: var(--psi-border-neutral);
202
+ --psi-inline-alert-border-success: var(--psi-fg-success);
203
+ --psi-inline-alert-border-warning: var(--psi-fg-warning);
204
+ --psi-inline-alert-border-danger: var(--psi-fg-danger);
205
+ --psi-inline-alert-radius: var(--psi-radius-8);
206
+ }
207
+ }
208
+
164
209
  @layer psi.components {
165
210
  :where(:root, [data-psi-theme]) {
166
211
  --psi-media-tint: none;
@@ -180,6 +225,7 @@
180
225
  --psi-menu-item-fg-danger: var(--psi-fg-danger);
181
226
  --psi-menu-item-fg-disabled: var(--psi-fg-quaternary);
182
227
  --psi-menu-separator-border: var(--psi-border-faint);
228
+ --psi-menu-focus-ring: var(--psi-border-focus);
183
229
  }
184
230
  }
185
231
 
@@ -192,6 +238,7 @@
192
238
  --psi-navbar-link-fg: var(--psi-fg-secondary);
193
239
  --psi-navbar-link-fg-hover: var(--psi-fg-primary);
194
240
  --psi-navbar-focus-ring: var(--psi-border-focus);
241
+ --psi-navbar-gutter: var(--psi-gutter);
195
242
  }
196
243
  }
197
244
 
@@ -238,6 +285,36 @@
238
285
  }
239
286
  }
240
287
 
288
+ @layer psi.components {
289
+ :where(:root, [data-psi-theme]) {
290
+ --psi-skeleton-bg: var(--psi-fill-neutral4);
291
+ --psi-skeleton-radius: var(--psi-radius-4);
292
+ }
293
+ }
294
+
295
+ @layer psi.components {
296
+ :where(:root, [data-psi-theme]) {
297
+ --psi-nav-tree-item-fg: var(--psi-fg-secondary);
298
+ --psi-nav-tree-item-fg-current: var(--psi-fg-primary);
299
+ --psi-nav-tree-item-bg-hover: var(--psi-fill-neutral3);
300
+ --psi-nav-tree-item-bg-current: var(--psi-fill-neutral4);
301
+ --psi-nav-tree-indicator: var(--psi-fill-accent);
302
+ --psi-nav-tree-group-fg: var(--psi-fg-secondary);
303
+ --psi-nav-tree-radius: var(--psi-control-radius);
304
+ --psi-nav-tree-focus-ring: var(--psi-border-focus);
305
+ }
306
+ }
307
+
308
+ @layer psi.components {
309
+ :where(:root, [data-psi-theme]) {
310
+ --psi-skip-link-bg: var(--psi-surface-bg);
311
+ --psi-skip-link-fg: var(--psi-fg-primary);
312
+ --psi-skip-link-border: var(--psi-surface-border);
313
+ --psi-skip-link-radius: var(--psi-control-radius);
314
+ --psi-skip-link-focus-ring: var(--psi-border-focus);
315
+ }
316
+ }
317
+
241
318
  @layer psi.components {
242
319
  :where(:root, [data-psi-theme]) {
243
320
  --psi-surface-bg: var(--psi-bg-secondary);
@@ -331,6 +408,7 @@
331
408
  @layer psi.components {
332
409
  :where(:root, [data-psi-theme]) {
333
410
  --psi-toolbar-control-width: 200px;
411
+ --psi-toolbar-action-offset: calc(var(--psi-field-label-height) + var(--psi-field-gap));
334
412
  }
335
413
  }
336
414
 
@@ -108,6 +108,14 @@ export const wcagAAPairs = [
108
108
  // the fgAccent/fillTintAccent pair above: a selected row keeps ordinary
109
109
  // body text, it does not recolor it to the accent.
110
110
  { fg: "fgPrimary", bg: "fillTintAccent", minRatio: 4.5 },
111
+ // Body text on the status tints (D86): Banner and InlineAlert set their
112
+ // title and body in fgPrimary on fill-tint-{success,warning,danger}. The
113
+ // tints are 12–15% of the status foreground over the canvas, so every
114
+ // shipped theme clears this by a wide margin; it is gated because a
115
+ // consumer theme built with psi-theme (D81) is held to the same matrix.
116
+ { fg: "fgPrimary", bg: "fillTintSuccess", minRatio: 4.5 },
117
+ { fg: "fgPrimary", bg: "fillTintWarning", minRatio: 4.5 },
118
+ { fg: "fgPrimary", bg: "fillTintDanger", minRatio: 4.5 },
111
119
  // Toast's status icon on the elevated surface (D64/D65). The three pairs
112
120
  // above gate these foregrounds on bgPrimary; --psi-surface-bg — what Toast,
113
121
  // Menu and Dialog all alias — resolves to bgSecondary, which was ungated for
@@ -85,7 +85,7 @@ export function validateScopeConsistency(themes) {
85
85
  }
86
86
  // Both gates resolve --psi-* names scale-first, so a semantic token whose
87
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)-/;
88
+ const SCALE_FAMILY_PREFIX = /^(space|size|radius|text|font|duration|ease|z|focus-ring)-/;
89
89
  /** D46 follow-up (HAN-21): reject semantic token kebab names that a
90
90
  * scale-family lookup would shadow before the semantic lookup runs. */
91
91
  export function validateNoScalePrefixShadow(kebabNames) {
@@ -155,7 +155,8 @@
155
155
  "$extensions": {
156
156
  "psi": {
157
157
  "scopes": [
158
- "text"
158
+ "text",
159
+ "border"
159
160
  ]
160
161
  }
161
162
  }
@@ -167,7 +168,8 @@
167
168
  "$extensions": {
168
169
  "psi": {
169
170
  "scopes": [
170
- "text"
171
+ "text",
172
+ "border"
171
173
  ]
172
174
  }
173
175
  }
@@ -607,6 +609,20 @@
607
609
  "$type": "dimension",
608
610
  "$value": "1.5rem"
609
611
  }
612
+ },
613
+ "focusRing": {
614
+ "width": {
615
+ "$type": "dimension",
616
+ "$value": "2px"
617
+ },
618
+ "offset": {
619
+ "$type": "dimension",
620
+ "$value": "2px"
621
+ },
622
+ "offsetInset": {
623
+ "$type": "dimension",
624
+ "$value": "-2px"
625
+ }
610
626
  }
611
627
  },
612
628
  "typography": {
@@ -155,7 +155,8 @@
155
155
  "$extensions": {
156
156
  "psi": {
157
157
  "scopes": [
158
- "text"
158
+ "text",
159
+ "border"
159
160
  ]
160
161
  }
161
162
  }
@@ -167,7 +168,8 @@
167
168
  "$extensions": {
168
169
  "psi": {
169
170
  "scopes": [
170
- "text"
171
+ "text",
172
+ "border"
171
173
  ]
172
174
  }
173
175
  }
@@ -607,6 +609,20 @@
607
609
  "$type": "dimension",
608
610
  "$value": "1.5rem"
609
611
  }
612
+ },
613
+ "focusRing": {
614
+ "width": {
615
+ "$type": "dimension",
616
+ "$value": "2px"
617
+ },
618
+ "offset": {
619
+ "$type": "dimension",
620
+ "$value": "2px"
621
+ },
622
+ "offsetInset": {
623
+ "$type": "dimension",
624
+ "$value": "-2px"
625
+ }
610
626
  }
611
627
  },
612
628
  "typography": {
@@ -155,7 +155,8 @@
155
155
  "$extensions": {
156
156
  "psi": {
157
157
  "scopes": [
158
- "text"
158
+ "text",
159
+ "border"
159
160
  ]
160
161
  }
161
162
  }
@@ -167,7 +168,8 @@
167
168
  "$extensions": {
168
169
  "psi": {
169
170
  "scopes": [
170
- "text"
171
+ "text",
172
+ "border"
171
173
  ]
172
174
  }
173
175
  }
@@ -607,6 +609,20 @@
607
609
  "$type": "dimension",
608
610
  "$value": "1.5rem"
609
611
  }
612
+ },
613
+ "focusRing": {
614
+ "width": {
615
+ "$type": "dimension",
616
+ "$value": "2px"
617
+ },
618
+ "offset": {
619
+ "$type": "dimension",
620
+ "$value": "2px"
621
+ },
622
+ "offsetInset": {
623
+ "$type": "dimension",
624
+ "$value": "-2px"
625
+ }
610
626
  }
611
627
  },
612
628
  "typography": {
@@ -155,7 +155,8 @@
155
155
  "$extensions": {
156
156
  "psi": {
157
157
  "scopes": [
158
- "text"
158
+ "text",
159
+ "border"
159
160
  ]
160
161
  }
161
162
  }
@@ -167,7 +168,8 @@
167
168
  "$extensions": {
168
169
  "psi": {
169
170
  "scopes": [
170
- "text"
171
+ "text",
172
+ "border"
171
173
  ]
172
174
  }
173
175
  }
@@ -607,6 +609,20 @@
607
609
  "$type": "dimension",
608
610
  "$value": "1.5rem"
609
611
  }
612
+ },
613
+ "focusRing": {
614
+ "width": {
615
+ "$type": "dimension",
616
+ "$value": "2px"
617
+ },
618
+ "offset": {
619
+ "$type": "dimension",
620
+ "$value": "2px"
621
+ },
622
+ "offsetInset": {
623
+ "$type": "dimension",
624
+ "$value": "-2px"
625
+ }
610
626
  }
611
627
  },
612
628
  "typography": {
@@ -54,14 +54,14 @@
54
54
  "Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.",
55
55
  "--psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).",
56
56
  "Wrap labeled form controls in Field — label association, description/error line, aria-describedby and aria-invalid come wired; don't hand-roll label+message rows.",
57
- "Field is for a control with a *visible* label. A toolbar filter control named by aria-label or placeholder takes neither a Field nor a hand-rolled label row — compare filter-toolbar (no labels) with table-pagination (\"Rows per page\" visible, so Field).",
57
+ "Field is for a control with a *visible* label. A toolbar filter control named by aria-label or placeholder takes neither a Field nor a hand-rolled label row — compare filter-toolbar (no labels) with table-pagination (\"Rows per page\" visible, so Field). Filters with visible labels submitted together by a Find button are filter-form: a Toolbar as=\"form\" align=\"start\" of Fields with one-line labels, where a field's error line is the Field's own and hangs below its control (D87, D92).",
58
58
  "Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group."
59
59
  ],
60
60
  "states": {
61
61
  "hover": "L - 0.04",
62
62
  "active": "L - 0.08",
63
63
  "disabled": "element opacity 0.4 (keeps hue)",
64
- "focus": "2px ring var(--psi-{component}-focus-ring)"
64
+ "focus": "outline: var(--psi-focus-ring-width) solid var(--psi-{component}-focus-ring) on :focus-visible, at --psi-focus-ring-offset (outside) or --psi-focus-ring-offset-inset (inside) — D82"
65
65
  },
66
66
  "typographyDefaults": {
67
67
  "body": "16-24-regular",
@@ -157,7 +157,8 @@
157
157
  "hex": "#007048",
158
158
  "formula": "slot(success) → L set(0.48), C cap(0.1086)",
159
159
  "scopes": [
160
- "text"
160
+ "text",
161
+ "border"
161
162
  ]
162
163
  },
163
164
  "fgWarning": {
@@ -170,7 +171,8 @@
170
171
  "hex": "#775800",
171
172
  "formula": "slot(warning) → L set(0.48), C cap(0.0984)",
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": "#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"
@@ -107,10 +107,12 @@
107
107
  "text"
108
108
  ],
109
109
  "fg-success": [
110
- "text"
110
+ "text",
111
+ "border"
111
112
  ],
112
113
  "fg-warning": [
113
- "text"
114
+ "text",
115
+ "border"
114
116
  ],
115
117
  "fg-danger": [
116
118
  "text",
@@ -191,6 +193,39 @@
191
193
  ]
192
194
  },
193
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
+ ],
194
229
  "button-accent-bg": [
195
230
  "surface"
196
231
  ],
@@ -341,6 +376,9 @@
341
376
  "field-marker-fg": [
342
377
  "text"
343
378
  ],
379
+ "field-required-text-fg": [
380
+ "text"
381
+ ],
344
382
  "input-bg": [
345
383
  "surface"
346
384
  ],
@@ -356,6 +394,45 @@
356
394
  "input-border-error": [
357
395
  "border"
358
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
+ ],
359
436
  "menu-bg": [
360
437
  "surface"
361
438
  ],
@@ -422,6 +499,33 @@
422
499
  "select-chevron-fg": [
423
500
  "text"
424
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
+ ],
425
529
  "surface-bg": [
426
530
  "surface"
427
531
  ],
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handamade/psi-tokens",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {