{% 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 %} {% from "ui/table.html" import resize_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, grouped by the file you import it from. Change a knob and both the preview and the Jinja call update. Two rules hold on every section: parameters are closed enumerations, with no class= and no colour to pass, and any keyword a macro does not name becomes an HTML attribute, so hx_post="/tasks" renders as hx-post="/tasks". {% 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() %}

In button, 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. An unknown keyword is passed through as an attribute, not rejected, which is how every hx-* reaches the element.

button_group renders differently by orientation. Horizontal is a row with a gap. Vertical is Basecoat's .button-group: the buttons join into one control with shared borders and no gap.

{% 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 four macros with knobs come first; the rest follow with one rendered example each.

table renders empty_state itself when rows is empty, so a caller writes no if-rows-else branch.

avatar uses name as both the alt text and the fallback initials. When an image carries the name, the initials are hidden from screen readers, so the person is announced once.

{% 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, with no fjkit JavaScript.{% 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.

Screen readers do not read CSS content(), so a tooltip cannot be a control's only label. 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 components for "wait", "done" and "that did not work".{% endset %} {% call lesson("feedback", "04", "Feedback", c05) %} {% call prose() %}

alert is a fact that stays on the page. spinner and skeleton both mean "not yet": use a skeleton when you know the shape of what is coming, a spinner when you do not. dialog interrupts. toast outlives the response that raised it.

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, not one per line.

{% 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.

range_field sets the filled part of the track on the server. Basecoat updates it only while dragging, so without this the first paint shows the default position, not the value.

{% 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. A sample is below; 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 %}Spacing and columns are macros, so a page needs no utility classes.{% endset %} {% call lesson("layout", "07", "Layout", c08) %} {% call prose() %}

A utility class such as grid gap-4 in your template is absent from the shipped stylesheet unless the kit happens to use it. stack, row and grid take spacing and column counts as parameters, and every value maps to a class the stylesheet contains.

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

page_header takes a block, and the block is its actions: the title, the description and the buttons stay on one line at every width. The header at the top of this page is the same macro with no actions.

centered and fill are one axis each. centered caps a width; fill takes the height the shell already gave main and places the children in it, which is how a sign-in card sits in the middle of the screen. For both, nest them with fill outside. fill has no preview below, because a card has no fixed height to fill.

{% 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 pieces of the shell's header, importable on their own. 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. A None href marks the current page, which renders as text rather than a link to itself.

{% 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 %}Pass one id; the macro derives the ids that connect a trigger to its panel.{% 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.

A trigger and its panel find each other through four related ids. A wrong one fails silently: the panel opens but is never announced, or never opens. Every macro here takes one id and derives the other three.

select_menu and combobox put their value in a hidden input, pre-filled on the server, so a swap that re-renders the fragment keeps the selection. Prefer select_field when it is enough: a native <select> is one element and needs no script.

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(), loaded by the page that has such a field, re-emits it on submit as a repeated field: labels=bug&labels=ui. The route declares labels: list[str] = Form([]). Without the script the field posts one JSON string, and the route answers 422.

A multiple combobox is laid out for a narrow column: the filter box takes the first line on its own, the chips wrap below it, the control fills the column and the list is anchored to the box rather than to the bottom of the chips, so picking several options in a row never moves the option under the pointer. Long labels wrap in the list and truncate in a chip.

Up to about a dozen options, a checkbox group inside a fieldset posts the same thing and needs no script.

{% 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 document template your base.html extends. This page is built on it.{% 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.

Filling the sidebar block changes the layout to a side column plus a top bar, and the shell adds the sidebar_trigger to the header. No flag is needed.

The header stays pinned to the top of the viewport while the page scrolls. It is position: sticky, not fixed, so the bar keeps its own space and nothing in main has to be padded to clear it. With a sidebar, the bar spans the whole width and the rail starts underneath it. A page that needs the header in the flow instead, such as an embedded or print layout, overrides the header block with its own <header>.

{% endcall %} {{ snippet("shell", "your base.html, in full") }} {{ snippet("shell_blocks", "every block the shell exposes, in source order") }} {% endcall %} {# ============================================================== 11 #} {% set c12 %}Navigation for more destinations than a header row holds, about six.{% endset %} {% call lesson("sidebar", "11", "Sidebar", c12) %} {% call prose() %}

The rail on the left of this page is this macro. It is position: fixed, so it has no preview card; the call that produced it is printed below.

Links take route names and icons take Lucide names. open sets the starting state on a wide screen only. On a narrow screen the sidebar is a full-screen overlay and always starts closed.

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. It takes rows without iterating them for that reason: the caller writes no if-rows-else branch.

Render each row with a macro call inside your loop. {% raw %}{% include %}{% endraw %} in a loop does a template lookup and builds a new context per row, and is 20–30% slower (jinja-performance.md).

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, so sorting and paging work without JavaScript and the URL can be bookmarked.

{% 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.

resizable=true puts a handle on the trailing edge of each labelled column. A visitor drags it, or focuses it and presses the arrow keys; Home or a double-click gives the column back its natural width. The widths are kept in localStorage under the table's id, which is why a resizable table must have one, and they come back after a reload and after an htmx swap. The page loads resize_scripts(); without it the handles stay hidden and the table is the ordinary one. CSS cannot do this: resize works only on a block box with overflow, and a table cell is not one.

page_size is a <form method="get">, because a bare <select> cannot submit without a script. Its url must carry no query string: a GET submit replaces the whole query with the form's fields, so pass the filters in keep. Do not keep the page number; a new page size invalidates it.

{% 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.

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 code tabs under every playground on this page use this macro.

For a tab group inside another tab's panel, such as a mode switch inside a page section, use variant="segmented" on the inner one. It renders as a pill strip, so the two levels do not look alike. variant here is shape, not a colour role as it is on button or badge; the values are underline, the default, and segmented, and anything else falls back to the default.

{% endcall %} {{ showcase("tabs", "tabs(items, label, selected) / tab_panel(id) — both take a block") }} {{ showcase("tabs_segmented", "tabs(items, label, variant=\"segmented\") — nested inside a tab_panel") }} {% 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), ]) }} {% set d_demo %}these macros inside a working app{% endset %} {% set d_ui %}the macro source — each one opens with its signature comment{% endset %} {% set d_tokens %}the token table behind every variant{% endset %} {{ defs("The source", [ ("examples/fjkit-demo/", d_demo), ("src/fjkit/templates/ui/", d_ui), ("src/fjkit/static/src/fjkit.css", d_tokens), ]) }} {% 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. The resizable table is the same. #} {{ reveal_scripts() }} {{ resize_scripts() }} {% endblock %}