max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+298 insertions,
-64 deletions
| @@ -5196,7 +5196,7 @@ | |||
| 5196 | 5196 | ||
| 5197 | 5197 | [[package]] | |
| 5198 | 5198 | name = "makenotwork" | |
| 5199 | - | version = "0.11.18" | |
| 5199 | + | version = "0.11.19" | |
| 5200 | 5200 | dependencies = [ | |
| 5201 | 5201 | "ammonia", | |
| 5202 | 5202 | "anyhow", | |
| @@ -5316,9 +5316,9 @@ | |||
| 5316 | 5316 | ||
| 5317 | 5317 | [[package]] | |
| 5318 | 5318 | name = "makeover-build" | |
| 5319 | - | version = "0.30.0" | |
| 5319 | + | version = "0.31.0" | |
| 5320 | 5320 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5321 | - | checksum = "86bea7fceaf452e085eea35efd010573c48f370655645dc3d52c805761421c37" | |
| 5321 | + | checksum = "02f630f22ab60b1f73e45956fc6ff26c31277edbee2aad523200ec96974e5a5a" | |
| 5322 | 5322 | dependencies = [ | |
| 5323 | 5323 | "makeover", | |
| 5324 | 5324 | "makeover-geometry", | |
| @@ -5333,15 +5333,15 @@ | |||
| 5333 | 5333 | ||
| 5334 | 5334 | [[package]] | |
| 5335 | 5335 | name = "makeover-layout" | |
| 5336 | - | version = "0.21.0" | |
| 5336 | + | version = "0.22.0" | |
| 5337 | 5337 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5338 | - | checksum = "cd297f0832ba18e17409931a57d8c2272cbae5a5d57cf3370899b23af32f15e0" | |
| 5338 | + | checksum = "58dad575602dd23c8f69297e9ef774be84e4c6e4c58a54892448175cb6a096da" | |
| 5339 | 5339 | ||
| 5340 | 5340 | [[package]] | |
| 5341 | 5341 | name = "makeover-touch" | |
| 5342 | - | version = "0.13.0" | |
| 5342 | + | version = "0.14.0" | |
| 5343 | 5343 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5344 | - | checksum = "694ed7bf31697afd8fcfe85d68d5dcba5d96f10ab13d8cbf52791d48d3acb75c" | |
| 5344 | + | checksum = "2d6241ec48fe2dea0d1051b1bfa3c6213bf0e7a75731153c20ca91c501953f24" | |
| 5345 | 5345 | dependencies = [ | |
| 5346 | 5346 | "makeover-geometry", | |
| 5347 | 5347 | "makeover-layout", | |
| @@ -5349,9 +5349,9 @@ | |||
| 5349 | 5349 | ||
| 5350 | 5350 | [[package]] | |
| 5351 | 5351 | name = "makeover-webview" | |
| 5352 | - | version = "0.37.0" | |
| 5352 | + | version = "0.38.0" | |
| 5353 | 5353 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5354 | - | checksum = "678efbe9ea9634b446eff14e8b43c033a186195dd70004ef166c1684fa02aba9" | |
| 5354 | + | checksum = "cf73eb60125c7ae695cfce2678b2338f22101ae5c835a080a599a9d52b595f18" | |
| 5355 | 5355 | dependencies = [ | |
| 5356 | 5356 | "makeover-geometry", | |
| 5357 | 5357 | "makeover-layout", | |
| @@ -6549,7 +6549,7 @@ | |||
| 6549 | 6549 | ||
| 6550 | 6550 | [[package]] | |
| 6551 | 6551 | name = "quasi-axum" | |
| 6552 | - | version = "0.3.0" | |
| 6552 | + | version = "0.4.0" | |
| 6553 | 6553 | dependencies = [ | |
| 6554 | 6554 | "axum", | |
| 6555 | 6555 | "http 1.4.2", | |
| @@ -6560,7 +6560,7 @@ | |||
| 6560 | 6560 | ||
| 6561 | 6561 | [[package]] | |
| 6562 | 6562 | name = "quasi-http" | |
| 6563 | - | version = "0.3.0" | |
| 6563 | + | version = "0.4.0" | |
| 6564 | 6564 | dependencies = [ | |
| 6565 | 6565 | "form_urlencoded", | |
| 6566 | 6566 | "http 1.4.2", | |
| @@ -6569,14 +6569,14 @@ | |||
| 6569 | 6569 | ||
| 6570 | 6570 | [[package]] | |
| 6571 | 6571 | name = "quasi-router" | |
| 6572 | - | version = "0.3.0" | |
| 6572 | + | version = "0.4.0" | |
| 6573 | 6573 | dependencies = [ | |
| 6574 | 6574 | "makeover-layout", | |
| 6575 | 6575 | ] | |
| 6576 | 6576 | ||
| 6577 | 6577 | [[package]] | |
| 6578 | 6578 | name = "quasi-webview" | |
| 6579 | - | version = "0.3.0" | |
| 6579 | + | version = "0.4.0" | |
| 6580 | 6580 | dependencies = [ | |
| 6581 | 6581 | "docengine", | |
| 6582 | 6582 | "makeover-layout", | |
| @@ -10687,18 +10687,6 @@ | |||
| 10687 | 10687 | "pkg-config", | |
| 10688 | 10688 | ] | |
| 10689 | 10689 | ||
| 10690 | - | [[patch.unused]] | |
| 10691 | - | name = "quasi-immediate" | |
| 10692 | - | version = "0.3.0" | |
| 10693 | - | ||
| 10694 | - | [[patch.unused]] | |
| 10695 | - | name = "quasi-store" | |
| 10696 | - | version = "0.1.0" | |
| 10697 | - | ||
| 10698 | - | [[patch.unused]] | |
| 10699 | - | name = "quasi-tauri" | |
| 10700 | - | version = "0.3.0" | |
| 10701 | - | ||
| 10702 | 10690 | [[patch.unused]] | |
| 10703 | 10691 | name = "kberg" | |
| 10704 | 10692 | version = "0.1.0" | |
| @@ -10711,6 +10699,18 @@ | |||
| 10711 | 10699 | name = "painhours" | |
| 10712 | 10700 | version = "0.1.0" | |
| 10713 | 10701 | ||
| 10702 | + | [[patch.unused]] | |
| 10703 | + | name = "quasi-immediate" | |
| 10704 | + | version = "0.4.0" | |
| 10705 | + | ||
| 10706 | + | [[patch.unused]] | |
| 10707 | + | name = "quasi-store" | |
| 10708 | + | version = "0.1.0" | |
| 10709 | + | ||
| 10710 | + | [[patch.unused]] | |
| 10711 | + | name = "quasi-tauri" | |
| 10712 | + | version = "0.4.0" | |
| 10713 | + | ||
| 10714 | 10714 | [[patch.unused]] | |
| 10715 | 10715 | name = "synckit-client" | |
| 10716 | 10716 | version = "0.8.0" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.11.18" | |
| 3 | + | version = "0.11.19" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "LicenseRef-PolyForm-Noncommercial-1.0.0" | |
| 6 | 6 | # Server binary: never published to a registry. Marks the crate private so | |
| @@ -158,17 +158,17 @@ | |||
| 158 | 158 | # through quasi beside its Askama equivalent, so the cost of describing a screen | |
| 159 | 159 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 160 | 160 | # See wiki look-wave-2, tier G. | |
| 161 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.3" } | |
| 161 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" } | |
| 162 | 162 | # The description vocabulary quasi's screen types are built from. Pinned here | |
| 163 | 163 | # rather than reached through quasi-router's re-export because a described | |
| 164 | 164 | # screen names FieldKind and Tone directly; it has to track what quasi-router | |
| 165 | 165 | # resolves or the two `layout::` paths are different crates. | |
| 166 | - | makeover-layout = "0.21.0" | |
| 166 | + | makeover-layout = "0.22.0" | |
| 167 | 167 | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 168 | 168 | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 169 | 169 | http = "1.3.1" | |
| 170 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.3" } | |
| 171 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.3" } | |
| 170 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" } | |
| 171 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" } | |
| 172 | 172 | ||
| 173 | 173 | # Git source browser | |
| 174 | 174 | gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] } | |
| @@ -240,7 +240,7 @@ | |||
| 240 | 240 | # Emits static/geometry.css (makeover-geometry) and static/layout.css | |
| 241 | 241 | # (makeover-webview). The same generator GO and BB run; only the output paths | |
| 242 | 242 | # differ, since the server serves its stylesheets rather than bundling them. | |
| 243 | - | makeover-build = "0.30.0" | |
| 243 | + | makeover-build = "0.31.0" | |
| 244 | 244 | # Read directly for the embeds' own copy of the spacing layer, which they need | |
| 245 | 245 | # because an iframe cannot link a stylesheet. makeover-build does not re-export | |
| 246 | 246 | # it, so the pin lives here and has to track the one makeover-build resolves. |
| @@ -1,4 +1,5 @@ | |||
| 1 | 1 | use std::collections::hash_map::DefaultHasher; | |
| 2 | + | use std::fmt::Write as _; | |
| 2 | 3 | use std::hash::{Hash, Hasher}; | |
| 3 | 4 | use std::process::Command; | |
| 4 | 5 | use std::{fs, path::Path}; | |
| @@ -48,6 +49,24 @@ | |||
| 48 | 49 | ||
| 49 | 50 | println!("cargo::rerun-if-changed=build.rs"); | |
| 50 | 51 | ||
| 52 | + | // The landing shots' own dimensions, so the description can reserve their | |
| 53 | + | // space. Generated rather than written down; see `shot_dimensions`. | |
| 54 | + | let shots = shot_dimensions(); | |
| 55 | + | let mut table = String::from( | |
| 56 | + | "/// Intrinsic size of each landing screenshot, by URL path.\n\ | |
| 57 | + | /// Generated by build.rs from the files themselves.\n\ | |
| 58 | + | pub static SHOT_DIMENSIONS: &[(&str, u32, u32)] = &[\n", | |
| 59 | + | ); | |
| 60 | + | for (path, w, h) in &shots { | |
| 61 | + | let _ = writeln!(table, " ({path:?}, {w}, {h}),"); | |
| 62 | + | } | |
| 63 | + | table.push_str("];\n"); | |
| 64 | + | fs::write( | |
| 65 | + | Path::new(&std::env::var("OUT_DIR").expect("OUT_DIR")).join("shot_dimensions.rs"), | |
| 66 | + | table, | |
| 67 | + | ) | |
| 68 | + | .expect("write shot_dimensions.rs"); | |
| 69 | + | ||
| 51 | 70 | // The same check goingson and balanced_breakfast run, out of | |
| 52 | 71 | // makeover-build rather than a third copy of it. The named-list form: | |
| 53 | 72 | // static/ holds the generated stylesheets and a bundler's output beside | |
| @@ -141,6 +160,77 @@ | |||
| 141 | 160 | write_if_changed(Path::new("templates/_sheet.html"), &sheet_partial); | |
| 142 | 161 | } | |
| 143 | 162 | ||
| 163 | + | /// Intrinsic dimensions of the landing screenshots, read from the files. | |
| 164 | + | /// | |
| 165 | + | /// A picture that cannot say how big it is cannot have its space reserved, so | |
| 166 | + | /// the browser gives it none until the bytes land and then takes its full | |
| 167 | + | /// height at once. That was measured at a 478px jump per frame on the landing | |
| 168 | + | /// page and 0.087 CLS for the document (2026-08-14). | |
| 169 | + | /// | |
| 170 | + | /// Read here rather than written down, because these files are regenerated by | |
| 171 | + | /// `scripts/capture-landing-carousel.mjs` and a hand-maintained number would be | |
| 172 | + | /// wrong the first time anyone re-shot them -- silently, since a wrong reserve | |
| 173 | + | /// looks like a right one until the image lands. | |
| 174 | + | /// | |
| 175 | + | /// WebP only, which is what the capture script emits. A file this cannot parse | |
| 176 | + | /// is skipped rather than guessed at: `None` reserves nothing, which is the | |
| 177 | + | /// behaviour before this existed, while a wrong number reserves the wrong room. | |
| 178 | + | fn shot_dimensions() -> Vec<(String, u32, u32)> { | |
| 179 | + | let dir = Path::new("static/images/shots"); | |
| 180 | + | println!("cargo::rerun-if-changed=static/images/shots"); | |
| 181 | + | let mut out = Vec::new(); | |
| 182 | + | let Ok(entries) = fs::read_dir(dir) else { | |
| 183 | + | return out; | |
| 184 | + | }; | |
| 185 | + | for entry in entries.flatten() { | |
| 186 | + | let path = entry.path(); | |
| 187 | + | if path.extension().and_then(|e| e.to_str()) != Some("webp") { | |
| 188 | + | continue; | |
| 189 | + | } | |
| 190 | + | let Ok(bytes) = fs::read(&path) else { continue }; | |
| 191 | + | let Some((w, h)) = webp_dimensions(&bytes) else { | |
| 192 | + | continue; | |
| 193 | + | }; | |
| 194 | + | if let Some(name) = path.file_name().and_then(|n| n.to_str()) { | |
| 195 | + | out.push((format!("/static/images/shots/{name}"), w, h)); | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | out.sort(); | |
| 199 | + | out | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Canvas size from a WebP header, for the three chunk layouts that exist. | |
| 203 | + | /// | |
| 204 | + | /// Header offsets only -- nothing is decoded. `None` for anything unrecognised, | |
| 205 | + | /// which the caller treats as "this picture does not know its size". | |
| 206 | + | fn webp_dimensions(b: &[u8]) -> Option<(u32, u32)> { | |
| 207 | + | if b.len() < 30 || &b[0..4] != b"RIFF" || &b[8..12] != b"WEBP" { | |
| 208 | + | return None; | |
| 209 | + | } | |
| 210 | + | match &b[12..16] { | |
| 211 | + | // Lossy. Three-byte frame tag, then the 3-byte sync code, then two | |
| 212 | + | // 14-bit dimensions. | |
| 213 | + | b"VP8 " => { | |
| 214 | + | let w = u16::from_le_bytes([b[26], b[27]]) & 0x3fff; | |
| 215 | + | let h = u16::from_le_bytes([b[28], b[29]]) & 0x3fff; | |
| 216 | + | Some((u32::from(w), u32::from(h))) | |
| 217 | + | } | |
| 218 | + | // Lossless. One signature byte, then 14 bits of width-1 and 14 of | |
| 219 | + | // height-1 packed into the next four. | |
| 220 | + | b"VP8L" => { | |
| 221 | + | let bits = u32::from_le_bytes([b[21], b[22], b[23], b[24]]); | |
| 222 | + | Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)) | |
| 223 | + | } | |
| 224 | + | // Extended. Canvas size as two 24-bit little-endian minus-ones. | |
| 225 | + | b"VP8X" => { | |
| 226 | + | let w = u32::from_le_bytes([b[24], b[25], b[26], 0]) + 1; | |
| 227 | + | let h = u32::from_le_bytes([b[27], b[28], b[29], 0]) + 1; | |
| 228 | + | Some((w, h)) | |
| 229 | + | } | |
| 230 | + | _ => None, | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 144 | 234 | /// How many generated classes may go unused before the build fails. | |
| 145 | 235 | /// | |
| 146 | 236 | /// One-sided: over this fails, under it warns and asks for the seal to be |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | "license": { | |
| 7 | 7 | "name": "PolyForm Noncommercial 1.0.0" | |
| 8 | 8 | }, | |
| 9 | - | "version": "0.11.18" | |
| 9 | + | "version": "0.11.19" | |
| 10 | 10 | }, | |
| 11 | 11 | "paths": { | |
| 12 | 12 | "/api/git/{owner}/{repo}/notes": { |
| @@ -11543,10 +11543,38 @@ | |||
| 11543 | 11543 | display: block; | |
| 11544 | 11544 | } | |
| 11545 | 11545 | ||
| 11546 | - | /* Unenhanced: every frame, in order, down the page. */ | |
| 11546 | + | /* The default is ONE frame, which is what a visitor with JavaScript sees, so | |
| 11547 | + | the page is settled from first paint and nothing rearranges on load. Showing | |
| 11548 | + | all three and collapsing them was a 141px -> 58px jump on every load that | |
| 11549 | + | everyone with JS paid; `no-js.css`, loaded from a <noscript>, is what opens | |
| 11550 | + | the stack out for a visitor without scripting. | |
| 11551 | + | ||
| 11552 | + | The bottom padding reserves the dots strip. The island injects it after the | |
| 11553 | + | frames, so without a held place its arrival would grow the widget and push | |
| 11554 | + | the page down -- a small shift, but the same kind, and the point of this pass | |
| 11555 | + | is that there are none. Its height: the strip's own 0.6rem plus the gap above | |
| 11556 | + | it. The nav buttons need no reservation; they are absolutely positioned. */ | |
| 11547 | 11557 | [data-widget="carousel"] { | |
| 11548 | - | display: grid; | |
| 11549 | - | gap: var(--gap-section); | |
| 11558 | + | position: relative; | |
| 11559 | + | padding-bottom: calc(0.6rem + var(--gap-group)); | |
| 11560 | + | } | |
| 11561 | + | ||
| 11562 | + | [data-widget="carousel"] > .picture, | |
| 11563 | + | [data-widget="carousel"] > .picture-img { | |
| 11564 | + | /* respec-ok: makeover says a picture is display:block, which is what a | |
| 11565 | + | picture is; this says the frames that are not current are not shown, which | |
| 11566 | + | is what a carousel is. Nothing in the vocabulary knows that one frame of a | |
| 11567 | + | set is current, so the generated sheet cannot express it. The selector | |
| 11568 | + | keeps the claim narrow: it reaches a picture only while it is a frame in a | |
| 11569 | + | carousel. [hidden] is not the way out either, because makeover's rule is an | |
| 11570 | + | author rule and beats the UA stylesheet. */ | |
| 11571 | + | display: none; | |
| 11572 | + | } | |
| 11573 | + | ||
| 11574 | + | [data-widget="carousel"] > .picture:first-child, | |
| 11575 | + | [data-widget="carousel"] > .picture-img:first-child, | |
| 11576 | + | [data-widget="carousel"] > .is-active { | |
| 11577 | + | display: block; | |
| 11550 | 11578 | } | |
| 11551 | 11579 | ||
| 11552 | 11580 | [data-widget="carousel"] > .picture { | |
| @@ -11602,27 +11630,6 @@ | |||
| 11602 | 11630 | [data-ready] once it has upgraded and injected the chrome. | |
| 11603 | 11631 | --------------------------------------------------------------------------- */ | |
| 11604 | 11632 | ||
| 11605 | - | [data-widget="carousel"][data-ready] { | |
| 11606 | - | position: relative; | |
| 11607 | - | display: block; | |
| 11608 | - | } | |
| 11609 | - | ||
| 11610 | - | [data-widget="carousel"][data-ready] > .picture, | |
| 11611 | - | [data-widget="carousel"][data-ready] > .picture-img { | |
| 11612 | - | /* respec-ok: makeover says a picture is display:block, which is what a | |
| 11613 | - | picture is; this says the frames that are not current are not shown, which | |
| 11614 | - | is what a carousel is. Nothing in the vocabulary knows that one frame of a | |
| 11615 | - | set is current, so the generated sheet cannot express it. The selector | |
| 11616 | - | keeps the claim narrow: it reaches a picture only while it is a frame in an | |
| 11617 | - | enhanced carousel. [hidden] is not the way out either, because makeover's | |
| 11618 | - | rule is an author rule and beats the UA stylesheet. */ | |
| 11619 | - | display: none; | |
| 11620 | - | } | |
| 11621 | - | ||
| 11622 | - | [data-widget="carousel"][data-ready] > .is-active { | |
| 11623 | - | display: block; | |
| 11624 | - | } | |
| 11625 | - | ||
| 11626 | 11633 | [data-widget="carousel"][data-ready]:focus-visible { | |
| 11627 | 11634 | outline: 2px solid var(--focus-ring); | |
| 11628 | 11635 | outline-offset: 3px; |
| @@ -26,6 +26,21 @@ | |||
| 26 | 26 | /// but all authenticated pages should include it. | |
| 27 | 27 | pub type CsrfTokenOption = Option<String>; | |
| 28 | 28 | ||
| 29 | + | include!(concat!(env!("OUT_DIR"), "/shot_dimensions.rs")); | |
| 30 | + | ||
| 31 | + | /// The picture's own size, for a static asset this build read off disk. | |
| 32 | + | /// | |
| 33 | + | /// `None` for anything not in the generated table -- a creator's gallery upload, | |
| 34 | + | /// whose dimensions the database never recorded. That is honest: the renderer | |
| 35 | + | /// reserves nothing rather than reserving the wrong thing. | |
| 36 | + | #[must_use] | |
| 37 | + | pub fn shot_size(path: &str) -> Option<(u32, u32)> { | |
| 38 | + | SHOT_DIMENSIONS | |
| 39 | + | .iter() | |
| 40 | + | .find(|(p, _, _)| *p == path) | |
| 41 | + | .map(|&(_, w, h)| (w, h)) | |
| 42 | + | } | |
| 43 | + | ||
| 29 | 44 | /// One frame of the composable click-through carousel (`partials/carousel.html`). | |
| 30 | 45 | /// | |
| 31 | 46 | /// A carousel is just an ordered `&[CarouselFrame]`; the same macro renders it | |
| @@ -46,6 +61,16 @@ | |||
| 46 | 61 | pub alt: String, | |
| 47 | 62 | /// Optional caption shown under the frame. | |
| 48 | 63 | pub caption: Option<String>, | |
| 64 | + | /// The image's own pixel dimensions, where this build could learn them. | |
| 65 | + | /// | |
| 66 | + | /// Static shots get theirs read off disk by build.rs. A creator's gallery | |
| 67 | + | /// upload gets `None`, because `gallery_images` records a byte count and | |
| 68 | + | /// never recorded a size. | |
| 69 | + | /// | |
| 70 | + | /// What it buys: the renderer writes `width`/`height`, the browser holds | |
| 71 | + | /// the frame's place from first paint, and nothing below the carousel moves | |
| 72 | + | /// when the picture lands. | |
| 73 | + | pub intrinsic: Option<(u32, u32)>, | |
| 49 | 74 | } | |
| 50 | 75 | ||
| 51 | 76 | impl CarouselFrame { | |
| @@ -70,6 +95,11 @@ | |||
| 70 | 95 | human description of what the screenshot shows instead" | |
| 71 | 96 | ); | |
| 72 | 97 | Self { | |
| 98 | + | // Looked up rather than passed, so every caller that names a static | |
| 99 | + | // shot reserves its space without having to know it did. A path the | |
| 100 | + | // table does not carry -- a creator upload -- stays None, which is | |
| 101 | + | // the honest answer and not a fallback. | |
| 102 | + | intrinsic: shot_size(&image), | |
| 73 | 103 | image, | |
| 74 | 104 | alt, | |
| 75 | 105 | caption: None, |
| @@ -25,13 +25,19 @@ | |||
| 25 | 25 | - The frame's edge is a bevel from makeover, not a border and an rgba() | |
| 26 | 26 | shadow written by hand here. In-flow depth is a bevel; elevation is for | |
| 27 | 27 | what sits over the page. | |
| 28 | - | - Progressive enhancement runs the other way. This used to render frame one | |
| 29 | - | and leave the other frames unreachable without JS, because the controls | |
| 30 | - | that reach them are the scripted part. Now the unenhanced page is the | |
| 31 | - | whole gallery in order, and the script's job is to collapse it to one at a | |
| 32 | - | time. It degrades to more content rather than less. | |
| 28 | + | - Progressive enhancement runs the other way, and costs nothing to do it. | |
| 29 | + | This used to render frame one and leave the other frames unreachable | |
| 30 | + | without JS, because the controls that reach them are the scripted part. | |
| 31 | + | ||
| 32 | + | The description now carries the whole gallery, but the DEFAULT rendering | |
| 33 | + | shows one frame, so a visitor with JavaScript sees a settled page from | |
| 34 | + | first paint. `no-js.css`, loaded from the <noscript> below, is what opens | |
| 35 | + | the stack out for a visitor without it. Both properties, no layout shift: | |
| 36 | + | collapsing three frames to one after load was a visible jump that everyone | |
| 37 | + | paid so that a few could reach frames two and three. | |
| 33 | 38 | #} | |
| 34 | 39 | ||
| 35 | 40 | {% macro carousel(id, frames) -%} | |
| 41 | + | <noscript><link rel="stylesheet" href="/static/no-js.css"></noscript> | |
| 36 | 42 | <mnw-carousel>{{ crate::quasi::widgets::carousel::html(id, frames)|safe }}</mnw-carousel> | |
| 37 | 43 | {%- endmacro %} |
| @@ -60,6 +60,12 @@ | |||
| 60 | 60 | pub alt: String, | |
| 61 | 61 | /// A visible line under it, where there is one. | |
| 62 | 62 | pub caption: Option<String>, | |
| 63 | + | /// The picture's own dimensions, where the caller knows them. | |
| 64 | + | /// | |
| 65 | + | /// What lets the renderer hold the frame's place from first paint. Without | |
| 66 | + | /// it the frame occupies nothing until the bytes land and then takes its | |
| 67 | + | /// full height at once, which measured as a 478px jump on the landing page. | |
| 68 | + | pub intrinsic: Option<(u32, u32)>, | |
| 63 | 69 | } | |
| 64 | 70 | ||
| 65 | 71 | impl Frame { | |
| @@ -69,9 +75,17 @@ | |||
| 69 | 75 | src: src.into(), | |
| 70 | 76 | alt: alt.into(), | |
| 71 | 77 | caption: None, | |
| 78 | + | intrinsic: None, | |
| 72 | 79 | } | |
| 73 | 80 | } | |
| 74 | 81 | ||
| 82 | + | /// The picture's own dimensions. | |
| 83 | + | #[must_use] | |
| 84 | + | pub const fn intrinsic(mut self, width: u32, height: u32) -> Self { | |
| 85 | + | self.intrinsic = Some((width, height)); | |
| 86 | + | self | |
| 87 | + | } | |
| 88 | + | ||
| 75 | 89 | /// A visible line under it. | |
| 76 | 90 | #[must_use] | |
| 77 | 91 | pub fn caption(mut self, caption: impl Into<String>) -> Self { | |
| @@ -86,12 +100,28 @@ | |||
| 86 | 100 | /// having on its own: a gallery that does not page is this without the widget | |
| 87 | 101 | /// name around it, and that is the second consumer this module expects. | |
| 88 | 102 | pub fn frames(frames: impl IntoIterator<Item = Frame>) -> impl Iterator<Item = Node> { | |
| 89 | - | frames.into_iter().map(|frame| { | |
| 103 | + | frames.into_iter().enumerate().map(|(i, frame)| { | |
| 90 | 104 | // Natural, and it is the whole reason `Fit` has three members rather | |
| 91 | 105 | // than the one MNW uses at 15 of its 17 other sites. A screenshot | |
| 92 | 106 | // cropped to fill its box is a screenshot with its edges cut off, and | |
| 93 | 107 | // the edges of a screenshot of an interface are where the interface is. | |
| 94 | - | let picture = Picture::new(frame.src, frame.alt).fit(Fit::Natural); | |
| 108 | + | let mut picture = Picture::new(frame.src, frame.alt).fit(Fit::Natural); | |
| 109 | + | if let Some((w, h)) = frame.intrinsic { | |
| 110 | + | picture = picture.intrinsic(w, h); | |
| 111 | + | } | |
| 112 | + | // The first frame is the one on screen, and the rest are not. Eager for | |
| 113 | + | // the one, lazy for the others -- which is the case that proves loading | |
| 114 | + | // cannot be a single setting the renderer picks: both answers are | |
| 115 | + | // correct, in one widget, at one moment. | |
| 116 | + | // | |
| 117 | + | // Getting this backwards is what the old partial did by lazily loading | |
| 118 | + | // all three, including the one the visitor was already looking at. That | |
| 119 | + | // delays the only picture that matters and buys nothing, because the | |
| 120 | + | // other two are display:none and were never going to be fetched early | |
| 121 | + | // anyway. | |
| 122 | + | if i > 0 { | |
| 123 | + | picture = picture.lazy(); | |
| 124 | + | } | |
| 95 | 125 | Node::Image(match frame.caption { | |
| 96 | 126 | Some(caption) => picture.caption(caption), | |
| 97 | 127 | None => picture, | |
| @@ -119,7 +149,10 @@ | |||
| 119 | 149 | let node = Node::Region(carousel( | |
| 120 | 150 | id, | |
| 121 | 151 | items.iter().map(|frame| { | |
| 122 | - | let built = Frame::new(&frame.image, &frame.alt); | |
| 152 | + | let mut built = Frame::new(&frame.image, &frame.alt); | |
| 153 | + | if let Some((w, h)) = frame.intrinsic { | |
| 154 | + | built = built.intrinsic(w, h); | |
| 155 | + | } | |
| 123 | 156 | match &frame.caption { | |
| 124 | 157 | Some(caption) => built.caption(caption), | |
| 125 | 158 | None => built, | |
| @@ -185,6 +218,45 @@ | |||
| 185 | 218 | assert!(html.contains("Library</figcaption>"), "{html}"); | |
| 186 | 219 | } | |
| 187 | 220 | ||
| 221 | + | #[test] | |
| 222 | + | fn a_frame_that_knows_its_size_reserves_its_space() { | |
| 223 | + | // The 478px jump this exists to stop: without width/height the browser | |
| 224 | + | // gives the picture no room until the bytes land. | |
| 225 | + | let html = render( | |
| 226 | + | "g", | |
| 227 | + | vec![Frame::new("/a.png", "Alpha").intrinsic(5120, 3412)], | |
| 228 | + | ); | |
| 229 | + | assert!(html.contains(r#"width="5120" height="3412""#), "{html}"); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn a_frame_that_does_not_know_its_size_says_nothing() { | |
| 234 | + | // A creator upload. Reserving the wrong room is worse than none, so an | |
| 235 | + | // absent size must not become a guessed one. | |
| 236 | + | let html = render("g", vec![Frame::new("/a.png", "Alpha")]); | |
| 237 | + | assert!(!html.contains("width="), "{html}"); | |
| 238 | + | assert!(!html.contains("height="), "{html}"); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn the_visible_frame_is_fetched_now_and_the_rest_can_wait() { | |
| 243 | + | // Both answers in one widget at one moment, which is why loading is the | |
| 244 | + | // description's to say rather than a renderer-wide setting. | |
| 245 | + | let html = render("g", three()); | |
| 246 | + | assert_eq!( | |
| 247 | + | html.matches("loading=\"lazy\"").count(), | |
| 248 | + | 2, | |
| 249 | + | "expected the two offscreen frames only: {html}" | |
| 250 | + | ); | |
| 251 | + | // Everything before the second frame's source is the first frame, so | |
| 252 | + | // nothing in that span may defer: it is the picture already on screen. | |
| 253 | + | let upto_second = &html[..html.find("/b.png").unwrap()]; | |
| 254 | + | assert!( | |
| 255 | + | !upto_second.contains("loading=\"lazy\""), | |
| 256 | + | "the visible frame must not be deferred: {html}" | |
| 257 | + | ); | |
| 258 | + | } | |
| 259 | + | ||
| 188 | 260 | #[test] | |
| 189 | 261 | fn a_screenshot_keeps_its_own_shape() { | |
| 190 | 262 | // Natural is the default and emits no attribute, so the assertion is |