@handamade/psi-mcp 0.8.0 → 0.9.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/README.md +1 -1
- package/dist/psi-index.json +474 -10
- package/dist/store.js +51 -2
- package/llms.txt +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @handamade/psi-mcp
|
|
2
2
|
|
|
3
|
-
MCP server and agent-context generator for the Psi (Ψ) design system. Documents the system — the 18 React 19 components,
|
|
3
|
+
MCP server and agent-context generator for the Psi (Ψ) design system. Documents the system — the 18 React 19 components, 13 composition patterns (D47) that recipe them together with gap-annotated compose trees, every semantic token with its OKLCH derivation formula resolved across all four themes, and the usage guidance — through two read-only tools. It does not write code.
|
|
4
4
|
|
|
5
5
|
## Connect
|
|
6
6
|
|
package/dist/psi-index.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.9.0",
|
|
3
3
|
"themes": [
|
|
4
4
|
"light",
|
|
5
5
|
"dark",
|
|
@@ -68,6 +68,13 @@
|
|
|
68
68
|
"name": "IconButton",
|
|
69
69
|
"description": "Square icon-only button; same variant/size/href contract as Button. Give it an accessible name (`aria-label`).",
|
|
70
70
|
"props": [
|
|
71
|
+
{
|
|
72
|
+
"name": "aria-label",
|
|
73
|
+
"type": "string",
|
|
74
|
+
"required": true,
|
|
75
|
+
"default": null,
|
|
76
|
+
"description": "Accessible name for the icon-only control. Required: an IconButton has\nno text to name it, so omitting this ships an unnameable button (D60).\nIf you also pass `aria-labelledby`, that wins per the accname algorithm."
|
|
77
|
+
},
|
|
71
78
|
{
|
|
72
79
|
"name": "variant",
|
|
73
80
|
"type": "\"accent\" | \"neutral\" | \"ghost\" | \"danger\"",
|
|
@@ -119,7 +126,7 @@
|
|
|
119
126
|
}
|
|
120
127
|
],
|
|
121
128
|
"slots": [],
|
|
122
|
-
"doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# IconButton\n\nSquare icon-only button; same variant/size/href contract as Button. Give it an accessible name (`aria-label`).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `variant` | `\"accent\" \\| \"neutral\" \\| \"ghost\" \\| \"danger\"` | neutral | no | Visual variant. |\n| `size` | `24 \\| 32 \\| 40 \\| 48` | 32 | no | Square size in px (24 \\| 32 \\| 40 \\| 48). |\n| `href` | `string` | — | no | Render as an anchor with this href (D33). disabled → aria-disabled, no href attribute, pointer-events: none. |\n| `target` | `string` | — | no | Anchor target; only used with href. |\n| `rel` | `string` | — | no | Anchor rel; only used with href. |\n| `ref` | `Ref<HTMLButtonElement \\| HTMLAnchorElement>` | — | no | Forwarded ref to the underlying element. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Enter / Space | Activates. |\n\nRequires an accessible name — pass aria-label. IconButton does not hide its icon for you; mark the icon aria-hidden yourself.\n\n## Theming\n\nOverride `--psi-button-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).\n\n## Variant guidance\n\n- **accent** — Primary action, draws attention. Submit, CTA, main action in a group.\n- **accent-subtle** — Accent tone, lower visual weight. Selected state, active filter, soft CTA.\n- **neutral** — Default, structurally present. Secondary actions, toolbar buttons.\n- **neutral-subtle** — Minimal chrome. Inline actions, table row actions.\n- **ghost** — No visible container until hover. Icon-only triggers, compact toolbars.\n- **outline** — Bordered ghost — visible structure, no fill until hover. Marketing CTA, download button; hover fills accent.\n- **danger** — Destructive action. Delete, remove, disconnect.\n- **danger-subtle** — Destructive context, low urgency. Warning badges, soft destructive hints.\n- **success | warning** — Status communication (Tag only). Status badges, labels.\n\n## Rules\n\n- One accent per visual group; everything else neutral or ghost.\n- danger only for actions with real consequences.\n- Sizes are px numbers (24|32|40|48), never S/M/L.\n- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.\n- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.\n- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).\n- 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.\n- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.\n"
|
|
129
|
+
"doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# IconButton\n\nSquare icon-only button; same variant/size/href contract as Button. Give it an accessible name (`aria-label`).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `aria-label` | `string` | — | yes | Accessible name for the icon-only control. Required: an IconButton has no text to name it, so omitting this ships an unnameable button (D60). If you also pass `aria-labelledby`, that wins per the accname algorithm. |\n| `variant` | `\"accent\" \\| \"neutral\" \\| \"ghost\" \\| \"danger\"` | neutral | no | Visual variant. |\n| `size` | `24 \\| 32 \\| 40 \\| 48` | 32 | no | Square size in px (24 \\| 32 \\| 40 \\| 48). |\n| `href` | `string` | — | no | Render as an anchor with this href (D33). disabled → aria-disabled, no href attribute, pointer-events: none. |\n| `target` | `string` | — | no | Anchor target; only used with href. |\n| `rel` | `string` | — | no | Anchor rel; only used with href. |\n| `ref` | `Ref<HTMLButtonElement \\| HTMLAnchorElement>` | — | no | Forwarded ref to the underlying element. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Enter / Space | Activates. |\n\nRequires an accessible name — pass aria-label. IconButton does not hide its icon for you; mark the icon aria-hidden yourself.\n\n## Theming\n\nOverride `--psi-button-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).\n\n## Variant guidance\n\n- **accent** — Primary action, draws attention. Submit, CTA, main action in a group.\n- **accent-subtle** — Accent tone, lower visual weight. Selected state, active filter, soft CTA.\n- **neutral** — Default, structurally present. Secondary actions, toolbar buttons.\n- **neutral-subtle** — Minimal chrome. Inline actions, table row actions.\n- **ghost** — No visible container until hover. Icon-only triggers, compact toolbars.\n- **outline** — Bordered ghost — visible structure, no fill until hover. Marketing CTA, download button; hover fills accent.\n- **danger** — Destructive action. Delete, remove, disconnect.\n- **danger-subtle** — Destructive context, low urgency. Warning badges, soft destructive hints.\n- **success | warning** — Status communication (Tag only). Status badges, labels.\n\n## Rules\n\n- One accent per visual group; everything else neutral or ghost.\n- danger only for actions with real consequences.\n- Sizes are px numbers (24|32|40|48), never S/M/L.\n- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.\n- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.\n- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).\n- 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.\n- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.\n"
|
|
123
130
|
},
|
|
124
131
|
{
|
|
125
132
|
"name": "Card",
|
|
@@ -204,6 +211,13 @@
|
|
|
204
211
|
"name": "Input",
|
|
205
212
|
"description": "Single-line text input with pixel-true heights (24–48) and an error state. Inside a Field, id/aria-describedby/aria-invalid/required are wired automatically (D49).",
|
|
206
213
|
"props": [
|
|
214
|
+
{
|
|
215
|
+
"name": "type",
|
|
216
|
+
"type": "\"number\" | \"text\" | \"search\" | \"email\" | \"tel\" | \"url\" | \"password\" | \"date\"",
|
|
217
|
+
"required": false,
|
|
218
|
+
"default": "text",
|
|
219
|
+
"description": "Input type."
|
|
220
|
+
},
|
|
207
221
|
{
|
|
208
222
|
"name": "size",
|
|
209
223
|
"type": "24 | 32 | 40 | 48",
|
|
@@ -241,7 +255,7 @@
|
|
|
241
255
|
}
|
|
242
256
|
],
|
|
243
257
|
"slots": [],
|
|
244
|
-
"doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Input\n\nSingle-line text input with pixel-true heights (24–48) and an error state. Inside a Field, id/aria-describedby/aria-invalid/required are wired automatically (D49).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `size` | `24 \\| 32 \\| 40 \\| 48` | 32 | no | Height in px (24 \\| 32 \\| 40 \\| 48). |\n| `error` | `boolean` | false | no | Show error styling. Inside a Field, the Field's error also lights this. |\n| `ref` | `Ref<HTMLInputElement>` | — | no | Forwarded ref to the underlying `<input>` element. |\n| `placeholder` | `string` | — | no | Native placeholder text shown while the field is empty (only on components whose host element supports it). |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Tab | Focuses the native <input>; focus ring on the field. |\n\nerror sets a red border only. Inside a Field, aria-invalid and aria-describedby are wired automatically (D49); standalone, pair them yourself.\n\n## Theming\n\nOverride `--psi-input-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).\n\n\n\n## Rules\n\n- One accent per visual group; everything else neutral or ghost.\n- danger only for actions with real consequences.\n- Sizes are px numbers (24|32|40|48), never S/M/L.\n- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.\n- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.\n- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).\n- 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.\n- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.\n"
|
|
258
|
+
"doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Input\n\nSingle-line text input with pixel-true heights (24–48) and an error state. Inside a Field, id/aria-describedby/aria-invalid/required are wired automatically (D49).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `type` | `\"number\" \\| \"text\" \\| \"search\" \\| \"email\" \\| \"tel\" \\| \"url\" \\| \"password\" \\| \"date\"` | text | no | Input type. |\n| `size` | `24 \\| 32 \\| 40 \\| 48` | 32 | no | Height in px (24 \\| 32 \\| 40 \\| 48). |\n| `error` | `boolean` | false | no | Show error styling. Inside a Field, the Field's error also lights this. |\n| `ref` | `Ref<HTMLInputElement>` | — | no | Forwarded ref to the underlying `<input>` element. |\n| `placeholder` | `string` | — | no | Native placeholder text shown while the field is empty (only on components whose host element supports it). |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Tab | Focuses the native <input>; focus ring on the field. |\n\nerror sets a red border only. Inside a Field, aria-invalid and aria-describedby are wired automatically (D49); standalone, pair them yourself.\n\n## Theming\n\nOverride `--psi-input-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).\n\n\n\n## Rules\n\n- One accent per visual group; everything else neutral or ghost.\n- danger only for actions with real consequences.\n- Sizes are px numbers (24|32|40|48), never S/M/L.\n- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.\n- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.\n- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).\n- 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.\n- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.\n"
|
|
245
259
|
},
|
|
246
260
|
{
|
|
247
261
|
"name": "Select",
|
|
@@ -717,7 +731,7 @@
|
|
|
717
731
|
"type": "(reason: \"item-select\" | \"esc\" | \"outside\") => void",
|
|
718
732
|
"required": true,
|
|
719
733
|
"default": null,
|
|
720
|
-
"description": "Called when a dismissal is *requested*; the consumer flips `open`.\n\nMenu never closes itself on `\"esc\"` or `\"item-select\"` — the popover stays\nopen until `open` becomes false (D50). `\"outside\"` is the one exception:\nlight dismiss is performed by the browser before it tells us, so by the\ntime that reason is reported the popover is already closed. Flipping\n`open` to false is still required, to keep React's state in step."
|
|
734
|
+
"description": "Called when a dismissal is *requested*; the consumer flips `open`.\n\nMenu never closes itself on `\"esc\"` or `\"item-select\"` — the popover stays\nopen until `open` becomes false (D50). `\"outside\"` is the one exception:\nlight dismiss is performed by the browser before it tells us, so by the\ntime that reason is reported the popover is already closed. Flipping\n`open` to false is still required, to keep React's state in step.\n\nInvariant (D58): a dismissal is only ever reported for a menu that is\ncurrently open according to its own `open` prop. The platform can close\nan auto popover before the consumer's state catches up — clicking another\nmenu's trigger light-dismisses this one before the consumer's click\nhandler runs — and a report for an already-closed menu would clear a\nselection that has since moved on."
|
|
721
735
|
},
|
|
722
736
|
{
|
|
723
737
|
"name": "trigger",
|
|
@@ -778,7 +792,7 @@
|
|
|
778
792
|
"order": 2
|
|
779
793
|
}
|
|
780
794
|
],
|
|
781
|
-
"doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Menu\n\nAction menu on the native Popover API top layer: the top layer and light dismiss come from the platform; Esc, roving keyboard, placement and dismissal reasons are Psi's (D53). Controlled-only, like Dialog (D50). Controlled-only means Menu does not change its own visibility. Esc and item-select *report* a dismissal via onClose(reason) and leave the popover open; only the consumer flipping `open` to false actually closes it. Esc calls preventDefault() to suppress the platform's own popover dismissal, which is what makes that possible. Light dismiss (outside click) is the one asymmetry, and it is forced by the platform: the browser hides the popover itself and the hide-side `beforetoggle` is not cancelable, so the popover is already closed by the time the resulting `toggle` lets us report onClose(\"outside\"). The consumer must still flip `open` to false so React's state matches the DOM. `toggle` therefore reports only that one reason. A close driven by the consumer (`open` true -> false) runs through the sync effect's own hidePopover(), which raises a `toggle` too — that one is suppressed, because a programmatic close is not a dismissal and must not call onClose.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `open` | `boolean` | — | yes | Controlled open state; syncs to showPopover()/hidePopover(). |\n| `onClose` | `(reason: \"item-select\" \\| \"esc\" \\| \"outside\") => void` | — | yes | Called when a dismissal is *requested*; the consumer flips `open`. Menu never closes itself on `\"esc\"` or `\"item-select\"` — the popover stays open until `open` becomes false (D50). `\"outside\"` is the one exception: light dismiss is performed by the browser before it tells us, so by the time that reason is reported the popover is already closed. Flipping `open` to false is still required, to keep React's state in step. |\n| `trigger` | `ReactElement<Record<string, unknown>, string \\| JSXElementConstructor<any>>` | — | yes | The trigger element. Must be a single React element that spreads unknown props onto a focusable node (a Psi `Button`, say): Menu clones it to add `aria-haspopup=\"menu\"` and `aria-expanded`, so assistive tech associates the menu with the control users actually focus. |\n| `placement` | `\"bottom-start\" \\| \"bottom-end\" \\| \"top-start\" \\| \"top-end\"` | bottom-start | no | Placement relative to the trigger. |\n| `aria-label` | `string` | — | no | Accessible name for the menu when there is no visible label. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the popover element. |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Arrow Down / Arrow Up | Moves between enabled items, wrapping at both ends. Disabled items are skipped. |\n| Home / End | Jumps to the first or last enabled item. |\n| Any single printable key | Typeahead — focuses the first enabled item whose label starts with the typed prefix; the prefix resets after 500ms. Keystrokes with Meta/Ctrl/Alt held are ignored. |\n| Esc | Suppresses the platform's own dismissal and reports onClose(\"esc\"); the menu stays open until the consumer flips `open` (D50). |\n| Enter / Space | Activates the focused item (native button behavior). |\n\nOpens on the native top layer via popover=\"auto\", which supplies light dismiss. Controlled-only: every dismissal path (esc, item-select, outside) only reports onClose(reason) — the consumer must flip `open` to actually close it; outside is the one path the platform has already acted on by the time it is reported. Opening moves focus to the first enabled item — Menu takes focus off the trigger as soon as `open` becomes true. Focus returns to the trigger when the menu actually closes, and only if focus is still inside the menu, so a light dismiss onto another control does not steal focus back. Requires an accessible name — pass aria-label. Placement uses CSS anchor positioning where supported and a JS fallback below that floor; below it there is no collision flip.\n\n## Theming\n\nOverride `--psi-menu-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).\n\n\n\n## Rules\n\n- One accent per visual group; everything else neutral or ghost.\n- danger only for actions with real consequences.\n- Sizes are px numbers (24|32|40|48), never S/M/L.\n- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.\n- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.\n- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).\n- 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.\n- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.\n"
|
|
795
|
+
"doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Menu\n\nAction menu on the native Popover API top layer: the top layer and light dismiss come from the platform; Esc, roving keyboard, placement and dismissal reasons are Psi's (D53). Controlled-only, like Dialog (D50). Controlled-only means Menu does not change its own visibility. Esc and item-select *report* a dismissal via onClose(reason) and leave the popover open; only the consumer flipping `open` to false actually closes it. Esc calls preventDefault() to suppress the platform's own popover dismissal, which is what makes that possible. Light dismiss (outside click) is the one asymmetry, and it is forced by the platform: the browser hides the popover itself and the hide-side `beforetoggle` is not cancelable, so the popover is already closed by the time the resulting `toggle` lets us report onClose(\"outside\"). The consumer must still flip `open` to false so React's state matches the DOM. `toggle` therefore reports only that one reason. A close driven by the consumer (`open` true -> false) runs through the sync effect's own hidePopover(), which raises a `toggle` too — that one is suppressed, because a programmatic close is not a dismissal and must not call onClose.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `open` | `boolean` | — | yes | Controlled open state; syncs to showPopover()/hidePopover(). |\n| `onClose` | `(reason: \"item-select\" \\| \"esc\" \\| \"outside\") => void` | — | yes | Called when a dismissal is *requested*; the consumer flips `open`. Menu never closes itself on `\"esc\"` or `\"item-select\"` — the popover stays open until `open` becomes false (D50). `\"outside\"` is the one exception: light dismiss is performed by the browser before it tells us, so by the time that reason is reported the popover is already closed. Flipping `open` to false is still required, to keep React's state in step. Invariant (D58): a dismissal is only ever reported for a menu that is currently open according to its own `open` prop. The platform can close an auto popover before the consumer's state catches up — clicking another menu's trigger light-dismisses this one before the consumer's click handler runs — and a report for an already-closed menu would clear a selection that has since moved on. |\n| `trigger` | `ReactElement<Record<string, unknown>, string \\| JSXElementConstructor<any>>` | — | yes | The trigger element. Must be a single React element that spreads unknown props onto a focusable node (a Psi `Button`, say): Menu clones it to add `aria-haspopup=\"menu\"` and `aria-expanded`, so assistive tech associates the menu with the control users actually focus. |\n| `placement` | `\"bottom-start\" \\| \"bottom-end\" \\| \"top-start\" \\| \"top-end\"` | bottom-start | no | Placement relative to the trigger. |\n| `aria-label` | `string` | — | no | Accessible name for the menu when there is no visible label. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the popover element. |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Arrow Down / Arrow Up | Moves between enabled items, wrapping at both ends. Disabled items are skipped. |\n| Home / End | Jumps to the first or last enabled item. |\n| Any single printable key | Typeahead — focuses the first enabled item whose label starts with the typed prefix; the prefix resets after 500ms. Keystrokes with Meta/Ctrl/Alt held are ignored. |\n| Esc | Suppresses the platform's own dismissal and reports onClose(\"esc\"); the menu stays open until the consumer flips `open` (D50). |\n| Enter / Space | Activates the focused item (native button behavior). |\n\nOpens on the native top layer via popover=\"auto\", which supplies light dismiss. Controlled-only: every dismissal path (esc, item-select, outside) only reports onClose(reason) — the consumer must flip `open` to actually close it; outside is the one path the platform has already acted on by the time it is reported. Opening moves focus to the first enabled item — Menu takes focus off the trigger as soon as `open` becomes true. Focus returns to the trigger when the menu actually closes, and only if focus is still inside the menu, so a light dismiss onto another control does not steal focus back. Requires an accessible name — pass aria-label. Placement uses CSS anchor positioning where supported and a JS fallback below that floor; below it there is no collision flip.\n\n## Theming\n\nOverride `--psi-menu-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).\n\n\n\n## Rules\n\n- One accent per visual group; everything else neutral or ghost.\n- danger only for actions with real consequences.\n- Sizes are px numbers (24|32|40|48), never S/M/L.\n- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.\n- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.\n- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).\n- 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.\n- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.\n"
|
|
782
796
|
},
|
|
783
797
|
{
|
|
784
798
|
"name": "MenuItem",
|
|
@@ -2316,6 +2330,216 @@
|
|
|
2316
2330
|
}
|
|
2317
2331
|
],
|
|
2318
2332
|
"patterns": [
|
|
2333
|
+
{
|
|
2334
|
+
"id": "action-feedback",
|
|
2335
|
+
"intent": "Transient confirmation after a completed action, with an optional undo affordance",
|
|
2336
|
+
"match": [
|
|
2337
|
+
"toast",
|
|
2338
|
+
"snackbar",
|
|
2339
|
+
"action feedback",
|
|
2340
|
+
"confirmation message",
|
|
2341
|
+
"undo toast"
|
|
2342
|
+
],
|
|
2343
|
+
"compose": {
|
|
2344
|
+
"component": "Toast",
|
|
2345
|
+
"props": {
|
|
2346
|
+
"variant": "{param:variant}"
|
|
2347
|
+
},
|
|
2348
|
+
"slots": {
|
|
2349
|
+
"body": [
|
|
2350
|
+
"{content:message}"
|
|
2351
|
+
],
|
|
2352
|
+
"action": [
|
|
2353
|
+
{
|
|
2354
|
+
"component": "Button",
|
|
2355
|
+
"props": {
|
|
2356
|
+
"variant": "ghost"
|
|
2357
|
+
},
|
|
2358
|
+
"content": "undo-label"
|
|
2359
|
+
}
|
|
2360
|
+
]
|
|
2361
|
+
}
|
|
2362
|
+
},
|
|
2363
|
+
"parameters": [
|
|
2364
|
+
{
|
|
2365
|
+
"key": "variant",
|
|
2366
|
+
"ask": "What kind of outcome is being confirmed?",
|
|
2367
|
+
"options": [
|
|
2368
|
+
"success",
|
|
2369
|
+
"danger",
|
|
2370
|
+
"neutral"
|
|
2371
|
+
],
|
|
2372
|
+
"default": "success"
|
|
2373
|
+
}
|
|
2374
|
+
],
|
|
2375
|
+
"content": {
|
|
2376
|
+
"message": "[what just happened]",
|
|
2377
|
+
"undo-label": "Undo"
|
|
2378
|
+
},
|
|
2379
|
+
"gaps": [
|
|
2380
|
+
"Toast"
|
|
2381
|
+
],
|
|
2382
|
+
"blocked": true,
|
|
2383
|
+
"preset": null
|
|
2384
|
+
},
|
|
2385
|
+
{
|
|
2386
|
+
"id": "bulk-action-bar",
|
|
2387
|
+
"intent": "Bar that appears when table rows are selected: selection count, bulk actions, and a clear-selection control",
|
|
2388
|
+
"match": [
|
|
2389
|
+
"bulk actions",
|
|
2390
|
+
"selection bar",
|
|
2391
|
+
"batch actions",
|
|
2392
|
+
"selected rows toolbar"
|
|
2393
|
+
],
|
|
2394
|
+
"compose": {
|
|
2395
|
+
"component": "Toolbar",
|
|
2396
|
+
"props": {
|
|
2397
|
+
"gap": 12
|
|
2398
|
+
},
|
|
2399
|
+
"slots": {
|
|
2400
|
+
"body": [
|
|
2401
|
+
{
|
|
2402
|
+
"component": "Tag",
|
|
2403
|
+
"props": {
|
|
2404
|
+
"variant": "accent",
|
|
2405
|
+
"subtle": true
|
|
2406
|
+
},
|
|
2407
|
+
"content": "count-label"
|
|
2408
|
+
},
|
|
2409
|
+
{
|
|
2410
|
+
"component": "Button",
|
|
2411
|
+
"props": {
|
|
2412
|
+
"variant": "neutral"
|
|
2413
|
+
},
|
|
2414
|
+
"content": "export-label"
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
"component": "Button",
|
|
2418
|
+
"props": {
|
|
2419
|
+
"variant": "danger-subtle"
|
|
2420
|
+
},
|
|
2421
|
+
"content": "delete-label"
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
"component": "Button",
|
|
2425
|
+
"props": {
|
|
2426
|
+
"variant": "ghost"
|
|
2427
|
+
},
|
|
2428
|
+
"content": "clear-label"
|
|
2429
|
+
}
|
|
2430
|
+
]
|
|
2431
|
+
}
|
|
2432
|
+
},
|
|
2433
|
+
"parameters": [],
|
|
2434
|
+
"content": {
|
|
2435
|
+
"count-label": "[n] selected",
|
|
2436
|
+
"export-label": "Export",
|
|
2437
|
+
"delete-label": "Delete",
|
|
2438
|
+
"clear-label": "Clear selection"
|
|
2439
|
+
},
|
|
2440
|
+
"gaps": [],
|
|
2441
|
+
"blocked": false,
|
|
2442
|
+
"preset": "<Toolbar\n gap={12}\n>\n <Tag subtle={true} variant=\"accent\">[n] selected</Tag>\n <Button variant=\"neutral\">Export</Button>\n <Button variant=\"danger-subtle\">Delete</Button>\n <Button variant=\"ghost\">Clear selection</Button>\n</Toolbar>\n"
|
|
2443
|
+
},
|
|
2444
|
+
{
|
|
2445
|
+
"id": "data-table",
|
|
2446
|
+
"intent": "Sortable, selectable table of records with numeric columns and a per-row actions menu — compose the row menu from the row-actions pattern",
|
|
2447
|
+
"match": [
|
|
2448
|
+
"data table",
|
|
2449
|
+
"transactions table",
|
|
2450
|
+
"sortable table",
|
|
2451
|
+
"records table",
|
|
2452
|
+
"list of transactions"
|
|
2453
|
+
],
|
|
2454
|
+
"compose": {
|
|
2455
|
+
"component": "Table",
|
|
2456
|
+
"props": {
|
|
2457
|
+
"selectable": true,
|
|
2458
|
+
"sortable": true
|
|
2459
|
+
},
|
|
2460
|
+
"slots": {
|
|
2461
|
+
"head": [
|
|
2462
|
+
"{content:columns-note}"
|
|
2463
|
+
],
|
|
2464
|
+
"body": [
|
|
2465
|
+
"{content:rows-note}"
|
|
2466
|
+
]
|
|
2467
|
+
}
|
|
2468
|
+
},
|
|
2469
|
+
"parameters": [],
|
|
2470
|
+
"content": {
|
|
2471
|
+
"columns-note": "[one header cell per column; numeric columns align right]",
|
|
2472
|
+
"rows-note": "[one row per record; put the row-actions menu in the last cell]"
|
|
2473
|
+
},
|
|
2474
|
+
"gaps": [
|
|
2475
|
+
"Table"
|
|
2476
|
+
],
|
|
2477
|
+
"blocked": true,
|
|
2478
|
+
"preset": null
|
|
2479
|
+
},
|
|
2480
|
+
{
|
|
2481
|
+
"id": "date-range-filter",
|
|
2482
|
+
"intent": "From/to date pair for narrowing a dated list — native date inputs, no calendar widget",
|
|
2483
|
+
"match": [
|
|
2484
|
+
"date range filter",
|
|
2485
|
+
"from to date",
|
|
2486
|
+
"date filter",
|
|
2487
|
+
"period filter",
|
|
2488
|
+
"filter by date"
|
|
2489
|
+
],
|
|
2490
|
+
"compose": {
|
|
2491
|
+
"component": "Toolbar",
|
|
2492
|
+
"props": {
|
|
2493
|
+
"gap": 12
|
|
2494
|
+
},
|
|
2495
|
+
"slots": {
|
|
2496
|
+
"body": [
|
|
2497
|
+
{
|
|
2498
|
+
"component": "Field",
|
|
2499
|
+
"slots": {
|
|
2500
|
+
"label": [
|
|
2501
|
+
"{content:from-label}"
|
|
2502
|
+
],
|
|
2503
|
+
"body": [
|
|
2504
|
+
{
|
|
2505
|
+
"component": "Input",
|
|
2506
|
+
"props": {
|
|
2507
|
+
"type": "date",
|
|
2508
|
+
"size": 32
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
]
|
|
2512
|
+
}
|
|
2513
|
+
},
|
|
2514
|
+
{
|
|
2515
|
+
"component": "Field",
|
|
2516
|
+
"slots": {
|
|
2517
|
+
"label": [
|
|
2518
|
+
"{content:to-label}"
|
|
2519
|
+
],
|
|
2520
|
+
"body": [
|
|
2521
|
+
{
|
|
2522
|
+
"component": "Input",
|
|
2523
|
+
"props": {
|
|
2524
|
+
"type": "date",
|
|
2525
|
+
"size": 32
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
]
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
]
|
|
2532
|
+
}
|
|
2533
|
+
},
|
|
2534
|
+
"parameters": [],
|
|
2535
|
+
"content": {
|
|
2536
|
+
"from-label": "From",
|
|
2537
|
+
"to-label": "To"
|
|
2538
|
+
},
|
|
2539
|
+
"gaps": [],
|
|
2540
|
+
"blocked": false,
|
|
2541
|
+
"preset": "<Toolbar\n gap={12}\n>\n <Field\n label=\"From\"\n >\n <Input size={32} type=\"date\" />\n </Field>\n <Field\n label=\"To\"\n >\n <Input size={32} type=\"date\" />\n </Field>\n</Toolbar>\n"
|
|
2542
|
+
},
|
|
2319
2543
|
{
|
|
2320
2544
|
"id": "destructive-confirm",
|
|
2321
2545
|
"intent": "Dialog confirming a destructive action",
|
|
@@ -2375,6 +2599,108 @@
|
|
|
2375
2599
|
"blocked": false,
|
|
2376
2600
|
"preset": "<Dialog\n footer={<>\n <Button size={32} variant=\"ghost\">Cancel</Button>\n <Button size={32} variant=\"danger\">[verb the object]</Button>\n </>}\n title=\"[Delete the object?]\"\n>\n [what is permanently lost]\n</Dialog>\n"
|
|
2377
2601
|
},
|
|
2602
|
+
{
|
|
2603
|
+
"id": "detail-drawer",
|
|
2604
|
+
"intent": "Side sheet showing one record's detail, opened from a table row, with its primary actions in a footer",
|
|
2605
|
+
"match": [
|
|
2606
|
+
"detail drawer",
|
|
2607
|
+
"side sheet",
|
|
2608
|
+
"row detail",
|
|
2609
|
+
"slide-over",
|
|
2610
|
+
"record detail panel"
|
|
2611
|
+
],
|
|
2612
|
+
"compose": {
|
|
2613
|
+
"component": "Drawer",
|
|
2614
|
+
"props": {
|
|
2615
|
+
"side": "{param:side}"
|
|
2616
|
+
},
|
|
2617
|
+
"slots": {
|
|
2618
|
+
"title": [
|
|
2619
|
+
"{content:title}"
|
|
2620
|
+
],
|
|
2621
|
+
"body": [
|
|
2622
|
+
"{content:body-note}"
|
|
2623
|
+
],
|
|
2624
|
+
"footer": [
|
|
2625
|
+
{
|
|
2626
|
+
"component": "Button",
|
|
2627
|
+
"props": {
|
|
2628
|
+
"variant": "accent"
|
|
2629
|
+
},
|
|
2630
|
+
"content": "primary-label"
|
|
2631
|
+
},
|
|
2632
|
+
{
|
|
2633
|
+
"component": "Button",
|
|
2634
|
+
"props": {
|
|
2635
|
+
"variant": "neutral"
|
|
2636
|
+
},
|
|
2637
|
+
"content": "dismiss-label"
|
|
2638
|
+
}
|
|
2639
|
+
]
|
|
2640
|
+
}
|
|
2641
|
+
},
|
|
2642
|
+
"parameters": [
|
|
2643
|
+
{
|
|
2644
|
+
"key": "side",
|
|
2645
|
+
"ask": "Which edge should the drawer open from?",
|
|
2646
|
+
"options": [
|
|
2647
|
+
"end",
|
|
2648
|
+
"start"
|
|
2649
|
+
],
|
|
2650
|
+
"default": "end"
|
|
2651
|
+
}
|
|
2652
|
+
],
|
|
2653
|
+
"content": {
|
|
2654
|
+
"title": "[record title]",
|
|
2655
|
+
"body-note": "[key-value summary of the selected record]",
|
|
2656
|
+
"primary-label": "Save",
|
|
2657
|
+
"dismiss-label": "Close"
|
|
2658
|
+
},
|
|
2659
|
+
"gaps": [
|
|
2660
|
+
"Drawer"
|
|
2661
|
+
],
|
|
2662
|
+
"blocked": true,
|
|
2663
|
+
"preset": null
|
|
2664
|
+
},
|
|
2665
|
+
{
|
|
2666
|
+
"id": "empty-state",
|
|
2667
|
+
"intent": "Placeholder shown when a list has no results, distinguishing an empty filter from an empty dataset",
|
|
2668
|
+
"match": [
|
|
2669
|
+
"empty state",
|
|
2670
|
+
"no results",
|
|
2671
|
+
"nothing here",
|
|
2672
|
+
"zero state",
|
|
2673
|
+
"no data"
|
|
2674
|
+
],
|
|
2675
|
+
"compose": {
|
|
2676
|
+
"component": "Panel",
|
|
2677
|
+
"props": {
|
|
2678
|
+
"padding": 24
|
|
2679
|
+
},
|
|
2680
|
+
"slots": {
|
|
2681
|
+
"body": [
|
|
2682
|
+
"{content:headline}",
|
|
2683
|
+
"{content:explanation}",
|
|
2684
|
+
{
|
|
2685
|
+
"component": "Button",
|
|
2686
|
+
"props": {
|
|
2687
|
+
"variant": "neutral"
|
|
2688
|
+
},
|
|
2689
|
+
"content": "reset-label"
|
|
2690
|
+
}
|
|
2691
|
+
]
|
|
2692
|
+
}
|
|
2693
|
+
},
|
|
2694
|
+
"parameters": [],
|
|
2695
|
+
"content": {
|
|
2696
|
+
"headline": "[nothing matches these filters]",
|
|
2697
|
+
"explanation": "[say whether the dataset is empty or the filters are too narrow]",
|
|
2698
|
+
"reset-label": "Clear filters"
|
|
2699
|
+
},
|
|
2700
|
+
"gaps": [],
|
|
2701
|
+
"blocked": false,
|
|
2702
|
+
"preset": "<Panel\n padding={24}\n>\n [nothing matches these filters]\n [say whether the dataset is empty or the filters are too narrow]\n <Button variant=\"neutral\">Clear filters</Button>\n</Panel>\n"
|
|
2703
|
+
},
|
|
2378
2704
|
{
|
|
2379
2705
|
"id": "filter-toolbar",
|
|
2380
2706
|
"intent": "Toolbar of filter controls: search, category select, active-filter tags",
|
|
@@ -2411,7 +2737,7 @@
|
|
|
2411
2737
|
},
|
|
2412
2738
|
{
|
|
2413
2739
|
"id": "row-actions",
|
|
2414
|
-
"intent": "List or table row with
|
|
2740
|
+
"intent": "List or table row with an icon-only actions menu, including a destructive item — hand the destructive item's confirmation off to the destructive-confirm pattern",
|
|
2415
2741
|
"match": [
|
|
2416
2742
|
"row actions",
|
|
2417
2743
|
"per-row actions",
|
|
@@ -2428,11 +2754,12 @@
|
|
|
2428
2754
|
"slots": {
|
|
2429
2755
|
"trigger": [
|
|
2430
2756
|
{
|
|
2431
|
-
"component": "
|
|
2757
|
+
"component": "IconButton",
|
|
2432
2758
|
"props": {
|
|
2433
|
-
"variant": "ghost"
|
|
2759
|
+
"variant": "ghost",
|
|
2760
|
+
"aria-label": "{content:trigger-label}"
|
|
2434
2761
|
},
|
|
2435
|
-
"content": "trigger-
|
|
2762
|
+
"content": "trigger-icon"
|
|
2436
2763
|
}
|
|
2437
2764
|
],
|
|
2438
2765
|
"body": [
|
|
@@ -2470,13 +2797,14 @@
|
|
|
2470
2797
|
],
|
|
2471
2798
|
"content": {
|
|
2472
2799
|
"trigger-label": "Actions",
|
|
2800
|
+
"trigger-icon": "[icon]",
|
|
2473
2801
|
"edit-label": "Edit",
|
|
2474
2802
|
"duplicate-label": "Duplicate",
|
|
2475
2803
|
"delete-label": "Delete"
|
|
2476
2804
|
},
|
|
2477
2805
|
"gaps": [],
|
|
2478
2806
|
"blocked": false,
|
|
2479
|
-
"preset": "<Menu\n aria-label=\"Actions\"\n placement=\"bottom-end\"\n trigger={<
|
|
2807
|
+
"preset": "<Menu\n aria-label=\"Actions\"\n placement=\"bottom-end\"\n trigger={<IconButton aria-label=\"Actions\" variant=\"ghost\">[icon]</IconButton>}\n>\n <MenuItem>Edit</MenuItem>\n <MenuItem>Duplicate</MenuItem>\n <MenuSeparator />\n <MenuItem variant=\"danger\">Delete</MenuItem>\n</Menu>\n"
|
|
2480
2808
|
},
|
|
2481
2809
|
{
|
|
2482
2810
|
"id": "settings-form-row",
|
|
@@ -2517,6 +2845,142 @@
|
|
|
2517
2845
|
"gaps": [],
|
|
2518
2846
|
"blocked": false,
|
|
2519
2847
|
"preset": "<Field\n description=\"[one-line consequence of the setting]\"\n group={true}\n label=\"[setting name]\"\n>\n <Switch>[what turning it on does]</Switch>\n</Field>\n"
|
|
2848
|
+
},
|
|
2849
|
+
{
|
|
2850
|
+
"id": "summary-tiles",
|
|
2851
|
+
"intent": "Row of headline figures above a ledger — balances or totals, one Card per figure",
|
|
2852
|
+
"match": [
|
|
2853
|
+
"summary tiles",
|
|
2854
|
+
"stat cards",
|
|
2855
|
+
"kpi row",
|
|
2856
|
+
"balance cards",
|
|
2857
|
+
"totals row"
|
|
2858
|
+
],
|
|
2859
|
+
"compose": {
|
|
2860
|
+
"component": "Toolbar",
|
|
2861
|
+
"props": {
|
|
2862
|
+
"gap": 16
|
|
2863
|
+
},
|
|
2864
|
+
"slots": {
|
|
2865
|
+
"body": [
|
|
2866
|
+
{
|
|
2867
|
+
"component": "Card",
|
|
2868
|
+
"props": {
|
|
2869
|
+
"variant": "stacked"
|
|
2870
|
+
},
|
|
2871
|
+
"content": "tile-note"
|
|
2872
|
+
},
|
|
2873
|
+
{
|
|
2874
|
+
"component": "Card",
|
|
2875
|
+
"props": {
|
|
2876
|
+
"variant": "stacked"
|
|
2877
|
+
},
|
|
2878
|
+
"content": "tile-note"
|
|
2879
|
+
}
|
|
2880
|
+
]
|
|
2881
|
+
}
|
|
2882
|
+
},
|
|
2883
|
+
"parameters": [],
|
|
2884
|
+
"content": {
|
|
2885
|
+
"tile-note": "[label above, figure below]"
|
|
2886
|
+
},
|
|
2887
|
+
"gaps": [],
|
|
2888
|
+
"blocked": false,
|
|
2889
|
+
"preset": "<Toolbar\n gap={16}\n>\n <Card variant=\"stacked\">[label above, figure below]</Card>\n <Card variant=\"stacked\">[label above, figure below]</Card>\n</Toolbar>\n"
|
|
2890
|
+
},
|
|
2891
|
+
{
|
|
2892
|
+
"id": "tabbed-workspace",
|
|
2893
|
+
"intent": "Tab set switching between sibling views of the same workspace, such as accounts or saved filters",
|
|
2894
|
+
"match": [
|
|
2895
|
+
"tabs",
|
|
2896
|
+
"tabbed view",
|
|
2897
|
+
"switch between accounts",
|
|
2898
|
+
"tab bar",
|
|
2899
|
+
"segmented views"
|
|
2900
|
+
],
|
|
2901
|
+
"compose": {
|
|
2902
|
+
"component": "Tabs",
|
|
2903
|
+
"props": {
|
|
2904
|
+
"orientation": "{param:orientation}"
|
|
2905
|
+
},
|
|
2906
|
+
"slots": {
|
|
2907
|
+
"list": [
|
|
2908
|
+
"{content:tabs-note}"
|
|
2909
|
+
],
|
|
2910
|
+
"panels": [
|
|
2911
|
+
"{content:panels-note}"
|
|
2912
|
+
]
|
|
2913
|
+
}
|
|
2914
|
+
},
|
|
2915
|
+
"parameters": [
|
|
2916
|
+
{
|
|
2917
|
+
"key": "orientation",
|
|
2918
|
+
"ask": "How should the tab list be laid out?",
|
|
2919
|
+
"options": [
|
|
2920
|
+
"horizontal",
|
|
2921
|
+
"vertical"
|
|
2922
|
+
],
|
|
2923
|
+
"default": "horizontal"
|
|
2924
|
+
}
|
|
2925
|
+
],
|
|
2926
|
+
"content": {
|
|
2927
|
+
"tabs-note": "[one tab per view; keep labels to one or two words]",
|
|
2928
|
+
"panels-note": "[one panel per tab, in the same order]"
|
|
2929
|
+
},
|
|
2930
|
+
"gaps": [
|
|
2931
|
+
"Tabs"
|
|
2932
|
+
],
|
|
2933
|
+
"blocked": true,
|
|
2934
|
+
"preset": null
|
|
2935
|
+
},
|
|
2936
|
+
{
|
|
2937
|
+
"id": "table-pagination",
|
|
2938
|
+
"intent": "Page-size selector and page range control beneath a data table",
|
|
2939
|
+
"match": [
|
|
2940
|
+
"pagination",
|
|
2941
|
+
"table pagination",
|
|
2942
|
+
"page controls",
|
|
2943
|
+
"rows per page",
|
|
2944
|
+
"paging"
|
|
2945
|
+
],
|
|
2946
|
+
"compose": {
|
|
2947
|
+
"component": "Toolbar",
|
|
2948
|
+
"props": {
|
|
2949
|
+
"gap": 12
|
|
2950
|
+
},
|
|
2951
|
+
"slots": {
|
|
2952
|
+
"body": [
|
|
2953
|
+
{
|
|
2954
|
+
"component": "Field",
|
|
2955
|
+
"slots": {
|
|
2956
|
+
"label": [
|
|
2957
|
+
"{content:page-size-label}"
|
|
2958
|
+
],
|
|
2959
|
+
"body": [
|
|
2960
|
+
{
|
|
2961
|
+
"component": "Select",
|
|
2962
|
+
"props": {
|
|
2963
|
+
"size": 32
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
]
|
|
2967
|
+
}
|
|
2968
|
+
},
|
|
2969
|
+
{
|
|
2970
|
+
"component": "Pagination"
|
|
2971
|
+
}
|
|
2972
|
+
]
|
|
2973
|
+
}
|
|
2974
|
+
},
|
|
2975
|
+
"parameters": [],
|
|
2976
|
+
"content": {
|
|
2977
|
+
"page-size-label": "Rows per page"
|
|
2978
|
+
},
|
|
2979
|
+
"gaps": [
|
|
2980
|
+
"Pagination"
|
|
2981
|
+
],
|
|
2982
|
+
"blocked": true,
|
|
2983
|
+
"preset": null
|
|
2520
2984
|
}
|
|
2521
2985
|
],
|
|
2522
2986
|
"scales": {
|
package/dist/store.js
CHANGED
|
@@ -9,7 +9,11 @@ const RESPONSE_BUDGET = 6000;
|
|
|
9
9
|
* topics — a fixed, slow-growing set of terse briefs (~90 chars each).
|
|
10
10
|
* Carries the rules an agent cannot guess, `getting-started`
|
|
11
11
|
* (the five required CSS imports) above all.
|
|
12
|
-
* patterns —
|
|
12
|
+
* patterns — composition recipes; their stored summaries carry match
|
|
13
|
+
* phrases for keyword ranking and can run long, so the
|
|
14
|
+
* overview projects a trimmed copy (see overviewBrief) capped
|
|
15
|
+
* near topic size — the stored, untrimmed brief is still what
|
|
16
|
+
* score() ranks against.
|
|
13
17
|
* components — verbose (up to 220 chars each) and the only axis that grows
|
|
14
18
|
* with every release. Also the axis a keyword query recovers
|
|
15
19
|
* best: search("menu") finds Menu; no query re-derives a house
|
|
@@ -57,7 +61,52 @@ function patternSummary(p) {
|
|
|
57
61
|
summary += `, blocked (gaps: ${p.gaps.join(", ")})`;
|
|
58
62
|
return summary;
|
|
59
63
|
}
|
|
64
|
+
/** Overview-only cap on the *intro* portion of a pattern summary (intent +
|
|
65
|
+
* match phrases + parameter count), comparable to a topic brief (~100
|
|
66
|
+
* bytes). The stored `Brief.summary` built by patternSummary() above stays
|
|
67
|
+
* full-length — score() reads it for keyword ranking (the match phrases are
|
|
68
|
+
* what let "delete confirmation" find destructive-confirm) — so this only
|
|
69
|
+
* shapes a copy used when search("") projects the overview list.
|
|
70
|
+
*
|
|
71
|
+
* The `, blocked (gaps: …)` tail is never subject to this cap: it is the
|
|
72
|
+
* backlog signal this cycle exists to publish, and patternSummary() puts it
|
|
73
|
+
* at the END of the string — a length-based slice of the assembled summary
|
|
74
|
+
* would silently drop it for every one of the 13 patterns (all longer than
|
|
75
|
+
* 120 chars), which is exactly the bug this trims to avoid. So the overview
|
|
76
|
+
* projects a pattern's summary from the underlying PatternEntry directly:
|
|
77
|
+
* trim the intro, then always append the tail. */
|
|
78
|
+
const OVERVIEW_PATTERN_INTRO_LIMIT = 100;
|
|
79
|
+
/** Trims `s` to at most `limit` chars, backing off to the last word boundary
|
|
80
|
+
* and marking the cut with an ellipsis — never a mid-word fragment that
|
|
81
|
+
* could read as a complete (if coincidental) match phrase. */
|
|
82
|
+
function truncateAtWord(s, limit) {
|
|
83
|
+
if (s.length <= limit)
|
|
84
|
+
return s;
|
|
85
|
+
const cut = s.slice(0, limit);
|
|
86
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
87
|
+
return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
|
|
88
|
+
}
|
|
89
|
+
function overviewPatternSummary(p) {
|
|
90
|
+
const intro = truncateAtWord(`${p.intent} — ${p.match.join(", ")}, ${p.parameters.length} parameters`, OVERVIEW_PATTERN_INTRO_LIMIT);
|
|
91
|
+
return p.blocked ? `${intro}, blocked (gaps: ${p.gaps.join(", ")})` : intro;
|
|
92
|
+
}
|
|
60
93
|
export function createStore(index) {
|
|
94
|
+
const patternsById = new Map(index.patterns.map((p) => [p.id, p]));
|
|
95
|
+
/** Overview-only projection: returns `b` unchanged for every kind except
|
|
96
|
+
* pattern, where it re-derives the summary from the underlying
|
|
97
|
+
* `PatternEntry` (via `overviewPatternSummary`) rather than slicing the
|
|
98
|
+
* already-assembled, full-length `Brief.summary` — so the `blocked (gaps:
|
|
99
|
+
* …)` tail always survives the overview's trim. Always a shallow copy for
|
|
100
|
+
* patterns: the stored brief kept in `briefs` (and used by score()) is
|
|
101
|
+
* never mutated. */
|
|
102
|
+
function overviewBrief(b) {
|
|
103
|
+
if (b.kind !== "pattern")
|
|
104
|
+
return b;
|
|
105
|
+
const pattern = patternsById.get(b.title);
|
|
106
|
+
if (!pattern)
|
|
107
|
+
return b;
|
|
108
|
+
return { ...b, summary: overviewPatternSummary(pattern) };
|
|
109
|
+
}
|
|
61
110
|
const briefs = [
|
|
62
111
|
...index.components.map((c) => ({
|
|
63
112
|
id: `component:${c.name}`,
|
|
@@ -108,7 +157,7 @@ export function createStore(index) {
|
|
|
108
157
|
const candidates = OVERVIEW_KIND_ORDER.flatMap((kind) => briefs.filter((b) => b.kind === kind));
|
|
109
158
|
const out = [];
|
|
110
159
|
for (const brief of candidates) {
|
|
111
|
-
out.push(brief);
|
|
160
|
+
out.push(overviewBrief(brief));
|
|
112
161
|
if (JSON.stringify(out).length > RESPONSE_BUDGET) {
|
|
113
162
|
out.pop();
|
|
114
163
|
break;
|
package/llms.txt
CHANGED
|
@@ -7,4 +7,4 @@ Ids: component:<Name>, token:<name>, topic:<key>, pattern:<id>; bare names resol
|
|
|
7
7
|
|
|
8
8
|
Connect: https://psi.kurkin.de/mcp (Streamable HTTP) or `npx @handamade/psi-mcp` (stdio).
|
|
9
9
|
Agent context: `npx @handamade/psi-mcp init [--claude]` writes a marker-delimited block into AGENTS.md/CLAUDE.md (D44).
|
|
10
|
-
The index bakes packages/react/dist/{manifest.json,patterns.json} (patterns.json:
|
|
10
|
+
The index bakes packages/react/dist/{manifest.json,patterns.json} (patterns.json: 13 composition patterns with clarifying parameters, blocked/gaps flags, preset JSX) + packages/tokens/dist/{guidance.json,resolved/*.json} at publish; lockstep versioning keeps it truthful (D43).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@handamade/psi-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "MCP server and agent-context generator for the Psi (Ψ) design system",
|
|
6
6
|
"type": "module",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"tsx": "^4.22.4",
|
|
34
34
|
"typescript": "^5.7.0",
|
|
35
35
|
"vitest": "^3.2.6",
|
|
36
|
-
"@handamade/psi-
|
|
37
|
-
"@handamade/psi-
|
|
36
|
+
"@handamade/psi-tokens": "0.9.0",
|
|
37
|
+
"@handamade/psi-react": "0.9.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "tsx scripts/build-index.ts && tsc -p tsconfig.build.json"
|