{% extends "base.html" %} {% from "_parts.html" import code, defs, files, lesson, prose, stage, stage_with %} {% from "ui/button.html" import button, button_group %} {% from "ui/data.html" import badge, card, code_block, link %} {% from "ui/form.html" import field_row %} {% from "ui/layout.html" import grid, page_header, row, stack %} {% from "ui/table.html" import cell, table %} {# The rail in base.html is built from this list, so a section renamed here is renamed in the navigation. Ids match the anchors `lesson()` emits. #} {% set sections = [ {"id": "wiring", "num": "01", "title": "Wiring: config, mount, templates"}, {"id": "exchange", "num": "02", "title": "The htmx request and response"}, {"id": "targets", "num": "03", "title": "hx-target and hx-swap"}, {"id": "triggers", "num": "04", "title": "hx-trigger: typing, scrolling, polling"}, {"id": "partials", "num": "05", "title": "Partials: one file, page and swap"}, {"id": "oob", "num": "06", "title": "One reply, many regions: hx-swap-oob"}, {"id": "broadcast","num": "07", "title": "One id, many fragments: HX-Trigger"}, {"id": "pending", "num": "08", "title": "While the request is in flight"}, {"id": "patterns", "num": "09", "title": "Nine htmx patterns, and their handlers"}, {"id": "messages", "num": "10", "title": "Messages: three layers"}, {"id": "errors", "num": "11", "title": "When a form is rejected"}, {"id": "knob", "num": "12", "title": "Rebranding: brand and status tokens"}, {"id": "gate", "num": "13", "title": "fjkit check: the closed vocabulary"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} Thirteen lessons: how a fjkit app is wired, what htmx sends, what a rejected form does, how a rebrand works, and the command that fails a build. Every demo below is live. {% endset %} {{ page_header("Build the interface where you build the routes", intro) }} {# ============================================================== 01 #} {% set t01 %}Everything else you write is your app, not your front end.{% endset %} {% call lesson("wiring", "01", "Wiring: config, mount, render", t01) %} {% call prose() %}

A fjkit app touches the kit in exactly three places. FjkitConfig holds every knob. mount_fjkit() serves the stylesheet and the vendored JS straight out of the installed package, and builds the Jinja environment once per process. @render names the template a route answers with.

Click through the four files. There is no package.json and no watcher to keep running. examples/fjkit-demo in the repository is the same four files in a working app.

{% endcall %} {% call card(padded=false) %}
{{ code_block("", id="wiring-code") }}
{% endcall %} {% call prose() %}

The second tab is the one worth a picture. base.html is four blocks long because ui/shell.html — the template it extends — already holds everything else: the <head>, the script that applies dark mode before the first paint, the links to the stylesheet and the three vendored scripts, and the header/main/footer skeleton. Below is that file, drawn. The regions in the brand colour are the blocks you fill; everything else the shell owns, and you never see it unless you go looking.

{% endcall %} {% call stage("ui/shell.html, drawn", "Override a block by naming it in your base.html. Every block is listed on the Cheatsheet.") %} {% include "_shell_diagram.html" %} {% endcall %} {% call prose() %}

Four words below carry a specific meaning on this page, and every later lesson leans on them.

{% endcall %} {% set w_markup %}HTML text — what a template produces before a browser parses it, like <div id="board">…</div>. When a later lesson says a route "has markup waiting", it means a template is ready to turn the handler's return value into HTML rather than into JSON{% endset %} {% set w_shell %}ui/shell.html, the figure above: the document around your page — <head>, header, <main>, footer, toaster{% endset %} {% set w_fragment %}a piece of HTML that is not a whole document: no <!doctype>, no <head>, just the elements. It is what an htmx response carries{% endset %} {% set w_partial %}the template that renders a fragment. Its file name starts with an underscore, it never extends the shell, and the page and the htmx endpoints both use it — lesson 05{% endset %} {{ defs("The four words this page uses precisely", [ ("markup", w_markup), ("the shell", w_shell), ("a fragment", w_fragment), ("a partial", w_partial), ]) }} {% endcall %} {# ============================================================== 02 #} {% set t02 %}The server answers a swap with HTML. Nothing is serialised for it, and no client-side state is kept in sync.{% endset %} {% call lesson("exchange", "02", "The htmx request and response", t02, tag="htmx") %} {% call prose() %}

This is the whole idea, and it is smaller than it looks. An element names the request to make and where to put the answer. The server answers with a piece of HTML — the same markup it would have put in the page — and htmx drops that piece into the element you named.

Nothing is serialised for the swap, nothing is re-rendered on the client, and no state lives in two places. Press the button: the request below is real. htmx is loaded on this page, and a mock server in the browser plays the FastAPI side.

{% endcall %} {% call stage("One round trip", "The only thing that changes on the client is the subtree named by hx-target.") %} {% include "_diagram.html" %} {% endcall %} {% set fire %} {% call row(gap=2) %} {{ badge("idle", variant="outline", id="dg-stage-label") }} {{ button("Fire the request", variant="primary", size="sm", icon_name="arrow-right", icon_end=true, id="dg-run") }} {% endcall %} {% endset %} {% call stage_with("Try it", fire, "The exchange below is a genuine htmx request.") %} {% call stack(4) %}
{% endcall %} {% endcall %} {% call prose() %}

Those HX-* headers are how a route tells a swap from a full page load. Everything else is an ordinary POST. No client framework is involved on either side.

{% endcall %} {% endcall %} {# ============================================================== 03 #} {% set t03 %}Two attributes decide everything: which element, and how it is replaced.{% endset %} {% call lesson("targets", "03", "hx-target and hx-swap", t03, tag="htmx") %} {% call prose() %}

{{ link("hx-target", "https://htmx.org/attributes/hx-target/") }} picks the element. {{ link("hx-swap", "https://htmx.org/attributes/hx-swap/") }} says what to do with it. This pair is where most htmx confusion lives, so try it against a live board. Every combination below runs a real request and swaps the real response.

outerHTML replaces the target itself, so the response has to include the wrapper. innerHTML, the default, replaces what is inside it, so the response must not include the wrapper. Mixing the two up gives you a board inside a board.

{% endcall %} {% call card(padded=false) %}
{% endcall %} {% call stage("The board") %}
{% endcall %} {% call stage("What that spells, and what came back", padded=false) %} {{ code_block("", id="swap-code") }}
{% endcall %} {% endcall %} {# ============================================================== 04 #} {% set t04 %}Sensible defaults, and one attribute for everything else.{% endset %} {% call lesson("triggers", "04", "hx-trigger: typing, scrolling, polling", t04, tag="htmx") %} {% call prose() %}

You usually write no trigger at all. A button fires on click, a form on submit, an input or select on change. {{ link("hx-trigger", "https://htmx.org/attributes/hx-trigger/") }} is for the other cases: it is what gives you search-as-you-type, lazy loading and polling with no JavaScript. All three below are live.

{% endcall %} {% set searching %}{{ badge("searching…", variant="info", id="demo-searching") }}{% endset %} {% call stage_with("keyup changed delay:400ms", searching, "changed skips the request when the value did not change; delay:400ms restarts the countdown on every keystroke.") %} {% call stack(4) %}
{% endcall %} {% endcall %} {% set lazy_reset %}{{ button("reset", variant="ghost", size="xs", id="demo-lazy-reset") }}{% endset %} {% call stage_with("revealed — load when scrolled into view", lazy_reset, "Same idea as an image's loading=\"lazy\", except the thing deferred is a rendered fragment.") %}
{% endcall %} {% set poll_toggle %}{{ button("start polling", variant="outline", size="xs", id="demo-poll-toggle") }}{% endset %} {% call stage_with("every 2s — polling", poll_toggle, "Polling stops when the element is removed. For anything chattier, reach for SSE.") %}

Build status, refreshed by the server.

{% endcall %} {% endcall %} {# ============================================================== 05 #} {% set t05 %}The page embeds the fragment the htmx endpoint returns — so they cannot drift.{% endset %} {% call lesson("partials", "05", "Partials: one file, page and swap", t05, tag="htmx") %} {% call prose() %}

Now the fjkit half. A partial is any template whose name starts with an underscore. It never extends the shell, it declares its own imports, and it wraps itself in the element the swap replaces. The full page {% raw %}{% include %}{% endraw %}s it; every htmx endpoint returns it bare.

That is why a fjkit app has no "update the row" code path. Every mutation re-renders the whole board partial and swaps it. One definition of what a board looks like, used by the first paint and by every request after it. The real files are examples/fjkit-demo/app/templates/tasks/ in the repository.

{% endcall %} {% set flow_picker %} {% call button_group(id="flow-picker", role="group", aria_label="Request type") %} {{ button("GET /tasks", variant="secondary", size="sm", data_flow="page", aria_pressed="true") }} {{ button("POST /tasks (hx-post)", variant="ghost", size="sm", data_flow="swap", aria_pressed="false") }} {% endcall %} {% endset %} {% call stage_with("Request", flow_picker) %} {% call stack(4) %}
{{ code_block("", id="flow-code") }} {% endcall %} {% endcall %} {% call prose() %}

Those two handlers name the same file, and the name is the part that misleads. _board.html is not one task. It is the entire board — the create form, the filter bar, the stats card and every row in the table, wrapped in the one <div id="board"> that every control on the page targets. The leading underscore means "not a page", not "small".

So a click on Advance in row three sends one POST, and what comes back is the board with all eight rows re-rendered, one of them now saying Doing. That is the trade fjkit makes: you render more HTML than strictly changed, and in exchange there is exactly one definition of what a board looks like. Below are the three files, and every htmx attribute in them.

{% endcall %} {{ files([ {"id": "part-page", "label": "tasks/page.html", "lang": "jinja", "source": "{# tasks/page.html — answers the full GET #} {% extends \"base.html\" %} {% from \"ui/layout.html\" import page_header %} {% block content %} {{ page_header(\"Tasks\", \"Every control here swaps #board.\") }} {# The first paint. This is the same file POST /tasks answers with. #} {% include \"tasks/_board.html\" %} {% endblock %}", "note": "Extends the shell, writes a heading, and includes the partial. Nothing about the board is described twice."}, {"id": "part-board", "label": "tasks/_board.html", "lang": "jinja", "source": "{# tasks/_board.html — the whole board: create form, filters, stats, every row #} {% from \"ui/button.html\" import button, button_group %} {% from \"ui/table.html\" import table %} {% from \"tasks/macros.html\" import task_row, TASK_COLUMNS %} {# The wrapper is inside the file, because hx-swap=\"outerHTML\" replaces it. #}
{% include \"tasks/_new_task.html\" %} {# The filter bar. Each button re-fetches the board with a query string. #} {% call button_group() %} {% for value, label in status_filters %} {{ button(label, size=\"xs\", hx_get=url_for(request, \"tasks_board\") ~ (\"?status=\" ~ value if value else \"\"), hx_target=\"#board\", hx_swap=\"outerHTML\") }} {% endfor %} {% endcall %} {% call table(TASK_COLUMNS, rows=tasks) %} {# A repeated element is a macro, not an include in a loop. #} {% for task in tasks %}{{ task_row(request, task, filter_query) }}{% endfor %} {% endcall %}
", "note": "No extends and no doctype, so this renders standalone \u2014 which is what lets an htmx endpoint return it bare."}, {"id": "part-row", "label": "tasks/macros.html", "lang": "jinja", "source": "{# tasks/macros.html — one row, and the htmx that fires from it #} {% macro task_row(request, task, filter_query=\"\") %} {{ cell(task.title, tone=\"strong\") }} {{ cell(badge(task.status.value | capitalize, variant=task.status_variant)) }} {% call row_actions() %} {# Advance one task — and get the whole board back. #} {{ button(\"Advance\", variant=\"ghost\", size=\"xs\", hx_post=url_for(request, \"tasks_advance\", task_id=task.id) ~ filter_query, hx_target=\"#board\", hx_swap=\"outerHTML\", hx_disabled_elt=\"this\") }} {{ button(\"\", variant=\"ghost\", size=\"icon-xs\", icon_name=\"trash\", aria_label=\"Delete \" ~ task.title, hx_delete=url_for(request, \"tasks_delete\", task_id=task.id) ~ filter_query, hx_target=\"#board\", hx_swap=\"outerHTML\", hx_confirm=\"Delete this task?\") }} {% endcall %} {% endmacro %}", "note": "The row is a macro, and it is where the htmx lives: every action points at #board and swaps outerHTML, so the reply is always the whole partial."}, ]) }} {% call grid(cols=2, gap=4) %} {% set wh_notrow %}_board.html renders the form, the filters, the stats and all the rows. A single task is task_row() in macros.html — a macro, because a repeated element is a macro and a partial is what a request answers with{% endset %} {% set wh_wrapper %}the first line of the partial is <div id="board">. It has to be, because every control says hx-swap="outerHTML": the element being replaced is the wrapper, so the reply has to bring one back{% endset %} {% set wh_context %}both callers need the identical names in the context — tasks, stats, status_filters. The router builds them once, in board(), and both handlers return it{% endset %} {% set wh_filter %}filter_query rides along on every row action, so advancing a task while the board is filtered to Doing answers with the board still filtered to Doing{% endset %} {{ defs("Why one file covers both", [ ("it is the board, not a row", wh_notrow), ("the wrapper is in the file", wh_wrapper), ("one context, built once", wh_context), ("the filter travels", wh_filter), ]) }} {% set wh_cost %}the whole board, every time. That is a few kilobytes of HTML the browser parses instead of a diff it does not have to compute — and no client-side model to keep in step{% endset %} {% set wh_when %}when a board grows past what you want to re-render, the answer is a smaller partial with its own id, not a smarter client. Lesson 06 and lesson 07 are both that answer{% endset %} {% set wh_focus %}an outerHTML swap replaces the element the focused input is inside. htmx restores focus by id, so give an input that survives a swap a stable id{% endset %} {% set wh_stream %}templates.page() renders this. Reach for templates.stream() only when the row count is the user's to choose{% endset %} {{ defs("What it costs", [ ("bytes on the wire", wh_cost), ("when it stops paying", wh_when), ("focus", wh_focus), ("big boards", wh_stream), ]) }} {% endcall %} {% set board_busy %}{{ badge("working…", variant="info", id="demo-board-busy") }}{% endset %} {% call stage_with("The board, live — add, advance, delete", board_busy, "Every control targets #demo-board, and every response is the whole board partial.") %} {% call stack(4) %}
{% endcall %} {% endcall %} {% call grid(cols=2, gap=4) %} {% set trap_page %}returning page.html from an htmx route nests a whole document inside your div — return the partial{% endset %} {% set trap_wrapper %}outerHTML needs the wrapper in the response, or the target disappears{% endset %} {% set trap_inherit %}hx-target and hx-swap are inherited from ancestors. A button inside a card that polls itself with outerHTML inherits that swap, and replaces the element it meant to fill — spell both out on any trigger nested inside another htmx element{% endset %} {{ defs("Traps worth knowing", [ ("full page back", trap_page), ("4xx does not swap", "htmx ignores error responses by default. The shell adds 422, and only 422, which is what lets a rejected form come back as the form itself"), ("lost wrapper", trap_wrapper), ("stale filter", "a row action must carry the active filter, or the swap silently resets the view"), ("inherited swap", trap_inherit), ]) }} {{ defs("Server-side helpers", [ ("HX-Request", "the header that tells a route it is answering a swap"), ("HX-Trigger", "response header that fires a client event — how fjkit raises a toast from a swap, which has no toaster in it"), ("HX-Redirect", "full-page redirect from inside a swap response"), ("hx-swap-oob", "one response, several regions — the next lesson"), ]) }} {% endcall %} {% endcall %} {# ============================================================== 06 #} {% set t06 %}hx-target names one element. When one answer has to move six, the other five carry their own address.{% endset %} {% call lesson("oob", "06", "One reply, many regions: hx-swap-oob", t06, tag="htmx") %} {% call prose() %}

Every swap so far has had one target, and that is enough while the things that change sit under one element. The board is a single <div id="board">: every control on it points there, and every response is the whole board.

A search result is not one thing. One query moves a row of counters above the table, the table itself, a progress card, a facet list and a siblings panel in the aside, and a detail panel below it — six fragments in three places, and the only element that contains all six is <body>. Swapping the body is a page reload with extra steps.

So the reply carries the other five out of band — outside the one channel hx-target describes. Each of them says where it belongs by carrying the id it is going to.

{% endcall %} {% call prose() %}

Nothing about the trigger is special, and that is the point worth seeing before the reply. One input, one hx-get, one hx-target — the same shape as lesson 03. It names #search-matches and knows about no other region on the page.

{% endcall %} {{ files([ {"id": "oob-call", "label": "the macro call", "lang": "jinja", "source": "{# search/page.html — the one control that starts all of this #} {% call form(action=url_for(request, \"search_page\"), method=\"get\") %} {% call field_row(\"field-and-button\") %} {{ text_field(\"q\", label=\"Search tasks\", value=query, type=\"search\", hx_get=url_for(request, \"search_page\"), hx_trigger=\"input changed delay:250ms, search\", hx_target=\"#search-matches\", hx_swap=\"outerHTML\", hx_push_url=\"true\", hx_indicator=\"#search-busy\") }} {{ button(\"Search\", variant=\"primary\", type=\"submit\", icon_name=\"search\") }} {% endcall %} {% endcall %}", "note": "hx_* keywords on any fjkit macro become hx-* attributes on the element it emits. The form around it carries action and method, so the same query still works with scripting off."}, {"id": "oob-html", "label": "the HTML it emits", "lang": "html", "source": "", "note": "One target. htmx will replace #search-matches with whatever comes back \u2014 and it does not know the reply is about to move five other elements."}, ]) }} {% set oob_reply = "
…
← no marker: this is what hx-target gets
…
← flies to the counters
…
← flies to the aside
…
← flies to the aside
…
← flies to the aside
…
← flies below the table" %} {% call stage("What comes back from one keystroke", "GET /search?q=cache, answered by one handler and one response model.", padded=false) %} {{ code_block(oob_reply, label="The response body") }} {% endcall %} {% call grid(cols=2, gap=4) %} {% set step_lift %}every top-level element carrying hx-swap-oob is lifted out of the response{% endset %} {% set step_place %}each one is matched to the element on the page with the same id and replaces it{% endset %} {% set step_rest %}whatever is left is swapped into hx-target as usual{% endset %} {{ defs("What htmx does, in order", [ ("1 — lift", step_lift), ("2 — place", step_place), ("3 — then the target", step_rest), ]) }} {% set oob_values %}true replaces the matching element; innerHTML, beforeend and the rest of the swap vocabulary all work here too{% endset %} {% set oob_silent %}an id that matches nothing is dropped without a word — no error, no console entry, and that region simply keeps the previous query's numbers{% endset %} {% set oob_toplevel %}an out-of-band fragment nested inside a wrapper is not one — which is why the reply template is a flat list of includes{% endset %} {% set oob_target %}never mark the fragment that hx-target names: it would be lifted out too, and the target would be swapped with nothing{% endset %} {{ defs("Four things to know", [ ("the value", oob_values), ("it fails in silence", oob_silent), ("top level only", oob_toplevel), ("not the target", oob_target), ]) }} {% endcall %} {% call prose() %}

On the fjkit side there is no new machinery. The page and the reply {% raw %}{% include %}{% endraw %} the same six partials and differ in one variable: the page renders them cold, the reply renders them addressed. Each partial defaults the flag itself, so it still renders standalone.

{% endcall %} {% set oob_tmpl = "{# _results.html — what an htmx request gets #} {% include \"search/_matches.html\" %} {% with oob = true %} {% include \"search/_stats.html\" %} {% include \"search/_progress.html\" %} {% include \"search/_facets.html\" %} {% include \"search/_detail.html\" %} {% include \"search/_related.html\" %} {% endwith %} {# _stats.html — and every other region, at the top #} {% set oob = oob | default(false) %}
…
" %} {% call stage("One set of partials, two ways of rendering them", none, padded=false) %} {{ code_block(oob_tmpl, label="Jinja") }} {% endcall %} {% call prose() %}

The payoff is that the page decides where a fragment lives, once. Move the facet card from the aside into the main column and the router, the response model and the swap are all unchanged. The working files are examples/fjkit-demo/app/templates/search/ in the repository — and the next lesson is the other half of that same page, because a real screen needs both.

{% endcall %} {% endcall %} {# ============================================================== 07 #} {% set t07 %}The opposite problem to lesson 06: the trigger produces an id, and each region has a different question to ask with it.{% endset %} {% call lesson("broadcast", "07", "One id, many fragments: HX-Trigger", t07, tag="htmx") %} {% call prose() %}

The last lesson worked because one handler could answer every region at once: one query, six fragments, one response model holding all of them. Opening a row on that same page is a different shape. The click produces an id, and the panels that care about it each need their own query — the detail of that task, the other tasks its owner has. A handler answering all of them would have to import every service on the page, and a third panel would mean editing it.

So the pick answers with its own region — the table, with the row now marked — and sends the id out in HX-Trigger. htmx raises that header as a DOM event, and the fragments that care listen for it and fetch their own data. The route does not know how many of them there are, or that any of them exist. Adding a panel is one more subscriber and no change to any route.

{% endcall %} {% set bcast_route = "@router.get(\"/search/select/{task_id}\", name=\"search_select\") @render(\"search/_matches.html\", hx_trigger=lambda task_id: {\"task-selected\": {\"task_id\": task_id}}) def select_task(service: ServiceDep, task_id: int, q: str = \"\") -> MatchesResponse: \"\"\"Mark the row, answer with the table, and tell the panels which id.\"\"\" return _matches(service, q, _selected(service, task_id).id)" %} {% call stage("The route that picks", "A GET, because picking changes nothing on the server. The table is the button's hx-target, so the reply swaps it in band.", padded=false) %} {{ code(bcast_route, label="router.py") }} {% endcall %} {% call prose() %}

hx_trigger declares the event. It is a parameter of @render rather than a header the handler writes, so it works the same on a route that also renders. A callable is resolved per request from the handler's own parameters by name — task_id here — plus result, what the handler returned. Returning None from it is not an error, so a route can broadcast only sometimes.

Nothing else about that route is special. It is an ordinary partial route — one template, one response model — with an event attached, and that is what makes the pattern cheap: the swap and the broadcast are one round trip, declared in one place.

A second action on the same page shows the other half. Advance changes a status, so it raises a different event, and a different set of fragments cares.

{% endcall %} {% set bcast_shared = "@router.post(\"/search/advance/{task_id}\", name=\"search_advance\") @render(\"search/_detail.html\", hx_trigger=lambda task_id: {\"task-changed\": {\"task_id\": task_id}}) def advance_task(service: ServiceDep, task_id: int) -> DetailResponse: \"\"\"Advance the task, answer with the detail panel, and say what changed.\"\"\" if service.advance(task_id) is None: raise HTTPException(status_code=404, detail=\"task not found\") return DetailResponse(selected=service.get(task_id))" %} {% call stage("A second event, from a route that changes something", "The reply is the swap — the detail panel is this button's hx-target — and the header is what the other four hear.", padded=false) %} {{ code(bcast_shared, label="router.py") }} {% endcall %} {% call prose() %}

Two events, and each fragment hears the ones it has a reason to. The counters listen for task-changed and not task-selected, because picking a task changes no count. The facets listen for neither, because advancing one changes no owner and no priority. Which broadcasts a fragment hears is the fragment's own decision, and no route knows it exists.

{% endcall %} {% call stage("Who hears what", none, padded=false) %} {% call table([{"label": "Fragment"}, {"label": "task-selected"}, {"label": "task-changed"}, {"label": "Why"}]) %} {{ cell("the results table", tone="strong") }} {{ cell(badge("no", variant="outline")) }} {{ cell(badge("yes", variant="success")) }} {{ cell("it is the pick reply's own target; an advance changes what a row says") }} {{ cell("the detail panel", tone="strong") }} {{ cell(badge("yes", variant="success")) }} {{ cell(badge("no", variant="outline")) }} {{ cell("it is the advance reply's own target — hearing that event is the loop") }} {{ cell("the siblings panel", tone="strong") }} {{ cell(badge("yes", variant="success")) }} {{ cell(badge("yes", variant="success")) }} {{ cell("whose siblings these are, and what their status is") }} {{ cell("the counters, the progress card", tone="strong") }} {{ cell(badge("no", variant="outline")) }} {{ cell(badge("yes", variant="success")) }} {{ cell("a pick changes no count; an advance does") }} {{ cell("the facets", tone="strong") }} {{ cell(badge("no", variant="outline")) }} {{ cell(badge("no", variant="outline")) }} {{ cell("no facet on that card is about a status") }} {% endcall %} {% endcall %} {% call prose() %}

One rule holds this together: the fragment a route answers with must not subscribe to the event that route raises. It is already being replaced in band, so hearing the event fetches it a second time for the same click. On a fragment that is its own target, that never stops. htmx does not catch it, because a subscriber asking for its own URL is what the attribute says to do. So the table hears only task-changed and the detail panel only task-selected: each is a route's target for one event and a subscriber for the other, never both for the same one.

{% endcall %} {% set bcast_sub = "{# a panel that needs only the id #}
…
{# a region that also depends on the search box #}
…
" %} {% call stage("The subscriber", "These attributes on every region that cares, and nothing on the ones that do not.", padded=false) %} {{ code_block(bcast_sub, label="Jinja") }} {% endcall %} {% call grid(cols=2, gap=4) %} {% set sub_trigger %}from:body — htmx raises the event on the element that made the request, and it bubbles. The panels are nowhere near that element, so they listen where it arrives{% endset %} {% set sub_vals %}hx-vals="js:…" is the one place the id turns back into a query parameter. Reading it out of the event, rather than remembering the selection on the server, keeps the endpoints stateless and two open tabs independent{% endset %} {% set sub_target %}hx-target="this" — every subscriber replaces itself, so a reply is always one region and the sender never has to know how many regions exist{% endset %} {% set sub_include %}hx-include — for what the event does not carry. The counters count the matches, so they need the search box as well as the id, and a selector fetches it off the page instead of every event carrying it{% endset %} {{ defs("The attributes that matter", [ ("who is listening", sub_trigger), ("how the id arrives", sub_vals), ("what else it needs", sub_include), ("what gets replaced", sub_target), ]) }} {% set bc_object %}the trigger value must be a JSON object. htmx passes an object through as event.detail and wraps anything else — an array included — as {value: …}, so a listener reading event.detail.task_id finds nothing{% endset %} {% set bc_order %}HX-Trigger fires before the swap, which is why the id travels in the event: a hidden input holding the selection would still hold the old one when the subscribers read it. HX-Trigger-After-Swap is the header for the other order{% endset %} {% set bc_inherit %}hx-vals is inherited, and htmx merges it up the tree on a path that does not consult hx-disinherit. An htmx element inside a subscriber — the row's own title, which is the button that opens it — has to say hx-vals="unset", which is the only value that stops the walk{% endset %} {% set bc_cost %}N subscribers is N requests. That is the trade: the page fans out instead of the handler fanning in, and hx-sync or a cache is where you pay it back if the queries are expensive{% endset %} {{ defs("Four things to know", [ ("send an object", bc_object), ("it fires before the swap", bc_order), ("inheritance leaks in", bc_inherit), ("N panels, N requests", bc_cost), ]) }} {% endcall %} {% call prose() %}

The two lessons do not compete, and the demo does not put them on separate pages. They answer different questions, and /search asks both: a query is one question the server can answer completely, and a pick is an id that three regions each have a different question about.

{% endcall %} {% call stage("Which one a page needs", none, padded=false) %} {% call table([{"label": "Ask"}, {"label": "hx-swap-oob"}, {"label": "HX-Trigger"}]) %} {{ cell("what produces the change", tone="strong") }} {{ cell("one query the server can answer for every region") }} {{ cell("an id each region has a different question about") }} {{ cell("who knows the regions", tone="strong") }} {{ cell("the handler — it renders all of them") }} {{ cell("nobody; each region declares itself") }} {{ cell("requests per interaction", tone="strong") }} {{ cell(badge("1", variant="success")) }} {{ cell(badge("1 + one per subscriber", variant="outline")) }} {{ cell("adding a region", tone="strong") }} {{ cell("edit the handler, the model and the reply template") }} {{ cell("one more subscriber; nothing else changes") }} {% endcall %} {% endcall %} {% call prose() %}

The working files are examples/fjkit-demo/app/features/search/ and app/templates/search/ — the same page as the last lesson. Every region there carries a render stamp, so the page itself shows which regions an action reached, with no network tab. A query is one request and six new stamps. A pick is three requests and three stamps — the table, the detail panel, the siblings panel — while the counters, the progress card and the facets sit still. An advance is five, and the facets are then the only card that does not move.

{% endcall %} {% endcall %} {# ============================================================== 08 #} {% set t08 %}A swap is a round trip, and nothing stops a second click during it. Two attributes cover the whole of the wait.{% endset %} {% call lesson("pending", "08", "While the request is in flight", t08, tag="htmx") %} {% call prose() %}

htmx adds the class htmx-request to an element for the length of a request and removes it afterwards. Without hx-indicator that element is the one that fired the request; with it, it is whatever the selector names. Then this stylesheet — htmx's own, injected at startup — does the rest:

{% endcall %} {% set ind_css = ".htmx-indicator { opacity: 0; visibility: hidden } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; visibility: visible; transition: opacity 200ms ease-in }" %} {% call stage("The rules htmx injects", "Two selectors, comma separated.", padded=false) %} {{ code_block(ind_css, label="CSS") }} {% endcall %} {% call prose() %}

The first selector covers an indicator inside the marked element — a spinner in the button that was clicked. The second covers an indicator that is itself the marked element, which is the shape fjkit uses: spinner(indicator=true) adds htmx-indicator and nothing else, so a loading state costs you no CSS, no JavaScript and no round trip to reveal it.

{% endcall %} {% set ind_src = "{% call form(action=url_for(request, \"jobs_start\"), target=\"#job-list\", hx_indicator=\"#job-busy\", hx_disabled_elt=\"find button[type=submit]\") %} {% call field_row(\"field-and-button\") %} {{ select_field(\"kind\", label=\"Background job\", options=kind_options) }} {% call row(gap=2) %} {{ button(\"Start\", variant=\"primary\", type=\"submit\", icon_name=\"play\") }} {{ spinner(indicator=true, id=\"job-busy\", label=\"Starting the job\") }} {% endcall %} {% endcall %} {% endcall %}" %} {% call stage("Both attributes, on one form", "examples/fjkit-demo/app/templates/jobs/page.html", padded=false) %} {{ code_block(ind_src, label="Jinja") }} {% endcall %} {% call prose() %}

hx-disabled-elt is the same machinery aimed at a different problem. It sets disabled on whatever the selector names for the length of the request and clears it after, which is what stops a double click from advancing a task twice or posting two of the same form. The selector takes this, a find …/closest … relative form, or an ordinary CSS selector.

{% endcall %} {% call grid(cols=2, gap=4) %} {% set p_count %}both are counted, not toggled — htmx keeps a request count per element, so an early reply cannot re-enable a control another request is still using{% endset %} {% set p_space %}the indicator is hidden with visibility and opacity, never display — it holds its space, so nothing on the page jumps when it appears{% endset %} {% set p_inherit %}both are inherited: put hx-indicator on a container and everything inside it shares one spinner. this and inherit are valid values{% endset %} {% set p_warn %}a selector that matches nothing does warn here — The selector "…" returned no matches! — unlike an out-of-band id, which fails silently{% endset %} {{ defs("How they behave", [ ("counted", p_count), ("no layout shift", p_space), ("inherited", p_inherit), ("it tells you", p_warn), ]) }} {% set p_inside %}keep the indicator outside the target: an element inside what is being replaced is torn out mid-request, and its id with it{% endset %} {% set p_default %}with no hx-indicator at all the class lands on the trigger itself, so .htmx-request styling on a button is the cheapest loading state there is{% endset %} {% set p_marker %}htmx marks what it disabled with data-disabled-by-htmx, so a control that was already disabled stays disabled afterwards{% endset %} {% set p_flash %}expect some indicators to barely flash. That is the request being fast, not the indicator being broken — the jobs page does its work after the response, which is why the card polls{% endset %} {{ defs("Traps worth knowing", [ ("not inside the target", p_inside), ("the free version", p_default), ("it remembers", p_marker), ("a fast request", p_flash), ]) }} {% endcall %} {% endcall %} {# ============================================================== 09 #} {% set t09 %}Screens differ; the shapes do not. Each one is a trigger, a target and a partial.{% endset %} {% call lesson("patterns", "09", "Nine htmx patterns, and their handlers", t09, tag="htmx") %} {% call prose() %}

The whole attribute vocabulary fits on one screen, and the Cheatsheet page prints it. What takes longer to learn is the handful of arrangements it keeps forming. Here they are in the fjkit spelling: the macro call on one tab, the handler that answers it on the other.

Every handler below returns a partial and is a plain def, so Starlette runs it in the threadpool. Each pattern links out to the htmx page it comes from. The upstream {{ link("docs", "https://htmx.org/docs/") }} and {{ link("attribute reference", "https://htmx.org/reference/") }} are short enough to read whole, and worth reading whole.

{% endcall %} {% set pattern_doc %} {% call row(gap=2) %} {{ badge("", variant="outline", id="pattern-tag") }} {% endcall %} {% endset %} {% call stage_with("Pattern", pattern_doc) %} {% call grid(cols=2, gap=4) %}
{{ code_block("", id="pattern-code") }}
{% endcall %} {% endcall %} {% call grid(cols=2, gap=4) %} {{ defs("What they have in common", [ ("one partial", "the page includes it, the endpoint returns it — never two renderings of the same fragment"), ("no client state", "the cursor, the filter and the edit mode all live in the URL or in which partial is on screen"), ("state in the URL", "anything a reload has to survive is a query string a full GET can answer"), ("degrades", "drop the hx-* keywords and the same macros emit a form and links that still work"), ]) }} {{ defs("Not yet, on purpose", [ ("dialogs", "hx-confirm until then — it is the browser's own, and it works"), ("sorting", "a filter bar with different query params; the table macro learns it in 0.4"), ]) }} {% endcall %} {% endcall %} {# ============================================================== 10 #} {% set t10 %}One message, three layers — and only the top one needs a secret.{% endset %} {% call lesson("messages", "10", "Messages: three layers", t10, tag="fjkit.messages") %} {% call prose() %}

Showing a line of text to the person who made this request is not one feature but three, stacked: the surface that draws it, the way it gets onto a response, and the way it survives a redirect. Only the last needs a signing secret, which is why one of them is a plugin and the other two are not.

{% endcall %} {% call stage("The three layers", "Two of them are core, because neither of them needs anything from you.") %} {% call table([{"label": "Layer"}, {"label": "Where it lives"}, {"label": "Core or plugin"}]) %} {{ cell("the surface", tone="strong") }} {{ cell("ui/feedback.html's toaster region, drawn by ui/shell.html on every page whether or not anything is in it") }} {{ cell(badge("core", variant="secondary")) }} {{ cell("onto this response", tone="strong") }} {{ cell("fjkit.messages — messages.add(request, …), from a route, a service or an exception handler") }} {{ cell(badge("core", variant="secondary")) }} {{ cell("past a redirect", tone="strong") }} {% set flash_cell %}fjkit.flash — a short-lived signed cookie, a plugin on the Plugins page{% endset %} {{ cell(flash_cell) }} {{ cell(badge("plugin", variant="info")) }} {% endcall %} {% endcall %} {{ code("from fjkit import messages messages.add(request, \"Saved\", category=\"success\") messages.add(request, \"Could not save\", \"The title is required.\", category=\"error\")", label="anywhere that has the request") }} {% call prose() %}

No plugin, no secret, no configuration — which is the point of drawing the line here. Signing a cookie needs a secret, and requiring one from every app, including one with no forms and nothing to say, would end mount_fjkit(app) working with no config at all. Everything that does not need a secret is core.

The third layer is not a superset of the second either. A redirect replaces the page, so the document that would have shown the message is gone before the response arrives, and a cookie is the only thing that crosses. On a response that is not a redirect, that same cookie is a round trip for a message about to be rendered anyway. So the two are layered rather than merged: FlashPlugin queues into fjkit.messages, and the shell has one loop instead of one per delivery mechanism.

{% endcall %} {% call stage("How it reaches the browser", "@render picks the channel. The caller never has to know which.") %} {% call table([{"label": "What the response is"}, {"label": "The channel"}, {"label": "What draws it"}]) %} {{ cell("a full page render", tone="strong") }} {{ cell("the context") }} {{ cell("the shell's toaster region, straight into the markup") }} {{ cell("a fragment, an htmx swap", tone="strong") }} {{ cell(badge("HX-Trigger", variant="info")) }} {{ cell("the shell's listener, which turns the event back into a toast") }} {% endcall %} {% endcall %} {% call prose() %}

A header rather than markup, because a fragment has no toaster in it: an htmx swap replaces one region, and the region a toast belongs in is elsewhere on the page. It also works on a response htmx will not swap at all. htmx reads HX-Trigger in handleAjaxResponse before it consults responseHandling for the status code, so a 422 or a 500 can still raise a toast on the page the person is looking at. That is the entire delivery mechanism for section 11.

{% endcall %} {% set consumed %} a template that loops over the queue marks it delivered; asking whether there is anything to show does not, because that is not showing it. The flash plugin depends on the distinction {% endset %} {% set category_note %} info, success, warning, error — a meaning, not a colour. toast() maps each to an icon and a timeout and fjkit.css maps each to a role token, so "error is red" is decided once and survives a rebrand {% endset %} {{ defs("Worth knowing", [ ("fjkit_messages() is a global, not a context key", "so a route passes nothing in, and a page that has never heard of messages still shows one"), ("consumed by being iterated", consumed), ("HX-Trigger is merged, not replaced", "a handler that set an event of its own keeps it — dropping it because a toast happened to be queued is a bug that only appears when both are in play"), ("category", category_note), ]) }} {% endcall %} {# ============================================================== 11 #} {% set t11 %}A rejected form used to be a screen of JSON. Now it says what was wrong, under the field, and no route asked for that.{% endset %} {% call lesson("errors", "11", "When a form is rejected", t11, tag="fjkit.errors") %} {% call prose() %}

FastAPI answers a rejected form with {"detail":[{"type":"missing",…}]} and a 422. For an API that is right. For a page it is nothing: htmx does not swap a 4xx, so the person filling the form watches nothing happen, and a plain POST lands them on a screen of JSON.

The fix is to notice what that reply already is. It names every field that failed, under the string the form posted it as, with a message a person can read — and the form is still on the page, with everything the person typed still in its controls. Nothing has to be rendered again, remembered, or rebuilt. So mount_fjkit() sends the 422 back as it is, and one script the shell loads, js/errors.js, writes each message under its field. Every handler keeps the signature FastAPI wrote for it, and no route names a template for its rejected state, because there is nothing to render.

{% endcall %} {% call stage("What each caller gets", "The same question @render asks about the wire, asked about a submit that was refused.") %} {% call table([{"label": "Who asked"}, {"label": "Answer"}, {"label": "Why"}]) %} {{ cell("htmx", tone="strong") }} {{ cell(badge("FastAPI's 422, drawn by the page", variant="info")) }} {{ cell("the form is still there; the script writes each message under the control its loc names, and what was typed stays put") }} {{ cell("a browser navigation", tone="strong") }} {{ cell(badge("422 and a page", variant="info")) }} {{ cell("a plain
has left the page, so errors/page.html is rendered through your own shell") }} {{ cell("a script, curl, a service", tone="strong") }} {{ cell(badge("FastAPI's own reply", variant="outline")) }} {{ cell("— byte for byte, with the status it always had. The same bytes as the first row; only the reader differs") }} {% endcall %} {% endcall %} {% call prose() %}

The last row is the one worth saying out loud. A caller that did not ask for markup keeps {"detail": …}, because turning an API's error into an HTML page would be a worse bug than the one this fixes. The first row is the same bytes, which is the whole design: an endpoint that is also somebody's API has one error contract, and the page reads it the way any other client would.

That asks one thing of a route, and it is what FastAPI already asks:

{% endcall %} {{ files([ {"id": "invalid-route", "label": "router.py", "lang": "python", "source": "@router.post(\"/tasks\", name=\"tasks_create\") @render(\"tasks/_board.html\") def create_task(service: ServiceDep, payload: TaskCreate) -> BoardResponse: service.create(payload) return _board(service)", "note": "The model is the signature. Its fields, its limits and its OpenAPI schema are one declaration, and a rejected one is FastAPI's to report — under body, with the field's name."}, {"id": "invalid-form", "label": "_new_task.html", "lang": "jinja", "source": "{% call form(action=url_for(request, \"tasks_create\"), target=\"#board\", reset_on_success=true, encoding=\"json\", id=\"new-task\") %} {{ text_field(\"title\", label=\"New task\", required=true) }} {{ select_field(\"priority\", label=\"Priority\", options=priority_options, selected=\"normal\") }} {% endcall %}", "note": "One form, written once, with nothing about errors in it. The script finds each control by name and the paragraph under it by aria-describedby — the wiring every field macro already does."}, ]) }} {% call prose() %}

Keeping what was typed costs nothing, because nothing touches it. The old answer to a 422 was to render the form again with the values echoed back, and it worked until a field held more than one value, or the form needed something only the handler that never ran could have built. Both problems disappear when the form is not rendered again.

A message with no field to land on — a whole-model rule, a body that could not be parsed, a bad path segment — is raised as a toast, through the event fjkit.messages uses. The next request from the same form clears what the last one said, so an accepted resubmit leaves no stale rejection on screen.

{% endcall %} {% set model_error %} left to travel, on purpose. It has the same shape as a rejected form and is not one: it is equally what Task(**row) failing three layers down in a service looks like, and drawing a bug as a friendly message hides it behind an apology about a field nobody filled in. Declare the model in the signature and FastAPI raises the request-scoped error that is handled {% endset %} {% set unexpected %} off by default, and that default is the useful one: in development a traceback in the terminal beats a tidy apology in the browser. FjkitConfig.for_production() turns it on, with the other two dev-only knobs. The 422 handler is installed either way, because a rejected form is not a bug {% endset %} {{ defs("The rest of the surface", [ ("a ValidationError your own code raised", model_error), ("catch_unexpected_errors", unexpected), ("unexpected_error", "the words that toast says. Leave it and the kit says Something went wrong; set a Message to change them once, or a callable (request, exc) -> Message to pick them per exception. Choose the words from the exception — never put str(exc) in them, that is the inside of your app"), ("errors/page.html", "the page a failed navigation lands on. Replace it by writing a file at the same path in your own template directory — the same shadowing fjkit eject uses, so there is no separate custom-error-page mechanism to learn"), ("js/errors.js", "the third and last file every page loads, after htmx and Basecoat. It listens to htmx:beforeSwap, so a 422 that is not FastAPI's list — a route answering with markup of its own — is left alone"), ]) }} {% endcall %} {# ============================================================== 12 #} {% set t12 %}Rebranding is a CSS variable. "Green means done" is not on the dial.{% endset %} {% call lesson("knob", "12", "Rebranding: brand and status tokens", t12, tag="theming") %} {% call prose() %}

Drag the hue. You are editing --primary on :root, which is the same three lines an app writes in its own stylesheet. There is no build step in between. The whole page moves, including this sidebar and these cards, because the page is built on the same tokens.

Watch the status badges. They do not move. Status colour is deliberately not wired to the brand, because "green means done" has to survive a rebrand.

{% endcall %} {# `.field > input[type="range"]` is Basecoat's: the slider needs no class of its own, and Basecoat's JS binds the fill track. The same holds for the checkbox in the playground on the Components page. #} {% call card("The three lines you would write") %} {# field_row has no "four" template — two columns, twice. #} {% call field_row("two") %}
Presets
Scheme {% call button_group(id="theme-picker") %} {{ button("Light", variant="ghost", size="sm", data_theme_choice="light") }} {{ button("Dark", variant="ghost", size="sm", data_theme_choice="dark") }} {{ button("Auto", variant="secondary", size="sm", data_theme_choice="auto", aria_pressed="true") }} {% endcall %}
{% endcall %} {% endcall %} {% call stage("The page, repainted") %}
{% endcall %} {% call stage("Your whole rebranding procedure", padded=false) %} {{ code_block("", id="knob-code") }} {% endcall %} {% call grid(cols=2, gap=4) %} {% set d_primary %}brand fill, plus ring for focus{% endset %} {# `fjkit check` scans every line for colour literals, not just class attributes, so prose cannot name the utility it is warning you off. Recorded in docs/BACKLOG.md: the colour rules should skip comments and text nodes. #} {% set d_pfg %}text on a brand fill — never an absolute white{% endset %} {{ defs("On the knob", [ ("primary", d_primary), ("primary-foreground", d_pfg), ("background · card", "page ground and surfaces, tinted toward the brand"), ("muted · border", "quiet surfaces, hairlines, secondary text"), ]) }} {{ defs("Off the knob, on purpose", [ ("success", "done, passing, healthy"), ("warning", "needs attention, not yet broken"), ("info", "in progress, neutral notice"), ("destructive", "delete, fail, irreversible"), ]) }} {% endcall %} {% endcall %} {# ============================================================== 13 #} {% set t13 %}A convention nobody can violate by accident is worth more than one written down.{% endset %} {% call lesson("gate", "13", "fjkit check: the closed vocabulary", t13) %} {% call prose() %}

fjkit check reads your templates and rejects two things: colour literals, and any class outside the component vocabulary. Utilities are rejected even when they work today. An app leaning on a spacing utility because fjkit's shell happens to emit it breaks silently the day the shell stops.

Edit the markup. The checker below runs fjkit's own rules against its real class list — … — and it is the code that runs in CI and over this site's templates.

{% endcall %} {% set samples %} {% call button_group() %} {{ button("the tempting version", variant="ghost", size="xs", data_sample="bad") }} {{ button("the fjkit version", variant="ghost", size="xs", data_sample="good") }} {% endcall %} {% endset %} {% call stage_with("app/templates/tasks/_board.html", samples) %} {% call grid(cols=2, gap=4) %} {# The checker appends one `alert` per violation. Without a gap their borders butt together, and every boundary renders as a doubled line with two rounded corners colliding — so the container is a `stack` rather than a bare div. #} {% call stack(gap=2, id="checker-output", aria_live="polite") %}{% endcall %} {% endcall %} {% endcall %} {% call prose() %}

A blocked class is information: it names the component or the parameter that does not exist yet. When 0.1 landed, the pre-fjkit templates scored 258 violations; the rewrite scores 0.

{% endcall %} {% call grid(cols=2, gap=4) %} {{ defs("Commands", [ ("fastapi dev", "run the app — nothing to rebuild alongside it"), ("fjkit check", "every class is part of the vocabulary"), ("fjkit eject", "the escape hatch: take one macro into your app; its file's other macros stay the kit's"), ("fjkit build-css", "fjkit's own release step, never yours"), ]) }} {% set d_components %}every signature and enumeration, live{% endset %} {{ defs("Read next", [ ("Components", d_components), ("examples/fjkit-demo/", "the same patterns in a working app, in the repo"), ("src/fjkit/static/src/fjkit.css", "every token, defined once — the file a rebrand overrides"), ("docs/jinja-performance.md", "page() against stream(), and the numbers behind the advice"), ]) }} {% endcall %} {% endcall %} {% endcall %} {% endblock %} {% block page_scripts %} {% endblock %}