@handamade/psi-mcp 0.5.0 → 0.7.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 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 13 React 19 components, 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.
3
+ MCP server and agent-context generator for the Psi (Ψ) design system. Documents the system — the 15 React 19 components, 3 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
 
@@ -32,6 +32,9 @@ async function readJson(path) {
32
32
  }
33
33
  export async function buildIndex(inputs) {
34
34
  const manifest = (await readJson(join(inputs.reactDist, "manifest.json")));
35
+ // Fail-fast is deliberate: requires @handamade/psi-react built first — same posture
36
+ // as the manifest/guidance reads above.
37
+ const patternsFile = (await readJson(join(inputs.reactDist, "patterns.json")));
35
38
  const guidance = (await readJson(join(inputs.tokensDist, "guidance.json")));
36
39
  const resolved = {};
37
40
  for (const theme of THEMES) {
@@ -61,6 +64,7 @@ export async function buildIndex(inputs) {
61
64
  themes: [...THEMES],
62
65
  components,
63
66
  tokens,
67
+ patterns: patternsFile.patterns,
64
68
  scales: light.scales,
65
69
  topics: {
66
70
  ...guidance,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PsiIndex } from "./types.js";
2
- export type { PsiIndex, ComponentEntry, TokenEntry, PropDoc } from "./types.js";
2
+ export type { PsiIndex, ComponentEntry, TokenEntry, PropDoc, PatternEntry, PatternNode, PatternParameter, } from "./types.js";
3
3
  export { buildIndex, GETTING_STARTED } from "./index-builder.js";
4
4
  export { createStore } from "./store.js";
5
5
  export type { Store, Brief, Detail } from "./store.js";
package/dist/init.js CHANGED
@@ -28,6 +28,7 @@ export function renderAgentsBlock(index) {
28
28
  "- Never hardcode colors — bind var(--psi-*) custom properties.",
29
29
  "- Import all four token CSS files: base.css, one theme css, components.css, utilities.css (utilities is REQUIRED — .psi-container + reduced-motion zeroing) — PLUS @handamade/psi-react/styles (component CSS; separate export, never auto-imported).",
30
30
  `- Themes: ${index.themes.join(" | ")} — set data-psi-theme on <html> or a subtree root.`,
31
+ "- Before composing multi-component UI, search for a matching pattern and ask its clarifying parameters; only freehand when no pattern matches.",
31
32
  "",
32
33
  `## Components (${index.components.length})`,
33
34
  ...index.components.map((c) => `- ${c.name}${axes(c)}`),
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.0",
2
+ "version": "0.7.0",
3
3
  "themes": [
4
4
  "light",
5
5
  "dark",
@@ -164,6 +164,42 @@
164
164
  "slots": [],
165
165
  "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Card\n\nContent container with an optional edge-to-edge media slot; `stacked` (media above body) or `featured` (media beside body) layout.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `variant` | `\"stacked\" \\| \"featured\"` | stacked | no | stacked = media above body; featured = media beside body (~1.6fr/1fr), stacks under md. |\n| `media` | `ReactNode` | — | no | Media slot rendered edge-to-edge (img, AspectRatio, …). |\n| `hoverLift` | `boolean` | false | no | Lift on hover: translateY(-6px) over --psi-duration-350. |\n| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the root element. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n\n## Theming\n\nOverride `--psi-card-*` 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"
166
166
  },
167
+ {
168
+ "name": "Panel",
169
+ "description": "Elevated surface panel (D51): secondary background, faint hairline, radius-12 — the shared --psi-surface-* recipe Dialog's panel also binds. Not a Card: no media slot, no hover lift, opaque by design.",
170
+ "props": [
171
+ {
172
+ "name": "padding",
173
+ "type": "16 | 24",
174
+ "required": false,
175
+ "default": 24,
176
+ "description": "Inner padding in px."
177
+ },
178
+ {
179
+ "name": "ref",
180
+ "type": "Ref<HTMLDivElement>",
181
+ "required": false,
182
+ "default": null,
183
+ "description": "Forwarded ref to the root element."
184
+ },
185
+ {
186
+ "name": "className",
187
+ "type": "string",
188
+ "required": false,
189
+ "default": null,
190
+ "description": "Additional CSS class name(s) merged onto the component's root element."
191
+ }
192
+ ],
193
+ "slots": [
194
+ {
195
+ "name": "body",
196
+ "accepts": {},
197
+ "cardinality": "1..*",
198
+ "order": 1
199
+ }
200
+ ],
201
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Panel\n\nElevated surface panel (D51): secondary background, faint hairline, radius-12 — the shared --psi-surface-* recipe Dialog's panel also binds. Not a Card: no media slot, no hover lift, opaque by design.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `padding` | `16 \\| 24` | 24 | no | Inner padding in px. |\n| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the root 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| Tab | Skipped — Panel itself is not focusable; focus moves through its children. |\n\nPlain <div> container with no implicit role. Pass aria-* host props if the panel should announce as a region.\n\n## Theming\n\nOverride `--psi-panel-*` 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"
202
+ },
167
203
  {
168
204
  "name": "Input",
169
205
  "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).",
@@ -189,6 +225,13 @@
189
225
  "default": null,
190
226
  "description": "Forwarded ref to the underlying `<input>` element."
191
227
  },
228
+ {
229
+ "name": "placeholder",
230
+ "type": "string",
231
+ "required": false,
232
+ "default": null,
233
+ "description": "Native placeholder text shown while the field is empty (only on components whose host element supports it)."
234
+ },
192
235
  {
193
236
  "name": "className",
194
237
  "type": "string",
@@ -198,7 +241,7 @@
198
241
  }
199
242
  ],
200
243
  "slots": [],
201
- "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| `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"
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"
202
245
  },
203
246
  {
204
247
  "name": "Select",
@@ -297,7 +340,26 @@
297
340
  "description": "Additional CSS class name(s) merged onto the component's root element."
298
341
  }
299
342
  ],
300
- "slots": [],
343
+ "slots": [
344
+ {
345
+ "name": "label",
346
+ "accepts": {},
347
+ "cardinality": "0..1",
348
+ "order": 1
349
+ },
350
+ {
351
+ "name": "body",
352
+ "accepts": {},
353
+ "cardinality": "1..*",
354
+ "order": 2
355
+ },
356
+ {
357
+ "name": "description",
358
+ "accepts": {},
359
+ "cardinality": "0..1",
360
+ "order": 3
361
+ }
362
+ ],
301
363
  "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Field\n\nLabeled form-row wrapper: label above, control, one message line below — description normally, error when set (aria-live). Auto-wires id/aria-describedby/aria-invalid into Input and Select (D49).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `label` | `ReactNode` | — | no | Label content; renders a <label> (or <legend> in group mode). |\n| `description` | `ReactNode` | — | no | Helper line under the control; replaced by error when error is set. |\n| `error` | `ReactNode` | — | no | Error content; when truthy it replaces the description and switches the field (and a wrapped Input/Select) into error state. |\n| `required` | `boolean` | false | no | Renders the required marker and flows `required` to the control. |\n| `group` | `boolean` | false | no | Group mode: fieldset/legend wrapping several self-labeled controls (Checkbox/Switch); the message describes the whole group. |\n| `htmlFor` | `string` | — | no | Override the generated control id (pair it with the same id on the control). |\n| `ref` | `Ref<HTMLDivElement \\| HTMLFieldSetElement>` | — | no | Forwarded ref to the root 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| Tab | Focus moves to the wrapped control; the label is announced with it. |\n\nWires label association, aria-describedby and aria-invalid into a wrapped Input/Select automatically; the message line is aria-live=polite. Group mode renders fieldset/legend.\n\n## Theming\n\nOverride `--psi-field-*` 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"
302
364
  },
