Step Header Band

Step-level header band rendering helper — the title + optional subtitle on the left, optional trailing slot on the right composition that introduces each workflow step inside StepFlow’s per-step render. Composes V2 anatomy slots into a single FT-returning helper. Codifies Convention V2 (Step header band anatomy) as running code per the V12 convention/implementation split: V2 anatomy stays design-system-owned (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.ipynb owns the anatomy recipe — CSS class strings only. The step_header_band frozen-dataclass instance carries four slots: layout (flex row, items.start vertical alignment, justify.between horizontal distribution, bottom-margin), title (responsive text-2xllg:text-3xl + font-bold), subtitle (V13 secondary + responsive hiddenlg: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 a Div with 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.

  • title is required; everything else is optional. The minimal step header band is render_step_header_band("Verify") — just the H2 title inside the layout Div.
  • subtitle is an optional string. When supplied, the helper emits a P element below the H2 with V2’s responsive subtitle class (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.
  • trailing accepts 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 with step_header_band.trailing as 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.
  • id is 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

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",
)

Segment & Align

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!