{# 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 . The header is always pinned to the top of the viewport. A page that needs it in the flow instead — an embedded or print layout — overrides the `header` block with its own markup: {% block header %}
…
{% endblock %} With a sidebar, that override renders inside the content wrapper, after the aside, so it takes the sidebar's margin like the page does. Only a header carrying `data-position="sticky"` renders before the aside and moves the rail down. #} {% 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 %} {# `scroll-pt-16` keeps an anchor target or a focused control from scrolling to a position underneath the pinned bar (WCAG 2.4.11). 4rem clears the bar's fixed `h-14` (3.5rem) with room to spare. #} {% 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. With a sidebar the header leaves the wrapper: the bar spans the whole viewport and the rail hangs underneath it, so the header comes before the aside instead of inside the wrapper after it. The adjacency above still holds, because nothing moves between the aside and the wrapper. `fjkit.css` moves the rail's top edge down by the bar's height when a `data-position="sticky"` header is the aside's previous sibling; that is why the bar has a fixed `h-14` here rather than padding — the offset has to be one number. #} {# `sticky` rather than `position: fixed`. A fixed bar leaves the flow, so `main` would need a top padding equal to the bar's height, and that height changes with the style pack, the brand mark and whatever `header_actions` holds. A sticky one keeps its own space and pins only once the page scrolls past it. The bar paints `bg-background` so content scrolls under it rather than through it, and `border-b` marks where the page ends. Inside the centred wrapper, `-mx-5 px-5` stretches it across the gutter, so content scrolling beside the bar does not show in a 20px strip on either side. `z-30` is one step below everything Basecoat layers over a page: the narrow-screen sidebar backdrop (40), the sidebar panel, dialogs, popovers and toasts (50). A dropdown opened from `header_actions` still covers the page, because the bar's stacking context sits above `main`'s. On a narrow screen the open sidebar is a full-screen overlay, and it covers the bar as well. Captured, like the sidebar slot, because it renders in one of two places and a block can be defined only once. Which place is read off the captured string: Jinja cannot ask whether the `header` block was overridden, and the attribute is the same hook `fjkit.css` uses for the rail offset, so the two cannot disagree. #} {% set header_markup %} {% 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 %} {% endset %} {% set bar_above_rail = has_sidebar and 'data-position="sticky"' in header_markup %} {% if bar_above_rail %}{{ header_markup }}{% endif %} {{ sidebar_slot }} {# Under a top bar the wrapper's minimum is the viewport less the bar, so a short page still puts the footer at the bottom without scrolling 3.5rem. #}
{% if not bar_above_rail %}{{ header_markup }}{% endif %} {# A flex column, not a block, so `ui/layout.html`'s `fill` has a main axis to grow along. A percentage height here would not do it: `main` takes its own height from `flex-1`, and a flex item's main-axis size is indefinite, so a child asking for `min-height: 100%` gets 0 and lands back at the top. For every page that does not use `fill` this is the same layout it was. A single child stretches to the full width exactly as a block child did, and keeps its own height because nothing tells it to grow. The one real difference is that adjacent children no longer collapse their margins, which is why the kit spaces bands with `stack` and a gap rather than with margins. #}
{% 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 §4.2 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 %}