diff --git a/.changeset/palette-a11y-names.md b/.changeset/palette-a11y-names.md
new file mode 100644
index 0000000..5e8e0e7
--- /dev/null
+++ b/.changeset/palette-a11y-names.md
@@ -0,0 +1,5 @@
+---
+"acture-palette-react": patch
+---
+
+Accessibility: the picker chain's text step now has an accessible name (the parameter name), and its validation error is announced (`role="alert"`, `aria-describedby`, `aria-invalid`). Adds `palette.a11y.test.tsx`, which pins the APG combobox roles, `aria-activedescendant` and the focus hand-off between the list, the picker chain and a host form.
diff --git a/.claude/skills/acture-palette-design/SKILL.md b/.claude/skills/acture-palette-design/SKILL.md
index 7306372..05a0445 100644
--- a/.claude/skills/acture-palette-design/SKILL.md
+++ b/.claude/skills/acture-palette-design/SKILL.md
@@ -97,6 +97,30 @@ Peer range is `@rjsf` `^5.20.0 || ^6.0.0`, and CI installs and tests both majors
Acture's core does not bundle a form library. Per redesign-takeaways §2.3.
+## Unavailable commands: hidden or disabled? (decided, research-12 §3.2)
+
+A command whose `when` is false is **unavailable**. What a surface does with it is a per-surface rule, not a property of the record:
+
+| Surface | Unavailable command is… | Why |
+| --- | --- | --- |
+| Command palette (`acture-palette-react`) | **hidden**, when the host passes `context` (`registry.list({ context })` drops it; without `context`, `when` is not evaluated and an unavailable command fails at dispatch) | The palette is a search box; unavailable rows are noise and VS Code does the same. |
+| AI tool list / MCP | **should be hidden; not implemented yet.** `acture-mcp-server` and `acture-ai-vercel` call `registry.list()` without a context, so they list every command and an unavailable one fails with `when_clause_failed` at dispatch | A tool the model cannot usefully call only invites a failed call. |
+| Menu, toolbar, button, native menu | **shown, disabled** | These are spatial and persistent; a vanishing item breaks muscle memory and hides what the app can do. |
+
+For the "shown, disabled" surfaces, build the projection from the **unfiltered** `registry.list()` and evaluate `when` yourself with `evaluateWhen(cmd.when, ctx)`. Map the result to `aria-disabled="true"` (not the `disabled` attribute, which takes the control out of the tab order, so keyboard users can no longer reach it and learn why it is off; APG menu items stay focusable), `AXEnabled: false` (macOS), `IsEnabled: false` (UIA), or Electron/Tauri `enabled: false`. A disabled command must not advertise a live shortcut (`aria-keyshortcuts`).
+
+A palette host that wants the menu convention instead can render its own rows from the unfiltered list and pass the `disabled` prop to `Command.Item` (cmdk renders `aria-disabled` itself, overwriting any `aria-disabled` you pass, and skips disabled items in keyboard navigation). That is a host choice; the shipped palette keeps hiding.
+
+## Accessibility contract (tested in `palette.a11y.test.tsx`)
+
+`acture-palette-react` inherits the WAI-ARIA APG combobox pattern from cmdk and pins it with tests. Keep these true when changing the palette:
+
+- The input is `role="combobox"` with `aria-controls` pointing at the `listbox`, named by the `label` ("Command palette"); rows are `option`s inside named `group`s.
+- DOM focus stays in the input; the highlighted row is `aria-selected` and `aria-activedescendant` follows it. (cmdk 1.1.1 only publishes `aria-activedescendant` once the highlight moves; the very first row is not announced until the first arrow key.)
+- When the view switches, the new view's first input takes focus (`autoFocus`), and cancelling returns to the list with the input focused. A host `formAdapter` owns focus inside its form and must `autoFocus` its first field.
+- Every text input the palette renders has an accessible name (the parameter name), and a validation error is `role="alert"`, linked by `aria-describedby`, with `aria-invalid` on the input.
+- Decorative icons are `aria-hidden`; the visible title is part of the option's text (WCAG 2.5.3).
+
## The don't-do list (research-2 §9.5)
1. **Do not parse a single text blob into multiple parameters.** Slack's pattern. H2 hate; industry is migrating away.
@@ -122,3 +146,4 @@ The original wrapex `paletteHint` for delegating to a UI panel (Pattern D in `pa
- `docs/parameterized_command_palette_guide.md` — implementation patterns (defer to research-2 on UX conflicts)
- `acture-command-record-shape` — the `kind` field spec
- `acture-schema-bridge` — how `params` projects to forms
+- `docs/research/acture_research_12 -- Accessibility APIs and Command Dispatch.md` — the accessibility alignment this section comes from
diff --git a/packages/palette-react/src/palette.a11y.test.tsx b/packages/palette-react/src/palette.a11y.test.tsx
new file mode 100644
index 0000000..f4262fb
--- /dev/null
+++ b/packages/palette-react/src/palette.a11y.test.tsx
@@ -0,0 +1,243 @@
+///
+/**
+ * Accessibility contract of `` (research-12 §3 item 5, #64).
+ *
+ * The palette inherits the WAI-ARIA APG combobox pattern from cmdk. These
+ * tests pin that inheritance down — roles, the accessible name, the
+ * `aria-activedescendant` roving highlight — and the focus hand-off when the
+ * view switches (list → picker chain / form → back to the list), so a cmdk
+ * upgrade or an acture refactor cannot silently drop it.
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
+import { z } from 'zod';
+import { createRegistry, defineCommand, ok } from 'acture';
+import { CommandPalette } from './palette.js';
+import type { PaletteFormAdapter } from './palette.js';
+
+afterEach(() => cleanup());
+
+function setup() {
+ const registry = createRegistry();
+ registry.registerAll([
+ defineCommand({
+ id: 'app.view.zoomToFit',
+ title: 'Zoom to fit',
+ category: 'View',
+ execute: () => ok('zoomed'),
+ }),
+ defineCommand({
+ id: 'app.selection.selectAll',
+ title: 'Select all',
+ category: 'Selection',
+ execute: () => ok('selected'),
+ }),
+ defineCommand({
+ id: 'app.style.set',
+ title: 'Set style',
+ category: 'Style',
+ params: z.object({ variant: z.enum(['solid', 'dashed']) }),
+ execute: (p) => ok(p),
+ }),
+ defineCommand({
+ id: 'app.graph.addNode',
+ title: 'Add node',
+ category: 'Graph',
+ params: z.object({ label: z.string(), size: z.number() }),
+ execute: (p) => ok(p),
+ }),
+ ]);
+ return registry;
+}
+
+describe(' accessibility', () => {
+ it('exposes the APG combobox pattern: combobox input controlling a listbox of options', () => {
+ render();
+ const input = screen.getByRole('combobox');
+ const listbox = screen.getByRole('listbox');
+ expect(input.getAttribute('aria-autocomplete')).toBe('list');
+ expect(input.getAttribute('aria-expanded')).toBe('true');
+ expect(input.getAttribute('aria-controls')).toBe(listbox.id);
+ expect(screen.getAllByRole('option')).toHaveLength(4);
+ });
+
+ it('gives the palette, the listbox and the input an accessible name', () => {
+ const { container } = render();
+ const root = container.querySelector('[cmdk-root]')!;
+ expect(root.getAttribute('data-acture-palette-view')).toBe('list');
+ // `label` on is rendered as a visually-hidden