← Gallery

The HaTchi-MaXchi Guide

Theory in five short sections — everything embedded below is a live, drift-gated artifact, not hand-typed documentation.

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.

RequestTriggerResponse fragmentSwapEnvelopeStates
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 paginationthe 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 showsinnerMorph 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) pagebody_onlyloading empty populated error
POST /app/{region}/bulka 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 listinnerMorph of the tbody (`[data-grid-body]`) plus the OOB footer (its `data-grid-total` re-stamps the matched total)body_onlypopulated 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 exchangethis 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 GETnonepopulated 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_only
  • POST /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_only
  • PUT /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 / nested data-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 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

html
<!-- 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

html
<!-- 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

html
<!-- 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

html
<!-- 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

text
// 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

text
<!-- 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.

python
"""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).

NodeAttrConstraint
[data-grid-edit]data-edit-kindone of ['text', 'date', 'time', 'number', 'bool', 'select']
[data-grid-edit]data-edit-valuepresent (any value)
[data-grid-edit]data-edit-labelpresent (any value)
[data-grid-edit]data-edit-optionsJSON [[value, label], …]; required when {'data-edit-kind': 'select'}

Ingestion model GridEditCell

Server-side shape before render — one normalisation boundary for producers.

FieldTypeRequired
colstringrequired
kindstring ∈ ['text', 'date', 'time', 'number', 'bool', 'select']required
valuestringrequired
labelstringrequired
optionsarray | nulloptional

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.

python
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).

Fix the door

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).

python
@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).

NodeAttrConstraint
[data-grid-col-toggle]data-grid-col-togglepresent (any value)
[data-col]data-colpresent (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.

python
"""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).

NodeAttrConstraint
[data-grid-resize]data-grid-resizepresent (any value)
col[data-col], [data-col]data-colpresent (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.

python
"""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.