303
365
  {
@@ -495,6 +557,42 @@
495
557
  "slots": [],
496
558
  "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Tag\n\nCompact label for status or category; semantic variants, optional subtle tint and dismiss button.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `variant` | `\"neutral\" \\| \"accent\" \\| \"success\" \\| \"warning\" \\| \"danger\"` | neutral | no | Semantic color variant. |\n| `subtle` | `boolean` | false | no | Use tinted background with semantic foreground. |\n| `onDismiss` | `(() => void)` | — | no | When provided, renders a dismiss button that calls this handler. |\n| `ref` | `Ref<HTMLSpanElement>` | — | no | Forwarded ref to the underlying `<span>` 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 (dismiss button) | onDismiss renders a real <button>; keyboard-dismissible. |\n\nPassive label otherwise; not in the tab order without onDismiss.\n\n## Theming\n\nOverride `--psi-tag-*` 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"
497
559
  },
560
+ {
561
+ "name": "Toolbar",
562
+ "description": "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\".",
563
+ "props": [
564
+ {
565
+ "name": "gap",
566
+ "type": "8 | 12 | 16",
567
+ "required": false,
568
+ "default": 8,
569
+ "description": "Gap between controls in px."
570
+ },
571
+ {
572
+ "name": "ref",
573
+ "type": "Ref<HTMLDivElement>",
574
+ "required": false,
575
+ "default": null,
576
+ "description": "Forwarded ref to the root element."
577
+ },
578
+ {
579
+ "name": "className",
580
+ "type": "string",
581
+ "required": false,
582
+ "default": null,
583
+ "description": "Additional CSS class name(s) merged onto the component's root element."
584
+ }
585
+ ],
586
+ "slots": [
587
+ {
588
+ "name": "body",
589
+ "accepts": {},
590
+ "cardinality": "0..*",
591
+ "order": 1
592
+ }
593
+ ],
594
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Toolbar\n\nHorizontal 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\".\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `gap` | `8 \\| 12 \\| 16` | 8 | no | Gap between controls in px. |\n| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the root 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| Tab | Moves through the controls in DOM order — no roving tabindex (deliberately not role=toolbar, D52). |\n\nWith aria-label it renders role=group so the control cluster announces with a name; unlabeled it is a plain layout div.\n\n## Theming\n\nOverride `--psi-toolbar-*` 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"
595
+ },
498
596
  {
499
597
  "name": "Tooltip",
500
598
  "description": "Short hover/focus hint attached to a single trigger element.",
@@ -982,11 +1080,11 @@
982
1080
  },
