//! The document head, emitted by quasi's renderer rather than by `base.html`.
//!
//! Wiki note `mnw-server-conversion-plan`, step S1. The dashboard is being
//! converted to described screens one at a time, and a converted screen renders
//! through [`quasi_webview::Shell`] while everything around it still renders
//! through Askama. Two heads written by two hands drift, and the drift is
//! silent: a layer statement that moves below a stylesheet, an htmx config that
//! is stated below the script that reads it, a viewport that says one thing on
//! half the site. So the head is the renderer's on both paths from here,
//! before any screen is described.
//!
//! [`Shell::parts`] is what a host whose templating writes into the head takes:
//! Askama renders the per-page `{% block title %}` and `{% block head %}` in
//! place, inside `base.html`, and no caller ever holds them as a string.
//! `base.html` writes the title, ``, the `
` tag and the close;
//! everything above is here.
//!
//! One shell for the process, not one per request. Nothing in it varies by
//! viewer: the creator's theme block is per-request and unlayered on purpose,
//! and it stays where it is, injected by the three templates that show a
//! creator's work through `{% block head %}` so it lands after every sheet and
//! outranks every named layer.
use std::sync::OnceLock;
use quasi_webview::Shell;
/// The cache-busting suffix on the site's own assets.
///
/// A content hash of every watched static file, computed in `build.rs` and
/// handed over as an env var. It used to reach the head through a generated
/// `_head_assets.html`; the head is the renderer's now, so the version is all
/// that crosses. `_sheet.html` and `_island.html` are still generated with the
/// same hash, for the per-page sheets and islands this module does not see.
const V: &str = env!("STATIC_VERSION");
/// The vendored htmx release, and the cache-busting suffix on its extensions.
///
/// Not the content hash above: an extension is a pinned file that changes only
/// when htmx is bumped, so naming the release is both the version and the
/// record of which one is on disk. `static/htmx.min.js` itself is under the
/// content hash, because `build.rs` already watches it.
const HTMX: &str = "4.0.0-beta6";
fn parts() -> &'static quasi_webview::Parts {
static PARTS: OnceLock = OnceLock::new();
PARTS.get_or_init(|| {
Shell::under("/static")
// Earlier in the list = lower priority, and `makeover` is prepended
// by the renderer. A layer's position is fixed where its name is
// FIRST seen, so without the statement the generated sheets would
// establish `makeover` simply by loading first and reordering two
// links would silently reorder the cascade. `components` is where
// the site's own sheets live, including the per-page wizard.css and
// media-player.css that arrive later through `{% block head %}` and
// that nothing here can order. `base` and `responsive` are still
// empty; declaring an empty layer costs nothing and fixes its
// position.
.layered(["base", "components", "responsive"])
// Whole value is being early: a preload discovered after the sheets
// it races bought nothing, and htmx reads its config once, when the
// script runs, so the meta has to be above it.
//
// `noSwap` restores htmx 2's rule that a 4xx or 5xx response does
// not swap. htmx 4 swaps everything but 204 and 304, and what this
// server answers a failed fragment request with is a whole rendered
// error page (`error.rs`), so the default would paint that page
// inside whatever the request targeted. The error toast in
// `htmx-glue.ts` reads the `HX-Error` header on the same response
// and is what a user sees instead, unchanged from 2.x.
//
// The cost, worth knowing before turning a described screen on: a
// blanket `noSwap` is checked before `hx-status:4xx`, so an element
// cannot opt back in. Decision 9's classified errors (403 `Denied`,
// 404 `NotFound`) therefore still render nothing here, which is the
// gap `quasi-overview` expected htmx 4 to close for free.
.with_head_first(
"\
\
",
)
// First, and before the sheets that use the tokens it defines. The
// `@font-face` rules take no part in the cascade so their position
// buys nothing there; what it buys is discovery, since a face the
// parser has not reached yet is a face the browser has not started
// fetching. `style.css` still wins every contest it won before:
// this file defines two tokens and matches no element.
.styled(format!("/static/typography.css?v={V}"))
.styled(format!("/static/geometry.css?v={V}"))
.styled(format!("/static/layout.css?v={V}"))
.styled(format!("/static/style.css?v={V}"))
// Last in the head, after htmx: the favicon has no order to keep,
// and neither of the last two scripts reads htmx at load.
// `upload.js` only defines `S3Uploader`, and the core module is
// deferred by being a module, so it still runs after the deferred
// htmx above it.
//
// `hx-history-cache` is the exception and is why it carries
// `defer`: it calls `htmx.registerExtension` as it loads, so an
// ordinary script would run during parsing and reach for an htmx
// that has not executed yet. Deferred scripts run in document
// order, and htmx's own init waits a tick past that.
//
// What it restores is htmx 2's history cache, which htmx 4 dropped:
// without it every Back is a fresh request for the pushed URL, and
// the URLs this site pushes are the wizard's step routes, which
// answer a GET with a bare partial rather than a page. So a Back
// out of a wizard step painted a chromeless fragment over the
// document. The extension is first-party, keyed on sessionStorage
// rather than localStorage, and defaults to the same 10 entries
// htmx 2 kept.
.with_head(format!(
"\
\
\
"
))
.parts()
})
}
/// `` through the head's contents, without ``.
///
/// Called from `base.html`, which appends the title and the per-page head and
/// closes the element.
pub fn head() -> &'static str {
&parts().head
}
/// The attributes the shell owns on ``, each one space-prefixed.
///
/// `base.html` writes ``, so a
/// page's own class attribute composes with these instead of replacing them.
pub fn body_attrs() -> &'static str {
&parts().body_attrs
}
pub use makeover_layout::Measure;
/// The body class for a screen's measure.
///
/// `0eccff0d`. 69 of 72 templates carried one of these three strings as a
/// literal, which made how wide a page runs a fact about the template rather
/// than about the screen. The strings are unchanged and the rules in
/// `style.css` are untouched: what moved is where the choice is written down,
/// from a class name in markup to a described property with a name in every
/// renderer's vocabulary.
///
/// The old names stay on the left-hand side of the rules because they are what
/// `style.css` matches, and renaming them is a separate change with no
/// description in it. `padded-page` is [`Measure::Wide`] because a padded page
/// is the full width with gutters, which is what the class always meant.
///
/// The four standalone tokens -- `health-page`, `purchase-page`, `buy-page`,
/// `stripe-disclaimer-page` -- are screen identity rather than measure, and are
/// deliberately not here.
#[must_use]
pub const fn measure(measure: Measure) -> &'static str {
match measure {
Measure::Contained => "centered-page",
Measure::Reading => "article-page",
// The default, and the arm a member added upstream lands in. A measure
// this server has not learned yet should render at the width every
// other page does rather than unstyled.
_ => "padded-page",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_layer_statement_precedes_every_stylesheet() {
// The property the hand-written `` existed to
// hold, now held by the renderer. It is the one that fails silently:
// the CSS stays valid and buttons and badges look subtly wrong.
let head = head();
let stmt = head
.find("@layer makeover, base, components, responsive;")
.expect("the order is stated");
for sheet in ["typography.css", "geometry.css", "layout.css", "style.css"] {
assert!(stmt < head.find(sheet).expect("the sheet is linked"));
}
}
#[test]
fn the_head_is_not_closed_and_carries_no_title() {
// Both are `base.html`'s, and emitting either here would produce a
// second one rather than an error.
assert!(!head().contains(""));
assert!(!head().contains(""));
assert!(head().starts_with(""));
}
#[test]
fn the_fonts_are_preloaded_before_the_sheets_that_race_them() {
let head = head();
assert!(head.find("QuasiBody.woff2") < head.find("style.css"));
// The retired pair, checked by absence: a preload for a face nothing
// declares is a download the browser makes and never uses.
assert!(!head.contains("Lato"));
assert!(!head.contains("IBMPlexMono"));
}
#[test]
fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() {
// `base.html` writes them straight against ` Vec {
let mut found = Vec::new();
let mut stack = vec![std::path::PathBuf::from(root)];
while let Some(at) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&at) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "html") {
found.push(path);
}
}
}
found
}
#[test]
fn the_transport_the_head_links_is_served_from_here() {
// The shell links one script for the transport. Morphing is a swap
// style in htmx 4 rather than something an extension supplies, so the
// idiomorph registration and the extension script both left the head
// when quasi-webview moved to 4 (`2246072d`, ruled 2026-08-18), and the
// vendored file left the tree with `6b87e5ff`.
assert!(head().contains("htmx.min.js"));
assert!(!head().contains("idiomorph"));
let vendored = concat!(env!("CARGO_MANIFEST_DIR"), "/static/htmx.min.js");
assert!(std::path::Path::new(vendored).exists());
// The served bundle and the markup the templates carry are one version
// or the other, never a mix: 4 reads `hx-disable` as "disable while the
// request runs" where 2 read it as "skip this subtree".
let bundle = std::fs::read_to_string(vendored).expect("the bundle reads");
assert!(bundle.contains(HTMX), "the vendored bundle is {HTMX}");
}
#[test]
fn every_vendored_extension_is_busted_by_the_htmx_release_it_came_from() {
// Two extensions are vendored out of the htmx release: the history
// cache the shell links site-wide, and `hx-prompt`, which one admin
// template links for itself. Both are pinned files, so the release is
// their version, and a bump that leaves a `?v=` behind serves a browser
// the old extension against the new core.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut linked = 0;
let mut sources = vec![head().to_string()];
for entry in walk("templates") {
sources.push(std::fs::read_to_string(&entry).expect("a template reads"));
}
for source in &sources {
for (at, _) in source.match_indices("/static/hx-") {
let tail = &source[at + "/static/".len()..];
// The URL alone, cut at whichever quote closes the attribute.
let url = tail.split(['"', '\'']).next().expect("a quoted url");
let (name, suffix) = url.split_once("?v=").unwrap_or((url, ""));
assert!(
root.join("static").join(name).exists(),
"{name} is linked but not vendored"
);
assert_eq!(suffix, HTMX, "{name} is busted by the wrong version");
linked += 1;
}
}
assert_eq!(linked, 2, "the vendored extension count changed");
}
#[test]
fn the_htmx_config_is_stated_above_the_script_that_reads_it() {
// htmx reads `meta[name=htmx-config]` once, as the script runs. Below
// it the tag is inert and 4xx responses start swapping a rendered error
// page into whatever the request targeted.
let head = head();
let meta = head
.find("name=\"htmx-config\"")
.expect("the config is stated");
assert!(meta < head.find("htmx.min.js").expect("htmx is linked"));
assert!(head.contains("\"noSwap\""));
}
}