@handamade/psi-react 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +1 -1
  2. package/dist/AppShell/AppShell.d.ts +51 -0
  3. package/dist/AppShell/AppShell.d.ts.map +1 -0
  4. package/dist/Banner/Banner.d.ts +35 -0
  5. package/dist/Banner/Banner.d.ts.map +1 -0
  6. package/dist/Button/Button.d.ts +4 -0
  7. package/dist/Button/Button.d.ts.map +1 -1
  8. package/dist/CopyButton/CopyButton.d.ts +23 -0
  9. package/dist/CopyButton/CopyButton.d.ts.map +1 -0
  10. package/dist/CursorPagination/CursorPagination.d.ts +29 -0
  11. package/dist/CursorPagination/CursorPagination.d.ts.map +1 -0
  12. package/dist/Field/Field.d.ts +11 -1
  13. package/dist/Field/Field.d.ts.map +1 -1
  14. package/dist/Field/useFieldControl.d.ts +40 -0
  15. package/dist/Field/useFieldControl.d.ts.map +1 -0
  16. package/dist/InlineAlert/InlineAlert.d.ts +32 -0
  17. package/dist/InlineAlert/InlineAlert.d.ts.map +1 -0
  18. package/dist/NavBar/NavBar.d.ts +10 -2
  19. package/dist/NavBar/NavBar.d.ts.map +1 -1
  20. package/dist/NavTree/NavGroup.d.ts +22 -0
  21. package/dist/NavTree/NavGroup.d.ts.map +1 -0
  22. package/dist/NavTree/NavItem.d.ts +25 -0
  23. package/dist/NavTree/NavItem.d.ts.map +1 -0
  24. package/dist/NavTree/NavTree.d.ts +23 -0
  25. package/dist/NavTree/NavTree.d.ts.map +1 -0
  26. package/dist/Skeleton/Skeleton.d.ts +22 -0
  27. package/dist/Skeleton/Skeleton.d.ts.map +1 -0
  28. package/dist/SkipLink/SkipLink.d.ts +25 -0
  29. package/dist/SkipLink/SkipLink.d.ts.map +1 -0
  30. package/dist/Table/Table.d.ts +6 -3
  31. package/dist/Table/Table.d.ts.map +1 -1
  32. package/dist/Table/TableBody.d.ts +7 -4
  33. package/dist/Table/TableBody.d.ts.map +1 -1
  34. package/dist/Table/TableCaption.d.ts +21 -0
  35. package/dist/Table/TableCaption.d.ts.map +1 -0
  36. package/dist/Table/TableCell.d.ts +15 -5
  37. package/dist/Table/TableCell.d.ts.map +1 -1
  38. package/dist/Table/TableHead.d.ts +5 -3
  39. package/dist/Table/TableHead.d.ts.map +1 -1
  40. package/dist/Table/TableHeaderCell.d.ts +5 -3
  41. package/dist/Table/TableHeaderCell.d.ts.map +1 -1
  42. package/dist/Table/TableRow.d.ts +5 -3
  43. package/dist/Table/TableRow.d.ts.map +1 -1
  44. package/dist/Toast/Announcement.d.ts +30 -0
  45. package/dist/Toast/Announcement.d.ts.map +1 -0
  46. package/dist/Toast/Toast.d.ts +16 -2
  47. package/dist/Toast/Toast.d.ts.map +1 -1
  48. package/dist/Toast/ToastProvider.d.ts.map +1 -1
  49. package/dist/Toast/ToastRegion.d.ts +16 -7
  50. package/dist/Toast/ToastRegion.d.ts.map +1 -1
  51. package/dist/Toast/status.d.ts +15 -0
  52. package/dist/Toast/status.d.ts.map +1 -0
  53. package/dist/Toast/useToast.d.ts +6 -1
  54. package/dist/Toast/useToast.d.ts.map +1 -1
  55. package/dist/Toolbar/Toolbar.d.ts +16 -4
  56. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  57. package/dist/a11y-meta.d.ts.map +1 -1
  58. package/dist/index.d.ts +29 -3
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1377 -1065
  61. package/dist/manifest.json +1033 -240
  62. package/dist/patterns.json +671 -3
  63. package/dist/styles.css +1 -1
  64. package/docs/Announcement.md +37 -0
  65. package/docs/AppShell.md +46 -0
  66. package/docs/AspectRatio.md +1 -1
  67. package/docs/Banner.md +54 -0
  68. package/docs/Button.md +2 -1
  69. package/docs/Card.md +1 -1
  70. package/docs/Checkbox.md +1 -1
  71. package/docs/CopyButton.md +51 -0
  72. package/docs/CursorPagination.md +45 -0
  73. package/docs/DescriptionItem.md +1 -1
  74. package/docs/DescriptionList.md +1 -1
  75. package/docs/Dialog.md +1 -1
  76. package/docs/Field.md +3 -2
  77. package/docs/IconButton.md +1 -1
  78. package/docs/InlineAlert.md +53 -0
  79. package/docs/Input.md +1 -1
  80. package/docs/Menu.md +1 -1
  81. package/docs/MenuItem.md +1 -1
  82. package/docs/MenuSeparator.md +1 -1
  83. package/docs/NavBar.md +4 -2
  84. package/docs/NavGroup.md +40 -0
  85. package/docs/NavItem.md +40 -0
  86. package/docs/NavTree.md +39 -0
  87. package/docs/Pagination.md +1 -1
  88. package/docs/Panel.md +1 -1
  89. package/docs/Select.md +1 -1
  90. package/docs/Skeleton.md +50 -0
  91. package/docs/SkipLink.md +40 -0
  92. package/docs/Switch.md +1 -1
  93. package/docs/Tab.md +1 -1
  94. package/docs/TabList.md +1 -1
  95. package/docs/TabPanel.md +1 -1
  96. package/docs/Table.md +1 -1
  97. package/docs/TableBody.md +3 -2
  98. package/docs/TableCaption.md +39 -0
  99. package/docs/TableCell.md +5 -2
  100. package/docs/TableHead.md +2 -1
  101. package/docs/TableHeaderCell.md +2 -1
  102. package/docs/TableRow.md +2 -1
  103. package/docs/Tabs.md +1 -1
  104. package/docs/Tag.md +1 -1
  105. package/docs/Toast.md +5 -3
  106. package/docs/ToastProvider.md +1 -1
  107. package/docs/ToastRegion.md +4 -4
  108. package/docs/Toolbar.md +5 -3
  109. package/docs/Tooltip.md +1 -1
  110. package/llms.txt +39 -9
  111. package/package.json +2 -2
