@handamade/psi-mcp 0.9.0 → 0.10.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, 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.
3
+ MCP server and agent-context generator for the Psi (Ψ) design system. Documents the system — the 25 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.9.0",
2
+ "version": "0.10.0",
3
3
  "themes": [
4
4
  "light",
5
5
  "dark",
@@ -481,6 +481,13 @@
481
481
  "default": null,
482
482
  "description": "Label text."
483
483
  },
484
+ {
485
+ "name": "aria-label",
486
+ "type": "string",
487
+ "required": false,
488
+ "default": null,
489
+ "description": "Accessible name when there is no visible label — a table's row-selection\ncheckbox, say. Optional, unlike IconButton's required form (D60): a\nlabelled checkbox is still the common case."
490
+ },
484
491
  {
485
492
  "name": "ref",
486
493
  "type": "Ref<HTMLInputElement>",
@@ -497,7 +504,7 @@
497
504
  }
498
505
  ],
499
506
  "slots": [],
500
- "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Checkbox\n\nNative checkbox with a built-in label; for independent binary choices.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `children` | `ReactNode` | — | no | Label text. |\n| `ref` | `Ref<HTMLInputElement>` | — | no | Forwarded ref to the underlying `<input type=\"checkbox\">` 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| Space | Toggles. Native <input type=checkbox> underneath (visually hidden). |\n\n\n## Theming\n\nOverride `--psi-checkbox-*` custom properties at any scope.\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"
507
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Checkbox\n\nNative checkbox with a built-in label; for independent binary choices.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `children` | `ReactNode` | — | no | Label text. |\n| `aria-label` | `string` | — | no | Accessible name when there is no visible label — a table's row-selection checkbox, say. Optional, unlike IconButton's required form (D60): a labelled checkbox is still the common case. |\n| `ref` | `Ref<HTMLInputElement>` | — | no | Forwarded ref to the underlying `<input type=\"checkbox\">` 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| Space | Toggles. Native <input type=checkbox> underneath (visually hidden). |\n\n\n## Theming\n\nOverride `--psi-checkbox-*` custom properties at any scope.\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"
501
508
  },
