@pithyjs/pidl-catalog 0.1.0-beta.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/dist/specs/atoms/actions/button.pidl.json +289 -0
  3. package/dist/specs/atoms/actions/icon-group.pidl.json +141 -0
  4. package/dist/specs/atoms/actions/segmented-control.pidl.json +137 -0
  5. package/dist/specs/atoms/content/action-tile.pidl.json +135 -0
  6. package/dist/specs/atoms/content/badge.pidl.json +189 -0
  7. package/dist/specs/atoms/content/callout.pidl.json +170 -0
  8. package/dist/specs/atoms/content/code-block.pidl.json +170 -0
  9. package/dist/specs/atoms/content/heading.pidl.json +175 -0
  10. package/dist/specs/atoms/content/icon.pidl.json +127 -0
  11. package/dist/specs/atoms/content/image.pidl.json +227 -0
  12. package/dist/specs/atoms/content/prose.pidl.json +183 -0
  13. package/dist/specs/atoms/content/stat.pidl.json +158 -0
  14. package/dist/specs/atoms/content/tabs.pidl.json +207 -0
  15. package/dist/specs/atoms/content/text.pidl.json +212 -0
  16. package/dist/specs/atoms/content/tile.pidl.json +156 -0
  17. package/dist/specs/atoms/data/donut.pidl.json +352 -0
  18. package/dist/specs/atoms/data/gauge.pidl.json +274 -0
  19. package/dist/specs/atoms/forms/checkbox-group.pidl.json +170 -0
  20. package/dist/specs/atoms/forms/checkbox.pidl.json +323 -0
  21. package/dist/specs/atoms/forms/file-picker.pidl.json +161 -0
  22. package/dist/specs/atoms/forms/form-field.pidl.json +106 -0
  23. package/dist/specs/atoms/forms/input.pidl.json +466 -0
  24. package/dist/specs/atoms/forms/radio-group.pidl.json +174 -0
  25. package/dist/specs/atoms/forms/radio.pidl.json +312 -0
  26. package/dist/specs/atoms/forms/search-input.pidl.json +265 -0
  27. package/dist/specs/atoms/forms/select.pidl.json +420 -0
  28. package/dist/specs/atoms/forms/textarea.pidl.json +422 -0
  29. package/dist/specs/atoms/forms/toggle.pidl.json +314 -0
  30. package/dist/specs/layout/grid-item.pidl.json +120 -0
  31. package/dist/specs/layout/grid.pidl.json +157 -0
  32. package/dist/specs/layout/page-section.pidl.json +105 -0
  33. package/dist/specs/layout/smart-container.pidl.json +148 -0
  34. package/dist/specs/layout/stack.pidl.json +144 -0
  35. package/dist/specs/organisms/content/accordion.pidl.json +196 -0
  36. package/dist/specs/organisms/content/card.pidl.json +283 -0
  37. package/dist/specs/organisms/feedback/alert-dialog.pidl.json +158 -0
  38. package/dist/specs/organisms/feedback/modal.pidl.json +133 -0
  39. package/dist/specs/organisms/feedback/toast.pidl.json +150 -0
  40. package/dist/specs/organisms/forms/dynamic-form.pidl.json +314 -0
  41. package/dist/specs/organisms/forms/form.pidl.json +135 -0
  42. package/dist/specs/organisms/forms/subscribe-form.pidl.json +188 -0
  43. package/dist/specs/organisms/navigation/command-palette.pidl.json +263 -0
  44. package/dist/specs/organisms/navigation/docs-sidebar.pidl.json +264 -0
  45. package/dist/specs/organisms/navigation/navbar.pidl.json +356 -0
  46. package/dist/specs/organisms/navigation/table-of-contents.pidl.json +197 -0
  47. package/dist/specs/sections/contact-section.pidl.json +183 -0
  48. package/dist/specs/sections/cta-section.pidl.json +168 -0
  49. package/dist/specs/sections/faq-section.pidl.json +202 -0
  50. package/dist/specs/sections/features-section.pidl.json +214 -0
  51. package/dist/specs/sections/footer-section.pidl.json +187 -0
  52. package/dist/specs/sections/hero-section.pidl.json +202 -0
  53. package/dist/src/borders-psychology.d.ts +17 -0
  54. package/dist/src/borders-psychology.d.ts.map +1 -0
  55. package/dist/src/borders-psychology.js +42 -0
  56. package/dist/src/borders-psychology.js.map +1 -0
  57. package/dist/src/color-psychology.d.ts +20 -0
  58. package/dist/src/color-psychology.d.ts.map +1 -0
  59. package/dist/src/color-psychology.js +57 -0
  60. package/dist/src/color-psychology.js.map +1 -0
  61. package/dist/src/design-intelligence.d.ts +13 -0
  62. package/dist/src/design-intelligence.d.ts.map +1 -0
  63. package/dist/src/design-intelligence.js +36 -0
  64. package/dist/src/design-intelligence.js.map +1 -0
  65. package/dist/src/index.d.ts +6238 -0
  66. package/dist/src/index.d.ts.map +1 -0
  67. package/dist/src/index.js +105 -0
  68. package/dist/src/index.js.map +1 -0
  69. package/dist/src/motion-psychology.d.ts +19 -0
  70. package/dist/src/motion-psychology.d.ts.map +1 -0
  71. package/dist/src/motion-psychology.js +47 -0
  72. package/dist/src/motion-psychology.js.map +1 -0
  73. package/dist/src/mutation-vocabulary.d.ts +13 -0
  74. package/dist/src/mutation-vocabulary.d.ts.map +1 -0
  75. package/dist/src/mutation-vocabulary.js +67 -0
  76. package/dist/src/mutation-vocabulary.js.map +1 -0
  77. package/dist/src/radius-psychology.d.ts +17 -0
  78. package/dist/src/radius-psychology.d.ts.map +1 -0
  79. package/dist/src/radius-psychology.js +42 -0
  80. package/dist/src/radius-psychology.js.map +1 -0
  81. package/dist/src/shadows-psychology.d.ts +17 -0
  82. package/dist/src/shadows-psychology.d.ts.map +1 -0
  83. package/dist/src/shadows-psychology.js +44 -0
  84. package/dist/src/shadows-psychology.js.map +1 -0
  85. package/dist/src/spacing-psychology.d.ts +22 -0
  86. package/dist/src/spacing-psychology.d.ts.map +1 -0
  87. package/dist/src/spacing-psychology.js +59 -0
  88. package/dist/src/spacing-psychology.js.map +1 -0
  89. package/dist/src/typography-psychology.d.ts +22 -0
  90. package/dist/src/typography-psychology.d.ts.map +1 -0
  91. package/dist/src/typography-psychology.js +68 -0
  92. package/dist/src/typography-psychology.js.map +1 -0
  93. package/package.json +64 -0
