Card-Safety Invariants¶
Canonical spec for what "a card" means in Dazzle templates and the eight invariants every card-bearing surface must satisfy. Extracted from the #794 post-mortem — a single bug that shipped three times before the underlying class of risk was fully named.
This document is the contract the shape-nesting and duplicate-title scanners enforce. A new region template, new dashboard layout, or new fragment primitive that violates an invariant here is a regression — CI will fail. When you're tempted to change one of these rules, update this doc first and make the reasoning explicit.
Enforcement moved to the typed Fragment substrate (post-#1042)
The invariants below are unchanged and load-bearing. But several Enforcement
bullets were written for the Jinja era and name artefacts that no longer exist —
src/dazzle/page/templates/.../*.html, the region_card macro, _fetch_region_html
in workspace_rendering.py, and the test_template_html.py /
TestDashboardRegionCompositeShapes test classes. Jinja2 was retired in #1042
(v0.67.92, ADR-0023); rendering now goes
through the typed Fragment substrate. The current gate is:
src/dazzle/testing/ux/contract_checker.py (the scanners) run on the stitched
post-HTMX composite by tests/unit/test_htmx_workspace_composite.py, with
HtmxClient.get_workspace_composite() driving dazzle ux verify --contracts.
Read the Jinja-era specifics below as the historical shape of the gate, not current
file paths.
Background: the #794 class of risk¶
Symptom: a dashboard card shows a second card inside it, often with the same title printed twice. User sees "a card within a card."
Root cause, all variants: two layers of the rendering pipeline each independently decide to emit card chrome (rounded corners + border + background) and/or the card title. Nothing in the pipeline was asserting that exactly one layer is responsible for each.
Why it kept shipping: each isolated template looked fine on its own. The bug only appeared when the dashboard slot and the HTMX- loaded region fragment were concatenated in a real browser. Our QA was testing each layer alone. Three fix attempts (2e9ca0cc, b5e3ef85, v0.57.36) all addressed real sub-variants — wrapper chrome, grid-item chrome, macro chrome — but the ones they missed were only visible post-composite.
The remedy is structural:
- One layer owns card chrome: the dashboard slot.
- One layer owns the card title: the dashboard slot.
- Everything else renders bare content.
- Tests assert the composite, not the layers.
What counts as "card chrome"¶
Primary guarantee is structural, not scanner-based. Since ADR-0049 the
typed substrate is THE render path, and the Card-in-Card regression is
enforced by construction: Card.__post_init__ (render/fragment/primitives/containers.py)
raises CardSafetyError if a Card contains a Card in any slot; Region
has no title field by design; Toolbar.__post_init__ bans a hidden first
action. Substrate-composed DOM therefore cannot nest chrome. The HTML
scanner below is defence-in-depth for the raw-HTML bypass paths that skip
the primitives — adapter section.body passthrough (cohort/timeline/entity-card
regions), custom_renderer, and project-authored region bodies.
A DOM element is card chrome iff it is a block container AND either:
- (semantic — current substrate) its class list carries the exact
dz-cardtoken — the card surface emitted by both the dashboard slot (<article class="dz-card">) and the standalone Card primitive (<div class="dz-card dz-card--border-* …">). Matched exactly, so thedz-card-wrapperpositioner anddz-card-header/-body/-title/__headersub-parts do NOT count (they would self-nest against their own surface); or - (legacy — pre-substrate Jinja era) a
rounded-*class (any scale, incl. arbitraryrounded-[6px], side-scopedrounded-t-md) and a full border (border,border-<shade>,border-<color>) that is NOT side-scoped (border-l-*/-r-*/-t-*/-b-*/-x-*/-y-*are accents, not card edges).
HM-convergence note. The legacy Tailwind-utility path is migration debt. The house direction (2026-07-08) is to delegate all frontend design into HaTchi-MaXchi; the
dz-*semantic vocabulary is the HM-aligned target, and this Tailwind path is retired by thehm-convergenceimprove lane once the Tailwind reservoir in the emitters reaches zero.
Block containers: div, article, section, aside, nav,
main, header, footer, li. Inline elements (span, button,
a, input, td) are explicitly excluded even when they carry
chrome-shaped classes — a status-badge <span> with bg-primary
rounded-full is a pill label, not a card.
A background colour alone (bg-* without border) is also not
chrome under the legacy path. Progress-bar tracks (bg-muted rounded-full),
kanban column backdrops (bg-muted/0.4 rounded-[6px]), and filled tiles are
decorative fills, not card surfaces. This rule was tightened in
v0.57.37 after the composite gate over-flagged them.
Enforcement: _has_card_chrome() in
src/dazzle/testing/ux/contract_checker.py (defence-in-depth); the
structural invariants in render/fragment/primitives/containers.py are the
primary guarantee.
The eight invariants¶
INV-1: No nested card chrome¶
Rule: No card chrome element may have an ancestor that is also card chrome.
Why: Two nested card surfaces read as a card-within-a-card. The eye can't distinguish "decorated section" from "child card" when both have the same rounded + bordered + filled edge.
Enforcement:
- Scanner: find_nested_chromes(html) in contract_checker.py
returns the list of (outer_tag, inner_tag) pairs.
- Applied during check_contract() for WorkspaceContract and
DetailViewContract.
- Tests: test_ux_contract_checker.py::TestFindNestedChromes::*
- Composite test: the nested-chrome scanner runs on the stitched composite in tests/unit/test_htmx_workspace_composite.py.
Bad shape (pre-v0.57.36):
<article class="rounded-md border bg-[hsl(var(--card))]"> ← outer chrome
<div class="bg-[hsl(var(--card))] border rounded-[6px]"> ← nested chrome ✗
chart content
</div>
</article>
Good shape:
<article class="rounded-md border bg-[hsl(var(--card))]">
<div data-dz-region id="region-x"> ← bare hook
chart content
</div>
</article>
INV-2: No duplicate title within a card¶
Rule: A card container may contain at most one heading (<h1>
through <h6>) with any given text.
Why: Duplicate titles are the symptom AegisMark reported for
794 — page.get_by_text("Grade Distribution").count() == 3. Two¶
layers both rendering the title is the structural bug; the visible shape is a card with its header text showing twice.
Enforcement:
- Scanner: find_duplicate_titles_in_cards(html) in
contract_checker.py.
- Tests: test_ux_contract_checker.py::TestFindDuplicateTitlesInCards::*
- Composite test: the duplicate-title scanner runs on the stitched composite in tests/unit/test_htmx_workspace_composite.py.
Bad shape:
<div data-card-id="card-0">
<article>
<h3>Grade Distribution</h3> ← dashboard header
<div data-dz-region>
<h3>Grade Distribution</h3> ← region renders title too ✗
...
Good shape: only the dashboard slot renders the title. Regions
never render a <h3> containing the region's title.
INV-3: Side borders are accents, not card edges¶
Rule: border-l-*, border-r-*, border-t-*, border-b-*,
border-x-*, border-y-* are permitted alongside card chrome and
do not themselves constitute chrome.
Why: Dazzle uses left-border accents for attention states (critical/warning/notice) on items inside a region. An accent stripe on a queue row or timeline event must not falsely trigger the nested-chrome gate — it's a visual cue, not a bounded surface.
Enforcement: _is_side_border_class() in
contract_checker.py. Test: test_ux_contract_checker.py::
TestFindNestedChromes::test_side_border_is_not_chrome.
INV-4: Bg-only rounded is not chrome¶
Rule: An element with rounded-* and bg-* but no full border
is not card chrome.
Why: Progress-bar tracks (bg-muted rounded-full), kanban
column backdrops (bg-muted/0.4 rounded-[6px]), and filled tiles
are decorative fills — they don't draw a card edge. Before this
tightening (v0.57.37) the scanner over-flagged them as
card-in-card, producing false positives in bar_chart and kanban
regions.
Enforcement: _has_card_chrome() requires has_full_border.
Test: test_ux_contract_checker.py::TestFindNestedChromes::
test_ignores_bg_only_rounded.
INV-5: Inline tags are never cards¶
Rule: Only block containers (div, article, section,
aside, nav, main, header, footer, li) may be classified
as chrome. Inline tags (span, button, a, input, td, etc.)
are ignored by the scanner.
Why: Pills, badges, and buttons frequently have rounded-plus-bg
classes (bg-primary rounded-full) because that's how they look
like pills. They are not cards. Scoping chrome detection to block
tags eliminates this false-positive source.
Enforcement:
_NestedChromeScanner._CARD_CANDIDATE_TAGS + _DuplicateTitle
Scanner._is_card.
INV-6: Region templates emit zero chrome¶
Rule: Every template under
src/dazzle/page/templates/workspace/regions/*.html, and the shared
macros/region_wrapper.html::region_card, must emit no card
chrome (no rounded + full border + bg on any block container).
Items inside a region (rows, tiles, events) must also be bare —
padding + optional hover bg + optional side-border accent are
fine; a full border turns them into nested cards.
Why: Regions are always rendered into a dashboard card slot which already owns chrome. Any chrome a region adds is necessarily nested chrome. This is the root cause of three-out-of-three pre-v0.57.37 follow-ups to #794.
Enforcement (current — see the status note above; Jinja-era specifics retired):
- Single render path: regions are emitted through the typed Fragment
renderer (src/dazzle/render/fragment/). If a new render site is
added, re-validate the invariant there.
- Scanner gate: the shape-nesting scanner in
src/dazzle/testing/ux/contract_checker.py runs on the stitched
post-HTMX composite via tests/unit/test_htmx_workspace_composite.py.
- Primitive gate: the region container primitive emits no card chrome —
covered by tests/unit/render/fragment/test_container_primitives.py.
INV-7: Region templates emit zero title¶
Rule: Region templates must not render a <h1>–<h6>
containing the region's title. Other headings (subsection labels,
metric group names) are permitted but must be unique text within
the card.
Why: Companion to INV-2. The dashboard card header already renders the card title from Alpine state. A region that also renders the title produces the three-copies-in-DOM symptom.
Enforcement:
- Composite test: the duplicate-title scanner runs on the stitched
composite in tests/unit/test_htmx_workspace_composite.py.
- Primitive check: region primitives never emit the card title (the
dashboard slot owns it) — there is no template title binding to grep
for, since rendering no longer goes through Jinja templates.
INV-8: Tests must run on the composite, not the layers¶
Rule: Any shape-safety test intended to gate regressions in dashboard output must feed the scanner the post-HTMX-hydration DOM — the initial page + each fetched region stitched in — not individual templates in isolation.
Why: Every pre-v0.57.37 attempt to fix #794 was validated by a passing isolated-template test. The bug lived in the concatenation. Isolated-template tests are useful for regressing sub-invariants, but the gate of record is the composite.
Enforcement:
- Composite-level: the typed Fragment renderer emits the
dashboard-slot shell + each region, and both scanners run on the
stitched composite (tests/unit/test_htmx_workspace_composite.py).
- HTTP-level: HtmxClient.get_workspace_composite(path) in
htmx_client.py follows the HTMX boot sequence and returns the
real post-hydration DOM. Used by dazzle ux verify --contracts
for every WorkspaceContract.
- Stitcher: assemble_workspace_composite(initial_html,
region_htmls) — pure function, unit-tested in
test_htmx_workspace_composite.py.
INV-9: Primary actions are reachable without pointer hover¶
Rule: Any button (or <a role="button">) whose aria-label
names a destructive/state-changing action — Remove, Delete,
Dismiss, Close, Archive, Unarchive, Disable, Deactivate, Revoke —
must not live inside an opacity-0 group-hover:opacity-100 (or
equivalent hover-only reveal) ancestor without also having a
non-hover reveal (focus-within:opacity-*, focus:opacity-*,
peer-focus:opacity-*, group-focus:opacity-*,
group-focus-within:opacity-*).
Why: Touch devices have no hover state — a hover-only reveal is permanently invisible. Keyboard users must know to move the pointer into the card before Tab can reach the action. Issue #799 shipped this exact shape on every dashboard card.
Enforcement:
- Scanner: find_hidden_primary_actions(html) in
contract_checker.py.
- Applied inside check_contract for WorkspaceContract and
DetailViewContract — same dispatch point as INV-1 / INV-2.
- Tests: test_ux_contract_checker.py::TestFindHiddenPrimaryActions::*
(10 cases covering opacity-0 detection, focus-within reveal,
always-visible, Alpine conditional skip, non-primary-action
label, link-button role, missing aria-label, button-level
opacity-0, post-fix shape pass, multiple hidden actions).
Bad shape (pre-v0.57.46):
<div class="opacity-0 group-hover:opacity-100">
<button aria-label="Remove card">×</button> <!-- invisible on touch ✗ -->
</div>
Good shape (post-v0.57.46):
<div class="opacity-60 group-hover:opacity-100 group-focus-within:opacity-100">
<button aria-label="Remove card">×</button>
</div>
Notes on the detection rule:
- Alpine conditional ancestors (x-show / x-if / x-cloak)
are treated as orchestrated reveals and skipped. A "Close panel"
button inside x-show="open" is perfectly valid.
- Non-primary-action labels (Submit, Save, Continue) are not
targets of this gate — hover-only reveal on those is a separate
design concern.
- Missing aria-label means the button can't be classified;
the gate is silent (absent accessibility-label is handled by a
separate concern).
Adding a new invariant¶
If you find a new class of card-safety regression:
- Give it a name (INV-N) and add a section here.
- Add a named test that reproduces the bad shape and verifies the
fix. Prefer a regression test inside
tests/unit/test_ux_contract_checker.py(scanner-level) ortests/unit/test_htmx_workspace_composite.py(composite-level). - Update the relevant scanner in
src/dazzle/testing/ux/contract_checker.py. - Ship.
Avoid inventing a new scanner just for one case — the existing
find_nested_chromes and find_duplicate_titles_in_cards cover the
class well. A new invariant usually tightens or relaxes the input
heuristic rather than adding new machinery.