@handamade/psi-react 0.26.0 → 0.28.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/docs/Stack.md ADDED
@@ -0,0 +1,40 @@
1
+ <!-- Generated by emit-docs.ts — do not edit. -->
2
+ # Stack
3
+
4
+ A column with a gap from the space scale (D109): Toolbar's vertical counterpart, minus the wrap. A plain <div> with no role. It gives a pattern tree a layout context, which Panel and the psi-gap-* utilities do not.
5
+
6
+ ## Props
7
+
8
+ | Prop | Type | Default | Required | Description |
9
+ |---|---|---|---|---|
10
+ | `children` | `ReactNode` | — | no | Stack contents, laid out top to bottom. |
11
+ | `gap` | `4 \| 8 \| 12 \| 16 \| 24 \| 32` | 16 | no | Space between children in px, from the space scale. |
12
+ | `align` | `"stretch" \| "start" \| "center" \| "end"` | stretch | no | Cross-axis alignment. `stretch` (the default) makes each child full width; `start` keeps a Button or a short line at its own width. |
13
+ | `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the root element. |
14
+ | `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |
15
+
16
+ ## Keyboard & assistive tech
17
+
18
+ | Keys | Behavior |
19
+ |---|---|
20
+ | Tab | Skipped — Stack itself is not focusable; focus moves through its children in source order. |
21
+
22
+ Plain <div> with no role (D109): a column with a gap. Visual order is source order, so reading and focus order match.
23
+
24
+ ## Theming
25
+
26
+ This component has no `--psi-stack-*` tokens — its styling binds scale tokens only, so there is nothing component-scoped to override. Theme changes reach it through the semantic tokens of its children and surroundings.
27
+
28
+
29
+
30
+ ## Rules
31
+
32
+ - One accent per visual group; everything else neutral or ghost.
33
+ - danger only for actions with real consequences.
34
+ - Sizes are px numbers (24|32|40|48), never S/M/L.
35
+ - Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.
36
+ - Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.
37
+ - --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).
38
+ - 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.
39
+ - 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).
40
+ - Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.
package/docs/Text.md ADDED
@@ -0,0 +1,42 @@
1
+ <!-- Generated by emit-docs.ts — do not edit. -->
2
+ # Text
3
+
4
+ A line or paragraph of text at one of Psi's text styles (D109). It gives a pattern tree an element per line, so a headline and its explanation do not run together. Margin 0: a Stack's gap does the spacing.
5
+
6
+ ## Props
7
+
8
+ | Prop | Type | Default | Required | Description |
9
+ |---|---|---|---|---|
10
+ | `children` | `ReactNode` | — | no | The text. |
11
+ | `as` | `"p" \| "span" \| "div" \| "h2" \| "h3" \| "h4"` | p | no | The element. A heading's level follows the document outline, its size the design, so `as` and `size` are separate. |
12
+ | `size` | `12 \| 14 \| 16 \| 18` | 14 | no | Font size in px; each size carries its own line height. |
13
+ | `weight` | `"regular" \| "medium"` | regular | no | |
14
+ | `tone` | `"primary" \| "secondary"` | primary | no | `secondary` for supporting text. |
15
+ | `ref` | `Ref<HTMLElement>` | — | no | Forwarded ref to the element. |
16
+ | `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |
17
+
18
+ ## Keyboard & assistive tech
19
+
20
+ | Keys | Behavior |
21
+ |---|---|
22
+ | Tab | Not focusable. |
23
+
24
+ Renders the element `as` names (p by default). Use h2–h4 for a heading at its outline level, whatever its `size`.
25
+
26
+ ## Theming
27
+
28
+ Override `--psi-text-*` custom properties at any scope.
29
+
30
+
31
+
32
+ ## Rules
33
+
34
+ - One accent per visual group; everything else neutral or ghost.
35
+ - danger only for actions with real consequences.
36
+ - Sizes are px numbers (24|32|40|48), never S/M/L.
37
+ - Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.
38
+ - Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.
39
+ - --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).
40
+ - 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.
41
+ - 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).
42
+ - 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
@@ -8,7 +8,7 @@ Short hover/focus hint attached to a single trigger element.
8
8
  | Prop | Type | Default | Required | Description |
9
9
  |---|---|---|---|---|
10
10
  | `content` | `ReactNode` | — | yes | Tooltip content. |
11
- | `children` | `ReactElement<Record<string, unknown>, string \| JSXElementConstructor<any>>` | — | yes | The trigger element (must accept ref, onMouseEnter/Leave, onFocus/Blur). |
11
+ | `children` | `ReactElement<Record<string, unknown>, string \| JSXElementConstructor<any>>` | — | yes | The trigger element (must accept onFocus/Blur). |
12
12
  | `placement` | `"top" \| "bottom" \| "left" \| "right"` | top | no | Preferred placement. |
13
13
 
14
14
  ## Keyboard & assistive tech
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @handamade/psi-react
2
2
 
3
- 50 React 19 components consuming @handamade/psi-tokens. Zero runtime deps, CSS Modules, ref-as-prop.
3
+ 52 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
@@ -30,6 +30,10 @@
30
30
  ## Panel
31
31
  - <Panel padding={16|24}>: the elevated surface panel (D51) — bg-secondary, faint hairline border, radius-12, space-24 padding (16 for compact). Binds the shared --psi-surface-* recipe Dialog's panel also uses; brands retune both by overriding --psi-surface-*. Not a Card: no media slot, no hover lift.
32
32
 
33
+
34
+ ## Stack and Text
35
+ - <Stack gap={4|8|12|16|24|32} align="stretch|start|center|end">: a column with a gap from the space scale (D109), default gap 16 and stretch. A plain <div>, no role, no wrap: Toolbar's vertical counterpart. Use it for the layout a pattern tree cannot get from Panel (which pads but lays out nothing) or from psi-gap-* (which sets gap only). `align="start"` keeps a Button at its own width. Fields in a form: gap 24.
36
+ - <Text as="p|span|div|h2|h3|h4" size={12|14|16|18} weight="regular|medium" tone="primary|secondary">: one line or paragraph at a Psi text style (D109), margin 0, default p / 14 / regular / primary. `as` follows the document outline and `size` the design, so `<Text as="h3" size={16} weight="medium">` is the usual section headline. In a pattern tree, put each line in its own Text: adjacent bare strings render as one run.
33
37
  ## DescriptionList (2)
34
38
  - `<DescriptionList layout="stacked|inline" gap={8|12|16}>` with one `<DescriptionItem term="Date">value</DescriptionItem>` per field (D70). Renders `<dl>`; each item is a `<div><dt/><dd/></div>` group, which is the HTML5 grouping element, so the term/value association survives.
35
39
  - `stacked` (default) puts the term above its value; `inline` is a two-column grid and is what a detail drawer wants. This is the body of the `detail-drawer` pattern — do not hand-roll a `<dl>`.
@@ -44,6 +48,7 @@
44
48
  ## NavBar
45
49
  - <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.
46
50
  - `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).
