# Slot convention (v2.28+ — composable cells)

Phase 1 of the [composable-slots epic (#420)](https://github.com/mrwuss/ui-kit/issues/420). Defines the `{% call(slot) %}` pattern adopted on `data_table` (closes #437) and `stat_card`. Phase 3 sweeps the same pattern across the rest of the kit.

## Why slots

Most macros bake their inner cell layout into the macro body. Consumers who want a different cell shape (primary text + nested conditional badge, multi-line content, composed nested macros) currently have to drop the macro and hand-roll the markup — exactly what consumer issue #437 reports.

Slots invert the choice: the macro owns chrome (table sort, pagination, filter UI; card label, surface, link wrapper), and the consumer can compose any markup — including nested macros — into named slot regions.

## Two shapes

### Shape A — Dynamic per-key slots (data-driven macros)

Used by macros with N consumer-supplied data shapes (rows × columns). The consumer flags individual data shapes (columns, rows, items) with `slot: true`; the macro emits `caller(<key>)` per slotted shape. Example: `data_table`.

```jinja
{% call(slot) data_table(
    id="extraction-models",
    columns=[
        {"key": "name",     "label": "Model",    "slot": true},
        {"key": "provider", "label": "Provider", "slot": true},
        {"key": "added",    "label": "Added"}
    ],
    data_var="filteredRows"
) %}
  {%- if slot == 'name' %}
    <span class="font-medium" x-text="row.name"></span>
    <template x-if="row.recommended">
      {{ badge("Recommended", variant="green", size="sm") }}
    </template>
    <p class="text-xs text-gray-500" x-text="row.description"></p>

  {%- elif slot == 'provider' %}
    {{ badge(text_var="row.provider",
             variant_var="row.provider === 'gemini' ? 'blue' : 'orange'") }}
  {%- endif %}
{% endcall %}
```

The non-slotted `added` column renders via the macro's existing `col.type` logic (text / currency / link / actions / default). Mixed-mode is the default — consumers slot in just the columns where the default doesn't fit.

Inside the caller body:
- **`slot`** is the current column's `key` (Jinja value — use it in `{% if %}` dispatching).
- **`row`** and **`ri`** are Alpine identifiers, NOT Jinja values. They're resolved by the surrounding `<template x-for="(row, ri) in paged">`. Consumers reference them as bare strings inside Alpine directives (`x-text="row.name"`, `x-show="row.active"`, `:class="{ 'text-green-600': row.recommended }"`).

**Hard rule:** never use Jinja interpolation `{{ row.field }}` inside a slot body. Jinja resolves `row` at template-render time (once, at server side) — but the cell markup gets replicated per-row by Alpine *client-side*, and `row` doesn't exist in the Jinja context. The result is silent — Jinja autoescapes `Undefined` to empty, and the cell renders blank.

```jinja
{# WRONG — `row` is not a Jinja value at template render time #}
{% call(slot) data_table(...) %}
  {%- if slot == 'name' %}{{ row.name }}{%- endif %}
{% endcall %}

{# CORRECT — Alpine resolves `row` per iteration on the client #}
{% call(slot) data_table(...) %}
  {%- if slot == 'name' %}<span x-text="row.name"></span>{%- endif %}
{% endcall %}

{# CORRECT — pass `row.field` as a `*_var` arg to nested macros #}
{% call(slot) data_table(...) %}
  {%- if slot == 'tier' %}{{ badge(text_var="row.tier", variant_var="row.tier === 'pro' ? 'amber' : 'gray'") }}{%- endif %}
{% endcall %}
```

The mental model: the macro emits the slot markup **once per column at template render time** (inside the Jinja-time `{% for col in columns %}` loop). Alpine then replicates that markup per row at runtime via `<template x-for>`. Anything you want resolved per-row must be expressed as Alpine bindings (`x-text=`, `:class=`, `x-show=`) or as `*_var` args to nested macros that themselves emit Alpine bindings.

### Shape B — Fixed-name slots (chrome-driven macros)

Used by macros with a small fixed set of meaningful regions (icon, value, footer; header, body, footer). Example: `stat_card` (`icon`, `value`, `trend`).

```jinja
{% call(slot) stat_card(label="Revenue") %}
  {%- if slot == 'icon' %}
    {{ my_brand_logo | safe }}

  {%- elif slot == 'value' %}
    <span class="text-3xl font-bold text-green-600">${{ "1,234.56" }}</span>
    <span class="text-sm text-gray-500">USD</span>

  {%- elif slot == 'trend' %}
    {{ sparkline(data=rev_history, color="green") }}
  {%- endif %}
{% endcall %}
```

Slot names are **macro-defined** for fixed-name shape. Each macro's docblock lists its slots.

## Backwards compatibility (the hard rule)

A consumer who never wraps a macro in `{% call(slot) %}` gets exactly the v2.27 rendering. No new params needed; no markup changes. Every test in `TestMacros` from v2.27 still passes against v2.28 — the macros only invoke `caller(...)` when `caller` is defined (Jinja's built-in detection).

The slot params and flags are purely additive:
- `data_table` columns: `slot: true` is opt-in per column. Without it, `col.type` rules unchanged.
- `stat_card`: no new param at all — wrapping the macro with `{% call %}` is the opt-in signal.

## When *NOT* to use slots — the `*_html` and `*_var` escape hatches

Slots are the right fit when:
- The cell needs Alpine reactivity (resolves `row.field` per iteration).
- The cell composes other Jinja macros (`badge`, `sparkline`, `health_indicator`).
- The shape varies per data row (not fixed at template-render time).

Use the existing `*_html` / `*_var` params when:
- The cell is a one-shot pre-rendered string with no per-row reactivity (a static title bar, a fixed metric label).
- The consumer already has the rendered HTML in a Python variable (e.g. server-side Markdown render, escaped user text).

`stat_card` keeps `value_html` / `value_var` for these cases — slot precedence is `caller('value')` → `value_html` → `value_var` → static `value`.

## Per-slot opt-in (the convention's universal-friendly default)

Slots fall back to the macro's defaults when the consumer's caller body emits empty for that slot. The mechanism: each macro-defined slot does

```jinja
{%- set _slot_x = caller('x') if caller else "" -%}
{% if _slot_x and _slot_x | trim %}
  {{ _slot_x }}
{% else %}
  <existing default renderer>
{% endif %}
```

So a consumer who wraps `stat_card` with `{% call %}` to override **just the value** slot keeps the default icon and the default trend untouched:

```jinja
{% call(slot) stat_card(label="Revenue", trend="+12%", trend_up=true) %}
  {%- if slot == 'value' %}
    <span class="text-3xl text-green-600">$1,234.56</span>
  {%- endif %}
{% endcall %}
```

Renders: `[default donut icon] | Revenue / $1,234.56 (consumer-styled) | ↑ +12% (default trend renderer)`.

The consumer doesn't need to opt out of the slots they don't care about, and they don't lose the macro's chrome defaults by virtue of wrapping it. This is friendlier than Vue/Svelte slot semantics (which take total ownership when the slot is bound).

**The only thing wrapping with `{% call %}` costs you:** the existing `*_html` / `*_var` precedence — when the caller emits markup for the value slot, it overrides `value_html`, `value_var`, and the static `value` for that cell. Use the `*_html` / `*_var` params when you don't want a `{% call %}` wrapper.

The same fallback applies to `data_table` — a column with `slot: true` whose caller emits empty for that key falls back to `col.type` rendering. This means `slot: true` is non-destructive: you can pre-flag columns you might want to override later without breaking the default render.

## Trust model — slot bodies follow normal Jinja autoescape rules

Slot output is emitted via `{{ _slot_x }}` (already a Jinja `Markup` object from `caller()`). The contained HTML renders unescaped — that's the whole point: consumers compose nested macros (`badge`, `health_indicator`) whose macro-emitted markup is trusted.

But the trust model inside the slot body is the same as anywhere else in the consumer's templates: **bare `{{ var }}` inside the slot body is autoescaped; `{{ var | safe }}` is not.** If the consumer interpolates user-supplied data inside a slot, they must NOT pipe it through `| safe`. The kit cannot guarantee this — same contract as v2.8 `suite_header.right_slot`, v2.20 card slots, modal `header_slot` / `footer_slot`.

```jinja
{# WRONG — XSS sink if user_input is user-controlled #}
{% call(slot) data_table(...) %}
  {%- if slot == 'name' %}<span>{{ user_input | safe }}</span>{%- endif %}
{% endcall %}

{# CORRECT — Jinja autoescapes the bare interpolation #}
{% call(slot) data_table(...) %}
  {%- if slot == 'name' %}<span>{{ user_input }}</span>{%- endif %}
{% endcall %}

{# Or use Alpine x-text, which always escapes (bind to row.field, not interpolation) #}
{% call(slot) data_table(...) %}
  {%- if slot == 'name' %}<span x-text="row.name"></span>{%- endif %}
{% endcall %}
```

For slot bodies that mostly bind Alpine identifiers (`x-text="row.field"`, `:class="row.flag"`) this isn't a concern — Alpine's `x-text` always escapes. The risk is purely server-side `{{ var | safe }}` inside the slot body.

## Performance note — caller() runs once per slot

For fixed-name shape (`stat_card`), the macro invokes `caller('icon')`, `caller('value')`, `caller('trend')` separately. Each invocation re-runs the entire caller body with that slot arg. So a `{% call %}` body with three nested macro calls (one per slot branch) actually evaluates each `{% if slot == 'X' %}` branch three times — once per slot — but only the matching branch emits markup; the others short-circuit on the `{% if %}` and emit empty.

In practice this is a non-issue for typical card grids (10–30 cards × 3 caller invocations × cheap `{% if %}` dispatch is sub-millisecond). Watch out only when:
- The caller body computes something expensive *outside* the slot dispatch (e.g. a Python list comprehension at the top of the body) — that runs N times per card.
- A page renders thousands of stat cards in a single template (rare).

Mitigation if needed: hoist the expensive computation into the consumer's `x-data` so it runs once per card on the client, not N times per slot on the server.

For dynamic-per-key shape (`data_table`), the caller body runs once per slotted column (typically 2–5 columns), inside the macro's `{% for col in columns %}` Jinja loop — same characteristics, same mitigation.

## Author guidelines (for kit contributors adding slots to a macro)

1. **Identify the cell regions** before adding the slot. For `data_table` it's "per-column cell content". For `stat_card` it's three named regions.
2. **Pick the shape** — dynamic-per-key (Shape A) for data-shaped macros, fixed-name (Shape B) for chrome.
3. **Write the docblock first**. List slot names + Alpine identifiers in scope. Future authors read this; the macro body is too long to skim.
4. **Wrap the cell render in `{% if caller %}{{ caller(slot_key) }}{% else %}<existing>{% endif %}`**. Don't replace the existing path — keep it as the no-caller fallback.
5. **Test all three modes:**
   - **Backcompat:** call without `{% call %}` — render must match the prior version's output (run the v2.27 test against v2.28 source).
   - **Slot override:** `{% call(slot) %}` with a body for the slot — assert the body's markup appears in the output.
   - **Mixed mode:** for Shape A, mix slotted and non-slotted columns — assert both paths emit.
6. **Add a preview demo** mimicking the consumer's actual use case. The demo proves the ergonomics; tests prove the mechanism.

## Phase progress (live)

- **Phase 1 — convention prototype** (v2.28.0, closed) — slot pattern proven on `data_table` (dynamic-per-key shape) and `stat_card` (fixed-name shape).
- **Phase 2 — file split into `components/ui/<cat>.html`** (v2.29.0–v2.29.4, closed) — all 253 public macros now live in 23 category files; `ui.html` is a 1556-line shim (was 22308). Five Jinja constraints surfaced and resolved in flight (#439, #440, #443, #445, #447).
- **Phase 3 — slot convention extends across categories** (v2.30.0+, **ongoing — issue-driven**).

### Phase 3 is consumer-driven, not a one-time sweep

The original epic framing was "one PR per category file, slots added to every macro in that category". After completing Phase 2, the practical reality is more nuanced:

- **Many macros don't benefit from slots.** `button`, `badge`, `alert`, `kbd`, `divider`, `spinner`, `breadcrumb`, etc. carry single-region content that's already configurable via params. Adding slots speculatively wastes API surface and adds maintenance burden.
- **The macros that DO benefit are the ones with multiple cell regions, composable nested children, or list-shaped content.** Cards, tables, modals (for non-trivial messages), and timeline/accordion items are the primary candidates.
- **Each Phase 3 slot extension lands as a patch or minor bump** when a consumer issue (or kit author) identifies a real need. The convention is fixed and documented; subsequent additions follow the template established by `data_table` / `stat_card` / `confirm_modal`.

Macros that have shipped slot support (chronological):

| Version | Macro | Slot shape |
|---|---|---|
| v2.28.0 | `data_table` | dynamic-per-key (`"slot": true` on column dict) |
| v2.28.0 | `stat_card` | fixed-name (`icon`, `value`, `trend`) |
| v2.30.0 | `confirm_modal` | unnamed-caller body (formatted message) |
| ... | (future macros, as consumers ask) | ... |

When a consumer hits a case where a fixed param doesn't fit (a confirmation message that needs `<strong>` formatting, a card body that nests a `sparkline`, a table cell with a conditional `Recommended` badge), file an issue or open a PR. The CONVENTION rules + per-slot fallback semantics + tests pattern in this doc are the template — new slots stay backwards-compatible by defaulting to the existing render path.