package/docs/Toolbar.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <!-- Generated by emit-docs.ts — do not edit. -->
2
2
  # Toolbar
3
3
 
4
- Horizontal grouping row for filter/search controls (D52). Wraps on overflow; zero JS. Deliberately NOT ARIA role="toolbar" — that role contracts roving-tabindex arrow-key navigation, wrong for form controls. With aria-label it announces as role="group".
4
+ Horizontal grouping row for filter/search controls (D52). Wraps on overflow; zero JS. Deliberately NOT ARIA role="toolbar" — that role contracts roving-tabindex arrow-key navigation, wrong for form controls. With aria-label it announces as role="group"; as a form, as a named form landmark instead (D87).
5
5
 
6
6
  ## Props
7
7
 
@@ -9,7 +9,9 @@ Horizontal grouping row for filter/search controls (D52). Wraps on overflow; zer
9
9
  |---|---|---|---|---|
10
10
  | `children` | `ReactNode` | — | no | The controls. |
11
11
  | `gap` | `8 \| 12 \| 16` | 8 | no | Gap between controls in px. |
12
- | `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the root element. |
12
+ | `align` | `"center" \| "end"` | center | no | Cross-axis alignment. `end` lines items up on their bottom edge, so controls under visible Field labels and a button without one share a control line (D87). It lines up each item's *last* line: a Field in an end-aligned row must end on its control — no description or error line under it; put a hint in the label. |
13
+ | `as` | `"div" \| "form"` | div | no | Root element. `form` renders a real `<form>`, so a submit Button submits it and Enter in a field does too; with `aria-label` it is a named form landmark, so it takes no `role="group"` (D87). Wire `onSubmit`. |
14
+ | `ref` | `Ref<HTMLDivElement \| HTMLFormElement>` | — | no | Forwarded ref to the root element. |
13
15
  | `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |
