Bar chart data Primitive
Label / track / value rows — the workhorse categorical chart, server-computed and scope-safe.
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="bar-chart-region hm-measure-lg" data-bar-chart>
<div class="bar-chart-bars">
<div class="bar-chart-row">
<span class="bar-chart-label">API</span>
<div class="bar-chart-track">
<div class="bar-chart-fill" style="width: 84%"></div>
</div>
<span class="bar-chart-value">126</span>
</div>
<div class="bar-chart-row">
<span class="bar-chart-label">Dashboard</span>
<div class="bar-chart-track">
<div class="bar-chart-fill" style="width: 56%"></div>
</div>
<span class="bar-chart-value">84</span>
</div>
<div class="bar-chart-row">
<span class="bar-chart-label">Billing</span>
<div class="bar-chart-track">
<div class="bar-chart-fill" style="width: 23%"></div>
</div>
<span class="bar-chart-value">35</span>
</div>
</div>
</div>Server exchange
This Hyperpart has no server exchange — it is presentation or client chrome only. htmx does not issue a request on this part's behalf. If you put an affordance (hx-*) on a control that uses this markup, that action's exchange belongs to the action, not this part. See Swap contract for host-owned envelopes.
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.
No host HTMX exchange on this part — presentation or client chrome only. exchange envelope: n/a.
If a host wraps this markup in hx-*, that host owns the swap contract (sole identity + envelope). Prefer innerMorph / outerMorph for stable slots; replacement for flash; body-only responses under inner swaps.
Envelope response examples
This part has no owned exchange (envelope n/a). If a host adds hx-*, that host’s envelope applies — typically body_only:
<!-- Host wraps this presentation part with hx-* (host owns envelope) -->
<!-- Prefer: hx-swap="innerMorph" hx-target="#panel-body" -->
<!-- Server returns body_only interior for #panel-body -->
<div class="dz-stack">content…</div>
Do not re-own the slot:
<!-- WRONG: server returns the presentation root with a new id every poll -->
<div id="bar-chart-root" data-dz-region>…</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
- no Server exchange on this part — pure presentation or client chrome
- 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/bar_chart.py
Required in the DOM: root [data-bar-chart] (part bar-chart). 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-bar-chart] | data-bar-chart | present (any value) |
Ingestion model BarChartRow
Server-side shape before render — one normalisation boundary for producers.
| Field | Type | Required |
|---|---|---|
label | string | required |
count | integer | optional |
width_pct | integer | optional |
label_html | 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(chart: BarChart) -> str:
"""Model → bar chart region."""
if not chart.rows:
return '<div class="dz-bar-chart-region" data-dz-bar-chart></div>'
rows_html = "".join(
f'<div class="dz-bar-chart-row">'
f'<span class="dz-bar-chart-label">'
f"{(row.label_html if row.label_html.strip() else html.escape(row.label))}"
f"</span>"
f'<div class="dz-bar-chart-track">'
f'<div class="dz-bar-chart-fill" '
f'style="width: {max(0, min(100, row.width_pct))}%"></div>'
f"</div>"
f'<span class="dz-bar-chart-value">{row.count}</span>'
f"</div>"
for row in chart.rows
)
return (
f'<div class="dz-bar-chart-region" data-dz-bar-chart>'
f'<div class="dz-bar-chart-bars">{rows_html}</div>'
f"</div>"
)
Live output of render(EXEMPLARS[0]) — fixture markup the dual-lock validates (sample field values only).
Notes
data-bar-chart (contracts/bar_chart.py). In Dazzle every bar chart compiles to ONE scope-aware GROUP BY — the bucket list and the counts come from the same query, so they cannot disagree (the #847-class bug this design retired). Fill widths are server-computed percentages of the max bucket.Source files
Canonical registration in the registry. No dedicated controller — CSS for this part lives in the layered bundle.
site/registry.py · contracts/bar_chart.py