502
509
  {
503
510
  "name": "Switch",
@@ -829,6 +836,256 @@
829
836
  "props": [],
830
837
  "slots": [],
831
838
  "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"
839
+ },
840
+ {
841
+ "name": "Table",
842
+ "description": "Data table on native table semantics. Holds no state: sorting, selection and pagination are the consumer's (D62, extending D50/D53).",
843
+ "props": [
844
+ {
845
+ "name": "size",
846
+ "type": "32 | 40 | 48",
847
+ "required": false,
848
+ "default": 40,
849
+ "description": "Row height in px."
850
+ },
851
+ {
852
+ "name": "stickyHeader",
853
+ "type": "boolean",
854
+ "required": false,
855
+ "default": null,
856
+ "description": "Pins the header while the body scrolls."
857
+ },
858
+ {
859
+ "name": "sortable",
860
+ "type": "boolean",
861
+ "required": false,
862
+ "default": false,
863
+ "description": "Enables the sort affordance on header cells that declare a `sortKey`."
864
+ },
865
+ {
866
+ "name": "sort",
867
+ "type": "TableSortState | null",
868
+ "required": false,
869
+ "default": null,
870
+ "description": "Controlled sort state; `null` when nothing is sorted."
871
+ },
872
+ {
873
+ "name": "onSortChange",
874
+ "type": "((sort: TableSortState) => void)",
875
+ "required": false,
876
+ "default": null,
877
+ "description": "Called when a header's sort control is activated. Optional in the type\nbecause `sortable` may be false; a discriminated union expressing the real\ncontract does not survive docgen's flat prop extraction, which would strip\nthese props from the manifest entirely (D62)."
878
+ },
879
+ {
880
+ "name": "selectable",
881
+ "type": "boolean",
882
+ "required": false,
883
+ "default": false,
884
+ "description": "Renders the row-selection checkbox column."
885
+ },
886
+ {
887
+ "name": "selected",
888
+ "type": "ReadonlySet<string>",
889
+ "required": false,
890
+ "default": "new Set<string>()",
891
+ "description": "Controlled selection, keyed by each `TableRow`'s `rowId`."
892
+ },
893
+ {
894
+ "name": "onSelectionChange",
895
+ "type": "((selected: ReadonlySet<string>) => void)",
896
+ "required": false,
897
+ "default": null,
898
+ "description": "Called with the next selection. See `onSortChange` on why it is optional."
899
+ },
900
+ {
901
+ "name": "className",
902
+ "type": "string",
903
+ "required": false,
904
+ "default": null,
905
+ "description": "Additional CSS class name(s) merged onto the component's root element."
906
+ },
907
+ {
908
+ "name": "ref",
909
+ "type": "Ref<HTMLTableElement>",
910
+ "required": false,
911
+ "default": null,
912
+ "description": "Forwarded ref to the underlying `<table>` element."
913
+ }
914
+ ],
915
+ "slots": [
916
+ {
917
+ "name": "body",
918
+ "accepts": {
919
+ "components": [
920
+ "TableHead",
921
+ "TableBody"
922
+ ]
923
+ },
924
+ "cardinality": "1..*",
925
+ "order": 1
926
+ }
927
+ ],
928
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Table\n\nData table on native table semantics. Holds no state: sorting, selection and pagination are the consumer's (D62, extending D50/D53).\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `size` | `32 \\| 40 \\| 48` | 40 | no | Row height in px. |\n| `stickyHeader` | `boolean` | — | no | Pins the header while the body scrolls. |\n| `sortable` | `boolean` | false | no | Enables the sort affordance on header cells that declare a `sortKey`. |\n| `sort` | `TableSortState \\| null` | — | no | Controlled sort state; `null` when nothing is sorted. |\n| `onSortChange` | `((sort: TableSortState) => void)` | — | no | Called when a header's sort control is activated. Optional in the type because `sortable` may be false; a discriminated union expressing the real contract does not survive docgen's flat prop extraction, which would strip these props from the manifest entirely (D62). |\n| `selectable` | `boolean` | false | no | Renders the row-selection checkbox column. |\n| `selected` | `ReadonlySet<string>` | new Set<string>() | no | Controlled selection, keyed by each `TableRow`'s `rowId`. |\n| `onSelectionChange` | `((selected: ReadonlySet<string>) => void)` | — | no | Called with the next selection. See `onSortChange` on why it is optional. |\n| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |\n| `ref` | `Ref<HTMLTableElement>` | — | no | Forwarded ref to the underlying `<table>` element. |\n\n## Theming\n\nOverride `--psi-table-*` 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"
929
+ },
930
+ {
931
+ "name": "TableHead",
932
+ "description": "`<thead>`. When the table is selectable, prepends the select-all cell to the FIRST header row so the header's column count matches the body's — the checkbox column is Table's, not the consumer's schema. Clones the row rather than rebuilding it, so the consumer's own props on that `TableRow` survive.",
933
+ "props": [
934
+ {
935
+ "name": "className",
936
+ "type": "string",
937
+ "required": false,
938
+ "default": null,
939
+ "description": "Additional CSS class name(s) merged onto the component's root element."
940
+ }
941
+ ],
942
+ "slots": [],
943
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# TableHead\n\n`<thead>`. When the table is selectable, prepends the select-all cell to the FIRST header row so the header's column count matches the body's — the checkbox column is Table's, not the consumer's schema. Clones the row rather than rebuilding it, so the consumer's own props on that `TableRow` survive.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\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-table-head-*` 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"
944
+ },
945
+ {
946
+ "name": "TableBody",
947
+ "description": "`<tbody>` wrapper.",
948
+ "props": [
949
+ {
950
+ "name": "className",
951
+ "type": "string",
952
+ "required": false,
953
+ "default": null,
954
+ "description": "Additional CSS class name(s) merged onto the component's root element."
955
+ }
956
+ ],
957
+ "slots": [],
958
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# TableBody\n\n`<tbody>` wrapper.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\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-table-body-*` 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"
959
+ },
960
+ {
961
+ "name": "TableRow",
962
+ "description": "`<tr>`. `rowId` is the key the `selected` set holds.",
963
+ "props": [
964
+ {
965
+ "name": "rowId",
966
+ "type": "string",
967
+ "required": false,
968
+ "default": null,
969
+ "description": "Stable identity for selection. Required for selectable tables (D62)."
970
+ },
971
+ {
972
+ "name": "selectLabel",
973
+ "type": "string",
974
+ "required": false,
975
+ "default": null,
976
+ "description": "Accessible name for this row's selection checkbox."
977
+ },
978
+ {
979
+ "name": "className",
980
+ "type": "string",
981
+ "required": false,
982
+ "default": null,
983
+ "description": "Additional CSS class name(s) merged onto the component's root element."
984
+ }
985
+ ],
986
+ "slots": [],
987
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# TableRow\n\n`<tr>`. `rowId` is the key the `selected` set holds.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `rowId` | `string` | — | no | Stable identity for selection. Required for selectable tables (D62). |\n| `selectLabel` | `string` | — | no | Accessible name for this row's selection checkbox. |\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-table-row-*` 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"
988
+ },
989
+ {
990
+ "name": "TableHeaderCell",
991
+ "description": "`<th scope=\"col\">`. `aria-sort` belongs on the th, never on the inner button — assistive tech reads the sort state from the column header.",
992
+ "props": [
993
+ {
994
+ "name": "sortKey",
995
+ "type": "string",
996
+ "required": false,
997
+ "default": null,
998
+ "description": "Sort key this column emits. Enables the sort control when the table is `sortable`."
999
+ },
1000
+ {
1001
+ "name": "numeric",
1002
+ "type": "boolean",
1003
+ "required": false,
1004
+ "default": null,
1005
+ "description": "Right-aligns and renders tabular figures (D62)."
1006
+ },
1007
+ {
1008
+ "name": "className",
1009
+ "type": "string",
1010
+ "required": false,
1011
+ "default": null,
1012
+ "description": "Additional CSS class name(s) merged onto the component's root element."
1013
+ }
1014
+ ],
1015
+ "slots": [],
1016
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# TableHeaderCell\n\n`<th scope=\"col\">`. `aria-sort` belongs on the th, never on the inner button — assistive tech reads the sort state from the column header.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `sortKey` | `string` | — | no | Sort key this column emits. Enables the sort control when the table is `sortable`. |\n| `numeric` | `boolean` | — | no | Right-aligns and renders tabular figures (D62). |\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-table-header-cell-*` 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"
1017
+ },
1018
+ {
1019
+ "name": "TableCell",
1020
+ "description": "`<td>`. `numeric` means right-aligned *and* tabular — a column that aligns but whose digits jitter between rows defeats the purpose.",
1021
+ "props": [
1022
+ {
1023
+ "name": "numeric",
1024
+ "type": "boolean",
1025
+ "required": false,
1026
+ "default": null,
1027
+ "description": "Right-aligns and renders tabular figures (D62)."
1028
+ },
1029
+ {
1030
+ "name": "className",
1031
+ "type": "string",
1032
+ "required": false,
1033
+ "default": null,
1034
+ "description": "Additional CSS class name(s) merged onto the component's root element."
1035
+ }
1036
+ ],
1037
+ "slots": [],
1038
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# TableCell\n\n`<td>`. `numeric` means right-aligned *and* tabular — a column that aligns but whose digits jitter between rows defeats the purpose.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `numeric` | `boolean` | — | no | Right-aligns and renders tabular figures (D62). |\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-table-cell-*` 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"
1039
+ },
1040
+ {
1041
+ "name": "Pagination",
1042
+ "description": "Numbered pager with ellipsis truncation (D63). Standalone rather than a Table family member: `table-pagination` composes it as a Toolbar sibling of the page-size Select.",
1043
+ "props": [
1044
+ {
1045
+ "name": "page",
1046
+ "type": "number",
1047
+ "required": true,
1048
+ "default": null,
1049
+ "description": "Current page, 1-based."
1050
+ },
1051
+ {
1052
+ "name": "pageCount",
1053
+ "type": "number",
1054
+ "required": true,
1055
+ "default": null,
1056
+ "description": "Total number of pages."
1057
+ },
1058
+ {
1059
+ "name": "onPageChange",
1060
+ "type": "(page: number) => void",
1061
+ "required": true,
1062
+ "default": null,
1063
+ "description": "Called with the requested page. Required — unlike Table's `onSortChange`/\n`onSelectionChange`, which are optional because `sortable`/`selectable`\ngate whether they're meaningful, `Pagination` has no such gating boolean:\nthe prop is unconditionally meaningful, so a `Pagination` without it is a\ndead control (final review finding, D62)."
1064
+ },
1065
+ {
1066
+ "name": "siblingCount",
1067
+ "type": "number",
1068
+ "required": false,
1069
+ "default": 1,
1070
+ "description": "Pages shown either side of the current one before truncating."
1071
+ },
1072
+ {
1073
+ "name": "aria-label",
1074
+ "type": "string",
1075
+ "required": false,
1076
+ "default": "Pagination",
1077
+ "description": "Accessible name for the nav landmark."
1078
+ },
1079
+ {
1080
+ "name": "className",
1081
+ "type": "string",
1082
+ "required": false,
1083
+ "default": null,
1084
+ "description": "Additional CSS class name(s) merged onto the component's root element."
1085
+ }
1086
+ ],
1087
+ "slots": [],
1088
+ "doc": "<!-- Generated by emit-docs.ts — do not edit. -->\n# Pagination\n\nNumbered pager with ellipsis truncation (D63). Standalone rather than a Table family member: `table-pagination` composes it as a Toolbar sibling of the page-size Select.\n\n## Props\n\n| Prop | Type | Default | Required | Description |\n|---|---|---|---|---|\n| `page` | `number` | — | yes | Current page, 1-based. |\n| `pageCount` | `number` | — | yes | Total number of pages. |\n| `onPageChange` | `(page: number) => void` | — | yes | Called with the requested page. Required — unlike Table's `onSortChange`/ `onSelectionChange`, which are optional because `sortable`/`selectable` gate whether they're meaningful, `Pagination` has no such gating boolean: the prop is unconditionally meaningful, so a `Pagination` without it is a dead control (final review finding, D62). |\n| `siblingCount` | `number` | 1 | no | Pages shown either side of the current one before truncating. |\n| `aria-label` | `string` | Pagination | no | Accessible name for the nav landmark. |\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-pagination-*` 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"
832
1089
  }
