Pagination

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.

INV-001 · Acme
INV-002 · Globex
INV-003 · Initech

Copy this

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

RequestTriggerResponse fragmentSwapEnvelopeStates
GET /app/{region}?page={n}&page_size={size}a page button, on clickthe 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_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/{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 / 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 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

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

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

NodeAttrConstraint
[data-pagination]data-paginationpresent (any value)
[data-pagination]data-grid-paginationpresent (any value)
[data-pagination]data-grid-totalpresent (any value)

Ingestion model Pagination

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

FieldTypeRequired
totalintegeroptional
pages_htmlstringoptional
rows_labelstringoptional

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.

python
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

Dual-lock root is 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