Pagination htmx Endpoint
The footer beneath a data table — a summary and page buttons. Each button hx-gets a page into the list body (an Exchange, not a widget).
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="hm-stack hm-measure-lg">
<div id="hm-pag-body" class="hm-pag-list">
<div class="hm-pag-row">INV-001 · Acme</div>
<div class="hm-pag-row">INV-002 · Globex</div>
<div class="hm-pag-row">INV-003 · Initech</div>
</div>
<div class="pagination" data-pagination data-grid-pagination data-grid-total="42" aria-label="Pagination">
<span class="pagination-summary"><span class="bulk-summary-selected"><span data-bulk-count-target>0</span> of 42 selected</span><span class="bulk-summary-rows">42 rows</span></span>
<div class="pagination-pages"><button class="pagination-page" disabled aria-label="Previous page">‹</button><button class="pagination-page is-current" aria-current="page">1</button><button class="pagination-page" hx-get="/mock/pagination/2" hx-target="#hm-pag-body" hx-swap="innerHTML">2</button><button class="pagination-page" hx-get="/mock/pagination/3" hx-target="#hm-pag-body" hx-swap="innerHTML">3</button><span class="pagination-ellipsis" aria-hidden="true">…</span><button class="pagination-page" hx-get="/mock/pagination/9" hx-target="#hm-pag-body" hx-swap="innerHTML">9</button><button class="pagination-page" hx-get="/mock/pagination/2" hx-target="#hm-pag-body" hx-swap="innerHTML" aria-label="Next page">›</button></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/{region}?page={n}&page_size={size} | a page button, on click | the list body fragment for page n — the rows the region renders, with the current-page button marked `is-current` + `aria-current='page'` | innerMorph of the region's body (`#{region}-body`) | body_only | loading 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}?page={n}&page_size={size}→ innerMorph of the region's body (`#{region}-body`) · 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 Pagination. Match the exchange envelope; dual-lock still applies to interior markup.
GET /app/{region}?page={n}&page_size={size} · envelope=body_only
Correct response for body_only into #{region}-body (innerHTML / innerMorph). Wrong: re-wrapping the slot.
Do — correct response body
<!-- envelope=body_only → rows/interior only into #{region}-body -->
<div class="dz-list-row">INV-041 · Acme</div>
<div class="dz-list-row">INV-042 · Globex</div>
<!-- optional OOB footer — not a second region chrome -->
Don’t — violates body_only
<!-- WRONG: re-wraps the slot / nests data-dz-region under innerMorph -->
<div id="invoice_queue-body" data-dz-region data-dz-region-name="invoice_queue">
<div class="dz-list-row">INV-041 · Acme</div>
</div>
How to use it
No extended guidance authored yet — start from Copy this and the dependency chips (Primitive = markup only; controller = load listed JS; Endpoint = implement Server exchange).
Seams
- copy the partial under Copy this; keep root class and data-* modifiers so the CSS/JS bundle matches
- implement Server exchange endpoints; return HTML fragments, not JSON
- satisfy the DOM contract tables (CI stop-ship)
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/pagination.py
Required in the DOM: root [data-pagination] (part pagination). 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-pagination] | data-pagination | present (any value) |
[data-pagination] | data-grid-pagination | present (any value) |
[data-pagination] | data-grid-total | present (any value) |
Ingestion model Pagination
Server-side shape before render — one normalisation boundary for producers.
| Field | Type | Required |
|---|---|---|
total | integer | optional |
pages_html | string | optional |
rows_label | 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.
def render(p: Pagination) -> str:
"""Model → pagination footer. Empty total + empty pages → \"\"."""
if p.total <= 0 and not p.pages_html:
return ""
if not p.pages_html:
return ""
label = p.rows_label or ("row" if p.total == 1 else "rows")
return (
f'<div class="dz-pagination" data-dz-pagination data-dz-grid-pagination '
f'data-dz-grid-total="{p.total}">'
f'<span class="dz-pagination-summary">'
f'<span class="dz-bulk-summary-selected">'
f"<span data-dz-bulk-count-target>0</span> of {p.total} selected"
f"</span>"
f'<span class="dz-bulk-summary-rows">{p.total} {label}</span>'
f"</span>"
f'<div class="dz-pagination-pages">{p.pages_html}</div>'
f"</div>"
)
Live output of render(EXEMPLARS[0]) — fixture markup the dual-lock validates (sample field values only).
Notes
data-pagination (contracts/pagination.py) plus data-grid-pagination / data-grid-total for grid selection. Production swaps use innerMorph of the region body (#{region}-body) so selection and focus can survive page changes. Each page button carries its own hx-get; leftover-honest include_closed / as_of ride that hx-get (cycle 2175) — dropping them invents open-only / current after a page click. Rest-state gallery omits them (oral #33). Here the gallery mock approximates with innerHTML into #hm-pag-body.Source files
Canonical registration in the registry. No dedicated controller — CSS for this part lives in the layered bundle.
site/registry.py · contracts/pagination.py