Skip to main content

max / makeover-layout

0.21.0: name a picture Found by trying to describe MNW's carousel under 0.20.0's widget tier and getting one step in. The vocabulary could say a number with a caption, a badge, a meter and a table, and could not say the thing three of MNW's public pages are mostly made of. It reads as an oversight and is a measurement: 24 <img> sites across 22 MNW templates, against one in goingson and none in Balanced Breakfast or audiofiles. A picture is furniture a content platform has, and MNW is the only one in the tree, so the evidence never arrived from the two-app direction the old rule looked in. Under generic-against-bespoke it is not close. A primitive rather than a widget, which the tier makes a real question now. A widget is an assembly of things already sayable; a picture is a leaf, assembled from nothing. It passes the test Node::Html failed too: every host has an honest answer, a terminal included, which has a graphics protocol or has the alt text. No source. Act's split, for Act's reason: an address is not this crate's to hold. It matters more here than for a control, because the address is most of what a webview needs and none of what the description knows. alt is not an Option. For two of the three renderers it is not a fallback but the whole rendering, and making it optional would make "invisible on a terminal" the default. An empty alt is the decorative claim. Fit is three members because MNW makes the distinction deliberately at 17 sites and makes it three ways. Natural is the default: it is the only one that shows the whole picture at its own shape, so a renderer ignoring the enum is still right about the common case.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 16:04 UTC
Signed with PGP, not checked
Commit: e8b3bda4a62dddcf849c15e0032f577e28ba69e1
Parent: 315a110
2 files changed, +164 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.20.0"
3 + version = "0.21.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +163
@@ -224,6 +224,28 @@
224 224 //! `widget-tier` for the ownership model, which is why this member carries a
225 225 //! name a renderer may decline to know.
226 226 //!
227 + //! 0.21.0 adds [`Image`] and [`Fit`], found by trying to describe MNW's
228 + //! carousel under 0.20.0's widget tier and getting one step in. Nothing named a
229 + //! picture. The vocabulary could say a number with a caption, a badge, a meter
230 + //! and a table, and could not say the thing three of MNW's public pages are
231 + //! mostly made of.
232 + //!
233 + //! It reads as an oversight and is a measurement: 24 `<img>` sites across 22
234 + //! MNW templates, against one in goingson and none in Balanced Breakfast or
235 + //! audiofiles. A picture is furniture a *content platform* has, and MNW is the
236 + //! only one in the tree, so the evidence never arrived from the two-app
237 + //! direction the earlier rule looked in. Under the generic-against-bespoke bar
238 + //! it is not close: a picture is not one app's own.
239 + //!
240 + //! A primitive rather than a widget, which is worth stating now that the tier
241 + //! makes it a real question. A widget is an assembly of things already sayable
242 + //! and a picture is a leaf, assembled from nothing. It also passes the test
243 + //! `Node::Html` failed — every host has an honest answer, including a terminal,
244 + //! which has a graphics protocol or has [`Image::alt`].
245 + //!
246 + //! [`Image`] carries no source, the split [`Act`] already makes: an address is
247 + //! not this crate's to hold. See its own docs, which is where the argument is.
248 + //!
227 249 //! # Reach, focus and the focus ring
228 250 //!
229 251 //! Three terms, and no others, for what 0.19.0 moved out of the description.
@@ -1213,6 +1235,117 @@
1213 1235 /// omission: a question asked *after* a control is pressed belongs to whatever
1214 1236 /// is holding the interaction, and a renderer that drew it would be asking
1215 1237 /// before there was anything to answer.
1238 + /// How a picture sits in the box it is given.
1239 + ///
1240 + /// An intent rather than a value, so a renderer picks the expression it has:
1241 + /// `object-fit` in a webview, a texture's UV rect in egui, and in a terminal a
1242 + /// choice about how many cells the blit gets. Named because MNW already makes
1243 + /// the distinction deliberately at 17 sites and makes it three different ways,
1244 + /// which is a policy the app decided rather than one a shared crate would be
1245 + /// picking by accident.
1246 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1247 + #[non_exhaustive]
1248 + pub enum Fit {
1249 + /// The picture's own proportions, and the box takes the height they imply.
1250 + ///
1251 + /// The default because it is the only one that shows the whole picture at
1252 + /// its own shape, so a renderer that ignores this enum entirely is still
1253 + /// right about the common case. A screenshot wants this; the shipped MNW
1254 + /// carousel sets no `object-fit` at all, which is this.
1255 + #[default]
1256 + Natural,
1257 + /// Fill the box and crop whatever does not fit.
1258 + ///
1259 + /// For a picture in a slot whose shape the layout fixed: a thumbnail, an
1260 + /// avatar, cover art. 15 of MNW's 17 sites.
1261 + Cover,
1262 + /// Fit inside the box whole, leaving space on two sides.
1263 + ///
1264 + /// The letterbox. For when the whole picture matters more than filling the
1265 + /// space, and the space is not the picture's shape.
1266 + Contain,
1267 + }
1268 +
1269 + /// A picture, and what it says to someone who is not looking at it.
1270 + ///
1271 + /// # No source
1272 + ///
1273 + /// [`Act`]'s split, for [`Act`]'s reason. A source is an address, and this
1274 + /// crate has no notion of an address: it says what a thing *is* and the caller
1275 + /// keeps what it points at. The three findings dropped from 0.11.0 were all
1276 + /// this same shape.
1277 + ///
1278 + /// It matters more here than it does for a control, because a picture is the
1279 + /// one member where the address is most of what a webview needs and *none* of
1280 + /// what the description knows. `quasi_router::Node::Image` carries the URL, the
1281 + /// way it carries an `Action` for a control.
1282 + ///
1283 + /// # Why [`alt`](Self::alt) is not optional
1284 + ///
1285 + /// Every other host has to draw something, and for two of the three the alt
1286 + /// text is not a fallback but the whole rendering: a terminal without a
1287 + /// graphics protocol has the words and nothing else. Making it optional would
1288 + /// make "this picture is invisible on a terminal" the default, and the
1289 + /// description would be carrying a webview assumption in its shape.
1290 + ///
1291 + /// An image that genuinely says nothing — a rule, a spacer, a decoration
1292 + /// repeating what the text beside it already said — is an empty `alt`, which is
1293 + /// the same thing HTML means by it and is a claim rather than an oversight.
1294 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1295 + pub struct Image<'a> {
1296 + /// What the picture says, for anything not showing it.
1297 + ///
1298 + /// Empty means the picture is decorative and adds nothing to the text
1299 + /// around it. See the type's own docs on why this is not an `Option`.
1300 + pub alt: &'a str,
1301 + /// A visible line under the picture, where the app wants one.
1302 + ///
1303 + /// Distinct from [`alt`](Self::alt) and the difference is who it is for: a
1304 + /// caption is content everybody reads, alt text is what stands in for the
1305 + /// picture. A screenshot captioned "The library view" still needs alt text
1306 + /// describing what is in the shot.
1307 + pub caption: Option<&'a str>,
1308 + /// How it sits in the box it is given.
1309 + pub fit: Fit,
1310 + }
1311 +
1312 + impl<'a> Image<'a> {
1313 + /// A picture that carries its own proportions.
1314 + #[must_use]
1315 + pub const fn new(alt: &'a str) -> Self {
1316 + Self {
1317 + alt,
1318 + caption: None,
1319 + fit: Fit::Natural,
1320 + }
1321 + }
1322 +
1323 + /// A visible line under it.
1324 + #[must_use]
1325 + pub const fn caption(mut self, caption: &'a str) -> Self {
1326 + self.caption = Some(caption);
1327 + self
1328 + }
1329 +
1330 + /// How it sits in its box.
1331 + #[must_use]
1332 + pub const fn fit(mut self, fit: Fit) -> Self {
1333 + self.fit = fit;
1334 + self
1335 + }
1336 +
1337 + /// Whether the picture adds anything for someone not looking at it.
1338 + ///
1339 + /// A renderer with no way to show a picture uses this to decide between
1340 + /// drawing the alt text and drawing nothing at all. Both are correct and
1341 + /// the difference is this flag: standing in for a decorative rule with the
1342 + /// word "decoration" is worse than leaving the space empty.
1343 + #[must_use]
1344 + pub const fn speaks(self) -> bool {
1345 + !self.alt.is_empty()
1346 + }
1347 + }
1348 +
1216 1349 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1217 1350 pub struct Act<'a> {
1218 1351 /// What the control says.
@@ -2823,6 +2956,36 @@
2823 2956 assert!(!Region::Bespoke { name: "day-plan" }.described());
2824 2957 }
2825 2958
2959 + #[test]
2960 + fn a_picture_that_says_nothing_is_a_claim_and_not_an_oversight() {
2961 + // The distinction a renderer with no graphics protocol runs on: draw
2962 + // the words, or draw nothing. Standing in for a decorative rule with
2963 + // the word "decoration" is worse than leaving the space empty.
2964 + assert!(Image::new("The library view, mid-import").speaks());
2965 + assert!(!Image::new("").speaks());
2966 + }
2967 +
2968 + #[test]
2969 + fn a_caption_and_alt_text_are_not_the_same_line() {
2970 + // A caption is content everybody reads; alt text stands in for the
2971 + // picture. A screenshot with a caption still needs alt text.
2972 + let shot = Image::new("A file list with three rows selected").caption("The library view");
2973 + assert_eq!(shot.caption, Some("The library view"));
2974 + assert!(shot.speaks());
2975 + assert_ne!(shot.alt, shot.caption.unwrap());
2976 + }
2977 +
2978 + #[test]
2979 + fn a_picture_keeps_its_own_proportions_unless_told_otherwise() {
2980 + // The default is the one that shows the whole picture at its own shape,
2981 + // so a renderer ignoring Fit entirely is still right about the common
2982 + // case. The shipped MNW carousel sets no object-fit at all, which is
2983 + // this.
2984 + assert_eq!(Image::new("a").fit, Fit::Natural);
2985 + assert_eq!(Fit::default(), Fit::Natural);
2986 + assert_eq!(Image::new("a").fit(Fit::Cover).fit, Fit::Cover);
2987 + }
2988 +
2826 2989 #[test]
2827 2990 fn a_widget_inherits_its_depth_the_way_a_bespoke_does() {
2828 2991 // Stronger than the bespoke case: a widget is drawn by whichever