Command palette interactive SpriteControllerEndpoint
The hx-get palette — the htmx4 flagship. Press ⌘K.
Layer: L1 surface · Recipe: command-palette — command palette search. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.
Copy this
<!-- icons: include the icon sheet once per page (see the Setup section, #setup) -->
<button class="button" data-variant="outline" data-hm-open-command>Open palette <kbd class="kbd">⌘K</kbd></button>
<dialog class="command" data-command aria-label="Command palette" closedby="any">
<div class="command__bar"><input class="command__input" type="search" name="q" placeholder="Search workspaces and records…" autocomplete="off" aria-controls="command-results" aria-autocomplete="list" hx-get="/mock/command" hx-trigger="input changed delay:150ms, focus once" hx-target="next .command__results"><button type="button" class="command__close" data-hm-close-command aria-label="Close command palette"><svg class="icon" aria-hidden="true"><use href="#i-x"/></svg></button></div>
<div class="command__results" id="command-results" role="listbox" aria-label="Results"></div>
</dialog>Server exchange
When the client affordance finishes (click, confirm, keystroke…), htmx issues this request. Your API must return the response fragment in the table — usually HTML, not JSON (unless the partial says otherwise). Dazzle often renders these routes from the app model; a standalone HTMX4 app implements them explicitly. The Envelope column is the exchange envelope (part of the Swap contract) — what the response may re-emit relative to the persistent slot.
Do not reimplement the gallery. Flash toasts (e.g. “Deleted (demo).”), /mock/* paths, and other static-site scaffolding are demo-only (MOCK_HTMX in site/build_site.py). They are not Hyperpart surface and not a product API. If an agent is stuck “making the toast work,” stop — implement the exchange row below instead.
| Request | Trigger | Response fragment | Swap | Envelope | States |
|---|---|---|---|---|---|
GET /app/command | the search input, on `input` (debounced 150ms) and first `focus` | zero or more result rows — `<a>`/`<button class="command__item" role="option">` grouped by `<div class="command__group">` headers; empty query returns the full persona catalog; no matches returns `<div class="command__empty">` | innerHTML of the sibling `.command__results` listbox | body_only | loading empty populated error |
Swap contract
Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.
Gallery mocks may approximate morph with innerHTML — production follows the Swap + Envelope columns in Server exchange.
Exchanges (swap · envelope)
GET /app/command→ innerHTML of the sibling `.dz-command__results` listbox · envelope=body_only
Envelope rules
body_only— innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nesteddata-dz-region).outer— outerHTML / outerMorph; response may carry identity.none— no HTML swap (JSON/204/bytes; client or OOB companion).host_owned— swap target/mode chosen by the host button’shx-target/hx-swap.document— full navigation / document load (not a fragment).- Slot owns stable
id/ domain keys; state in DOM, not Alpine.
Envelope response examples
What the server returns for each exchange on Command palette. Match the exchange envelope; dual-lock still applies to interior markup.
GET /app/command · envelope=body_only
Correct response for body_only into .dz-command__results (innerHTML / innerMorph). Wrong: re-wrapping the slot.
Do — correct response body
<!-- envelope=body_only → option rows for the results listbox -->
<div class="dz-command__group" role="group" aria-label="Actions">
<a class="dz-command__item" role="option" href="/app/invoices">Invoices</a>
<button type="button" class="dz-command__item" role="option">New contact</button>
</div>
Don’t — violates body_only
<!-- WRONG: whole command palette chrome -->
<div class="dz-command" data-dz-command>
<input class="dz-command__input" />
<div class="dz-command__results">…</div>
</div>
How to use it
Seams
- hx-get on the search input returns persona-scoped result fragments
- open triggers: data-hm-open-command / ⌘K; close: data-hm-close-command + closedby=any
- shortcut-hint-chrome: opener kbd is adjacent (button gap); item kbd is trailing
Do / Don't
| Do | Don't |
|---|---|
| return result-list fragments from /app/command (or mock) | hydrate a client-side result model the palette must re-render |
| adjacent gap on opener; trailing auto on result-row kbd | flush label+kbd or treat ⌘K as a primary icon |
Pitfalls
- type=search swallows Esc to clear the value — the controller must close on first Esc
- do not absolute-position the close button against a modal dialog (Safari/iPadOS collapse)
- do not glue ⌘K to the opener label (0 gap) — chip is spatially secondary
Keyboard / AT
- Esc closes the palette on first press even mid-query
- Arrow keys move aria-activedescendant through results; Enter activates
- close button is the touch dismiss affordance (no Esc key on tablets)
Related parts
DOM contract
What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.
contracts/command.py
Required in the DOM: root [data-command] (part command). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
| Node | Attr | Constraint |
|---|---|---|
[data-command] | — | — |
Module source
Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.
"""HYPERPART: command — palette dialog root contract."""
from contracts._kit import DomContract, Node
DOM_CONTRACT = DomContract(
part="command",
root="[data-dz-command]",
nodes=(Node("[data-dz-command]", attrs={}),),
)
__all__ = ["DOM_CONTRACT"]
Notes
/app/command, which returns persona-scoped results as real links. The gallery mock returns <button type=button class=command__item> rows so picking an option closes the palette without href=# scrolling the page to the top mid-browse. Shortcut chips (stem shortcut-hint-chrome): opener uses adjacent layout (button gap); result rows use trailing (margin-inline-start: auto on .kbd).Source files
One logical Hyperpart, 5 code items (CSS layered, JS bundled). Bound by HYPERPART: command — python tools/hyperpart.py command lists them.
site/registry.py · contracts/command.py · components/hm-core.css:316 · controllers/dz-command.js · mock /mock/command