@handamade/psi-mcp 0.7.1 → 0.8.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry Kurkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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 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.
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.
4
4
 
5
5
  ## Connect
6
6
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.1",
2
+ "version": "0.8.0",
3
3
  "themes": [
4
4
  "light",
5
5
  "dark",
@@ -700,6 +700,121 @@
700
700
  ],
701
701
  "slots": [],
702
702
  "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# AspectRatio\n\nFixed-aspect-ratio box that sizes its child media.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `ratio` | `number` | — | yes | Width/height ratio, e.g. 16/10 or 4/5. |\n| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the frame element. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n\n## Theming\n\nThis component has no `--psi-aspect-ratio-*` tokens — its styling binds scale tokens only, so there is nothing component-scoped to override. Theme changes reach it through the semantic tokens of its children and surroundings.\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"
703
+ },
704
+ {
705
+ "name": "Menu",
706
+ "description": "Action menu on the native Popover API top layer: the top layer and light dismiss come from the platform; Esc, roving keyboard, placement and dismissal reasons are Psi's (D53). Controlled-only, like Dialog (D50). Controlled-only means Menu does not change its own visibility. Esc and item-select *report* a dismissal via onClose(reason) and leave the popover open; only the consumer flipping `open` to false actually closes it. Esc calls preventDefault() to suppress the platform's own popover dismissal, which is what makes that possible. Light dismiss (outside click) is the one asymmetry, and it is forced by the platform: the browser hides the popover itself and the hide-side `beforetoggle` is not cancelable, so the popover is already closed by the time the resulting `toggle` lets us report onClose(\"outside\"). The consumer must still flip `open` to false so React's state matches the DOM. `toggle` therefore reports only that one reason. A close driven by the consumer (`open` true -> false) runs through the sync effect's own hidePopover(), which raises a `toggle` too — that one is suppressed, because a programmatic close is not a dismissal and must not call onClose.",
707
+ "props": [
708
+ {
709
+ "name": "open",
710
+ "type": "boolean",
711
+ "required": true,
712
+ "default": null,
713
+ "description": "Controlled open state; syncs to showPopover()/hidePopover()."
714
+ },
715
+ {
716
+ "name": "onClose",
717
+ "type": "(reason: \"item-select\" | \"esc\" | \"outside\") => void",
718
+ "required": true,
719
+ "default": null,
720
+ "description": "Called when a dismissal is *requested*; the consumer flips `open`.\n\nMenu never closes itself on `\"esc\"` or `\"item-select\"` — the popover stays\nopen until `open` becomes false (D50). `\"outside\"` is the one exception:\nlight dismiss is performed by the browser before it tells us, so by the\ntime that reason is reported the popover is already closed. Flipping\n`open` to false is still required, to keep React's state in step."
721
+ },
722
+ {
723
+ "name": "trigger",
724
+ "type": "ReactElement<Record<string, unknown>, string | JSXElementConstructor<any>>",
725
+ "required": true,
726
+ "default": null,
727
+ "description": "The trigger element. Must be a single React element that spreads unknown\nprops onto a focusable node (a Psi `Button`, say): Menu clones it to add\n`aria-haspopup=\"menu\"` and `aria-expanded`, so assistive tech associates\nthe menu with the control users actually focus."
728
+ },
729
+ {
730
+ "name": "placement",
731
+ "type": "\"bottom-start\" | \"bottom-end\" | \"top-start\" | \"top-end\"",
732
+ "required": false,
733
+ "default": "bottom-start",
734
+ "description": "Placement relative to the trigger."
735
+ },
736
+ {
737
+ "name": "aria-label",
738
+ "type": "string",
739
+ "required": false,
740
+ "default": null,
741
+ "description": "Accessible name for the menu when there is no visible label."
742
+ },
743
+ {
744
+ "name": "className",
745
+ "type": "string",
746
+ "required": false,
747
+ "default": null,
748
+ "description": "Additional CSS class name(s) merged onto the component's root element."
749
+ },
750
+ {
751
+ "name": "ref",
752
+ "type": "Ref<HTMLDivElement>",
753
+ "required": false,
754
+ "default": null,
755
+ "description": "Forwarded ref to the popover element."
756
+ }
757
+ ],
758
+ "slots": [
759
+ {
760
+ "name": "trigger",
761
+ "accepts": {
762
+ "contracts": [
763
+ "interactive"
764
+ ]
765
+ },
766
+ "cardinality": "1..1",
767
+ "order": 1
768
+ },
769
+ {
770
+ "name": "body",
771
+ "accepts": {
772
+ "components": [
773
+ "MenuItem",
774
+ "MenuSeparator"
775
+ ]
776
+ },
777
+ "cardinality": "1..*",
778
+ "order": 2
779
+ }
780
+ ],
781
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Menu\n\nAction menu on the native Popover API top layer: the top layer and light dismiss come from the platform; Esc, roving keyboard, placement and dismissal reasons are Psi's (D53). Controlled-only, like Dialog (D50). Controlled-only means Menu does not change its own visibility. Esc and item-select *report* a dismissal via onClose(reason) and leave the popover open; only the consumer flipping `open` to false actually closes it. Esc calls preventDefault() to suppress the platform's own popover dismissal, which is what makes that possible. Light dismiss (outside click) is the one asymmetry, and it is forced by the platform: the browser hides the popover itself and the hide-side `beforetoggle` is not cancelable, so the popover is already closed by the time the resulting `toggle` lets us report onClose(\"outside\"). The consumer must still flip `open` to false so React's state matches the DOM. `toggle` therefore reports only that one reason. A close driven by the consumer (`open` true -> false) runs through the sync effect's own hidePopover(), which raises a `toggle` too — that one is suppressed, because a programmatic close is not a dismissal and must not call onClose.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `open` | `boolean` | — | yes | Controlled open state; syncs to showPopover()/hidePopover(). |\n| `onClose` | `(reason: \"item-select\" \\| \"esc\" \\| \"outside\") => void` | — | yes | Called when a dismissal is *requested*; the consumer flips `open`. Menu never closes itself on `\"esc\"` or `\"item-select\"` — the popover stays open until `open` becomes false (D50). `\"outside\"` is the one exception: light dismiss is performed by the browser before it tells us, so by the time that reason is reported the popover is already closed. Flipping `open` to false is still required, to keep React's state in step. |\n| `trigger` | `ReactElement<Record<string, unknown>, string \\| JSXElementConstructor<any>>` | — | yes | The trigger element. Must be a single React element that spreads unknown props onto a focusable node (a Psi `Button`, say): Menu clones it to add `aria-haspopup=\"menu\"` and `aria-expanded`, so assistive tech associates the menu with the control users actually focus. |\n| `placement` | `\"bottom-start\" \\| \"bottom-end\" \\| \"top-start\" \\| \"top-end\"` | bottom-start | no | Placement relative to the trigger. |\n| `aria-label` | `string` | — | no | Accessible name for the menu when there is no visible label. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the popover element. |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Arrow Down / Arrow Up | Moves between enabled items, wrapping at both ends. Disabled items are skipped. |\n| Home / End | Jumps to the first or last enabled item. |\n| Any single printable key | Typeahead — focuses the first enabled item whose label starts with the typed prefix; the prefix resets after 500ms. Keystrokes with Meta/Ctrl/Alt held are ignored. |\n| Esc | Suppresses the platform's own dismissal and reports onClose(\"esc\"); the menu stays open until the consumer flips `open` (D50). |\n| Enter / Space | Activates the focused item (native button behavior). |\n\nOpens on the native top layer via popover=\"auto\", which supplies light dismiss. Controlled-only: every dismissal path (esc, item-select, outside) only reports onClose(reason) — the consumer must flip `open` to actually close it; outside is the one path the platform has already acted on by the time it is reported. Opening moves focus to the first enabled item — Menu takes focus off the trigger as soon as `open` becomes true. Focus returns to the trigger when the menu actually closes, and only if focus is still inside the menu, so a light dismiss onto another control does not steal focus back. Requires an accessible name — pass aria-label. Placement uses CSS anchor positioning where supported and a JS fallback below that floor; below it there is no collision flip.\n\n## Theming\n\nOverride `--psi-menu-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).\n\n\n\n## Rules\n\n- One accent per visual group; everything else neutral or ghost.\n- danger only for actions with real consequences.\n- Sizes are px numbers (24|32|40|48), never S/M/L.\n- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.\n- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.\n- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).\n- Wrap labeled form controls in Field — label association, description/error line, aria-describedby and aria-invalid come wired; don't hand-roll label+message rows.\n- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.\n"
782
+ },
783
+ {
784
+ "name": "MenuItem",
785
+ "description": "One action in a Menu. Renders a real <button> so activation, Enter and Space come from the platform; the roving tabindex is applied by Menu's keyboard hook, which finds items via [data-psi-menu-item] (D53).",
786
+ "props": [
787
+ {
788
+ "name": "onSelect",
789
+ "type": "() => void",
790
+ "required": true,
791
+ "default": null,
792
+ "description": "Fires on activation; Menu then reports the dismissal via onClose(\"item-select\") (D50)."
793
+ },
794
+ {
795
+ "name": "variant",
796
+ "type": "\"neutral\" | \"danger\"",
797
+ "required": false,
798
+ "default": "neutral",
799
+ "description": "danger is for destructive actions only (house rule)."
800
+ },
801
+ {
802
+ "name": "disabled",
803
+ "type": "boolean",
804
+ "required": false,
805
+ "default": false,
806
+ "description": ""
807
+ }
808
+ ],
809
+ "slots": [],
810
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# MenuItem\n\nOne action in a Menu. Renders a real <button> so activation, Enter and Space come from the platform; the roving tabindex is applied by Menu's keyboard hook, which finds items via [data-psi-menu-item] (D53).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `onSelect` | `() => void` | — | yes | Fires on activation; Menu then reports the dismissal via onClose(\"item-select\") (D50). |\n| `variant` | `\"neutral\" \\| \"danger\"` | neutral | no | danger is for destructive actions only (house rule). |\n| `disabled` | `boolean` | false | no | |\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n| Enter / Space | Activates; Menu reports onClose(\"item-select\") but does not close itself. |\n\nRenders a real <button>. disabled sets aria-disabled (not the disabled attribute) so the item stays discoverable to assistive tech while being skipped by roving navigation. variant=\"danger\" is for destructive actions only.\n\n## Theming\n\nThis component has no `--psi-menu-item-*` tokens — its styling binds scale tokens only, so there is nothing component-scoped to override. Theme changes reach it through the semantic tokens of its children and surroundings.\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"
811
+ },
812
+ {
813
+ "name": "MenuSeparator",
814
+ "description": "Hairline rule between Menu item groups. No props by design (D53).",
815
+ "props": [],
816
+ "slots": [],
817
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# MenuSeparator\n\nHairline rule between Menu item groups. No props by design (D53).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n\n\n## Keyboard & assistive tech\n\n| Keys | Behavior |\n|---|---|\n\n\nNon-interactive rule with role=\"separator\" — exposed to assistive tech as a separator, never focusable, and skipped by roving navigation.\n\n## Theming\n\nThis component has no `--psi-menu-separator-*` tokens — its styling binds scale tokens only, so there is nothing component-scoped to override. Theme changes reach it through the semantic tokens of its children and surroundings.\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"
703
818
  }
704
819
  ],
