Skip to main content

max / makenotwork

The head is the renderer's, on the templated pages too Step S1 of the conversion plan. The dashboard is going to be described one screen at a time, and a described screen renders its document through quasi-webview's Shell while everything around it still renders through Askama. Two heads written by two hands drift, and the drift is silent: a layer statement below a stylesheet, an htmx without the morph extension, a viewport that says one thing on half the site. So the head moves first, before any screen is described, and both halves emit the same markup. crate::shell holds the one Shell and hands base.html the parts Askama cannot take as a whole document: the per-page title and head are blocks the parent renders in place, so the template keeps the title, </head>, the body tag and the close, and everything above them is the renderer's. _head_assets.html is gone with it. It was generated, so all that crosses the build boundary now is the content hash, as STATIC_VERSION. Two changes visible on the page, both intended: - The document carries htmx's response-handling config, which this server had no equivalent of. Without it a 4xx does not swap, and the notice a handler carefully wrote reaches nobody. - idiomorph is loaded and registered on <body>. The file has been served since 2026-08-09 and nothing linked it. Nothing on a templated page asks for a morph swap yet, so it is inert until a described screen does.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 18:01 UTC
Signed with PGP, not checked
Commit: c8f27c701da303ab942ec79a671b12ef0b01294b
Parent: 9dfe85e
7 files changed, +170 insertions, -54 deletions
M .gitignore -1
@@ -44,7 +44,6 @@
44 44 .sqlx/
45 45
46 46 # Generated template partials (build.rs output)
47 - server/templates/_head_assets.html
48 47 server/templates/_island.html
49 48 server/templates/_sheet.html
50 49
M server/build.rs +9 -43
@@ -56,9 +56,9 @@
56 56 // busting browser caches automatically.
57 57 let static_files = [
58 58 "static/style.css",
59 - // The two per-page sheets. Linked from page templates rather than from
60 - // _head_assets.html, which is why they were outside the fingerprint
61 - // and served stale to any browser holding a cached copy.
59 + // The two per-page sheets. Linked from page templates rather than
60 + // from the head, which is why they were outside the fingerprint and
61 + // served stale to any browser holding a cached copy.
62 62 "static/wizard.css",
63 63 "static/media-player.css",
64 64 "static/htmx.min.js",
@@ -98,45 +98,11 @@
98 98 let static_hash = format!("{:016x}", hasher.finish());
99 99 let version = &static_hash[..8];
100 100
101 - // Generate a template partial with versioned asset URLs.
102 - // base.html includes this via {% include "_head_assets.html" %}
103 - let partial = format!(
104 - r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin>
105 - <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin>
106 - <!-- Cascade layer order, declared before any stylesheet so it is a statement
107 - rather than an accident of link order. A layer's position is fixed where
108 - its name is FIRST seen, so without this the two generated sheets below
109 - would establish `makeover` simply by loading first, and reordering these
110 - links would silently reorder the cascade. It also has to precede the
111 - per-page sheets (wizard.css, media-player.css), which this partial does
112 - not link and which base.html therefore cannot order on its own.
113 -
114 - Earlier in the list = lower priority. `makeover` is first because the
115 - design system is what the site overrides, never the reverse.
116 -
117 - `components` is where the site's own sheets live: style.css, and the
118 - per-page wizard.css and media-player.css. It sits after `makeover`, so
119 - those sheets win the contests they were already winning when they were
120 - unlayered, and they now win them by a stated rule. A layer resolves
121 - internally by source order, so a per-page sheet still beats style.css
122 - on a tie exactly as before.
123 -
124 - Unlayered rules still beat every named layer, and one thing relies on
125 - that: the theme block theming.rs injects into <head> is unlayered, so a
126 - creator's chosen theme outranks all of the above without knowing this
127 - order. `base` and `responsive` are still empty; declaring an empty
128 - layer costs nothing and fixes its position. -->
129 - <style>@layer makeover, base, components, responsive;</style>
130 - <link rel="stylesheet" href="/static/geometry.css?v={version}">
131 - <link rel="stylesheet" href="/static/layout.css?v={version}">
132 - <link rel="stylesheet" href="/static/style.css?v={version}">
133 - <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
134 - <script src="/static/htmx.min.js"></script>
135 - <script src="/static/upload.js?v={version}"></script>
136 - <script type="module" src="/static/dist/core/index.js?v={version}"></script>"#,
137 - );
138 -
139 - write_if_changed(Path::new("templates/_head_assets.html"), &partial);
101 + // The head's own assets are no longer a generated partial: crate::shell
102 + // builds them into quasi-webview's Shell, which is what a described screen
103 + // renders through, so both halves of the converted site emit one head. All
104 + // that crosses the build boundary now is the version.
105 + println!("cargo::rustc-env=STATIC_VERSION={version}");
140 106
141 107 // Per-page island loader macro. Heavy/page-specific islands (media player,
142 108 // uploader, ...) load on the pages that use them via
@@ -151,7 +117,7 @@
151 117
152 118 // Per-page stylesheet loader, same idea as the island macro. wizard.css and
153 119 // media-player.css are linked by the pages that need them rather than by
154 - // _head_assets.html, so this is how they get the content hash.
120 + // the shell, so this is how they get the content hash.
155 121 let sheet_partial = r#"{% macro sheet(name) -%}
156 122 <link rel="stylesheet" href="/static/{{ name }}?v=__VER__">
157 123 {%- endmacro %}
@@ -56,6 +56,7 @@
56 56 pub mod scheduler;
57 57 pub mod security_signals;
58 58 pub mod seed;
59 + pub mod shell;
59 60 pub mod site_docs;
60 61 pub mod storage;
61 62 pub mod synckit_auth;
@@ -1,8 +1,10 @@
1 - <!DOCTYPE html>
2 - <html lang="en">
3 - <head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
1 + {# Everything from <!doctype> down to the last script in the head is quasi's,
2 + emitted by the same shell a described screen renders through. See
3 + crate::shell: the site is being converted a screen at a time, and two heads
4 + written by two hands drift in ways nothing catches. What is left here is
5 + what a template has to own, because Askama renders these blocks in place and
6 + no caller ever holds them as a string. #}
7 + {{ crate::shell::head()|safe }}
6 8 {% if let Some(token) = csrf_token %}<meta name="csrf-token" content="{{ token }}">{% endif %}
7 9 <title>{% block title %}Makenotwork{% endblock %}</title>
8 10 <meta name="description" content="{% block meta_description %}Sell your work directly. 0% platform fee, only Stripe's ~3% processing. Music, software, writing, and more.{% endblock %}">
@@ -16,10 +18,9 @@
16 18 <meta property="og:type" content="website">
17 19 <meta name="twitter:card" content="summary_large_image">
18 20
19 - {% include "_head_assets.html" %}
20 21 {% block head %}{% endblock %}
21 22 </head>
22 - <body{% block body_attrs %}{% endblock %}>
23 + <body{{ crate::shell::body_attrs()|safe }}{% block body_attrs %}{% endblock %}>
23 24 <a href="#main-content" class="skip-to-main">Skip to main content</a>
24 25 <main id="main-content">
25 26 {% block content %}{% endblock %}
@@ -49,7 +50,7 @@
49 50 <!-- Delegated on* handlers, extracted from inline attributes so the CSP can
50 51 drop script-src 'unsafe-inline'. The data-action dispatcher + core
51 52 primitives now live in the typed core module (frontend/src/core),
52 - loaded as an ES module in <head> via _head_assets.html; these classic
53 + loaded as an ES module in <head> via crate::shell; these classic
53 54 shims resolve through its registry / window bridge. -->
54 55 <script src="/static/actions-pages.js?v=0701"></script>
55 56 <script src="/static/actions-partials.js?v=0701"></script>
@@ -5,7 +5,7 @@
5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 6 <title>Buy {{ item.title }}: Makenotwork</title>
7 7 {# Standalone head, so the three sheets are listed here rather than coming
8 - from _head_assets.html. Order matters: style.css reads --bevel-raised /
8 + from crate::shell. Order matters: style.css reads --bevel-raised /
9 9 --bevel-inset out of layout.css, and a box-shadow naming an undefined
10 10 var drops the whole declaration instead of degrading. #}
11 11 <link rel="stylesheet" href="/static/geometry.css">
@@ -671,7 +671,7 @@
671 671 <meta name="viewport" content="width=device-width, initial-scale=1.0">
672 672 <title>Payment Complete | Makenotwork</title>
673 673 <!-- Standalone head, so the three sheets are listed here rather than
674 - coming from _head_assets.html. style.css reads the bevel pair out of
674 + coming from crate::shell. style.css reads the bevel pair out of
675 675 layout.css, so it has to load after it. -->
676 676 <link rel="stylesheet" href="/static/geometry.css">
677 677 <link rel="stylesheet" href="/static/layout.css">
@@ -1,0 +1,149 @@
1 + //! The document head, emitted by quasi's renderer rather than by `base.html`.
2 + //!
3 + //! Wiki note `mnw-server-conversion-plan`, step S1. The dashboard is being
4 + //! converted to described screens one at a time, and a converted screen renders
5 + //! through [`quasi_webview::Shell`] while everything around it still renders
6 + //! through Askama. Two heads written by two hands drift, and the drift is
7 + //! silent: a layer statement that moves below a stylesheet, an htmx that loads
8 + //! without the morph extension, a viewport that says one thing on half the
9 + //! site. So the head is the renderer's on both paths from here, before any
10 + //! screen is described.
11 + //!
12 + //! [`Shell::parts`] is what a host whose templating writes into the head takes:
13 + //! Askama renders the per-page `{% block title %}` and `{% block head %}` in
14 + //! place, inside `base.html`, and no caller ever holds them as a string.
15 + //! `base.html` writes the title, `</head>`, the `<body>` tag and the close;
16 + //! everything above is here.
17 + //!
18 + //! One shell for the process, not one per request. Nothing in it varies by
19 + //! viewer: the creator's theme block is per-request and unlayered on purpose,
20 + //! and it stays where it is, injected by the three templates that show a
21 + //! creator's work through `{% block head %}` so it lands after every sheet and
22 + //! outranks every named layer.
23 +
24 + use std::sync::OnceLock;
25 +
26 + use quasi_webview::Shell;
27 +
28 + /// The cache-busting suffix on the site's own assets.
29 + ///
30 + /// A content hash of every watched static file, computed in `build.rs` and
31 + /// handed over as an env var. It used to reach the head through a generated
32 + /// `_head_assets.html`; the head is the renderer's now, so the version is all
33 + /// that crosses. `_sheet.html` and `_island.html` are still generated with the
34 + /// same hash, for the per-page sheets and islands this module does not see.
35 + const V: &str = env!("STATIC_VERSION");
36 +
37 + fn parts() -> &'static quasi_webview::Parts {
38 + static PARTS: OnceLock<quasi_webview::Parts> = OnceLock::new();
39 + PARTS.get_or_init(|| {
40 + Shell::under("/static")
41 + // Earlier in the list = lower priority, and `makeover` is prepended
42 + // by the renderer. A layer's position is fixed where its name is
43 + // FIRST seen, so without the statement the generated sheets would
44 + // establish `makeover` simply by loading first and reordering two
45 + // links would silently reorder the cascade. `components` is where
46 + // the site's own sheets live, including the per-page wizard.css and
47 + // media-player.css that arrive later through `{% block head %}` and
48 + // that nothing here can order. `base` and `responsive` are still
49 + // empty; declaring an empty layer costs nothing and fixes its
50 + // position.
51 + .layered(["base", "components", "responsive"])
52 + // Whole value is being early: a preload discovered after the sheets
53 + // it races bought nothing.
54 + .with_head_first(
55 + "<link rel=\"preload\" href=\"/static/fonts/Lato-Regular.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>\
56 + <link rel=\"preload\" href=\"/static/fonts/ysrf.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>",
57 + )
58 + .styled(format!("/static/geometry.css?v={V}"))
59 + .styled(format!("/static/layout.css?v={V}"))
60 + .styled(format!("/static/style.css?v={V}"))
61 + // Last in the head, after htmx: the favicon has no order to keep,
62 + // and neither script reads htmx at load. `upload.js` only defines
63 + // `S3Uploader`, and the core module is deferred by being a module,
64 + // so it still runs after the deferred htmx above it.
65 + .with_head(format!(
66 + "<link rel=\"icon\" href=\"/static/images/favicon.ico\" type=\"image/x-icon\">\
67 + <script src=\"/static/upload.js?v={V}\"></script>\
68 + <script type=\"module\" src=\"/static/dist/core/index.js?v={V}\"></script>"
69 + ))
70 + .parts()
71 + })
72 + }
73 +
74 + /// `<!doctype>` through the head's contents, without `</head>`.
75 + ///
76 + /// Called from `base.html`, which appends the title and the per-page head and
77 + /// closes the element.
78 + pub fn head() -> &'static str {
79 + &parts().head
80 + }
81 +
82 + /// The attributes the shell owns on `<body>`, each one space-prefixed.
83 + ///
84 + /// `base.html` writes `<body{{ body_attrs() }}{% block body_attrs %}>`, so a
85 + /// page's own class attribute composes with these instead of replacing them.
86 + pub fn body_attrs() -> &'static str {
87 + &parts().body_attrs
88 + }
89 +
90 + #[cfg(test)]
91 + mod tests {
92 + use super::*;
93 +
94 + #[test]
95 + fn the_layer_statement_precedes_every_stylesheet() {
96 + // The property the hand-written `<style>@layer ...</style>` existed to
97 + // hold, now held by the renderer. It is the one that fails silently:
98 + // the CSS stays valid and buttons and badges look subtly wrong.
99 + let head = head();
100 + let stmt = head
101 + .find("@layer makeover, base, components, responsive;")
102 + .expect("the order is stated");
103 + for sheet in ["geometry.css", "layout.css", "style.css"] {
104 + assert!(stmt < head.find(sheet).expect("the sheet is linked"));
105 + }
106 + }
107 +
108 + #[test]
109 + fn the_head_is_not_closed_and_carries_no_title() {
110 + // Both are `base.html`'s, and emitting either here would produce a
111 + // second one rather than an error.
112 + assert!(!head().contains("</head>"));
113 + assert!(!head().contains("<title>"));
114 + assert!(head().starts_with("<!doctype html><html lang=\"en\">"));
115 + }
116 +
117 + #[test]
118 + fn the_fonts_are_preloaded_before_the_sheets_that_race_them() {
119 + let head = head();
120 + assert!(head.find("Lato-Regular.woff2") < head.find("style.css"));
121 + }
122 +
123 + #[test]
124 + fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() {
125 + // `base.html` writes them straight against `<body`, and a page's
126 + // `{% block body_attrs %}` straight after. Neither side puts a
127 + // separator in, so this one has to carry it.
128 + let attrs = body_attrs();
129 + assert!(attrs.starts_with(' '));
130 + assert!(attrs.contains("hx-ext=\"morph\""));
131 + // No class of its own, or a page's class attribute would be the second
132 + // on the tag and the browser would drop it.
133 + assert!(!attrs.contains("class="));
134 + }
135 +
136 + #[test]
137 + fn morph_is_loaded_wherever_a_swap_can_ask_for_it() {
138 + // The shell writes `hx-ext="morph"` onto every page's body, so the
139 + // extension has to be served. It is: `static/idiomorph-ext.min.js`.
140 + assert!(head().contains("idiomorph-ext.min.js"));
141 + assert!(
142 + std::path::Path::new(concat!(
143 + env!("CARGO_MANIFEST_DIR"),
144 + "/static/idiomorph-ext.min.js"
145 + ))
146 + .exists()
147 + );
148 + }
149 + }