//! 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") } /// The wordmark the sessionless pages open with. /// /// Ten templates wrote this same `

` out by hand -- `login`, `two_factor`, /// `forgot_password`, `reset_password`, `confirm_delete`, `acknowledge`, /// `oauth_authorize`, `sandbox`, `purchase` and `index` -- which is ten places /// for the dot to move. Here for the same reason [`site_header`] is: it is one /// element of the assembly layer, called from the templates and handed to /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own /// document. /// /// Not [`site_header`]'s logo and not a substitute for it. The pages that carry /// this one carry no header at all: they are the screens a reader reaches /// without a session, where the nav would offer a Library and a Dashboard the /// reader cannot open. The dot is `aria-hidden`, matching the logo, so a screen /// reader hears the name rather than the punctuation. #[must_use] pub fn wordmark() -> &'static str { "

Makenot.work

" } /// 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!( "
", "", "", "", "", "", "", "", ) } /// What the site offers from every page, as a description. /// /// The header was 48 lines of hand-written markup in this module until /// `c7b0d3c1`, and the reason it could not be a [`Chrome`] was that the nav and /// the mobile menu were emitted at two different points in the document: /// `style.css` opens that menu with `.nav-toggle-checkbox:checked ~ nav`, and /// `~` reaches siblings only. [`quasi_router::Band`] is the member that closed /// it. The band is one element and everything in the header is inside it, so /// the checkbox and the nav are siblings again and the rule matches. /// /// Built per request, unlike the chrome a described app hangs on its router: /// what the nav offers depends on whether there is a session and whether that /// session is an admin, and a chrome built once could not say either. /// /// The shortcuts binding is not here. That is /// [`crate::quasi::shortcuts::chrome`] and it is hung on the described /// document's shell; this is the header, and the two are joined by whatever /// builds a `Shell`. #[must_use] pub fn header_chrome(user: Option<&crate::auth::SessionUser>) -> quasi_router::Chrome { use quasi_router::{Action, Band, Brand, Chrome, Disclose, Field, Place, layout}; let mut chrome = Chrome::new().banded( Band::new() .branded(Brand::new("Makenot.work", Action::get("/").navigating()).marking(".")) .searching({ let mut field = Field::new(layout::FieldKind::Text, "q", "Search items and projects"); field.placeholder = Some("Search... (Cmd+K)".to_owned()); // A box that goes somewhere when it settles, which is what // the hand-written `
` // was. field.writes(Action::get("/discover").navigating()) }) // The narrow-viewport menu, named rather than hand-rolled. The // checkbox and the three bars are the renderer's; what this says is // that the places are worth hiding when there is no room. .disclosing(Disclose::Narrow), ); chrome = chrome.offering(Place::new( "discover", "Discover", Action::get("/discover").navigating(), )); match user { Some(user) => { chrome = chrome .offering(Place::new( "library", "Library", Action::get("/library").navigating(), )) .offering(Place::new( "dashboard", "Dashboard", Action::get("/dashboard").navigating(), )) // Offered always and drawn only when there is something in it. // How many items a cart holds is not known when the document is // rendered -- `static/dist/core/cart-badge.js` asks // `/api/cart/count` -- so the presence is the script's and the // place is the description's. It finds this one by // `data-place="cart"`, which is `Place::key` as the renderer // emits it. .offering(Place::new( "cart", "Cart", Action::get("/cart").navigating(), )); if user.is_admin { // The one thing in the nav that is a permission rather than a // session. chrome = chrome.offering(Place::new( "admin", "Admin", Action::get("/admin/waitlist").navigating(), )); } // A write, so it is emitted as an htmx post rather than an anchor. // The token rides on the `X-CSRF-Token` header that // `frontend/src/core/htmx-glue.ts` attaches to every request from // the `csrf-token` meta, which is why this no longer takes one: the // hidden `_csrf` input existed because the logout was a form, and // it is not a form any more. chrome = chrome.offering(Place::new("logout", "Log Out", Action::post("/logout"))); } None => { for (key, label, route) in [ ("use-cases", "Use Cases", "/use-cases"), ("docs", "Docs", "/docs"), ("fan-plus", "Fan+", "/fan-plus"), ("login", "Login", "/login"), ("join", "Join", "/join"), ] { chrome = chrome.offering(Place::new(key, label, Action::get(route).navigating())); } } } chrome } /// 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 header is written once. [`body_last`]'s argument one /// element up. /// /// The markup is the renderer's now: this builds [`header_chrome`] and /// `quasi_webview::chrome::header_html` writes it. That is the whole of /// `c7b0d3c1` -- one description, three renderers, and no markup in the /// assembly layer. /// /// # It no longer takes the CSRF token /// /// It took one while the logout was a `` carrying a hidden `_csrf`. The /// logout is a [`quasi_router::Place`] now, emitted as an htmx post, and /// `frontend/src/core/htmx-glue.ts` attaches the token to every htmx request /// from the `csrf-token` meta the document already carries. A second copy in /// the markup would be a second thing that can go stale against a token that /// rotates mid-session. /// /// Nothing marks a current place. The templated pages do not know which place /// they are, and a described screen says it with /// [`quasi_router::Screen::place`] on the path this function is not on. #[must_use] pub fn site_header(user: Option<&crate::auth::SessionUser>) -> String { quasi_webview::chrome::header_html( &header_chrome(user), None, &makeover_webview::Emit::default(), ) } /// `` 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("<!doctype html><html lang=\"en\">")); } #[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 `<body`, and a page's // `{% block body_attrs %}` straight after. Neither side puts a // separator in, so this one has to carry it. let attrs = body_attrs(); assert!(attrs.is_empty() || attrs.starts_with(' ')); // No class of its own, or a page's class attribute would be the second // on the tag and the browser would drop it. assert!(!attrs.contains("class=")); } #[test] fn every_measure_keeps_the_class_the_templates_used_to_write() { // `0eccff0d` moved where the choice is written down and changed no // rule in `style.css`, so the three strings have to come out exactly as // the 69 templates spelled them. A typo here renders 53 pages unstyled. assert_eq!(measure(Measure::Wide), "padded-page"); assert_eq!(measure(Measure::Contained), "centered-page"); assert_eq!(measure(Measure::Reading), "article-page"); } #[test] fn a_screen_with_nothing_of_its_own_carries_only_its_measure() { assert_eq!(body_class(Measure::Contained, &[]), "centered-page"); } #[test] fn a_screens_own_token_lands_beside_its_measure_rather_than_instead_of_it() { // The exact string `templates/pages/feed.html` renders today, which is // the parity the next conversion has to hold. assert_eq!( body_class(Measure::Wide, &["feed-page"]), "padded-page feed-page" ); assert_eq!( body_class(Measure::Wide, &["dashboard-page", "dashboard-user-page"]), "padded-page dashboard-page dashboard-user-page" ); } /// A reader with no session is offered the two ways in and no way out. #[test] fn the_signed_out_header_offers_the_ways_in() { let html = site_header(None); assert!(html.contains("href=\"/login\""), "{html}"); assert!(html.contains("href=\"/join\""), "{html}"); assert!(html.contains("href=\"/discover\""), "{html}"); assert!(!html.contains("/logout"), "{html}"); assert!(!html.contains("/dashboard"), "{html}"); } /// A signed-in reader gets the account nav and a way out. /// /// The logout was a `<form>` with a hidden `_csrf` until `c7b0d3c1`. It is /// a `Place` now, so it is an htmx post and the token rides on the header /// `htmx-glue.ts` attaches from the `csrf-token` meta. What this asserts is /// that it is still a write: a logout emitted as an anchor would be a /// logout a link prefetcher can perform. #[test] fn the_signed_in_header_carries_a_logout_that_writes() { let html = site_header(Some(&a_user(false))); assert!(html.contains("hx-post=\"/logout\""), "{html}"); assert!( !html.contains("href=\"/logout\""), "a prefetch logs out: {html}" ); assert!(html.contains("href=\"/library\""), "{html}"); assert!(html.contains("href=\"/dashboard\""), "{html}"); assert!(!html.contains("href=\"/login\""), "{html}"); } /// The admin link is the one thing in the nav that is a permission rather /// than a session. #[test] fn only_an_admin_is_offered_the_admin_link() { assert!(site_header(Some(&a_user(true))).contains("/admin/waitlist")); assert!(!site_header(Some(&a_user(false))).contains("/admin/waitlist")); } /// The mobile menu is a checkbox styling its siblings, which is the whole /// reason this header is one element. The three have to stay siblings /// inside `<header>`, in this order, and it is the renderer that keeps them /// there now: `Chrome::band` is the member that made saying so possible. #[test] fn the_disclosure_stays_a_sibling_of_what_it_reveals() { let html = site_header(None); let toggle = html.find("chrome-disclose-state").expect("the checkbox"); let search = html.find("chrome-search").expect("the search box"); let nav = html.find("<nav").expect("the nav"); assert!(toggle < search && search < nav, "{html}"); assert!(html.ends_with("</header>"), "{html}"); } /// The wordmark comes apart at its dot, and is read whole. /// /// The hand-written header marked the dot `aria-hidden`, which left the /// accessible name as "Makenotwork" -- not something the site is called. /// `Brand` says the name and which run of it is the mark, and the renderer /// draws both. #[test] fn the_header_wordmark_is_marked_and_still_reads_whole() { let html = site_header(None); assert!( html.contains(">Makenot<span class=\"chrome-brand-mark\">.</span>work</a>"), "{html}" ); } fn a_user(is_admin: bool) -> crate::auth::SessionUser { crate::auth::SessionUser { id: crate::db::UserId::new(), username: crate::db::Username::from_trusted("areader".to_string()), email: "areader@example.com".to_string(), display_name: None, can_create_projects: false, suspended: false, is_admin, is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, settlement_currency: crate::currency::SettlementCurrency::Usd, conversion_preference: crate::currency::ConversionChoice::AtCheckout, } } /// One spelling of the wordmark, enforced the way the layout class is. /// /// Ten templates wrote the same `<h1>` before [`wordmark`] existed, so the /// obvious failure is an eleventh pasted from one of them. The three /// `brand-h1` headings that remain say something else -- "Account Deleted", /// "Email preferences", and the email-result title -- and are not the /// wordmark, so the check is for the wordmark's own text. #[test] fn no_template_still_writes_the_wordmark_by_hand() { let mut offenders = Vec::new(); for entry in walk("templates", "html") { let source = std::fs::read_to_string(&entry).expect("a template reads"); for (at, line) in source.lines().enumerate() { if line.contains("brand-h1") && line.contains("Makenot") { offenders.push(format!("{}:{}", entry.display(), at + 1)); } } } assert!( offenders.is_empty(), "call crate::shell::wordmark instead: {offenders:?}" ); } #[test] fn the_wordmark_hides_the_dot_from_a_screen_reader() { // Matching the header's logo, which has always done this: a reader // hears the name rather than the punctuation inside it. assert!(wordmark().contains(r#"<span class="dot" aria-hidden="true">"#)); } #[test] fn no_template_still_writes_a_layout_class_by_hand() { // The done-condition, checked rather than remembered: the layout axis // is derived from the described property. A new template pasted from an // old one fails here instead of quietly reintroducing the literal. // // The four standalone tokens are screen identity rather than measure // and are deliberately left alone, so they are not looked for. let mut offenders = Vec::new(); for entry in walk("templates", "html") { let source = std::fs::read_to_string(&entry).expect("a template reads"); for (at, line) in source.lines().enumerate() { if !line.contains("block body_attrs") { continue; } // The literal, as distinct from the call that produces it: the // rendered class still says `padded-page`, and should. let derived = line.contains("crate::shell::measure("); let literal = ["padded-page", "centered-page", "article-page"] .iter() .any(|name| line.contains(name)); if literal && !derived { offenders.push(format!("{}:{}", entry.display(), at + 1)); } } } assert!(offenders.is_empty(), "{offenders:?}"); } #[test] fn no_described_screen_writes_a_layout_class_by_hand() { // The screen-side twin of the template check above: a converted screen // states its measure and reads the class off it, so a literal reaching // `Document::classed` is a screen that will not follow `style.css` when // the mapping moves. The four standalone identity tokens and the embed // classes are screen identity rather than measure and are not looked // for. let mut offenders = Vec::new(); for entry in walk("src", "rs") { // This file states the three strings once, which is the point of it. if entry.ends_with("shell.rs") { continue; } let source = std::fs::read_to_string(&entry).expect("a source file reads"); for (at, line) in source.lines().enumerate() { let literal = ["padded-page", "centered-page", "article-page"] .iter() .any(|name| line.contains(name)); if line.contains("classed(") && literal { offenders.push(format!("{}:{}", entry.display(), at + 1)); } } } assert!(offenders.is_empty(), "{offenders:?}"); } /// Every file with the given extension under a directory. fn walk(root: &str, wanted: &str) -> Vec<std::path::PathBuf> { 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 == wanted) { 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", "html") { 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\"")); } }