Master–detail

Master–detail composite CompositeControllerEndpointSprite

Exchange composition — a list item hx-gets its detail card into the detail pane. The canonical htmx composite; two can coexist on a page.

Layer: L2 host · Recipe: list-region-host — server-driven list / data table host. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.

INV-001 · Acme
£1,250.00
Paid · 2 days ago

Copy this

html
<div class="master-detail" data-master-detail>
  <ul class="master-detail__list" aria-label="Invoices" data-master-detail-list-body>
    <li><a class="master-detail__item" href="#" aria-current="true" hx-get="/mock/master-detail/inv-001" hx-target="next .master-detail__detail">INV-001 · Acme</a></li>
    <li><a class="master-detail__item" href="#" hx-get="/mock/master-detail/inv-002" hx-target="next .master-detail__detail">INV-002 · Globex</a></li>
    <li><a class="master-detail__item" href="#" hx-get="/mock/master-detail/inv-003" hx-target="next .master-detail__detail">INV-003 · Initech</a></li>
  </ul>
  <div class="master-detail__detail" data-master-detail-detail-body>
    <div class="card card-body">
      <div class="card-label">INV-001 · Acme</div>
      <div class="card-value">£1,250.00</div>
      <div class="card-delta">Paid · 2 days ago</div>
    </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.

RequestTriggerResponse fragmentSwapEnvelopeStates
GET /app/master-detail/{id}a list item, on clicka detail card fragment — `<div class="card card-body">…`innerHTML of the sibling `.master-detail__detail` panebody_onlyloading 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/master-detail/{id} → innerHTML of the sibling `.dz-master-detail__detail` pane · envelope=body_only

Envelope rules

  • body_only — innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nested data-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’s hx-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 Master–detail. Match the exchange envelope; dual-lock still applies to interior markup.

GET /app/master-detail/{id} · envelope=body_only

Correct response for body_only into .dz-master-detail__detail (innerHTML / innerMorph). Wrong: re-wrapping the slot.

Do — correct response body

html
<!-- envelope=body_only → detail pane interior -->
<div class="dz-card dz-card-body">
  <h2 class="dz-heading" data-dz-level="3">Acme Ltd</h2>
  <p>Company #12345678 · Active</p>
</div>

Don’t — violates body_only

html
<!-- WRONG: whole master–detail shell -->
<div class="dz-master-detail" data-dz-master-detail>
  <div class="dz-master-detail__list">…</div>
  <div class="dz-master-detail__detail">…</div>
</div>

How to use it

Seams

  • detail pane loads a card fragment via hx-get from the selected item
  • aria-current marks the chosen list item, scoped to THIS root

Do / Don't

DoDon't
hx-get the detail card into the detail pane on item activatestash all detail payloads in data-* attributes on every list row
replace (`innerHTML`) the detail pane — it is disposable content that should fully reset on each selection (decision 0005)morph the detail pane by default when focus/edit state must not leak across selections (prefer explicit replace)

Pitfalls

  • two master-detail roots must not share aria-current — controller is per-root
  • detail content is a server fragment, not a client template

Keyboard / AT

  • aria-current=true on the active list item
  • list items remain keyboard-activatable links/buttons

Related parts

card list-region

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/master_detail.py

Required in the DOM: root [data-master-detail] (part master-detail). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).

NodeAttrConstraint
[data-master-detail]——
[data-master-detail-list-body]——
[data-master-detail-detail-body]——

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.

python
"""HYPERPART: master-detail — selection marker + detail pane root.

Dazzle emission site (workspace dual_pane_flow LIST+DETAIL pair):
``dazzle.page.runtime.dual_pane_master_detail.render_master_detail_shell``.
List rows carry ``.dz-master-detail__item`` and hx-get a detail fragment into
``.dz-master-detail__detail``; ``dz-master-detail.js`` owns aria-current.

Leftover honesty (cycle 2183 class-close): list-pane ``hx-get``
must echo leftover-honest ``include_closed`` / ``as_of``. Bare
``hx-get="{list_endpoint}"`` dropped them and invented open-only /
current on pane load. Leftover junk must not invent. Valid
``true`` / YYYY-MM-DD still ride. Do not walk another ``hx-get``
sibling (oral #67).
"""

from contracts._kit import DomContract, Node

DOM_CONTRACT = DomContract(
    part="master-detail",
    root="[data-dz-master-detail]",
    nodes=(
        Node("[data-dz-master-detail]", attrs={}),
        # Pane markers (kit selectors are [attr] only — no class engine).
        Node("[data-dz-master-detail-list-body]", attrs={}),
        Node("[data-dz-master-detail-detail-body]", attrs={}),
    ),
)

__all__ = ["DOM_CONTRACT"]

Notes

The detail pane loads a card fragment via hx-get; master-detail.js sets aria-current on the chosen item, scoped to THIS root so two coexist.

Source files

One logical Hyperpart, 5 code items (CSS layered, JS bundled). Bound by HYPERPART: master-detail — python tools/hyperpart.py master-detail lists them.

site/registry.py · contracts/master_detail.py · components/hm-core.css:471 · controllers/dz-master-detail.js · mock /mock/master-detail

Composed of

Card