{% extends "base.html" %} {% from "_parts.html" import band, defs, lesson, prose, stage %} {% from "ui/data.html" import card, code_block %} {% from "ui/tabs.html" import tab_panel, tabs %} {% from "ui/layout.html" import grid, page_header, stack %} {% from "ui/form.html" import reveal_scripts %} {# One section per file in `src/fjkit/templates/ui/`, in the order `ls` prints them. The directory is the table of contents: a reader holding a macro finds its section by the file it lives in, and a macro added to the kit has exactly one place to be documented. `attrs.html` is the one file with no section. It is the plumbing every other macro forwards its leftover keywords through, an app never calls it, and section 01 states what it does, where the first `hx_*` keyword shows up. The Cheatsheet page still lists it, because that index is by file rather than by section. #} {% set sections = [ {"id": "button", "num": "01", "title": "Button"}, {"id": "data", "num": "02", "title": "Data"}, {"id": "disclosure", "num": "03", "title": "Disclosure"}, {"id": "feedback", "num": "04", "title": "Feedback"}, {"id": "form", "num": "05", "title": "Form"}, {"id": "icon", "num": "06", "title": "Icon"}, {"id": "layout", "num": "07", "title": "Layout"}, {"id": "nav", "num": "08", "title": "Nav"}, {"id": "overlay", "num": "09", "title": "Overlay"}, {"id": "shell", "num": "10", "title": "Shell"}, {"id": "sidebar", "num": "11", "title": "Sidebar"}, {"id": "table", "num": "12", "title": "Table"}, {"id": "tabs", "num": "13", "title": "Tabs"}, {"id": "next", "title": "Read next"}, ] %} {# The strings the two shared skeletons below need. Set once, read inside the macros: a page in the other language changes these five and nothing else. #} {% set t_preview = "Preview" %} {% set t_write = "what you write" %} {% set t_emit = "what it emits" %} {% set t_jinja = "the Jinja call" %} {% set t_html = "the HTML it emits" %} {# One playground. `slug` names the section, and `components.js` finds its four panes by that name: a tab strip when the file has more than one macro to offer, a bare control row when it has one, and below them the preview, the two representations and the caption. The script fills the tablist and its panels, because the list of macros lives there. A hardcoded list here would be a second source of truth that silently loses a tab the day one is added. So this is the `.tabs` skeleton only, and the script writes the aria contract Basecoat's tab behaviour reads. #} {% macro playground(slug, tabbed=true) %} {% call card(padded=false) %} {% if tabbed %}
{% else %}
{% endif %} {% endcall %} {% call stage(t_preview) %}
{% endcall %} {% call card(padded=false) %} {% call tabs([{"id": slug ~ "-jinja", "label": t_write}, {"id": slug ~ "-html", "label": t_emit}], label="Representation") %} {% call tab_panel(slug ~ "-jinja") %}{{ code_block("", label=t_jinja) }}{% endcall %} {% call tab_panel(slug ~ "-html") %}{{ code_block("", label=t_html, wrap=true) }}{% endcall %} {% endcall %} {% endcall %} {% endmacro %} {# One showcase is one macro with nothing to configure. The preview and the code are both filled from `SHOWCASE` in components.js, which reads the same data.json every other preview on the site comes from, so adding one here never means pasting markup. #} {% macro showcase(key, name) %} {# Preview and source are two cards: a preview needs the card's padding around it, and a code block needs the card's edge. One card cannot give both, and a padded=false card hands the preview to the border. #} {% call stage(name) %}
{% endcall %} {% call card(padded=false) %} {{ code_block("", data_showcase_code=key) }} {% endcall %} {% endmacro %} {# A call with no preview beside it: the two files whose live instance is this page rather than something that fits in a card. Same source as everything else, `SNIPPET` in components.js. #} {% macro snippet(key, name) %} {% call card(name, padded=false) %} {{ code_block("", data_snippet_code=key) }} {% endcall %} {% endmacro %} {% block content %} {% call stack(8) %} {% set intro %} Every macro, with a live preview and the call that produced it, grouped by the file it lives in and in the order src/fjkit/templates/ui/ lists them. Turn the knobs and two things change together: the component, and the Jinja you would have typed to get it. Every preview was rendered by fjkit's own Environment at build time. {% endset %} {{ page_header("Every macro, and the call that made it", intro) }} {# ============================================================== 01 #} {% set c02 %}One element, seven variants, seven sizes — and no class at the call site.{% endset %} {% call lesson("button", "01", "Button", c02) %} {% call prose() %}

Every parameter on this page is a closed enumeration. There is no class= escape hatch and no hue to pass: you pick a variant, and the token layer decides what it looks like. Anything a macro does not name becomes an HTML attribute, so hx_post="/tasks" renders as hx-post="/tasks". That conversion is one file, ui/attrs.html, which every macro forwards its leftover keywords to and no app ever calls. It is why htmx appears in no macro definition in the kit, and why a keyword a macro has never heard of is an attribute rather than an error.

button is the macro every other file reaches for, and the one whose signature is worth learning first: variant and size are closed lookups, href switches the element to an <a> and drops type, and icon_name takes any Lucide name so a caller never imports icon to put a glyph on a button.

button_group is two components under one name, and the knob tells you which: horizontal is a row, so the gap is yours; vertical is Basecoat's .button-group, which joins the buttons into a single control with shared borders and has no gap to give.

{% endcall %} {{ playground("button") }} {% endcall %} {# ============================================================== 02 #} {% set c03 %}The file that displays a value — a count, a state, a person, a definition.{% endset %} {% call lesson("data", "02", "Data", c03) %} {% call prose() %}

The largest file in ui/, and the one an ordinary page spends most of its calls on. The four with knobs are below; the rest follow, one rendered example each.

empty_state is worth knowing before you need it. table renders it for you when rows is empty, so no caller writes the if-rows-else branch.

avatar is the one whose signature to read. The name is both the alt text and the fallback initials, so the two cannot disagree, and the initials leave the accessibility tree the moment an image carries the name. Otherwise the same person is announced twice, once properly and once as two stray letters.

{% endcall %} {{ playground("data") }} {{ showcase("empty_state", "empty_state(title, description, icon_name)") }} {{ showcase("metric_group", "metric_group(items, cols=3)") }} {{ showcase("bullet_list", "bullet_list(tone) / list_item() — both take a block") }} {{ showcase("caption", "caption(text) — also takes a block, for a caption holding a link") }} {{ showcase("inline", "link(label, href) and kbd(keys)") }} {{ showcase("item_list", "item_list() takes a block of item(title, description, clamp) calls") }} {{ showcase("description_list", "description_list(layout) takes a block of description_item(term, value) calls") }} {{ showcase("code_block", "code_block(source, label, wrap)") }} {{ showcase("avatar", "avatar(name, src, size, badge_tone) and avatar_group(overflow)") }} {% endcall %} {# ============================================================== 03 #} {% set c04 %}Two disclosures, and not one line of fjkit JavaScript between them.{% endset %} {% call lesson("disclosure", "03", "Disclosure", c04) %} {% call prose() %}

collapsible is a native <details>: the open state, the keyboard operation and the disclosure role all come from the element, and Basecoat animates it with CSS. tooltip is drawn entirely from a data-tooltip attribute — no second element exists.

The tooltip's mechanism has one consequence: CSS content() is never in the accessibility tree. A tooltip is a hint, never the only label a control has. Give the control an aria_label when the tooltip is the only text on it.

{% endcall %} {{ showcase("collapsible", "accordion(multiple) / collapsible(summary, open) — both take a block") }} {{ showcase("tooltip", "tooltip(text, side, align) — the block is the element it wraps") }} {% endcall %} {# ============================================================== 04 #} {% set c05 %}Five ways of saying "wait", "done" and "that did not work".{% endset %} {% call lesson("feedback", "04", "Feedback", c05) %} {% call prose() %}

These differ in who is waiting and for what, which is how you pick one. alert is a fact that stays on the page. spinner and skeleton both mean "not yet", and the choice between them is whether you know the shape of what is coming: a skeleton draws that shape, a spinner admits you cannot. dialog interrupts. toast outlives the response that raised it.

Two of them make an accessibility decision for you. alert(variant="destructive") takes role="alert", which interrupts a screen reader mid-sentence; every other variant is role="status", which waits for a pause. A multi-line skeleton is one live region rather than six, because six bars are one thing loading.

{% endcall %} {{ showcase("alert", "alert(title, body, variant, icon_name) — the block is the actions") }} {{ showcase("spinner", "spinner(size, tone, label, indicator)") }} {{ showcase("skeleton", "skeleton(shape, width, lines)") }} {{ showcase("dialog", "dialog(id, title, description, size, footer) — the block is the body") }} {{ snippet("toaster", "toaster(align) with toast(title, description, category)") }} {% endcall %} {# ============================================================== 05 #} {% set c06 %}Every field takes the same five parameters, and error replaces the hint.{% endset %} {% call lesson("form", "05", "Form", c06) %} {% call prose() %}

Every field macro takes the same name, label, hint, error and id, and error replaces the hint rather than stacking on it while marking the control aria-invalid. The playground walks five shapes of the same form: the htmx one, a field in error, the long controls, the radios-versus-select choice, and switches inside a fieldset.

target= is what makes a form an htmx form; drop it and the same macro emits an ordinary POST that works without JavaScript.

The two below are the fields that correct their own mechanism. range_field writes the filled part of the track for the first paint: Basecoat paints it from a custom property and updates it from JavaScript only on drag, so without that the bar sits at upstream's default and disagrees with the number beside it.

{% endcall %} {{ playground("form", tabbed=false) }} {{ showcase("range_field", "range_field(name, label, value, min, max, step, output)") }} {{ showcase("input_group_field", "input_group_field(name, label, start, end)") }} {{ showcase("reveal", "input_group_field(name, revealable=true) — needs reveal_scripts()") }} {{ showcase("file_field", "file_field(name, label, accept, multiple), and the bar form(progress=) moves") }} {% endcall %} {# ============================================================== 06 #} {% set c07 %}1,767 names, no sprite sheet, no icon font, no request.{% endset %} {% call lesson("icon", "06", "Icon", c07) %} {% call prose() %}

icon("plus") inlines the Lucide path as an <svg>. It inherits currentColor, so an icon inside a primary button is already the right colour, and it costs no network request at any size.

Most macros take icon_name= directly — button, stat, empty_state, brand, sidebar_link — so you rarely call this one yourself. The links in this site's sidebar are all of them. A sample of the set is below, and any Lucide name works.

{% endcall %} {% call stage("icon(name, size=16)") %}
{% endcall %} {% call card(padded=false) %} {{ code_block("", id="icon-code") }} {% endcall %} {% endcall %} {# ============================================================== 07 #} {% set c08 %}The moment you type grid gap-4, the no-build promise is over.{% endset %} {% call lesson("layout", "07", "Layout", c08) %} {% call prose() %}

Components alone do not get you to a closed vocabulary. The first time you need two cards side by side you reach for a utility class, and the stylesheet fjkit shipped has no idea you wanted it. So spacing and columns are macros too. Every value is a lookup whose entries appear verbatim in the kit's source, where Tailwind can see them.

The dashed tiles are your content. Change the knobs and the source line changes with them.

The other three in this file are what a page is made of before any of its data arrives. page_header takes a block, and the block is its actions: a title, a description and a row of buttons stay on one line at every width, with no caller measuring anything. The header at the top of this page is the same macro with no actions to place.

{% endcall %} {% call card(padded=false) %}
{% endcall %} {% call stage(t_preview) %}
{% endcall %} {% call card(padded=false) %} {{ code_block("", id="layout-code") }} {% endcall %} {{ showcase("centered", "centered(width=\"sm\", gap=6) — the block is the children") }} {{ showcase("page_header", "page_header(title, description) — the block is the actions") }} {{ showcase("section", "section(title, description, gap=4) — the block is the children") }} {{ showcase("divider", "divider()") }} {% endcall %} {# ============================================================== 08 #} {% set c09 %}The header's three pieces, and the trail that says where you are.{% endset %} {% call lesson("nav", "08", "Nav", c09) %} {% call prose() %}

These are the shell's header, split out so an app can import them without importing the template it extends. Two of the three are already on this page: the mark at the top of the rail is brand, and the switch in the header is theme_toggle.

nav_links takes route names, not URLs, so the active state comes from comparing routes rather than string-matching paths, and stays correct when a URL gains a query string. Past about six destinations a header row runs out of width; fill the shell's sidebar block instead.

breadcrumb takes (label, href) pairs, where a null href marks the current page. That is deliberate: the usual way this component goes wrong is a trail whose last entry links to the page you are already on, and the shape of the argument makes that unspellable.

{% endcall %} {{ showcase("brand", "brand(label, href, icon_name | icon_src)") }} {{ showcase("nav_links", "nav_links(request, links) — links is (route, label) pairs") }} {{ showcase("theme_toggle", "theme_toggle() — live: it toggles this page") }} {{ showcase("breadcrumb", "breadcrumb(trail, separator) — href=none marks the current page") }} {% endcall %} {# ============================================================== 09 #} {% set c10 %}One id in, four out — and a mismatch fails without a word.{% endset %} {% call lesson("overlay", "09", "Overlay", c10) %} {% call prose() %}

Everything in ui/overlay.html is driven by Basecoat's own JavaScript, which the shell already loads and which re-scans on htmx:afterSwap, so an overlay that arrives in a swap works with nothing extra.

What they share is id wiring. A trigger and its panel find each other through four related ids, and getting one wrong fails silently: the panel opens but is never announced, or never opens at all. Every macro here takes one id and derives the rest, which is why they are macros rather than markup to copy.

select_menu and combobox put their value in a hidden input, pre-filled. Upstream ships value="" and writes it from JS, which loses the selection on any round trip that re-renders the fragment. Prefer select_field to either: a native <select> is correct for free and costs one element instead of thirty.

Both take multiple=true, and both then want a list in selected where they took a scalar before. The hidden input carries Basecoat's JSON array, and multiselect_scripts() — one script, opted into by the page that has such a field — re-emits it on submit as the repeated field HTML has always used: labels=bug&labels=ui. The route stays ordinary at labels: list[str] = Form([]), with no parsing anywhere. Forget the script and the field posts one JSON-shaped string instead, which is a 422 on a page that looks correctly filled in.

A checkbox group inside a fieldset posts the same thing and needs no script at all. It is the better answer up to about a dozen options, which is where these two start.

{% endcall %} {{ showcase("popover", "popover(id, label, side, align, width) — the block is the body") }} {{ showcase("dropdown_menu", "dropdown_menu(id, label) with menu_item / menu_group / menu_separator") }} {{ showcase("select_menu", "select_menu(name, options, selected)") }} {{ showcase("combobox", "combobox(name, options, selected, empty)") }} {{ showcase("select_menu_multiple", "select_menu(name, options, selected, multiple) — needs multiselect_scripts()") }} {{ showcase("combobox_multiple", "combobox(name, options, selected, multiple) — chips are drawn by JS") }} {{ showcase("select_menu_field", "visible_label / hint / error — the same two controls rendered as fields") }} {{ showcase("drawer", "drawer(id, title, side) with drawer_trigger(label, target)") }} {{ showcase("command", "command(id, dialog) with command_group / command_item") }} {% endcall %} {# ============================================================== 10 #} {% set c11 %}The one file you extend instead of importing — and the page you are reading.{% endset %} {% call lesson("shell", "10", "Shell", c11) %} {% call prose() %}

shell.html has no macros and no preview, because the preview is this page. It owns the <head>, the theme flash-guard, the asset links, the toaster and the header/main/footer skeleton. What it does not own — your brand, your navigation, your footer text — are blocks.

One of them changes the shape of the page rather than its contents. Fill sidebar and the skeleton becomes a side column plus a thin top bar; the shell tests that block for emptiness itself and adds the sidebar_trigger to the header, so an app never sets a flag to say which layout it wanted.

{% endcall %} {{ snippet("shell", "your base.html, in full") }} {{ snippet("shell_blocks", "every block the shell exposes, in source order") }} {% endcall %} {# ============================================================== 11 #} {% set c12 %}A back office outgrows a header bar at about six destinations.{% endset %} {% call lesson("sidebar", "11", "Sidebar", c12) %} {% call prose() %}

The rail on the left of this page is this file. There is no preview in a card below: the panel is position: fixed, so a copy of it here would cover the page rather than sit in it. The running instance is on the left, and the call that produced it is printed below.

Route names again, an icon by name, closed enumerations, and no class string anywhere in the signature. open is the desktop starting state only: on a narrow screen the sidebar always starts closed, because it renders as a full-screen overlay there and a page that opens covered by its own navigation is a bug rather than a preference.

With JavaScript off, the server-rendered aria-hidden="false" leaves a wide screen fully navigable and hides the overlay on a narrow one. The links are in the HTML either way, so a crawler and reader mode still see them.

{% endcall %} {{ snippet("sidebar", "the sidebar block, as this site fills it") }} {% endcall %} {# ============================================================== 12 #} {% set c13 %}A column spec, and the two controls a list needs around it.{% endset %} {% call lesson("table", "12", "Table", c13) %} {% call prose() %}

columns is a list of dicts — label, and optionally align, width, sort, sort_url or select — and the macro tests rows to decide whether to render your body or the empty state. That is why it takes rows it does not iterate: no caller repeats the if-rows-else branch, and no page can ship a table that renders as a lone header row.

A repeated row is a macro call inside your loop. {% raw %}{% include %}{% endraw %} there is a template lookup plus a fresh context per iteration, which is the difference the benchmark on the Learn page measures.

A column carrying a sort_url becomes a sortable header, and pagination goes under the table. Neither builds a URL: the router owns the query string, because it is the only thing that knows which filters are on. Both render real hrefs and take target= to add the htmx swap on top — an order and a page number are the two things about a list that people bookmark, so both have to survive JavaScript being off.

{% raw %}{"select": true}{% endraw %} puts a batch-selection column in front of the rest. It posts as the repeated field a checkbox column has always sent, and select_scripts() is what ticks the whole column from the header box.

page_size is a <form method="get">, and that is the design rather than an implementation detail: a <select> on its own has no way to act when its value changes, and the kit does not script one. Its url must carry no query string, because a native GET submit replaces the whole query with the form's own fields — so the filters travel in keep as hidden fields. The page number never does. It names a position, and changing the size moves every position.

{% endcall %} {{ playground("table") }} {% endcall %} {# ============================================================== 13 #} {% set c14 %}Two ids, passed once, and the keyboard is Basecoat's.{% endset %} {% call lesson("tabs", "13", "Tabs", c14) %} {% call prose() %}

Basecoat owns which tab is selected, the roving tabindex and the arrow keys, and the shell already loads it. This macro carries the aria contract that behaviour reads: a [role="tab"] whose aria-controls names a [role="tabpanel"] with that id. Getting that pairing right by hand is what a component should stop you having to think about.

selected names the tab that starts open. The server knows which one the request was for, so it is a parameter rather than something a script decides after paint. The two representations under every playground on this page are one of these.

{% endcall %} {{ showcase("tabs", "tabs(items, label, selected) / tab_panel(id) — both take a block") }} {% endcall %} {# ============================================================== next #} {% set lead_next %}Fourteen files, and the index that lists all of them on one page.{% endset %} {% call band("next", "Read next", lead_next) %} {% call grid(cols=2, gap=4) %} {% set d_sheet %}every macro in one table per file, with the block it takes and the htmx attributes that go with it{% endset %} {% set d_learn %}how the pieces fit, and what htmx sends{% endset %} {{ defs("The rest of the documentation", [ ("Cheatsheet", d_sheet), ("Learn", d_learn), ]) }} {{ defs("The source", [ ("examples/fjkit-demo/", "these macros inside a working app, in the repo"), ("src/fjkit/templates/ui/", "the macro source — each one opens with its signature comment"), ("src/fjkit/static/src/fjkit.css", "the token table behind every variant"), ]) }} {% endcall %} {% endcall %} {% endcall %} {% endblock %} {% block page_scripts %} {# The reveal showcase is a working control, so the page opts into its script the same way an app's sign-in page does. #} {{ reveal_scripts() }} {% endblock %}