Skip to main content

max / quasi

The nav goes before the content, not in the trailing chrome block Emitting it with the panels put a navigation after `<main>`, which is a landmark a screen reader reaches last and a block the stylesheet has to lift with `position: fixed` and then pay for in padding. Document order is the one placement decision this renderer makes, so it makes it there instead: `nav_html` runs between the shell's open and the `<main>` tag, and `chrome_html` keeps the bindings, the overlay container and the panels, which genuinely do belong after the content. `chrome_html` loses the `at` parameter with the nav, and `parts` says why it has no nav to emit: a host assembling its own body writes the content itself, and `Chrome::nav` is public for it to place.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 16:23 UTC
Signed with PGP, not checked
Commit: 9c13ecb22862f490cee9eb7572643384e6b526d5
Parent: c3c2130
4 files changed, +67 insertions, -56 deletions
M Cargo.lock +4 -4
@@ -6197,10 +6197,6 @@
6197 6197 "winnow 1.0.4",
6198 6198 ]
6199 6199
6200 - [[patch.unused]]
6201 - name = "quasi-type"
6202 - version = "0.1.0"
6203 -
6204 6200 [[patch.unused]]
6205 6201 name = "kberg"
6206 6202 version = "0.1.0"
@@ -6217,6 +6213,10 @@
6217 6213 name = "tagtree"
6218 6214 version = "0.4.1"
6219 6215
6216 + [[patch.unused]]
6217 + name = "quasi-type"
6218 + version = "0.1.0"
6219 +
6220 6220 [[patch.unused]]
6221 6221 name = "synckit-client"
6222 6222 version = "0.8.1"
@@ -68,17 +68,10 @@
68 68 /// makes this additive.
69 69 pub(crate) fn chrome_html(
70 70 chrome: &Chrome,
71 - at: Option<&str>,
72 71 opts: &Emit,
73 72 fills: &HashMap<String, String>,
74 73 out: &mut String,
75 74 ) {
76 - // First among the chrome, which is the one placement decision this renderer
77 - // does make and it makes it in document order rather than in pixels: a
78 - // navigation landmark that comes after everything else is reachable and
79 - // reads as an afterthought. Where it lands visually is still the
80 - // stylesheet's, same as a panel's.
81 - nav_html(&chrome.nav, at, opts, out);
82 75 for binding in &chrome.bindings {
83 76 binding_html(binding, out);
84 77 }
@@ -109,6 +102,13 @@
109 102
110 103 /// The nav: the app's places, as links, with the current one marked.
111 104 ///
105 + /// Emitted before the screen rather than with the rest of the chrome, and that
106 + /// is the one placement decision this renderer makes. It makes it in document
107 + /// order and not in pixels: a navigation that comes after the content is a
108 + /// landmark a screen reader reaches last and a block a stylesheet has to lift
109 + /// with `position: fixed` and then pay for in padding. Where it lands visually
110 + /// is still the stylesheet's, the same as a panel's.
111 + ///
112 112 /// A `nav` element and not a list of buttons. These are addresses, so they are
113 113 /// links: middle-click opens one in a tab, the status bar shows where it goes,
114 114 /// and a screen reader lands on a navigation landmark. None of that is
@@ -374,13 +374,7 @@
374 374 #[test]
375 375 fn an_app_with_no_chrome_emits_nothing_at_all() {
376 376 let mut out = String::new();
377 - chrome_html(
378 - &Chrome::new(),
379 - None,
380 - &Emit::default(),
381 - &HashMap::new(),
382 - &mut out,
383 - );
377 + chrome_html(&Chrome::new(), &Emit::default(), &HashMap::new(), &mut out);
384 378 assert!(out.is_empty());
385 379 }
386 380
@@ -394,7 +388,7 @@
394 388 Node::text("00:12:04"),
395 389 );
396 390 let mut out = String::new();
397 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
391 + chrome_html(&chrome, &Emit::default(), &HashMap::new(), &mut out);
398 392 assert!(out.contains("id=\"timer\""), "{out}");
399 393 assert!(out.contains("chrome-panel"), "{out}");
400 394 assert!(out.contains("00:12:04"), "{out}");
@@ -415,7 +409,7 @@
415 409 Node::text("00:12:04"),
416 410 );
417 411 let mut out = String::new();
418 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
412 + chrome_html(&chrome, &Emit::default(), &HashMap::new(), &mut out);
419 413 // The annotation is for whoever finds an empty div in devtools, so it
420 414 // has to survive the swap that fills the div. Before the element, not
421 415 // within it.
@@ -433,7 +427,7 @@
433 427 .presenting("timer", Role::Activity, Node::text("00:12:04"))
434 428 .presenting("sync", Role::Status, Node::text("Synced"));
435 429 let mut out = String::new();
436 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
430 + chrome_html(&chrome, &Emit::default(), &HashMap::new(), &mut out);
437 431
438 432 assert!(out.contains("id=\"timer\""), "{out}");
439 433 assert!(out.contains("id=\"sync\""), "{out}");
@@ -448,9 +442,9 @@
448 442 fn the_nav_is_links_because_the_places_are_addresses() {
449 443 use quasi_router::{Action, Place};
450 444
451 - let chrome = Chrome::new().offering(Place::new("time", "Time", Action::get("/day")));
445 + let places = [Place::new("time", "Time", Action::get("/day"))];
452 446 let mut out = String::new();
453 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
447 + nav_html(&places, None, &Emit::default(), &mut out);
454 448
455 449 // A link and not a button: middle-click opens a tab, the status bar
456 450 // shows where it goes, and a screen reader lands on a landmark.
@@ -464,21 +458,16 @@
464 458 fn the_place_the_screen_names_is_the_one_marked_current() {
465 459 use quasi_router::{Action, Place};
466 460
467 - let chrome = Chrome::new()
468 - .offering(Place::new("work", "Work", Action::get("/tasks")).within([
461 + let places = [
462 + Place::new("work", "Work", Action::get("/tasks")).within([
469 463 Place::new("tasks", "Tasks", Action::get("/tasks")),
470 464 Place::new("board", "Board", Action::get("/board")),
471 - ]))
472 - .offering(Place::new("time", "Time", Action::get("/day")));
465 + ]),
466 + Place::new("time", "Time", Action::get("/day")),
467 + ];
473 468
474 469 let mut out = String::new();
475 - chrome_html(
476 - &chrome,
477 - Some("board"),
478 - &Emit::default(),
479 - &HashMap::new(),
480 - &mut out,
481 - );
470 + nav_html(&places, Some("board"), &Emit::default(), &mut out);
482 471
483 472 // The place itself, and the place holding it. Both are where the user
484 473 // is, and a tab bar that lit the pill and not the tab would be lying
@@ -497,25 +486,43 @@
497 486 // A confirmation drawn over one, a detail reached from a row. The last
498 487 // place staying lit would be the nav claiming the user is somewhere
499 488 // they left.
500 - let chrome = Chrome::new().offering(Place::new("time", "Time", Action::get("/day")));
489 + let places = [Place::new("time", "Time", Action::get("/day"))];
501 490 let mut out = String::new();
502 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
491 + nav_html(&places, None, &Emit::default(), &mut out);
503 492 assert!(!out.contains("aria-current"), "{out}");
504 493
505 494 // And a key no place carries is the same: nothing marked, no error. The
506 495 // nav is the app's and so is the key, and a renderer is the wrong place
507 496 // to discover an app disagreeing with itself.
508 497 let mut other = String::new();
509 - chrome_html(
510 - &chrome,
511 - Some("nowhere"),
512 - &Emit::default(),
513 - &HashMap::new(),
514 - &mut other,
515 - );
498 + nav_html(&places, Some("nowhere"), &Emit::default(), &mut other);
516 499 assert!(!other.contains("aria-current"), "{other}");
517 500 }
518 501
502 + #[test]
503 + fn the_nav_comes_before_the_content_it_navigates() {
504 + use quasi_http::Serves as _;
505 + use quasi_router::{Action, Node, Place, Screen, Slot};
506 +
507 + // Document order, which is the one placement decision this renderer
508 + // makes. A navigation after the content is a landmark reached last and
509 + // a block the stylesheet has to lift with `position: fixed` and then
510 + // pay for in padding.
511 + let webview =
512 + crate::Webview::new().with_shell(crate::Shell::default().with_chrome(
513 + Chrome::new().offering(Place::new("time", "Time", Action::get("/day"))),
514 + ));
515 + let screen = Screen::list_detail("Day", false)
516 + .at_place("time")
517 + .with(Slot::new("body", quasi_router::RegionKind::Pane).with(Node::text("the day")));
518 +
519 + let out = webview.screen(&screen);
520 + let nav = out.find("data-chrome=\"nav\"").expect("drawn");
521 + let main = out.find("<main").expect("drawn");
522 + assert!(nav < main, "{out}");
523 + assert!(out.contains("aria-current=\"page\""), "{out}");
524 + }
525 +
519 526 #[test]
520 527 fn a_nav_alone_still_gets_the_overlay_container() {
521 528 use quasi_router::{Action, Place};
@@ -524,7 +531,7 @@
524 531 // binding involved, so any app declaring chrome gets a container.
525 532 let chrome = Chrome::new().offering(Place::new("time", "Time", Action::get("/day")));
526 533 let mut out = String::new();
527 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
534 + chrome_html(&chrome, &Emit::default(), &HashMap::new(), &mut out);
528 535 assert!(out.contains(OVERLAY_ID), "{out}");
529 536 }
530 537
@@ -534,7 +541,7 @@
534 541
535 542 let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette"));
536 543 let mut out = String::new();
537 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
544 + chrome_html(&chrome, &Emit::default(), &HashMap::new(), &mut out);
538 545 assert!(out.contains("hx-get=\"/palette\""), "{out}");
539 546 assert!(out.contains("hx-target=\"#quasi-overlay\""), "{out}");
540 547 assert!(out.contains("from:body"), "{out}");
@@ -553,7 +560,7 @@
553 560 .bind("dpad-left", "Nope", Action::get("/nope"))
554 561 .bind("ctrl+k", "Search", Action::get("/palette"));
555 562 let mut out = String::new();
556 - chrome_html(&chrome, None, &Emit::default(), &HashMap::new(), &mut out);
563 + chrome_html(&chrome, &Emit::default(), &HashMap::new(), &mut out);
557 564 assert!(!out.contains("/nope"), "{out}");
558 565 assert!(out.contains("/palette"), "{out}");
559 566 }
@@ -296,9 +296,11 @@
296 296 // No screen here, so no place to mark: `parts` is for a host
297 297 // assembling its own body, and which place is showing is a fact about a
298 298 // screen. The nav is drawn; nothing in it is current.
299 + // The nav is not here: `parts` is for a host assembling its own body,
300 + // and a navigation goes before the content that host is about to write.
301 + // `nav` is public on the chrome for exactly that; see `Serves::screen`.
299 302 crate::chrome::chrome_html(
300 303 &self.shell.chrome,
301 - None,
302 304 &self.emit,
303 305 &self.fills,
304 306 &mut parts.body_chrome,
@@ -382,6 +384,16 @@
382 384 self.shell
383 385 .open(&screen.title, Some(&screen.discovery), &mut out);
384 386
387 + // Before the content, which is where a navigation belongs in the
388 + // document whatever a stylesheet later does with it. See
389 + // `chrome::nav_html`.
390 + crate::chrome::nav_html(
391 + &self.shell.chrome.nav,
392 + screen.place.as_deref(),
393 + &self.emit,
394 + &mut out,
395 + );
396 +
385 397 out.push_str("<main class=\"");
386 398 node::class_into(
387 399 Self::arrangement_class(screen.arrangement),
@@ -440,13 +452,7 @@
440 452 crate::frame::frame_html(&self.frame, &resting, &self.emit, &self.fills, &mut out);
441 453 // After the main content: the chrome belongs to the app rather than to
442 454 // the screen, so it sits outside what a screen's markup is.
443 - crate::chrome::chrome_html(
444 - &self.shell.chrome,
445 - screen.place.as_deref(),
446 - &self.emit,
447 - &self.fills,
448 - &mut out,
449 - );
455 + crate::chrome::chrome_html(&self.shell.chrome, &self.emit, &self.fills, &mut out);
450 456 Shell::close(&mut out);
451 457 out
452 458 }
@@ -439,8 +439,6 @@
439 439 let mut body_chrome = String::new();
440 440 crate::chrome::chrome_html(
441 441 &self.chrome,
442 - // See `Webview::parts`: no screen, so no place is current.
443 - None,
444 442 &makeover_webview::Emit::default(),
445 443 &std::collections::HashMap::new(),
446 444 &mut body_chrome,