983
1081
  "ember": {
984
1082
  "oklch": {
985
- "l": 0.25,
1083
+ "l": 0.14,
986
1084
  "c": 0.011,
987
1085
  "h": 72
988
1086
  },
989
- "hex": "#25211c"
1087
+ "hex": "#0c0805"
990
1088
  }
991
1089
  }
992
1090
  },
@@ -1690,6 +1788,44 @@
1690
1788
  }
1691
1789
  }
1692
1790
  },
1791
+ {
1792
+ "name": "fillStaticWhite",
1793
+ "formula": "ref(fgStaticWhite)",
1794
+ "values": {
1795
+ "light": {
1796
+ "oklch": {
1797
+ "l": 1,
1798
+ "c": 0,
1799
+ "h": 250
1800
+ },
1801
+ "hex": "#ffffff"
1802
+ },
1803
+ "dark": {
1804
+ "oklch": {
1805
+ "l": 1,
1806
+ "c": 0,
1807
+ "h": 250
1808
+ },
1809
+ "hex": "#ffffff"
1810
+ },
1811
+ "acme": {
1812
+ "oklch": {
1813
+ "l": 1,
1814
+ "c": 0,
1815
+ "h": 80
1816
+ },
1817
+ "hex": "#ffffff"
1818
+ },
1819
+ "ember": {
1820
+ "oklch": {
1821
+ "l": 1,
1822
+ "c": 0,
1823
+ "h": 49
1824
+ },
1825
+ "hex": "#ffffff"
1826
+ }
1827
+ }
1828
+ },
1693
1829
  {
1694
1830
  "name": "borderFaint",
1695
1831
  "formula": "ref(fgPrimary) → alpha(0.08)",
@@ -1979,6 +2115,217 @@
1979
2115
  "hex": "#0c0a09"
1980
2116
  }
