@handamade/psi-mcp 0.8.1 → 0.9.1

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 18 React 19 components, 4 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.
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
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.8.1",
2
+ "version": "0.9.1",
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",
@@ -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 a labelled actions menu, including a destructive item — hand the destructive item's confirmation off to the destructive-confirm pattern. The trigger is a text Button, not an icon-only kebab: IconButton cannot carry an accessible name through the manifest, so it is not composable in a pattern yet",
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": "Button",
2757
+ "component": "IconButton",
2432
2758
  "props": {
2433
- "variant": "ghost"
2759
+ "variant": "ghost",
2760
+ "aria-label": "{content:trigger-label}"
2434
2761
  },
2435
- "content": "trigger-label"
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={<Button variant=\"ghost\">Actions</Button>}\n>\n <MenuItem>Edit</MenuItem>\n <MenuItem>Duplicate</MenuItem>\n <MenuSeparator />\n <MenuItem variant=\"danger\">Delete</MenuItem>\n</Menu>\n"
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
@@ -3,24 +3,32 @@ const MAX_RESULTS = 20;
3
3
  * (~1.5k tokens)" (docs/superpowers/plans/2026-07-18-agent-access-plan.md),
4
4
  * asserted by __tests__/store.test.ts's "stays within the response budget". */
5
5
  const RESPONSE_BUDGET = 6000;
6
- /** Order the empty-query overview is filled in. Guidance first, catalog last,
7
- * because the two grow and cost very differently:
6
+ /** How the empty-query overview divides its budget across kinds. Topics,
7
+ * patterns, and components grow and cost very differently:
8
8
  *
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
- * (the five required CSS imports) above all.
12
- * patterns — a handful of composition recipes, moderately sized.
11
+ * (the five required CSS imports) above all. Takes its full
12
+ * cost, unconditionally.
13
13
  * components — verbose (up to 220 chars each) and the only axis that grows
14
14
  * with every release. Also the axis a keyword query recovers
15
15
  * best: search("menu") finds Menu; no query re-derives a house
16
- * rule an agent doesn't know exists.
16
+ * rule an agent doesn't know exists. Reserved a floor of
17
+ * `COMPONENT_FLOOR` items before patterns spend the remainder,
18
+ * so catalog growth cannot starve them below the floor.
19
+ * patterns — composition recipes; their stored summaries carry match
20
+ * phrases for keyword ranking and can run long, so the overview
21
+ * projects a trimmed copy (see overviewBrief) at a *derived*
22
+ * per-call cap — the largest that lets every pattern fit
23
+ * alongside topics and the component floor. The stored,
24
+ * untrimmed brief is still what score() ranks against.
17
25
  *
18
- * Capping by item *count* (the previous scheme) evicted the tail, and since
19
- * components were emitted first the tail was always topics — so registering
20
- * D53's Menu components silently dropped eight topics including
21
- * `getting-started`. Filling by serialized *length* in this order instead
22
- * degrades only the component tail, which is the cheap thing to lose. */
23
- const OVERVIEW_KIND_ORDER = ["topic", "pattern", "component"];
26
+ * Previously the overview filled topics, then patterns, then components
27
+ * until the budget ran out, so whichever kind was filled last absorbed all
28
+ * catalog growth — three more patterns was enough to starve components from
29
+ * six down to four (D61). Allocating by kind first means catalog growth
30
+ * shortens pattern summaries instead of evicting items off the tail. */
31
+ const COMPONENT_FLOOR = 8;
24
32
  const TOPIC_SUMMARIES = {
25
33
  variants: "Variant intent and typical use for all 8 flat variants",
26
34
  rules: "House rules: accent budget, sizing, color binding",
@@ -57,7 +65,53 @@ function patternSummary(p) {
57
65
  summary += `, blocked (gaps: ${p.gaps.join(", ")})`;
58
66
  return summary;
59
67
  }
68
+ /** Most an overview pattern intro (intent + match phrases + parameter count)
69
+ * may cost. The effective limit is derived per-call (see the empty-query
70
+ * branch): patterns take whatever the budget leaves after topics and the
71
+ * component floor, so a growing catalog shortens summaries instead of
72
+ * evicting items (D61).
73
+ *
74
+ * The `, blocked (gaps: …)` tail is never subject to this cap: it is the
75
+ * backlog signal this cycle exists to publish, and patternSummary() puts it
76
+ * at the END of the string — a length-based slice of the assembled summary
77
+ * would silently drop it for every one of the 13 patterns (all longer than
78
+ * 120 chars), which is exactly the bug this trims to avoid. So the overview
79
+ * projects a pattern's summary from the underlying PatternEntry directly:
80
+ * trim the intro, then always append the tail. */
81
+ const OVERVIEW_PATTERN_INTRO_MAX = 120;
82
+ /** Trims `s` to at most `limit` chars, backing off to the last word boundary
83
+ * and marking the cut with an ellipsis — never a mid-word fragment that
84
+ * could read as a complete (if coincidental) match phrase. */
85
+ function truncateAtWord(s, limit) {
86
+ if (s.length <= limit)
87
+ return s;
88
+ const cut = s.slice(0, limit);
89
+ const lastSpace = cut.lastIndexOf(" ");
90
+ return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
91
+ }
92
+ function overviewPatternSummary(p, limit) {
93
+ const intro = truncateAtWord(`${p.intent} — ${p.match.join(", ")}, ${p.parameters.length} parameters`, limit);
94
+ return p.blocked ? `${intro}, blocked (gaps: ${p.gaps.join(", ")})` : intro;
95
+ }
60
96
  export function createStore(index) {
97
+ const patternsById = new Map(index.patterns.map((p) => [p.id, p]));
98
+ /** Overview-only projection: returns `b` unchanged for every kind except
99
+ * pattern, where it re-derives the summary from the underlying
100
+ * `PatternEntry` (via `overviewPatternSummary`) rather than slicing the
101
+ * already-assembled, full-length `Brief.summary` — so the `blocked (gaps:
102
+ * …)` tail always survives the overview's trim. Always a shallow copy for
103
+ * patterns: the stored brief kept in `briefs` (and used by score()) is
104
+ * never mutated. `limit` is derived per-call by the empty-query branch
105
+ * (D61), not a module constant, so a growing catalog shortens summaries
106
+ * instead of evicting items. */
107
+ function overviewBrief(b, limit) {
108
+ if (b.kind !== "pattern")
109
+ return b;
110
+ const pattern = patternsById.get(b.title);
111
+ if (!pattern)
112
+ return b;
113
+ return { ...b, summary: overviewPatternSummary(pattern, limit) };
114
+ }
61
115
  const briefs = [
62
116
  ...index.components.map((c) => ({
63
117
  id: `component:${c.name}`,
@@ -101,14 +155,25 @@ export function createStore(index) {
101
155
  function search(query) {
102
156
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
103
157
  if (terms.length === 0) {
104
- // Overview: topics, then patterns, then as many components as the
105
- // response budget still affords (tokens are discoverable via query).
106
- // Filled by serialized length, not item count, so the thing that gets
107
- // dropped is always the component tail — never the guidance surface.
108
- const candidates = OVERVIEW_KIND_ORDER.flatMap((kind) => briefs.filter((b) => b.kind === kind));
109
- const out = [];
110
- for (const brief of candidates) {
111
- out.push(brief);
158
+ // D61: allocate before filling. Topics take their full cost, components
159
+ // are reserved a floor, and patterns take the remainder via a derived
160
+ // cap — so catalog growth shortens summaries instead of evicting the
161
+ // tail. Whichever kind was filled last used to absorb all growth;
162
+ // that was the bug. (Tokens are omitted; they are discoverable via
163
+ // query.)
164
+ const topics = briefs.filter((b) => b.kind === "topic");
165
+ const patterns = briefs.filter((b) => b.kind === "pattern");
166
+ const components = briefs.filter((b) => b.kind === "component");
167
+ const reserve = JSON.stringify(components.slice(0, COMPONENT_FLOOR)).length;
168
+ const fits = (limit) => JSON.stringify([...topics, ...patterns.map((b) => overviewBrief(b, limit))]).length +
169
+ reserve <=
170
+ RESPONSE_BUDGET;
171
+ let limit = OVERVIEW_PATTERN_INTRO_MAX;
172
+ while (limit > 0 && !fits(limit))
173
+ limit -= 10;
174
+ const out = [...topics, ...patterns.map((b) => overviewBrief(b, limit))];
175
+ for (const component of components) {
176
+ out.push(component);
112
177
  if (JSON.stringify(out).length > RESPONSE_BUDGET) {
113
178
  out.pop();
114
179
  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: 4 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).
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.8.1",
3
+ "version": "0.9.1",
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-tokens": "0.8.1",
37
- "@handamade/psi-react": "0.8.1"
36
+ "@handamade/psi-tokens": "0.9.1",
37
+ "@handamade/psi-react": "0.9.1"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsx scripts/build-index.ts && tsc -p tsconfig.build.json"