833
1090
  ],
834
1091
  "tokens": [
@@ -2458,24 +2715,90 @@
2458
2715
  "sortable": true
2459
2716
  },
2460
2717
  "slots": {
2461
- "head": [
2462
- "{content:columns-note}"
2463
- ],
2464
2718
  "body": [
2465
- "{content:rows-note}"
2719
+ {
2720
+ "component": "TableHead",
2721
+ "slots": {
2722
+ "body": [
2723
+ {
2724
+ "component": "TableRow",
2725
+ "slots": {
2726
+ "body": [
2727
+ {
2728
+ "component": "TableHeaderCell",
2729
+ "props": {
2730
+ "sortKey": "date"
2731
+ },
2732
+ "content": "date-label"
2733
+ },
2734
+ {
2735
+ "component": "TableHeaderCell",
2736
+ "props": {
2737
+ "sortKey": "payee"
2738
+ },
2739
+ "content": "payee-label"
2740
+ },
2741
+ {
2742
+ "component": "TableHeaderCell",
2743
+ "props": {
2744
+ "sortKey": "amount",
2745
+ "numeric": true
2746
+ },
2747
+ "content": "amount-label"
2748
+ }
2749
+ ]
2750
+ }
2751
+ }
2752
+ ]
2753
+ }
2754
+ },
2755
+ {
2756
+ "component": "TableBody",
2757
+ "slots": {
2758
+ "body": [
2759
+ {
2760
+ "component": "TableRow",
2761
+ "props": {
2762
+ "rowId": "row-id"
2763
+ },
2764
+ "slots": {
2765
+ "body": [
2766
+ {
2767
+ "component": "TableCell",
2768
+ "content": "date-cell"
2769
+ },
2770
+ {
2771
+ "component": "TableCell",
2772
+ "content": "payee-cell"
2773
+ },
2774
+ {
2775
+ "component": "TableCell",
2776
+ "props": {
2777
+ "numeric": true
2778
+ },
2779
+ "content": "amount-cell"
2780
+ }
2781
+ ]
2782
+ }
2783
+ }
2784
+ ]
2785
+ }
2786
+ }
2466
2787
  ]
2467
2788
  }