14
16
 
15
17
  ## Keyboard & assistive tech
@@ -35,5 +37,5 @@ Override `--psi-toolbar-*` custom properties at any scope.
35
37
  - Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.
36
38
  - --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).
37
39
  - 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.
38
- - 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).
40
+ - 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="end" of Fields (D87).
39
41
  - Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.
package/docs/Tooltip.md CHANGED
@@ -35,5 +35,5 @@ Override `--psi-tooltip-*` custom properties at any scope.
35
35
  - Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.
36
36
  - --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).
37
37
  - 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.
38
- - 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).
38
+ - 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="end" of Fields (D87).
39
39
  - Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.
package/llms.txt CHANGED
@@ -1,10 +1,10 @@
1
1
  # @handamade/psi-react
2
2
 
3
- 34 React 19 components consuming @handamade/psi-tokens. Zero runtime deps, CSS Modules, ref-as-prop.
3
+ 46 React 19 components consuming @handamade/psi-tokens. Zero runtime deps, CSS Modules, ref-as-prop.
4
4
 
5
5
  ## Machine-readable artifacts
6
6
  - dist/manifest.json: full component/prop inventory with types and defaults
7
- - dist/patterns.json: 13 composition patterns (D47) — gap-annotated recipes with clarifying parameters; presets are generated JSX, never hand-written
7
+ - dist/patterns.json: 22 composition patterns (D47) — gap-annotated recipes with clarifying parameters; presets are generated JSX, never hand-written
8
8
  - docs/{Component}.md: per-component docs (props, theming, variant guidance)
9
9
 
10
10
  ## Rules for generated code
@@ -13,6 +13,7 @@
13
13
  - size is a px number (24|32|40|48), never "sm"/"md"/"lg".
14
14
  - variant vocabulary: accent | accent-subtle | neutral | neutral-subtle | ghost | danger | danger-subtle | outline.
15
15
  - One accent per visual group. danger only for destructive actions.
16
+ - Every focusable part Psi renders draws one focus ring on `:focus-visible` (D82). For your own focusable element, bind the same tokens rather than writing a literal: `outline: var(--psi-focus-ring-width) solid var(--psi-border-focus); outline-offset: var(--psi-focus-ring-offset)` (or `--psi-focus-ring-offset-inset` to draw it inside). Never `outline: none`.
16
17
  - Before composing multi-component UI, consult dist/patterns.json (or psi-mcp search) for a matching pattern and honor its clarifying parameters; presets are copy-paste JSX generated from the compose tree.
17
18
  - Pattern `content` text in `[square brackets]` is a placeholder to replace with real copy. Never `<angle brackets>` or `{braces}` — those do not parse as JSX text, so the build rejects them (D48 class 9).
18
19
 
@@ -34,9 +35,15 @@
34
35
 
35
36
  ## Toolbar
36
37
  - <Toolbar gap={8|12|16} aria-label="Filters">: horizontal wrapping row for filter/search controls (Input, Select, Tag, Button). Wraps on overflow (D52); with aria-label it announces as role=group. Deliberately not ARIA role=toolbar (no roving tabindex). Unblocks the filter-toolbar pattern — consult dist/patterns.json for the preset.
38
+ - `align="end"` lines items up on their bottom edge, so Field-wrapped controls (label above) and a button without a label share one control line (D87). It aligns each item's last line: a Field in that row must end on its control — no description or error under it.
39
+ - `as="form"` renders a real `<form>`: a `Button type="submit"` and Enter in a field submit it; wire `onSubmit`. With `aria-label` it is a named form landmark (no role=group). Filters that apply as they change, named by aria-label → filter-toolbar; labelled filters submitted together with *Find* → filter-form. Never name a control by its placeholder alone.
37
40
 
38
41
  ## NavBar
