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