@handamade/psi-mcp 0.8.1 → 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 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.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",
@@ -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
@@ -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 — a handful of composition recipes, moderately sized.
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: 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.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-tokens": "0.8.1",
37
- "@handamade/psi-react": "0.8.1"
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"