Search box forms ControllerEndpoint
The FTS search region: a debounced search input, an aria-live results panel, and a coaching line that hides — via pure CSS — the moment the user types.
Layer: L1 surface · Recipe: unset — see docs/agent/pick-a-surface.md. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.
Copy this
<div class="search-box-region hm-measure" data-search-box>
<div class="search-box-input-row">
<label for="hm-search-input" class="visually-hidden">Search records</label>
<input id="hm-search-input" type="search" name="q" class="search-box-input" placeholder="Search records…" autocomplete="off" hx-get="/mock/search" hx-trigger="input changed delay:250ms[this.value.trim().length>0], search[this.value.trim().length>0]" hx-target="#hm-search-results" hx-swap="innerHTML">
</div>
<div id="hm-search-results" class="search-box-results" role="region" aria-live="polite">
<div class="search-box-empty">Type a title or keyword</div>
</div>
</div>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/fts/{entity}?q=&html=1 | the input, debounced 250ms when q is non-empty (native `search` / Esc/clear restores coaching — no GET) | the results fragment: a `search-box-result-count` line + a `search-box-result-list` of linked rows with `<mark>`-highlighted snippets; zero hits return the `--no-results` variant of the empty line (which the CSS toggle deliberately never hides). Empty queries aren't sent (min length 1) | innerHTML | body_only | — |
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/fts/{entity}?q=&html=1→ innerHTML · 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 Search box. Match the exchange envelope; dual-lock still applies to interior markup.
GET /app/fts/{entity}?q=&html=1 · envelope=body_only
Correct response for body_only into .dz-search-box-results (innerHTML / innerMorph). Wrong: re-wrapping the slot.
Do — correct response body
<!-- envelope=body_only → results list rows / empty prompt -->
<div class="dz-search-result-row" role="option">
<div class="dz-search-result-name">Acme Ltd</div>
<div class="dz-search-result-secondary">Co. 123</div>
</div>
Don’t — violates body_only
<!-- WRONG: entire search field + listbox chrome -->
<div class="dz-search-box" data-dz-search-box id="slot">
<input type="search" />
<div>…results…</div>
</div>
How to use it
Seams
- native type=search is the query; [data-search-box] owns the results slot
- hx-trigger debounce + min-length filter; controller restores coaching on clear
Do / Don't
| Do | Don't |
|---|---|
| stop empty exchanges and restore the coaching empty line | GET empty q= and let /mock/search invent Aurora/Beacon hits |
| filter leftover q= so zzz is empty and substation still hits | path-only /mock/search that ignores q= and invents Aurora |
Pitfalls
- empty / whitespace query must not hx-get a silent or fake result list
- clear after a hit must restore coaching — do not leave stale Aurora rows
- leftover typed q (zzz) must not invent Aurora — /mock/search filters
Keyboard / AT
- type=search keeps Esc/clear and the native search event
- results region stays aria-live=polite; coaching is not a live hit list
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/search_box.py
Required in the DOM: root [data-search-box] (part search-box). 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-search-box] | data-search-box | present (any value) |
Ingestion model SearchBox
Server-side shape before render — one normalisation boundary for producers.
| Field | Type | Required |
|---|---|---|
name | string | optional |
label | string | optional |
placeholder | string | optional |
coaching_message | string | optional |
endpoint | string | optional |
results_html | string | optional |
Exemplar render()
Executable in CI: the Python below is render(); the boxed preview is render(EXEMPLARS[0]) — the first fixture the dual-lock tests emit, not a separate widget and not gallery mock data. Sample model: label=Search records.
def render(s: SearchBox) -> str:
"""Model → search-box region."""
results_id = f"dz-search-results-{html.escape(s.name, quote=True)}"
endpoint = html.escape(s.endpoint, quote=True)
placeholder = html.escape(s.placeholder or "Search…", quote=True)
label_text = html.escape(s.label or s.placeholder or "Search")
coaching = html.escape(s.coaching_message or "Type a title or keyword")
results_body = s.results_html.strip() or (f'<div class="dz-search-box-empty">{coaching}</div>')
return (
f'<div class="dz-search-box-region" data-dz-search-box>'
f'<div class="dz-search-box-input-row">'
f'<label for="{results_id}-input" class="visually-hidden">{label_text}</label>'
f'<input id="{results_id}-input" type="search" name="q" '
f'class="dz-search-box-input" placeholder="{placeholder}" '
f'autocomplete="off" '
f'hx-get="{endpoint}" '
f'hx-trigger="input changed delay:250ms[this.value.trim().length>0], '
f'search[this.value.trim().length>0]" '
f'hx-target="#{results_id}" '
f'hx-swap="innerHTML">'
f"</div>"
f'<div id="{results_id}" class="dz-search-box-results" '
f'role="region" aria-live="polite">'
f"{results_body}"
f"</div>"
f"</div>"
)
Live output of render(EXEMPLARS[0]) — fixture markup the dual-lock validates (sample field values only).
Notes
data-search-box (contracts/search_box.py). The 250ms debounce is hx-trigger with a min-length filter; search-box.js still blocks empty/whitespace queries (gallery mock ignores the filter) and restores the coaching line so clear does not swap a fake hit list. The input posts name=q so leftover text reaches /mock/search — leftover zzz is empty, not canned Aurora (cycle 2148). Results land in an aria-live="polite" region; the coaching line is hidden by :has(input:not(:placeholder-shown)) until a swap. Results are server-rendered search-box-result rows (title + per-field <mark>-highlighted snippets, count line above); the no-results state reuses search-box-empty with the --no-results modifier.Source files
One logical Hyperpart, 4 code items (CSS layered, JS bundled). Bound by HYPERPART: search-box — python tools/hyperpart.py search-box lists them.
site/registry.py · contracts/search_box.py · components/search-box.css:1 · controllers/dz-search-box.js