//! 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. The head is the renderer's, so the version is all
/// that crosses. `_sheet.html` and `_island.html` are 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(|| described().parts())
}
/// The shell every document on this site is drawn in, described or templated.
///
/// `base.html` takes it as [`parts`] and assembles the rest itself; a screen
/// that owns its whole document (`crate::quasi::pricing`) takes the `Shell` and
/// hands it to a renderer. One builder either way, which is the whole point:
/// two heads written by two hands drift in ways nothing catches.
#[must_use]
pub fn described() -> Shell {
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/timing.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!(
"\
\
\
"
))
}
/// The link that jumps a keyboard reader past everything to the content.
///
/// Takes the id it points at, because a described screen's first region
/// publishes its own slot id and `base.html`'s `` is called
/// `main-content`. One spelling of the markup either way: this is an a11y
/// affordance the site owes on every page, and a second copy of it is a copy
/// that goes stale.
#[must_use]
pub fn skip_link(target: &str) -> String {
format!("Skip to main content")
}
/// What every page on this site ends with: the toast container and the classic
/// script shims the `data-action` dispatcher resolves through.
///
/// Called by `base.html` for the templated pages and handed to
/// [`quasi_webview::Shell::with_body_last`] by a screen that owns its own
/// document, so the tail is written once. Markup no description will ever name
/// -- a script tag, and a container another script writes into.
#[must_use]
pub fn body_last() -> &'static str {
concat!(
"",
"",
"",
"",
"",
"",
"",
"",
)
}
/// The site header, as every page on this site carries it.
///
/// Called by `partials/site_header.html` for the templated pages and handed to
/// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own
/// document, so the nav is written once. [`body_last`]'s argument one element
/// up.
///
/// Not a `Chrome`. `Chrome::nav` is emitted before `` and a
/// `Chrome::panel` after ``, and this header's mobile menu is a checkbox
/// styling its siblings (`style.css`, `.nav-toggle-checkbox:checked ~ .header-search`
/// and `~ nav`), so the two halves have to be one element. Wiki note
/// `mnw-server-conversion-plan`, the 2026-08-31 ruling.
///
/// Nothing a reader wrote is emitted here: the only per-request values are
/// `user.is_admin` and the session's CSRF token, and the token is escaped
/// anyway rather than trusted for being ours.
#[must_use]
pub fn site_header(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> String {
let mut out = String::with_capacity(1600);
out.push_str(concat!(
"",
"",
"Makenot.work",
"",
"",
"",
"");
out
}
/// `` 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.
///
/// How wide a page runs is a property of the screen, not a literal in its
/// template: name the measure here rather than writing one of these class
/// strings into markup.
///
/// The class names are what `style.css` matches, so they stay as they are.
/// `padded-page` is [`Measure::Wide`]: a padded page is the full width with
/// gutters.
///
/// 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",
}
}
/// The `` class a screen that owns its document carries: its measure
/// first, then whatever else its template said beside it.
///
/// [`quasi_router::Document::classed`] replaces rather than appends, so a
/// screen with a grouping or identity token of its own hands over one string.
/// Composing it here is what stops a screen that meant to add `feed-page` from
/// dropping `padded-page` on the way.
///
/// The global half is not here. `Shell` carries what is true of every document
/// and the renderer writes the two beside each other, the same composition
/// `base.html` does with [`body_attrs`].
#[must_use]
pub fn body_class(page: Measure, own: &[&str]) -> String {
let mut class = String::from(measure(page));
for token in own {
class.push(' ');
class.push_str(token);
}
class
}
#[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",
"timing.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 ``, in this order.
#[test]
fn the_disclosure_stays_a_sibling_of_what_it_reveals() {
let html = site_header(None, None);
let toggle = html.find("nav-toggle-checkbox").expect("the checkbox");
let search = html.find("header-search").expect("the search form");
let nav = html.find("