Generic empty-state component — a centered icon-above-title-above-detail composition optionally including a call-to-action, composing V8 anatomy slots into a single rendering helper. Codifies Convention V8 (Empty-state anatomy) as running code per the V12 convention/implementation split: V8 anatomy stays design-system-owned (cjm-fasthtml-design-system/nbs/empty_states.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 empty-state convention spans two libraries:
cjm-fasthtml-design-system/nbs/empty_states.ipynb owns the anatomy recipe — CSS class strings only. The empty_states frozen-dataclass instance carries four slots: wrapper (flex column, centered, padded, fills available flex space), icon (V13 subtle opacity), title (V13 secondary emphasis at font_size.lg, centered), detail (V13 tertiary emphasis at font_size.sm, centered).
This notebook owns the rendering helper — an FT-returning render_empty_state(message, detail, icon_name, cta, id) that composes V8 anatomy slots into a Div with the canonical child ordering (icon → title → detail → CTA).
The split is load-bearing: recipes return strings (design-system layer); layout primitives return FT (app-core layer). V12 (render_confirm_modal) established this pattern; V8 follows it.
Why this helper is the recommended consumer entry point. Consumers who want fine-grained DOM control can compose V8 anatomy slots directly from cjm_fasthtml_design_system.empty_states. Consumers who want the canonical default — centered icon-above-title-above-detail, optional CTA, all V8/V11/V13 composition baked in — call this helper. Both paths consume the same anatomy; the helper is the recommended starting point because it locks in the canonical child ordering (icon-first, CTA-last) which is the load-bearing visual contract V8 documents but doesn’t enforce structurally.
Why the parameters look the way they do.
message is required; everything else is optional. The minimal empty state is render_empty_state("No items available") — just the title slot inside the wrapper.
icon_name is a string (Lucide icon name like "inbox" or "file-plus"), not a pre-built FT. The helper internally calls lucide_icon(icon_name, size=icons.empty_state, cls=empty_states.icon) so V8’s icon-size + opacity contract holds. Consumers needing alternate sizes or non-Lucide icons compose against V8 anatomy directly.
cta accepts any FT, not just a Button. Most CTAs are Button(label, cls=buttons.primary_action) but the helper doesn’t constrain the shape — an <A> link styled as a button, a small button cluster, etc., all work.
id is the outer Div id, exposed for HTMX targeting (hx_swap_oob, etc.) and consumer ID-schema integration (e.g., card-stack passing ids.card_stack_empty).
For the convention-level rationale (why V13 secondary for title, why V13 tertiary for detail, why subtle for icon, why gap(3) on the wrapper), see cjm-fasthtml-design-system/nbs/empty_states.ipynb. This helper deliberately doesn’t restate that rationale — the catalog notebook is canonical.
render_empty_state
Render a canonical empty-state component per V8 anatomy. The minimal call is render_empty_state("No items available") — just the title inside the wrapper. Adding icon_name, detail, or cta populates the corresponding V8 slots in canonical order (icon → title → detail → CTA).
render_empty_state
def render_empty_state( message:str, # Primary empty-state message (single sentence in the title slot) detail:Optional=None, # Optional supporting line rendered below the title in the detail slot icon_name:Optional=None, # Optional Lucide icon name; rendered at V11 icons.empty_state size with V8 icon opacity cta:Optional=None, # Optional call-to-action element appended as the last wrapper child (typically Button with V1 buttons.primary_action)id:Optional=None, # Optional outer Div id (for HTMX targeting or consumer ID-schema integration))->FT: # Div implementing V8 empty-state anatomy
Render a canonical empty-state component per V8 anatomy (icon → title → detail → CTA child ordering, all slots optional except message).
Example
from fasthtml.common import Buttonfrom cjm_fasthtml_design_system.buttons import buttons# Canonical full empty state: icon + title + detail + CTArender_empty_state( message="No transcription sources selected", detail="Add a source from the browser to begin.", icon_name="inbox", cta=Button("Add a source", cls=buttons.primary_action),id="example-empty-state",)
Each assertion below maps to one load-bearing decision in the V8 + V12 architecture: V8 anatomy slots composed correctly, optional slots omitted when not supplied, child ordering invariant (icon-first, CTA-last) holds across all combinations.
from fasthtml.common import to_xml, Buttonfrom cjm_fasthtml_design_system.buttons import buttonsfrom cjm_fasthtml_design_system.empty_states import empty_states# ── Slot composition guards ───────────────────────────────────────────# Render the canonical full empty state and verify each V8 slot's class# composition lands in the output. We assert against V8 anatomy via the# `empty_states` instance directly (rather than hardcoded class strings)# so future V8 value-tuning sessions (per CD3 two-evidence discipline)# don't break these tests — the wire is V8-anatomy-as-contract.full = render_empty_state( message="No transcription sources selected", detail="Add a source from the browser to begin.", icon_name="inbox", cta=Button("Add a source", cls=buttons.primary_action),id="test-empty",)full_html = to_xml(full)# V8 wrapper applied to outer Div (flex column + grow + center)assert empty_states.wrapper in full_html, (f"V8 contract: outer Div must carry empty_states.wrapper class. "f"Got HTML: {full_html[:200]!r}")# V8 title slot applied to title Passert empty_states.title in full_html, (f"V8 contract: title P must carry empty_states.title class. "f"Got HTML: {full_html[:200]!r}")# V8 detail slot applied to detail P (when provided)assert empty_states.detail in full_html, (f"V8 contract: detail P must carry empty_states.detail class when detail supplied.")# V8 icon slot applied to icon element (when icon_name provided)# icon slot = V13 text_tiers.subtle; pair with V11 icons.empty_state sizeassert empty_states.icon in full_html, (f"V8 contract: icon element must carry empty_states.icon class when icon_name supplied.")# Message text renderedassert"No transcription sources selected"in full_html# Detail text renderedassert"Add a source from the browser to begin."in full_html# CTA forwarded into the output verbatimassert"Add a source"in full_htmlassert"btn-primary"in full_html# Outer Div id forwardedassert'id="test-empty"'in full_html# ── Child-ordering invariant (icon → title → detail → CTA) ─# Load-bearing visual contract: icon always first if present, CTA always# last if present. A refactor that reordered children would silently break# the canonical empty-state appearance at every consumer site.# Use V8 slot class strings as positional markers in the serialized HTML.icon_pos = full_html.find(empty_states.icon)title_pos = full_html.find(empty_states.title)detail_pos = full_html.find(empty_states.detail)cta_pos = full_html.find("btn-primary")assert0<= icon_pos < title_pos < detail_pos < cta_pos, (f"V8 child-ordering contract: icon → title → detail → CTA. "f"Got positions: icon={icon_pos}, title={title_pos}, detail={detail_pos}, "f"cta={cta_pos}.")# ── Optional-slot omission guards ─────────────────────────────────────# When a slot is not requested, the helper must not emit its class string.# Catches accidental "always render an empty Span" or "default to empty# detail line" regressions.title_only_html = to_xml(render_empty_state(message="No items available"))# Title is always presentassert empty_states.title in title_only_html# Other slots are NOT presentassert empty_states.detail notin title_only_html, ("Detail slot must be omitted from output when detail param is None.")assert empty_states.icon notin title_only_html, ("Icon slot must be omitted from output when icon_name param is None.")# ── Title-and-icon-only (no detail, no CTA) ───────────────────────────icon_title_html = to_xml(render_empty_state( message="No items available", icon_name="inbox",))assert empty_states.icon in icon_title_htmlassert empty_states.title in icon_title_htmlassert empty_states.detail notin icon_title_html# Ordering still holds: icon before title.assert icon_title_html.find(empty_states.icon) < icon_title_html.find(empty_states.title)# ── id omission guard ─────────────────────────────────────────────────# When id is not supplied, the outer Div has no id attribute.no_id_html = to_xml(render_empty_state(message="x"))assert"id="notin no_id_html, ("Outer Div must not carry an id attribute when id param is None. "f"Got HTML: {no_id_html!r}")# ── CTA accepts arbitrary FT, not just Button ─────────────────────────# V8 doesn't codify a CTA role; the helper forwards whatever element the# caller passes. A regression that constrained cta to Button-only would# break legitimate use cases (anchor-styled-as-button, button clusters).from fasthtml.common import Acta_anchor_html = to_xml(render_empty_state( message="No items", cta=A("Browse all", href="/all", cls=buttons.primary_action),))assert"Browse all"in cta_anchor_htmlassert'href="/all"'in cta_anchor_htmlprint("render_empty_state tests passed!")