Accordion interactive Primitive
Native <details> group; single-open via the HTML name= attribute — opening one closes its siblings, zero JS.
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.
What is a Hyperpart?
Do I need a client framework?
Can two panels be open at once?
Copy this
<div class="accordion">
<details class="accordion__item" name="hm-acc" open>
<summary class="accordion__trigger">What is a Hyperpart?</summary>
<div class="accordion__panel">A server-rendered partial plus its exchange contract — the htmx-native unit of reuse.</div>
</details>
<details class="accordion__item" name="hm-acc">
<summary class="accordion__trigger">Do I need a client framework?</summary>
<div class="accordion__panel">No — state lives on the server and htmx swaps the markup.</div>
</details>
<details class="accordion__item" name="hm-acc">
<summary class="accordion__trigger">Can two panels be open at once?</summary>
<div class="accordion__panel">Not while they share a name=. Drop the attribute for an independent, multi-open group.</div>
</details>
</div>Server exchange
This Hyperpart has no server exchange — it is presentation or client chrome only. htmx does not issue a request on this part's behalf. If you put an affordance (hx-*) on a control that uses this markup, that action's exchange belongs to the action, not this part. See Swap contract for host-owned envelopes.
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.
No host HTMX exchange on this part — presentation or client chrome only. exchange envelope: n/a.
If a host wraps this markup in hx-*, that host owns the swap contract (sole identity + envelope). Prefer innerMorph / outerMorph for stable slots; replacement for flash; body-only responses under inner swaps.
Envelope response examples
This part has no owned exchange (envelope n/a). If a host adds hx-*, that host’s envelope applies — typically body_only:
<!-- Host wraps this presentation part with hx-* (host owns envelope) -->
<!-- Prefer: hx-swap="innerMorph" hx-target="#panel-body" -->
<!-- Server returns body_only interior for #panel-body -->
<div class="dz-stack">content…</div>
Do not re-own the slot:
<!-- WRONG: server returns the presentation root with a new id every poll -->
<div id="accordion-root" data-dz-region>…</div>
How to use it
Seams
- `details.accordion__item` + shared `name=` for exclusive group
- `summary.accordion__trigger` — native toggle, no controller
Do / Don't
| Do | Don't |
|---|---|
| Share one name= across peer items for single-open FAQ | Copy menubar exclusive controller onto accordion panels |
Pitfalls
- Missing or mismatched name= → multi-open (not exclusive)
- Do not add exclusive-open JS here — browser name= is the contract
- Do not confuse with tree (multi_open forest) or menubar (controller chrome)
Keyboard / AT
- details/summary expose expanded state natively
- Keyboard: Enter/Space on summary
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/accordion.py
Required in the DOM: root .accordion (part accordion). 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 |
|---|---|---|
.accordion | — | — |
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: accordion — native details group (single-open via name=).
Dual-lock unit is the accordion root. Item open state, panel body, and
shared ``name=`` exclusive-open policy are host-owned. Class
``.dz-accordion`` is the stable substrate root (gallery CSS; no
FragmentRenderer emit yet).
"""
from contracts._kit import DomContract, Node
DOM_CONTRACT = DomContract(
part="accordion",
root=".dz-accordion",
nodes=(Node(".dz-accordion", attrs={}),),
)
__all__ = ["DOM_CONTRACT"]
Notes
name= on peer <details> (stem details-open-intent) — zero JS. Gallery probe accordion.exclusive_open. No aria-expanded wiring: details/summary carry it. Drop name= only when multi-open FAQ is intentional.Source files
Canonical registration in the registry. No dedicated controller — CSS for this part lives in the layered bundle.
site/registry.py · contracts/accordion.py