{% 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.
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.
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.
{{ 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.
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.
Build status, refreshed by the server.
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.
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. #}_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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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"}]) %}
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.
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.
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.
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.
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") %}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 %}