# UI Kit — Component Reference

Parameter schema for the macro library. **252 macros** total (`components/ui.html` 250 + `components/code.html` 2). Each entry below shows the macro signature, parameter table, and a minimal usage example.

> **Authoritative source:** `src/ui_kit/templates/components/ui.html` is the single source of truth for signatures, defaults, and behavior. This document is a curated lookup; when in doubt, read the macro source. `CLAUDE.md` keeps the running inventory and decision guide.

> **v2.0 migration** documented under [v2.0 macros](#v20-macros); see `docs/v2/MIGRATING-v2.md`.
> **v2.11–v2.14 additions** are documented in the section-in-place entries (see the top of each macro's entry for the first version that shipped the signature). The v2.14 #350 Alpine-friction epic appears as its own cluster at the bottom.

**Notation:** `param="default"` means optional with a default. No `=` means required. Types in parentheses are value options.

---

## Buttons

### `button`
```
button(label, type="primary", size="md", icon="", disabled=false, loading=false,
       onclick="", classes="", x_bind_disabled="", x_bind_class="",
       label_var="", loading_var="", tone="", href="", tone_var="",
       icons=None, icon_var="",
       x_show="", href_var="", title="", title_var="", click_modifiers="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| label | string | *required* | Button text (ignored when `label_var` is set) |
| type | string | `"primary"` | `"primary"` `"secondary"` `"danger"` `"ghost"` |
| size | string | `"md"` | `"sm"` `"md"` `"lg"` |
| icon | string | `""` | Raw SVG string, placed before label |
| disabled | bool | `false` | Server-time disabled |
| loading | bool | `false` | Server-time spinner + disabled |
| onclick | string | `""` | Alpine `@click` handler |
| classes | string | `""` | Extra CSS appended |
| x_bind_disabled | string | `""` | Alpine `:disabled=` expression (reactive) |
| x_bind_class | string | `""` | Alpine `:class=` expression |
| label_var | string | `""` | Alpine expression for reactive label (`<span x-text>`) |
| loading_var | string | `""` | Alpine expression for reactive spinner. When truthy, shows spinner; static `icon` (if any) is gated by `!(loading_var)`. Wins over the static `loading` param. Parallel to `label_var` / `variant_var` / `text_var`. **Also OR'd into `:disabled`** so the button is automatically disabled while loading — prevents double-submit without requiring `x_bind_disabled` to be set separately. |
| tone | string | `""` | Soft-tinted palette: `"primary"` `"green"` `"blue"` `"amber"` `"red"` `"gray"` `"purple"` `"teal"` `"indigo"` `"orange"` `"cyan"`. When set, overrides `type` and renders `bg-{tone}-100 text-{tone}-700 hover:bg-{tone}-200` with matching dark-mode variants — for category pills, quick-action chips, sidebar nav-item buttons. |
| href | string | `""` | When set, renders an `<a href>` anchor instead of a `<button>`. Same class composition; `@click` / `:disabled` wiring is dropped (not valid on `<a>`). If both `href` and `onclick` are set, `href` wins. |
| tone_var | string | `""` | Alpine expression resolving to a tone key (`"green"`, `"amber"`, etc.). Emits a reactive `:class` object binding over the full tone palette so the button's tone flips when the expression changes. Wins over static `tone`. Composes with `x_bind_class` (Alpine `:class` array). |
| icons | dict\|None | `None` | **v2.7 (#310).** Multi-state icon map — `{key: "<svg>..."}`. When set alongside `icon_var`, emits one `<span x-show>` per key so the icon swaps reactively. Use for tri-state buttons (engine start/stop, play/pause). `loading_var` still suppresses all icons while loading. If both `icons` + `icon_var` are set, `icon` (single-SVG) is ignored. |
| icon_var | string | `""` | Alpine expression resolving to a key in `icons` (e.g. `"engineRunning ? 'running' : 'stopped'"`). Only active when `icons` is set. |
| x_show | string | `""` | **v2.11 (#344).** Alpine `x-show=` expression — reactive visibility. Adds `x-cloak` automatically so the button doesn't flash visible on page load before Alpine initializes. |
| href_var | string | `""` | **v2.11 (#344).** Alpine expression for reactive `:href`. When set, forces the `<a>` render branch. Composes with static `href` (both can be set; `:href` overrides at runtime). |
| title | string | `""` | **v2.11 (#344).** Native HTML `title=` (hover tooltip). Distinct from `icon_button(tooltip=)` which renders a styled tooltip span. |
| title_var | string | `""` | **v2.11 (#344).** Alpine expression for reactive `:title`. Composes with static `title`. |
| click_modifiers | string | `""` | **v2.11 (#344).** Alpine `@click` modifier chain: `"stop"`, `"prevent"`, `"stop.prevent"`, `"stop,prevent"`, `"once"`, `"away"`, `"outside"`, or any dotted/comma-separated combo. Commas are normalized to dots internally. When set, emits `@click.{modifiers}="..."` instead of plain `@click`. Required for nested-clickable patterns (row-click + button-click without bubbling). |

```jinja
{{ button("Save", icon=svg_save, onclick="save()") }}
{{ button("Sync Now",
          icon=svg_refresh,
          onclick="if (!syncing) triggerSync()",
          x_bind_disabled="syncing",
          label_var="syncing ? 'Syncing…' : 'Sync Now'",
          loading_var="syncing") }}
```

### `icon_button`
```
icon_button(icon_svg, tooltip="", type="secondary", size="md", onclick="",
            classes="", aria_label="", disabled=false,
            x_show="", x_bind_disabled="", x_bind_class="", tooltip_var="",
            click_modifiers="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| icon_svg | string | *required* | Raw SVG markup |
| tooltip | string | `""` | Hover text + aria-label |
| type | string | `"secondary"` | `"primary"` `"secondary"` `"danger"` `"ghost"` |
| size | string | `"md"` | `"sm"` `"md"` `"lg"` |
| onclick | string | `""` | Alpine.js click handler |
| classes | string | `""` | Extra CSS appended |
| aria_label | string | `""` | Accessibility label; falls back to `tooltip` then `"Action button"` |
| disabled | bool | `false` | **v2.11 (#344).** Server-time disabled. |
| x_show | string | `""` | **v2.11 (#344).** Alpine `x-show=` on the outer wrapper (hides the button + its tooltip span together). Adds `x-cloak`. |
| x_bind_disabled | string | `""` | **v2.11 (#344).** Alpine `:disabled=` expression. |
| x_bind_class | string | `""` | **v2.11 (#344).** Alpine `:class=` expression on the inner `<button>`. |
| tooltip_var | string | `""` | **v2.11 (#344).** Alpine expression for reactive native `:title=`. Composes with static `tooltip`; the styled tooltip span continues to show the static value. |
| click_modifiers | string | `""` | **v2.11 (#344).** Same as `button.click_modifiers` — emits `@click.{modifiers}="..."`. |

### `button_group`
```
button_group(options, model="selected", size="md", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| options | list | *required* | `[{value, label, icon?}]` |
| model | string | `"selected"` | Alpine.js variable for active value |
| size | string | `"md"` | `"sm"` `"md"` |
| classes | string | `""` | |

---

## Badges & Status

### `badge`
```
badge(text, variant="gray", size="md", classes="", text_var="", variant_var="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| text | string | *required* | Static label (ignored when `text_var` is set) |
| variant | string | `"gray"` | `"primary"` `"green"` `"blue"` `"amber"` `"red"` `"gray"` `"purple"` `"teal"` `"indigo"` `"orange"` `"cyan"` |
| size | string | `"md"` | `"sm"` `"md"` |
| classes | string | `""` | Extra CSS appended to the wrapper |
| text_var | string | `""` | Alpine expression; when set, renders `<span x-text="…">` |
| variant_var | string | `""` | Alpine expression; when set, `:class` switch picks the palette from the same variant options |

```jinja
{{ badge("Active", variant="green") }}
{{ badge("New", variant="indigo") }}
{{ badge("Hot", variant="orange") }}
{{ badge("", text_var="row.label", variant_var="row.status") }}
```

### `dot_badge`
```
dot_badge(text, variant="green", size="md", classes="", text_var="", variant_var="")
```
Same surface as `badge` — adds a colored dot indicator before the text.

| Param | Type | Default | Options |
|-------|------|---------|---------|
| text | string | *required* | Static label (ignored when `text_var` is set) |
| variant | string | `"green"` | `"primary"` `"green"` `"blue"` `"amber"` `"red"` `"gray"` `"purple"` `"teal"` `"indigo"` `"orange"` `"cyan"` |
| size | string | `"md"` | `"sm"` `"md"` |
| classes | string | `""` | Extra CSS appended to the wrapper |
| text_var | string | `""` | Alpine expression; when set, renders `<span x-text="…">` |
| variant_var | string | `""` | Alpine expression; when set, `:class` switch drives both the pill palette and the dot color |

```jinja
{{ dot_badge("Operational", variant="green") }}
{{ dot_badge("Scheduled", variant="indigo") }}
{{ dot_badge("Preview", variant="orange") }}
```

### `notification_counter`
```
notification_counter(count=0, max=99, variant="red", classes="")
```
`{% call %}` pattern — wraps the element you want the counter on.
| Param | Type | Default | Options |
|-------|------|---------|---------|
| count | int | `0` | 0 hides the badge |
| max | int | `99` | Shows `N+` above this |
| variant | string | `"red"` | `"red"` `"primary"` `"blue"` |

### `count_badge`
```
count_badge(count=0, max=99, variant="gray", size="sm", zero_hidden=true,
            count_var="", variant_var="", classes="")
```
**v2.11.3 (#349 predecessor).** Inline count pill — "Tasks 12", "Outcomes 3" next to headings. Distinct from `notification_counter` (overlay bubble requiring `{% call %}`) and `badge` (generic text label with no numeric formatting).

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| count | int | `0` | Static count. Ignored when `count_var` is set. |
| max | int | `99` | Overflow threshold. `count > max` renders `"{max}+"` (e.g. `count=250, max=99` → `99+`). |
| variant | string | `"gray"` | `"gray"` `"primary"` `"green"` `"blue"` `"amber"` `"red"` `"purple"` `"teal"` `"indigo"` `"cyan"` — same 10-color palette as `badge`. |
| size | string | `"sm"` | `"xs"` (inline with small text) `"sm"` (section titles) `"md"`. |
| zero_hidden | bool | `true` | When true and count is 0, the macro renders nothing. Set `false` to keep a `"0"` chip for layout parity (e.g. uniform table cells). |
| count_var | string | `""` | Alpine expression for reactive count (`x-text` + `x-cloak`). Wins over static `count`. |
| variant_var | string | `""` | Alpine expression resolving to a variant key (e.g. `"cases.unresolved > 10 ? 'red' : 'amber'"`). Emits a reactive `:class` map so color flips at thresholds. |
| classes | string | `""` | Extra CSS appended. |

```jinja
{# Static — next to a heading #}
<h3>Outcomes {{ count_badge(3) }}</h3>

{# Overflow #}
{{ count_badge(250, max=99) }}  {# renders "99+" #}

{# Reactive count + threshold color #}
<h3>
  Unresolved
  {{ count_badge(count_var="cases.unresolved",
                 variant_var="cases.unresolved > 10 ? 'red' : 'amber'") }}
</h3>

{# Keep a "0" chip for layout parity #}
<td>{{ count_badge(row.count, zero_hidden=false) }}</td>
```

### `health_indicator`
```
health_indicator(status="unknown", size="md", label=false, pulsing=true, classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| status | string | `"unknown"` | `"healthy"` `"degraded"` `"down"` `"unknown"` |
| size | string | `"md"` | `"sm"` `"md"` `"lg"` |
| label | bool | `false` | Show text label next to dot |
| pulsing | bool | `true` | Animate pulse on healthy/degraded |

### `status_dot`
```
status_dot(variant="neutral", icon="", size="md", classes="", pulse=false,
           variant_var="", pulse_var="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| variant | string | `"neutral"` | `"success"` `"error"` `"warning"` `"info"` `"neutral"` |
| icon | string | `""` | `"check"` `"x"` `"dash"` or empty (plain dot) |
| size | string | `"md"` | `"sm"` `"md"` |
| pulse | bool | `false` | When `true`, emits `animate-pulse` on the dot — for live-status indicators (SSE connected, handoff active, engine running) |
| variant_var | string | `""` | Alpine expression returning one of the variant tokens; emits a `:class` switch that picks the palette reactively (for use inside `x-for` where each row has its own health) |
| pulse_var | string | `""` | Alpine boolean expression driving `animate-pulse` reactively — pairs with `variant_var` for per-row live-status indicators |

### `match_method_badge`
```
match_method_badge(method="exact", label="", size="sm")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| method | string | `"exact"` | `"exact"` `"prefix"` `"suffix"` `"fuzzy"` `"combined"` `"none"` |
| label | string | `""` | Override display text |
| size | string | `"sm"` | `"sm"` `"md"` |

### `validation_score`
```
validation_score(score, threshold_good=85, threshold_warn=60, label="Match", show_bar=false, size="md", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| score | number | *required* | 0-100 |
| threshold_good | number | `85` | Green threshold |
| threshold_warn | number | `60` | Amber threshold (below = red) |
| label | string | `"Match"` | Prefix text |
| show_bar | bool | `false` | Show mini progress bar |
| size | string | `"md"` | `"sm"` `"md"` |

### `env_badge`
```
env_badge(env="production", classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| env | string | `"production"` | `"production"` `"staging"` `"development"` `"test"` |

### `sse_status`
```
sse_status(model="sseStatus", label=true, classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `"sseStatus"` | Alpine.js variable: `"connected"` `"connecting"` `"disconnected"` |
| label | bool | `true` | Show text label |

### `live_update_banner`
```
live_update_banner(count_var, on_load, on_dismiss, label_singular, label_plural,
                   icon, position, variant, classes)
```
Dismissable "N new items" banner for SSE/poll-driven list views — complements `sse_status`.
Auto-hides at count 0, pulses on increment, Enter loads, Esc dismisses.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| count_var | string | `"newItemsCount"` | Alpine expression evaluating to pending count |
| on_load | string | `""` | Alpine expression fired on click / Enter |
| on_dismiss | string | `""` | Alpine expression fired on ✕ / Esc (default: sets count_var to 0) |
| label_singular | string | `"new item"` | Label when count == 1 |
| label_plural | string | `"new items"` | Label when count != 1 |
| icon | string | `"arrow-down"` | `"arrow-down"` `"refresh"` `"bell"` |
| position | string | `"inline"` | `"inline"` (flows with list) `"sticky"` (pins to top) |
| variant | string | `"primary"` | `"primary"` `"success"` `"info"` |

### `error_ref`
```
error_ref(ref="", prefix="REF-", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| ref | string | `""` | Explicit code (auto-generates 4-char Base34 if empty) |
| prefix | string | `"REF-"` | Display prefix |

### `copy_button`
```
copy_button(text, label="", size="sm", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| text | string | *required* | Text to copy to clipboard |
| label | string | `""` | Button text (icon-only if empty) |
| size | string | `"sm"` | `"sm"` `"md"` |

### `avatar`
```
avatar(name, size="md", color="primary")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| name | string | *required* | Shows first letter as initial |
| size | string | `"md"` | `"sm"` `"md"` `"lg"` `"xl"` |
| color | string | `"primary"` | Any variant color |

---

## Form Inputs

**Inline help (`help_anchor`, `help_summary`)** — `input_field`, `select_field`, `number_field`, `date_field`, `toggle_switch`, and `date_range` each accept two optional params for a per-field `(?)` icon next to the label:

- `help_summary=""` — short text shown on hover (native `title` tooltip)
- `help_anchor=""` — doc route opened on click, format `"doc"` or `"doc#anchor"`; dispatches `open-doc` with `highlight: true` so the paired `doc_viewer` flashes the target section

When both are empty the macro renders exactly as before — back-compat.

### `input_field`
```
input_field(label, name="", type="text", placeholder="", model="", value="", required=false, disabled=false, help="", classes="", help_anchor="", help_summary="", secret=false, secret_toggle=true)
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| label | string | *required* | |
| name | string | `""` | HTML name attribute |
| type | string | `"text"` | Any HTML input type |
| model | string | `""` | Alpine.js `x-model` binding |
| value | string | `""` | Initial value |
| help | string | `""` | Help text below field |
| help_anchor | string | `""` | Doc route for the inline `(?)` icon |
| help_summary | string | `""` | Tooltip shown on hover of the `(?)` icon |
| secret | bool | `false` | **v2.7.** Mask the value via CSS (`-webkit-text-security: disc`) while keeping `type="text"` so browsers don't trigger save-password prompts. For machine-to-machine tokens (webhook secrets, API keys, shared secrets) — NOT user credentials. Real passwords should still use `type="password"`. Forces autofill-suppression attributes. |
| secret_toggle | bool | `true` | Show an eye icon inside the input that flips `_revealed` to unmask. Only active when `secret=true`. |

### `select_field`
```
select_field(label, options, name="", model="", value="", required=false, disabled=false, classes="", help_anchor="", help_summary="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| options | list | *required* | `[{value, label}]`, `[(val, label)]`, or `[string]` |

### `textarea_field`
```
textarea_field(label, name="", placeholder="", model="", rows=3, required=false, disabled=false, classes="")
```

### `number_field`
```
number_field(label, name="", placeholder="", model="", min=none, max=none, step=none, required=false, disabled=false, prefix="", suffix="", classes="", help_anchor="", help_summary="")
```

**v2.6:** emits `x-model.number` (not `x-model`) so Alpine coerces the value to a JS `number`. Empty input → `null`; non-numeric input falls back to the string (Alpine's `.number` semantics).

### `date_field`
```
date_field(label, name="", model="", value="", required=false, disabled=false, classes="", help_anchor="", help_summary="")
```

### `time_input`
```
time_input(label, name="", model="", value="", required=false, disabled=false, step=60, classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| step | int | `60` | Seconds granularity. `1` = show seconds, `60` = minutes only |

### `date_range`
```
date_range(label="", start_name="start_date", end_name="end_date", start_model="", end_model="", start_value="", end_value="", required=false, disabled=false, presets=[], classes="", help_anchor="", help_summary="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| presets | list | `[]` | `[{label, start, end}]` for quick-select buttons |
| end input gets `:min` bound to start_model automatically |

### `currency_input`
```
currency_input(label, name="", model="", value="", required=false, disabled=false, step="0.01", classes="")
```
Right-aligned, monospace, `$` prefix.

### `checkbox_field`
```
checkbox_field(label, name="", model="", checked=false, disabled=false)
```

### `toggle_switch`
```
toggle_switch(label, model="", checked=false, disabled=false, description="", help_anchor="", help_summary="")
```

### `radio_group`
```
radio_group(label, options, name="", model="", value="", layout="vertical", disabled=false)
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| options | list | *required* | `[{value, label, description?}]` or `[(val, label)]` |
| layout | string | `"vertical"` | `"vertical"` `"horizontal"` |

### `chip_input`
```
chip_input(label="", model="chips", placeholder="Add tag...", max=0, classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `"chips"` | Alpine.js array variable |
| max | int | `0` | Max chips (0 = unlimited) |

### `file_upload`
```
file_upload(label="", name="file", model="", accept="", multiple=false, max_size="", help="", classes="")
```

### `editable_field`
```
editable_field(value="", model="", mode_var="editMode", type="text", step="", placeholder="", prefix="", suffix="", name="", classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| mode_var | string | `"editMode"` | Alpine.js boolean controlling display/edit |
| type | string | `"text"` | `"text"` `"number"` `"date"` `"textarea"` `"currency"` |

### `edit_mode_card`
```jinja
edit_mode_card(id="", mode_var="_editMode", on_save="", on_cancel="",
               title="", fields=[], columns=2,
               save_label="Save", cancel_label="Cancel", edit_label="Edit",
               classes="")
```
Group-level read/edit toggle for detail views — replaces N parallel
`<template x-if="_editMode">` wrappers around a batch of editable attributes
with a single card that snapshots values on enter-edit and restores them on Cancel.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `""` | Card id (autogenerated if empty); used for aria wiring |
| mode_var | string | `"_editMode"` | Alpine boolean toggled by Edit/Save/Cancel |
| on_save | string | `""` | Alpine expression fired on Save — consumer reads from field `value` exprs |
| on_cancel | string | `""` | Alpine expression fired AFTER snapshot restore |
| title | string | `""` | Header title |
| fields | list | `[]` | Field dicts (see below) |
| columns | int | `2` | `1` / `2` / `3` — responsive via @container |
| save_label / cancel_label / edit_label | string | `"Save"` / `"Cancel"` / `"Edit"` | Button labels |

**Field dict schema:**
- `key` — unique id
- `label` — human label
- `type` — `"text"` / `"number"` / `"currency"` / `"date"` / `"select"` / `"textarea"` / `"checkbox"`
- `value` — Alpine expression for the field's value (read in both modes; `x-model` target in edit mode)
- `display` — optional Alpine expression to use in read mode (formatting fallback to `value`)
- `options` / `options_var` — for `select`
- `error_var` — Alpine expression for per-field error message (surfaces inline)
- `placeholder` — input placeholder

Caller content is rendered in BOTH modes below the field grid — mix with custom layouts (line-items, notes, attachments).

```html
{% from "components/ui.html" import edit_mode_card %}
{{ edit_mode_card(
    id="invoiceSummary",
    mode_var="item._editMode",
    on_save="saveRecord(item.id)",
    title="Summary",
    fields=[
      {"key": "vendor",   "label": "Vendor",    "type": "text",     "value": "item.vendor"},
      {"key": "total",    "label": "Total",     "type": "currency", "value": "item.total"},
      {"key": "terms",    "label": "Terms",     "type": "select",   "value": "item.terms",
       "options_var": "paymentTermsOptions"},
    ],
    columns=2
) }}
```

### `async_select`
```
async_select(label, options_var="options", loaded_var="optionsLoaded", error_var="optionsError", model="", name="", placeholder="Select...", required=false, classes="")
```
Three states: loading spinner → populated select → freetext fallback on error.

### `field_error`
```
field_error(message="", type="error", model="", show="", classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| type | string | `"error"` | `"error"` `"success"` `"warning"` `"hint"` |
| model | string | `""` | Alpine.js expression for dynamic message |
| show | string | `""` | Alpine.js expression controlling visibility |

### `form_group`
```
form_group(title="", description="", columns=1, border=true, classes="")
```
`{% call %}` pattern. Wraps fields in a labeled fieldset with grid layout.
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| columns | int | `1` | Grid columns: 1, 2, 3, or 4 |
| border | bool | `true` | Top border (false for first group) |

### `template_editor`
```jinja
template_editor(id="tplEditor", subject_model="form.subject_template",
                body_model="form.body_template", variables=[],
                variables_var="", preview_endpoint="",
                sample_data_var="{}", html_mode=false,
                body_html_model="form.body_html_template",
                layout="side-by-side", height="h-[420px]", classes="")
```
Notification / email / report template authoring surface. Variable-chip rail with click-to-insert at cursor, debounced live preview against a server endpoint, unknown-token detection with Levenshtein-distance suggestions, optional HTML mode with a tab strip in the preview pane, and a `Ctrl+Space` searchable variable picker. Zero hard deps.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"tplEditor"` | Used for input ids, JSON island, aria wiring |
| subject_model / body_model | string | — | Alpine expressions for the subject + body template strings |
| variables | list | `[]` | Server-time list of `{name, description?, example?}` |
| variables_var | string | `""` | Reactive Alpine expression — overrides `variables` |
| preview_endpoint | string | `""` | URL to POST `{subject_template, body_template, body_html_template?, sample_data}` to; expects `{rendered_subject, rendered_body, rendered_body_html?, unknown_variables}` back |
| sample_data_var | string | `"{}"` | Alpine expression for the sample data dict |
| html_mode | bool | `false` | Adds a body_html textarea + Text/HTML preview tabs |
| body_html_model | string | — | Alpine expression for the HTML body (only when html_mode) |
| layout | string | `"side-by-side"` | `"side-by-side"` / `"stacked"` / `"editor-only"` — side-by-side stacks below `@lg` |
| height | string | `"h-[420px]"` | Tailwind height class for the editor area |

**a11y:** subject + body are labeled inputs. Chip rail uses `role="toolbar"` + per-chip `aria-label`. Preview pane is `role="region"` + `aria-live="polite"`. The HTML preview trusts the server's `rendered_body_html` — your endpoint is the sanitization boundary.

```html
{{ template_editor(
    id="notifTpl",
    subject_model="form.subject",
    body_model="form.body",
    variables=[
      {"name": "vendor_name", "description": "Vendor name", "example": "ACME"},
      {"name": "error_message", "description": "Failure reason", "example": "Timeout"}
    ],
    preview_endpoint="/api/templates/preview",
    sample_data_var="previewSamples"
) }}
```

### `permission_matrix`
```jinja
permission_matrix(id="matrix", rows=[], columns=[], model="matrix",
                  on_change="", row_groups=[], show_row_toggle=true,
                  show_col_toggle=true, compact=false, classes="")
```
2D toggle grid for subscriptions / ACL / feature-flag editors / notification matrices. Cells are bound to a flat Alpine dict keyed `"<rowId>:<colId>"`. Sticky column header + sticky first column on horizontal scroll. Full keyboard navigation (Tab through cells, Space toggles, Shift+Space toggles entire row, arrow keys move focus).

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"matrix"` | Used for grid id, JSON islands, aria caption |
| rows | list | `[]` | `[{id, label, description?}]` |
| columns | list | `[]` | `[{id, label}]` |
| model | string | `"matrix"` | Alpine expression for the flat boolean dict |
| on_change | string | `""` | Fires per cell toggle with `row`, `col`, `value` in scope |
| row_groups | list | `[]` | `[{label, row_ids}]` — un-grouped rows fall under "Other" |
| show_row_toggle | bool | `true` | Click row label to toggle the entire row |
| show_col_toggle | bool | `true` | Click column header to toggle the entire column |
| compact | bool | `false` | Tighter cell padding for high-density matrices |

**Cell-key shape:** `"<rowId>:<colId>"`. Both ids must be free of `:` to avoid key collisions.

**a11y:** `role="grid"` + `columnheader` / `rowheader` / `gridcell` + `aria-pressed` per cell. Row groups expose `<th scope="rowgroup">`. Per-cell `aria-label` reads "Toggle <row> for <col>".

```html
{{ permission_matrix(
    id="notifMatrix",
    rows=[
      {"id": "circuit_breaker_opened", "label": "Circuit breaker opened",
       "description": "Trips when the upstream is offline."},
      {"id": "handoff_failed", "label": "Handoff failed"}
    ],
    columns=[
      {"id": "all", "label": "All processes"},
      {"id": "order_ack", "label": "Order Ack"},
      {"id": "accounts_payable", "label": "AP"}
    ],
    model="form.subscriptions",
    on_change="saveCell(row, col, value)",
    row_groups=[
      {"label": "System events",
       "row_ids": ["circuit_breaker_opened"]},
      {"label": "Process events",
       "row_ids": ["handoff_failed"]}
    ]
) }}
```

### `recurring_schedule_picker`
```jinja
recurring_schedule_picker(id="schedule", model="schedule", timezone_model="",
                          allow_multi_window=true, presets=[],
                          timezone_options=[], timezone_options_var="",
                          day_labels=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],
                          show_preview=true, classes="")
```
Active-hours / quiet-hours / business-hours picker. Produces and consumes
`[{days, start, end}, …]` (`days` is a list of day labels matching `day_labels`).
Day chips, native `type="time"` range inputs (15-min snap), optional preset
dropdown, optional timezone slot, soft DST guardrail when a window touches
02:00, and a pure-CSS week-grid preview (7 cols × 24 rows).

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"schedule"` | Stable id prefix for inputs |
| model | string | `"schedule"` | Alpine expression for the JSON array |
| timezone_model | string | `""` | When set, a `<select>` appears next to the preset dropdown bound with `x-model` |
| allow_multi_window | bool | `true` | `false` hides Add/Remove and auto-seeds one window |
| presets | list | `[]` | `[{label, value}]` canned schedules |
| timezone_options / timezone_options_var | list / string | `[]` / `""` | Server-time options OR Alpine reactive options expression |
| day_labels | list | `["Mon",...,"Sun"]` | Override for localization or week-start swap |
| show_preview | bool | `true` | Skip the heatmap when `false` |

**a11y:** day chips are `<button aria-pressed>`; each window is a `<fieldset>` with `<legend>` "Window N". The week-grid preview is `aria-hidden` (decorative).

```html
{{ recurring_schedule_picker(
    id="oncall",
    model="contact.active_hours",
    timezone_model="contact.timezone",
    timezone_options_var="$store.tz.options",
    presets=[
      {"label": "Business hours (M-F 8-6)",
       "value": [{"days": ["Mon","Tue","Wed","Thu","Fri"], "start": "08:00", "end": "18:00"}]},
      {"label": "24/7",
       "value": [{"days": ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"], "start": "00:00", "end": "23:59"}]},
      {"label": "Never", "value": []}
    ]
) }}
```

### `escalation_ladder`
```jinja
escalation_ladder(id="ladder", model="steps", step_schema=[], on_change="",
                  add_label="+ Add step", max_steps=0,
                  empty_text="No steps yet — click + Add step to begin.",
                  classes="")
```
Ordered step builder — escalation ladders, staged workflows, tiered approval rules, retry-with-backoff policies. Drag-reorder (mouse + keyboard), cumulative delay-from-start display, schema-driven fields, debounced `on_change`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"ladder"` | Stable id used for field ids inside each step |
| model | string | `"steps"` | Alpine expression for the step list (e.g. `"form.steps"`) |
| step_schema | list | `[]` | Field dicts per step (see below) |
| on_change | string | `""` | Fires 300ms after the last edit (debounced); immediate on add/remove/move |
| add_label | string | `"+ Add step"` | Add-button text |
| max_steps | int | `0` | `0` = unlimited; otherwise disables Add at this count |
| empty_text | string | — | Shown when the model list is empty |

**Schema field types:** `number`, `text`, `select`, `multi`, `chip_input`, `checkbox`. Keys MUST be JS identifiers (macro emits dot-notation bindings). Cumulative delay display lights up automatically when any field's `key` is `"delay_seconds"`.

**Keyboard:** Shift+↑ / Shift+↓ on the drag handle reorders without a mouse.

```html
{{ escalation_ladder(
    id="incidentEscalation",
    model="form.steps",
    step_schema=[
      {"key": "delay_seconds", "type": "number", "label": "Delay", "suffix": "s", "min": 0, "width": "quarter"},
      {"key": "channels", "type": "multi", "label": "Channels",
       "options": [{"id": "email", "label": "Email"}, {"id": "sms", "label": "SMS"}]},
      {"key": "recipients", "type": "chip_input", "label": "Recipients", "placeholder": "Add recipient"},
    ],
    on_change="persistLadder()",
    max_steps=10
) }}
```

---

## Dropdowns (Alpine.js)

### `dropdown`
```
dropdown(label, options, model="", placeholder="Select...", disabled=false, classes="")
```

### `dropdown_search`
```
dropdown_search(label, options, model="", placeholder="Select...", search_placeholder="Search...", disabled=false, classes="")
```

### `dropdown_multi`
```
dropdown_multi(label, options, model="[]", placeholder="Select items...", disabled=false, classes="")
```

### `dropdown_multi_search`
```
dropdown_multi_search(label, options, model="[]", placeholder="Select items...", search_placeholder="Search...", disabled=false, classes="")
```

All dropdowns accept options as `[{value, label}]`, `[(val, label)]`, or `[string]`.

---

## Search

### `search_input`
```
search_input(placeholder="Search...", model="search", classes="",
             debounce=0, clearable=false, count_var="", count_label="results")
```
Simple search field with icon. No results dropdown.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| placeholder | string | `"Search..."` | Input placeholder text |
| model | string | `"search"` | Alpine `x-model` path |
| classes | string | `""` | Extra CSS on the outer wrapper |
| debounce | int | `0` | ms; routes through Alpine's `x-model.debounce.{N}ms` modifier. `0` = no debounce |
| clearable | bool | `false` | Renders an X button that resets `model` to `""` |
| count_var | string | `""` | Alpine expression for the result count (e.g. `"filtered.length"`); rendered as subtle gray text next to the input |
| count_label | string | `"results"` | Noun paired with the count (`"records"`, `"vendors"`, `"matches"`) |
| on_search | string | `""` | Alpine expression fired on debounced input (e.g. `"loadResults()"`) for server-side queries; compiles to `@input.debounce.{debounce}ms="<expr>"` alongside the x-model binding |

```jinja
{{ search_input(placeholder="Search vendors",
                model="vendorSearch",
                count_var="filteredVendors.length",
                count_label="vendors",
                debounce=300,
                clearable=true) }}
```

### `search_with_results`
```
search_with_results(placeholder="Search...", model="query", results_var="searchResults", loading_var="searchLoading", on_select="", on_search="", debounce=300, min_chars=1, show_clear=true, max_height="max-h-80", grid_cols="", classes="",
                    input_width="", input_classes="",
                    results_width="", results_classes="", results_align="left",
                    columns_schema=[])
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| results_var | string | `"searchResults"` | Alpine.js array of result objects |
| loading_var | string | `"searchLoading"` | Alpine.js boolean |
| on_select | string | `""` | Alpine.js expression, receives `result` |
| on_search | string | `""` | Alpine.js expression for async search |
| debounce | int | `300` | Input debounce in ms |
| grid_cols | string | `""` | CSS grid-template-columns for multi-column results |
| input_width | string | `""` | **v2.8 (#324).** Tailwind width class on the input wrapper only. Empty → input inherits the outer `classes` width. |
| input_classes | string | `""` | **v2.8 (#324).** Extra classes appended to the `<input>` (e.g. `"bg-gray-50 focus:border-blue-400"`). |
| results_width | string | `""` | **v2.8 (#324).** Tailwind width class on the dropdown panel only. Empty → dropdown stays `w-full` of the wrapper (today's behavior). Use when you need a wider results panel than the input. |
| results_classes | string | `""` | **v2.8 (#324).** Extra classes on the dropdown panel (bg / ring / shadow / rounded overrides). |
| results_align | string | `"left"` | **v2.8 (#324).** `"left"` or `"right"` — which edge of the wrapper the dropdown anchors to. Use `"right"` when `results_width` would overflow the right viewport edge. |
| columns_schema | list | `[]` | **v2.8 (#325).** Drives a table-shaped result list with a sticky header row and typed cells (see below). Empty list → caller slot / `result.columns` / simple layout wins. When set, overrides those unless a caller slot is present. |

**v2.8 (#325) — `columns_schema` table mode**

```jinja
{{ search_with_results(
    model='q', results_var='docs',
    columns_schema=[
      {'key': 'doc_id',   'label': 'Doc ID',   'width': '120px', 'type': 'mono'},
      {'key': 'title',    'label': 'Title',    'width': '1fr'},
      {'label': 'Open',   'width': '60px',  'type': 'link',
       'href_key': 'url', 'text': 'View',  'target': '_blank'},
      {'key': 'doc_type', 'label': 'Type',   'width': '100px',
       'type': 'badge', 'variant_key': 'doc_type_variant'},
      {'key': 'modified', 'label': 'Modified', 'width': '100px', 'type': 'mono'},
    ],
    results_width='w-[48rem]'
) }}
```

**Column entry shape:** `{key, label, width?, type?, align?, variant?, variant_key?, href_key?, text?, text_key?, target?, expr?}`

| Field | Notes |
|-------|-------|
| `key` | Result field to read (bracket-notation-safe) |
| `label` | Header text |
| `width` | `grid-template-columns` entry — e.g. `"120px"`, `"1fr"`, `"minmax(120px, 1fr)"`. Default `"1fr"`. |
| `type` | One of exactly: `"text"` (default) / `"mono"` / `"badge"` / `"link"` / `"html"` / `"expr"`. Any other value (including `"date"`, `"number"`, `"currency"`) falls through to escaped text — see note below. |
| `align` | `"left"` (default) / `"right"` / `"center"` — on both header and body |
| `variant` | Badge palette key (fixed across all rows): `primary` `green` `blue` `amber` `red` `gray` `purple` `teal` `indigo` `orange` `cyan` |
| `variant_key` | Result field that holds the badge variant per row (wins over `variant`) |
| `href_key` | For `type=link`: result field holding the URL. Default `"url"`. |
| `text` | For `type=link`: static link text (e.g. `"View"`). |
| `text_key` | For `type=link`: result field for link text (wins over `text`). If neither is set, falls back to `result[key]`. |
| `target` | For `type=link`: e.g. `"_blank"` — auto-adds `rel="noopener noreferrer"`. |
| `expr` | For `type=expr`: Alpine expression with `result` in scope. Reactive — updates when fields mutate (SSE-friendly). |
| `trusted_html` | For `type=html` only: must be `true` to render via `x-html`. Otherwise the cell falls back to `x-text` (escaped). Opt-in gate to prevent accidental XSS. |

- `html` type uses `x-html` **only when `trusted_html: true`** on the column — without that flag, values render as escaped text. Set the flag only for server-sanitized markup, never raw user input.
- Link cells emit `@click.stop` so row-select isn't double-fired; also carry a visible `focus:ring-2 focus:ring-primary-400/50` for keyboard navigation.
- Reactive/SSE data works automatically — mutate `result[key]` and the cell re-renders.
- **There is no `date` / `number` / `currency` dispatch branch.** Those strings are NOT recognized cell types. Format the value server-side and pass it through `type: 'text'` or `type: 'mono'`. For client-side formatting use `type: 'expr'` with a custom Alpine expression (e.g. `new Date(result.ts).toLocaleDateString()`).

**Result shapes:**
- Simple: `{label, description?, badge?, badge_variant?, icon?, group?}`
- Multi-column: `{columns: [{value, type?, variant?, width?}], group?}`
- Column types: `"badge"` `"number"` `"date"` `"link"` `"mono"` `"muted"`

**v2.7 (#311) — custom result template via caller slot:**

Wrap the macro with `{% call %}...{% endcall %}` to render each result row yourself. The caller body runs inside the hover/click target with `result` in scope — render any shape (badges, pills, multi-row, icons).

```jinja
{% call search_with_results(model='q', results_var='hits', loading_var='loading', on_select='open(result)') %}
  <div class="flex items-center gap-2">
    <span :class="badgeClass(result.process_type)" x-text="result.process_label"></span>
    <span class="font-semibold" x-text="result.po_number"></span>
    <span class="text-xs text-gray-500" x-text="result.vendor_name"></span>
  </div>
{% endcall %}
```

Omit the caller → default single-row template renders unchanged (back-compat). Keyboard hint (`Enter` kbd) stays either way.

---

## Data Tables

### `table_start` / `table_end`
```
table_start(classes="")
table_end()
```

### `table_head`
```
table_head(columns)
```
| Param | Type | Notes |
|-------|------|-------|
| columns | list | List of header strings |

### `sortable_table_head`
```
sortable_table_head(columns, sort_model="sortBy", dir_model="sortDir")
```
| Param | Type | Notes |
|-------|------|-------|
| columns | list | `[{key, label, sortable?}]` — sortable defaults true |

### `table_body_start` / `table_body_end` / `table_row_start` / `table_row_end`
```
table_body_start()  table_body_end()
table_row_start(classes="")  table_row_end()
```

### `table_cell`
```
table_cell(content="", classes="")
```

### `table_pagination`
```
table_pagination(start, end, total, prev_action="", next_action="")
```

### `comparison_table`
```
comparison_table(label_a="Source", label_b="Target", classes="")
```
`{% call %}` pattern — put `comparison_row` macros inside.

### `comparison_row`
```
comparison_row(label, value_a="", value_b="", status="match", classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| status | string | `"match"` | `"match"` `"mismatch"` `"partial"` `"missing"` |

### `data_table`
```
data_table(id, columns, data_var="tableData", page_size=10, selectable=false, on_select="", searchable=true, striped=false, compact=false, height="", classes="", density="normal")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| columns | list | *required* | `[{key, label, type?, sortable?, filterable?, filter_type?, width?, align?}]` |
| data_var | string | `"tableData"` | Alpine.js array |
| page_size | int | `10` | 0 = no pagination |
| selectable | bool | `false` | Row checkboxes |
| height | string | `""` | Max height class (e.g., `"max-h-96"`) |
| density | string | `"normal"` | **v2.10 (#338).** `"normal"` = `px-4 py-3` padding; `"compact"` = `px-3 py-1.5`. Unified vocabulary matching `log_console(density=…)`. Inner `<table>` uses `border-collapse` (v2.10) — removes the ~4px browser-default row spacing. |
| compact | bool | `false` | **Deprecated alias (v2.10).** `compact=true` still works but fires a one-time `console.warn`. Use `density="compact"` instead. Removal scheduled for v3. |

**Column types:** `"text"` `"number"` `"date"` `"badge"` `"currency"` `"link"` `"actions"`
**Filter types:** `"text"` (default) `"select"` (dropdown of unique values)
**Badge data:** `{text: "Active", variant: "green"}`

### `spark_cell`
```
spark_cell(data, color="primary", width=80, height=20, fill=false)
```
Sparkline for table cells. `data` can be a Jinja2 list or Alpine.js expression string.

---

## Charts

### `sparkline`
```
sparkline(data, color="primary", width=80, height=24, fill=false, classes="")
```
No dependency — pure SVG.

### `stat_card_spark`
```
stat_card_spark(label, value, data=[], color="primary", trend="", trend_up=true, spark_fill=true, icon_svg="", classes="")
```
KPI card with integrated sparkline. No dependency.

### `chart_card`
```
chart_card(title="", subtitle="", ranges=[], range_model="chartRange", actions="", padding="p-4", classes="")
```
`{% call %}` pattern — put any chart inside.
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| ranges | list | `[]` | `[{label, value}]` for time range buttons |
| range_model | string | `"chartRange"` | Alpine.js variable for selected range |
| actions | string | `""` | Raw HTML for right-side actions |

### `plotly_chart`
```
plotly_chart(id, traces="[]", layout="{}", config="{}", height="h-72", classes="")
```
**Requires Plotly.js.** Shows styled error if missing.

### `bar_chart`
```
bar_chart(id, labels, datasets, orientation="v", stacked=false, height="h-72", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| labels | list | *required* | Category labels |
| datasets | list | *required* | `[{name, values, color?}]` |
| orientation | string | `"v"` | `"v"` `"h"` |
| stacked | bool | `false` | |

### `line_chart`
```
line_chart(id, labels, datasets, height="h-72", classes="")
```
| Param | Type | Notes |
|-------|------|-------|
| datasets | list | `[{name, values, color?, fill?}]` — `fill: true` for area |

### `pie_chart`
```
pie_chart(id, labels, values, donut=true, height="h-72", classes="")
```

### `gauge_chart`
```
gauge_chart(id, value, min_val=0, max_val=100, label="", suffix="", thresholds=none, height="h-56", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| thresholds | list | auto | `[{value, color}]` — default: red<40%, amber<70%, green>=70% |

### `live_chart`
```
live_chart(id, traces="[]", layout="{}", max_points=100, event_name="chart-data", height="h-72", classes="")
```
**Requires Plotly.js.** Listens for `event_name` CustomEvent and appends data.
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| max_points | int | `100` | Rolling window per trace |
| event_name | string | `"chart-data"` | CustomEvent name |

Event: `detail: {x: value, y: value}` (single trace) or `{x: [[v]], y: [[v]]}` (multi-trace)

---

## Modals & Panels

### `modal`
```
modal(id, title="", classes="", open_var="", on_close="", size="md",
      floating=false, default_x=0, default_y=0, draggable=false, resizable=false,
      header_actions="")
```
`{% call %}` pattern. `id` is Alpine.js variable name (e.g., `"editOpen"` → use `editOpenOpen`). Set `floating=True` for draggable/resizable multi-window panels (no backdrop, `$store.ui.floatingStack`). `header_actions` takes a `{% set %}…{% endset %}` capture rendered between title and close button for in-header toggles/tab bars.

### `confirm_modal`
```
confirm_modal(id, title, message, confirm_label="Delete", confirm_type="danger", cancel_label="Cancel", on_confirm="")
```

### `slide_over`
```
slide_over(id, title="", width="md", side="right")
```
`{% call %}` pattern.
| Param | Type | Default | Options |
|-------|------|---------|---------|
| width | string | `"md"` | `"sm"` `"md"` `"lg"` `"xl"` |
| side | string | `"right"` | `"right"` `"left"` |

### `docked_panel`
```jinja
docked_panel(id="dock", side="right", width="lg", open_var="_dockOpen",
             collapsed_var="_dockCollapsed", resizable=true, persist=true,
             title="", title_var="", tabs=[], active_tab_var="_dockActiveTab",
             classes="")
```
`{% call %}` pattern. Long-lived reference panel that docks to one edge —
distinct from `modal` (blocks) and `slide_over` (transient). Resizable, collapsible
to a 32px rail, optionally tabbed, and persists width/open/collapsed state to
localStorage under `uikit:dock:<id>`. Emits a CSS custom property
`--dock-<side>-width` on `document.documentElement` so consumers can pad
their main layout via `padding-right: var(--dock-right-width, 0px)` — no JS
wiring required.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"dock"` | Panel id (used for localStorage key + aria) |
| side | string | `"right"` | `"left"` / `"right"` / `"bottom"` |
| width | string/int | `"lg"` | `"sm"=320` / `"md"=420` / `"lg"=560` / `"xl"=720`, or explicit `"640px"` |
| open_var | string | `"_dockOpen"` | Alpine bool; default seeds local state |
| collapsed_var | string | `"_dockCollapsed"` | Alpine bool; collapses to 32px rail |
| resizable | bool | `true` | Drag handle on inside edge (min 220px, max viewport-280) |
| persist | bool | `true` | Write size/open/collapsed to localStorage |
| title / title_var | string | `""` | Static header title / Alpine expression (dynamic) |
| tabs | list | `[]` | `[{id, label}]` — emits tablist below header |
| active_tab_var | string | `"_dockActiveTab"` | Alpine variable holding active tab id |

**Keyboard:** `Ctrl+\` toggles collapsed; `Esc` collapses (does NOT close — distinct from modal/slide-over) when focus is inside.

```html
{% call docked_panel(
    id="contextPanel", side="right", width="lg",
    open_var="$store.ui.contextOpen",
    title="Context",
    tabs=[{"id": "doc", "label": "Document"}, {"id": "log", "label": "History"}],
    active_tab_var="ctx.tab"
) %}
  <template x-if="ctx.tab === 'doc'">{{ iframe_viewer(...) }}</template>
  <template x-if="ctx.tab === 'log'">{{ log_console(...) }}</template>
{% endcall %}
```

### `command_palette`
```
command_palette(id="cmdOpen", placeholder="Search commands...", model="cmdSearch", classes="")
```
`{% call %}` pattern — put result items inside.

### `popover`
```
popover(trigger="hover", position="bottom-start", width="md", offset="2", classes="")
```
`{% call %}` pattern.
| Param | Type | Default | Options |
|-------|------|---------|---------|
| trigger | string | `"hover"` | `"hover"` `"click"` |
| position | string | `"bottom-start"` | `"bottom-start"` `"bottom-center"` `"bottom-end"` `"top-start"` `"top-center"` `"top-end"` `"left"` `"right"` |
| width | string | `"md"` | `"sm"` `"md"` `"lg"` `"auto"` |

### `document_viewer`
```
document_viewer(id, title="Document", src="", src_model="", type="iframe",
                width="lg", height="lg",
                docs=None, default_doc="",
                email_data=None, email_data_var="",
                text_body="", text_body_var="")
```
Draggable, resizable, zoomable modal for documents. v2.12 (#349) adds multi-doc tabs, `type="email"`, `type="text"`, and `type="image"`. Single-doc callers with `type="iframe"` or `type="pdf"` get the exact pre-v2.12 DOM.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | *required* | Alpine boolean variable (e.g. `"docViewerOpen"`) — pass via `@click="docViewerOpen = true"`. |
| title | string | `"Document"` | Header strip title. |
| src | string | `""` | Document URL for single-doc mode. |
| src_model | string | `""` | Alpine expression for reactive `:src` / `:data`. Overrides static `src`. |
| type | string | `"iframe"` | `"iframe"` `"pdf"` `"email"` `"text"` `"image"`. Ignored when `docs` is set — each tab brings its own `type`. |
| width | string | `"lg"` | `"md"` `"lg"` `"xl"` `"full"`. |
| height | string | `"lg"` | `"md"` `"lg"` `"xl"`. |
| docs | list\|None | `None` | **v2.12.** Multi-doc tabs: `[{id, title, type, src?, email_data?, text_body?, alt?}]`. When set, renders a tab bar under the title; `type` / `src` / `email_data` / `text_body` top-level params are ignored. |
| default_doc | string | `""` | **v2.12.** Active tab id on open. Defaults to `docs[0].id`. |
| email_data | dict\|None | `None` | **v2.12.** Structured email payload for `type="email"` — see shape below. |
| email_data_var | string | `""` | **v2.12.** Alpine expression for reactive email payload. Composes with `email_data` (static fallback if expression evaluates falsy). |
| text_body | string | `""` | **v2.12.** Static text for `type="text"`. Rendered in `<pre class="whitespace-pre-wrap font-mono">`. |
| text_body_var | string | `""` | **v2.12.** Alpine expression for reactive text body. |

**`email_data` shape:**
```python
email_data = {
  "from_name": "Acme Billing",           # optional display name
  "from": "vendor@example.com",          # email address
  "to": ["ap@ifpusa.com", ...],          # list of strings
  "cc": ["manager@ifpusa.com"],          # optional
  "subject": "Invoice 5512 — 30-day terms",
  "date": "Apr 15, 2026 · 9:32 AM",      # pre-formatted by server
  "body_html": "<p>...</p>",             # sandboxed iframe, no allow-scripts
  "body_text": "Terms attached.",        # fallback when body_html is empty
  "attachments": [
    {"filename": "inv5512.pdf", "size": "142 KB",
     "url": "/att/1/inv5512.pdf", "type": "pdf"},
  ],
}
```

**Security (type="email") — two required layers:**

1. **Kit layer — sandboxed iframe.** `body_html` renders inside `<iframe sandbox="allow-same-origin">`. The sandbox explicitly omits `allow-scripts`, `allow-forms`, `allow-popups`, `allow-top-navigation` — `<script>` tags, event handlers (`onclick=`, `onload=`), `javascript:` URLs, and form submits cannot execute. Static path: `srcdoc="{{ body_html | e }}"` (Jinja autoescape). Reactive path: `:srcdoc="ed.body_html"` (Alpine attribute-value escaping).

2. **Consumer layer — server-side sanitization.** The sandbox prevents *script execution* but not *malicious rendering* — phishing forms, misleading links, tracking pixels, CSS exfiltration. Consumers MUST sanitize `body_html` on the server before passing it to the macro:

   ```python
   import bleach
   ALLOWED_TAGS = ["p", "br", "div", "span", "a", "strong", "em", "ul", "ol",
                   "li", "table", "tr", "td", "th", "thead", "tbody", "img",
                   "blockquote", "h1", "h2", "h3", "h4", "pre", "code"]
   ALLOWED_ATTRS = {"a": ["href", "title"], "img": ["src", "alt", "width", "height"],
                    "*": ["class"]}
   clean_html = bleach.clean(raw_body_html, tags=ALLOWED_TAGS,
                              attributes=ALLOWED_ATTRS, strip=True)
   email_data["body_html"] = clean_html
   ```

   The kit does NOT ship a sanitizer — bring your own.

Header values (`from`, `to`, `cc`, `subject`, `date`) render through `| e` autoescape. Attachment links are `target="_blank" rel="noopener"` and never auto-download — the user explicitly opens them.

**Server-side parsing (consumer's responsibility):**
```python
# EML (stdlib)
import email
from email import policy

msg = email.message_from_bytes(raw_bytes, policy=policy.default)
email_data = {
    "from": msg.get("From", ""),
    "subject": msg.get("Subject", ""),
    "date": msg.get("Date", ""),
    "to": [a.strip() for a in (msg.get("To") or "").split(",") if a.strip()],
    "body_html": msg.get_body(("html",)).get_content() if msg.get_body(("html",)) else None,
    "body_text": msg.get_body(("plain",)).get_content() if msg.get_body(("plain",)) else None,
    "attachments": [...],
}

# MSG (third-party)
import extract_msg
msg = extract_msg.openMsg(io.BytesIO(raw_bytes))
email_data = {
    "from_name": msg.sender,
    "subject": msg.subject,
    "body_html": msg.htmlBody.decode("utf-8", errors="replace") if msg.htmlBody else None,
    "body_text": msg.body,
    "attachments": [{"filename": a.longFilename or a.shortFilename,
                     "size": f"{len(a.data) // 1024} KB",
                     "url": f"/att/{tok}/{a.longFilename}"} for a in msg.attachments],
}
```

The kit ships neither parser. Consumer's `utils/email_parser.py` (COE pattern) should produce this dict shape.

**Examples:**
```jinja
{# Single PDF — pre-v2.12 callers unchanged #}
{{ document_viewer("v", "Invoice", src="/pdf/inv.pdf", type="pdf") }}

{# Single email (static dict) #}
{{ document_viewer("v", "Customer Reply", type="email", email_data=parsed_email) }}

{# Multi-doc tabs: invoice, packing slip, email thread #}
{{ document_viewer("v", docs=[
     {"id": "inv", "title": "Invoice 5512", "type": "pdf", "src": "/pdf/inv.pdf"},
     {"id": "ps",  "title": "Packing Slip", "type": "pdf", "src": "/pdf/ps.pdf"},
     {"id": "thread", "title": "AP Thread", "type": "email",
      "email_data": thread_email_data},
     {"id": "log", "title": "Processing Log", "type": "text",
      "text_body": process_log_str},
]) }}

{# Reactive email (Alpine-driven) — row click sets currentEmail, viewer follows #}
{{ document_viewer("v", type="email", email_data_var="currentEmail") }}
```

### `blocking_modal`
```
blocking_modal(id="showBlocker", type="error", title="", message="", ref_id="", action="", action_fn="", show_ref=none)
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| type | string | `"error"` | `"error"` `"denied"` `"maintenance"` |
| show_ref | bool | `none` | Auto: true for error, false for denied/maintenance |

### `auth_modal`
```
auth_modal(id, title="Sign In", auth_url="/auth/login", success_redirect="", session_expired=false, on_success="", credentials="same-origin", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | *required* | Alpine.js state variable prefix |
| title | string | `"Sign In"` | Modal heading |
| auth_url | string | `"/auth/login"` | POST endpoint for credentials |
| success_redirect | string | `""` | URL to redirect after login; empty = close modal |
| session_expired | bool | `false` | Show "session expired" warning; makes modal non-dismissible |
| on_success | string | `""` | JS expression to run on success (instead of redirect) |
| credentials | string | `"same-origin"` | Fetch credentials mode |
| classes | string | `""` | Extra CSS classes |

Alpine state: `username`, `password`, `error`, `loading`

### `auth_redirect_modal`
```
auth_redirect_modal(id, title="Access Restricted", message="You don't have access to this page.", destinations=[], contact="", home_url="/", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | *required* | Alpine.js state variable prefix |
| title | string | `"Access Restricted"` | Modal heading |
| message | string | `"You don't have access to this page."` | Explanation text |
| destinations | list | `[]` | `[{label, url, icon?}]` — empty shows "Go to Home" fallback |
| contact | string | `""` | Admin contact info |
| home_url | string | `"/"` | Fallback home link URL |
| classes | string | `""` | Extra CSS classes |

Non-dismissible — no close button, no escape key.

### `api_key_modal`
```
api_key_modal(id, title="API Authentication", auth_url="/auth/api-key", key_label="API Key", help="", success_redirect="", on_success="", credentials="same-origin", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | *required* | Alpine.js state variable prefix |
| title | string | `"API Authentication"` | Modal heading |
| auth_url | string | `"/auth/api-key"` | POST endpoint |
| key_label | string | `"API Key"` | Input label |
| help | string | `""` | Help text below input |
| success_redirect | string | `""` | URL after success; empty = close modal |
| on_success | string | `""` | JS expression on success |
| credentials | string | `"same-origin"` | Fetch credentials mode |
| classes | string | `""` | Extra CSS classes |

Alpine state: `apiKey`, `error`, `loading`

---

## Alerts & Feedback

### `alert`
```
alert(message, type="info", dismissible=false, icon=true, classes="", x_show="",
      message_var="", action_label="", on_action="", action_type="", action_url="",
      secondary_action_label="", on_secondary_action="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| type | string | `"info"` | `"info"` `"success"` `"warning"` `"error"` |
| action_label | string | `""` | When set, renders a primary action button (or anchor — see `action_url`) |
| on_action | string | `""` | Alpine `@click` handler — preferred for SPA retries |
| action_url | string | `""` | `<a href>` fallback when the action is a navigate-away link (e.g. "Refresh page"). Mutually exclusive with `on_action`; if both are set, `on_action` wins |
| action_type | string | (auto) | Override the button variant (`"primary"` / `"secondary"` / `"danger"` / `"ghost"`); defaults based on alert `type` |
| secondary_action_label | string | `""` | Ghost-styled second button (e.g. "Cancel" next to "Retry") |

### `toast_container`
```
toast_container(position="top-right", classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| position | string | `"top-right"` | `"top-right"` `"top-left"` `"bottom-right"` `"bottom-left"` |

Dispatch: `new CustomEvent('toast', {detail: {type, title?, message, sticky?, duration?, image?, image_alt?, icon?}})`

**v2.6 — custom icon / image.** Optional `detail.image` (URL → rounded thumbnail), `detail.image_alt` (a11y), and `detail.icon` (raw SVG string via `x-html`). Precedence: `image` > `icon` > default type SVG. Toasts that omit all three render identically to prior versions. Image load errors fall through to the icon / type SVG (via `@error`).

### `inline_confirm`
```
inline_confirm(id="confirming", message="Are you sure?", confirm_label="Yes, delete", cancel_label="Cancel", confirm_type="danger", on_confirm="", classes="")
```

### `spinner`
```
spinner(size="md", color="primary", label="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| size | string | `"md"` | `"sm"` `"md"` `"lg"` |
| color | string | `"primary"` | `"primary"` `"blue"` `"green"` `"red"` `"gray"` `"white"` `"indigo"` `"orange"` `"cyan"` |
| label | string | `""` | Optional caption next to the spinner |

### `loading_overlay`
```
loading_overlay(text="", icon="", size="md", classes="")
```
Covers parent (parent must be `position: relative`).

### `skeleton`
```
skeleton(type="text", lines=3, rows=5, classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| type | string | `"text"` | `"text"` `"card"` `"avatar"` `"table"` `"stat"` |

### `empty_state`
```
empty_state(title, message="", action_label="", action_url="", action_onclick="", icon_svg="")
```

### `progress_bar`
```
progress_bar(value=0, max=100, color="auto", size="md", show_label=false,
             value_var="", max_var="")
```
`color="auto"` picks color by percentage (red<50, amber<75, blue<100, green=100).

**v2.20 (#406) — reactive progress.** `value_var` / `max_var` bind the width, ARIA value, and label via Alpine `:style` + `x-text` against live JS expressions. With `color="auto"`, the bar color also re-computes from the current percent on every update. The static `value=` / `max=` are still rendered so the bar paints correctly before Alpine hydrates.

```jinja2
{{ progress_bar(value=0, max=100,
                value_var="run.successful + run.failed",
                max_var="run.expected",
                color="primary", show_label=true) }}
```

---

## Navigation

### `nav_bar`
```
nav_bar(items, user, active_page, root_path="", app_name="App", app_icon="", collapsible=true)
```
Collapsible sidebar. Items: `[{id, label, url, icon, roles}]`. Deny by default — only listed roles see each item.

### `nav_bar_top`
```
nav_bar_top(items, user, active_page, root_path="")
```
Horizontal icon bar with tooltips.

### `suite_header`
```
suite_header(brand_name="", brand_logo_src="", brand_subtitle="",
             services=[], active_key="",
             show_theme_toggle=true, right_slot="",
             accent_class="border-primary-500",
             nav="", search="", actions="", user="")
```
Top-bar chrome for multi-service suites (COE / CAW / RDB). Dark gradient header with brand block, suite-service pills, optional `theme_toggle`, caller or `right_slot` for app-specific controls, and a bottom accent stripe. Header stays dark in both themes; the page below flips via `$store.ui.darkMode`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| brand_name | string | `""` | App name (top line) |
| brand_logo_src | string | `""` | Logo image URL |
| brand_subtitle | string | `""` | Second-line text (e.g. `"v2.6.0"`) |
| services | list | `[]` | `[{key, label, url, health_var?, active?}]`. `health_var` is an Alpine expression bound to `status_dot(variant_var=…)`. |
| active_key | string | `""` | Alternative to per-entry `active: true`. Per-entry `active` wins when both set. |
| show_theme_toggle | bool | `true` | Render `theme_toggle` in the right cluster |
| right_slot | string | `""` | Trusted HTML string, rendered with `\| safe`. Prefer `{% call suite_header(...) %}…{% endcall %}` for multi-line or Jinja-templated content — caller wins over `right_slot` when both are set. |
| accent_class | string | `"border-primary-500"` | Bottom stripe color |

Responsive: md+ shows the pill list; below md collapses to a hamburger dropdown. Theme toggle stays visible at every breakpoint.

**v2.8 (#329) — slotted layout.** Four new optional params let services collapse their separate page_header.html into the suite bar:

| Slot | Purpose |
|------|---------|
| `nav` | Typically a `tab_bar(chrome="dark")` for service-level navigation — renders with `flex-1` in the middle zone. |
| `search` | `search_with_results(...)` or similar — shrink-0 to the right of `nav`, hidden below `md`. |
| `actions` | Engine state, pause-all, profile menu — sits left of the theme toggle. |
| `user` | User menu / avatar — between `actions` and the theme toggle. |

Slots are **strings** (macro output captured via Jinja's `{% set … %}{{ … }}{% endset %}`):

```jinja
{% set nav %}{{ tab_bar(tabs=[…], chrome="dark") }}{% endset %}
{% set search %}{% call search_with_results(…) %}…{% endcall %}{% endset %}
{% set actions %}{{ button("Pause All", …) }}{% endset %}
{{ suite_header(brand_name="COE", services=[…], active_key="coe",
                nav=nav, search=search, actions=actions) }}
```

The existing `services`, `right_slot`, and `{% call suite_header %}...{% endcall %}` caller are preserved. v2.7 callers render byte-identical.

**Trust model (important).** All slot strings (`nav`, `search`, `actions`, `user`, `right_slot`, and the `{% call %}` caller body) are rendered with `| safe` — autoescape is bypassed. Build slot strings exclusively from ui-kit macro calls or pre-escaped server-rendered markup. **Never inject unescaped user-controlled data** (display names, search queries, API responses) directly into a slot string — it will render as raw HTML. If user data must appear in a slot, render it through a macro that escapes internally, or pre-escape with `| e` inside the `{% set %}` block.

**Adopting in an app that extends `base/base.html`:** override `{% block header %}` (v2.6.1+) to replace the built-in top bar outright — otherwise you'll render both the stock header and your `suite_header` (double-header). Example:

```jinja
{% extends "base/base.html" %}

{% block header %}
  {{ suite_header(brand_name="COE", services=[...], active_key="coe") }}
{% endblock %}
```

```jinja
{{ suite_header(
    brand_name="Core Orchestration Engine",
    brand_subtitle="v" ~ version,
    services=[
      {"key": "coe", "label": "COE", "url": "/coe/", "health_var": "suiteHealth.coe"},
      {"key": "caw", "label": "CAW", "url": "/caw/", "health_var": "suiteHealth.caw"},
      {"key": "rdb", "label": "RDB", "url": "/rdb/", "health_var": "suiteHealth.rdb"},
    ],
    active_key="coe"
) }}
```

### `theme_toggle`
```
theme_toggle(size="md", classes="")
```
Sun/moon button bound to `$store.ui.darkMode` (core-stores.js). Shows a moon in light mode and a sun in dark mode. Neutral base colors (`opacity-80`, inherited text, `hover:bg-gray-500/10`) compose on both light toolbars and dark suite headers.

| Param | Type | Default | Options |
|-------|------|---------|---------|
| size | string | `"md"` | `"sm"` `"md"` `"lg"` |
| classes | string | `""` | Extra utility classes |

### `nav_bar_top_panel`
```
nav_bar_top_panel(items, user, active_page, root_path="", app_name="", app_icon="")
```
`{% call %}` pattern — right-side slot via caller. Items with `panel_id` trigger slide-down panels.

### `nav_panel`
```
nav_panel(id, classes="")
```
`{% call %}` pattern — content for a slide-down panel.

### `nav_item`
```
nav_item(label, url, icon_svg, active=false)
```

### `nav_submenu`
```
nav_submenu(label, icon_svg, items, open=false)
```
| Param | Type | Notes |
|-------|------|-------|
| items | list | `[{label, url, active?}]` |

### `sidebar_user`
```
sidebar_user(name, role="", color="primary", logout_url="/logout", classes="")
```

### `tab_bar`
```
tab_bar(tabs, model="activeTab", classes="", on_click="", count_var="", chrome="light")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| tabs | list | *required* | `[{id, label, accent?, on_click?, x_if?, kind?, menu_*?}]` |
| model | string | `"activeTab"` | Alpine binding for the active tab id |
| on_click | string | `""` | Fires on every tab click after the model update |
| count_var | string | `""` | Alpine object indexed by tab id — renders a count pill when the entry exists |
| chrome | string | `"light"` | **v2.7 (#308).** `"dark"` swaps to a curated palette (`text-gray-300` → `text-white` hover, `text-primary-300` active) for tabs placed inside a dark chrome bar (e.g. nested in `suite_header`). Doesn't depend on the `.dark` ancestor class. |

**Tab entry fields:**
| Field | Notes |
|-------|-------|
| id | Unique identifier (matches `model`) |
| label | Display text |
| accent | Tone key (`"primary"`, `"green"`, ... 11-color palette) for the active-state border/text |
| on_click | Per-tab Alpine expression, fires after macro-level `on_click` |
| x_if | Alpine expression; when set, the tab renders inside `<template x-if>` (conditional mount) |
| kind | **v2.7 (#309).** `"menu"` → render as a dropdown picker instead of a plain tab. See below. |
| menu_items_var | Alpine expression returning an array (used when `kind="menu"`) |
| menu_item_key | Field name for `:key` on the `x-for` (default `"id"`) |
| menu_item_label | Field name for the item label (default `"label"`) |
| menu_item_on_click | Function name called with the item as arg (e.g. `"navigateToProcess"` → `navigateToProcess(item)`) |
| menu_header | Optional fixed entries rendered before the `x-for`: `[{label, on_click}]` |

```jinja
{{ tab_bar(tabs=[
    {"id": "picker", "label": "Master Dashboard", "kind": "menu",
     "menu_items_var": "processes", "menu_item_on_click": "navigateToProcess",
     "menu_header": [{"label": "All processes", "on_click": "navigateToMaster()"}]},
    {"id": "services", "label": "Services"}
], chrome="dark") }}
```

### `breadcrumb`
```
breadcrumb(items)
```
| Param | Type | Notes |
|-------|------|-------|
| items | list | `[{label, url?}]` — last item is current page (no link) |

---

## Layout & Display

### `card`
```
card(title="", subtitle="", padding="p-4", classes="",
     icon_svg="", header_right="", footer="", body_classes="", variant="plain",
     x_style_var="",
     clickable=false, on_click="", aria_label_var="",
     collapsible=false, open_var="")
```
`{% call %}` pattern. Caller block is the body.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| title | string | `""` | Header text. Omit to suppress the header block entirely. |
| subtitle | string | `""` | Secondary text under title. |
| padding | string | `"p-4"` | Body padding. Applies to the caller-block wrapper in `sectioned`, to the outer wrapper in `plain`. |
| classes | string | `""` | Extra CSS appended to the outer wrapper. |
| icon_svg | string | `""` | **v2.11 (#344).** Raw SVG placed before the title. Rendered with `\| safe`. Pass a server-escaped SVG literal — never interpolate user input. |
| header_right | string | `""` | **v2.11 (#344).** Trusted HTML string for the right-aligned header slot (toggles, action buttons, metadata). Rendered with `\| safe`. **Trust model**: must be pre-escaped server markup, a `Markup(...)` wrap, or the rendered output of other macro calls. Never pass DB text or request body content directly. Prefer `{% call card(...) %}…{% endcall %}` or composing via `button(...) \| string` when the content is complex. |
| footer | string | `""` | **v2.11 (#344).** Trusted HTML string for a bottom footer row. Rendered with `\| safe`. Same trust model as `header_right`. |
| body_classes | string | `""` | **v2.11 (#344).** Extra CSS on the caller-block wrapper (e.g. `space-y-2 divide-y`). |
| variant | string | `"plain"` | **v2.11 (#344).** `"plain"` (default) preserves pre-v2.11 DOM exactly when no new params are set. `"sectioned"` wraps in a bordered shell with `bg-gray-50 dark:bg-gray-700` header + bordered footer — the COE dashboard card style. |
| x_style_var | string | `""` | **v2.20 (#402).** Alpine `:style` expression on the outer wrapper — e.g. `"'grid-column: span ' + item.w"` for user-resizable dashboard tiles. |
| clickable | bool | `false` | **v2.20 (#405).** Outer wrapper becomes a button-role element: `role="button"`, `tabindex="0"`, `@click` / `@keydown.enter.prevent` / `@keydown.space.prevent` all fire `on_click`, plus a `focus-visible:ring-2 focus-visible:ring-primary-400/50` keyboard-focus ring and `hover:shadow-md` affordance. Suppressed when `collapsible=true` so the header toggle is the only trigger. |
| on_click | string | `""` | **v2.20 (#405).** Alpine expression fired on click / keyboard activation when `clickable=true`. |
| aria_label_var | string | `""` | **v2.20 (#405).** Alpine expression for `:aria-label` — use when the label is data-driven (selection grids, process pickers). |
| collapsible | bool | `false` | **v2.20 (#401) — `variant="sectioned"` only.** Header becomes a toggle for `open_var`: chevron rotates, body wraps in `x-show` + `x-collapse`, footer collapses with the body. `header_right` content is wrapped in `@click.stop` so nested buttons don't toggle the card. No-op without `open_var`. |
| open_var | string | `""` | **v2.20 (#401).** Alpine boolean variable driving `collapsible`. Caller owns the `x-data` declaration. |

**Trust model (header_right / footer).** These params render via `{{ … \| safe }}`. The macro is safe for trusted markup (pre-escaped or composed via other macros) and unsafe for user-controlled strings. Same pattern as `suite_header.right_slot` (v2.8). If you need untrusted-content support, wrap with `{{ value \| e }}` at the call site, or feed the content through a `{% call %}` block and use inline Jinja which auto-escapes.

**Trust model (v2.20 Alpine-expression params — `x_style_var`, `on_click`, `aria_label_var`, `open_var`).** These are rendered verbatim into Alpine directives (`:style`, `@click`, `@keydown.enter.prevent`, `@keydown.space.prevent`, `:aria-label`, `x-show`, `x-collapse`). Treat them as **author-trusted JS expressions**, never as interpolation sinks for user data. If an expression has an unbalanced quote or a syntax error, Alpine silently drops the handler — for `on_click` that means all three of `@click` / `@keydown.enter` / `@keydown.space` break together. Keep these expressions short and side-effect-free (call a method on `x-data` rather than inlining business logic). Same contract the kit has carried since v2.11 for `x_bind_*`, `variant_var`, etc.; this note just restates it for the new card params.

```jinja
{# Plain variant — back-compat with pre-v2.11 callers #}
{% call card("Quick Actions") %}
  {{ button_toolbar(...) }}
{% endcall %}

{# Sectioned variant — bordered COE-style card with header toggle #}
{% call card("Core Services Database",
             subtitle="Shared config",
             variant="sectioned",
             icon_svg=svg_db,
             header_right=toggle_switch("archive_enabled",
                                        model="cfg.archive",
                                        layout="right")) %}
  {{ kv_row("Host", value_var="cfg.host") }}
  {{ kv_row("Port", value_var="cfg.port") }}
{% endcall %}
```

### `bordered_card`
```
bordered_card(title="", subtitle="", border="primary", padding="p-4", muted=false, classes="")
```
`{% call %}` pattern. Colored left border.
| Param | Type | Default | Options |
|-------|------|---------|---------|
| border | string | `"primary"` | `"primary"` `"green"` `"amber"` `"red"` `"blue"` `"purple"` `"gray"` |
| muted | bool | `false` | Reduced opacity for processed items |

### `record_card`
```jinja
record_card(id="", status="gray", status_var="", expanded_var="",
            on_expand="", actions=[], hover=true, compact=false,
            classes="")
```
`{% call %}` pattern with header + detail split on `<!-- detail -->` sentinel.
Expandable status-stripe card for queue / inbox / workflow views.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `""` | Card id (autogenerated if empty); used for aria wiring + expand event detail |
| status | string | `"gray"` | Static stripe color: `"green"` `"amber"` `"red"` `"blue"` `"gray"` `"primary"` |
| status_var | string | `""` | Alpine expression for reactive stripe color (takes precedence over `status`) |
| expanded_var | string | `""` | Alpine boolean on parent scope (preserves open state across SSE merges) |
| on_expand | string | `""` | Fires once per expand transition (not on collapse, not on re-render) |
| actions | list | `[]` | `[{icon, label, on_click}]` — right-aligned icon buttons; clicks stop-propagate |
| hover | bool | `true` | Hover highlight on header row |
| compact | bool | `false` | Tighter padding |

Container-query aware — header cluster wraps below `@sm`. Header only renders
as a toggle when a detail region is supplied; otherwise it's static content.
Every expand dispatches a `record-card:expand` `CustomEvent` with
`{ detail: { id } }` for list-level observers; `on_expand` runs in the card's
local Alpine scope (no `$event`).

```html
{% from "components/ui.html" import record_card %}
{% call record_card(
    id="rec_42",
    status_var="item.state",
    expanded_var="item._open",
    on_expand="loadDetail(item.id)",
    actions=[{"icon": "<svg>...</svg>", "label": "Archive",
              "on_click": "archive(item.id)"}]
) %}
  <span class="font-medium" x-text="item.title"></span>
  <span class="text-xs text-gray-500" x-text="item.updated_at"></span>
  <!-- detail -->
  <div x-text="item.detail"></div>
{% endcall %}
```

### `stat_card`
```
stat_card(label, value, icon_svg="", color="primary", trend="", trend_up=true,
          label_var="", value_var="", trend_var="", value_html="", classes="",
          link="", link_var="")
```

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| label / value | string | required | Static text (swap to `label_var` / `value_var` for reactive). |
| icon_svg | string | `""` | Raw SVG rendered with `\| safe` in the colored icon bubble. |
| color | string | `"primary"` | `"primary"` `"green"` `"blue"` `"red"` `"amber"` `"purple"` `"teal"` |
| trend / trend_up / trend_var | — | — | Up/down indicator; `trend_var` is Alpine-reactive. |
| value_html | string | `""` | Trusted HTML for the value (rendered with `\| safe`). |
| link | string | `""` | **v2.11 (#344).** When set, wraps the card in `<a href>` with hover shadow + focus ring. |
| link_var | string | `""` | **v2.11 (#344).** Alpine expression for reactive `:href`. Forces `<a>` even without static `link`. |

When `link` / `link_var` is set, the outer `<a>` receives a visible `focus-visible:ring-2 focus-visible:ring-primary-400/50` for keyboard accessibility, plus `no-underline` so the stat label doesn't render underlined.

### `service_health_card`
```
service_health_card(name, display, status="unknown", version="", details=[],
                    url="", is_self=false, classes="", status_var="")
```
| Param | Type | Notes |
|-------|------|-------|
| details | list | `[{label, value}]` |
| is_self | bool | Shows "This Service" ribbon |
| status_var | string | **v2.20 (#399).** Alpine expression returning one of `"healthy"`, `"degraded"`, `"down"`, `"unknown"`. Border color, health-indicator dot, label, and link state (`pointer-events-none` on `down`) all bind via `:class` against the expression. The same var is passed through to the nested `health_indicator`. Static `status=` is still rendered as the pre-hydration fallback. |

### `page_heading`
```
page_heading(title, subtitle="", actions="")
```

### `page_footer`
```
page_footer(app_name="App", version="", year="", links=[], classes="")
```
| Param | Type | Notes |
|-------|------|-------|
| links | list | `[{label, url}]` |

### `divider`
```
divider(label="", orientation="horizontal", classes="")
```
| Param | Type | Default | Options |
|-------|------|---------|---------|
| orientation | string | `"horizontal"` | `"horizontal"` `"vertical"` |
| label | string | `""` | Centered text label |

### `accordion`
```
accordion(items, multi=false, depth=0, classes="")
```
| Param | Type | Notes |
|-------|------|-------|
| items | list | `[{id, title, content?, children?}]` — children = nested accordion |
| multi | bool | Allow multiple open at once |
| depth | int | Internal — do not pass |

### `description_list`
```
description_list(items, columns=1, striped=false, classes="")
```
| Param | Type | Notes |
|-------|------|-------|
| items | list | `[{label, value}]` or `[(label, value)]` |
| columns | int | 1 or 2 |

### `timeline`
```
timeline(items, classes="")
```
| Param | Type | Notes |
|-------|------|-------|
| items | list | `[{title, description?, time?, icon_svg?, variant?}]` |

### `stepper`
```
stepper(steps, current=0, model="", classes="")
```
| Param | Type | Notes |
|-------|------|-------|
| steps | list | `[{label, description?}]` |
| current | int | 0-based active step (static) |
| model | string | Alpine.js variable (dynamic, overrides current) |

### `wizard`
```jinja
wizard(id="wiz", steps=[], current_var="wizard.current", max_var="wizard.max",
       on_finish="", back_label="Back", next_label="Next",
       finish_label="Finish", allow_back=true, classes="")
```
Scoped-caller macro. Composes `stepper` with Back / Next / Finish navigation,
per-step validation gates, monotonic `max_var`, keyboard shortcuts
(Alt+← / Alt+→ / Enter), and window events for transitions.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"wiz"` | Used in every `wizard:*` event detail |
| steps | list | `[]` | `[{id, label, description?, can_advance?}]` — `can_advance` is an Alpine expression; missing = always advanceable |
| current_var | string | `"wizard.current"` | Alpine variable holding active index (macro writes to it) |
| max_var | string | `"wizard.max"` | Monotonic furthest-reached index; gates clickable dots |
| on_finish | string | `""` | Alpine expression fired on the last step's Finish button |
| back_label / next_label / finish_label | string | `"Back"` / `"Next"` / `"Finish"` | Button labels |
| allow_back | bool | `true` | Set to `false` for wizards that shouldn't be reversed mid-flow |

**Events** (all fire on `window`, with `{id, from, to}`): `wizard:advance`,
`wizard:back`, `wizard:jump`, `wizard:finish`.

**Scoped caller** — `{% call(step) wizard(...) %}` runs once per step; only
the active step mounts (`<template x-if>`), so step bodies can safely do
their own `async_state` fetches on entry.

```html
{% from "components/ui.html" import wizard %}
{% call(step) wizard(
    id="reportBuilder",
    steps=[
      {"id": "source",  "label": "Data source", "can_advance": "form.source"},
      {"id": "metrics", "label": "Metrics",     "can_advance": "form.metrics.length > 0"},
      {"id": "review",  "label": "Review"}
    ],
    on_finish="submitReport()",
    finish_label="Create report"
) %}
  {% if step.id == "source" %}{{ data_source_panel() }}{% endif %}
  {% if step.id == "metrics" %}{{ metrics_picker() }}{% endif %}
  {% if step.id == "review" %}{{ review_panel() }}{% endif %}
{% endcall %}
```

### `pipeline_flow`
```
pipeline_flow(steps=none, classes="", steps_var="")
```
| Param | Type | Notes |
|-------|------|-------|
| steps | list | `[{label, service?, status?}]` — status: `"active"` `"complete"` `"inactive"` `"error"` |
| steps_var | string | **v2.20 (#399).** Alpine expression returning a JS array of `{label, status, service?}` objects. Emits `<template x-for>` so dots, labels, connectors, and connector visibility all react to live status. Mutually exclusive with `steps=` (use one or the other). |

### `tooltip`
```
tooltip(text, position="top")
```
`{% call %}` pattern.

### `truncate_text`
```
truncate_text(text, lines=3, model="", classes="")
```

### `kbd`
```
kbd(keys, size="sm", classes="")
```
| Param | Type | Notes |
|-------|------|-------|
| keys | string | `+`-separated: `"Ctrl+K"`, `"Enter"`, `"Ctrl+Shift+P"` |

---

## Log Console

### `log_console`
```
log_console(mode="standalone", id="logConsoleOpen", model="[]", title="Console",
            height="md", max_lines=1000, show_time=true, show_source=false, classes="",
            sse_url="", initial_fetch_url="", lazy=false,
            line_select=false, expand_tracebacks=false, source_filter=false, file_line_jump=false,
            resizable=false, default_height_px=None, min_height_px=120, max_height_vh=80,
            persist_height=false, wrap_long_lines=true)
```
| Param | Type | Default | Options / notes |
|-------|------|---------|-----------------|
| mode | string | `"standalone"` | `"standalone"` / `"dock"` / `"bottom_panel"` / `"raw"` |
| height | string | `"md"` | `"sm"` (200px) / `"md"` (300px) / `"lg"` (400px) / `"xl"` (500px) |
| model | string | `"[]"` | Alpine expression for the entries array. Default empty-array literal (v2.8.1 fix) — macro is self-contained; entries arrive via the `log` CustomEvent. |
| sse_url | string | `""` | Auto-opens `EventSource`, parses each payload into `addEntry`. Disconnects on unmount. (v2.7) |
| initial_fetch_url | string | `""` | One-shot fetch on mount; `lazy=true` defers to first expand. (v2.7) |
| line_select | bool | `false` | Click / shift-click row selection; window Ctrl+C copies selected lines. (v2.7) |
| expand_tracebacks | bool | `false` | Multi-line messages show a chevron; click to expand. (v2.7) |
| source_filter | bool | `false` | Dropdown of distinct `entry.source` values. (v2.7) |
| file_line_jump | bool | `false` | `entry.source` matching `file.py:123` renders as a link; dispatches `navigate-to-source`. (v2.7) |
| resizable | bool | `false` | **v2.9.** `bottom_panel` / `dock` only. Adds a 4px drag handle at top. Drag up to grow, double-click to reset. |
| default_height_px | int\|None | `None` | **v2.9.** Starting / reset height in px. When `None`, resolves from `height` (sm=200/md=300/lg=400/xl=500). |
| min_height_px | int | `120` | **v2.9.** Floor for the drag — header + toolbar + ~1 row. |
| max_height_vh | int | `80` | **v2.9.** Ceiling as % of viewport height. Clamps initial size + upward drag. |
| persist_height | bool | `false` | **v2.9.** Save user's chosen height to `localStorage` under `logConsole.<id>.height`; restored on mount. Reset clears the key. |
| wrap_long_lines | bool | `true` | **v2.9.** `true` → `overflow-x-hidden` + `break-all` (v2.7 behavior). `false` → `overflow-x-auto` + `whitespace-pre` for full traceback visibility. |
| density | string | `"normal"` | **v2.9.1 (#336).** `"normal"` keeps v2.9 row spacing (~21px/row: `py-0.5` + row borders). `"compact"` drops padding + borders and forces `leading-4` for ~16px/row (`tail -f` feel). |
| level_weight | string | `"bold"` | **v2.10 (#342).** Tailwind weight (`normal` / `medium` / `semibold` / `bold`) on the level `<td>`. Default `"bold"` reads as a halo on colored text at text-[10px] in dark mode; `"normal"` drops the glow. |
| level_size | string | `"[10px]"` | **v2.10.** Arbitrary-value-safe token applied as `text-{size}` on the level `<span>`. |
| level_colors | dict | `{}` | **v2.10.** Per-level text-color override map. Keys in the dict win; absent keys fall through to the built-in INFO/WARNING/ERROR/DEBUG palette. |
| level_row_bg | dict | `{}` | **v2.10.** Per-level row-background tint override map. Default built-in tints ERROR red; pass `{"ERROR": ""}` to disable, or extend the map to tint WARNING / INFO. |
| time_weight / time_size / time_color | string | `""` | **v2.10.** Empty = no override; built-in `text-gray-400 dark:text-gray-500` stays. Any value emits the corresponding Tailwind token on the time `<td>`. |
| source_weight / source_size / source_color / source_max_width | string | mixed | **v2.10.** Same pattern for the source cell. `source_max_width` defaults to `"max-w-[160px]"` (matches v2.9). |

Push entries: `new CustomEvent('log', {detail: {level, message, time?, source?, traceback?}})`
Levels: `"DEBUG"` `"INFO"` `"WARNING"` `"ERROR"`

---

## Cards (specialized)

### `contact_card`
```
contact_card(name, role="", department="", email="", phone="", status="", status_variant="gray", avatar_color="primary", fields=[], on_click="", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| name | string | *required* | Person's name (also used for avatar initial) |
| role | string | `""` | Job title / role |
| department | string | `""` | Department or team |
| email | string | `""` | Email address (rendered as mailto link) |
| phone | string | `""` | Phone number (rendered as tel link) |
| status | string | `""` | Status badge text |
| status_variant | string | `"gray"` | Badge variant color |
| avatar_color | string | `"primary"` | Avatar circle color |
| fields | list | `[]` | `[{label, value}]` — extra key-value fields |
| on_click | string | `""` | Alpine.js click handler (makes card clickable) |
| classes | string | `""` | Additional CSS classes |

```html
{{ contact_card("Jane Cooper", role="Regional Sales Manager", department="Sales", email="jcooper@example.com", phone="555-0142", status="Active", status_variant="green") }}
```

### `item_card`
```
item_card(title, subtitle="", icon_svg="", image_url="", status="", status_variant="gray", fields=[], on_click="", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| title | string | *required* | Item title |
| subtitle | string | `""` | Secondary text |
| icon_svg | string | `""` | SVG icon markup |
| image_url | string | `""` | Image URL (takes precedence over icon) |
| status | string | `""` | Status badge text |
| status_variant | string | `"gray"` | Badge variant color |
| fields | list | `[]` | `[{label, value}]` — typed detail fields |
| on_click | string | `""` | Alpine.js click handler (makes card clickable) |
| classes | string | `""` | Additional CSS classes |

```html
{{ item_card("Ball Valve 2in", subtitle="SKU: BV-2000", status="In Stock", status_variant="green", fields=[{"label": "Price", "value": "$142.00"}, {"label": "Qty", "value": "38"}]) }}
```

### `document_card`
```
document_card(filename, doc_type="", status="", status_variant="gray", timestamp="", size="", page_count="", fields=[], actions=[], on_click="", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| filename | string | *required* | Document filename |
| doc_type | string | `""` | Type label (e.g., "PDF", "Invoice", "PO") |
| status | string | `""` | Status badge text |
| status_variant | string | `"gray"` | Badge variant color |
| timestamp | string | `""` | Date/time string |
| size | string | `""` | File size display (e.g., "2.4 MB") |
| page_count | string | `""` | Number of pages |
| fields | list | `[]` | `[{label, value}]` — extra metadata fields |
| actions | list | `[]` | `[{label, onclick, type?}]` — action buttons |
| on_click | string | `""` | Alpine.js click handler (makes card clickable) |
| classes | string | `""` | Additional CSS classes |

```html
{{ document_card("invoice-2024-001.pdf", doc_type="Invoice", status="Processed", status_variant="green", timestamp="2024-10-31", size="2.4 MB", page_count="3", actions=[{"label": "View", "onclick": "viewDoc(1)"}, {"label": "Delete", "onclick": "deleteDoc(1)", "type": "danger"}]) }}
```

---

## Dashboard Utilities

### `filter_toolbar`
```
filter_toolbar(classes="")
```
`{% call %}` pattern — place filter inputs inside.
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| classes | string | `""` | Additional CSS classes |

```html
{% call filter_toolbar() %}
  {{ select_field("Region", regions, model="filters.region") }}
  {{ date_range("Period", start_model="filters.start", end_model="filters.end") }}
  {{ button("Apply", type="primary", size="sm") }}
{% endcall %}
```

### `active_filters`
```
active_filters(filters_var="activeFilters", on_remove="", on_clear="", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| filters_var | string | `"activeFilters"` | Alpine.js array of `{key, label, value}` |
| on_remove | string | `""` | Alpine.js expression, receives `filter` |
| on_clear | string | `""` | Alpine.js expression to clear all |
| classes | string | `""` | Additional CSS classes |

```html
{{ active_filters(filters_var="activeFilters", on_remove="removeFilter(filter)", on_clear="clearAllFilters()") }}
```

### `metric_delta`
```
metric_delta(value, suffix="%", positive_is_good=true, size="md", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| value | number | *required* | Delta value (positive or negative) |
| suffix | string | `"%"` | Unit suffix |
| positive_is_good | bool | `true` | true = green for positive, false = red for positive |
| size | string | `"md"` | `"sm"` `"md"` `"lg"` |
| classes | string | `""` | Additional CSS classes |

```html
{{ metric_delta(12.5) }}          {# green "▲ +12.5%" #}
{{ metric_delta(-3.2) }}          {# red "▼ -3.2%" #}
{{ metric_delta(0) }}             {# gray "0%" #}
{{ metric_delta(5, suffix="pts", positive_is_good=false) }}  {# red — positive is bad #}
```

### `bulk_action_bar`
```
bulk_action_bar(count_var="selectedCount", on_clear="", classes="")
```
`{% call %}` pattern — place action buttons inside.
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| count_var | string | `"selectedCount"` | Alpine.js variable for selected item count |
| on_clear | string | `""` | Alpine.js expression to deselect all |
| classes | string | `""` | Additional CSS classes |

```html
{% call bulk_action_bar(count_var="selected.length", on_clear="selected = []") %}
  {{ button("Export", type="secondary", size="sm", onclick="exportSelected()") }}
  {{ button("Delete", type="danger", size="sm", onclick="deleteSelected()") }}
{% endcall %}
```

### `queue_stats`
```
queue_stats(segments, classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| segments | list | *required* | `[{label, count, variant}]` — proportional status segments |
| classes | string | `""` | Additional CSS classes |

```html
{{ queue_stats([
  {"label": "Pending", "count": 12, "variant": "amber"},
  {"label": "Processing", "count": 5, "variant": "blue"},
  {"label": "Complete", "count": 83, "variant": "green"},
  {"label": "Failed", "count": 2, "variant": "red"},
]) }}
```

---

## Data Visualization

### `tracker`
```
tracker(data, color_map={}, height="sm", gap="gap-0.5", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| data | list | *required* | List of status values (e.g., `["up", "up", "down", "up"]`) |
| color_map | dict | `{}` | `{status: variant}` mapping (e.g., `{"up": "green", "down": "red"}`) |
| height | string | `"sm"` | `"xs"` `"sm"` `"md"` |
| gap | string | `"gap-0.5"` | Tailwind gap class |
| classes | string | `""` | Additional CSS classes |

```html
{{ tracker(uptime_data, color_map={"up": "green", "degraded": "amber", "down": "red"}) }}
```

### `data_bar`
```
data_bar(segments, height="md", show_labels=false, classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| segments | list | *required* | `[{label, value, variant}]` — proportional segments |
| height | string | `"md"` | `"sm"` `"md"` `"lg"` |
| show_labels | bool | `false` | Show labels inside segments |
| classes | string | `""` | Additional CSS classes |

```html
{{ data_bar([
  {"label": "Pass", "value": 82, "variant": "green"},
  {"label": "Warn", "value": 12, "variant": "amber"},
  {"label": "Fail", "value": 6, "variant": "red"},
], show_labels=true) }}
```

---

## Communication

### `notification_panel`
```
notification_panel(id, items_var="notifications", on_read="", on_clear="", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | *required* | Alpine.js state variable prefix |
| items_var | string | `"notifications"` | Alpine.js array of `{id, title, message, time, read?, variant?}` |
| on_read | string | `""` | Alpine.js expression, receives `item` |
| on_clear | string | `""` | Alpine.js expression to clear all |
| classes | string | `""` | Additional CSS classes |

```html
{{ notification_panel("notifPanel", items_var="notifications", on_read="markRead(item)", on_clear="clearAll()") }}
```

### `chat_bubble`
```
chat_bubble(sender, time="", avatar_name="", avatar_color="primary", is_self=false, classes="")
```
`{% call %}` pattern — message content goes inside.
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| sender | string | *required* | Sender name |
| time | string | `""` | Timestamp text |
| avatar_name | string | `""` | Avatar initial source (defaults to sender) |
| avatar_color | string | `"primary"` | Avatar color variant |
| is_self | bool | `false` | Right-align as own message |
| classes | string | `""` | Additional CSS classes |

```html
{% call chat_bubble("Jane Cooper", time="2:34 PM", avatar_color="blue") %}
  <p>Can you check the latest shipment status?</p>
{% endcall %}

{% call chat_bubble("You", time="2:36 PM", is_self=true) %}
  <p>Done - tracking number updated.</p>
{% endcall %}
```

---

## Navigation (continued)

### `tree_view`
```
tree_view(items, model="", classes="")
```
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| items | list | *required* | `[{id, label, children?, icon_svg?, selectable?}]` — recursive |
| model | string | `""` | Alpine.js variable for selected item id |
| classes | string | `""` | Additional CSS classes |

```html
{{ tree_view([
  {"id": "root", "label": "Company", "children": [
    {"id": "sales", "label": "Sales", "children": [
      {"id": "iowa", "label": "Iowa Region"},
      {"id": "north", "label": "North Central"},
    ]},
    {"id": "ops", "label": "Operations"},
  ]},
], model="selectedNode") }}
```

---

## JS Utility Modules

These modules are in `src/ui_kit/static/js/` and work as both `<script>` tags (window globals) and ES modules.

### CoreAPI
Fetch wrapper with credential handling, CSRF double-submit, and 401 auth events.

| Method | Description |
|--------|-------------|
| `new CoreAPI({baseUrl, headers, apiKeyHeader, apiKey, csrfCookie, csrfHeader})` | Constructor |
| `fetchJSON(url, options)` | GET → parsed JSON |
| `postJSON(url, data, options)` | POST JSON → parsed JSON |
| `putJSON(url, data, options)` | PUT JSON → parsed JSON |
| `patchJSON(url, data, options)` | PATCH JSON → parsed JSON |
| `deleteJSON(url, options)` | DELETE → parsed JSON |
| `CoreAPI.configure({csrfCookie, csrfHeader})` | Static: set suite-wide CSRF names (accepts `csrf_cookie` / `csrf_header` aliases) |

Dispatches `auth-required` CustomEvent on 401 responses.

**CSRF:** On mutating methods (POST/PUT/PATCH/DELETE) the wrapper reads the `csrf_token` cookie and mirrors it into the `X-CSRF-Token` header — the double-submit pattern matched by the shared `ifp_core_contracts.suite.auth` server middleware. Override the cookie/header names per instance or globally via `CoreAPI.configure()`. If the cookie is absent the header is omitted (server middleware is the authority on rejection).

### CoreSSE
SSE connection manager with reconnect and visibility handling.

| Method/Property | Description |
|--------|-------------|
| `new CoreSSE(url, {onMessage, onConnect, onDisconnect, handlers, maxRetries, baseDelay, maxDelay, disconnectDelay, visibilityHandling})` | Constructor |
| `connect()` | Open SSE connection |
| `disconnect()` | Close connection |
| `destroy()` | Clean up all resources |
| `connected` | Boolean getter |

### CoreFormat
Display formatting utilities.

| Method | Description |
|--------|-------------|
| `formatLocalTime(timestamp)` | UTC → local time string |
| `formatDate(timestamp)` | Short date (e.g., "Jan 15, 2024") |
| `formatShortDateTime(timestamp)` | Compact datetime |
| `formatStatus(status)` | snake_case → Title Case |
| `getStatusColor(status)` | Tailwind badge classes (accent inversion) |
| `getStatusTextColor(status)` | Tailwind text-only classes |
| `copyToClipboard(text)` | Copy with fallback, returns Promise<boolean> |

---

## v2.0 macros

New since v2.0. All others kept their v1.x signatures unless documented in `docs/v2/MIGRATING-v2.md`.

### `code_block` <small>(components/code.html)</small>
```
code_block(source, language="text", filename="", classes="")
```
Pre-formatted dark-themed source with optional filename caption, language tag, and inline copy-to-clipboard button. Source is HTML-escaped via Jinja autoescape (XSS-safe). Outputs `<code class="language-{lang}">` so consumers can opt into Prism.js or highlight.js by including the lib in their base template — no required dependency.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| source | string | *required* | Source code; rendered escaped |
| language | string | `"text"` | Language hint for `<code class="language-…">` |
| filename | string | `""` | Caption row above the code |
| classes | string | `""` | Extra utility classes on the wrapper |

```html
{% from "components/code.html" import code_block %}
{{ code_block("def hello(): pass", language="python", filename="hello.py") }}
```

### `view_code_button` <small>(components/code.html)</small>
```
view_code_button(id, source, language="text", filename="", label="View code",
                 size="sm", placement="inline", classes="")
```
Small `</>`-icon trigger that opens a modal containing a `code_block` with the supplied source. Pairs with live demos in showcase pages so reviewers can read the source that produced the demo. Three placement modes mirror `help_link`. Each instance manages its own Alpine state via `{id}Open`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | *required* | Unique within the page; drives modal aria + Alpine state |
| source | string | *required* | Source body; passed through to `code_block` |
| language | string | `"text"` | |
| filename | string | `""` | |
| label | string | `"View code"` | Button text + tooltip + dialog title |
| size | string | `"sm"` | `"sm"` `"md"` |
| placement | string | `"inline"` | `"inline"` `"absolute"` `"corner"` (top-right of a card) |
| classes | string | `""` | |

```html
{% from "components/code.html" import view_code_button %}
{{ view_code_button("btnDemo", '{{ button("OK") }}', language="jinja", placement="corner") }}
```

### `doc_viewer` <small>(v1.8.0 — listed here for completeness)</small>
Modal markdown viewer with role-gated tabs, auto-built sidebar TOC from `h2`/`h3` anchors, scoped anchor scrolling, URL fragment deep-links, focus trap, themed prose typography. See `examples/doc_help.html` for a full demo and `src/ui_kit/templates/components/ui.html` for the macro source.

### `help_link` <small>(v1.8.0)</small>
Contextual `(?)` icon trigger in three placement modes that dispatches the `open-doc` window event with `{doc, anchor}` so it can open any paired `doc_viewer` from anywhere in the app.

### `doc_nav` <small>(v1.8.0)</small>
Navigation add-on for `doc_viewer`: fuzzy cross-doc search bar, IntersectionObserver-driven breadcrumb, prev/next section buttons that cross doc boundaries, recently-viewed in `localStorage`, and global `?` / `Ctrl+K` keyboard shortcuts.

### Container-query opt-in (v2.0)

Card and grid macros (`stat_card`, `data_table`, `accordion_table`, `category_card`, `service_health_card`, etc.) now ship `@xs:`/`@sm:`/`@md:` container variants alongside their existing `sm:`/`md:` viewport breakpoints. The new behavior activates only when a parent declares `class="@container"`:

```html
<div class="@container max-w-md">
  <div class="grid grid-cols-1 @md:grid-cols-2 gap-3">
    {{ stat_card("A", "1") }}
    {{ stat_card("B", "2") }}
  </div>
</div>
```

If no `@container` is declared, behavior is identical to v1.x. See `docs/v2/MIGRATING-v2.md` "Container queries" for the full list of macros that ship variants.

## v1.7.0 macros

Full entries for the 15 macros added in v1.7.0.

### `slider`
```
slider(model="sliderValue", min=0, max=100, step=1, label="",
       range_mode=false, min_model="rangeMin", max_model="rangeMax",
       show_value=true, color="primary", size="md", width="w-full",
       disabled=false, classes="", x_bind_disabled="", on_change="")
```
Single or dual-handle range input with colored track fill. Set `range_mode=true` to bind two values via `min_model` / `max_model`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `"sliderValue"` | Alpine model (single handle) |
| min / max / step | number | `0` / `100` / `1` | |
| label | string | `""` | |
| range_mode | bool | `false` | Enables dual handles |
| min_model / max_model | string | `"rangeMin"` / `"rangeMax"` | Used when `range_mode=true` |
| show_value | bool | `true` | Live value badge |
| color | string | `"primary"` | `"primary"` `"green"` `"blue"` `"amber"` `"red"` |
| size | string | `"md"` | `"sm"` `"md"` `"lg"` |
| width | string | `"w-full"` | |
| disabled | bool | `false` | |
| classes / x_bind_disabled / on_change | string | `""` | |

### `otp_input`
```
otp_input(model="otpCode", length=6, type="numeric", auto_submit=true,
          size="md", disabled=false, classes="",
          x_bind_disabled="", on_complete="")
```
Verification-code input with auto-advance and paste support. `model` receives the joined string when length is reached.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `"otpCode"` | |
| length | int | `6` | |
| type | string | `"numeric"` | `"numeric"` (digits only) `"alphanumeric"` |
| auto_submit | bool | `true` | Fires `on_complete` when full |
| size / disabled / classes / x_bind_disabled / on_complete | — | — | |

### `scroll_area`
```
scroll_area(height="h-64", direction="vertical", auto_hide=true, classes="")
```
Wraps `caller()` content in a scrollable region with thin auto-hiding scrollbars.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| height | string | `"h-64"` | Tailwind height class |
| direction | string | `"vertical"` | `"vertical"` `"horizontal"` `"both"` |
| auto_hide | bool | `true` | Scrollbar fades when not interacting |

### `context_menu`
```
context_menu(id="ctxMenu", items=[], classes="")
```
Right-click contextual menu with submenus, icons, keyboard shortcuts. `items` is a list of `{label, icon, shortcut, action, divider, items}` (nest `items` for submenus).

### `carousel`
```
carousel(id="carousel", auto_play=false, interval=5000, transition="slide",
         show_dots=true, show_arrows=true, height="h-64", classes="")
```
Content slider. `caller()` provides slides — wrap each in `<div class="carousel-slide">`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"carousel"` | Unique on the page |
| auto_play / interval | bool / ms | `false` / `5000` | |
| transition | string | `"slide"` | `"slide"` `"fade"` |
| show_dots / show_arrows | bool | `true` | |
| height | string | `"h-64"` | |

### `resizable`
```
resizable(id="resizable", direction="horizontal", sizes=[50, 50],
          min_sizes=[], collapsible=[], persist=true, classes="")
```
Draggable split-pane. `sizes` is a list of percentages; `caller()` provides the panes (one per size). Persists to `componentState` keyed by `id`.

### `calendar`
```
calendar(model="selectedDate", min_date="", max_date="", disabled_dates="",
         range_mode=false, start_model="rangeStart", end_model="rangeEnd",
         size="md", inline=true, classes="", on_change="")
```
Inline date picker with single or range selection, keyboard navigation (roving tabindex), and disabled-date support.

### `column_filter_multi`
```
column_filter_multi(label, options_var="filterOptions", model="selectedFilters",
                    size="md", classes="", on_change="")
```
Multi-select checkbox dropdown for table columns. `options_var` is an Alpine reference to the options array.

### `column_filter_date`
```
column_filter_date(label, operator_model="dateOp", value_model="dateVal",
                   value2_model="dateVal2", size="md", classes="", on_change="")
```
Date filter with operators (`=`, `>`, `<`, `between`). `value2_model` only used when operator is `between`.

### `score_hero`
```
score_hero(value=0, label="", suffix="%", thresholds=none, size="md",
           classes="", value_var="")
```
Large centered KPI card with threshold-based color coding. `thresholds=[good, warn]` (e.g., `[85, 60]`) drives the accent.

### `score_gauge`
```
score_gauge(actual=0, goal=100, label="", format="number", inverse=false,
            compact=false, size="md", classes="", value_var="", goal_var="")
```
Actual-vs-goal gauge with colored progress bar. `compact=true` is table-cell-sized. `inverse=true` flips so lower-is-better.

### `pass_fail_badge`
```
pass_fail_badge(value=true, editable=false, model="", size="md",
                classes="", on_change="")
```
Binary pass/fail indicator. `editable=true` renders as a toggle bound to `model`.

### `category_card`
```
category_card(title="", weight=0, type="ranged", score=0, actual=0, goal=0,
              format="number", inverse=false, expandable=false,
              expanded_model="", on_expand="", classes="")
```
Scorecard category with gauge or pass/fail and an optional expand-to-drill-down section. `type="ranged"` renders a gauge; `type="binary"` renders pass/fail.

### `table_title_row`
```
table_title_row(title="", colspan=99, align="left", classes="", title_var="")
```
Spanning `<caption>` row inside a table. `title_var` binds dynamic Alpine text.

### `table_footer_row`
```
table_footer_row(cells=[], classes="")
```
Per-cell summary footer. `cells` is a list of `{value, value_var, align, type}` (use `value_var` for Alpine binding, `type` for currency / number formatting).

### `rating_input`
```jinja
rating_input(label="", name="", model="", value=0, max=5,
             half_step=false, allow_zero=true, readonly=false,
             disabled=false, show_value=false, size="md",
             color="amber", classes="", on_change="")
```
Star / 1-N rating picker. Supports whole or half-step granularity, optional
hidden `<input>` for plain-form submission, and full keyboard control.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `""` | Alpine expression bound to the numeric value; writes back on `set()` |
| value | number | `0` | Server-time default (used when `model` is empty) |
| max | int | `5` | Number of stars / ticks |
| half_step | bool | `false` | `true` enables 0.5 increments; clicks on left half of a star snap to `i-0.5` |
| allow_zero | bool | `true` | Clicking the current value clears to 0 |
| readonly | bool | `false` | Display-only (not keyboard-interactive) |
| disabled | bool | `false` | Greyed-out, not interactive |
| show_value | bool | `false` | Render the numeric value next to the stars |
| color | string | `"amber"` | Tailwind color token for the filled star |
| on_change | string | `""` | Alpine expression fired on value change (scope: `value`) |

**Keyboard:** Arrow Right / Up increments, Arrow Left / Down decrements,
Home = 0, End = max. Step honours `half_step`.

**a11y:** `role="slider"` with `aria-valuemin` / `aria-valuemax` / `aria-valuenow` +
`aria-valuetext="3.5 out of 5"`. `readonly` emits `aria-readonly`; `disabled` emits
`aria-disabled` and drops the element from the tab order.

```html
{{ rating_input(
    label="Rate this response",
    model="feedback.rating",
    max=5,
    half_step=true,
    show_value=true,
    on_change="submitFeedback()"
) }}
```

### `skip_to_content`
```jinja
skip_to_content(label="Skip to main content", target_id="main", classes="")
```
First-Tab accessibility link. Hidden off-screen until focused, then translates
into the top-left of the viewport with high-contrast chrome. Place as the VERY
FIRST focusable element inside `<body>` — the very first Tab press surfaces it,
Enter jumps to `#{{ target_id }}`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| label | string | `"Skip to main content"` | Link text shown on focus |
| target_id | string | `"main"` | Fragment to jump to — set your main landmark to `id="{{ target_id }}"` |
| classes | string | `""` | Extra classes appended to the anchor |

Uses `sr-only` + `focus:not-sr-only` so the link remains in the tab order (unlike
`display:none`). Chrome on focus: `bg-primary-600 text-white shadow-lg`.

```html
<body>
  {{ skip_to_content() }}
  {{ nav_bar(...) }}
  <main id="main">...</main>
</body>
```

### `font_scale_toggle`
```jinja
font_scale_toggle(label="Text size", scales=none, default="md",
                  persist=true, size="md", classes="", on_change="")
```
A- / A / A+ font-size toggle group. Writes `data-font-scale="sm|md|lg"` to
`<html>` on every change so the host app's CSS can key off it — e.g.:

```css
html[data-font-scale="sm"] body { font-size: 0.875rem; }
html[data-font-scale="lg"] body { font-size: 1.125rem; }
```

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| label | string | `"Text size"` | Accessible group label (`aria-label`) |
| scales | list | `None` | `[{key, label, aria}]`; defaults to A / A / A mapped to sm / md / lg |
| default | string | `"md"` | Initial scale when no saved pref |
| persist | bool | `true` | Persist via `componentState` + localStorage (key `"fontScale"`) |
| on_change | string | `""` | Alpine expression fired on change (scope: `scale`) |

**a11y:** `role="group"` wrapping `role="radio"` buttons. Arrow keys move focus
within the group; the selected option has `tabindex=0`, others `-1`.

```html
{# In your settings page #}
{{ font_scale_toggle(on_change="audit.log('font-scale', scale)") }}
```

### `reduced_motion_toggle`
```jinja
reduced_motion_toggle(label="Reduce motion", description="",
                      persist=true, size="md", classes="", on_change="")
```
Boolean switch that sets `data-reduced-motion="true"` on `<html>` when active.
Host CSS can then disable transitions / animations globally:

```css
html[data-reduced-motion="true"] *,
html[data-reduced-motion="true"] *::before,
html[data-reduced-motion="true"] *::after {
  animation: none !important;
  transition: none !important;
}
```

Seeds from `window.matchMedia('(prefers-reduced-motion: reduce)')` on first
load; the user's explicit choice always wins after that and is persisted.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| label | string | `"Reduce motion"` | Visible label next to the switch |
| description | string | `""` | Help text rendered under the label |
| persist | bool | `true` | Persist via `componentState` + localStorage (key `"reducedMotion"`) |
| on_change | string | `""` | Alpine expression fired on toggle (scope: `value`) |

**a11y:** `role="switch"` + `aria-checked`; label is clickable via `for=`.

```html
{{ reduced_motion_toggle(
    description="Disable animations and transitions site-wide.",
    on_change="audit.log('reduced-motion', value)"
) }}
```

### `file_upload` (audited — v2.0+)
```jinja
file_upload(label="", name="file", model="", accept="", multiple=false,
            max_size="", max_size_bytes=0, help="", size="md",
            width="w-full", classes="", on_change="", on_error="")
```
Drag-and-drop file upload zone with per-file progress + validation.

**v2.0 audit (#168)** — the drop zone is now a keyboard-focusable `<button>` (was
a `<div>`), each file row shows a per-file progress bar when `f.progress` is
between 0 and 100, files that fail client-side validation (type mismatch,
`max_size_bytes` exceeded) surface as red-bordered rows with an inline error,
and an `aria-live` region announces add / remove / reject events. All v1.x
params and their defaults are preserved.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `""` | Alpine expression for the file list — entries may carry `{name, size, file, progress, status, error}` |
| accept | string | `""` | File-type filter; enforced client-side as a soft check (server is still the authority) |
| multiple | bool | `false` | Allow multi-select |
| max_size | string | `""` | Display-only copy (e.g. `"10MB"`) |
| max_size_bytes | int | `0` | Enforced byte ceiling; `0` disables the check. Oversized files land in the list with `status="error"` |
| on_change | string | `""` | Alpine expression fired after add / remove (scope: `files`) |
| on_error | string | `""` | Alpine expression fired when a file is rejected (scope: `name`, `reason`) |

**Per-file progress protocol:** your upload pipeline mutates entries in-place:
```js
files[i].status = 'uploading';
files[i].progress = 42;   // 0–100
// …on completion:
files[i].status = 'done';
files[i].progress = 100;
```
The row picks up the change reactively.

**a11y:** drop zone is a `<button>` with Space / Enter to open the picker and
`aria-describedby` pointing at the help / size copy. Per-file progress bars are
`role="progressbar"` with `aria-valuenow`. Remove buttons carry
`aria-label="Remove {{ filename }}"`.

```html
{{ file_upload(
    label="Supporting documents",
    model="form.attachments",
    accept=".pdf,.docx,image/*",
    multiple=true,
    max_size="10MB",
    max_size_bytes=10485760,
    help="PDFs, Word docs, or images",
    on_change="touchForm()",
    on_error="toast.error(name + ': ' + reason)"
) }}
```

## Phase T — celebration / motion primitives (v2.0+)

### `number_counter`
```jinja
number_counter(target=0, duration_ms=800, format="int", decimals=none,
               prefix="", suffix="", auto_start=true, size="md",
               classes="", value_var="")
```
Animated count-up from `0` to `target`. On scroll into view by default
(IntersectionObserver, `threshold=0.15`); call `$data.start()` to re-fire.
Renders with eased cubic-out interpolation over `duration_ms`. Respects
`prefers-reduced-motion` AND the host's `html[data-reduced-motion="true"]`
attr — snaps instantly to `target` in either mode.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| target | number | `0` | Final value |
| duration_ms | int | `800` | Animation length |
| format | string | `"int"` | `"int"` (grouped thousands), `"float"`, `"currency"` (USD), `"percent"` |
| decimals | int \| None | `None` | Override the format-default precision (int: 0, currency: 0, percent: 1, float: 2) |
| prefix | string | `""` | Rendered before the number — rides on `data-prefix` to stay out of `x-data` |
| suffix | string | `""` | Rendered after the number — rides on `data-suffix` |
| auto_start | bool | `true` | Fire on scroll-into-view; when `false`, snaps to `target` on mount |
| size | string | `"md"` | `"sm"` \| `"md"` \| `"lg"` \| `"xl"` — Tailwind text-size token |
| value_var | string | `""` | Alpine expression watched; when it changes, `start()` re-runs with the new target |

**a11y:** `role="status"` + `aria-live="polite"` so assistive tech hears the
final value on settle.

```html
{# KPI card — counts up once when scrolled into view #}
{{ number_counter(target=1247, format="int", suffix=" orders", size="xl") }}

{# Currency — decimals=0 by default, override for cents #}
{{ number_counter(target=12450.75, format="currency", decimals=2) }}

{# Percent — 1 decimal by default #}
{{ number_counter(target=87.4, format="percent") }}

{# Reactive — re-animates whenever dashboard.total changes #}
{{ number_counter(value_var="dashboard.total", auto_start=false, format="int") }}
```

### `skeleton_shimmer`
```jinja
skeleton_shimmer(variant="line", width="w-full", height="", lines=3,
                 rounded="rounded", classes="")
```
Shimmer-sweep variant of `skeleton`. Adds a CSS linear-gradient overlay that
sweeps left-to-right every 1.4s. In light mode the sweep is white at 55%
opacity; in dark mode it drops to 8% to stay subtle. For reduced-motion users
the sweep is suppressed and the block falls back to the classic
`animate-pulse` cadence.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| variant | string | `"line"` | `"line"` (repeated bars) \| `"box"` \| `"avatar"` (10×10 round) \| `"circle"` |
| width | string | `"w-full"` | Tailwind width class (ignored for avatar/circle) |
| height | string | `""` | Tailwind height class — default by variant: line=`h-4`, box=`h-24`, avatar/circle=`h-10` |
| lines | int | `3` | Number of bars when variant=`line` — last is `w-3/4` |
| rounded | string | `"rounded"` | Tailwind radius (ignored for avatar/circle, which are `rounded-full`) |

**a11y:** `role="status"` + `aria-label="Loading"` + hidden `sr-only` "Loading…".

```html
{# Three-line text block #}
{{ skeleton_shimmer(lines=3) }}

{# Card hero image #}
{{ skeleton_shimmer(variant="box", height="h-48", rounded="rounded-lg") }}

{# Avatar row #}
<div class="flex items-center gap-3">
  {{ skeleton_shimmer(variant="avatar") }}
  <div class="flex-1">{{ skeleton_shimmer(lines=2) }}</div>
</div>
```

### `confetti`
```jinja
confetti(event_name="confetti:fire", colors=None, particle_count=60,
         duration_ms=2000, z_index=9999, classes="")
```
Fixed-position fullscreen canvas overlay. Listens for a window-level custom
event and paints a short canvas-based particle burst from the viewport center.
Self-contained (no `canvas-confetti`, no deps — ~60 lines of JS). The canvas
is `pointer-events-none`, so it never blocks clicks. `aria-hidden="true"` —
this is pure decoration.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| event_name | string | `"confetti:fire"` | Window `CustomEvent` name that triggers the burst |
| colors | list | default 7-color palette | CSS color strings (defaults: orange/green/blue/yellow/red/purple/teal — matches IFP primary + semantic swatches) |
| particle_count | int | `60` | Number of particles per burst |
| duration_ms | int | `2000` | Total burst lifetime |
| z_index | int | `9999` | Inline z-index on the canvas |

**Reduced motion:** if the OS or the host's `data-reduced-motion` is set, `fire()` is a no-op — no canvas paint at all.

```html
{# 1. Mount the overlay once, high in the page #}
{{ confetti() }}

{# 2. Anywhere — fire after a success #}
<script>
  async function submitOrder() {
    const ok = await postOrder();
    if (ok) window.dispatchEvent(new CustomEvent('confetti:fire'));
  }
</script>

{# Custom palette + event, e.g. per milestone #}
{{ confetti(event_name="goal:hit", colors=["#22c55e", "#f97316"], particle_count=120) }}
```

### `shortcut_help`
```jinja
shortcut_help(shortcuts=None, title="Keyboard shortcuts",
              trigger_key="?", id="shortcutHelp", classes="")
```
Keyboard-shortcut cheat-sheet modal. Opens on `?` press anywhere in the page
(the global listener is auto-suppressed while focus is inside an `<input>`,
`<textarea>`, `<select>`, or `contenteditable` element). Escape closes.
Shortcuts can be a flat list or a grouped list with `section` headers.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| shortcuts | list | stock "?" + "Esc" list | Flat: `[{keys: [...], description: "..."}]` OR grouped: `[{section: "Group", items: [...]}]` |
| title | string | `"Keyboard shortcuts"` | Dialog title |
| trigger_key | string | `"?"` | Global key that opens the help; empty string disables the listener |
| id | string | `"shortcutHelp"` | DOM id root (affects the JSON-config `<script>` id) |

**a11y:** `role="dialog"` + `aria-modal="true"` + `aria-labelledby`. Each key
chip is a `<kbd>` with solid accent-inverted dark-mode chrome.

```html
{# Minimal — shows just ? and Esc #}
{{ shortcut_help() }}

{# Flat list #}
{{ shortcut_help(shortcuts=[
     {"keys": ["Ctrl", "K"], "description": "Open command palette"},
     {"keys": ["g", "d"],    "description": "Go to dashboard"},
     {"keys": ["/"],         "description": "Focus search"},
     {"keys": ["?"],         "description": "Show this help"}
]) }}

{# Grouped — renders a section heading per group #}
{{ shortcut_help(shortcuts=[
     {"section": "Navigation", "items": [
       {"keys": ["g", "d"], "description": "Dashboard"},
       {"keys": ["g", "c"], "description": "Campaigns"}
     ]},
     {"section": "Editing", "items": [
       {"keys": ["Ctrl", "S"], "description": "Save"},
       {"keys": ["Ctrl", "Z"], "description": "Undo"}
     ]}
]) }}
```

### `qr_generator`
```jinja
qr_generator(value="", ec_level="M", size=192, padding=4,
             fg_color="#000", bg_color="#fff", alt="", classes="",
             value_var="")
```
Client-side SVG QR renderer backed by the bundled `qrcode-svg` vendor
library (~18 KB, MIT, [papnkukn/qrcode-svg](https://github.com/papnkukn/qrcode-svg)).
Computes the SVG in the browser via `new QRCode({...}).svg()` and injects
it with `x-html`. Works fully offline once the vendor script is loaded.
When `value_var` is supplied the SVG re-renders reactively on every
change — ideal for share-link / auth-token / invoice-id pickers.

**Requires:** `<script src="/static/js/vendor/qrcode-svg.js"></script>` in
your base template (same pattern as `plotly_chart`). If the global
`QRCode` is missing at render time the macro shows an amber error panel
and emits a single `console.warn` — identical failure mode to the Plotly
macros.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| value | string | `""` | Text / URL / payload to encode. Empty → dashed placeholder with "No value". |
| ec_level | string | `"M"` | Error-correction level: `"L"` (~7%) \| `"M"` (~15%) \| `"Q"` (~25%) \| `"H"` (~30%). Use `"H"` for logos/overlay or dirty scans. |
| size | int | `192` | Pixel width + height of the square. |
| padding | int | `4` | Quiet-zone modules around the code (spec recommends ≥4). |
| fg_color | string | `"#000"` | Module color. |
| bg_color | string | `"#fff"` | Background color — override for dark surfaces but keep high contrast. |
| alt | string | `""` | Screen-reader label on the wrapper; defaults to `"QR code"` when empty. |
| value_var | string | `""` | Alpine expression; `$watch`'d — changes re-render the SVG. |

**a11y:** wrapper is `role="img"` with `aria-label`; the injected `<svg>`
is decorative so scanners announce meaning via the label, not the raw
module matrix.

**Security — vendor footgun for direct callers:** `qrcode-svg.js` builds its
SVG output by string-concatenating the caller-supplied `color` and
`background` values (no internal validation). The `qr_generator` macro is
safe because it funnels both values through a `^#([0-9a-fA-F]{3,8})$`
validator (`_safeColor`) and falls back to `#000` / `#fff` when either
fails. **The vendor itself has no such guard.** Consumers that call
`new QRCode({ color: untrusted, ... })` directly from their own JS
bypass that protection and can inject arbitrary SVG content. If you need
to call the vendor directly, mirror the macro's regex check or wrap it in
your own sanitizer. See [#248](https://github.com/mrwuss/ui-kit/issues/248)
for the full rationale — the decision to document rather than patch the
vendor keeps the file a clean mirror of upstream and avoids merge cost on
every version bump.

```html
{# 1. Load the vendor script once (base template) #}
<script src="/static/js/vendor/qrcode-svg.js"></script>

{# 2. Static QR — encoded at page render #}
{{ qr_generator(value="https://ifpusa.com/onboard?tok=abc123",
                alt="Scan to open onboarding") }}

{# High error-correction + larger quiet zone for print #}
{{ qr_generator(value="ORDER-48219", ec_level="H", size=256, padding=6) }}

{# Reactive — re-renders whenever form.shareUrl changes #}
<div x-data="{ form: { shareUrl: '' } }">
  {{ input_field("Share link", name="shareUrl", model="form.shareUrl") }}
  {{ qr_generator(value_var="form.shareUrl",
                  alt="QR for the share link",
                  size=160) }}
</div>

{# Custom palette for a dark-surface card #}
{{ qr_generator(value="wifi:T:WPA;S:Guest;P:hunter2;;",
                fg_color="#f9fafb", bg_color="#1a1c23",
                alt="Guest Wi-Fi") }}
```

## Phase U.2 — Security Primitives

### `recovery_codes`
```jinja
recovery_codes(codes=[], columns=2, show_warning=True,
               downloadable=True, printable=True,
               id="recoveryCodes", classes="")
```
Backup-code display panel with copy-all, download, and print actions.
Codes render through `x-for` bound to a JSON data island — never embedded
inside `x-data`. Print injects a scoped `@media print` rule so only the
code grid appears on paper.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| codes | list[str] | `[]` | Backup codes (typically 8–10) |
| columns | int | `2` | Grid columns: `1`, `2`, `3`, or `4`. `2` is print-optimized |
| show_warning | bool | `True` | Amber "save these now" banner with `role="alert"` |
| downloadable | bool | `True` | Shows "Download .txt" button (Blob + `a.download`) |
| printable | bool | `True` | Shows "Print" button — scoped `@media print` rule |
| id | string | `"recoveryCodes"` | DOM id root |

**a11y:** `role="region"` + `aria-label="Recovery codes"`. Each code is
inside `<code>` with `select-all`. Copy-all joins codes with a single space.

```html
{{ recovery_codes(codes=[
     "AAAA-1111", "BBBB-2222",
     "CCCC-3333", "DDDD-4444",
     "EEEE-5555", "FFFF-6666",
     "GGGG-7777", "HHHH-8888"
]) }}

{# Compact, no warning (when already inside a labeled panel) #}
{{ recovery_codes(codes=codes, columns=4, show_warning=False) }}
```

### `twofa_setup`
```jinja
twofa_setup(otpauth_uri="", manual_key="", verify_endpoint="",
            recovery_codes=None, issuer="App", on_success="",
            id="twofaSetup", classes="")
```
Three-step TOTP enrollment wizard. **Step 1** renders `qr_generator` for
the `otpauth://` URI plus the base32 `manual_key` with a `copy_button`.
**Step 2** collects a 6-digit code through `otp_input` and POSTs `{code}`
to `verify_endpoint`, expecting `{ok: bool, recovery_codes?: list}`.
**Step 3** shows the success panel with recovery codes (server-returned or
pre-supplied via `recovery_codes=`).

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| otpauth_uri | string | `""` | The `otpauth://totp/...` URI encoded into the QR |
| manual_key | string | `""` | Plain base32 secret shown for manual entry |
| verify_endpoint | string | `""` | POST URL; body `{code}`; expects `{ok: bool, recovery_codes?: [...]}` |
| recovery_codes | list\|None | `None` | Optional pre-computed list for step 3 |
| issuer | string | `"App"` | Display name in the manual-key label and QR alt text |
| on_success | string | `""` | Alpine expression fired from the step-3 "Done" button |
| id | string | `"twofaSetup"` | DOM id root |

**Events dispatched:** `twofa:verified` (on successful code check, `detail: {id, codes}`) and `twofa:done` (on "Done" click).

**a11y:** `role="region"` + `aria-label="Two-factor setup"`. Step labels announced via `aria-live="polite"`. Error state on step 2 uses `role="alert"`.

```html
{# Typical enrollment page #}
{{ twofa_setup(
     otpauth_uri="otpauth://totp/IFP:jscar@ifpusa.com?secret=JBSWY3DPEHPK3PXP&issuer=IFP",
     manual_key="JBSWY3DPEHPK3PXP",
     verify_endpoint="/api/2fa/verify",
     issuer="IFP",
     on_success="location.href = '/account/security'"
) }}

{# Codes delivered up-front (skip server round-trip on success) #}
{{ twofa_setup(
     otpauth_uri=uri, manual_key=secret,
     verify_endpoint="/api/2fa/verify",
     recovery_codes=["ABCD-1234", "EFGH-5678", "IJKL-9012", "MNOP-3456"]
) }}
```

## Phase U.3 — Auth & admin primitives

### `api_key_list`
```jinja
api_key_list(keys=[], revoke_endpoint="", on_revoke="",
             show_prefix=true, id="apiKeyList", classes="")
```
Read-only listing of API keys with inline **confirm-then-revoke** per row. Revoked
keys stay visible (strike-through + amber "Revoked" badge) for audit trail.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| keys | list | `[]` | `[{id, label, prefix, last_used, created, revoked}]` |
| revoke_endpoint | string | `""` | POST URL; body `{id}` |
| on_revoke | string | `""` | Alpine expression on success — `key` in scope |
| show_prefix | bool | `true` | When false the prefix is masked |
| id | string | `"apiKeyList"` | DOM id root |

**a11y:** `role="table"` + `aria-label="API keys"`; Revoke button carries `aria-label="Revoke <label>"`; error banner `role="alert"`.

```html
{{ api_key_list(
     keys=[{"id":"k_prod","label":"Production","prefix":"sk_live_abc…xyz",
            "last_used":"2026-04-18T09:12:00Z","created":"2026-01-02T00:00:00Z","revoked":False}],
     revoke_endpoint="/api/keys/revoke") }}
```

### `permission_audit`
```jinja
permission_audit(roles=[], permissions=[], grants={},
                 admin_role_id="", show_legend=true,
                 id="permAudit", classes="")
```
Read-only companion to `permission_matrix`. Sticky role × permission grid;
admin role (`admin_role_id`) gets a ★ badge and cells render as ghosted stars.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| roles | list | `[]` | `[{id, label}]` |
| permissions | list | `[]` | `[{id, label, description?}]` |
| grants | dict | `{}` | `{role_id: [permission_id, …]}` |
| admin_role_id | string | `""` | Role id that overrides all |
| show_legend | bool | `true` | Render legend strip |
| id | string | `"permAudit"` | DOM id root |

**a11y:** `role="table"` + `aria-label="Permission audit"`; cells carry `aria-label="Granted: <role> / <perm>"` or `"Not granted: …"`.

```html
{{ permission_audit(
     roles=[{"id":"admin","label":"Admin"},{"id":"vp","label":"VP"},{"id":"rep","label":"Rep"}],
     permissions=[{"id":"view","label":"View reports"},{"id":"edit","label":"Edit targets"}],
     grants={"vp":["view","edit"],"rep":["view"]},
     admin_role_id="admin") }}
```

### `webhook_secret_rotator`
```jinja
webhook_secret_rotator(current_key="", current_created="",
                       rotate_endpoint="",
                       grace_seconds=86400, on_rotate="",
                       id="webhookRotator", classes="")
```
Rotate a signing secret with a **grace window** during which both old and new
secrets verify. Card with current key + Rotate → confirm → POST → both secrets
shown with live "expires in Xh Ym" countdown; old-key block auto-removed at 0.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| current_key | string | `""` | Masked preview of current secret |
| current_created | string | `""` | ISO timestamp (informational) |
| rotate_endpoint | string | `""` | POST URL; response `{old_key, new_key, grace_until?}` |
| grace_seconds | int | `86400` | Default grace window — 24h |
| on_rotate | string | `""` | Alpine expression after rotation — `oldKey`, `newKey` in scope |
| id | string | `"webhookRotator"` | DOM id root |

**a11y:** error banner `role="alert"`; old-key card tagged `data-old-key-block`.

```html
{{ webhook_secret_rotator(
     current_key="whsec_abc…xyz",
     current_created="2026-01-15T10:00:00Z",
     rotate_endpoint="/api/webhooks/signing-secret/rotate",
     grace_seconds=86400) }}
```

## Phase V.1 — Editors

### `md_editor`
```jinja
md_editor(id="mdEditor", model="md.source", parser="",
          toolbar=["bold","italic","code","link","list","heading"],
          layout="split", height="h-72",
          placeholder="Write Markdown…", classes="")
```
Markdown authoring field with a toolbar + live preview pane. The toolbar
wraps the current selection with markdown syntax; the preview pane re-renders
on every keystroke (debounced 200 ms). Consumer wires the MD→HTML parser as
an Alpine expression — same contract as `template_editor`'s preview endpoint —
so the kit stays dep-free. Without a parser the preview falls back to an
escaped `<pre>`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"mdEditor"` | DOM id root |
| model | string | `"md.source"` | Alpine model path for the source text |
| parser | string | `""` | Alpine expression returning a `fn(src)→HTML` string (e.g. `"window.marked.parse"`) |
| toolbar | list | `[bold, italic, code, link, list, heading]` | Buttons to render |
| layout | string | `"split"` | `"split"` / `"stacked"` / `"editor-only"` |
| height | string | `"h-72"` | Tailwind height class for both panes |
| placeholder | string | `"Write Markdown…"` | Textarea placeholder |

**a11y:** toolbar has `role="toolbar"` + `aria-label="Markdown formatting"`.
Preview pane is `aria-live="polite"` with `aria-label="Markdown preview"`.
Tab inserts two spaces without losing focus.

**Container queries:** wraps itself in `@container`, so split-mode collapses
to stacked in narrow parents via `@md:grid-cols-2`.

```html
{# With marked.js wired from the consumer's base template #}
{{ md_editor(id="releaseNotes",
             model="form.body",
             parser="window.marked.parse",
             layout="split") }}

{# Editor-only mode — preview owned by a sibling panel #}
{{ md_editor(id="notes", model="form.body", layout="editor-only") }}
```

### `json_editor`
```jinja
json_editor(id="jsonEditor", model="json.data", mode="text",
            height="h-72", validate="", on_validate="", classes="")
```
Writable counterpart to `json_viewer`. Two modes: **text** (monospace
textarea with live `JSON.parse` + red border on parse error) and **tree**
(type-aware inline leaf editors for string/number/bool/null; nested
objects/arrays fall back to text mode with a "use text mode" hint).
Mode toggle persists to `localStorage`. Optional schema validation via
a consumer-wired Ajv (or equivalent) expression.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"jsonEditor"` | DOM id root; mode preference key is `<id>_mode` |
| model | string | `"json.data"` | Alpine model path for the JSON object (two-way) |
| mode | string | `"text"` | Initial mode — `"text"` or `"tree"` |
| height | string | `"h-72"` | Tailwind height class for both modes |
| validate | string | `""` | Alpine expression returning `fn(parsed) → {valid, errors}` (Ajv-style) |
| on_validate | string | `""` | Alpine expression — `$event.detail` is `{valid, errors, parsed}` |

**a11y:** mode toggle is `role="tablist"` with `aria-selected` on each
tab. Parse-error and schema-error banners use `role="alert"`. Tree mode
is `role="tree"` with `role="treeitem"` rows.

**Schema validation (optional):**
```html
<script>
  const schema = { type: 'object', required: ['name'], properties: { name: {type:'string'} } };
  const ajv = new Ajv();
  window.validateCfg = ajv.compile(schema);
</script>

{{ json_editor(id="cfg",
               model="form.config",
               validate="window.validateCfg",
               on_validate="status = $event.detail.valid ? 'ok' : 'error'") }}
```

### `diff_viewer`
```jinja
diff_viewer(original="", modified="", diff=None, layout="split",
            height="h-96", id="diffViewer", classes="")
```
Side-by-side (or unified) text diff viewer. Given `original` and `modified`
strings, computes a line-level LCS diff in the browser (~40 lines of JS,
dep-free) and renders with add/remove/unchanged row coloring. Consumer can
also supply a pre-computed diff as a list of `{op, text}` ops to skip the
client-side compute entirely.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| original | string | `""` | Original text |
| modified | string | `""` | Modified text |
| diff | list\|None | `None` | Pre-computed ops `[{op:"=" / "-" / "+", text}]` |
| layout | string | `"split"` | `"split"` (two panes) or `"unified"` (single column with `+` / `-` gutter) |
| height | string | `"h-96"` | Tailwind height class |
| id | string | `"diffViewer"` | DOM id root |

**a11y:** wrapper is `role="region"` + `aria-label="Diff viewer"`. Split panes
are labelled `"Original"` / `"Modified"` for screen readers.

```html
{# Client computes the diff #}
{{ diff_viewer(id="relDiff",
               original=prev_release_notes,
               modified=current_release_notes) }}

{# Server pre-computed — skip the LCS #}
{{ diff_viewer(id="relDiff",
               diff=[
                 {"op":"=","text":"# Release notes"},
                 {"op":"-","text":"- Added foo"},
                 {"op":"+","text":"- Added foo and bar"}
               ],
               layout="unified") }}
```

## Phase W.1 — Specialty inputs

### `color_picker`
```jinja
color_picker(model="color.value",
             palette=[Tailwind 500s],
             show_rgb=False, alpha=False,
             recent_n=8, id="colorPicker", classes="")
```
Swatch grid + HEX input + optional R/G/B/A sliders. Recent picks persist
to `localStorage`. Emits `#rrggbb` (or `rgba(...)` when `alpha=True`).

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `"color.value"` | Alpine two-way model |
| palette | list | Tailwind 500 row | Swatch colors |
| show_rgb | bool | `False` | Show R/G/B sliders |
| alpha | bool | `False` | Add A slider + output as `rgba()` |
| recent_n | int | `8` | Recent-pick memory |

**a11y:** each swatch is a button with `aria-label="Color <hex>"` and
`aria-pressed`. HEX input has `aria-label="Hex color value"`.

```html
{{ color_picker(id="fg", model="form.color") }}
{{ color_picker(id="bg", model="form.bg", show_rgb=True, alpha=True) }}
```

### `signature_pad`
```jinja
signature_pad(model="sig.value", width=400, height=150,
              pen_color="#111827", stroke_width=2,
              smoothing=True, id="signaturePad", classes="")
```
HTML5 canvas with touch + mouse drawing. Emits PNG data URL on every
stroke-end; `clear()` resets the canvas and emits `""`. Midpoint
smoothing for legible strokes.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `"sig.value"` | Alpine model receives PNG data URL |
| width / height | int | 400 / 150 | CSS pixel size (internal backing is DPR-scaled) |
| pen_color | string | `"#111827"` | Stroke color |
| stroke_width | int | `2` | CSS pixel width |
| smoothing | bool | `True` | Quadratic-midpoint curve fitting |

**a11y:** canvas is `role="img"` + `aria-label="Signature pad"`; Clear
button carries `aria-label="Clear signature"`.

```html
{{ signature_pad(id="sig", model="form.signature") }}
```

### `code_input`
```jinja
code_input(model="code.value", length=6, size="md",
           disabled=False, on_complete="",
           id="codeInput", classes="")
```
N-cell numeric code field (verification code, PIN). Auto-advances on
input, splits on paste, backspace jumps to prev cell. `on_complete`
fires when all cells are filled.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `"code.value"` | Joined digit string |
| length | int | `6` | Number of cells |
| size | string | `"md"` | `"sm"` / `"md"` / `"lg"` |
| on_complete | string | `""` | Alpine expression on full fill |

**a11y:** group wrapper has `aria-label="<N>-digit code"`; each cell has
`aria-label="Digit N of M"`, `inputmode="numeric"`, `pattern="[0-9]"`.

```html
{{ code_input(id="verify", model="form.code", length=6,
              on_complete="verify()") }}
```

---

## v2.14 — Alpine-friction epic (#350, #355, #356)

Five new public macros that hide recurring Alpine patterns or fill a
gap consumers were re-implementing by hand. Closes the actionable
`#350` tracks; no breaking changes to existing call sites.

### `status_page` — built-in polling (#355)

```jinja
status_page(components=[], components_var="", overall_var="",
            poll_url="", poll_interval=30000, pause_when_hidden=True,
            on_error="", id="statusPage", classes="")
```

v2.14 adds four polling-related params. When `poll_url` is set the
macro owns `components` state internally (static `components` /
`components_var` are ignored), fetches the URL on interval, pauses
while the tab is hidden, and exposes reactive `_lastPolledAt` and
`_pollError` properties for downstream UI.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| poll_url | string | `""` | Endpoint returning `{components:[...], overall?:"..."}` |
| poll_interval | int | `30000` | Poll interval in ms |
| pause_when_hidden | bool | `True` | Pause polling when tab is not visible; resume on focus |
| on_error | string | `""` | Alpine expression fired on fetch error; `err` (message string) is in scope |

Backwards compatible: existing static / `components_var` call sites
keep working when `poll_url` is empty.

```html
{{ status_page(poll_url="/api/suite/health", poll_interval=30000,
               on_error="$store.toasts.push({type:'warning',title:err})") }}
```

### `validation_strip` — attached pass/fail bar (#356)

```jinja
validation_strip(segments=[], total=None, size="md", show_rollup=True,
                 show_labels=False, tooltips=True, classes="")
```

Pass/warn/fail/neutral segmented bar sized for card-header
attachment. Colors match `validation_score` (green / amber / red /
gray). `role="meter"` with `aria-valuetext` for screen readers.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| segments | list | `[]` | `[{label, count, status}]` where status ∈ `pass` / `warn` / `fail` / `neutral` |
| total | int | `None` | Optional denominator override (defaults to `sum(count)`) |
| size | string | `"md"` | `"sm"` (2px) / `"md"` (4px) / `"lg"` (6px) |
| show_rollup | bool | `True` | Show `"N/total"` text at the end |
| show_labels | bool | `False` | Show legend row with status-tinted dots |
| tooltips | bool | `True` | Per-segment `title="label: count"` |

```html
{{ validation_strip(segments=[
    {"label":"Price","count":4,"status":"pass"},
    {"label":"Date","count":1,"status":"warn"},
    {"label":"Qty","count":2,"status":"fail"}
]) }}
```

### `alpine_dropdown_portal`

```jinja
alpine_dropdown_portal(id="dropdown", trigger_label="", align="left",
                       width="w-56", classes="", trigger_classes="")
```

Trigger button + menu panel teleported to `<body>` so `overflow-hidden`
ancestors can't clip it. Built-in Escape + click-outside dismissal.
`{% call %}` body becomes the menu contents.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | `"dropdown"` | DOM id root |
| trigger_label | string | `""` | Text on the trigger button |
| align | string | `"left"` | `"left"` / `"right"` / `"center"` anchor |
| width | string | `"w-56"` | Tailwind width class on the panel |
| trigger_classes | string | `""` | Extra classes on the trigger button |

```html
{% call alpine_dropdown_portal(id="userMenu", trigger_label="Account", align="right") %}
  <a href="/profile" class="block px-3 py-2 text-sm">Profile</a>
  <a href="/logout"  class="block px-3 py-2 text-sm">Log out</a>
{% endcall %}
```

### `alpine_debounced_input`

```jinja
alpine_debounced_input(model="", on_input="", debounce=300,
                       placeholder="", name="", type="text",
                       size="md", width="w-full", classes="", id="")
```

Text input with built-in `x-model.debounce.Nms` + optional `@input`
handler. Removes per-consumer discovery of the debounce modifier shape.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| model | string | `""` | Alpine property to bind (`x-model.debounce.Nms`) |
| on_input | string | `""` | Alpine expression fired after debounce; `value` (the debounced text) is in scope |
| debounce | int | `300` | ms |
| type | string | `"text"` | `"text"` / `"search"` / `"email"` |
| size | string | `"md"` | `"sm"` / `"md"` / `"lg"` |

```html
{{ alpine_debounced_input(model="form.query", on_input="fetchResults(value)",
                          debounce=250, placeholder="Search...") }}
```

### `alpine_x_init_boundary`

```jinja
alpine_x_init_boundary(watches=[], classes="", id="")
```

Component shell that captures one or more Alpine expressions into
local reactive properties via `x-effect` with a `_lastValue` guard.
Canonical fix for the method-body scope trap (#245, #247): instead
of interpolating `{{ expr_var }}` inside a method body, declare it
as a watch and the body reads `this.<name>` through the reactive
proxy.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| watches | list | `[]` | `[{name, expr}]`. `expr` is a plain Alpine expression; `name` is the local property it flows into |

```html
{% call alpine_x_init_boundary(watches=[
    {"name": "ownerName", "expr": "form.owner.name"},
    {"name": "status",    "expr": "record.status"}
]) %}
  <span x-text="ownerName"></span>
  <span :class="status === 'paid' ? 'text-green-500' : ''"></span>
{% endcall %}
```

### `reactive_list`

```jinja
reactive_list(items_var="items", key_field="id", on_reorder="",
              empty_text="No items", id="reactiveList", classes="")
```

`x-for` loop over a Jinja-provided array with keyed children,
enter/leave transitions, and optional drag-to-reorder (auto-enables
when `on_reorder` is set). Caller receives `item` and `index`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| items_var | string | `"items"` | Alpine expression returning the array |
| key_field | string | `"id"` | Object field used as `:key` |
| on_reorder | string | `""` | Alpine expression fired on drag-reorder; `newOrder` (array of keys) + `movedKey` are in scope. Drag handles enable only when this is set. |
| empty_text | string | `"No items"` | Empty-state message |

```html
{% call(item, index) reactive_list(items_var="form.tasks",
                                    key_field="id",
                                    on_reorder="persistOrder(newOrder)") %}
  <div class="flex items-center gap-2 p-2">
    <span x-text="item.title"></span>
    <button @click="remove(item.id)" class="text-xs text-red-600">Remove</button>
  </div>
{% endcall %}
```

### `alpine_json_island`

```jinja
alpine_json_island(id, data)
```

Emit a `<script type="application/json">` tag so server-time payloads can reach Alpine without breaking the `x-data` attribute quoting rule (Check 9 in `scripts/lint.sh`). Callers read via `JSON.parse(document.getElementById('id').textContent)`.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| id | string | — | Unique DOM id for the `<script>` tag. Required. |
| data | JSON-serialisable | — | Dict, list, string, number, bool, None. Escaped via the private `_safe_json` helper so `</script>` inside a string value cannot break out. Required. |

```html
{# Server-side #}
{{ alpine_json_island(id="vendorCfg", data=vendor_config) }}

{# Client-side — at the x-data boundary #}
<div x-data="{
  cfg: JSON.parse(document.getElementById('vendorCfg').textContent)
}">
  ...
</div>
```

Added v2.19.0 (#350 Phase 2 Track B). CLAUDE.md has marked this pattern MANDATORY since v2.0; the macro promotes it from convention to primitive.

### `alpine_shortcut_binding`

```jinja
alpine_shortcut_binding(key, handler, prevent=false, stop=false,
                         when="", id="")
```

Window-scoped keyboard shortcut rendered as a hidden `<span>` with bare `x-data`. The listener attaches at window level via `.window`, so the span's visibility is irrelevant.

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| key | string | — | Alpine key-modifier string, minus leading dot and minus `.window` (e.g. `"escape"`, `"ctrl.k"`, `"meta.slash"`). Required. |
| handler | string | — | Alpine expression fired on the key combo. Resolves against nearest parent scope. Required. |
| prevent | bool | `false` | Append `.prevent` (stops browser default, e.g. Ctrl+K's built-in search). |
| stop | bool | `false` | Append `.stop` (stops event propagation). |
| when | string | `""` | Alpine boolean expression guarding the handler — only fires when truthy. Used for top-most-modal Esc routing. |

```html
{# Global Cmd/Ctrl+K opens the command palette #}
{{ alpine_shortcut_binding(
     key="meta.k",
     handler="$store.ui.commandPaletteOpen = true",
     prevent=true) }}

{# Escape only dismisses the top-most modal (via $store.ui) #}
{{ alpine_shortcut_binding(
     key="escape",
     handler="closeModal(modalId)",
     when="$store.ui.focusedModalId === modalId") }}
```

Both guard and handler are wrapped in parens (`(guard) && (handler)`) so composite expressions compose correctly — avoids the precedence trap Check 10 flags.

**Trust boundary:** `key`, `handler`, and `when` are author-trusted, interpolated as Alpine code. Do not pass request/DB-sourced content through them. `key` must be a well-formed Alpine key-modifier string (no spaces, quotes, or `=`).

Added v2.19.0 (#350 Phase 2 Track B).

### `alpine_polling_timer`

```jinja
alpine_polling_timer(interval=5000, fn="", active_var="",
                      run_on_init=false, id="")
```

`setInterval` helper with Alpine lifecycle: automatic `clearInterval` on component destroy, `visibilitychange` pause while the tab is hidden, **immediate tick on resume** (so the user doesn't wait up to `interval` ms for fresh data after returning to the tab), and optional `active_var=` reactive pause/resume driven through `x-effect` (NOT `$watch` — composite expressions like `form.live && !form.paused` work correctly).

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| interval | int | `5000` | Poll interval in ms. |
| fn | string | `""` | Alpine expression fired on each tick. Resolves against nearest parent Alpine scope via a scoped `@ui-kit-poll.stop` directive — place the macro INSIDE the component that owns the handler. Required. |
| active_var | string | `""` | Optional Alpine expression; truthy runs, falsy pauses. Watched via `x-effect` with a `_lastActive` guard, so composite predicates work. |
| run_on_init | bool | `false` | Fire `fn` immediately on mount, before the first interval elapses. |

```html
<div x-data="dashPage()">
  {{ alpine_polling_timer(
       interval=10000,
       fn="refreshMetrics()",
       active_var="form.liveMode",
       run_on_init=true) }}
  ...
</div>
```

**Trust boundary:** `fn` and `active_var` are Alpine expressions evaluated as code. Author-trusted — do not sink user input through them.

**Don't use for:** one-shot post-render hooks (use plain `x-effect` or `$nextTick`), or session countdowns that must keep ticking while the tab is hidden (`alpine_polling_timer` pauses on `visibilitychange` — `session_timeout_modal` uses a direct `setInterval` with the `{# noqa: alpine-timer-in-xinit #}` marker for exactly this reason).

Added v2.19.0 (#350 Phase 2 Track B).

### `$store.ui` — modal stack + command-palette flag (#350 Track 2)

v2.14 adds three store properties + three methods on the existing
`$store.ui`:

| Property / method | Type | Purpose |
|-------------------|------|---------|
| `modalStack` | array | LIFO of currently-open modal ids. Top entry is focused. |
| `focusedModalId` | string \| null | Mirror of `modalStack[-1]`; `null` when empty. |
| `commandPaletteOpen` | bool | Mirrors the `command_palette` open flag. |
| `pushModal(id)` | method | Register a modal as open. Idempotent — existing id moves to top. |
| `popModal(id)` | method | Unregister; `focusedModalId` falls back to the new top. |
| `isTopModal(id)` | method | `focusedModalId === id`. |

`modal` and `confirm_modal` macros now auto-register via `x-effect`
and guard their Escape handler on `isTopModal(id)` — so nested modals
unstack one at a time. Custom modal-like components can integrate the
same way:

```html
<div x-show="myOpen"
     x-effect="myOpen ? $store.ui.pushModal('myId') : $store.ui.popModal('myId')"
     @keydown.escape.window="if ($store.ui.isTopModal('myId')) myOpen = false">
  ...
</div>
```
