Skip to main content

max / quasi

Say the three things the contacts screen could not All three were found by describing a real goingson screen, and all three were worked around in the port with the workaround asserted by a test so it stayed visible. This replaces the workarounds. A row carries tokens. Against makeover-layout 0.9.0's RowPart::Tokens. The payload of Node::Token becomes its own struct, Tag, because a row can hold these now and the alternative was defining the same five fields twice and watching them drift. tag_html loses its eight arguments and the too_many_arguments allow with them. A row's tick is not the app's pointer. Row::selected meant "the detail pane is showing this", so there was one word for the app's state and the user's. It is Row::current now, and selected is the tick. Option<bool> rather than bool because a plain bool cannot tell "not ticked" from "not tickable", and every renderer would have had to be told selectability some other way. The webview emits a real checkbox: it is the one control here the browser already gets right, including the label association and the space key. An action can leave the app. Destination::Route against Destination::External, found by a contact's social handle. The renderer branches on the variant and never on the shape of the string, because "starts with https" is how a route named /https-setup ends up opening a browser. An external destination is an anchor rather than a button, since a button that navigates away lies to middle-click and to a screen reader, and it carries rel=noopener: a new tab with window.opener intact hands the other page a handle on this one. A disabled external control gets aria-disabled and no href, because an anchor has no disabled attribute and omitting the href is what actually stops it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 01:58 UTC
Signed with PGP, not checked
Commit: 1d75f233f34f21360bd2a6fa2c967a047656647e
Parent: b34b74e
7 files changed, +466 insertions, -96 deletions
M Cargo.lock +8 -4
@@ -4538,10 +4538,6 @@
4538 4538 name = "synckit-config"
4539 4539 version = "0.2.0"
4540 4540
4541 - [[patch.unused]]
4542 - name = "docengine"
4543 - version = "0.4.0"
4544 -
4545 4541 [[patch.unused]]
4546 4542 name = "kberg"
4547 4543 version = "0.1.0"
@@ -4553,3 +4549,11 @@
4553 4549 [[patch.unused]]
4554 4550 name = "tagtree"
4555 4551 version = "0.4.0"
4552 +
4553 + [[patch.unused]]
4554 + name = "docengine"
4555 + version = "0.4.0"
4556 +
4557 + [[patch.unused]]
4558 + name = "makeover-layout"
4559 + version = "0.9.0"
@@ -265,9 +265,10 @@
265 265 fn real_screen() -> Screen {
266 266 Screen::list_detail("Tasks", false)
267 267 .with(
268 - Slot::new("list", RegionKind::Pane)
269 - .with(Node::list([quasi_router::screen::Row::new("Write it down")
270 - .activate(quasi_router::Action::get("/task/1"))])),
268 + Slot::new("list", RegionKind::Pane).with(Node::list([quasi_router::screen::Row::new(
269 + "Write it down",
270 + )
271 + .activate(quasi_router::Action::get("/task/1"))])),
271 272 )
272 273 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")))
273 274 }
@@ -108,7 +108,8 @@
108 108 pub use crate::response::Response;
109 109 pub use crate::router::{Handler, Router};
110 110 pub use crate::screen::{
111 - Act, Action, Cells, Choice, Column, Field, Node, RegionKind, Row, Screen, Slot,
111 + Act, Action, Cells, Choice, Column, Destination, Field, Node, RegionKind, Row, Screen, Slot,
112 + Tag,
112 113 };
113 114
114 115 #[cfg(test)]
@@ -33,21 +33,99 @@
33 33
34 34 use crate::request::{Method, Params};
35 35
36 + /// Where an action goes.
37 + ///
38 + /// Added 2026-08-08, found by the goingson contacts screen. A contact's social
39 + /// handle and custom field both carry a URL that points out of the app
40 + /// entirely, and until this existed there was nothing to say about it: an
41 + /// action was a route, a route is something this app answers, and an address
42 + /// somewhere else is not. The port put the URL in the row's trailing text,
43 + /// which made it something to copy rather than something to follow.
44 + ///
45 + /// # Why this and not a separate link node
46 + ///
47 + /// Both were on the table. A destination keeps one concept where there would
48 + /// have been two, and the cost is that every renderer now branches: a webview
49 + /// emits an anchor rather than a button, and a terminal has to decide whether
50 + /// it can open a browser or should show the address. That branch is honest
51 + /// work, and it is work each renderer must do anyway once external addresses
52 + /// exist at all.
53 + ///
54 + /// What it must never become is a guess. The renderer branches on this enum and
55 + /// never on the shape of the string, because "starts with https" is how a route
56 + /// named `/https-setup` ends up opening a browser.
57 + #[derive(Debug, Clone, PartialEq, Eq, Hash)]
58 + pub enum Destination {
59 + /// A path this app's router answers.
60 + Route(String),
61 + /// An address outside the app. Nothing here will ever call it.
62 + External(String),
63 + }
64 +
65 + /// An empty route rather than an empty external address, so a half-built
66 + /// [`Action`] is something this app would answer rather than somewhere it would
67 + /// send a user. Same reasoning as [`Method`]'s default being the safe verb.
68 + ///
69 + /// Written out because `#[default]` only applies to unit variants.
70 + impl Default for Destination {
71 + fn default() -> Self {
72 + Self::Route(String::new())
73 + }
74 + }
75 +
76 + impl Destination {
77 + /// The route path, if it is one.
78 + ///
79 + /// `None` for an external address, which is the answer a host wants when it
80 + /// is deciding whether it can dispatch something.
81 + #[must_use]
82 + pub fn route(&self) -> Option<&str> {
83 + match self {
84 + Self::Route(path) => Some(path),
85 + Self::External(_) => None,
86 + }
87 + }
88 +
89 + /// The address as written, whichever kind it is.
90 + ///
91 + /// For rendering only. A host deciding whether to dispatch wants
92 + /// [`route`](Self::route), which cannot hand back something uncallable.
93 + #[must_use]
94 + pub fn as_str(&self) -> &str {
95 + let (Self::Route(address) | Self::External(address)) = self;
96 + address
97 + }
98 +
99 + /// Whether it leaves the app.
100 + #[must_use]
101 + pub const fn is_external(&self) -> bool {
102 + matches!(self, Self::External(_))
103 + }
104 + }
105 +
36 106 /// An address a control calls when it acts.
37 107 ///
38 108 /// Decision 2: an action is a route. The webview emits this as an `hx-get` or
39 109 /// `hx-post`, the terminal binds a key to it, egui calls it directly. All three
40 110 /// are calling the same path with the same verb.
41 111 ///
112 + /// Since 2026-08-08 a route is not the only thing it can be: see
113 + /// [`Destination`]. Decision 2 still holds for everything the app answers, and
114 + /// an external address is the case it never covered.
115 + ///
42 116 /// It does not carry a target. What a response replaces is the *response's*
43 117 /// business, per decision 7, because the router is the only party that knows
44 118 /// what it just changed.
45 119 #[derive(Debug, Clone, PartialEq, Eq, Default)]
46 120 pub struct Action {
47 121 /// Asking or telling.
122 + ///
123 + /// Meaningless for a [`Destination::External`], which is nobody's route to
124 + /// answer. Left on the struct rather than moved inside `Destination`
125 + /// because a method that is ignored is simpler than two shapes of action.
48 126 pub method: Method,
49 - /// Where.
50 - pub path: String,
127 + /// Where it goes.
128 + pub destination: Destination,
51 129 /// Values the control sends that are not in the path.
52 130 ///
53 131 /// A webview appends these to a query string or emits them as `hx-vals`; a
@@ -61,7 +139,7 @@
61 139 pub fn get(path: impl Into<String>) -> Self {
62 140 Self {
63 141 method: Method::Get,
64 - path: path.into(),
142 + destination: Destination::Route(path.into()),
65 143 params: Params::new(),
66 144 }
67 145 }
@@ -70,11 +148,29 @@
70 148 pub fn post(path: impl Into<String>) -> Self {
71 149 Self {
72 150 method: Method::Post,
73 - path: path.into(),
151 + destination: Destination::Route(path.into()),
74 152 params: Params::new(),
75 153 }
76 154 }
77 155
156 + /// Somewhere outside the app.
157 + ///
158 + /// [`Method::Get`], because following a link asks and does not tell, and a
159 + /// host that ignores the method loses nothing by it.
160 + pub fn external(url: impl Into<String>) -> Self {
161 + Self {
162 + method: Method::Get,
163 + destination: Destination::External(url.into()),
164 + params: Params::new(),
165 + }
166 + }
167 +
168 + /// The route this calls, if it calls one.
169 + #[must_use]
170 + pub fn route(&self) -> Option<&str> {
171 + self.destination.route()
172 + }
173 +
78 174 /// Send a value along with the call.
79 175 #[must_use]
80 176 pub fn with(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
@@ -83,6 +179,75 @@
83 179 }
84 180 }
85 181
182 + /// A small labelled thing: a badge, a chip, a tag.
183 + ///
184 + /// Its own struct as of 2026-08-08, having been the inline payload of
185 + /// [`Node::Token`]. Extracted because a row can carry these now
186 + /// ([`Row::tokens`], against `makeover-layout`'s `RowPart::Tokens`), and the
187 + /// alternative was defining the same five fields twice and watching them drift.
188 + ///
189 + /// The tone rides on the tag rather than on whatever holds it, which is what
190 + /// lets a strip of them say different things: a neutral type and an amber
191 + /// status, side by side in one row.
192 + ///
193 + /// No `Hash`, because it can hold an [`Action`], which holds [`Params`], which
194 + /// is a `Vec`. Same derive set as `Action` for that reason.
195 + #[derive(Debug, Clone, PartialEq, Eq)]
196 + pub struct Tag {
197 + /// Whether it answers a click, and whether it can be removed.
198 + pub kind: layout::Token,
199 + /// What it says.
200 + pub label: String,
201 + /// What it is saying.
202 + pub tone: layout::Tone,
203 + /// Whether it is currently held down. Only meaningful for a chip.
204 + pub latched: bool,
205 + /// What clicking it calls, if it answers a click.
206 + pub action: Option<Action>,
207 + }
208 +
209 + impl Tag {
210 + /// A neutral badge: it says something and answers nothing.
211 + pub fn badge(label: impl Into<String>) -> Self {
212 + Self {
213 + kind: layout::Token::Badge,
214 + label: label.into(),
215 + tone: layout::Tone::Neutral,
216 + latched: false,
217 + action: None,
218 + }
219 + }
220 +
221 + /// A chip that calls a route when clicked.
222 + ///
223 + /// Not removable. A removable chip is a different control with a different
224 + /// affordance, so it says so rather than being inferred from carrying an
225 + /// action.
226 + pub fn chip(label: impl Into<String>, action: Action) -> Self {
227 + Self {
228 + kind: layout::Token::Chip { removable: false },
229 + label: label.into(),
230 + tone: layout::Tone::Neutral,
231 + latched: false,
232 + action: Some(action),
233 + }
234 + }
235 +
236 + /// Set what it is saying.
237 + #[must_use]
238 + pub const fn tone(mut self, tone: layout::Tone) -> Self {
239 + self.tone = tone;
240 + self
241 + }
242 +
243 + /// Hold it down. Only meaningful for a chip.
244 + #[must_use]
245 + pub const fn latched(mut self, latched: bool) -> Self {
246 + self.latched = latched;
247 + self
248 + }
249 + }
250 +
86 251 /// One option offered by a field, owned.
87 252 ///
88 253 /// The borrowed original is `makeover-layout`'s [`layout::Choice`]. Two strings
@@ -482,12 +647,44 @@
482 647 pub secondary: Option<String>,
483 648 /// A short trailing fact: a count, a size, a date.
484 649 pub meta: Option<String>,
650 + /// Small labelled things belonging to the row: badges, chips, tags.
651 + ///
652 + /// Against `makeover-layout`'s `RowPart::Tokens`, which arrived at 0.9.0 for
653 + /// this. Before it, a row that carried two trailing facts had to join them
654 + /// into [`meta`](Self::meta) as text, which kept both and lost what the
655 + /// second one was: a toned status read as prose rather than as colour.
656 + ///
657 + /// [`meta`](Self::meta) is still the right place for a plain fact. The line
658 + /// is whether the thing has its own standing — a tone of its own, or a click
659 + /// to answer. "3 files" is meta; an amber status and a clickable tag are
660 + /// tokens.
661 + pub tokens: Vec<Tag>,
485 662 /// Controls that act on this row.
486 663 pub actions: Vec<Act>,
487 664 /// The route that selects this row, if selecting it does anything.
488 665 pub activate: Option<Action>,
489 666 /// Whether this is the row the detail side is currently showing.
490 - pub selected: bool,
667 + ///
668 + /// Named `selected` until 2026-08-08, which was one word doing two jobs.
669 + /// This one is the app's own pointer into a set: what a list-detail
670 + /// arrangement highlights because its pane is showing it, and what a
671 + /// webview says with `aria-current`. The user's tick is
672 + /// [`selected`](Self::selected), and conflating them meant a screen with
673 + /// bulk actions could not describe its checkboxes at all.
674 + pub current: bool,
675 + /// Whether the user has ticked this row, and whether they can.
676 + ///
677 + /// Three states in one field, which is why it is not a `bool`. `None` means
678 + /// the row is not selectable and no affordance should be drawn; `Some(false)`
679 + /// means it can be ticked and is not; `Some(true)` means it is. A plain bool
680 + /// cannot tell "not ticked" from "not tickable", so every renderer would
681 + /// have had to be told selectability some other way, and each would have
682 + /// picked a different way.
683 + ///
684 + /// This is the user's selection, as distinct from
685 + /// [`current`](Self::current). goingson's contacts and tasks screens both
686 + /// drive bulk actions from it.
687 + pub selected: Option<bool>,
491 688 }
492 689
493 690 impl Row {
@@ -513,6 +710,23 @@
513 710 self
514 711 }
515 712
713 + /// Add a token, chaining.
714 + #[must_use]
715 + pub fn token(mut self, tag: Tag) -> Self {
716 + self.tokens.push(tag);
717 + self
718 + }
719 +
720 + /// Make the row tickable, and say whether it is ticked.
721 + ///
722 + /// A row is not selectable until something says so, which is what keeps a
723 + /// checkbox off every list in the app.
724 + #[must_use]
725 + pub const fn selectable(mut self, ticked: bool) -> Self {
726 + self.selected = Some(ticked);
727 + self
728 + }
729 +
516 730 /// The route selecting this row.
517 731 #[must_use]
518 732 pub fn activate(mut self, action: Action) -> Self {
@@ -587,18 +801,7 @@
587 801 /// A control that calls a route.
588 802 Act(Act),
589 803 /// A small labelled thing sitting inside something else.
590 - Token {
591 - /// Whether it answers a click, and whether it can be removed.
592 - kind: layout::Token,
593 - /// What it says.
594 - label: String,
595 - /// What it is saying.
596 - tone: layout::Tone,
597 - /// Whether it is currently held down. Only meaningful for a chip.
598 - latched: bool,
599 - /// What clicking it calls, if it answers a click.
600 - action: Option<Action>,
601 - },
804 + Token(Tag),
602 805 /// Something the app is telling the user, unprompted.
603 806 Notice {
604 807 /// Transient and stacked, or persistent and in flow.
@@ -263,9 +263,10 @@
263 263 fn real_screen() -> Screen {
264 264 Screen::list_detail("Tasks", false)
265 265 .with(
266 - Slot::new("list", RegionKind::Pane)
267 - .with(Node::list([quasi_router::screen::Row::new("Write it down")
268 - .activate(quasi_router::Action::get("/task/1"))])),
266 + Slot::new("list", RegionKind::Pane).with(Node::list([quasi_router::screen::Row::new(
267 + "Write it down",
268 + )
269 + .activate(quasi_router::Action::get("/task/1"))])),
269 270 )
270 271 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")))
271 272 }
@@ -25,7 +25,7 @@
25 25 use makeover_webview::Emit;
26 26 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
27 27 use makeover_webview::list::{Cell, cells_html};
28 - use quasi_router::screen::{Act, Cells, Node, Row, Slot};
28 + use quasi_router::screen::{Act, Cells, Destination, Node, Row, Slot, Tag};
29 29 use quasi_router::{Action, Method, Params};
30 30
31 31 /// The class-name prefix, applied through [`Emit::class_prefix`].
@@ -107,12 +107,23 @@
107 107 /// That is where escaping bugs live, and the description layer's own reason for
108 108 /// carrying [`Action::params`] as values rather than as text.
109 109 pub(crate) fn action_attrs(action: &Action, morphs: bool, out: &mut String) {
110 + // An external destination is not htmx's business: nothing swaps, no route
111 + // is called, and the browser follows a normal link. `rel` rather than
112 + // trust: a new tab with `window.opener` left intact hands the other page a
113 + // handle on this one.
114 + if let Destination::External(url) = &action.destination {
115 + out.push_str(" href=\"");
116 + out.push_str(&escape(url));
117 + out.push_str("\" target=\"_blank\" rel=\"noopener noreferrer\"");
118 + return;
119 + }
120 +
110 121 let verb = match action.method {
111 122 Method::Get => " hx-get=\"",
112 123 Method::Post => " hx-post=\"",
113 124 };
114 125 out.push_str(verb);
115 - out.push_str(&escape(&action.path));
126 + out.push_str(&escape(action.destination.as_str()));
116 127 out.push('"');
117 128
118 129 if !action.params.is_empty() {
@@ -128,6 +139,24 @@
128 139 }
129 140 }
130 141
142 + /// The element a control becomes.
143 + ///
144 + /// A route is a button: it calls this app and something here answers. An
145 + /// external destination is an anchor: it leaves, and a button that navigates
146 + /// away is a button lying to everything that reads the page, middle-click and
147 + /// screen readers included.
148 + ///
149 + /// Branching on the [`Destination`] variant and never on the shape of the
150 + /// string is the rule `Destination`'s own docs set. "Starts with https" is how a
151 + /// route named `/https-setup` ends up opening a browser.
152 + const fn control_tag(action: &Action) -> (&'static str, &'static str) {
153 + if action.destination.is_external() {
154 + ("<a", "</a>")
155 + } else {
156 + ("<button type=\"button\"", "</button>")
157 + }
158 + }
159 +
131 160 /// A control that calls a route.
132 161 pub(crate) fn act_html(act: &Act, morphs: bool, opts: &Emit, out: &mut String) {
133 162 let mut classes = vec!["act"];
@@ -135,7 +164,8 @@
135 164 classes.push(tone);
136 165 }
137 166
138 - out.push_str("<button type=\"button\"");
167 + let (open, close) = control_tag(&act.action);
168 + out.push_str(open);
139 169 class_attr(&classes, opts, out);
140 170
141 171 match act.state {
@@ -144,7 +174,16 @@
144 174 // still carrying the address. A control that stops answering input
145 175 // should also stop being a request waiting to be re-enabled from
146 176 // the console.
147 - out.push_str(" disabled");
177 + //
178 + // An anchor has no `disabled`, and omitting the href is what
179 + // actually stops it: an `<a>` without one is not a link, so it
180 + // drops out of the tab order on its own. `aria-disabled` is what
181 + // says why, since a bare span-shaped anchor says nothing.
182 + if act.action.destination.is_external() {
183 + out.push_str(" aria-disabled=\"true\"");
184 + } else {
185 + out.push_str(" disabled");
186 + }
148 187 }
149 188 Some(layout::State::Focus) => {
150 189 out.push_str(" autofocus");
@@ -159,36 +198,58 @@
159 198
160 199 out.push('>');
161 200 out.push_str(&escape(&act.label));
162 - out.push_str("</button>");
201 + out.push_str(close);
163 202 }
164 203
165 204 /// One row of a list.
166 205 fn row_html(row: &Row, morphs: bool, opts: &Emit, out: &mut String) {
167 206 let mut classes = vec!["row"];
168 - if row.selected {
207 + if row.current {
208 + classes.push("row-current");
209 + }
210 + if row.selected == Some(true) {
169 211 classes.push("row-selected");
170 212 }
171 213
172 214 out.push_str("<li");
173 215 class_attr(&classes, opts, out);
174 - if row.selected {
175 - // The description's `selected` is what the detail side is showing, so
176 - // it is a current item within a set rather than a pressed control.
216 + if row.current {
217 + // `current` is what the detail side is showing: a current item within a
218 + // set rather than a pressed control, and rather than anything the user
219 + // ticked. That distinction is why the description carries two fields.
177 220 out.push_str(" aria-current=\"true\"");
178 221 }
179 222 out.push('>');
180 223
224 + // The tick, when the row is selectable at all. A real checkbox rather than
225 + // a styled span: it is the one control here the browser already gets right,
226 + // including the label association, the space key and the mixed state a
227 + // screen reader announces.
228 + if let Some(ticked) = row.selected {
229 + out.push_str("<input type=\"checkbox\"");
230 + class_attr(&["row-select"], opts, out);
231 + if ticked {
232 + out.push_str(" checked");
233 + }
234 + // What ticking it *does* is not describable here, and deliberately: it
235 + // is local state until something submits it, and the description layer
236 + // does not carry local state. The app binds this the way it binds any
237 + // other input, which for goingson is its delegated dispatcher.
238 + out.push_str(" aria-label=\"Select\">");
239 + }
240 +
181 241 // The primary is a control when selecting the row does something, and plain
182 242 // text when it does not. Emitting a button either way would give a screen
183 243 // reader an affordance that answers nothing.
184 244 match &row.activate {
185 245 Some(action) => {
186 - out.push_str("<button type=\"button\"");
246 + let (open, close) = control_tag(action);
247 + out.push_str(open);
187 248 class_attr(&["row-activate"], opts, out);
188 249 action_attrs(action, morphs, out);
189 250 out.push('>');
190 251 out.push_str(&escape(&row.primary));
191 - out.push_str("</button>");
252 + out.push_str(close);
192 253 }
193 254 None => {
194 255 out.push_str("<span");
@@ -215,6 +276,20 @@
215 276 out.push_str("</span>");
216 277 }
217 278
279 + // Tokens after meta and before actions, which is emphasis order: the row's
280 + // own name, then what supports it, then the plain trailing fact, then the
281 + // things with a standing of their own, then the controls. `RowPart`'s
282 + // `intent` falls off in the same order and never rises again.
283 + if !row.tokens.is_empty() {
284 + out.push_str("<span");
285 + class_attr(&["row-tokens"], opts, out);
286 + out.push('>');
287 + for tag in &row.tokens {
288 + tag_html(tag, morphs, opts, out);
289 + }
290 + out.push_str("</span>");
291 + }
292 +
218 293 if !row.actions.is_empty() {
219 294 out.push_str("<span");
220 295 class_attr(&["row-actions"], opts, out);
@@ -300,13 +375,7 @@
300 375
301 376 Node::Act(act) => act_html(act, morphs, opts, out),
302 377
303 - Node::Token {
304 - kind,
305 - label,
306 - tone,
307 - latched,
308 - action,
309 - } => token_html(*kind, label, *tone, *latched, action.as_ref(), morphs, opts, out),
378 + Node::Token(tag) => tag_html(tag, morphs, opts, out),
310 379
311 380 Node::Notice { kind, tone, text } => {
312 381 let mut classes = vec![match kind {
@@ -406,24 +475,36 @@
406 475 options,
407 476 chosen,
408 477 action,
409 - } => select_html(*kind, options, chosen.as_deref(), action.as_ref(), morphs, opts, out),
478 + } => select_html(
479 + *kind,
480 + options,
481 + chosen.as_deref(),
482 + action.as_ref(),
483 + morphs,
484 + opts,
485 + out,
486 + ),
410 487
411 488 Node::Region(slot) => slot_html(slot, morphs, opts, out),
412 489 }
413 490 }
414 491
415 492 /// A small labelled thing sitting inside something else.
416 - #[expect(clippy::too_many_arguments, reason = "one per field of Node::Token")]
417 - fn token_html(
418 - kind: layout::Token,
419 - label: &str,
420 - tone: layout::Tone,
421 - latched: bool,
422 - action: Option<&Action>,
423 - morphs: bool,
424 - opts: &Emit,
425 - out: &mut String,
426 - ) {
493 + ///
494 + /// Took eight arguments, one per field of `Node::Token`, until the payload
495 + /// became [`Tag`] so a row could carry one. The `too_many_arguments` allow went
496 + /// with them.
497 + fn tag_html(tag: &Tag, morphs: bool, opts: &Emit, out: &mut String) {
498 + let Tag {
499 + kind,
500 + label,
501 + tone,
502 + latched,
503 + action,
504 + } = tag;
505 + let (kind, tone, latched) = (*kind, *tone, *latched);
506 + let action = action.as_ref();
507 +
427 508 let mut classes = vec![match kind {
428 509 layout::Token::Badge => "badge",
429 510 layout::Token::Chip { .. } => "chip",
@@ -439,11 +520,17 @@
439 520 // The description says which through the kind, which is the whole reason
440 521 // the two are separate members rather than one with a flag.
441 522 let interactive = kind.interactive() && action.is_some();
442 - if interactive {
443 - out.push_str("<button type=\"button\"");
523 + let close = if interactive {
524 + // An interactive tag whose destination leaves the app is an anchor, for
525 + // the reason `control_tag` gives. A tag that answers nothing stays a
526 + // span either way.
527 + let (open, close) = action.map_or(("<span", "</span>"), control_tag);
528 + out.push_str(open);
529 + close
444 530 } else {
445 531 out.push_str("<span");
446 - }
532 + "</span>"
533 + };
447 534 class_attr(&classes, opts, out);
448 535
449 536 if interactive {
@@ -465,11 +552,7 @@
465 552 class_attr(&["chip-remove"], opts, out);
466 553 out.push_str(" aria-hidden=\"true\"></span>");
467 554 }
468 - if interactive {
469 - out.push_str("</button>");
470 - } else {
471 - out.push_str("</span>");
472 - }
555 + out.push_str(close);
473 556 }
474 557
475 558 /// A control that picks between things.
@@ -519,9 +602,7 @@
519 602 // The picked value travels under one name, decided once in
520 603 // `Node::SELECTED`, rather than agreed per screen between a
521 604 // renderer and a handler.
522 - let carrying = action
523 - .clone()
524 - .with(Node::SELECTED, option.value.clone());
605 + let carrying = action.clone().with(Node::SELECTED, option.value.clone());
525 606 action_attrs(&carrying, morphs, out);
526 607 }
527 608
@@ -9,7 +9,7 @@
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, Row};
12 + use quasi_router::screen::{Act, Cells, Choice, Column, Field, Row, Tag};
13 13 use quasi_router::{Action, Node, RegionKind, Screen, Slot};
14 14
15 15 use crate::{Shell, Webview};
@@ -89,12 +89,16 @@
89 89
90 90 #[test]
91 91 fn params_travel_as_hx_vals_not_as_a_query_string() {
92 - let action = Action::get("/tasks").with("filter", "open").with("sort", "due");
92 + let action = Action::get("/tasks")
93 + .with("filter", "open")
94 + .with("sort", "due");
93 95 let html = fragment(&Node::act("Filter", action));
94 96
95 97 assert!(html.contains("hx-get=\"/tasks\""));
96 98 assert!(!html.contains('?'));
97 - assert!(html.contains("hx-vals=\"{&quot;filter&quot;:&quot;open&quot;,&quot;sort&quot;:&quot;due&quot;}\""));
99 + assert!(html.contains(
100 + "hx-vals=\"{&quot;filter&quot;:&quot;open&quot;,&quot;sort&quot;:&quot;due&quot;}\""
101 + ));
98 102 }
99 103
100 104 #[test]
@@ -117,7 +121,9 @@
117 121 let screen = Screen::list_detail("Tasks", false)
118 122 .with(
119 123 Slot::new("list", RegionKind::Pane)
120 - .with(Node::list([Row::new("One").activate(Action::get("/tasks/1"))]))
124 + .with(Node::list([
125 + Row::new("One").activate(Action::get("/tasks/1"))
126 + ]))
121 127 .with(Node::act("New", Action::post("/tasks"))),
122 128 )
123 129 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")));
@@ -174,36 +180,108 @@
174 180 }
175 181
176 182 #[test]
177 - fn a_selected_row_says_so_to_a_screen_reader() {
183 + fn the_current_row_says_so_to_a_screen_reader() {
184 + // This field was called `selected` until 2026-08-08 and this test asserted
185 + // both meanings at once, because there was only one field to assert. It is
186 + // the app's own pointer: what the detail pane is showing.
178 187 let html = fragment(&Node::list([Row {
179 - selected: true,
188 + current: true,
180 189 ..Row::new("One")
181 190 }]));
182 191 assert!(html.contains("aria-current=\"true\""));
183 - assert!(html.contains("row-selected"));
192 + assert!(html.contains("row-current"));
193 + // Not a tick. Nothing here is selectable, so no checkbox.
194 + assert!(!html.contains("type=\"checkbox\""));
195 + }
196 +
197 + #[test]
198 + fn a_selectable_row_gets_a_real_checkbox_and_an_unselectable_one_gets_nothing() {
199 + // The other half of the split. `selected` is now the user's tick, and
200 + // `Option` is what tells "not ticked" from "not tickable" -- the ambiguity
201 + // that made goingson's bulk-selection checkbox undescribable.
202 + let untickable = fragment(&Node::list([Row::new("One")]));
203 + assert!(!untickable.contains("type=\"checkbox\""));
204 +
205 + let unticked = fragment(&Node::list([Row::new("One").selectable(false)]));
206 + assert!(unticked.contains("type=\"checkbox\""));
207 + assert!(!unticked.contains(" checked"));
208 + assert!(!unticked.contains("row-selected"));
209 +
210 + let ticked = fragment(&Node::list([Row::new("One").selectable(true)]));
211 + assert!(ticked.contains("type=\"checkbox\""));
212 + assert!(ticked.contains(" checked"));
213 + assert!(ticked.contains("row-selected"));
214 + // A tick is not the app's pointer, so it claims no `aria-current`.
215 + assert!(!ticked.contains("aria-current"));
216 + }
217 +
218 + #[test]
219 + fn a_row_carries_its_tokens_as_tokens_rather_than_as_joined_text() {
220 + // What makeover-layout 0.9.0's `RowPart::Tokens` was added for. Both
221 + // goingson ports had to join two trailing facts into `meta` and lost what
222 + // the second one was; here the tone survives to the markup.
223 + let html = fragment(&Node::list([Row::new("Mine")
224 + .meta("3 files")
225 + .token(Tag::badge("Side Project"))
226 + .token(Tag::badge("On Hold").tone(layout::Tone::Warning))]));
227 +
228 + assert!(html.contains("class=\"row-tokens\""));
229 + assert!(html.contains("On Hold"));
230 + // The thing the join could not keep.
231 + assert!(html.contains("warning"));
232 + // And the plain fact stays a plain fact rather than becoming a badge.
233 + assert!(html.contains("class=\"row-meta\">3 files</span>"));
234 + }
235 +
236 + #[test]
237 + fn an_external_destination_is_an_anchor_and_never_a_route() {
238 + // The contacts screen's social handles. A button that navigates away lies
239 + // to middle-click and to a screen reader, so the element changes with the
240 + // destination and not just the attributes.
241 + let html = fragment(&Node::act(
242 + "Profile",
243 + Action::external("https://example.com/@ada"),
244 + ));
245 +
246 + assert!(html.contains("<a "));
247 + assert!(html.contains("href=\"https://example.com/@ada\""));
248 + assert!(html.contains("rel=\"noopener noreferrer\""));
249 + // Nothing here is htmx's business: no route is called and nothing swaps.
250 + assert!(!html.contains("hx-get"));
251 + assert!(!html.contains("hx-post"));
252 + assert!(!html.contains("<button"));
253 + }
254 +
255 + #[test]
256 + fn an_external_url_cannot_break_out_of_its_own_attribute() {
257 + let html = fragment(&Node::act(
258 + "Profile",
259 + Action::external("\"><script>alert(1)</script>"),
260 + ));
261 + assert!(!html.contains("<script>"));
184 262 }
185 263
186 264 #[test]
187 265 fn a_badge_is_not_a_button_and_a_chip_is() {
188 - let badge = fragment(&Node::Token {
266 + let badge = fragment(&Node::Token(Tag {
189 267 kind: layout::Token::Badge,
190 268 label: "3".into(),
191 269 tone: layout::Tone::Info,
192 270 latched: false,
193 271 action: Some(Action::get("/x")),
194 - });
272 + }));
195 273 // A badge answers no click however it is dressed, so it emits no transport
196 274 // even when a description hands it an action.
197 275 assert!(!badge.contains("<button"));
198 276 assert!(!badge.contains("hx-get"));
199 277
200 - let chip = fragment(&Node::Token {
278 + let chip = fragment(&Node::Token(Tag {
201 279 kind: layout::Token::Chip { removable: false },
202 280 label: "open".into(),
203 281 tone: layout::Tone::Neutral,
204 282 latched: true,
205 283 action: Some(Action::get("/x")),
206 - });
284 + }));
207 285 assert!(chip.contains("<button"));
208 286 assert!(chip.contains("aria-pressed=\"true\""));
209 287 assert!(chip.contains("hx-get=\"/x\""));
@@ -236,8 +314,8 @@
236 314
237 315 #[test]
238 316 fn a_pending_region_says_it_is_waiting() {
239 - let screen = Screen::list_detail("Tasks", false)
240 - .with(Slot::new("detail", RegionKind::Pane).pending());
317 + let screen =
318 + Screen::list_detail("Tasks", false).with(Slot::new("detail", RegionKind::Pane).pending());
241 319 assert!(render(&screen).contains("aria-busy=\"true\""));
242 320 }
243 321
@@ -245,8 +323,7 @@
245 323 fn a_bespoke_region_is_a_place_and_nothing_else() {
246 324 // Decision 4: the renderer hands the space over under the name the app
247 325 // chose and never interprets it.
248 - let screen = Screen::list_detail("Tasks", false)
249 - .with(Slot::bespoke("player", "media-player"));
326 + let screen = Screen::list_detail("Tasks", false).with(Slot::bespoke("player", "media-player"));
250 327 let html = render(&screen);
251 328
252 329 assert!(html.contains("id=\"player\""));
@@ -257,15 +334,13 @@
257 334
258 335 #[test]
259 336 fn a_slot_id_survives_intact_because_a_fragment_is_aimed_at_it() {
260 - let screen =
261 - Screen::sidebar_content("Feeds").with(Slot::new("feed-list", RegionKind::Sidebar));
337 + let screen = Screen::sidebar_content("Feeds").with(Slot::new("feed-list", RegionKind::Sidebar));
262 338 assert!(render(&screen).contains("id=\"feed-list\""));
263 339 }
264 340
265 341 #[test]
266 342 fn a_modal_says_it_takes_input_until_dismissed() {
267 - let screen =
268 - Screen::list_detail("Tasks", false).with(Slot::new("confirm", RegionKind::Modal));
343 + let screen = Screen::list_detail("Tasks", false).with(Slot::new("confirm", RegionKind::Modal));
269 344 let html = render(&screen);
270 345 assert!(html.contains("role=\"dialog\""));
271 346 assert!(html.contains("aria-modal=\"true\""));
@@ -392,7 +467,9 @@
392 467 #[test]
393 468 fn stylesheets_link_in_the_order_they_were_added() {
394 469 let shell = Shell::default().styled("/a.css").styled("/b.css");
395 - let html = Webview::new().with_shell(shell).screen(&Screen::list_detail("A", false));
470 + let html = Webview::new()
471 + .with_shell(shell)
472 + .screen(&Screen::list_detail("A", false));
396 473
397 474 let a = html.find("/a.css").expect("a is linked");
398 475 let b = html.find("/b.css").expect("b is linked");
@@ -402,7 +479,9 @@
402 479 #[test]
403 480 fn injected_head_markup_lands_last_so_it_can_override() {
404 481 let shell = Shell::default().with_head("<link rel=\"icon\" href=\"/f.png\">");
405 - let html = Webview::new().with_shell(shell).screen(&Screen::list_detail("A", false));
482 + let html = Webview::new()
483 + .with_shell(shell)
484 + .screen(&Screen::list_detail("A", false));
406 485
407 486 let icon = html.find("/f.png").expect("the icon is linked");
408 487 let htmx = html.find("htmx.min.js").expect("htmx is linked");
@@ -412,10 +491,10 @@
412 491
413 492 #[test]
414 493 fn a_nested_region_renders_inside_its_parent() {
415 - let screen = Screen::list_detail("Tasks", false).with(
416 - Slot::new("outer", RegionKind::Split)
417 - .with(Node::Region(Slot::new("inner", RegionKind::Pane).with(Node::text("in")))),
418 - );
494 + let screen =
495 + Screen::list_detail("Tasks", false).with(Slot::new("outer", RegionKind::Split).with(
496 + Node::Region(Slot::new("inner", RegionKind::Pane).with(Node::text("in"))),
497 + ));
419 498 let html = render(&screen);
420 499
421 500 let outer = html.find("id=\"outer\"").expect("outer renders");
@@ -442,13 +521,13 @@
442 521 .secondary(hostile)
443 522 .meta(hostile)
444 523 .act(Act::new(hostile, Action::post("/y")))]))
445 - .with(Node::Token {
524 + .with(Node::Token(Tag {
446 525 kind: layout::Token::Chip { removable: true },
447 526 label: hostile.into(),
448 527 tone: layout::Tone::Neutral,
449 528 latched: false,
450 529 action: Some(Action::get("/z")),
451 - }),
530 + })),
452 531 );
453 532
454 533 let html = render(&screen);