max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
18 files changed,
+335 insertions,
-166 deletions
| @@ -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"] } |
| @@ -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&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 |