//! 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 `