39
42
  - <NavBar brand={...} actions={...}>{links}</NavBar>: brand slot leading, nav links centered/inline, actions slot trailing (theme switch, CTA). Renders inside .psi-container. Background defaults to --psi-scrim-heavy so content can scroll underneath; border uses --psi-border-faint.
43
+ - `navLabel` names the links' `<nav>` (rest props land on `<header>`, so it cannot be labelled any other way). `<nav>` renders only when there are children: a brand-and-actions bar has no empty landmark, and `actions` keeps the trailing edge. `fluid` drops .psi-container for a full-width row that keeps the `--psi-gutter` side padding — an application header (D88).
44
+
45
+ ## SkipLink
46
+ - <SkipLink href="#main">Skip to content</SkipLink>: a plain anchor, no script, render it first in the document. Invisible until it takes keyboard focus, then fixed at the inline-start top corner on a surface with the shared focus ring. Fragment navigation moves focus only to a focusable target — give `<main id="main" tabIndex={-1}>` (AppShell's main will be one). A modal Dialog makes it inert; do not place a ToastRegion at `top-start` in an app with a skip link, because the top layer paints over it (D88).
40
47
 
41
48
  ## AspectRatio
42
49
  - <AspectRatio ratio={16/10}>{children}</AspectRatio>: a frame div with CSS aspect-ratio set from the numeric ratio prop — wrap images/media to reserve layout space before load. Combine with the .psi-media-tint utility for the brand media-tint treatment.
@@ -44,26 +51,49 @@
44
51
  ## Icons (26)
45
52
  - Full icon set: IconPlus, IconMinus, IconCheck, IconClose, IconChevronDown, IconChevronRight, IconSearch, IconSettings, IconUser, IconEye, IconEyeOff, IconEdit, IconCopy, IconTrash, IconLoader, IconExternalLink, plus D36 additions — arrows IconArrowDown and IconArrowUpRight, and social glyphs IconLinkedIn, IconGitHub, IconX, IconInstagram — plus the D64 status glyphs IconInfo, IconAlertTriangle and IconAlertCircle, and the D70 IconMoreHorizontal — the ellipsis glyph for a row-actions trigger.
46
53
 
47
- ## Table family (6)
54
+ ## Table family (7)
48
55
  - `<Table>` renders `<table>` and is **controlled-only** — it holds no state (D62). Sorting is `sort={{key, direction}}` + `onSortChange`, which is called with the **next** state, already toggled — store it as given (a fresh column arrives `asc`; an active `asc` emits `desc`); selection is `selected` (a `ReadonlySet<string>` keyed by each `<TableRow rowId>`) + `onSelectionChange`. `sortable` and `selectable` only enable the affordances. `size` is `32 | 40 | 48` px (default 40) and `stickyHeader` pins the header. It does no filtering, sorting or slicing of rows — the consumer does that.
49
56
  - `<TableHead>`, `<TableBody>`, `<TableRow>` render `<thead>`, `<tbody>`, `<tr>`. `<TableHeaderCell>` renders `<th scope="col">`, taking `sortKey` to become sortable and putting `aria-sort` on the `<th>`. `<TableCell>` renders `<td>`. Both cells take `numeric`, which right-aligns **and** renders tabular figures.
57
+ - `<TableCaption detail="Rows 51–100 of more than 100,000">Applications</TableCaption>` renders `<caption>` as the table's first child — its visible name, plus a `detail` line for the range (D85). For a token-paged list give it `tabIndex={-1}` and move focus to it (through its `ref`) after a page change, so the new range is read. `<TableCell colSpan={n}>` spans an empty-state row; `<TableCell rowHeader>` renders `<th scope="row">` for the cell that names its row. Every family member spreads its remaining attributes onto its element (`aria-busy` on `TableBody` while a page loads, `aria-label`, `id`, `data-*`) and takes a `ref`; its own attributes (`data-size`, `scope`, `aria-sort`) win.
50
58
  - Selecting rows: give each `<TableRow>` a `rowId` and a `selectLabel` — the row checkbox has no visible label, so `selectLabel` is its accessible name. `<TableHead>` grows the select-all checkbox automatically; do not add one. Consult dist/patterns.json for the `data-table` pattern.
51
59
 
52
- ## Pagination
60
+ ## Pagination and CursorPagination
53
61
  - `<Pagination page={n} pageCount={total} onPageChange={...}>`: a numbered pager with ellipsis truncation (`‹ 1 … 4 5 6 … 13 ›`), built from Button/IconButton (D63). `siblingCount` (default 1) sets how many pages flank the current one before truncating; `aria-current="page"` marks the active page and the ellipsis is inert. `page` outside `[1, pageCount]` (including non-finite values) is clamped for rendering and warns in development (D78).
54
62
  - It is **standalone, not a Table family member** and has no connection to `Table` — `Table` has no `page` prop. The `table-pagination` pattern composes it as a `Toolbar` sibling of a page-size `Select`; the consumer owns page state and slices the rows.
63
+ - `<CursorPagination hasPrevious hasNext onPrevious={...} onNext={...}>` is the pager for a list that pages by opaque token — a backend returning a `nextPageToken`, no page count (D85). A `<nav>` of two ghost Buttons with **visible words**, *Previous* and *Next* (`previousLabel`/`nextLabel` to translate, `aria-label` for the nav), each with a chevron that only reinforces the word; an unavailable direction is disabled. It holds no state: the consumer holds the tokens. Not a mode of `Pagination`, which needs a page count. Consult dist/patterns.json for the `cursor-pagination` pattern, and pair it with a `TableCaption` that states the range.
55
64
 
56
65
  ## Dialog, and drawers (there is no Drawer component)
57
66
  - `<Dialog open onClose={(reason) => …} title footer width={400|560|720} dismissible>`: modal on the native `<dialog>` top layer. **Controlled-only** (D50) — every dismissal path (`"esc"`, `"backdrop"`, `"close-button"`) only *reports* via `onClose(reason)`; the consumer flips `open`. `dismissible={false}` swallows Esc and backdrop, leaving the footer as the only exit.
58
67
  - **A drawer / side sheet is `<Dialog placement="inline-end">`, not a separate component (D66).** `placement` is `center | inline-start | inline-end`; the `inline-*` values pin the panel full-height to that edge. Do not import `Drawer` — it does not exist and never will. `width` is then the drawer's width; the height is always the viewport.
59
68
  - Placement changes position and nothing else: modality, the focus trap, `aria-modal`, focus restore and the dismissal reasons are identical. Logical (`inline-*`, not left/right), so RTL flips for free. Consult dist/patterns.json for the `detail-drawer` pattern.
60
69
 
61
- ## Toast family (3) + useToast
62
- - `<Toast variant="neutral|success|warning|danger" action={...} onDismiss={...}>message</Toast>` is presentational and **controlled** (D64): it holds no state, runs no timer and never removes itself — `onDismiss` reports and the owner disposes. Variants are Tag's **status** axis, not the flat action variants; there is deliberately no `accent`. The variant's meaning is announced by a visually hidden status word, so never rely on the icon colour alone.
63
- - `<ToastRegion placement="bottom-end">` is the positioned container and must wrap every toast. It renders **two always-present live wrappers** (`role="status"` polite, `role="alert"` assertive) and routes each toast by variant — neutral/success polite, warning/danger assertive. It sits on the native top layer via `popover="manual"`, so a toast raised from inside a modal `Dialog` is still painted above the backdrop and still announced — though not clickable until the dialog closes, since `showModal()` makes everything outside the dialog inert. Raise toasts *after* the dialog closes if they carry an `action`.
64
- - `<ToastProvider limit={3} duration={5000} actionDuration={10000}>` owns the queue, the timers and the single region — the library's **one stateful container** (D65), and opt-in. Inside it, `const toast = useToast()` gives `show({variant, message, action}) => id`, `dismiss(id)` and `clear()`. Timers pause while the pointer or focus is in the region and resume with the time remaining (WCAG 2.2.1); toasts carrying an action get the longer lifetime.
70
+ ## Feedback (4)
71
+ - `<Banner variant="neutral|success|warning|danger" title action onDismiss statusLabel>message</Banner>` is **not a live region** (D86): a page-level message, full width, above the content. It shows the message and announces nothing; speak the same event through the announcer. Same status vocabulary as Toast; `action` takes a ghost Button, `onDismiss` renders a labelled Dismiss button, `statusLabel` replaces or drops the hidden status word. Consult dist/patterns.json for `page-banner`.
72
+ - `<InlineAlert variant title>message</InlineAlert>` is **not a live region** (D86), and despite its name it is **not `role="alert"`**: a message inside content, bordered in the variant's foreground colour. Put it above a form after a rejected submission and speak the result separately. Consult dist/patterns.json for `form-feedback`.
73
+ - `<Skeleton>` is **not a live region** (D86): a loading placeholder that is always `aria-hidden`, so it says nothing. Put `aria-busy` on the region it stands in for (a `TableBody`, a list) — that is what assistive tech reads. The shimmer stops under `prefers-reduced-motion`. Consult dist/patterns.json for `loading-table`.
74
+ - `<CopyButton value label size onCopy>` is **not a live region** (D86): a Button with a visible label that copies `value` to the clipboard and reports `"copied" | "failed"` through `onCopy`. It never changes its label or icon and announces nothing; speak the result through the announcer or a Toast. Consult dist/patterns.json for `copyable-id`.
75
+
76
+ ## Toast family (4) + useToast
77
+ - `<Toast variant="neutral|success|warning|danger" action={...} onDismiss={...}>message</Toast>` is presentational and **controlled** (D64): it holds no state, runs no timer and never removes itself — `onDismiss` reports and the owner disposes. Variants are Tag's **status** axis, not the flat action variants; there is deliberately no `accent`. The variant's meaning is announced by a visually hidden status word, so never rely on the icon colour alone; `statusLabel="Fehler:"` replaces that word with a translated one and `statusLabel={null}` drops it (D83).
78
+ - `<ToastRegion placement="bottom-end">` is the positioned container and must wrap every toast. It renders **two always-present live wrappers** (`role="status"` polite, `role="alert"` assertive) and routes each child into one: by its `politeness` prop when it names one, otherwise by variant — neutral/success polite, warning/danger assertive. Set `politeness="assertive"` on a success toast when the outcome of the user's own action should interrupt (D83). Whatever it holds, the region is exactly two live regions. Other HTML attributes, `data-*` included, land on its root element: mark it `data-react-aria-top-layer` if the app uses React Aria overlays, or they hide it. It sits on the native top layer via `popover="manual"`, so a toast raised from inside a modal `Dialog` is still painted above the backdrop and still announced — though not clickable until the dialog closes, since `showModal()` makes everything outside the dialog inert. Raise toasts *after* the dialog closes if they carry an `action`.
79
+ - `<ToastProvider limit={3} duration={5000} actionDuration={10000}>` owns the queue, the timers and the single region — the library's **one stateful container** (D65), and opt-in. Inside it, `const toast = useToast()` gives `show({variant, message, action, politeness, statusLabel}) => id`, `dismiss(id)` and `clear()`. Timers pause while the pointer or focus is in the region and resume with the time remaining (WCAG 2.2.1); toasts carrying an action get the longer lifetime.
80
+ - `<Announcement politeness="polite|assertive">text</Announcement>` is **not a live region** (D83). It is visually hidden text that `ToastRegion` places in its polite (default) or assertive wrapper — for an event that must be spoken and has nothing to show, such as a state change of the object on screen, or field errors the form already displays. It renders no `role` and no `aria-live`, so outside a `ToastRegion` it announces nothing: never use it as a second announcer. It never removes itself; remove it once spoken, and to announce the same text again render it again with a new `key`. Consult dist/patterns.json for the `announcer` pattern.
65
81
  - **Do not hand-roll a queue or a `setTimeout`** — that is what `ToastProvider` is for. Do not render a bare `<Toast>` outside a `ToastRegion`: it would announce nothing. `useToast()` throws outside a provider rather than silently doing nothing.
66
82
 
83
+ ## NavTree family (3)
84
+ - `<NavTree aria-label="Main">` is a `<nav>` around a `<ul>` of `<NavItem>` and `<NavGroup>` (D88). `aria-label` is **required**: an app has more than one navigation landmark. It is native and router-agnostic — **Tab** moves through group buttons and the links of open groups; there is no roving tabindex, no arrow keys and no `role="menu"` or `role="tree"`.
85
+ - `<NavItem>` is an `<li>` that styles the one anchor inside it: pass your router's link, with `aria-current="page"` on the current one. The current page shows by weight, a raised surface and an inline-start bar, never by colour alone. `<NavItem href="#x" current>Text</NavItem>` renders the anchor itself (`aria-current="page"` when `current`): for plain links and presets, where a compose tree can hold only manifest components; router apps keep passing their own link as the child.
86
+ - `<NavGroup label open onOpenChange>` is **controlled-only**, like Tabs: `open` and `onOpenChange` are required and it never opens itself. It renders a `<button aria-expanded aria-controls>` over a `<ul>` that is `hidden` when closed, so `aria-controls` always resolves. The chevron is `aria-hidden` and turns on a duration token.
87
+ - Tokens `--psi-nav-tree-*` alias the semantic layer, so under a `data-psi-theme="dark"` sidebar in a light page they resolve dark. The theme paints no background: the sidebar paints `--psi-bg-primary` itself.
88
+
89
+ ## AppShell (D88)
90
+ - `<AppShell skipLink header sidebar sidebarOpen sidebarTheme>` is the **frame of an application**: a grid filling the viewport (`100dvh`) with the header in a row of its own over a sidebar and a `<main>` that **each scroll on their own**. Nothing scrolls under the header, so a focused element is never obscured by it (WCAG 2.2 *Focus Not Obscured*) — no `scroll-padding` needed, and none would work: measured on a `main` that is not the document's scroller, it does nothing. It is a **desktop frame**: no responsive drawer, no stored open state. A page whose document must scroll as a whole does not use it. Mount it at the root of the page with no margin or padding around it (it is `100dvh` tall, so anything around it makes the document scroll and slides the frame). Consult dist/patterns.json for the `app-shell` pattern.
91
+ - Slots: `skipLink` (a `<SkipLink href="#main">`, rendered first), `header` (a `<NavBar fluid>`), `sidebar` (a `<NavTree>`), and `children` as main's content. `main` carries `id="main"` (`mainId`) and `tabIndex={-1}`: the skip link's target, not a tab stop. The sidebar is a `<div id="sidebar">` (`sidebarId`), not an `<aside>`: the `NavTree` inside is already the landmark.
92
+ - `sidebarOpen` (default `true`) is **controlled**: wire your Menu toggle's `aria-expanded`, `aria-controls="sidebar"` and `sidebarOpen` to one piece of state. Closed, the sidebar is `hidden` (out of the layout and the accessibility tree, still in the DOM so `aria-controls` resolves) and `main` takes the full width.
93
+ - `sidebarTheme="dark"` sets `data-psi-theme` on the sidebar alone; its tokens re-resolve dark inside a light page, and the sidebar paints `--psi-bg-primary` itself (a theme paints no background). Tokens `--psi-app-shell-*`.
94
+ - `NavItem` takes `href` and `current` for plain links and presets: `<NavItem href="#sales" current>Sales</NavItem>` renders the anchor with `aria-current="page"`. A compose tree can hold only manifest components, so a preset cannot put a raw `<a>` in a `NavItem`; router apps keep passing their own link as the child.
95
+ - **Do not put a `ToastRegion` at `top-start` in an app with a skip link:** the native top layer paints over the focused link. Its default, `bottom-end`, is clear of it.
96
+
67
97
  ## Tabs family (4)
68
98
  - `<Tabs value={v} onValueChange={setV} orientation="horizontal|vertical">` is **controlled-only** (D67): `value` and `onValueChange` are required, there is no `defaultValue`, and Tabs never selects itself. Compose `<TabList aria-label="…">` with one `<Tab value>` each, then one `<TabPanel value>` per tab.
69
99
  - **Values are strings, not indices**, and `Tab`/`TabPanel` pair by value — their source order need not match, though keeping it matching reads better.
@@ -77,7 +107,7 @@
77
107
 
78
108
  ## Compositions (recipes for what has no component yet)
79
109
 
80
- - Form field: wrap the label, control, and message with `<Field label description error required>{control}</Field>` — label association (htmlFor/id), the description/error message line (swaps to error, aria-live="polite"; `announce={false}` drops the live region for an app that routes every announcement through one announcer of its own — the message still describes the control, D81), and aria-describedby/aria-invalid wiring onto a nested Input/Select are automatic (D49). `group` mode renders fieldset/legend for self-labeled controls (Checkbox/Switch). Standalone Input/Select used outside Field still need `aria-invalid` and `aria-describedby` paired manually. Spacing: `psi-gap-24` between fields, `psi-gap-12` for toggle groups, `psi-gap-8` for button rows and label+Tag rows.
110
+ - Form field: wrap the label, control, and message with `<Field label description error required>{control}</Field>` — label association (htmlFor/id), the description/error message line (swaps to error, aria-live="polite"; `announce={false}` drops the live region for an app that routes every announcement through one announcer of its own — the message still describes the control, D81), and aria-describedby/aria-invalid wiring onto a nested Input/Select are automatic (D49). `required` marks the label with an asterisk; `requiredText="(Required)"` states it in a word instead, the label's colour, aria-hidden because the control's `required` is the signal (D84). A control Psi did not write joins a Field with `const field = useFieldControl()` spread onto its focusable element — it returns `id`, `aria-labelledby` (a `<label for>` names only a labelable element, and a custom control is usually a div), `aria-describedby`, `aria-invalid`, `aria-required` and `required` as the Field sets them, and `{}` outside a Field (D84); the element needs a role that admits aria-required, such as combobox. `group` mode renders fieldset/legend for self-labeled controls (Checkbox/Switch). Standalone Input/Select used outside Field still need `aria-invalid` and `aria-describedby` paired manually. Spacing: `psi-gap-24` between fields, `psi-gap-12` for toggle groups, `psi-gap-8` for button rows and label+Tag rows.
81
111
  - Stacks/rows: `.psi-gap-*` utilities set only the gap — they do not create a flex/grid container. Pair them with `display: flex` (column for stacks, row for button groups).
82
112
  - Page-level spacing: the form scale above (24/12/8) governs controls inside a form, not page layout. For layout use `psi-gap-32` between sections of a page, `psi-gap-24` between cards or panels in a grid, `psi-py-32` for a section's vertical padding, and `.psi-container` for the page's max-width and gutters (it is in utilities.css, not components.css). Marketing-scale rhythm is app-level and deliberately not tokenized — the system stops at the container.
83
113
  - Utility classes: families are `psi-gap-*`, `psi-p-*`/`psi-px-*`/`psi-py-*`, `psi-m-*`/`psi-mx-*`/`psi-my-*` (all on the spacing scale), `psi-text-*` and `psi-display-*` (typography), plus `.psi-tabular`, `.psi-container`, `.psi-media-tint` and `.psi-sr-only` (hides content visually while keeping it in the accessibility tree). Scaled utility families set one property each and create no layout context — pair `psi-gap-*` with `display: flex`; the bare/fixed utilities (`.psi-container`, `.psi-media-tint`, `.psi-sr-only`) set several. Use `psi-m-0` to kill a UA margin rather than an inline style. The exact roster is `@handamade/psi-tokens/guidance.json` → `utilities.classes`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handamade/psi-react",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "tsx": "^4.22.4",
47
47
  "vite": "^6.0.0",
48
48
  "vitest": "^3.2.6",
49
- "@handamade/psi-tokens": "0.20.0"
49
+ "@handamade/psi-tokens": "0.21.0"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "vite build && tsc -p tsconfig.build.json && tsx scripts/emit-styles-dts.ts && tsx scripts/emit-manifest.ts && tsx scripts/emit-patterns.ts && tsx scripts/emit-docs.ts",