from fasthtml.common import Button
from cjm_fasthtml_lucide_icons.factory import lucide_icon
from cjm_fasthtml_design_system.buttons import buttons
from cjm_fasthtml_design_system.icons import icons
# Multi-trigger pattern (review / segment-align). The consumer wraps two
# disclosure triggers in a Div with step_header_band.trailing applied.
settings_trigger = Button(
lucide_icon("settings-2", size=icons.ghost_button),
cls=buttons.modal_disclosure,
)
hints_trigger = Button(
lucide_icon("keyboard", size=icons.ghost_button),
cls=buttons.modal_disclosure,
)
render_step_header_band(
title="Segment & Align",
subtitle="Decompose text into segments and align with audio timestamps.",
trailing=Div(
settings_trigger,
hints_trigger,
cls=step_header_band.trailing,
),
id="example-step-header-band",
)Step Header Band
cjm-fasthtml-design-system/nbs/step_chrome.ipynb); the rendering helper that composes those slots into FT lives here.
Where this lives, and why
Per the three-layer architecture (see cjm-fasthtml-design-system bootstrap plan, §8.6, and the multi-workflow design-system doc’s Library Architecture section), the step header band convention spans two libraries:
cjm-fasthtml-design-system/nbs/step_chrome.ipynbowns the anatomy recipe — CSS class strings only. Thestep_header_bandfrozen-dataclass instance carries four slots:layout(flex row, items.start vertical alignment, justify.between horizontal distribution, bottom-margin),title(responsivetext-2xl→lg:text-3xl+font-bold),subtitle(V13secondary+ responsivehidden→lg:block),trailing(consumer-opt-in flex wrapper for multi-trigger groups).- This notebook owns the rendering helper — an FT-returning
render_step_header_band(title, subtitle, trailing, id)that composes V2 anatomy slots into aDivwith the canonical child ordering (title block on the left, optional trailing on the right).
The split is load-bearing: recipes return strings (design-system layer); layout primitives return FT (app-core layer). V12 (render_confirm_modal) and V8 (render_empty_state) established this pattern; V2 follows it.
Why this helper is the recommended consumer entry point. Consumers wanting fine-grained DOM control can compose V2 anatomy slots directly from cjm_fasthtml_design_system.step_chrome. Consumers wanting the canonical default — H2 title (responsive), optional P subtitle (R3 density-shift hides at M1/M2), optional right-side trailing element, V2 layout composed correctly — call this helper. Both paths consume the same anatomy; the helper is the recommended starting point because it locks in the canonical structural ordering (title block first, trailing second) which is the load-bearing visual contract V2 documents but doesn’t enforce structurally.
Why the parameters look the way they do.
titleis required; everything else is optional. The minimal step header band isrender_step_header_band("Verify")— just the H2 title inside the layout Div.subtitleis an optional string. When supplied, the helper emits a P element below the H2 with V2’s responsivesubtitleclass (text-base-content/70 hidden lg:block— hidden at M1/M2, visible at M3+). When omitted, no P element is rendered. R3 density-shift behavior is baked into the slot class string; consumers don’t toggle visibility per-mode at the call site.trailingaccepts any FT, not a list or tuple. Consumers wrapping multiple triggers (review’s settings + keyboard-hints pair, segment-align’s zone-aware OOB-wrapped settings + keyboard-hints) compose their own Div withstep_header_band.trailingas the class. Consumers passing a single FT (source-selects’ single keyboard-hints trigger) skip the wrapping Div entirely. The helper does not auto-wrap because that would force a layout decision (always use the trailing flex wrapper) onto consumers whose single-trigger case doesn’t need it.idis the outer Div id, exposed for HTMX targeting (hx_swap_oob, etc.) and consumer ID-schema integration.
Why the title block (H2 + optional P) is wrapped in an inner Div. The outer Div carries step_header_band.layout with justify.between, distributing its flex children left vs right. Without an inner wrapper, an H2 + P + trailing combination would be three flex children, not two — justify.between would space them evenly across the band rather than putting the title-block on the left and trailing on the right. The inner Div groups H2 + P as one flex child; the helper always emits it (even in the title-only case where it wraps just the H2) for layout-structure consistency across all call shapes.
Why LG5 responsive compression is invisible at the helper API. V2’s responsive behavior is encoded entirely in the class strings (step_header_band.title carries both text-2xl and lg:text-3xl; step_header_band.subtitle carries both hidden and lg:block). The FT helper doesn’t expose a compressed_at parameter, a mode-detection hook, or any other knob. Per DR5 (mode boundaries are layout shifts; within a mode, only density may shift), the compression snaps at the lg: breakpoint (1024px, M2/M3 boundary) automatically via CSS — no runtime mode-detection or JS coordination needed. This is the design-system’s first responsive role catalog and the helper is the simplest possible consumer of it.
For the convention-level rationale (why responsive snaps at lg:, why subtitle hides at M1/M2 vs shrinks, why trailing stays icon-only across all modes, why the four-slot anatomy and not more), see cjm-fasthtml-design-system/nbs/step_chrome.ipynb. This helper deliberately doesn’t restate that rationale — the catalog notebook is canonical.
render_step_header_band
Render a canonical step-level header band per V2 anatomy. The minimal call is render_step_header_band("Verify") — just the H2 title inside the layout Div. Adding subtitle populates V2’s subtitle slot (with R3 density-shift — hidden at M1/M2 via CSS); adding trailing puts an FT on the right side (a single trigger, a multi-trigger wrapping Div, a badge, etc.).
render_step_header_band
def render_step_header_band(
title:str, # Step title text (H2)
subtitle:Optional=None, # Optional subtitle text (P); hidden at M1/M2 via V2's responsive subtitle class
trailing:Optional=None, # Optional right-side FT (single trigger, pre-wrapped multi-trigger Div, badge, etc.)
id:Optional=None, # Optional outer Div id (for HTMX targeting or consumer ID-schema integration)
)->FT: # Div implementing V2 step header band anatomy
Render a canonical step-level header band per V2 anatomy (title block on the left, optional trailing on the right). Responsive compression at the M2/M3 boundary is encoded in the V2 slot class strings; no per-call knobs needed.
Example
Tests — V2 anatomy + DOM-ordering regression guards
Each assertion below maps to one load-bearing decision in the V2 + V12 architecture: V2 anatomy slots composed correctly, optional slots omitted when not supplied, DOM ordering invariant (title block before trailing; H2 before P inside title block) holds across all call shapes, LG5 responsive class strings propagate to output.
from fasthtml.common import to_xml, Button, A
from cjm_fasthtml_lucide_icons.factory import lucide_icon
from cjm_fasthtml_design_system.buttons import buttons
from cjm_fasthtml_design_system.icons import icons
from cjm_fasthtml_design_system.step_chrome import step_header_band
# ── Slot composition guards ───────────────────────────────────────────
# Render the canonical full step header band and verify each V2 slot's
# class composition lands in the output. Assert against V2 anatomy via
# the `step_header_band` instance directly (not hardcoded class strings)
# so future V2 value-tuning sessions (per CD3 two-evidence discipline)
# don't break these tests — the wire is V2-anatomy-as-contract.
#
# Test text deliberately uses ASCII-only sentinel strings (no ampersand,
# no HTML-encoding-sensitive chars) so substring assertions match raw
# rendered text without needing HTML-entity normalization.
SAMPLE_TITLE = "Test Step Title"
SAMPLE_SUBTITLE = "Test subtitle for header band assertion."
trailing_div = Div(
Button(lucide_icon("settings-2", size=icons.ghost_button), cls=buttons.modal_disclosure),
Button(lucide_icon("keyboard", size=icons.ghost_button), cls=buttons.modal_disclosure),
cls=step_header_band.trailing,
)
full = render_step_header_band(
title=SAMPLE_TITLE,
subtitle=SAMPLE_SUBTITLE,
trailing=trailing_div,
id="test-header-band",
)
full_html = to_xml(full)
# V2 layout applied to outer Div (flex row + items.start + justify.between + mb-4)
assert step_header_band.layout in full_html, (
f"V2 contract: outer Div must carry step_header_band.layout class. "
f"Got HTML: {full_html[:200]!r}"
)
# V2 title slot applied to H2
assert step_header_band.title in full_html, (
f"V2 contract: H2 must carry step_header_band.title class. "
f"Got HTML: {full_html[:200]!r}"
)
# V2 subtitle slot applied to P (when subtitle supplied)
assert step_header_band.subtitle in full_html, (
f"V2 contract: subtitle P must carry step_header_band.subtitle class when subtitle supplied."
)
# V2 trailing slot applied to wrapper Div (forwarded verbatim from consumer)
assert step_header_band.trailing in full_html, (
f"V2 contract: consumer-supplied trailing wrapper must reach output verbatim."
)
# Text content rendered
assert SAMPLE_TITLE in full_html
assert SAMPLE_SUBTITLE in full_html
# Outer Div id forwarded
assert 'id="test-header-band"' in full_html
# ── LG5 responsive composition guards ─────────────────────────────────
# V2 is the design-system's first responsive role catalog. The helper
# must propagate the responsive class strings unchanged to the output —
# a regression that stripped lg:text-3xl or lg:block would silently
# change rendering at M3+ without breaking the per-slot guards above.
assert "text-2xl" in full_html and "lg:text-3xl" in full_html, (
"V2/LG5 contract: title H2 must carry BOTH the base size (text-2xl) "
"AND the canonical override (lg:text-3xl). Dropping either half "
"silently changes rendering at one mode tier."
)
assert "hidden" in full_html and "lg:block" in full_html, (
"V2/LG5 contract: subtitle P must carry BOTH the base visibility "
"(hidden, M1/M2) AND the canonical override (lg:block, M3+). "
"Dropping either half silently changes visibility at one mode tier."
)
# ── DOM-ordering invariant ────────────────────────────────────────────
# V2 child ordering: title block on the LEFT, trailing on the RIGHT.
# Inside the title block: H2 BEFORE P. A refactor that reordered these
# would silently break the canonical header-band appearance at every
# consumer site.
title_pos = full_html.find(step_header_band.title) # H2 position
subtitle_pos = full_html.find(step_header_band.subtitle) # P position
trailing_pos = full_html.find(step_header_band.trailing) # trailing wrapper position
assert 0 <= title_pos < subtitle_pos < trailing_pos, (
f"V2 DOM-ordering contract: title (H2) -> subtitle (P) -> trailing. "
f"Got positions: title={title_pos}, subtitle={subtitle_pos}, trailing={trailing_pos}."
)
# ── Optional-slot omission guards ─────────────────────────────────────
# When a slot is not requested, the helper must not emit its class or
# its element. Catches accidental "always render an empty P" or "always
# render an empty trailing Div" regressions.
title_only_html = to_xml(render_step_header_band(title="Verify"))
# Title is always present
assert step_header_band.title in title_only_html
assert step_header_band.layout in title_only_html
# Other slots are NOT present
assert step_header_band.subtitle not in title_only_html, (
"Subtitle slot must be omitted from output when subtitle param is None."
)
assert step_header_band.trailing not in title_only_html, (
"Trailing wrapper class must be absent from output when trailing param is None."
)
# Specifically no P element should be rendered (matching subtitle omission)
assert "<p" not in title_only_html.lower(), (
"No P element must render when subtitle is None. "
f"Got HTML: {title_only_html!r}"
)
# ── Title-and-trailing only (no subtitle) ─────────────────────────────
# Source-select-style single-trigger pattern: title + hints_trigger,
# no subtitle in compressed contexts.
single_trigger_html = to_xml(render_step_header_band(
title="Select Source Material",
trailing=Button(lucide_icon("keyboard", size=icons.ghost_button), cls=buttons.modal_disclosure),
))
assert step_header_band.title in single_trigger_html
assert step_header_band.subtitle not in single_trigger_html
# trailing element forwarded (we don't apply step_header_band.trailing to a
# single-FT trailing — only the consumer-supplied Div carries that class
# when the consumer chooses to wrap)
assert "btn-ghost" in single_trigger_html # V1 modal_disclosure is btn-xs + btn-ghost
# Ordering: title before trailing
assert single_trigger_html.find(step_header_band.title) < single_trigger_html.find("btn-ghost")
# ── id omission guard ─────────────────────────────────────────────────
# When id is not supplied, the outer Div has no id attribute.
no_id_html = to_xml(render_step_header_band(title="x"))
assert "id=" not in no_id_html, (
"Outer Div must not carry an id attribute when id param is None. "
f"Got HTML: {no_id_html!r}"
)
# ── trailing accepts arbitrary FT (not just Div) ──────────────────────
# V2 doesn't constrain the trailing slot shape; the helper forwards
# whatever FT the caller passes. Single button, anchor, badge, Span,
# pre-wrapped Div — all valid.
anchor_trailing_html = to_xml(render_step_header_band(
title="Documents",
trailing=A("View all", href="/all", cls=buttons.soft_utility),
))
assert "View all" in anchor_trailing_html
assert 'href="/all"' in anchor_trailing_html
# ── Title block grouping invariant ────────────────────────────────────
# Title block (H2 + optional P) must be wrapped in an inner Div so the
# outer flex layout has exactly two children (title block + trailing),
# not three (H2 + P + trailing). Without the wrapper, justify.between
# would space H2, P, and trailing evenly instead of putting title-block
# on the left and trailing on the right.
#
# Verified by counting Div elements: outer Div + inner title-block Div +
# the trailing Div (when supplied) = at least 3 opening Div tags.
div_count = full_html.lower().count("<div")
assert div_count >= 3, (
f"V2 contract: full header band must have at least 3 Div elements "
f"(outer + title block + trailing). Got {div_count} in: {full_html[:300]!r}"
)
# For title-only case (no subtitle, no trailing) — at least 2 Divs
# (outer + title-block wrapper around the H2).
title_only_div_count = title_only_html.lower().count("<div")
assert title_only_div_count >= 2, (
f"V2 contract: title-only header band must still wrap H2 in an inner "
f"title-block Div (for flex-child consistency). Got {title_only_div_count} "
f"Divs in: {title_only_html!r}"
)
print("render_step_header_band tests passed!")render_step_header_band tests passed!