2468
2789
  },
2469
2790
  "parameters": [],
2470
2791
  "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]"
2792
+ "date-label": "Date",
2793
+ "payee-label": "Payee",
2794
+ "amount-label": "Amount",
2795
+ "date-cell": "[one row per record]",
2796
+ "payee-cell": "[put the row-actions menu in the last cell]",
2797
+ "amount-cell": "[numeric columns align right and use tabular figures]"
2473
2798
  },
2474
- "gaps": [
2475
- "Table"
2476
- ],
2477
- "blocked": true,
2478
- "preset": null
2799
+ "gaps": [],
2800
+ "blocked": false,
2801
+ "preset": "<Table\n selectable={true}\n sortable={true}\n>\n <TableHead>\n <TableRow>\n <TableHeaderCell sortKey=\"date\">Date</TableHeaderCell>\n <TableHeaderCell sortKey=\"payee\">Payee</TableHeaderCell>\n <TableHeaderCell numeric={true} sortKey=\"amount\">Amount</TableHeaderCell>\n </TableRow>\n </TableHead>\n <TableBody>\n <TableRow\n rowId=\"row-id\"\n >\n <TableCell>[one row per record]</TableCell>\n <TableCell>[put the row-actions menu in the last cell]</TableCell>\n <TableCell numeric={true}>[numeric columns align right and use tabular figures]</TableCell>\n </TableRow>\n </TableBody>\n</Table>\n"
2479
2802
  },
