# Step Header Band


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

## 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-2xl` →
  `lg:text-3xl` + `font-bold`), `subtitle` (V13 `secondary` + responsive
  `hidden` → `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 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

``` python

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

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

``` html
<div id="example-step-header-band" class="flex items-start justify-between mb-4">
  <div>
    <h2 class="text-2xl lg:text-3xl font-bold">Segment &amp; Align</h2>
    <p class="text-base-content/70 hidden lg:block">Decompose text into segments and align with audio timestamps.</p>
  </div>
  <div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-full h-full"><path d="M14 17H5"></path><path d="M19 7h-9"></path><circle r="3" cx="17" cy="17"></circle><circle r="3" cx="7" cy="7"></circle></svg></button><button class="btn btn-ghost btn-xs"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-full h-full"><path d="M10 8h.01"></path><path d="M12 12h.01"></path><path d="M14 8h.01"></path><path d="M16 12h.01"></path><path d="M18 8h.01"></path><path d="M6 8h.01"></path><path d="M7 16h10"></path><path d="M8 12h.01"></path><rect width="20" height="16" x="2" y="4" rx="2"></rect></svg></button>  </div>
</div>
```

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

``` python
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!
