Skip to main content

max / makenotwork

Let the renderer write the site header, and delete the hand-written one `crate::shell::site_header` was 48 lines of markup in the assembly layer, reached by 55 templates through a partial and by ten described documents directly. It builds a `quasi_router::Chrome` now and `quasi_webview::chrome::header_html` writes it, which is one description and three renderers instead of one host's markup. The reason it could not be a `Chrome` before was `style.css`: the mobile menu is a checkbox styling its siblings, and the nav and the panel were emitted at two different points in the document. `Chrome::band` is one element and everything in the header is inside it, so `~` matches again. What changed for a reader, all of it deliberate: - The wordmark reads whole. The old one marked the dot `aria-hidden`, leaving the accessible name "Makenotwork", which is not what the site is called. `Brand` says the name and which run is the mark. - Log Out is a `Place` carrying a POST rather than a form with a hidden `_csrf`. The token rides on the header `htmx-glue.ts` attaches from the `csrf-token` meta, so there is no second copy to go stale against a token that rotates mid-session. `logout_handler` answers `HX-Redirect` for an htmx request, the way the 2FA verify handler already did. - The cart place is offered always and drawn when the cart holds something. `cart-badge.ts` finds it by `data-place="cart"` and writes `data-count`; the count is not a fact the server knows at render time, so it cannot be a fact the description carries. - The search box carries a real `<label>`, hidden, where the old one carried an `aria-label`. `.header-search input` restated four properties makeover already sets on `.field`, which `build.rs`'s drift check refuses. They are gone rather than renamed: the box is a described field now and the design system styles it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-02 12:58 UTC
Signed with PGP, not checked
Commit: 257af740d1580d5e76fc1caddf0663685c56e824
Parent: 4887b6f
18 files changed, +335 insertions, -166 deletions
M server/Cargo.lock +24 -24
@@ -2875,7 +2875,7 @@
2875 2875 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
2876 2876 dependencies = [
2877 2877 "libc",
2878 - "windows-sys 0.61.2",
2878 + "windows-sys 0.52.0",
2879 2879 ]
2880 2880
2881 2881 [[package]]
@@ -3660,7 +3660,7 @@
3660 3660 checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
3661 3661 dependencies = [
3662 3662 "bstr",
3663 - "hashbrown 0.17.1",
3663 + "hashbrown 0.15.5",
3664 3664 ]
3665 3665
3666 3666 [[package]]
@@ -4811,7 +4811,7 @@
4811 4811 source = "registry+https://github.com/rust-lang/crates.io-index"
4812 4812 checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
4813 4813 dependencies = [
4814 - "windows-sys 0.61.2",
4814 + "windows-sys 0.59.0",
4815 4815 ]
4816 4816
4817 4817 [[package]]
@@ -5722,7 +5722,7 @@
5722 5722 source = "registry+https://github.com/rust-lang/crates.io-index"
5723 5723 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5724 5724 dependencies = [
5725 - "windows-sys 0.61.2",
5725 + "windows-sys 0.59.0",
5726 5726 ]
5727 5727
5728 5728 [[package]]
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.97.0"
6639 + version = "0.98.0"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.97.0"
6650 + version = "0.98.0"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.97.0"
6658 + version = "0.98.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,7 +6664,7 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.97.0"
6667 + version = "0.98.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.97.0"
6690 + version = "0.98.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -7339,7 +7339,7 @@
7339 7339 "errno",
7340 7340 "libc",
7341 7341 "linux-raw-sys 0.12.1",
7342 - "windows-sys 0.61.2",
7342 + "windows-sys 0.52.0",
7343 7343 ]
7344 7344
7345 7345 [[package]]
@@ -7419,7 +7419,7 @@
7419 7419 "security-framework 3.7.0",
7420 7420 "security-framework-sys",
7421 7421 "webpki-root-certs",
7422 - "windows-sys 0.61.2",
7422 + "windows-sys 0.52.0",
7423 7423 ]
7424 7424
7425 7425 [[package]]
@@ -8545,7 +8545,7 @@
8545 8545 "getrandom 0.4.3",
8546 8546 "once_cell",
8547 8547 "rustix 1.1.4",
8548 - "windows-sys 0.61.2",
8548 + "windows-sys 0.52.0",
8549 8549 ]
8550 8550
8551 8551 [[package]]
@@ -9971,7 +9971,7 @@
9971 9971 source = "registry+https://github.com/rust-lang/crates.io-index"
9972 9972 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
9973 9973 dependencies = [
9974 - "windows-sys 0.61.2",
9974 + "windows-sys 0.48.0",
9975 9975 ]
9976 9976
9977 9977 [[package]]
@@ -10759,6 +10759,14 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 + [[patch.unused]]
10763 + name = "synckit-client"
10764 + version = "0.10.0"
10765 +
10766 + [[patch.unused]]
10767 + name = "synckit-config"
10768 + version = "0.2.0"
10769 +
10762 10770 [[patch.unused]]
10763 10771 name = "kberg"
10764 10772 version = "0.1.0"
@@ -10771,21 +10779,13 @@
10771 10779 name = "painhours"
10772 10780 version = "0.1.0"
10773 10781
10774 - [[patch.unused]]
10775 - name = "synckit-client"
10776 - version = "0.10.0"
10777 -
10778 - [[patch.unused]]
10779 - name = "synckit-config"
10780 - version = "0.2.0"
10781 -
10782 10782 [[patch.unused]]
10783 10783 name = "quasi-immediate"
10784 - version = "0.97.0"
10784 + version = "0.98.0"
10785 10785
10786 10786 [[patch.unused]]
10787 10787 name = "quasi-notifs"
10788 - version = "0.97.0"
10788 + version = "0.98.0"
10789 10789
10790 10790 [[patch.unused]]
10791 10791 name = "quasi-store"
@@ -10793,4 +10793,4 @@
10793 10793
10794 10794 [[patch.unused]]
10795 10795 name = "quasi-tauri"
10796 - version = "0.97.0"
10796 + version = "0.98.0"
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.97" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.98" }
167 167 # The description vocabulary quasi's screen types are built from. Pinned here
168 168 # rather than reached through quasi-router's re-export because a described
169 169 # screen names FieldKind and Tone directly; it has to track what quasi-router
@@ -179,13 +179,13 @@
179 179 # For the request head the per-viewer state factory reads. axum re-exports it,
180 180 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
181 181 http = "1.3.1"
182 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.97" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.97" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.98" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.98" }
184 184 # The first-party widget set. The carousel was proved here against three pages
185 185 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
186 186 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
187 187 # sits above quasi-router rather than inside the description suite.
188 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.97" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.98" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -277,7 +277,7 @@
277 277 # Written from the crate rather than copied into the repo for the reason the
278 278 # constants exist: a stale copy in a static directory is a silently dead bulk
279 279 # bar, and the version has to be the one the emitter agrees with.
280 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.97" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.98" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
M server/src/shell.rs +165 -74
@@ -180,70 +180,141 @@
180 180 )
181 181 }
182 182
183 + /// What the site offers from every page, as a description.
184 + ///
185 + /// The header was 48 lines of hand-written markup in this module until
186 + /// `c7b0d3c1`, and the reason it could not be a [`Chrome`] was that the nav and
187 + /// the mobile menu were emitted at two different points in the document:
188 + /// `style.css` opens that menu with `.nav-toggle-checkbox:checked ~ nav`, and
189 + /// `~` reaches siblings only. [`quasi_router::Band`] is the member that closed
190 + /// it. The band is one element and everything in the header is inside it, so
191 + /// the checkbox and the nav are siblings again and the rule matches.
192 + ///
193 + /// Built per request, unlike the chrome a described app hangs on its router:
194 + /// what the nav offers depends on whether there is a session and whether that
195 + /// session is an admin, and a chrome built once could not say either.
196 + ///
197 + /// The shortcuts binding is not here. That is
198 + /// [`crate::quasi::shortcuts::chrome`] and it is hung on the described
199 + /// document's shell; this is the header, and the two are joined by whatever
200 + /// builds a `Shell`.
201 + #[must_use]
202 + pub fn header_chrome(user: Option<&crate::auth::SessionUser>) -> quasi_router::Chrome {
203 + use quasi_router::{Action, Band, Brand, Chrome, Disclose, Field, Place, layout};
204 +
205 + let mut chrome = Chrome::new().banded(
206 + Band::new()
207 + .branded(Brand::new("Makenot.work", Action::get("/").navigating()).marking("."))
208 + .searching({
209 + let mut field =
210 + Field::new(layout::FieldKind::Text, "q", "Search items and projects");
211 + field.placeholder = Some("Search... (Cmd+K)".to_owned());
212 + // A box that goes somewhere when it settles, which is what
213 + // the hand-written `<form action="/discover" method="get">`
214 + // was.
215 + field.writes(Action::get("/discover").navigating())
216 + })
217 + // The narrow-viewport menu, named rather than hand-rolled. The
218 + // checkbox and the three bars are the renderer's; what this says is
219 + // that the places are worth hiding when there is no room.
220 + .disclosing(Disclose::Narrow),
221 + );
222 +
223 + chrome = chrome.offering(Place::new(
224 + "discover",
225 + "Discover",
226 + Action::get("/discover").navigating(),
227 + ));
228 +
229 + match user {
230 + Some(user) => {
231 + chrome = chrome
232 + .offering(Place::new(
233 + "library",
234 + "Library",
235 + Action::get("/library").navigating(),
236 + ))
237 + .offering(Place::new(
238 + "dashboard",
239 + "Dashboard",
240 + Action::get("/dashboard").navigating(),
241 + ))
242 + // Offered always and drawn only when there is something in it.
243 + // How many items a cart holds is not known when the document is
244 + // rendered -- `static/dist/core/cart-badge.js` asks
245 + // `/api/cart/count` -- so the presence is the script's and the
246 + // place is the description's. It finds this one by
247 + // `data-place="cart"`, which is `Place::key` as the renderer
248 + // emits it.
249 + .offering(Place::new(
250 + "cart",
251 + "Cart",
252 + Action::get("/cart").navigating(),
253 + ));
254 + if user.is_admin {
255 + // The one thing in the nav that is a permission rather than a
256 + // session.
257 + chrome = chrome.offering(Place::new(
258 + "admin",
259 + "Admin",
260 + Action::get("/admin/waitlist").navigating(),
261 + ));
262 + }
263 + // A write, so it is emitted as an htmx post rather than an anchor.
264 + // The token rides on the `X-CSRF-Token` header that
265 + // `frontend/src/core/htmx-glue.ts` attaches to every request from
266 + // the `csrf-token` meta, which is why this no longer takes one: the
267 + // hidden `_csrf` input existed because the logout was a form, and
268 + // it is not a form any more.
269 + chrome = chrome.offering(Place::new("logout", "Log Out", Action::post("/logout")));
270 + }
271 + None => {
272 + for (key, label, route) in [
273 + ("use-cases", "Use Cases", "/use-cases"),
274 + ("docs", "Docs", "/docs"),
275 + ("fan-plus", "Fan+", "/fan-plus"),
276 + ("login", "Login", "/login"),
277 + ("join", "Join", "/join"),
278 + ] {
279 + chrome = chrome.offering(Place::new(key, label, Action::get(route).navigating()));
280 + }
281 + }
282 + }
283 +
284 + chrome
285 + }
286 +
183 287 /// The site header, as every page on this site carries it.
184 288 ///
185 289 /// Called by `partials/site_header.html` for the templated pages and handed to
186 290 /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own
187 - /// document, so the nav is written once. [`body_last`]'s argument one element
188 - /// up.
291 + /// document, so the header is written once. [`body_last`]'s argument one
292 + /// element up.
189 293 ///
190 - /// Not a `Chrome`. `Chrome::nav` is emitted before `<main>` and a
191 - /// `Chrome::panel` after `</main>`, and this header's mobile menu is a checkbox
192 - /// styling its siblings (`style.css`, `.nav-toggle-checkbox:checked ~ .header-search`
193 - /// and `~ nav`), so the two halves have to be one element. Wiki note
194 - /// `mnw-server-conversion-plan`, the 2026-08-31 ruling.
294 + /// The markup is the renderer's now: this builds [`header_chrome`] and
295 + /// `quasi_webview::chrome::header_html` writes it. That is the whole of
296 + /// `c7b0d3c1` -- one description, three renderers, and no markup in the
297 + /// assembly layer.
195 298 ///
196 - /// Nothing a reader wrote is emitted here: the only per-request values are
197 - /// `user.is_admin` and the session's CSRF token, and the token is escaped
198 - /// anyway rather than trusted for being ours.
299 + /// # It no longer takes the CSRF token
300 + ///
301 + /// It took one while the logout was a `<form>` carrying a hidden `_csrf`. The
302 + /// logout is a [`quasi_router::Place`] now, emitted as an htmx post, and
303 + /// `frontend/src/core/htmx-glue.ts` attaches the token to every htmx request
304 + /// from the `csrf-token` meta the document already carries. A second copy in
305 + /// the markup would be a second thing that can go stale against a token that
306 + /// rotates mid-session.
307 + ///
308 + /// Nothing marks a current place. The templated pages do not know which place
309 + /// they are, and a described screen says it with
310 + /// [`quasi_router::Screen::place`] on the path this function is not on.
199 311 #[must_use]
200 - pub fn site_header(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> String {
201 - let mut out = String::with_capacity(1600);
202 - out.push_str(concat!(
203 - "<header class=\"site-header\" role=\"banner\">",
204 - "<a href=\"/\" class=\"site-logo\" aria-label=\"Makenotwork - Go to homepage\">",
205 - "Makenot<span class=\"dot\" aria-hidden=\"true\">.</span>work</a>",
206 - "<input type=\"checkbox\" id=\"nav-toggle\" class=\"nav-toggle-checkbox\" aria-hidden=\"true\">",
207 - "<label for=\"nav-toggle\" class=\"nav-toggle-label\" aria-label=\"Toggle navigation menu\">",
208 - "<span></span><span></span><span></span></label>",
209 - "<form action=\"/discover\" method=\"get\" class=\"header-search\" role=\"search\">",
210 - "<input type=\"search\" name=\"q\" id=\"header-search-input\" ",
211 - "placeholder=\"Search... (Cmd+K)\" autocomplete=\"off\" ",
212 - "aria-label=\"Search items and projects\"></form>",
213 - "<nav aria-label=\"Main navigation\"><div class=\"nav-links\">",
214 - "<a href=\"/discover\">Discover</a>",
215 - ));
216 - if let Some(user) = user {
217 - out.push_str(concat!(
218 - "<a href=\"/library\">Library</a>",
219 - "<a href=\"/dashboard\">Dashboard</a>",
220 - "<a href=\"/cart\" id=\"nav-cart-link\" class=\"hidden\">Cart",
221 - "<span id=\"cart-badge\" class=\"cart-badge-count\"></span></a>",
222 - ));
223 - if user.is_admin {
224 - out.push_str("<a href=\"/admin/waitlist\">Admin</a>");
225 - }
226 - out.push_str("<form action=\"/logout\" method=\"post\" class=\"nav-form\">");
227 - if let Some(token) = csrf {
228 - out.push_str("<input type=\"hidden\" name=\"_csrf\" value=\"");
229 - out.push_str(&crate::helpers::escape_html(token));
230 - out.push_str("\">");
231 - }
232 - out.push_str(concat!(
233 - "<button type=\"submit\" class=\"btn--link\" aria-label=\"Log out\">Log Out</button>",
234 - "</form>",
235 - ));
236 - } else {
237 - out.push_str(concat!(
238 - "<a href=\"/use-cases\">Use Cases</a>",
239 - "<a href=\"/docs\">Docs</a>",
240 - "<a href=\"/fan-plus\">Fan+</a>",
241 - "<a href=\"/login\">Login</a>",
242 - "<a href=\"/join\">Join</a>",
243 - ));
244 - }
245 - out.push_str("</div></nav></header>");
246 - out
312 + pub fn site_header(user: Option<&crate::auth::SessionUser>) -> String {
313 + quasi_webview::chrome::header_html(
314 + &header_chrome(user),
315 + None,
316 + &makeover_webview::Emit::default(),
317 + )
247 318 }
248 319
249 320 /// `<!doctype>` through the head's contents, without `</head>`.
@@ -397,7 +468,7 @@
397 468 /// A reader with no session is offered the two ways in and no way out.
398 469 #[test]
399 470 fn the_signed_out_header_offers_the_ways_in() {
400 - let html = site_header(None, None);
471 + let html = site_header(None);
401 472
402 473 assert!(html.contains("href=\"/login\""), "{html}");
403 474 assert!(html.contains("href=\"/join\""), "{html}");
@@ -406,17 +477,21 @@
406 477 assert!(!html.contains("/dashboard"), "{html}");
407 478 }
408 479
409 - /// A signed-in reader gets the account nav, the logout form and the token
410 - /// that form needs. A logout with no `_csrf` is a logout the envelope
411 - /// refuses.
480 + /// A signed-in reader gets the account nav and a way out.
481 + ///
482 + /// The logout was a `<form>` with a hidden `_csrf` until `c7b0d3c1`. It is
483 + /// a `Place` now, so it is an htmx post and the token rides on the header
484 + /// `htmx-glue.ts` attaches from the `csrf-token` meta. What this asserts is
485 + /// that it is still a write: a logout emitted as an anchor would be a
486 + /// logout a link prefetcher can perform.
412 487 #[test]
413 - fn the_signed_in_header_carries_the_logout_form_and_its_token() {
414 - let html = site_header(Some(&a_user(false)), Some("tok&en"));
488 + fn the_signed_in_header_carries_a_logout_that_writes() {
489 + let html = site_header(Some(&a_user(false)));
415 490
416 - assert!(html.contains("action=\"/logout\""), "{html}");
491 + assert!(html.contains("hx-post=\"/logout\""), "{html}");
417 492 assert!(
418 - html.contains("name=\"_csrf\" value=\"tok&amp;en\""),
419 - "{html}"
493 + !html.contains("href=\"/logout\""),
494 + "a prefetch logs out: {html}"
420 495 );
421 496 assert!(html.contains("href=\"/library\""), "{html}");
422 497 assert!(html.contains("href=\"/dashboard\""), "{html}");
@@ -427,24 +502,41 @@
427 502 /// than a session.
428 503 #[test]
429 504 fn only_an_admin_is_offered_the_admin_link() {
430 - assert!(site_header(Some(&a_user(true)), Some("t")).contains("/admin/waitlist"));
431 - assert!(!site_header(Some(&a_user(false)), Some("t")).contains("/admin/waitlist"));
505 + assert!(site_header(Some(&a_user(true))).contains("/admin/waitlist"));
506 + assert!(!site_header(Some(&a_user(false))).contains("/admin/waitlist"));
432 507 }
433 508
434 509 /// The mobile menu is a checkbox styling its siblings, which is the whole
435 - /// reason this header is one element rather than a `Chrome`. The three have
436 - /// to stay siblings inside `<header>`, in this order.
510 + /// reason this header is one element. The three have to stay siblings
511 + /// inside `<header>`, in this order, and it is the renderer that keeps them
512 + /// there now: `Chrome::band` is the member that made saying so possible.
437 513 #[test]
438 514 fn the_disclosure_stays_a_sibling_of_what_it_reveals() {
439 - let html = site_header(None, None);
515 + let html = site_header(None);
440 516
441 - let toggle = html.find("nav-toggle-checkbox").expect("the checkbox");
442 - let search = html.find("header-search").expect("the search form");
517 + let toggle = html.find("chrome-disclose-state").expect("the checkbox");
518 + let search = html.find("chrome-search").expect("the search box");
443 519 let nav = html.find("<nav").expect("the nav");
444 520 assert!(toggle < search && search < nav, "{html}");
445 521 assert!(html.ends_with("</header>"), "{html}");
446 522 }
447 523
524 + /// The wordmark comes apart at its dot, and is read whole.
525 + ///
526 + /// The hand-written header marked the dot `aria-hidden`, which left the
527 + /// accessible name as "Makenotwork" -- not something the site is called.
528 + /// `Brand` says the name and which run of it is the mark, and the renderer
529 + /// draws both.
530 + #[test]
531 + fn the_header_wordmark_is_marked_and_still_reads_whole() {
532 + let html = site_header(None);
533 +
534 + assert!(
535 + html.contains(">Makenot<span class=\"chrome-brand-mark\">.</span>work</a>"),
536 + "{html}"
537 + );
538 + }
539 +
448 540 fn a_user(is_admin: bool) -> crate::auth::SessionUser {
449 541 crate::auth::SessionUser {
450 542 id: crate::db::UserId::new(),
@@ -492,7 +584,6 @@
492 584 // Matching the header's logo, which has always done this: a reader
493 585 // hears the name rather than the punctuation inside it.
494 586 assert!(wordmark().contains(r#"<span class="dot" aria-hidden="true">"#));
495 - assert!(site_header(None, None).contains(r#"class="dot" aria-hidden="true""#));
496 587 }
497 588
498 589 #[test]
@@ -2994,7 +2994,7 @@
2994 2994
2995 2995 @media print {
2996 2996 .receipt-page .back-link,
2997 - .receipt-page .site-header,
2997 + .receipt-page .chrome-band,
2998 2998 .receipt-page .print-btn { display: none !important; }
2999 2999 .receipt-page .receipt-box { border: 1px solid var(--border); }
3000 3000 }
@@ -4577,8 +4577,12 @@
4577 4577 text-decoration: underline;
4578 4578 }
4579 4579
4580 - /* Site-wide header */
4581 - .site-header {
4580 + /* Site-wide header.
4581 + The class names are quasi-webview's, not this stylesheet's: the header is a
4582 + `quasi_router::Chrome::band` and `crate::shell::site_header` builds the
4583 + description rather than the markup. A rule here has to match what that
4584 + renderer emits, and `quasi_webview::vocabulary` is the list of what it may. */
4585 + .chrome-band {
4582 4586 display: flex;
4583 4587 justify-content: space-between;
4584 4588 align-items: center;
@@ -4586,64 +4590,111 @@
4586 4590 margin-bottom: var(--gap-pane);
4587 4591 }
4588 4592
4589 - .site-header nav {
4593 + .chrome-band .chrome-nav {
4590 4594 margin: 0;
4591 4595 }
4592 4596
4593 - .site-header .nav-links {
4597 + /* The places are a `ul` of `li`, which is what a nav of addresses is. The
4598 + markers and the indent go; the row is the layout. */
4599 + .chrome-nav ul {
4600 + display: flex;
4594 4601 gap: var(--gap-pane);
4602 + flex-wrap: wrap;
4603 + list-style: none;
4604 + margin: 0;
4605 + padding: 0;
4595 4606 }
4596 4607
4597 - .site-logo {
4608 + .chrome-place {
4609 + color: var(--content);
4610 + text-decoration: none;
4611 + font-family: var(--font-mono);
4612 + cursor: pointer;
4613 + transition: opacity 0.2s ease;
4614 + }
4615 +
4616 + .chrome-place:hover {
4617 + opacity: 0.6;
4618 + }
4619 +
4620 + /* The cart place is offered on every signed-in page and drawn only when the
4621 + cart holds something. How many items that is is not known when the document
4622 + is rendered, so `core/cart-badge.js` asks `/api/cart/count` and writes the
4623 + answer onto the place as `data-count`; the presence is that answer, rather
4624 + than a class the markup ships hidden and a script takes off.
4625 + The `li` is what hides, not the anchor: an empty item in a flex row still
4626 + pays its gap. */
4627 + .chrome-nav li:has(> [data-place="cart"]) {
4628 + display: none;
4629 + }
4630 +
4631 + .chrome-nav li:has(> [data-place="cart"][data-count]) {
4632 + display: block;
4633 + }
4634 +
4635 + .chrome-brand {
4598 4636 font-family: var(--font-display);
4599 4637 font-size: var(--text-subhead);
4600 4638 text-decoration: none;
4601 4639 color: var(--content);
4602 4640 }
4603 4641
4604 - .site-logo:hover {
4642 + .chrome-brand:hover {
4605 4643 opacity: 0.8;
4606 4644 }
4607 4645
4608 - .nav-form {
4609 - display: contents;
4610 - }
4611 -
4612 4646 /* Header search */
4613 - .header-search {
4647 + .chrome-search {
4614 4648 flex: 0 1 220px;
4615 4649 margin: 0 var(--gap-section);
4616 4650 }
4617 4651
4618 - .header-search input {
4652 + /* The box carries a real `<label>` now, where the hand-written header carried
4653 + an `aria-label`. Hidden rather than dropped: the same name is read out, and a
4654 + description that stopped naming its own question would be a worse
4655 + description for the sake of a stylesheet. */
4656 + .chrome-search .form-label {
4657 + position: absolute;
4658 + width: 1px;
4659 + height: 1px;
4660 + padding: 0;
4661 + margin: -1px;
4662 + overflow: hidden;
4663 + clip: rect(0, 0, 0, 0);
4664 + white-space: nowrap;
4665 + border: 0;
4666 + }
4667 +
4668 + .chrome-search .form-group {
4669 + margin: 0;
4670 + }
4671 +
4672 + /* Only what makeover does not already say about a `.field`. The surface, the
4673 + text colour and the focus ring are the design system's, and the drift check
4674 + in `build.rs` fails the build for restating one of them here. The old
4675 + `.header-search input` rules did restate all four; they were written before
4676 + the box was a described field. */
4677 + .chrome-search .field {
4619 4678 width: 100%;
4620 - padding: var(--gap-peer) var(--gap-group);
4621 4679 font-size: var(--text-note);
4622 4680 font-family: var(--font-mono);
4623 - border: 1px solid var(--border);
4624 - background: var(--surface-page);
4625 - color: var(--content);
4626 - border-radius: var(--radius-control);
4627 4681 }
4628 4682
4629 - .header-search input:focus {
4630 - outline: 2px solid var(--focus-ring);
4631 - outline-offset: -1px;
4632 - }
4633 -
4634 - /* Hamburger toggle, hidden by default, shown at 768px */
4635 - .nav-toggle-checkbox {
4683 + /* The disclosure, hidden by default and shown at 768px. The checkbox holds the
4684 + state and the label is the control: `Disclose::Narrow` says the places are
4685 + worth hiding when there is no room, and this is the browser's answer to it. */
4686 + .chrome-disclose-state {
4636 4687 display: none;
4637 4688 }
4638 4689
4639 - .nav-toggle-label {
4690 + .chrome-disclose {
4640 4691 display: none;
4641 4692 cursor: pointer;
4642 4693 padding: var(--gap-peer);
4643 4694 z-index: var(--z-header);
4644 4695 }
4645 4696
4646 - .nav-toggle-label span {
4697 + .chrome-disclose span {
4647 4698 display: block;
4648 4699 width: 22px;
4649 4700 height: 2px;
@@ -7229,19 +7280,21 @@
7229 7280 grid-template-columns: 1fr;
7230 7281 }
7231 7282
7232 - /* Hamburger menu */
7233 - .nav-toggle-label {
7283 + /* The disclosure. `~` reaches siblings and only forward, which is the whole
7284 + reason the header had to become one element: the checkbox, the search box
7285 + and the nav are inside `<header class="chrome-band">` and nothing else. */
7286 + .chrome-disclose {
7234 7287 display: block;
7235 7288 }
7236 7289
7237 - .header-search {
7290 + .chrome-search {
7238 7291 display: none;
7239 7292 flex: none;
7240 7293 margin: 0;
7241 7294 width: 100%;
7242 7295 }
7243 7296
7244 - .nav-toggle-checkbox:checked ~ .header-search {
7297 + .chrome-disclose-state:checked ~ .chrome-search {
7245 7298 display: block;
7246 7299 position: absolute;
7247 7300 top: 100%;
@@ -7253,7 +7306,7 @@
7253 7306 z-index: var(--z-header);
7254 7307 }
7255 7308
7256 - .site-header nav {
7309 + .chrome-band .chrome-nav {
7257 7310 display: none;
7258 7311 position: absolute;
7259 7312 top: 100%;
@@ -7265,29 +7318,29 @@
7265 7318 z-index: var(--z-nav);
7266 7319 }
7267 7320
7268 - .site-header {
7321 + .chrome-band {
7269 7322 position: relative;
7270 7323 }
7271 7324
7272 - .nav-toggle-checkbox:checked ~ nav {
7325 + .chrome-disclose-state:checked ~ .chrome-nav {
7273 7326 display: block;
7274 7327 }
7275 7328
7276 - .site-header .nav-links {
7329 + .chrome-band .chrome-nav ul {
7277 7330 flex-direction: column;
7278 7331 gap: var(--gap-section);
7279 7332 }
7280 7333
7281 - /* Animate hamburger to X when open */
7282 - .nav-toggle-checkbox:checked ~ .nav-toggle-label span:nth-child(1) {
7334 + /* Animate the bars to an X when open */
7335 + .chrome-disclose-state:checked ~ .chrome-disclose span:nth-child(1) {
7283 7336 transform: rotate(45deg) translate(5px, 5px);
7284 7337 }
7285 7338
7286 - .nav-toggle-checkbox:checked ~ .nav-toggle-label span:nth-child(2) {
7339 + .chrome-disclose-state:checked ~ .chrome-disclose span:nth-child(2) {
7287 7340 opacity: 0;
7288 7341 }
7289 7342
7290 - .nav-toggle-checkbox:checked ~ .nav-toggle-label span:nth-child(3) {
7343 + .chrome-disclose-state:checked ~ .chrome-disclose span:nth-child(3) {
7291 7344 transform: rotate(-45deg) translate(5px, -5px);
7292 7345 }
7293 7346 }
@@ -251,7 +251,7 @@
251 251 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
252 252 "{}{}",
253 253 crate::shell::skip_link(PAGE_REGION),
254 - crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
254 + crate::shell::site_header(viewer.user.as_ref()),
255 255 )))
256 256 }
257 257
@@ -247,7 +247,7 @@
247 247 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
248 248 "{}{}",
249 249 crate::shell::skip_link(PAGE_REGION),
250 - crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
250 + crate::shell::site_header(viewer.user.as_ref()),
251 251 )))
252 252 }
253 253
@@ -253,7 +253,7 @@
253 253 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
254 254 "{}{}",
255 255 crate::shell::skip_link(PAGE_REGION),
256 - crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
256 + crate::shell::site_header(viewer.user.as_ref()),
257 257 )))
258 258 }
259 259
@@ -223,7 +223,7 @@
223 223 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
224 224 "{}{}",
225 225 crate::shell::skip_link(PAGE_REGION),
226 - crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
226 + crate::shell::site_header(viewer.user.as_ref()),
227 227 )))
228 228 }
229 229
@@ -283,7 +283,7 @@
283 283 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
284 284 "{}{}",
285 285 crate::shell::skip_link(PAGE_REGION),
286 - crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
286 + crate::shell::site_header(viewer.user.as_ref()),
287 287 )))
288 288 }
289 289
@@ -197,7 +197,7 @@
197 197 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
198 198 "{}{}",
199 199 crate::shell::skip_link(PAGE_REGION),
200 - crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
200 + crate::shell::site_header(viewer.user.as_ref()),
201 201 )))
202 202 }
203 203