1981
2117
  }
2118
+ },
2119
+ {
2120
+ "name": "bgInverted",
2121
+ "formula": "ref(fgPrimary)",
2122
+ "values": {
2123
+ "light": {
2124
+ "oklch": {
2125
+ "l": 0.3,
2126
+ "c": 0.03,
2127
+ "h": 250
2128
+ },
2129
+ "hex": "#222f3c"
2130
+ },
2131
+ "dark": {
2132
+ "oklch": {
2133
+ "l": 0.93,
2134
+ "c": 0.01,
2135
+ "h": 250
2136
+ },
2137
+ "hex": "#e3e8ee"
2138
+ },
2139
+ "acme": {
2140
+ "oklch": {
2141
+ "l": 0.3,
2142
+ "c": 0.03,
2143
+ "h": 30
2144
+ },
2145
+ "hex": "#3c2825"
2146
+ },
2147
+ "ember": {
2148
+ "oklch": {
2149
+ "l": 0.949,
2150
+ "c": 0.011,
2151
+ "h": 72
2152
+ },
2153
+ "hex": "#f3ede6"
2154
+ }
2155
+ }
2156
+ },
2157
+ {
2158
+ "name": "fgOnInverted",
2159
+ "formula": "ref(bgPrimary)",
2160
+ "values": {
2161
+ "light": {
2162
+ "oklch": {
2163
+ "l": 0.95,
2164
+ "c": 0.005,
2165
+ "h": 250
2166
+ },
2167
+ "hex": "#eceff2"
2168
+ },
2169
+ "dark": {
2170
+ "oklch": {
2171
+ "l": 0.15,
2172
+ "c": 0.01,
2173
+ "h": 250
2174
+ },
2175
+ "hex": "#080c0f"
2176
+ },
2177
+ "acme": {
2178
+ "oklch": {
2179
+ "l": 0.96,
2180
+ "c": 0.01,
2181
+ "h": 80
2182
+ },
2183
+ "hex": "#f5f1ea"
2184
+ },
2185
+ "ember": {
2186
+ "oklch": {
2187
+ "l": 0.147,
2188
+ "c": 0.004,
2189
+ "h": 49
2190
+ },
2191
+ "hex": "#0c0a09"
2192
+ }
2193
+ }
2194
+ }
2195
+ ],
2196
+ "patterns": [
2197
+ {
2198
+ "id": "destructive-confirm",
2199
+ "intent": "Dialog confirming a destructive action",
2200
+ "match": [
2201
+ "delete confirmation",
2202
+ "are you sure",
2203
+ "destructive dialog",
2204
+ "confirm delete"
2205
+ ],
2206
+ "compose": {
2207
+ "component": "Dialog",
2208
+ "slots": {
2209
+ "title": [
2210
+ "{content:title}"
2211
+ ],
2212
+ "body": [
2213
+ "{content:consequence}"
2214
+ ],
2215
+ "footer": [
2216
+ {
2217
+ "component": "Button",
2218
+ "props": {
2219
+ "variant": "ghost",
2220
+ "size": "{param:size}"
2221
+ },
2222
+ "content": "cancel-label"
2223
+ },
2224
+ {
2225
+ "component": "Button",
2226
+ "props": {
2227
+ "variant": "danger",
2228
+ "size": "{param:size}"
2229
+ },
2230
+ "content": "confirm-label"
2231
+ }
2232
+ ]
2233
+ }
2234
+ },
2235
+ "parameters": [
2236
+ {
2237
+ "key": "size",
2238
+ "ask": "Button size?",
2239
+ "options": [
2240
+ 32,
2241
+ 40
2242
+ ],
2243
+ "default": 32
2244
+ }
2245
+ ],
2246
+ "content": {
2247
+ "title": "<Delete the object?>",
2248
+ "consequence": "<what is permanently lost>",
2249
+ "cancel-label": "Cancel",
2250
+ "confirm-label": "<verb the object>"
2251
+ },
2252
+ "gaps": [],
2253
+ "blocked": false,
2254
+ "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"
2255
+ },
2256
+ {
2257
+ "id": "filter-toolbar",
2258
+ "intent": "Toolbar of filter controls: search, category select, active-filter tags",
2259
+ "match": [
2260
+ "filter toolbar",
2261
+ "filter bar",
2262
+ "search and filter",
2263
+ "faceted filters"
2264
+ ],
2265
+ "compose": {
2266
+ "component": "Toolbar",
2267
+ "slots": {
2268
+ "body": [
2269
+ {
2270
+ "component": "Input"
2271
+ },
2272
+ {
2273
+ "component": "Select"
2274
+ },
2275
+ {
2276
+ "component": "Tag",
2277
+ "content": "example-filter"
2278
+ }
2279
+ ]
2280
+ }
2281
+ },
2282
+ "parameters": [],
2283
+ "content": {
2284
+ "example-filter": "<active filter>"
2285
+ },
2286
+ "gaps": [],
2287
+ "blocked": false,
2288
+ "preset": "<Toolbar>\n <Input />\n <Select />\n <Tag><active filter></Tag>\n</Toolbar>\n"
2289
+ },
2290
+ {
2291
+ "id": "settings-form-row",
2292
+ "intent": "Labeled settings row: a self-labeled control with a group label and helper text",
2293
+ "match": [
2294
+ "settings row",
2295
+ "form row",
2296
+ "labeled switch",
2297
+ "preference toggle",
2298
+ "settings form"
2299
+ ],
2300
+ "compose": {
2301
+ "component": "Field",
2302
+ "props": {
2303
+ "group": true
2304
+ },
2305
+ "slots": {
2306
+ "label": [
2307
+ "{content:row-label}"
2308
+ ],
2309
+ "body": [
2310
+ {
2311
+ "component": "Switch",
2312
+ "content": "control-label"
2313
+ }
2314
+ ],
2315
+ "description": [
2316
+ "{content:row-description}"
2317
+ ]
2318
+ }
2319
+ },
2320
+ "parameters": [],
2321
+ "content": {
2322
+ "row-label": "<setting name>",
2323
+ "control-label": "<what turning it on does>",
2324
+ "row-description": "<one-line consequence of the setting>"
2325
+ },
2326
+ "gaps": [],
2327
+ "blocked": false,
2328
+ "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"
1982
2329
  }
1983
2330
  ],
