{# The feedback layer: what the page says while it is busy, and afterwards. 0.6 fills the rest in (alert, toast, skeleton). 0.1 lands `spinner` and `dialog`, because htmx makes both first-week states: "a request is in flight" and "show me this row without leaving the page". Without a component for either, the app writes its own and its own overlay — a colour literal and a stack of utilities outside the closed vocabulary. #} {% from "ui/attrs.html" import attrs %} {% from "ui/icon.html" import icon %} {# spinner(size, tone, label, indicator, **html_attrs) size: xs | sm | default | lg tone: muted | primary | success | warning | info | destructive One Lucide arc plus `animate-spin`: no second SVG asset and no runtime JS. `stroke="currentColor"` takes the colour of whatever it sits in, so a spinner inside a primary button needs no tone. `label` chooses between two accessibility modes: * no label — the glyph stays `aria-hidden` and decorative. Correct when text beside it already says what is happening ("Saving…"): announcing the glyph as well says it twice. * a label — `role="status"` and a visually hidden name. Correct when the spinner is the only announcement. `role="status"` implies a polite live region, so `aria-live` would repeat it. `indicator=true` adds htmx's `htmx-indicator` class, which is all `hx-indicator` needs: htmx injects the show/hide rule itself, so the spinner renders hidden and appears only while the request naming it is in flight — a loading state with no JavaScript of ours and no round trip to reveal it. {{ button("Start", hx_post=…, hx_indicator="#busy") }} {{ spinner(indicator=true, id="busy", label="Starting") }} #} {% macro spinner(size="default", tone="muted", label=none, indicator=false) -%} {# Closed lookups, not interpolation. Tailwind finds a class by scanning this file's text, so `text-{{ tone }}` renders happily and is absent from the stylesheet. Every value below appears here literally, and an unknown key falls back rather than emitting a class that does not exist. #} {%- set sizes = {"xs": 12, "sm": 14, "default": 16, "lg": 24} -%} {%- set tones = { "muted": "text-muted-foreground", "primary": "text-primary", "success": "text-success", "warning": "text-warning", "info": "text-info", "destructive": "text-destructive", "current": "", } -%} {%- set tone_class = tones.get(tone, tones["muted"]) -%} {{- icon("loader-circle", sizes.get(size, 16), "animate-spin") -}} {%- if label %}{{ label }}{% endif -%} {%- endmacro %} {# dialog(id, title, description, size, footer, dismissible, **html_attrs) size: sm | default | lg A panel over the page, opened and closed by the browser, with no JavaScript from us and none from the app: {{ button("Details", popovertarget="task-7") }} {% call dialog("task-7", title="Ship the render benchmark") %} … {% endcall %} `popovertarget` is a plain HTML attribute and needs no macro of its own: it passes through `**attrs` on whatever button you already have. Closing is the same attribute plus `popovertargetaction="hide"`, which is what the ✕ in the corner uses. What the Popover API provides: open state, the top layer, the backdrop, Escape-to-close, click-outside-to-close, and focus returned to the trigger. What it lacks against `showModal()` is modality — the page behind stays reachable by Tab and by a screen reader — so this is `role="dialog"` without `aria-modal`, the honest label for what the markup does. That trade is right for showing a record and wrong for a confirmation that must not be dodged: `hx-confirm` covers that case, and a real modal is a decision to ship JavaScript (CHARTER.md §11.3) rather than something to fake with CSS. The body carries `id="-body"`, the seam htmx needs: the trigger fetches the content and targets that id, so a list of rows renders one shell per row and pays for the contents only when one is opened. {{ button("Details", popovertarget="task-7", hx_get=url_for(request, "task_detail", task_id=7), hx_target="#task-7-body") }} Both happen on the one click, in the order that reads best: the panel opens immediately with whatever the shell holds (a spinner), and the response replaces it. htmx cancels the click's default action only for anchors and submit buttons, so the popover on a plain button still opens. A shell per row also keeps an open panel from showing the previous row's data while a request is in flight. One shared dialog re-targeted at each row is the right shape for a long list, and it has to accept that flash. One trap, and it is silent: `hx-swap` is inherited. A trigger nested inside an element that swaps itself with `outerHTML` — a row that polls, say — replaces the body element rather than its contents, id and all, so the first open works and none of the others do. Spell out `hx_swap="innerHTML"` there. #} {% macro dialog(id, title=none, description=none, size="default", footer=none, dismissible=true) -%} {# Basecoat sizes `.dialog > *` and gives `.alert-dialog` a `data-size`; the two extra widths extend that same attribute API rather than inventing a `dialog-lg` class. `default` emits nothing, so the common case inherits Basecoat's own rule. #} {%- set sizes = {"sm": "sm", "default": "", "lg": "lg"} -%} {%- set size_attr = sizes.get(size, "") -%}