51
+ - Plain text in a slot takes `--psi-navbar-fg`, default `var(--psi-fg-primary)`, not the page's colour, so `data-psi-theme` on the `NavBar` reaches it. Set the token to keep a colour of your own (D111).
47
52
 
48
53
  ## SkipLink
49
54
  - <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).
@@ -95,6 +100,7 @@
95
100
  - 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.
96
101
  - `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.
97
102
  - `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-*`.
103
+ - **A frame with a rounded surface (D110)**: a one-colour header and sidebar, and `main` a surface with a rounded top-start corner. A theme's `componentOverrides` or the root's `style` sets `--psi-app-shell-bg`, `--psi-app-shell-main-bg` and `--psi-app-shell-main-radius`; all three paint nothing by default, and no consumer CSS is needed. `--psi-app-shell-bg` shows only behind the corner and **must equal the canvas of the theme on the header and the sidebar**: no `var()` reaches a sub-theme from the page's scope, and `--psi-bg-inverted` is another colour (under `light`, `#222f3c` against `dark`'s `#080c0f`; under `ember`, near white), so write the value, for `dark` `oklch(0.15 0.01 250)`. Put the header under that theme with `data-psi-theme` on the `NavBar` (its rest props land on `<header>`; there is no `headerTheme`), and set `--psi-navbar-bg: var(--psi-bg-primary)` and `--psi-navbar-border-width: 0` on it, which removes the line and its pixel. Tokens set on the page's theme do not cross into a sub-themed element, so the header's go on the `NavBar` or on the sub-theme. The corner is square unless a header and a sidebar both meet `main`. A rule of your own on the shell's root outside `@layer` beats these tokens. The `Frame` story shows it; the `app-shell` pattern stays the plain frame.
98
104
  - `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.
99
105
  - **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.
100
106
 
@@ -114,13 +120,13 @@
114
120
  ## Compositions (recipes for what has no component yet)
115
121
 
116
122
  - `<Textarea maxLength={280}>` is a multi-line text field (D104): the Input recipe on a native `<textarea>`, wired by a Field like Input. `minLength`/`maxLength` are native; with `maxLength` a counter under it shows `{length} / {maxLength}` — not a live region, read with the field through aria-describedby (`showCounter={false}` hides it). No `size`: its height is `rows` (default 3).
117
- - `<RadioGroup value onValueChange><Radio value="a">A</Radio>…</RadioGroup>` (D104): native radios sharing one name, so the browser supplies Tab and the arrow keys. Put it in `<Field group label required error>`: the fieldset and legend name the group and RadioGroup adds no role; standalone it is `role="radiogroup"` and needs `aria-label`. A required or invalid Field makes every radio `required` and `aria-invalid`. `orientation="horizontal"` lays them in a row. Consult dist/patterns.json for `feedback-form`.
123
+ - `<RadioGroup value onValueChange><Radio value="a">A</Radio>…</RadioGroup>` (D104): native radios sharing one name, so the browser supplies Tab and the arrow keys. Put it in `<Field group label required error>`: the fieldset and legend name the group and RadioGroup adds no role; standalone it is `role="radiogroup"` and needs `aria-label`. A required or invalid Field makes every radio `required` and `aria-invalid`; an invalid group Field also makes every `Checkbox` `aria-invalid` (not `required`), and an unchecked, `aria-invalid` box or circle outlines in the field error colour (D108). `orientation="horizontal"` lays them in a row. Consult dist/patterns.json for `feedback-form`.
118
124
  - 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.
119
125
  - 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).
120
126
  - 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.
121
127
  - 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), `psi-fg-*` (foreground colour, one per fg token: `psi-fg-primary`, `psi-fg-secondary`, `psi-fg-on-accent`, … — `color: var(--psi-fg-<role>)`) (D94), 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. A utility on a component root beats the component's own value (D94): `<Tabs className="psi-gap-24">` gets a 24px gap. The exact roster is `@handamade/psi-tokens/guidance.json` → `utilities.classes`.
122
128
  - Native pass-through: components spread unlisted props onto their underlying element (Input → `<input>`, Select → `<select>`, Button → `<button>`/`<a>`), so `value`, `onChange`, `type`, `id`, `autoComplete`, `checked` etc. all work even though manifest.json lists only the component-specific props. Exception: Tooltip takes no host props — put handlers/attributes on its trigger child.
123
- - Theming, programmatic: the standard setup is `data-psi-theme` on `<html>`. From JS, set it once at app init: `document.documentElement.dataset.psiTheme = "dark"` (React: in a root-level effect). Theme CSS does NOT paint the page background — paint `background: var(--psi-bg-primary)` at your app root yourself. For a themed subtree, set `data-psi-theme` on a wrapper and paint its background the same way; brand font combos do not reach nested subtrees (see guidance.fonts.scope).
129
+ - Theming, programmatic: the standard setup is `data-psi-theme` on `<html>`. From JS, set it once at app init: `document.documentElement.dataset.psiTheme = "dark"` (React: in a root-level effect). Theme CSS does NOT paint the page background or set its font — at your app root set `background: var(--psi-bg-primary)` and `color: var(--psi-fg-primary)` yourself, and `font: var(--psi-text-14-20-regular)` on `body`, in `@layer psi.base` (D107). Never set a font size on `html`: Psi's scales are rem, so it would resize every control, gap and radius. Components set their own type, but text you place in a Panel, Dialog, Card or tab panel inherits the page's, which is the browser's serif until you set it. Native `button`/`input`/`select`/`textarea` need `font: inherit` in the same layer. For a themed subtree, set `data-psi-theme` on a wrapper and paint its background the same way; brand font combos do not reach nested subtrees (see guidance.fonts.scope).
124
130
  - Size selection: 32 is the default (compact UI); 40 for comfortable forms and marketing pages; 24 for inline/table-row actions; 48 for hero CTAs.
125
131
  - Tags & the accent rule (D40): Tags are passive labels — they do NOT count against "one accent per visual group" (that rule governs buttons/CTAs). Badge mapping: highlight (e.g. a plan badge) → `accent-subtle`; meta → `neutral-subtle`; status → `success`/`warning`/`danger`. On Tag, `subtle` is a boolean prop — `accent-subtle` is spelled `variant="accent" subtle` (Tag's variant union has no `-subtle` entries).
126
132
  - CSS import order: all five DS stylesheets are order-independent — base.css and @handamade/psi-react/styles both open with an explicit `@layer psi.base, psi.theme, psi.components, psi.utilities;` statement. Recommended listing for readability: base → theme → components → utilities → @handamade/psi-react/styles.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handamade/psi-react",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -47,7 +47,7 @@
47
47
  "tsx": "^4.22.4",
48
48
  "vite": "^6.0.0",
49
49
  "vitest": "^3.2.6",
50
- "@handamade/psi-tokens": "0.26.0"
50
+ "@handamade/psi-tokens": "0.28.0"
51
51
  },
52
52
  "scripts": {
53
53
  "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",