2480
2803
  {
2481
2804
  "id": "date-range-filter",
@@ -2976,11 +3299,9 @@
2976
3299
  "content": {
2977
3300
  "page-size-label": "Rows per page"
2978
3301
  },
2979
- "gaps": [
2980
- "Pagination"
2981
- ],
2982
- "blocked": true,
2983
- "preset": null
3302
+ "gaps": [],
3303
+ "blocked": false,
3304
+ "preset": "<Toolbar\n gap={12}\n>\n <Field\n label=\"Rows per page\"\n >\n <Select size={32} />\n </Field>\n <Pagination />\n</Toolbar>\n"
2984
3305
  }
2985
3306
  ],
2986
3307
  "scales": {
@@ -3040,6 +3361,7 @@
3040
3361
  "gutterNarrow": 24
3041
3362
  },
3042
3363
  "zIndex": {
3364
+ "sticky": 1,
3043
3365
  "nav": 100,
3044
3366
  "overlay": 1000,
3045
3367
  "tooltip": 1100
@@ -3171,6 +3493,7 @@
3171
3493
  },
3172
3494
  "recipes": {
3173
3495
  "mediaTint": "Apply .psi-media-tint to media elements; the brand defines --psi-media-tint (D35). Hover/focus reveals true color over --psi-duration-450 --psi-ease-soft.",
3496
+ "tabularNumerals": "Apply .psi-tabular to numeric content for fixed-width digits; --psi-font-variant-numeric is tabular-nums (D62). Table's numeric columns bind the token directly rather than via the utility class.",
3174
3497
  "sectionHeader": "SectionHeader ships as a recipe, not a component (v1.2 non-goal): baseline-aligned flex row — mono annotation (--psi-text-mono-14-20-regular, --psi-fg-accent) + h2 (.psi-display-32-32-extrabold) + optional trailing meta, border-bottom 1px var(--psi-border-faint), padding-bottom var(--psi-space-20)."
3175
3498
  },
3176
3499
  "tags": {
@@ -3320,6 +3643,7 @@
3320
3643
  "gutterNarrow": 24
3321
3644
  },
