Skip to main content

max / makenotwork

Release mnw-server v0.11.19: hold the carousel's place Layout shift, measured before: CLS 0.087, and the carousel's height went 141 -> 58 -> 536 across the first half second of every load. Three causes, all mine, all fixed here. THE 478px JUMP was an unreserved picture. makeover-layout 0.22.0 lets a picture carry its own dimensions; build.rs reads them straight out of the WebP headers so nothing is hand-maintained and a re-shoot cannot silently invalidate them. CarouselFrame::new looks its own size up, so every caller naming a static shot reserves space without knowing it did. Gallery uploads stay None -- gallery_images records a byte count and never recorded a size, and reserving the wrong room is worse than reserving none. THE 141 -> 58 COLLAPSE was progressive enhancement pointed the wrong way. The description still carries the whole gallery, but the default rendering shows one frame, so a page with JavaScript is settled from first paint. no-js.css, loaded from a <noscript>, opens the stack out for a visitor without scripting. Both properties and no shift; before, everyone with JS paid a visible jump so that a few without could reach frames two and three. THE DOTS were injected into unreserved space. The strip's height is held by padding now. The nav buttons need nothing: they are absolutely positioned. Also: loading="lazy" is no longer emitted for every picture. The first frame is on screen and is fetched eagerly; the others are not and can wait. The old code deferred all three including the one being looked at, which delays the only picture that matters and buys nothing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 21:28 UTC
Signed with PGP, not checked
Commit: 9a5af7a55efb0df3a72dc8a60419f30ec93f3f9b
Parent: 9f66ee2
11 files changed, +298 insertions, -64 deletions
M server/Cargo.lock +25 -25
@@ -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