@@ -0,0 +1,188 @@
1
+ {
2
+ "kind": "component",
3
+ "id": "ui.subscribe-form",
4
+ "name": "SubscribeForm",
5
+ "displayName": "Subscribe Form",
6
+ "version": "0.1.0",
7
+ "level": "organism",
8
+ "domain": "forms",
9
+ "bidi": true,
10
+ "studioReady": false,
11
+ "description": "Backend-agnostic email-capture form for landing pages, waitlists, and newsletter signups. Composes an email Input and submit Button, manages an idle/submitting/success/error state machine, validates email format client-side, and includes a honeypot spam trap. Delivers submissions via a custom onSubmit handler or by POSTing JSON to an action URL — never hardcodes a specific email provider.",
12
+ "props": [
13
+ {
14
+ "name": "emailLabel",
15
+ "label": "Email label",
16
+ "editor": { "tab": "content" },
17
+ "type": "string",
18
+ "default": "Email",
19
+ "description": "Accessible label for the email field."
20
+ },
21
+ {
22
+ "name": "placeholder",
23
+ "label": "Placeholder",
24
+ "editor": { "tab": "content" },
25
+ "type": "string",
26
+ "default": "you@example.com",
27
+ "description": "Email input placeholder text."
28
+ },
29
+ {
30
+ "name": "buttonLabel",
31
+ "label": "Button label",
32
+ "editor": { "tab": "content" },
33
+ "type": "string",
34
+ "default": "Subscribe",
35
+ "description": "Submit button label — static string or signal accessor."
36
+ },
37
+ {
38
+ "name": "successMessage",
39
+ "label": "Success message",
40
+ "editor": { "tab": "content" },
41
+ "type": "string",
42
+ "default": "Thanks for subscribing!",
43
+ "description": "Message shown and announced after a successful submission."
44
+ },
45
+ {
46
+ "name": "errorMessage",
47
+ "label": "Error message",
48
+ "editor": { "tab": "content" },
49
+ "type": "string",
50
+ "default": "Something went wrong. Please try again.",
51
+ "description": "Message shown and announced when submission fails, and used as the email field's error text."
52
+ },
53
+ {
54
+ "name": "disclaimer",
55
+ "label": "Disclaimer",
56
+ "editor": { "tab": "content" },
57
+ "type": "string",
58
+ "description": "Optional small print rendered below the form (e.g. privacy note)."
59
+ },
60
+ {
61
+ "name": "color",
62
+ "label": "Color",
63
+ "editor": { "type": "token-color" },
64
+ "type": "enum",
65
+ "enum": [
66
+ "primary",
67
+ "secondary",
68
+ "tertiary",
69
+ "success",
70
+ "info",
71
+ "warning",
72
+ "danger"
73
+ ],
74
+ "default": "primary",
75
+ "description": "Semantic color forwarded to the submit Button."
76
+ },
77
+ {
78
+ "name": "size",
79
+ "type": "enum",
80
+ "enum": ["sm", "md", "lg"],
81
+ "default": "md",
82
+ "description": "Control size forwarded to the Input and Button."
83
+ },
84
+ {
85
+ "name": "id",
86
+ "label": "ID",
87
+ "editor": { "tab": "settings" },
88
+ "type": "string",
89
+ "description": "Explicit id for the root <form> element."
90
+ },
91
+ {
92
+ "name": "action",
93
+ "label": "Action URL",
94
+ "editor": { "tab": "settings" },
95
+ "type": "string",
96
+ "description": "Endpoint to POST { email } as JSON when submitted. Ignored when onSubmit is also provided."
97
+ },
98
+ {
99
+ "name": "onSubmit",
100
+ "type": "(email: string) => void | Promise<void>",
101
+ "required": false,
102
+ "description": "Custom submit handler, called with the validated email address. Takes precedence over action when both are set."
103
+ }
104
+ ],
105
+ "events": ["submit"],
106
+ "slots": [],
107
+ "tokens": {
108
+ "fields.gap": "--space-md",
109
+ "disclaimer.margin-top": "--space-sm"
110
+ },
111
+ "spacing": {
112
+ "internal": {
113
+ "input-to-button": { "relationship": "friends", "token": "md" },
114
+ "form-to-disclaimer": { "relationship": "close-friends", "token": "sm" }
115
+ }
116
+ },
117
+ "constraints": [
118
+ "Composes the Input and Button atoms — never renders a raw <input>/<button>",
119
+ "State machine is idle → submitting → success | error, exposed as a reactive `state` accessor on the returned handle",
120
+ "onSubmit takes precedence over action when both are provided",
121
+ "When neither action nor onSubmit is provided, submit is a no-op and a DEV warning is logged at construction time",
122
+ "Client-side email format validation runs before submission; invalid/empty email blocks submit and marks the Input invalid without entering the submitting state",
123
+ "Honeypot field (name=_honey) is checked before validation; a filled honeypot silently aborts submission with no error or state change",
124
+ "action mode POSTs JSON: fetch(action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) }); a non-2xx response is treated as an error",
125
+ "On success, the email/button fields are hidden and replaced with the successMessage; on error, the fields remain visible and enabled for retry",
126
+ "All CSS uses design system tokens"
127
+ ],
128
+ "a11y": [
129
+ "Native <form novalidate> element",
130
+ "Email field is named via aria-label (Input renders a visible <label> only in floatingLabel mode, which SubscribeForm does not use); aria-invalid and aria-describedby wire the error text. Sighted users rely on the placeholder plus the adjacent submit button for context — pass a visible heading/disclaimer alongside the form when more context is needed",
131
+ "Focus is managed across every terminal state: validation failure and submission failure return focus to the email field (native disabled blurs it during submit), success moves focus to the message that replaces the row",
132
+ "Exactly one announcement per outcome — the field error is announced by the Input's role=alert span and the success text by the focus move, so the sr-only status region is intentionally left empty rather than duplicating either string",
133
+ "Error state (validation or submission failure) is driven by passing a reactive error/errorMessage into the Input",
134
+ "An aria-live='polite' status region is present for outcome announcements but is deliberately kept empty — see the single-announcement rule above; it exists so a future summary (e.g. an error count) has somewhere to go without re-introducing duplicate announcements",
135
+ "Honeypot input is aria-hidden, tabindex=-1, autocomplete=off, and wrapped in a display:none container so it is invisible to sighted users and skipped by autofill, while still catching bots that scrape the DOM",
136
+ "Submit button shows a loading state (via Button) during async submit"
137
+ ],
138
+ "ai": {
139
+ "synonyms": [
140
+ "newsletter",
141
+ "waitlist",
142
+ "email signup",
143
+ "subscribe",
144
+ "mailing list"
145
+ ],
146
+ "avoid": [
147
+ "Using SubscribeForm for multi-field forms — use DynamicForm instead",
148
+ "Hardcoding a specific email provider — SubscribeForm is backend-agnostic via action/onSubmit",
149
+ "Putting custom validation logic in onSubmit — email format validation is built in"
150
+ ],
151
+ "patterns": [
152
+ "Waitlist: SubscribeForm({ buttonLabel: 'Join waitlist', successMessage: \"You're on the list!\" })",
153
+ "Newsletter with custom handler: SubscribeForm({ onSubmit: async (email) => api.subscribe(email) })",
154
+ "Zero-backend export: SubscribeForm({ action: 'https://api.example.com/subscribe' })",
155
+ "With disclaimer: SubscribeForm({ disclaimer: 'No spam, unsubscribe anytime.' })"
156
+ ]
157
+ },
158
+ "examples": [
159
+ {
160
+ "id": "basic",
161
+ "title": "Basic subscribe form with onSubmit",
162
+ "code": "SubscribeForm({ successMessage: \"You're on the list!\", onSubmit: async (email) => api.subscribe(email) })",
163
+ "assertions": [
164
+ "renders email Input and submit Button",
165
+ "invalid email blocks submit",
166
+ "onSubmit called with the validated email on submit",
167
+ "shows successMessage and hides fields after success"
168
+ ]
169
+ },
170
+ {
171
+ "id": "action",
172
+ "title": "Zero-backend export via action URL",
173
+ "code": "SubscribeForm({ action: 'https://api.example.com/subscribe', disclaimer: 'No spam, unsubscribe anytime.' })",
174
+ "assertions": [
175
+ "POSTs { email } as JSON to the action URL",
176
+ "non-2xx response transitions to error state",
177
+ "disclaimer rendered below the form"
178
+ ]
179
+ }
180
+ ],
181
+ "designGuidance": {
182
+ "surface": "No surface styling on the form itself — Input and Button provide all visual feedback.",
183
+ "depth": "None — transparent container.",
184
+ "borders": "None.",
185
+ "interactivity": "Submit button uses Button solid variant with loading state during async submit; both Input and Button are disabled while submitting.",
186
+ "bestPractices": "Use SubscribeForm for single-purpose email capture (newsletter, waitlist). Use DynamicForm when you need more than one field. Always provide either action or onSubmit — an unconfigured form is a dead end for visitors."
187
+ }
188
+ }
@@ -0,0 +1,263 @@
1
+ {
2
+ "kind": "component",
3
+ "id": "ui.command-palette",
4
+ "name": "CommandPalette",
5
+ "displayName": "Command palette",
6
+ "version": "0.1.0",
7
+ "level": "organism",
8
+ "domain": "navigation",
9
+ "studioReady": false,
10
+ "bidi": true,
11
+ "description": "⌘K command palette — a modal search field over a filtered command list. Composes <Modal> for the dialog (focus trap, Escape, backdrop) and <SearchInput> for the field, and adds the WAI-ARIA combobox-with-listbox-popup semantics on top: aria-expanded / aria-controls / aria-activedescendant on the field, a role=\"listbox\" of role=\"option\" items, real role=\"group\" sections for grouped commands, and a polite live region announcing the result count. SHELL ONLY — it owns no index. The default filter is a case-insensitive substring test over label + description + keywords, replaceable via the filter prop; the real search engine (Pagefind / Fuse over the codex index) is R3. studioReady is FALSE on purpose: the palette is driven by an open signal and an items array supplied in code, so there is nothing meaningful to author or preview on a canvas.",
12
+ "props": [
13
+ {
14
+ "name": "open",
15
+ "label": "Open",
16
+ "required": true,
17
+ "reactive": true,
18
+ "editor": { "tab": "settings", "type": "boolean" },
19
+ "type": "boolean | Signal<boolean>",
20
+ "description": "Whether the palette is showing. The palette never opens itself — pair this with onRequestOpen for the ⌘K binding."
21
+ },
22
+ {
23
+ "name": "items",
24
+ "label": "Commands",
25
+ "required": true,
26
+ "reactive": true,
27
+ "editor": { "tab": "content", "type": "array" },
28
+ "type": "CommandPaletteItem[] | Signal<CommandPaletteItem[]>",
29
+ "itemSchema": {
30
+ "id": { "type": "string", "required": true },
31
+ "label": { "type": "string", "required": true },
32
+ "description": { "type": "string" },
33
+ "group": { "type": "string" },
34
+ "icon": { "type": "string", "editor": { "type": "icon" } },
35
+ "href": { "type": "string" },
36
+ "keywords": { "type": "array" },
37
+ "disabled": { "type": "boolean" }
38
+ },
39
+ "placeholder": [
40
+ { "id": "home", "label": "Go home", "group": "Navigate", "href": "/" },
41
+ {
42
+ "id": "docs",
43
+ "label": "Open docs",
44
+ "group": "Navigate",
45
+ "href": "/docs",
46
+ "keywords": ["guide", "reference"]
47
+ },
48
+ {
49
+ "id": "theme",
50
+ "label": "Toggle theme",
51
+ "group": "Actions",
52
+ "description": "Switch between light and dark"
53
+ }
54
+ ],
55
+ "description": "The full command list, in the order it should appear. Grouping follows list order — a new role=\"group\" section is opened whenever `group` changes — so sort the array the way you want it read. Each item's `id` is surfaced on the rendered option as data-command-id (a stable hook for tests and analytics); it is NOT the option's id attribute, which must be document-unique for aria-activedescendant."
56
+ },
57
+ {
58
+ "name": "placeholder",
59
+ "label": "Placeholder",
60
+ "type": "string",
61
+ "default": "Type a command or search…",
62
+ "placeholder": "Type a command or search…",
63
+ "description": "Placeholder for the search field."
64
+ },
65
+ {
66
+ "name": "label",
67
+ "label": "Label",
68
+ "type": "string",
69
+ "default": "Command palette",
70
+ "placeholder": "Command palette",
71
+ "description": "Accessible name for the dialog. The search field and the results listbox get their OWN names ('Search commands' and 'Results') rather than reusing this one — three nested regions sharing a name makes a screen reader announce it three times before the reader reaches the first command."
72
+ },
73
+ {
74
+ "name": "emptyMessage",
75
+ "label": "Empty message",
76
+ "type": "string",
77
+ "default": "No results found",
78
+ "placeholder": "No results found",
79
+ "description": "Shown in place of the list when nothing matches — and announced through the live region, so the empty state is not silent."
80
+ },
81
+ {
82
+ "name": "hotkey",
83
+ "label": "Hotkey",
84
+ "editor": { "tab": "settings", "type": "string" },
85
+ "type": "string | false",
86
+ "default": "k",
87
+ "placeholder": "k",
88
+ "description": "Letter bound alongside the platform modifier (Meta or Control — both are accepted, so ⌘K and Ctrl+K each work). Modifiers must match exactly: Alt and Shift are excluded, so Cmd/Ctrl+Shift+K does not hijack the browser console shortcut. false binds nothing. The binding only exists when onRequestOpen is supplied."
89
+ },
90
+ {
91
+ "name": "maxResults",
92
+ "label": "Max results",
93
+ "editor": { "tab": "settings" },
94
+ "type": "number",
95
+ "default": 50,
96
+ "description": "Cap on rendered options. A palette is a shortlist — beyond a few dozen rows the reader should be typing, not scrolling, and every extra row is a DOM node rebuilt on each keystroke."
97
+ },
98
+ {
99
+ "name": "onClose",
100
+ "label": "On close",
101
+ "required": true,
102
+ "type": "() => void",
103
+ "description": "Called when the palette should close — Escape, a backdrop click, or a selection. The component does not own `open`; this is how it asks to be closed."
104
+ },
105
+ {
106
+ "name": "onSelect",
107
+ "label": "On select",
108
+ "type": "(item: CommandPaletteItem) => void",
109
+ "description": "Called for EVERY activation, after the item's own onSelect and before onClose. A pure observer: unlike an item's own onSelect it does NOT suppress href navigation, so adding analytics here cannot silently turn every link in the list into a no-op."
110
+ },
111
+ {
112
+ "name": "onRequestOpen",
113
+ "label": "On request open",
114
+ "type": "() => void",
115
+ "description": "Called when the hotkey fires. Supplying it is what enables the document-level binding — a palette with no way to open itself has no business owning a global shortcut."
116
+ },
117
+ {
118
+ "name": "filter",
119
+ "label": "Filter",
120
+ "type": "(items: CommandPaletteItem[], query: string) => CommandPaletteItem[]",
121
+ "description": "Replace the matcher. The default is a case-insensitive substring test over label, description and keywords; an empty query returns everything. This is the seam a real ranking engine drops into."
122
+ },
123
+ {
124
+ "name": "navigate",
125
+ "label": "Navigate",
126
+ "type": "(href: string) => void",
127
+ "description": "How an href-only item is followed. Defaults to a full page navigation; pass the router's navigate to keep it client-side."
128
+ }
129
+ ],
130
+ "tokens": {
131
+ "typography": "--font-size-body (option label), --font-size-sm (description, empty state), --font-size-xs + --font-weight-semibold (group heading), --line-height-body",
132
+ "color": "--color-text (options), --color-text-muted (group headings, descriptions, icons, empty state), --color-primary via --_option-accent (active row tint + icon), --color-divider (rule under the field)",
133
+ "spacing": "--space-xs (icon ↔ label, list padding, option block padding), --space-sm (option inline padding, group heading inline padding), --space-lg (empty-state block padding), --space-xl (dialog offset from the top of the viewport)",
134
+ "radius": "--radius-sm (option row); the dialog radius comes from modal.scss",
135
+ "border": "--border-width-hairline + --border-style-solid (the rule separating the query from its results)",
136
+ "state": "--state-selected-opacity via the a11y state-background mixin (active row), --state-disabled-opacity (disabled row)",
137
+ "motion": "--motion-transition-background / --motion-transition-color on the option row, inside prefers-reduced-motion: no-preference; the dialog entrance belongs to modal.scss"
138
+ },
139
+ "spacing": {
140
+ "icon-to-label": "--space-xs (Lovers) — the glyph and its command are one unit",
141
+ "option-padding": "--space-xs block / --space-sm inline — enough hit area without turning the list into a button stack",
142
+ "group-heading": "--space-xs block (Lovers) — a heading sits tight against the options it owns, not floating between two groups",
143
+ "dialog-offset": "--space-xl from the top of the viewport (Strangers) — the palette is anchored, not centred, so it grows downward without drifting under the cursor"
144
+ },
145
+ "constraints": [
146
+ "SHELL ONLY. No search backend, no fuzzy matching, no index. The default filter is a substring test; anything smarter arrives through the `filter` prop",
147
+ "The dialog, focus trap, Escape handling and backdrop come from <Modal>; the field comes from <SearchInput> with landmark=false. This component owns only the combobox semantics, the roving highlight and the filter state",
148
+ "The panel is rebuilt from scratch on every open (Modal re-invokes its content factory), which is what resets the query to '' and the active option to the first match — a stale query and a stale highlight are the two things that make a palette feel broken",
149
+ "Escape always closes; it never merely clears. <Modal> listens on window in the CAPTURE phase with stopImmediatePropagation, so it wins the key before the field's own Escape-to-clear can run. That is deliberate — one Escape, one outcome",
150
+ "The active option is tracked as an index into the rendered (filtered, capped) list, never into the source array — the two diverge the moment a query is typed",
151
+ "The active index is clamped at BOTH ends on every render. -1 (the value left behind when a render found no selectable option) is not 'before the list' to the wrapping step function: its modulo maps -1 to the LAST index, so a reactive items update arriving while a no-match query is showing would otherwise highlight the bottom row and make Enter fire the wrong command",
152
+ "Grouped commands use the APG grouped-listbox shape: a role=\"presentation\" <li> (present only because a <ul> may not hold a bare <ul>) wrapping a visible heading plus a real <ul role=\"group\" aria-label>. A bare role=\"presentation\" heading <li> — the obvious shortcut — is dropped from the accessibility tree entirely: it labels nothing, and listbox owns only option and group children, so grouping would be sighted-only",
153
+ "A heading is emitted only when its group actually has a surviving item, so filtering never leaves an orphan heading",
154
+ "Disabled items render but are unreachable: arrow keys step over them, Home/End land past them, click and Enter refuse them",
155
+ "An item's href is followed ONLY when the item carries no onSelect of its own. A palette-level onSelect does NOT suppress it: a cross-cutting observer (analytics, a recents list) disabling every link in the list would be silent action at a distance. The href is validated with isSafeHref() first, so a javascript: URL in a command list goes nowhere",
156
+ "The hotkey listener exists only when onRequestOpen is supplied, and is removed on destroy",
157
+ "The hotkey is ignored while focus is in an editable element (input, textarea, select, contenteditable) unless the palette is already open — otherwise ⌘K would fire out from under someone typing",
158
+ "maxResults caps rendering, not filtering — the filter still sees every item, so a custom ranking filter can order the whole set before the cap applies"
159
+ ],
160
+ "a11y": [
161
+ "Implements the WAI-ARIA combobox-with-listbox-popup pattern in full. Partial ARIA is worse than none, so the entire keyboard model ships with the roles",
162
+ "The field carries role=\"combobox\", aria-autocomplete=\"list\", aria-controls pointing at the listbox, aria-expanded tracking whether there are results, and aria-activedescendant naming the active option",
163
+ "DOM focus never leaves the field. The highlight moves through aria-activedescendant, which is what keeps typing and navigating in the same control",
164
+ "Exactly one option carries aria-selected=\"true\" at a time; aria-activedescendant is REMOVED when nothing matches, not set to the empty string — an empty string is not a valid IDREF, and omission is the conformant way to say 'nothing is active'",
165
+ "Keyboard: ArrowDown/ArrowUp rove and WRAP, skipping disabled options; Home/End jump to the first/last enabled option; Enter activates; Escape closes the dialog",
166
+ "Disabled options are aria-disabled=\"true\" and are skipped by every one of those keys — an option that can be reached but not chosen is a dead end",
167
+ "The active option is scrolled into view with block: 'nearest', guarded for environments without layout",
168
+ "Grouped commands sit inside a real <ul role=\"group\" aria-label>, so the group name is announced on entry and the options stay countable. The visible heading is aria-hidden because the group's aria-label already carries the same string",
169
+ "A visually-hidden role=\"status\" aria-live=\"polite\" region announces the result count as the query changes — otherwise a sighted-only list update is silent. It stays silent until the reader actually types, so opening the palette does not immediately talk over the field",
170
+ "The dialog is role=\"dialog\" aria-modal=\"true\" with an accessible name, and takes initial focus on the search field rather than the first focusable element",
171
+ "The dialog, the field and the listbox carry three DISTINCT accessible names ('Command palette', 'Search commands', 'Results'). Reusing one name across all three makes a screen reader announce it three times before the reader reaches the first command",
172
+ "Escape always closes the palette and never merely clears the query: <Modal> listens on window in the CAPTURE phase with stopImmediatePropagation(), so it wins the key before <SearchInput>'s own Escape-to-clear can run. One Escape, one outcome",
173
+ "The field is rendered with landmark=false: a role=\"search\" landmark nested inside a modal is not something a reader can navigate to as a landmark",
174
+ "The active row's tint is redrawn with Highlight/HighlightText under forced colours, and disabled rows with GrayText, because the author colours are discarded there",
175
+ "Row transitions are wrapped in prefers-reduced-motion: no-preference"
176
+ ],
177
+ "ai": {
178
+ "synonyms": [
179
+ "command palette",
180
+ "cmd k",
181
+ "command k",
182
+ "⌘K",
183
+ "quick open",
184
+ "quick actions",
185
+ "spotlight",
186
+ "omnibox",
187
+ "action search",
188
+ "fuzzy finder",
189
+ "command menu"
190
+ ],
191
+ "avoid": [
192
+ "expecting it to search anything — it filters the array you hand it. The docs search backend is R3",
193
+ "putting a <SearchInput landmark=\"true\"> inside it — the palette already supplies the field, with the landmark correctly dropped",
194
+ "hand-rolling the combobox ARIA on a <Modal> — aria-activedescendant, wrapping arrow keys, disabled-skipping and the live region are exactly what this component exists to own",
195
+ "using it as a navigation menu — a palette is a keyboard accelerator for people who already know what they want. Ship <Navbar> for people who are browsing",
196
+ "listing hundreds of commands and raising maxResults to match — the fix for a long list is a better filter, not a longer scroll",
197
+ "relying on href for items that need to run code: give the item an onSelect, and note that a handler suppresses href navigation entirely"
198
+ ],
199
+ "patterns": [
200
+ "With the ⌘K binding: <CommandPalette open=\"isOpen\" onClose=\"close\" onRequestOpen=\"open\" items=\"commands\" />",
201
+ "Grouped commands: items sorted so every 'Navigate' entry precedes every 'Actions' entry",
202
+ "Client-side routing: <CommandPalette navigate=\"router.navigate\" ... />",
203
+ "Custom ranking: <CommandPalette filter=\"fuzzyRank\" ... />",
204
+ "Advertise the shortcut in the navbar with <SearchInput shortcutHint=\"'⌘K'\" /> or the .ui-navbar-search-hint trigger shell"
205
+ ]
206
+ },
207
+ "designGuidance": {
208
+ "surface": "The <Modal> surface, re-shaped: anchored --space-xl from the top of the viewport rather than centred (a palette grows downward as the reader types, and a centred one would drift under the cursor), widened to 40rem, and stripped of its padding so the search field can run edge to edge across the head of the panel. The field gives up its own border and background; the dialog edge is the only frame, and a hairline --color-divider rule is what separates the query from its results.",
209
+ "depth": "The Modal's own elevation over a dimmed backdrop, and nothing more. Inside the panel everything is flat — the active row is a tint, not a raised card.",
210
+ "borders": "Exactly one: the rule under the search field. Option rows have no borders at all; grouping is carried by headings and whitespace, which is what lets a long list stay quiet.",
211
+ "interactivity": "One highlight, driven entirely by aria-selected so the visual state and the ARIA state cannot drift apart. It follows both the arrow keys and the pointer, so moving the mouse across the list does not fight the keyboard. Disabled rows dim and take the not-allowed cursor.",
212
+ "motion": "Background and colour on the rows, inside prefers-reduced-motion: no-preference. The dialog's entrance comes from modal.scss. Nothing animates on filter — rows appearing and disappearing under a fast typist is motion the reader did not ask for.",
213
+ "darkMode": "Nothing to do — every value is a --color-* token or a design-system state opacity, so the muted/active relationship inverts with the theme. The forced-colours block is the separate case: there the active row is redrawn with Highlight/HighlightText because author colours are discarded outright.",
214
+ "bestPractices": "Keep the command list short and the labels imperative ('Toggle theme', not 'Theme'). Use groups once you pass about six commands, and order them by how often they are wanted, not alphabetically. Put keywords on anything whose label does not contain the word people will type. Advertise the shortcut somewhere visible — a palette nobody knows about is dead weight. Give items an icon only if every item in a group has one; a half-iconed list reads as broken alignment."
215
+ },
216
+ "examples": [
217
+ {
218
+ "id": "default",
219
+ "title": "⌘K palette",
220
+ "pithy": "<CommandPalette open=\"isOpen\" onClose=\"close\" onRequestOpen=\"open\" items=\"commands\" />",
221
+ "assertions": [
222
+ "renders role='dialog' aria-modal='true' with an accessible name",
223
+ "the field is role='combobox' with aria-controls pointing at the listbox",
224
+ "initial focus lands on the search field",
225
+ "Meta+K and Ctrl+K both call onRequestOpen"
226
+ ]
227
+ },
228
+ {
229
+ "id": "filtering",
230
+ "title": "Filtering",
231
+ "pithy": "<CommandPalette items=\"commands\" />",
232
+ "assertions": [
233
+ "matches label, description and keywords, case-insensitively",
234
+ "a role=\"group\" section disappears once its whole group is filtered away",
235
+ "the empty state renders and is announced when nothing matches",
236
+ "the active option resets to the first match on every query change"
237
+ ]
238
+ },
239
+ {
240
+ "id": "keyboard",
241
+ "title": "Keyboard model",
242
+ "pithy": "<CommandPalette items=\"commands\" />",
243
+ "assertions": [
244
+ "ArrowDown/ArrowUp rove and wrap, skipping disabled options",
245
+ "Home/End jump to the first/last enabled option",
246
+ "aria-activedescendant tracks the active option and is removed outright when there are none",
247
+ "Enter runs item.onSelect, then onSelect, then onClose"
248
+ ]
249
+ },
250
+ {
251
+ "id": "hotkey-guard",
252
+ "title": "Hotkey guard",
253
+ "pithy": "<CommandPalette onRequestOpen=\"open\" hotkey=\"'k'\" items=\"commands\" />",
254
+ "assertions": [
255
+ "nothing is bound unless onRequestOpen is supplied",
256
+ "the keystroke is ignored while focus is in an input, textarea, select or contenteditable",
257
+ "Shift and Alt are excluded, so Cmd/Ctrl+Shift+K does not open it",
258
+ "it still fires while the palette itself is open",
259
+ "destroy() removes the document listener"
260
+ ]
261
+ }
262
+ ]
263
+ }