3322
3645
  "zIndex": {
3646
+ "sticky": 1,
3323
3647
  "nav": 100,
3324
3648
  "overlay": 1000,
3325
3649
  "tooltip": 1100
package/dist/store.js CHANGED
@@ -3,28 +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 — 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.
11
+ * (the five required CSS imports) above all. Takes its full
12
+ * cost, unconditionally.
17
13
  * components — verbose (up to 220 chars each) and the only axis that grows
18
14
  * with every release. Also the axis a keyword query recovers
19
15
  * best: search("menu") finds Menu; no query re-derives a house
20
- * 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.
21
25
  *
22
- * Capping by item *count* (the previous scheme) evicted the tail, and since
23
- * components were emitted first the tail was always topics — so registering
24
- * D53's Menu components silently dropped eight topics including
25
- * `getting-started`. Filling by serialized *length* in this order instead
26
- * degrades only the component tail, which is the cheap thing to lose. */
27
- 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;
28
32
  const TOPIC_SUMMARIES = {
29
33
  variants: "Variant intent and typical use for all 8 flat variants",
30
34
  rules: "House rules: accent budget, sizing, color binding",
@@ -61,12 +65,11 @@ function patternSummary(p) {
61
65
  summary += `, blocked (gaps: ${p.gaps.join(", ")})`;
62
66
  return summary;
63
67
  }
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.
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).
70
73
  *
71
74
  * The `, blocked (gaps: …)` tail is never subject to this cap: it is the
72
75
  * backlog signal this cycle exists to publish, and patternSummary() puts it
@@ -75,7 +78,7 @@ function patternSummary(p) {
75
78
  * 120 chars), which is exactly the bug this trims to avoid. So the overview
76
79
  * projects a pattern's summary from the underlying PatternEntry directly:
77
80
  * trim the intro, then always append the tail. */
78
- const OVERVIEW_PATTERN_INTRO_LIMIT = 100;
81
+ const OVERVIEW_PATTERN_INTRO_MAX = 120;
79
82
  /** Trims `s` to at most `limit` chars, backing off to the last word boundary
80
83
  * and marking the cut with an ellipsis — never a mid-word fragment that
81
84
  * could read as a complete (if coincidental) match phrase. */
@@ -86,8 +89,8 @@ function truncateAtWord(s, limit) {
86
89
  const lastSpace = cut.lastIndexOf(" ");
87
90
  return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
88
91
  }
89
- function overviewPatternSummary(p) {
90
- const intro = truncateAtWord(`${p.intent} — ${p.match.join(", ")}, ${p.parameters.length} parameters`, OVERVIEW_PATTERN_INTRO_LIMIT);
92
+ function overviewPatternSummary(p, limit) {
93
+ const intro = truncateAtWord(`${p.intent} — ${p.match.join(", ")}, ${p.parameters.length} parameters`, limit);
91
94
  return p.blocked ? `${intro}, blocked (gaps: ${p.gaps.join(", ")})` : intro;
92
95
  }
93
96
  export function createStore(index) {
@@ -98,14 +101,16 @@ export function createStore(index) {
98
101
  * already-assembled, full-length `Brief.summary` — so the `blocked (gaps:
99
102
  * …)` tail always survives the overview's trim. Always a shallow copy for
100
103
  * patterns: the stored brief kept in `briefs` (and used by score()) is
101
- * never mutated. */
102
- function overviewBrief(b) {
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) {
103
108
  if (b.kind !== "pattern")
104
109
  return b;
105
110
  const pattern = patternsById.get(b.title);
106
111
  if (!pattern)
107
112
  return b;
108
- return { ...b, summary: overviewPatternSummary(pattern) };
113
+ return { ...b, summary: overviewPatternSummary(pattern, limit) };
109
114
  }
110
115
  const briefs = [
111
116
  ...index.components.map((c) => ({
@@ -150,14 +155,25 @@ export function createStore(index) {
150
155
  function search(query) {
151
156
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
152
157
  if (terms.length === 0) {
153
- // Overview: topics, then patterns, then as many components as the
154
- // response budget still affords (tokens are discoverable via query).
155
- // Filled by serialized length, not item count, so the thing that gets
156
- // dropped is always the component tail — never the guidance surface.
157
- const candidates = OVERVIEW_KIND_ORDER.flatMap((kind) => briefs.filter((b) => b.kind === kind));
158
- const out = [];
159
- for (const brief of candidates) {
160
- out.push(overviewBrief(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);
161
177
  if (JSON.stringify(out).length > RESPONSE_BUDGET) {
162
178
  out.pop();
163
179
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handamade/psi-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.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.9.0",
37
- "@handamade/psi-react": "0.9.0"
36
+ "@handamade/psi-tokens": "0.10.0",
37
+ "@handamade/psi-react": "0.10.0"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsx scripts/build-index.ts && tsc -p tsconfig.build.json"