1 · Why hypermedia
HaTchi-MaXchi is an htmx4-native design system: the server renders markup, the browser swaps fragments, and there is no client state graph. State lives in two places only — on the server, and in the DOM itself (attributes, .checked, aria-*). A component here is a Hyperpart: a partial plus its exchange contracts plus, only where the platform lacks a primitive, a small delegated vanilla-JS controller. If you arrive with React priors, the renaming is deliberate: there is nothing to hydrate, no composition tree, and morphing swaps will discard any state a JS object tries to hold. Dotted terms: hover or focus for a plain-language gloss — these are fundamental hypermedia techniques, not a proprietary box.
2 · Tokens & theming
All variation flows through design tokens. Components reference semantic custom properties; schemes (light/dark) and aesthetic families override tokens, never rules. The theme toggle on every page of this site flips data-theme on the root — no component opts in, because no component names a raw colour. When styling looks wrong, the first question is always which token should this be reading?, not which rule should I add?
3 · Anatomy of a Hyperpart — the grid, worked
One Hyperpart is one logical unit distributed across files by build necessity: the partial and exchange contracts in the registry, CSS in layer order, an optional controller, and — for data-bearing parts — a typed contract module. The grid family shows every piece at once; its inline-edit extension is the canonical example of the morph-survival idiom (the typed edit buffer lives on the grid root, out of the swap path).
Source files
One logical Hyperpart, 12 code items (CSS layered, JS bundled). Bound by HYPERPART: grid — python tools/hyperpart.py grid lists them.
site/registry.py · contracts/grid.py · contracts/grid_edit.py · contracts/grid_cols.py · contracts/grid_resize.py · components/table.css:1 · components/table.css:263 · controllers/grid.js · controllers/grid-cols.js · controllers/grid-resize.js · controllers/grid-edit.js · mock /mock/grid
4 · Exchanges & contracts
A Hyperpart is only half markup. The other half is the contract the server must satisfy: the {_term('exchange')} (request/response round-trip each affordance initiates), its Swap contract / exchange envelope (what the fragment may re-emit into a persistent slot — body_only under inner swaps, etc.), and, for data-bearing seams, the typed contract module — ingestion model, DOM contract, and an executable exemplar that CI renders and validates. Dual-lock freezes part HTML; the swap contract freezes host/exchange topology (decision 0012 / ADR-0054). Both for the grid:
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/{region}/rows?q=&sort=&dir=&page=&page_size= | the tbody, on `load` and on `grid:refresh` (fired by a sort click, a filter change, a debounced search keystroke, or a page control) — with `page=` added for pagination | the current page's `<tr>` rows for the query — each a `tr-row` carrying a stable `id` (the idiomorph morph key) plus `data-grid-row-id` (the bulk-action payload anchor) — plus the repainted pagination footer (via an OOB `<nav>` or a wrapping region swap); a zero-result query returns an empty tbody so the `:has(tbody tr td)`-driven empty-state shows | innerMorph of the tbody (`[data-grid-body]`) — idiomorph keys on each row's `id`, so a live selection follows its row across a re-sort — PLUS an out-of-band update of the pagination footer: append `<nav data-grid-pagination data-grid-total="N" hx-swap-oob="true">…</nav>` to the response (the stamped total feeds the all-matching affordance) (or target a wrapping region that contains both the tbody and the footer in one swap). The footer's current-page button carries `aria-current="page"` — the client reads it back as the authoritative (possibly server-clamped) page | body_only | loading empty populated error |
POST /app/{region}/bulk | a bulk-action button (e.g. Delete), after the user approves its confirm dialog; the controller injects the selection on `htmx:configRequest` | the server RE-VALIDATES permissions and RE-SCOPES the action to the echoed query (never trusting the client `selected_ids` alone) and applies it. Two patterns: with `data-grid-bulk-refresh` on the button (this demo), the response swaps NOTHING (JSON/204) and the controller re-fetches rows + footer via the normal GET; without it, put `hx-target` on the button and return the refreshed `<tr>` rows directly. When `all_matching_selected=true`, the action applies to the WHOLE matched query minus `excluded_ids` — the server re-runs the echoed query itself, and MUST strip `page`/`page_size` first (they window the display, not the matched set — re-running them verbatim would apply the action to one page only); `selected_ids` is informational (visible state) only. NB form encoding: with no exclusions the `excluded_ids` key is ABSENT from the POST (not sent empty) — default it to the empty list | innerMorph of the tbody (`[data-grid-body]`) plus the OOB footer (its `data-grid-total` re-stamps the matched total) | body_only | populated empty error |
PUT /app/{entity}/{id} | the inline-edit extension (grid-edit.js): dblclick an editable cell's display span opens an in-cell editor; Enter (or a change, for bool/select/date) commits a raw fetch PUT to `{data-grid-edit-url}/{rowId}` — NOT an htmx exchange | this is the entity's STANDARD update route, not a bespoke field endpoint: the body is a single-field JSON object (`{"plan": "Pro"}`), so an all-optional update schema + exclude-unset semantics make it a partial update, and the full update gate (permissions, scoping, validation) applies. Return 2xx JSON on success; any non-2xx keeps the editor open with the response text as its error. The controller then fires `grid:refresh` on the tbody, so the committed value renders SERVER-side (badges/dates re-render; no client patching) | none (raw fetch) — the follow-up `grid:refresh` re-fetches rows + footer via the tbody's normal GET | none | 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/{region}/rows?q=&sort=&dir=&page=&page_size=→ innerMorph of the tbody (`[data-grid-body]`) — idiomorph keys on each row's `id`, so a live selection follows its row across a re-sort — PLUS an out-of-band update of the pagination footer: append `<nav data-grid-pagination data-grid-total="N" hx-swap-oob="true">…</nav>` to the response (the stamped total feeds the all-matching affordance) (or target a wrapping region that contains both the tbody and the footer in one swap). The footer's current-page button carries `aria-current="page"` — the client reads it back as the authoritative (possibly server-clamped) page · envelope=body_onlyPOST /app/{region}/bulk→ innerMorph of the tbody (`[data-grid-body]`) plus the OOB footer (its `data-grid-total` re-stamps the matched total) · envelope=body_onlyPUT /app/{entity}/{id}→ none (raw fetch) — the follow-up `grid:refresh` re-fetches rows + footer via the tbody's normal GET · envelope=none
Envelope rules
body_only— innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nesteddata-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 Data table. Match the exchange envelope; dual-lock still applies to interior markup.
GET /app/{region}/rows?q=&sort=&dir=&page=&page_size= · envelope=body_only
Correct response for body_only into [data-grid-body] (innerHTML / innerMorph). Wrong: re-wrapping the slot.
Do — correct response body
<!-- envelope=body_only → <tr> rows (+ optional OOB footer), not a table -->
<tr class="tr-row" id="row-42" data-grid-row-id="42">
<td>…</td><td>…</td>
</tr>
<tr class="tr-row" id="row-43" data-grid-row-id="43">
<td>…</td><td>…</td>
</tr>
<!-- optional: -->
<!-- <nav data-grid-pagination data-grid-total="N" hx-swap-oob="true">…</nav> -->
Don’t — violates body_only
<!-- WRONG: full table / tbody root into [data-grid-body] -->
<table class="table" data-grid>
<tbody>…</tbody>
</table>
POST /app/{region}/bulk · envelope=body_only
Correct response for body_only into [data-grid-body] (innerHTML / innerMorph). Wrong: re-wrapping the slot.
Do — correct response body
<!-- envelope=body_only → <tr> rows (+ optional OOB footer), not a table -->
<tr class="tr-row" id="row-42" data-grid-row-id="42">
<td>…</td><td>…</td>
</tr>
<tr class="tr-row" id="row-43" data-grid-row-id="43">
<td>…</td><td>…</td>
</tr>
<!-- optional: -->
<!-- <nav data-grid-pagination data-grid-total="N" hx-swap-oob="true">…</nav> -->
Don’t — violates body_only
<!-- WRONG: full table / tbody root into [data-grid-body] -->
<table class="table" data-grid>
<tbody>…</tbody>
</table>
PUT /app/{entity}/{id} · envelope=none
Correct response for none (raw fetch / no HTML swap).
Do — correct response body
// envelope=none — no HTML swap (JSON/204; client or OOB companion)
// HTTP 204 No Content
// or:
{ "ok": true }
// Application/json; status 200
// Optional: separate OOB HTML fragments if the host declares them
Don’t — violates none
<!-- WRONG: HTML body when hx-swap is none / raw fetch expects JSON|204 -->
<div class="alert">Deleted</div>
DOM contract
4 dual-lock modules for this part (contracts/grid.py, contracts/grid_edit.py, contracts/grid_cols.py, contracts/grid_resize.py). Read top-to-bottom: core root first, then extensions. Each Exemplar render() live box is CI fixture output for that module — not a second demo of the whole Hyperpart. What the tables require is the emitted HTML; Python under contracts/ is package-internal dual-lock (not an app route). Request/response wiring: Server exchange.
contracts/grid.py
Required in the DOM: root [data-grid] (part grid). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
Root-only contract — the root selector must match; no per-node attribute list.
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: grid — root contract (thin). The base grid's structural
root attributes; the data-bearing seams live in extension contracts
(grid_edit). Root-only: no ingestion model, no exemplars.
Leftover honesty (cycle 2157): URL ``?page=2abc`` / ``?page_size=2abc``
must not invent a window. ``parseInt("2abc", 10) === 2`` is leftover
junk — same class as PDF leftover page (2151). Empty / invalid
restores the server default. Valid whole numbers still window.
Rest-state gallery is unchanged (oral #33).
Leftover honesty (cycle 2170): ``ownedKeys`` / ``buildQuery`` must
echo leftover-honest ``include_closed`` / ``as_of``. Dropping them
from hx-get invented open-only / current after a refresh (page URL
foreign params survived; all-matching echo then invented). Leftover
junk (``zzz``, ``2abc``, ``maybe``, ``not-a-date``) must not invent.
Valid ``true`` / YYYY-MM-DD still ride hx-get. Not leftover list
include_closed / related-tab as_of / DETAIL as_of onto the edit form.
"""
from contracts._kit import DomContract
DOM_CONTRACT = DomContract(
part="grid",
root="[data-grid]",
nodes=(),
)
__all__ = ["DOM_CONTRACT"]
contracts/grid_edit.py
Required in the DOM: root [data-grid][data-grid-edit-url] (part grid-edit). 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-grid-edit] | data-edit-kind | one of ['text', 'date', 'time', 'number', 'bool', 'select'] |
[data-grid-edit] | data-edit-value | present (any value) |
[data-grid-edit] | data-edit-label | present (any value) |
[data-grid-edit] | data-edit-options | JSON [[value, label], …]; required when {'data-edit-kind': 'select'} |
Ingestion model GridEditCell
Server-side shape before render — one normalisation boundary for producers.
| Field | Type | Required |
|---|---|---|
col | string | required |
kind | string ∈ ['text', 'date', 'time', 'number', 'bool', 'select'] | required |
value | string | required |
label | string | required |
options | array | null | 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: value=Fix the door, label=Title, col=title, kind=text.
def render(cell: GridEditCell) -> str:
"""Model → conforming display-span fragment (the seam the controller reads)."""
opts = ""
if cell.kind == "select" and cell.options is not None:
pairs = json.dumps([[v, label] for v, label in cell.options])
opts = f' data-edit-options="{html.escape(pairs, quote=True)}"'
return (
f'<span class="tr-cell-display" '
f'data-grid-edit="{html.escape(cell.col, quote=True)}" '
f'data-edit-kind="{cell.kind}" '
f'data-edit-value="{html.escape(cell.value, quote=True)}" '
f'data-edit-label="{html.escape(cell.label, quote=True)}"{opts}>'
f"{html.escape(cell.value)}</span>"
)
Live output of render(EXEMPLARS[0]) — fixture markup the dual-lock validates (sample field values only).
FastAPI feed example — grid-edit exemplar — how a server feeds the inline-edit seam
Package exemplar for feeding this seam (not the gallery mock). Prefer the part's Server exchange server_example when present — that is the product handler shape. Avoid from __future__ import annotations in real FastAPI route files (ADR-0014).
@app.get("/rows", response_class=HTMLResponse)
def rows() -> str:
"""A tbody fragment: what a real endpoint returns to fill the grid.
Mirrors Dazzle's shape: the grid ROOT (with data-grid-edit-url)
is page furniture; this endpoint returns rows whose editable cells
carry the seam spans."""
cells = "".join(f"<td>{render(c)}</td>" for c in EXEMPLARS[:3])
return f'<tr id="row-1">{cells}</tr>'
contracts/grid_cols.py
Required in the DOM: root [data-grid] (part grid-cols). 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-grid-col-toggle] | data-grid-col-toggle | present (any value) |
[data-col] | data-col | present (any value) |
[data-grid-cols-reset] | — | — |
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: grid (extension: grid-cols) — column visibility seam."""
from contracts._kit import DomContract, Node, Present
DOM_CONTRACT = DomContract(
part="grid-cols",
root="[data-grid]",
nodes=(
Node("[data-grid-col-toggle]", attrs={"data-grid-col-toggle": Present()}),
Node("[data-col]", attrs={"data-col": Present()}),
Node("[data-grid-cols-reset]", attrs={}),
),
)
__all__ = ["DOM_CONTRACT"]
contracts/grid_resize.py
Required in the DOM: root [data-grid] (part grid-resize). 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-grid-resize] | data-grid-resize | present (any value) |
col[data-col], [data-col] | data-col | present (any value) |
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: grid (extension: grid-resize) — column resize seam."""
from contracts._kit import DomContract, Node, Present
DOM_CONTRACT = DomContract(
part="grid-resize",
root="[data-grid]",
nodes=(
Node("[data-grid-resize]", attrs={"data-grid-resize": Present()}),
Node("col[data-col], [data-col]", attrs={"data-col": Present()}),
),
)
__all__ = ["DOM_CONTRACT"]
5 · Composing Blueprints
Whole pages compose from published Hyperparts and Layout primitives only — a Blueprint is the thing you copy when starting a page. Layout responsiveness is intrinsic (primitives wrap on their own minimums; no media queries), which is what makes a Blueprint testable at any viewport. Study them live: Workspace with drawer · Master–detail page · Dashboard · Auth page · SaaS app shell · Record full page · Ops work queue · Triage with drawer · Manager SLA strip.
Building a NEW part instead? Follow the contract-first path in contracts/AUTHORING.md — decision test, contract module, controller, registry, consumer emitter.
6 · Layers, recipes, and agents
Same visual shape does not mean the same Hyperpart. Judgement is organised in three layers: L0 recipe (the user job), L1 surface (markup + exchange + lifetime), and L2 host (who embeds or deliberately refuses a surface). Composition is declared (composes / does_not_compose); resemblance alone is not composition. Example: the data table hosts in-cell enums with a bare <select> and refuses the combobox Hyperpart today—density, morph survival, and single-field PUT—not a forgotten dogfood.
Coding agents should start at AGENTS.md (curriculum), then docs/agent/pick-a-surface.md (pick matrix), then the per-part agents/<id>.md pack. Blast radius: CONSUMER_MAP.md. Frozen why: docs/decisions/. This guide is the human theory track; it does not replace that sequence.