705
820
  "tokens": [
@@ -2251,14 +2366,14 @@
2251
2366
  }
2252
2367
  ],
2253
2368
  "content": {
2254
- "title": "<Delete the object?>",
2255
- "consequence": "<what is permanently lost>",
2369
+ "title": "[Delete the object?]",
2370
+ "consequence": "[what is permanently lost]",
2256
2371
  "cancel-label": "Cancel",
2257
- "confirm-label": "<verb the object>"
2372
+ "confirm-label": "[verb the object]"
2258
2373
  },
2259
2374
  "gaps": [],
2260
2375
  "blocked": false,
2261
- "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"
2376
+ "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"
2262
2377
  },
2263
2378
  {
2264
2379
  "id": "filter-toolbar",
@@ -2288,11 +2403,80 @@
2288
2403
  },
2289
2404
  "parameters": [],
2290
2405
  "content": {
2291
- "example-filter": "<active filter>"
2406
+ "example-filter": "[active filter]"
2292
2407
  },
2293
2408
  "gaps": [],
2294
2409
  "blocked": false,
2295
- "preset": "<Toolbar>\n <Input />\n <Select />\n <Tag><active filter></Tag>\n</Toolbar>\n"
2410
+ "preset": "<Toolbar>\n <Input />\n <Select />\n <Tag>[active filter]</Tag>\n</Toolbar>\n"
2411
+ },
2412
+ {
2413
+ "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",
2415
+ "match": [
2416
+ "row actions",
2417
+ "per-row actions",
2418
+ "table row menu",
2419
+ "actions menu",
2420
+ "row action list"
2421
+ ],
2422
+ "compose": {
2423
+ "component": "Menu",
2424
+ "props": {
2425
+ "placement": "{param:placement}",
2426
+ "aria-label": "{content:trigger-label}"
2427
+ },
2428
+ "slots": {
2429
+ "trigger": [
2430
+ {
2431
+ "component": "Button",
2432
+ "props": {
2433
+ "variant": "ghost"
2434
+ },
2435
+ "content": "trigger-label"
2436
+ }
2437
+ ],
2438
+ "body": [
2439
+ {
2440
+ "component": "MenuItem",
2441
+ "content": "edit-label"
2442
+ },
2443
+ {
2444
+ "component": "MenuItem",
2445
+ "content": "duplicate-label"
2446
+ },
2447
+ {
2448
+ "component": "MenuSeparator"
2449
+ },
2450
+ {
2451
+ "component": "MenuItem",
2452
+ "props": {
2453
+ "variant": "danger"
2454
+ },
2455
+ "content": "delete-label"
2456
+ }
2457
+ ]
2458
+ }
2459
+ },
2460
+ "parameters": [
2461
+ {
2462
+ "key": "placement",
2463
+ "ask": "Which side should the menu open on?",
2464
+ "options": [
2465
+ "bottom-start",
2466
+ "bottom-end"
2467
+ ],
2468
+ "default": "bottom-end"
2469
+ }
2470
+ ],
2471
+ "content": {
2472
+ "trigger-label": "Actions",
2473
+ "edit-label": "Edit",
2474
+ "duplicate-label": "Duplicate",
2475
+ "delete-label": "Delete"
2476
+ },
2477
+ "gaps": [],
2478
+ "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"
2296
2480
  },
