Skip to main content

max / quasi

A row part says whether its string is markdown Row::secondary has always been a String, and three call sites had markdown to put in it: goingson's projects card, the mail list's preview, a contact's note next. Each put the source in, so a row read `**Ships Q3.** See [the brief](https://...)` where the screen it stands in for reads the sentence. Flattening at the call site was the first fix and it is half of one. It corrects what a webview shows and throws the fact away on the way: a renderer cannot tell text an author typed from markdown somebody already flattened, so it cannot decide for itself, and every site with markdown copies the same three lines. Prose is Meter's answer rather than Node's. The row still holds no node -- the 2026-08-08 ruling, and the door through which a description becomes a templating language -- it holds a two-case value naming which kind of string it has. From<&str> and From<String> both make Text, so every existing .secondary("...") call compiles unchanged and only the renderer had to move. quasi-webview draws Rich through docengine's phrase preset: inline emphasis, no blocks at all, no anchor. A row usually carries activate, so the row is already a target and a link in its prose would be a second target inside the first. A terminal renderer can emit bold from the same description, which is the whole point of carrying the kind rather than a flattened string. The template's note preview is the other case, and it is worth having both in the scaffold: it flattens with render_plain and hands back Text, because it cuts at 80 characters and cutting markdown source lands mid-syntax. What comes out is no longer markdown, so saying Rich would be a lie a renderer acts on.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 17:59 UTC
Signed with PGP, not checked
Commit: 9d9f465d885f0a59b6afbcea831144923cf01a71
Parent: b94222d
8 files changed, +287 insertions, -21 deletions
@@ -109,8 +109,8 @@
109 109 pub use crate::response::{Message, Outcome, Response};
110 110 pub use crate::router::{Handler, Router};
111 111 pub use crate::screen::{
112 - Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, RegionKind, Rest,
113 - Row, Screen, Slot, Tag,
112 + Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, Prose, RegionKind,
113 + Rest, Row, Screen, Slot, Tag,
114 114 };
115 115
116 116 #[cfg(test)]
@@ -376,6 +376,82 @@
376 376 }
377 377 }
378 378
379 + /// Prose in a row part: what it says, and whether it is markdown.
380 + ///
381 + /// `secondary` has always been a `String`, and three call sites had markdown to
382 + /// put in it: the goingson projects card's description, the mail list's body
383 + /// preview, and a contact's note next. Each put the **source** in, so a row read
384 + /// `**Ships Q3.** See [the brief](https://...)` where the screen it stands in
385 + /// for reads the sentence. Flattening at the call site fixes what the user sees
386 + /// and loses the fact on the way: a renderer receiving the row cannot tell text
387 + /// an author typed from markdown somebody already flattened, so it cannot decide
388 + /// for itself, and the flattening is copied per site.
389 + ///
390 + /// This is [`Meter`]'s answer, not [`Node`]'s. The row still holds no node --
391 + /// the 2026-08-08 ruling, and the door through which a description becomes a
392 + /// templating language -- it holds a two-case value saying which of two things
393 + /// its string is. A webview renders the markdown inline, a terminal can emit
394 + /// bold, and a renderer that wants neither flattens it, each from the same
395 + /// description.
396 + ///
397 + /// [`Text`](Self::Text) is the default in every sense: `From<&str>` and
398 + /// `From<String>` both produce it, so `.secondary("...")` means what it always
399 + /// meant and no existing call site changes.
400 + #[derive(Debug, Clone, PartialEq, Eq)]
401 + pub enum Prose {
402 + /// Text as written. A renderer escapes it and draws it, and nothing in it
403 + /// is markup however it is punctuated.
404 + Text(String),
405 + /// Markdown source, carried as source for the reason [`Node::Rich`] does:
406 + /// every renderer has an honest answer because each renders it its own way,
407 + /// and nothing here is markup a renderer has to trust.
408 + Rich(String),
409 + }
410 +
411 + impl Prose {
412 + /// Markdown, to be rendered by whoever draws it.
413 + pub fn rich(source: impl Into<String>) -> Self {
414 + Self::Rich(source.into())
415 + }
416 +
417 + /// The string, whichever case this is.
418 + ///
419 + /// For a renderer that treats both the same, and for a test that does not
420 + /// care. A renderer that draws this without looking at the case is drawing
421 + /// markdown as text, which is the bug this type exists to make visible
422 + /// rather than impossible.
423 + #[must_use]
424 + pub fn source(&self) -> &str {
425 + match self {
426 + Self::Text(text) | Self::Rich(text) => text,
427 + }
428 + }
429 +
430 + /// Whether there is anything to draw.
431 + #[must_use]
432 + pub fn is_empty(&self) -> bool {
433 + self.source().is_empty()
434 + }
435 + }
436 +
437 + impl From<String> for Prose {
438 + fn from(text: String) -> Self {
439 + Self::Text(text)
440 + }
441 + }
442 +
443 + impl From<&str> for Prose {
444 + fn from(text: &str) -> Self {
445 + Self::Text(text.to_owned())
446 + }
447 + }
448 +
449 + impl From<&String> for Prose {
450 + fn from(text: &String) -> Self {
451 + Self::Text(text.clone())
452 + }
453 + }
454 +
379 455 /// How much of a set is done, owned.
380 456 ///
381 457 /// The borrowed original is [`layout::Meter`], which arrived at 0.10.0 for this.
@@ -992,8 +1068,12 @@
992 1068 pub struct Row {
993 1069 /// The thing itself. What the row is called.
994 1070 pub primary: String,
995 - /// Supporting text under the primary.
996 - pub secondary: Option<String>,
1071 + /// Supporting text under the primary, plain or markdown.
1072 + ///
1073 + /// [`Prose`] rather than a `String` since the row-prose decision: the part
1074 + /// is still a string and still not a node, and it now says which of the two
1075 + /// kinds of string it is.
1076 + pub secondary: Option<Prose>,
997 1077 /// A short trailing fact: a count, a size, a date.
998 1078 pub meta: Option<String>,
999 1079 /// Small labelled things belonging to the row: badges, chips, tags.
@@ -1122,7 +1202,7 @@
1122 1202
1123 1203 /// Supporting text under the primary.
1124 1204 #[must_use]
1125 - pub fn secondary(mut self, text: impl Into<String>) -> Self {
1205 + pub fn secondary(mut self, text: impl Into<Prose>) -> Self {
1126 1206 self.secondary = Some(text.into());
1127 1207 self
1128 1208 }
@@ -1250,8 +1330,9 @@
1250 1330 /// description that sanitised would be deciding what a host can draw.
1251 1331 ///
1252 1332 /// Not available inside a [`Row`]: a row part holds no node, by the
1253 - /// 2026-08-08 ruling, and that is why the projects card keeps its raw
1254 - /// markdown in `secondary` rather than gaining this.
1333 + /// 2026-08-08 ruling. What a row can hold is [`Prose`], which carries the
1334 + /// same markdown source under the same reasoning without being a node, so
1335 + /// the projects card no longer keeps its raw markdown in `secondary`.
1255 1336 Rich {
1256 1337 /// The markdown, as written.
1257 1338 source: String,
@@ -29,7 +29,7 @@
29 29 use makeover_webview::list::{Cell, cells_html};
30 30 use makeover_webview::meter::meter_html;
31 31 use makeover_webview::placeholder::placeholder_html;
32 - use quasi_router::screen::{Act, Cells, Destination, Field, Node, Row, Slot, Tag};
32 + use quasi_router::screen::{Act, Cells, Destination, Field, Node, Prose, Row, Slot, Tag};
33 33 use quasi_router::{Action, Method, Params};
34 34
35 35 /// The class-name prefix, applied through [`Emit::class_prefix`].
@@ -118,6 +118,32 @@
118 118 }
119 119 }
120 120
121 + /// A row part's prose into markup.
122 + ///
123 + /// [`Prose::Text`] escapes, exactly as it did when the part was a `String`.
124 + /// [`Prose::Rich`] renders through docengine's `phrase` preset, which is the
125 + /// preset this call site asked for: markdown with no block structure at all and
126 + /// no links, keeping the inline emphasis. A heading, a list and a quote each
127 + /// contribute their words without claiming a block of a row that has no room
128 + /// for one, and not even a paragraph survives.
129 + ///
130 + /// Links go because a row usually carries [`Row::activate`], so the row itself
131 + /// is already a target and an anchor inside it is a second target inside the
132 + /// first: ambiguous to click, worse to reach by keyboard, and pointing somewhere
133 + /// a one-line summary cannot usefully send anyone. Their text stays.
134 + ///
135 + /// This is the renderer deciding, which is the point of the description
136 + /// carrying the kind rather than a flattened string. A terminal renderer facing
137 + /// the same [`Prose::Rich`] can emit bold instead, and one that wants neither
138 + /// can call `docengine::render_plain`. None of them has to be told by the
139 + /// screen author which to do.
140 + fn row_prose_html(prose: &Prose) -> String {
141 + match prose {
142 + Prose::Text(text) => escape(text),
143 + Prose::Rich(source) => docengine::render_phrase(source),
144 + }
145 + }
146 +
121 147 /// Markdown source into markup, for [`Node::Rich`].
122 148 ///
123 149 /// The strict preset, which is a deliberate difference from the
@@ -443,7 +469,7 @@
443 469 out.push_str("<span");
444 470 class_attr(&["row-secondary"], opts, out);
445 471 out.push('>');
446 - out.push_str(&escape(secondary));
472 + out.push_str(&row_prose_html(secondary));
447 473 out.push_str("</span>");
448 474 }
449 475
@@ -9,7 +9,9 @@
9 9
10 10 use makeover_layout as layout;
11 11 use quasi_http::Render;
12 - use quasi_router::screen::{Act, Cells, Choice, Column, Field, Figure, Meter, Rest, Row, Tag};
12 + use quasi_router::screen::{
13 + Act, Cells, Choice, Column, Field, Figure, Meter, Prose, Rest, Row, Tag,
14 + };
13 15 use quasi_router::{Action, Node, RegionKind, Screen, Slot};
14 16
15 17 use crate::{Emit, Shell, Webview};
@@ -1100,7 +1102,9 @@
1100 1102 let shell = Shell::default().layered(["base"]).styled("/style.css");
1101 1103 let html = shell.document("Console", "<main>x</main>");
1102 1104
1103 - let stmt = html.find("@layer makeover, base;").expect("the order is stated");
1105 + let stmt = html
1106 + .find("@layer makeover, base;")
1107 + .expect("the order is stated");
1104 1108 let sheet = html.find("/style.css").expect("the sheet is linked");
1105 1109 let body = html.find("<main>").expect("the body is placed");
1106 1110 assert!(stmt < sheet);
@@ -1207,3 +1211,97 @@
1207 1211 // its text fails here too.
1208 1212 assert_eq!(html.matches("&lt;script&gt;").count(), 10);
1209 1213 }
1214 +
1215 + #[test]
1216 + fn a_rows_plain_prose_is_escaped_exactly_as_it_always_was() {
1217 + // The default case, and the one that must not change: `.secondary("...")`
1218 + // still means text, and text is never markup however it is punctuated.
1219 + let row = Row::new("Atlas").secondary("**not bold** <b>not bold either</b>");
1220 + let node = Node::list(vec![row]);
1221 +
1222 + let html = Webview::new().fragment(&node);
1223 +
1224 + assert!(html.contains("**not bold**"), "got: {html}");
1225 + assert!(!html.contains("<strong>"), "got: {html}");
1226 + assert!(!html.contains("<b>"), "got: {html}");
1227 + assert!(html.contains("&lt;b&gt;"), "got: {html}");
1228 + }
1229 +
1230 + #[test]
1231 + fn a_rows_rich_prose_is_rendered_inline() {
1232 + // The row-prose decision. The description says the string is markdown and
1233 + // this renderer draws it as markdown, one line's worth.
1234 + let row = Row::new("Atlas").secondary(Prose::rich("**Ships Q3.** `soon`"));
1235 + let node = Node::list(vec![row]);
1236 +
1237 + let html = Webview::new().fragment(&node);
1238 +
1239 + assert!(html.contains("<strong>Ships Q3.</strong>"), "got: {html}");
1240 + assert!(html.contains("<code>soon</code>"), "got: {html}");
1241 + }
1242 +
1243 + #[test]
1244 + fn a_rows_rich_prose_keeps_no_blocks() {
1245 + // A row is one line tall. A heading, a list and a quote each contribute
1246 + // their words and none of them claims a block.
1247 + let row = Row::new("Borealis").secondary(Prose::rich("# Goal\n\n> ship it\n\n- one\n- two"));
1248 + let node = Node::list(vec![row]);
1249 +
1250 + let html = Webview::new().fragment(&node);
1251 +
1252 + // The outer `<ul class="list">` is the list itself; what must not appear is
1253 + // a second one inside the row's own span.
1254 + let secondary = html
1255 + .split_once(r#"<span class="row-secondary">"#)
1256 + .expect("the row draws its secondary")
1257 + .1
1258 + .split_once("</span>")
1259 + .expect("the part closes")
1260 + .0;
1261 + for block in ["<h1", "<blockquote", "<ul", "<li", "<p"] {
1262 + assert!(!secondary.contains(block), "no {block} in a row: {html}");
1263 + }
1264 + for word in ["Goal", "ship it", "one", "two"] {
1265 + assert!(html.contains(word), "{word} survives: {html}");
1266 + }
1267 + }
1268 +
1269 + #[test]
1270 + fn a_rows_rich_prose_carries_no_second_click_target() {
1271 + // The row is already the target through `activate`. An anchor inside it
1272 + // would be a second target inside the first.
1273 + let row = Row::new("Atlas")
1274 + .secondary(Prose::rich(
1275 + "see [the brief](https://example.com/a/long/path)",
1276 + ))
1277 + .activate(Action::get("/projects/1"));
1278 + let node = Node::list(vec![row]);
1279 +
1280 + let html = Webview::new().fragment(&node);
1281 +
1282 + assert!(html.contains("see the brief"), "the text survives: {html}");
1283 + assert!(!html.contains("example.com"), "no href: {html}");
1284 + // The row itself is an anchor, drawn from `activate`. That one is the
1285 + // target; the assertion is that the prose did not add a second.
1286 + assert_eq!(
1287 + html.matches("<a ").count(),
1288 + 1,
1289 + "the row is the only target: {html}"
1290 + );
1291 + }
1292 +
1293 + #[test]
1294 + fn a_rows_rich_prose_cannot_smuggle_markup() {
1295 + // `Prose::Rich` carries source, not markup, so the renderer decides what is
1296 + // drawable. Raw HTML in the source is not.
1297 + let row = Row::new("Atlas").secondary(Prose::rich(
1298 + "hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))",
1299 + ));
1300 + let node = Node::list(vec![row]);
1301 +
1302 + let html = Webview::new().fragment(&node);
1303 +
1304 + assert!(!html.contains("<script"), "got: {html}");
1305 + assert!(!html.contains("onerror"), "got: {html}");
1306 + assert!(!html.contains("javascript:"), "got: {html}");
1307 + }
@@ -34,6 +34,10 @@
34 34 # cargo refuses it outright — so the crate that is on both sides of the
35 35 # build-dependency line has to declare the smaller set centrally.
36 36 quasi-store = { git = "https://makenot.work/git/max/quasi.git", default-features = false }
37 + # The markdown engine. Already in the tree through quasi-webview, which renders
38 + # every `Node::Rich` and every `Prose::Rich` with it; declared here because the
39 + # core crate needs the other direction, markdown to text, for a row's preview.
40 + docengine = { git = "https://makenot.work/git/max/docengine.git" }
37 41
38 42 rusqlite = { version = "0.40.0", features = ["bundled"] }
39 43 thiserror = "2.0.17"
@@ -17,6 +17,10 @@
17 17 # thing that decides what a screen is.
18 18 quasi-router = { workspace = true }
19 19 quasi-store = { workspace = true, features = ["runtime"] }
20 + # Not a host crate: it turns markdown into text and knows nothing about where
21 + # the text goes. The rule above is about hosts, and this is the same kind of
22 + # thing `thiserror` is.
23 + docengine = { workspace = true }
20 24 rusqlite = { workspace = true }
21 25 thiserror = { workspace = true }
22 26 tracing = { workspace = true }
@@ -40,8 +40,8 @@
40 40
41 41 use quasi_router::layout::{FieldKind, Selector, Tone};
42 42 use quasi_router::{
43 - Act, Action, Choice, Field, Node, Params, RegionKind, Response, RouteError, Router, Screen,
44 - Slot,
43 + Act, Action, Choice, Field, Node, Params, Prose, RegionKind, Response, RouteError, Router,
44 + Screen, Slot,
45 45 };
46 46
47 47 use crate::state::AppState;
@@ -77,14 +77,29 @@
77 77 /// Truncation is the description's business rather than the renderer's here,
78 78 /// because what is being said is "a preview", and a renderer handed the whole
79 79 /// body cannot know that is what it was for.
80 - fn preview(body: &str) -> Option<String> {
81 - let line = body.lines().find(|line| !line.trim().is_empty())?.trim();
82 - Some(if line.chars().count() > 80 {
80 + ///
81 + /// The body is markdown, so it is flattened before it is cut, and the result is
82 + /// [`Prose::Text`] rather than [`Prose::Rich`]. Both halves matter and neither
83 + /// is the obvious one:
84 + ///
85 + /// - **Flatten first, then cut.** Cutting markdown source at 80 characters
86 + /// lands mid-syntax sooner or later, and `**bo…` renders as those four
87 + /// characters. Cutting text cannot do that.
88 + /// - **Text, not Rich.** What comes out is no longer markdown, so saying `Rich`
89 + /// would be a lie a renderer acts on. This is a description choosing to
90 + /// summarise, which is a different thing from the whole body travelling as
91 + /// source: compare goingson's projects card, which passes its description as
92 + /// `Prose::rich` and lets each renderer decide, because it is short and
93 + /// bounded and there is nothing to cut.
94 + fn preview(body: &str) -> Option<Prose> {
95 + let plain = docengine::render_plain(body);
96 + let line = plain.lines().find(|line| !line.trim().is_empty())?.trim();
97 + Some(Prose::Text(if line.chars().count() > 80 {
83 98 let cut: String = line.chars().take(79).collect();
84 99 format!("{cut}…")
85 100 } else {
86 101 line.to_owned()
87 - })
102 + }))
88 103 }
89 104
90 105 /// One note as a row.
@@ -356,12 +356,50 @@
356 356
357 357 #[test]
358 358 fn a_long_preview_is_cut_and_a_short_one_is_not() {
359 - assert_eq!(preview("short"), Some("short".to_owned()));
360 - assert_eq!(preview(" \n\n first line "), Some("first line".to_owned()));
359 + assert_eq!(preview("short"), Some(Prose::Text("short".to_owned())));
360 + assert_eq!(
361 + preview(" \n\n first line "),
362 + Some(Prose::Text("first line".to_owned()))
363 + );
361 364 assert_eq!(preview(" "), None);
362 365
363 366 let long = "x".repeat(200);
364 367 let cut = preview(&long).unwrap();
365 - assert_eq!(cut.chars().count(), 80);
366 - assert!(cut.ends_with('…'));
368 + assert_eq!(cut.source().chars().count(), 80);
369 + assert!(cut.source().ends_with('…'));
370 + }
371 +
372 + #[test]
373 + fn a_preview_says_what_the_markdown_says() {
374 + // The body is markdown, so a row showing its source would show the syntax:
375 + // `**Bold** and a [link](...)` rather than the sentence.
376 + assert_eq!(
377 + preview("**Bold** and a [link](https://example.com/a/long/path)"),
378 + Some(Prose::Text("Bold and a link".to_owned()))
379 + );
380 + // A heading is a first line like any other once the `#` is gone.
381 + assert_eq!(
382 + preview("# Title\n\nBody"),
383 + Some(Prose::Text("Title".to_owned()))
384 + );
385 + }
386 +
387 + #[test]
388 + fn a_preview_is_cut_after_flattening_never_before() {
389 + // The reason the order matters: cutting source lands mid-syntax sooner or
390 + // later, and `**bo…` reaches the reader as those four characters.
391 + // Trimmed: `** ` is not a closing delimiter, so an untrimmed fixture would
392 + // be literal asterisks and would pass this test for the wrong reason.
393 + let body = format!("**{}**", "word ".repeat(40).trim_end());
394 + let cut = preview(&body).expect("a preview");
395 + assert!(!cut.source().contains('*'), "got: {cut:?}");
396 + assert_eq!(cut.source().chars().count(), 80);
397 + }
398 +
399 + #[test]
400 + fn a_preview_is_text_because_it_is_no_longer_markdown() {
401 + // Saying `Rich` here would be a lie a renderer acts on: what is left after
402 + // flattening and cutting is text, and a renderer must not re-parse it.
403 + let cut = preview("**Bold**").expect("a preview");
404 + assert!(matches!(cut, Prose::Text(_)), "got: {cut:?}");
367 405 }