{# The document shell. Every page extends this, directly or through the app's own base.html. What it owns: the , the theme flash-guard, the asset links, and the header/main/footer skeleton. What it does not own: your brand, your nav, your footer text — those are blocks. Typical app base.html: {% extends "ui/shell.html" %} {% from "ui/nav.html" import brand, nav_links %} {% block site_title %}Acme{% endblock %} {% block brand %}{{ brand("Acme", url_for(request, "home"), "gauge") }}{% endblock %} {% block nav %}{{ nav_links(request, [("home", "Overview")]) }}{% endblock %} Fill the `sidebar` block instead of `nav` and the skeleton becomes a side column plus a thin top bar; see the note above . #} {% from "ui/feedback.html" import toast, toaster %} {% from "ui/nav.html" import theme_toggle %} {% from "ui/sidebar.html" import sidebar_trigger %} {# Rendered into a variable rather than straight into the page, because three things downstream depend on whether the app filled it in: the aside has to come first in the body, the content wrapper beside it changes shape, and the header grows a toggle. Jinja cannot ask whether a block is empty, so the block is captured once here and the emptiness test runs on the string. The cost is buffering the sidebar's markup — a few hundred bytes — before the response starts. The alternative is a second flag the app has to remember to set, and that flag gets forgotten. #} {% set sidebar_slot %}{% block sidebar %}{% endblock %}{% endset %} {% set has_sidebar = sidebar_slot.strip() | length > 0 -%} {% block title %}{% endblock %}{% block site_title_separator %} · {% endblock %}{% block site_title %}{% endblock %} {# Theme is applied before first paint so a dark-mode reload never flashes white. Basecoat's dark variant keys off `html.dark`, which is the only thing this script touches. #} {# One stylesheet, named after the style pack `FjkitConfig.style` selected. All eight ship built in the wheel; exactly one is ever requested. #} {# Where an app overrides tokens. One file, plain CSS, no build — see the brand section of the docs. Empty by default. #} {% block stylesheets %}{% endblock %} {# htmx's default handling: 2xx/3xx swap, 4xx/5xx do not. A 422 is read by `js/errors.js` in `htmx:beforeSwap` instead; 204 is what `fjkit.errors` answers a swap with when there is only a toast to raise. #} {# Draws a 422 under the fields of the form that sent it. After htmx: it listens to htmx's events. #} {% block head %}{% endblock %} {# With a sidebar, the aside is the first thing in the body and the wrapper is its immediate next sibling. That sibling is the element Basecoat's CSS gives the margin keeping the page clear of the fixed panel, and the margin slides back to zero when the sidebar collapses. Anything placed between the two loses the margin silently, so the shell owns this adjacency rather than documenting it. That layout also drops `mx-auto max-w-6xl`: `mx-auto` is a utility, Basecoat's margin comes through `@apply` in the components layer, and utilities win — a centred wrapper would park itself underneath the sidebar. #} {{ sidebar_slot }}
{% block header %}
{% if has_sidebar %}{% block sidebar_trigger %}{{ sidebar_trigger() }}{% endblock %}{% endif %} {% block brand %}{% endblock %} {% block nav %}{% endblock %}
{% block header_actions %}{{ theme_toggle() }}{% endblock %}
{% endblock %}
{% block content %}{% endblock %}
{% block footer_wrapper %} {% endblock %}
{# Where a message arrives — one raised on this response, or one that outlived a redirect. `fjkit_messages()` is a global rather than a context key, so a route passes nothing in and a page that has never heard of messages still shows one. `FlashPlugin` queues its cookie into the same place, which is why there is one loop here and not two. Iterating marks them delivered — see `fjkit.messages._Queue` — so this `{% for %}` is load-bearing: it is what lets the flash cookie be cleared, and an `{% if %}` deliberately does not count. The region renders whether or not anything is in it. It is one empty element, and keeping it always present lets a toast appear on a page that did not know in advance one was coming. #} {% block toasts %} {% call toaster() %} {% for message in fjkit_messages() %} {{ toast(message.title, message.text, category=message.category) }} {% endfor %} {% endcall %} {% endblock %} {# A message raised on a response that only swapped a fragment has nowhere to render: the toaster is on the page, not in the fragment. `fjkit.messages` sends those as `HX-Trigger`, and this turns the event back into a toast. Two reasons it is a listener rather than markup. htmx reads `HX-Trigger` in `handleAjaxResponse` before it consults `responseHandling`, so this path still works on a 500 — the response that most needs to say something. And Basecoat exposes its toaster as a method on the element (`#toaster.toast({…})`) rather than as a document-level event listener, so something has to make the call. `detail.messages`, because `fjkit.messages` sends an object. htmx hands a JSON object straight through as `event.detail` and wraps anything else, arrays included, as `{value: …}` — a difference invisible in a test and silent in a browser, where it shows up as an empty toast. ~180 bytes. CHARTER §7 budgets client JS per page; this listener and `js/errors.js` in the head are what fjkit adds beyond the two vendored files. `errors.js` dispatches this same event for a message with no field to land on, so there is one path into the toaster and not two. #} {% block scripts %}{% endblock %}