1984
2331
  "scales": {
@@ -2042,6 +2389,11 @@
2042
2389
  "overlay": 1000,
2043
2390
  "tooltip": 1100
2044
2391
  }
2392
+ },
2393
+ "scopes": {
2394
+ "space": [
2395
+ "gap"
2396
+ ]
2045
2397
  }
2046
2398
  },
2047
2399
  "topics": {
@@ -2258,6 +2610,11 @@
2258
2610
  "overlay": 1000,
2259
2611
  "tooltip": 1100
2260
2612
  }
2613
+ },
2614
+ "scopes": {
2615
+ "space": [
2616
+ "gap"
2617
+ ]
2261
2618
  }
2262
2619
  }
2263
2620
  }
package/dist/server.js CHANGED
@@ -4,8 +4,9 @@ export function createPsiServer(store, version) {
4
4
  const server = new McpServer({ name: "psi", version });
5
5
  server.registerTool("search", {
6
6
  title: "Search the Psi design system",
7
- description: "Keyword search over Psi components, semantic tokens, and guidance topics. " +
8
- "Returns compact briefs with ids for the get tool. Empty query returns an overview.",
7
+ description: "Keyword search over Psi components, composition patterns, semantic tokens, and " +
8
+ "guidance topics. Returns compact briefs with ids for the get tool. Empty query " +
9
+ "returns an overview.",
9
10
  inputSchema: { query: z.string().describe("Keywords, e.g. 'button variants' or 'bg token'") },
10
11
  }, async ({ query }) => ({
11
12
  content: [{ type: "text", text: JSON.stringify(store.search(query), null, 1) }],
@@ -13,8 +14,11 @@ export function createPsiServer(store, version) {
13
14
  server.registerTool("get", {
14
15
  title: "Get full Psi detail by id",
15
16
  description: "Full detail for one id from search: component (props, defaults, a11y doc), " +
16
- "token (formula + resolved OKLCH/hex in all four themes), or topic (guidance). " +
17
- "Accepts 'component:Button', 'token:bgPrimary', 'topic:variants', or a bare name.",
17
+ "pattern (composition recipe: compose tree, clarifying parameters, content, " +
18
+ "gaps/blocked status, and a copy-paste preset when unblocked), token (formula + " +
19
+ "resolved OKLCH/hex in all four themes), or topic (guidance). Accepts " +
20
+ "'component:Button', 'pattern:destructive-confirm', 'token:bgPrimary', " +
21
+ "'topic:variants', or a bare name.",
18
22
  inputSchema: { id: z.string().describe("An id from search results, or a bare name") },
19
23
  }, async ({ id }) => {
20
24
  const detail = store.get(id);
package/dist/store.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { ComponentEntry, PsiIndex, TokenEntry } from "./types.js";
1
+ import type { ComponentEntry, PatternEntry, PsiIndex, TokenEntry } from "./types.js";
2
2
  export interface Brief {
3
3
  id: string;
4
- kind: "component" | "token" | "topic";
4
+ kind: "component" | "token" | "topic" | "pattern";
5
5
  title: string;
6
6
  summary: string;
7
7
  }
@@ -9,7 +9,9 @@ export type Detail = ({
9
9
  kind: "component";
10
10
  } & ComponentEntry) | ({
11
11
  kind: "token";
12
- } & TokenEntry) | {
12
+ } & TokenEntry) | ({
13
+ kind: "pattern";
14
+ } & PatternEntry) | {
13
15
  kind: "topic";
14
16
  name: string;
15
17
  content: unknown;
package/dist/store.js CHANGED
@@ -29,6 +29,12 @@ function componentSummary(c) {
29
29
  parts.push(`props: ${c.props.map((p) => p.name).slice(0, 6).join(", ")}`);
30
30
  return parts.join(" — ").slice(0, 220);
31
31
  }
32
+ function patternSummary(p) {
33
+ let summary = `${p.intent} — ${p.match.join(", ")}, ${p.parameters.length} parameters`;
34
+ if (p.blocked)
35
+ summary += `, blocked (gaps: ${p.gaps.join(", ")})`;
36
+ return summary;
37
+ }
32
38
  export function createStore(index) {
33
39
  const briefs = [
34
40
  ...index.components.map((c) => ({
@@ -37,6 +43,12 @@ export function createStore(index) {
37
43
  title: c.name,
38
44
  summary: componentSummary(c),
39
45
  })),
46
+ ...index.patterns.map((p) => ({
47
+ id: `pattern:${p.id}`,
48
+ kind: "pattern",
49
+ title: p.id,
50
+ summary: patternSummary(p),
51
+ })),
40
52
  ...Object.keys(index.topics).map((name) => ({
41
53
  id: `topic:${name}`,
42
54
  kind: "topic",
@@ -67,7 +79,7 @@ export function createStore(index) {
67
79
  function search(query) {
68
80
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
69
81
  if (terms.length === 0) {
70
- // Overview: all components + all topics (tokens are discoverable via query).
82
+ // Overview: all components, patterns, and topics (tokens are discoverable via query).
71
83
  return briefs.filter((b) => b.kind !== "token").slice(0, MAX_RESULTS + 10);
72
84
  }
73
85
  return briefs
@@ -91,6 +103,11 @@ export function createStore(index) {
91
103
  if (t)
92
104
  return { kind: "token", ...t };
93
105
  }
106
+ if (want("pattern")) {
107
+ const p = index.patterns.find((p) => p.id.toLowerCase() === name);
108
+ if (p)
109
+ return { kind: "pattern", ...p };
110
+ }
94
111
  if (want("topic")) {
95
112
  const key = Object.keys(index.topics).find((k) => k.toLowerCase() === name);
96
113
  if (key)
package/dist/types.d.ts CHANGED
@@ -37,11 +37,43 @@ export interface TokenEntry {
37
37
  hex: string;
38
38
  }>;
39
39
  }
40
+ /** A single node in a pattern's `compose` tree: a component instance, its
41
+ * props (which may hold `{param:key}` / `{content:key}` placeholders), and
42
+ * its slot fills (nested nodes or string literals / placeholders). Mirrors
43
+ * `packages/react/scripts/patterns.ts`'s `PatternNode` — the shape emitted
44
+ * into `packages/react/dist/patterns.json` (D47). */
45
+ export interface PatternNode {
46
+ component: string;
47
+ props?: Record<string, unknown>;
48
+ slots?: Record<string, Array<PatternNode | string>>;
49
+ content?: string;
50
+ }
51
+ export interface PatternParameter {
52
+ key: string;
53
+ ask: string;
54
+ options: Array<string | number>;
55
+ default?: string | number;
56
+ }
57
+ /** Shape of one entry in `packages/react/dist/patterns.json` (D47/D48):
58
+ * an authored composition recipe plus its build-time validation/render
59
+ * results (`gaps`, `blocked`, `preset`). */
60
+ export interface PatternEntry {
61
+ id: string;
62
+ intent: string;
63
+ match: string[];
64
+ compose: PatternNode;
65
+ parameters: PatternParameter[];
66
+ content: Record<string, string>;
67
+ gaps: string[];
68
+ blocked: boolean;
69
+ preset: string | null;
70
+ }
40
71
  export interface PsiIndex {
41
72
  version: string;
42
73
  themes: string[];
43
74
  components: ComponentEntry[];
44
75
  tokens: TokenEntry[];
76
+ patterns: PatternEntry[];
45
77
  scales: Record<string, unknown>;
46
78
  topics: Record<string, unknown>;
47
79
  }
package/llms.txt CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Two read-only tools over a baked index of the generated artifacts:
4
4
  - search(query): briefs {id, kind, title, summary}; empty query = overview
5
- - get(id): component (props/defaults/a11y doc) | token (formula + OKLCH/hex per theme) | topic (guidance)
6
- Ids: component:<Name>, token:<name>, topic:<key>; bare names resolve case-insensitively.
5
+ - get(id): component (props/defaults/a11y doc) | token (formula + OKLCH/hex per theme) | topic (guidance) | pattern (compose tree, clarifying parameters, content, gaps/blocked status, preset JSX)
6
+ Ids: component:<Name>, token:<name>, topic:<key>, pattern:<id>; bare names resolve case-insensitively.
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 + packages/tokens/dist/{guidance.json,resolved/*.json} at publish; lockstep versioning keeps it truthful (D43).
10
+ The index bakes packages/react/dist/{manifest.json,patterns.json} (patterns.json: 3 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.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server and agent-context generator for the Psi (Ψ) design system",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,8 +32,8 @@
32
32
  "tsx": "^4.22.4",
33
33
  "typescript": "^5.7.0",
34
34
  "vitest": "^3.2.6",
35
- "@handamade/psi-react": "0.5.0",
36
- "@handamade/psi-tokens": "0.5.0"
35
+ "@handamade/psi-tokens": "0.7.0",
36
+ "@handamade/psi-react": "0.7.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsx scripts/build-index.ts && tsc -p tsconfig.build.json"