2297
2481
  {
2298
2482
  "id": "settings-form-row",
@@ -2326,13 +2510,13 @@
2326
2510
  },
2327
2511
  "parameters": [],
2328
2512
  "content": {
2329
- "row-label": "<setting name>",
2330
- "control-label": "<what turning it on does>",
2331
- "row-description": "<one-line consequence of the setting>"
2513
+ "row-label": "[setting name]",
2514
+ "control-label": "[what turning it on does]",
2515
+ "row-description": "[one-line consequence of the setting]"
2332
2516
  },
2333
2517
  "gaps": [],
2334
2518
  "blocked": false,
2335
- "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"
2519
+ "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"
2336
2520
  }
2337
2521
  ],
2338
2522
  "scales": {
@@ -2534,6 +2718,64 @@
2534
2718
  },
2535
2719
  "tagApi": "On Tag, subtle is a boolean prop, not part of the variant union: accent-subtle is spelled variant=\"accent\" subtle (Tag variants: neutral | accent | success | warning | danger)."
2536
2720
  },
2721
+ "geometry": {
2722
+ "sizes": [
2723
+ 24,
2724
+ 32,
2725
+ 40,
2726
+ 48
2727
+ ],
2728
+ "label": {
2729
+ "paddingInline": [
2730
+ 8,
2731
+ 12,
2732
+ 16,
2733
+ 20
2734
+ ],
2735
+ "paddingInlineIcon": [
2736
+ 6,
2737
+ 8,
2738
+ 12,
2739
+ 16
2740
+ ],
2741
+ "gap": [
2742
+ 4,
2743
+ 8,
2744
+ 8,
2745
+ 8
2746
+ ],
2747
+ "font": [
2748
+ "12-16-medium",
2749
+ "14-20-medium",
2750
+ "16-24-medium",
2751
+ "18-28-medium"
2752
+ ]
2753
+ },
2754
+ "value": {
2755
+ "paddingInline": [
2756
+ 8,
2757
+ 8,
2758
+ 12,
2759
+ 16
2760
+ ],
2761
+ "font": [
2762
+ "12-16-regular",
2763
+ "14-20-regular",
2764
+ "16-24-regular",
2765
+ "18-28-regular"
2766
+ ]
2767
+ },
2768
+ "components": {
2769
+ "Button": "label",
2770
+ "IconButton": "height-only",
2771
+ "Input": "value",
2772
+ "Select": "value"
2773
+ },
2774
+ "note": "Per-size geometry is data, not CSS literals (D54). Arrays are indexed by sizes[]. Read a value as --psi-control-{size}-{prop} for the label ramp and --psi-control-value-{size}-{prop} for the value ramp; components alias these as --psi-{component}-{size}-{prop}, which is the layer to override. The value ramp is one step tighter than the label ramp because a left-aligned value wants less air than a centred label (D55).",
2775
+ "iconInset": "A leading icon sits one step closer to the edge than text — an icon is a solid shape with no side bearing (D55). Applied by .size{n}:has(> svg:first-child) on Button. IconButton is height-only: it is square with padding 0, so no padding/gap/font token applies to it.",
2776
+ "iconInsetLimits": "The selector matches on element order, and :first-child ignores text nodes. Two consequences. (1) An icon wrapped in a <span> never matches — fails safe, no inset; set --psi-button-{size}-padding-inline-icon yourself. (2) A TRAILING icon (<Button>Next<IconChevronRight /></Button>) makes the svg the first ELEMENT child, so the rule fires and narrows the start (text) side while the icon keeps full padding — the inverse of the intent. CSS cannot tell the two apart without a DOM signal. Put the icon first, or override --psi-button-{size}-padding-inline-icon to match --psi-button-{size}-padding-inline to disable the inset.",
2777
+ "radius": "Control radius is size-invariant (D56) — it does NOT ride the size ramp, because border-radius sits on each component's base rule and all four sizes resolve to radius-8. One dial, --psi-control-radius, is the layer to override, aliased per component: --psi-button-radius (Button, and IconButton via the same token), --psi-input-radius, --psi-select-radius, --psi-checkbox-box-radius, and --psi-tooltip-radius. Checkbox and Tooltip cap themselves with min(var(--psi-control-radius), var(--psi-radius-4)) and min(…, var(--psi-radius-6)) respectively so neither over-rounds a small object — but the published radius scale's floor is radius-4, so Checkbox's cap is a no-op for every on-scale value a theme can set; it only bites at an off-scale value like 0px. Tooltip's higher ceiling does track a sharper theme, down to radius-4. Tag and Switch stay --psi-radius-full: pill-ness is component identity, not theme expression. A theme retunes every control's shape by setting --psi-control-radius once. Psi now has two radius dials, not one: --psi-control-radius (here, for interactive controls) and --psi-surface-radius (for elevated surfaces — Dialog, Panel, Menu). --psi-card-radius sits on neither dial, so a theme that retunes controls or surfaces leaves Card at its own default of radius-8."
2778
+ },
2537
2779
  "getting-started": {
2538
2780
  "install": "npm install @handamade/psi-tokens @handamade/psi-react",
2539
2781
  "cssImports": [
package/dist/store.js CHANGED
@@ -1,4 +1,26 @@
1
1
  const MAX_RESULTS = 20;
2
+ /** Documented search-response budget: "serialized result <= 6000 characters
3
+ * (~1.5k tokens)" (docs/superpowers/plans/2026-07-18-agent-access-plan.md),
4
+ * asserted by __tests__/store.test.ts's "stays within the response budget". */
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:
8
+ *
9
+ * topics — a fixed, slow-growing set of terse briefs (~90 chars each).
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.
13
+ * components — verbose (up to 220 chars each) and the only axis that grows
14
+ * with every release. Also the axis a keyword query recovers
15
+ * best: search("menu") finds Menu; no query re-derives a house
16
+ * rule an agent doesn't know exists.
17
+ *
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"];
2
24
  const TOPIC_SUMMARIES = {
3
25
  variants: "Variant intent and typical use for all 8 flat variants",
4
26
  rules: "House rules: accent budget, sizing, color binding",
@@ -79,8 +101,20 @@ export function createStore(index) {
79
101
  function search(query) {
80
102
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
81
103
  if (terms.length === 0) {
82
- // Overview: all components, patterns, and topics (tokens are discoverable via query).
83
- return briefs.filter((b) => b.kind !== "token").slice(0, MAX_RESULTS + 10);
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);
112
+ if (JSON.stringify(out).length > RESPONSE_BUDGET) {
113
+ out.pop();
114
+ break;
115
+ }
116
+ }
117
+ return out;
84
118
  }
85
119
  return briefs
86
120
  .map((b) => ({ b, s: score(b, terms) }))
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: 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).
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).
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@handamade/psi-mcp",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
+ "license": "MIT",
4
5
  "description": "MCP server and agent-context generator for the Psi (Ψ) design system",
5
6
  "type": "module",
6
7
  "bin": {
@@ -32,8 +33,8 @@
32
33
  "tsx": "^4.22.4",
33
34
  "typescript": "^5.7.0",
34
35
  "vitest": "^3.2.6",
35
- "@handamade/psi-tokens": "0.7.1",
36
- "@handamade/psi-react": "0.7.1"
36
+ "@handamade/psi-react": "0.8.0",
37
+ "@handamade/psi-tokens": "0.8.0"
37
38
  },
38
39
  "scripts": {
39
40
  "build": "tsx scripts/build-index.ts && tsc -p tsconfig.build.json"