Skip to main content

max / quasi

An internal navigation is a link, not a button that fetches A Method::Get to a Destination::Route emitted <button hx-get>. But a read of a route this app answers is a link in every host, and calling it a button cost middle-click, copy-link, crawlability and any page whose JavaScript did not run. The anchor costs the webview host nothing: htmx keeps its own attributes and prevents the default. A write stays a button however it is spelled. An anchor is something a browser may prefetch and a crawler will follow, and neither is allowed to delete a task. That line is now one predicate, is_link, so the element and the attributes cannot answer it differently. The href carries the params, because a link to a filtered list that drops the filter is a different place. It is built by quasi-http::route_url, which is the function the redirect already used: the same address, and one place for the escaping. A latched chip that turned out to be a link says aria-current rather than aria-pressed. Pressed is a button's state and means nothing on an anchor.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 17:24 UTC
Signed with PGP, not checked
Commit: 8ab02e3e82b16255417b5a0b7102cefe0c83e3d5
Parent: 8d48342
3 files changed, +136 insertions, -29 deletions
@@ -203,17 +203,7 @@
203 203 /// written: a `mailto:` or a `file://` has no query string this router built.
204 204 fn redirect(action: &Action) -> http::Response<Vec<u8>> {
205 205 let (header, address) = match &action.destination {
206 - Destination::Route(path) if action.params.is_empty() => (htmx::LOCATION, path.clone()),
207 - Destination::Route(path) => {
208 - // Encoded here rather than in the router, for the reason the router
209 - // does not decode: the host has this code already and a second
210 - // implementation is a second place for an escaping bug.
211 - let query = form_urlencoded::Serializer::new(String::new())
212 - .extend_pairs(action.params.iter())
213 - .finish();
214 - let joiner = if path.contains('?') { '&' } else { '?' };
215 - (htmx::LOCATION, format!("{path}{joiner}{query}"))
216 - }
206 + Destination::Route(path) => (htmx::LOCATION, route_url(path, &action.params)),
217 207 Destination::External(address) => (htmx::REDIRECT, address.clone()),
218 208 };
219 209 let mut builder = http::Response::builder().status(200);
@@ -225,6 +215,29 @@
225 215 .expect("a response with no body and one checked header is always valid")
226 216 }
227 217
218 + /// A route with its parameters folded into a query string.
219 + ///
220 + /// Encoded here rather than in the router, for the reason the router does not
221 + /// decode: the host has this code already and a second implementation is a
222 + /// second place for an escaping bug. Public because the renderers need the same
223 + /// answer the redirect does. A read that a renderer writes into an `href` and a
224 + /// redirect back to a filtered list are the same address, and two functions
225 + /// building it is how they stop being.
226 + ///
227 + /// A route with no parameters is returned as written, so nothing gains a
228 + /// trailing `?` it did not have.
229 + #[must_use]
230 + pub fn route_url(path: &str, params: &Params) -> String {
231 + if params.is_empty() {
232 + return path.to_owned();
233 + }
234 + let query = form_urlencoded::Serializer::new(String::new())
235 + .extend_pairs(params.iter())
236 + .finish();
237 + let joiner = if path.contains('?') { '&' } else { '?' };
238 + format!("{path}{joiner}{query}")
239 + }
240 +
228 241 /// Turn the adapter's own refusal into a response.
229 242 ///
230 243 /// No body, because there is nothing to say that the status does not already
@@ -222,6 +222,20 @@
222 222 return;
223 223 }
224 224
225 + // A read of a route this app answers is a link, and it gets the address as
226 + // well as the transport. htmx uses `hx-get` and prevents the default, so
227 + // the `href` is what everything else uses: middle-click, copy-link, a
228 + // crawler, and the page with JS off. The parameters are folded into it
229 + // because a link to a filtered list that drops the filter is a different
230 + // place, and `hx-vals` below carries the same ones down htmx's path.
231 + if let Destination::Route(path) = &action.destination
232 + && !action.method.mutates()
233 + {
234 + out.push_str(" href=\"");
235 + out.push_str(&escape(&quasi_http::route_url(path, &action.params)));
236 + out.push('"');
237 + }
238 +
225 239 let verb = match action.method {
226 240 Method::Get => " hx-get=\"",
227 241 Method::Post => " hx-post=\"",
@@ -264,18 +278,32 @@
264 278 }
265 279 }
266 280
281 + /// Whether an action is somewhere to go rather than something to do.
282 + ///
283 + /// Two ways to be a link. An external destination leaves. A read of a route
284 + /// this app answers is also a link: it has an address, it can be visited
285 + /// directly, and nothing changes because it was.
286 + ///
287 + /// A write is never a link however it is spelled, which is the whole of the
288 + /// other side. An anchor is something a browser may prefetch and a crawler will
289 + /// follow, and neither is allowed to delete a task.
290 + const fn is_link(action: &Action) -> bool {
291 + action.destination.is_external() || !action.method.mutates()
292 + }
293 +
267 294 /// The element a control becomes.
268 295 ///
269 - /// A route is a button: it calls this app and something here answers. An
270 - /// external destination is an anchor: it leaves, and a button that navigates
271 - /// away is a button lying to everything that reads the page, middle-click and
272 - /// screen readers included.
296 + /// A link is an anchor and a write is a button, and a button that navigates is
297 + /// a button lying to everything that reads the page: middle-click, copy-link,
298 + /// a crawler and a screen reader included. This keyed on external-or-not until
299 + /// the read case was separated out, which made every internal navigation a
300 + /// control that only worked by running JavaScript first.
273 301 ///
274 302 /// Branching on the [`Destination`] variant and never on the shape of the
275 303 /// string is the rule `Destination`'s own docs set. "Starts with https" is how a
276 304 /// route named `/https-setup` ends up opening a browser.
277 305 const fn control_tag(action: &Action) -> (&'static str, &'static str) {
278 - if action.destination.is_external() {
306 + if is_link(action) {
279 307 ("<a", "</a>")
280 308 } else {
281 309 ("<button type=\"button\"", "</button>")
@@ -304,7 +332,7 @@
304 332 // actually stops it: an `<a>` without one is not a link, so it
305 333 // drops out of the tab order on its own. `aria-disabled` is what
306 334 // says why, since a bare span-shaped anchor says nothing.
307 - if act.action.destination.is_external() {
335 + if is_link(&act.action) {
308 336 out.push_str(" aria-disabled=\"true\"");
309 337 } else {
310 338 out.push_str(" disabled");
@@ -832,10 +860,17 @@
832 860
833 861 if interactive {
834 862 if latched {
835 - // A chip standing for a filter is on or off, which is what
836 - // aria-pressed means. Its latched class carries the same fact
837 - // visually through Depth::pressed.
838 - out.push_str(" aria-pressed=\"true\"");
863 + // A chip standing for a filter is on or off, and its latched class
864 + // carries that fact visually through Depth::pressed either way.
865 + // Which word says it depends on what the chip turned out to be:
866 + // aria-pressed is a button's state and means nothing on an anchor,
867 + // and a link that is the view you are looking at is the one thing
868 + // aria-current exists to say.
869 + if action.is_some_and(is_link) {
870 + out.push_str(" aria-current=\"true\"");
871 + } else {
872 + out.push_str(" aria-pressed=\"true\"");
873 + }
839 874 }
840 875 if let Some(action) = action {
841 876 action_attrs(action, Fires::Click, None, morphs, out);
@@ -87,6 +87,35 @@
87 87 assert!(!post.contains("hx-get"));
88 88 }
89 89
90 + #[test]
91 + fn a_read_of_a_route_is_a_link_and_a_write_is_not() {
92 + // A GET to a route this app answers is a link in every host, and it was a
93 + // `<button hx-get>` until 2026-08-10: a control that only worked once
94 + // JavaScript had run, with no middle-click, no copy-link and nothing for a
95 + // crawler. The anchor costs the webview host nothing, because htmx still
96 + // has its own attributes and prevents the default.
97 + let read = fragment(&Node::act("Open", Action::get("/tasks/1")));
98 + assert!(read.contains("<a "));
99 + assert!(read.contains("href=\"/tasks/1\""));
100 + assert!(read.contains("hx-get=\"/tasks/1\""));
101 + assert!(!read.contains("<button"));
102 +
103 + // The other half, and the reason this is not simply "GET is an anchor
104 + // everywhere": a write is never a link however it is spelled. An anchor is
105 + // something a browser may prefetch and a crawler will follow, and neither
106 + // is allowed to delete a task.
107 + let write = fragment(&Node::act("Delete", Action::post("/tasks/1/delete")));
108 + assert!(write.contains("<button"));
109 + assert!(!write.contains("href"));
110 +
111 + // An external address is still a plain link that leaves, with no transport
112 + // on it at all.
113 + let away = fragment(&Node::act("Docs", Action::external("https://example.com")));
114 + assert!(away.contains("href=\"https://example.com\""));
115 + assert!(away.contains("rel=\"noopener noreferrer\""));
116 + assert!(!away.contains("hx-get"));
117 + }
118 +
90 119 #[test]
91 120 fn params_travel_as_hx_vals_not_as_a_query_string() {
92 121 let action = Action::get("/tasks")
@@ -94,11 +123,17 @@
94 123 .with("sort", "due");
95 124 let html = fragment(&Node::act("Filter", action));
96 125
126 + // The transport carries them as values and htmx folds them in itself, so
127 + // nothing here concatenates a `?` into what is sent.
97 128 assert!(html.contains("hx-get=\"/tasks\""));
98 - assert!(!html.contains('?'));
99 129 assert!(html.contains(
100 130 "hx-vals=\"{&quot;filter&quot;:&quot;open&quot;,&quot;sort&quot;:&quot;due&quot;}\""
101 131 ));
132 +
133 + // The href is the same address for the reader who is not htmx, and it
134 + // keeps the parameters: a link to a filtered list that drops the filter is
135 + // a different place.
136 + assert!(html.contains("href=\"/tasks?filter=open&amp;sort=due\""));
102 137 }
103 138
104 139 #[test]
@@ -171,11 +206,14 @@
171 206
172 207 #[test]
173 208 fn a_row_is_a_control_only_when_selecting_it_does_something() {
209 + // Opening a row is a read of a route, so it is a link and has an address.
174 210 let live = fragment(&Node::list([Row::new("One").activate(Action::get("/1"))]));
175 - assert!(live.contains("<button type=\"button\" class=\"row-activate\""));
211 + assert!(live.contains("<a class=\"row-activate\""));
212 + assert!(live.contains("href=\"/1\""));
176 213
177 214 let inert = fragment(&Node::list([Row::new("One")]));
178 215 assert!(!inert.contains("<button"));
216 + assert!(!inert.contains("<a "));
179 217 assert!(inert.contains("<span class=\"row-primary\">One</span>"));
180 218 }
181 219
@@ -330,7 +368,8 @@
330 368 )],
331 369 });
332 370
333 - assert!(html.contains("<button type=\"button\" class=\"figure-act\""));
371 + assert!(html.contains("<a class=\"figure-act\""));
372 + assert!(html.contains("href=\"/settings/held\""));
334 373 assert!(html.contains("hx-get=\"/settings/held\""));
335 374 // And the figure inside it is the same markup an inert one would be.
336 375 assert!(html.contains(&makeover_webview::figure::figure_html(
@@ -365,7 +404,9 @@
365 404 // A badge answers no click however it is dressed, so it emits no transport
366 405 // even when a description hands it an action.
367 406 assert!(!badge.contains("<button"));
407 + assert!(!badge.contains("<a "));
368 408 assert!(!badge.contains("hx-get"));
409 + assert!(!badge.contains("href"));
369 410
370 411 let chip = fragment(&Node::Token(Tag {
371 412 kind: layout::Token::Chip { removable: false },
@@ -374,9 +415,25 @@
374 415 latched: true,
375 416 action: Some(Action::get("/x")),
376 417 }));
377 - assert!(chip.contains("<button"));
378 - assert!(chip.contains("aria-pressed=\"true\""));
418 + // A read of a route, so the chip is a link and says its state the way a
419 + // link says it. aria-pressed is a button's word and means nothing here.
420 + assert!(chip.contains("<a "));
421 + assert!(chip.contains("aria-current=\"true\""));
422 + assert!(!chip.contains("aria-pressed"));
423 + assert!(chip.contains("href=\"/x\""));
379 424 assert!(chip.contains("hx-get=\"/x\""));
425 +
426 + // A chip whose activation writes is still a button, and still says so.
427 + let toggle = fragment(&Node::Token(Tag {
428 + kind: layout::Token::Chip { removable: false },
429 + label: "open".into(),
430 + tone: layout::Tone::Neutral,
431 + latched: true,
432 + action: Some(Action::post("/x/toggle")),
433 + }));
434 + assert!(toggle.contains("<button"));
435 + assert!(toggle.contains("aria-pressed=\"true\""));
436 + assert!(!toggle.contains("href"));
380 437 }
381 438
382 439 #[test]
@@ -835,9 +892,11 @@
835 892
836 893 assert!(html.contains(r#"aria-sort="ascending""#));
837 894 assert_eq!(html.matches("data-sortable").count(), 2);
838 - // The press is a button inside the header cell, never the cell itself: a
839 - // `columnheader` is not a control and must not announce itself as one.
840 - assert!(html.contains(r#"<button type="button" class="table-sort""#));
895 + // The press is its own control inside the header cell, never the cell
896 + // itself: a `columnheader` is not a control and must not announce itself as
897 + // one. Reordering is a read, so the control is a link and is addressable.
898 + assert!(html.contains(r#"<a class="table-sort""#));
899 + assert!(html.contains("href=\"/tasks?sort=due\""));
841 900 assert!(html.contains("hx-get=\"/tasks?sort=due\""));
842 901 }
843 902