Skip to main content

max / quasi

The overlay container follows chrome, not bindings A route can answer Outcome::Over without a binding being involved: a control on a screen calls it, and the route answers with an overlay. goingson does exactly that and declares no bindings, so the container was dropped out of the one document that needed it. The retarget went out anyway, htmx found no such element, and the swap did nothing. The guard is now any declared chrome rather than a non-empty binding list. An app declaring no chrome at all still gets the byte-for-byte document it always got, which is what keeps this additive. The container also says what it is: data-chrome="overlay", and a comment in front of it rather than inside, since an overlay swap replaces the contents and would take the annotation with it. Outcome::Over now states the contract it had left implicit.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-21 23:27 UTC
Signed with PGP, not checked
Commit: feaaac0e751b7558bf3222698aaf963b9003d538
Parent: 347072a
2 files changed, +55 insertions, -10 deletions
@@ -198,6 +198,14 @@
198 198 /// what was missing was never the contents but the way to say "drawn over".
199 199 /// The way in is usually a [`Chrome`](crate::Chrome) binding, since an
200 200 /// affordance available from everywhere is what an overlay normally is.
201 + /// Usually and not always: a control on one screen can call a route that
202 + /// answers this, which is how goingson opens a focus countdown from a row.
203 + ///
204 + /// **An app answering this declares chrome, even when it declares no
205 + /// binding.** A renderer draws the overlay into a container it emits once
206 + /// per document, and it emits that container for an app that declares
207 + /// chrome. An app that declares none has nowhere to put the answer, and
208 + /// what that looks like is a swap that does nothing rather than an error.
201 209 ///
202 210 /// Not [`RegionKind::Modal`](crate::RegionKind::Modal), which is a modal a
203 211 /// screen *contains* and goes when that screen goes. This one belongs to
@@ -18,7 +18,11 @@
18 18 //! Plus the overlay container, which is where an
19 19 //! [`Outcome::Over`](quasi_router::Outcome::Over) lands. It is emitted empty
20 20 //! and stays empty until something is drawn into it, so a document with chrome
21 - //! and no overlay open is a document with one spare `div` in it.
21 + //! and no overlay open is a document with one spare `div` in it. That spare
22 + //! `div` is the rule and not an edge case: any app declaring chrome gets one,
23 + //! because a route can answer `Over` without a binding to open it, and
24 + //! [`overlay_target`](quasi_http::Serves::overlay_target) promises this
25 + //! renderer has a container to aim at whatever the app declared.
22 26 //!
23 27 //! # Interpreting a key name is this crate's job, not the description's
24 28 //!
@@ -59,14 +63,25 @@
59 63 for binding in &chrome.bindings {
60 64 binding_html(binding, out);
61 65 }
62 - // Emitted only alongside bindings, because a document with no way to open
63 - // an overlay has nothing to put in one. A host driving `Outcome::Over` from
64 - // somewhere other than a binding declares a binding for it. A panel is not
65 - // a way to open one: it is drawn, and what its controls call is a route.
66 - if !chrome.bindings.is_empty() {
66 + // Emitted whenever the app declares any chrome, because a route can answer
67 + // `Outcome::Over` without a binding being involved: a control on a screen
68 + // calls a route, and the route answers with an overlay. Bindings were the
69 + // wrong condition. They were the only way an overlay was opened when this
70 + // was written, and goingson opens one from a row's control with no binding
71 + // anywhere in its chrome, so the guard dropped the container out of exactly
72 + // the document that needed it.
73 + //
74 + // Not unconditional, so an app declaring no chrome at all still gets the
75 + // byte-for-byte document above.
76 + //
77 + // The comment rides in front rather than inside: an overlay swap replaces
78 + // the container's contents, so a comment within it would be destroyed by
79 + // the first `Over` and gone by the time anyone inspected the element.
80 + if !chrome.bindings.is_empty() || chrome.panel.is_some() {
81 + out.push_str("<!-- quasi: overlay container; an Outcome::Over lands here -->");
67 82 out.push_str("<div id=\"");
68 83 out.push_str(OVERLAY_ID);
69 - out.push_str("\"></div>");
84 + out.push_str("\" data-chrome=\"overlay\"></div>");
70 85 }
71 86 if let Some(panel) = &chrome.panel {
72 87 panel_html(panel, opts, fills, out);
@@ -281,8 +296,27 @@
281 296 assert!(out.contains("id=\"timer\""), "{out}");
282 297 assert!(out.contains("chrome-panel"), "{out}");
283 298 assert!(out.contains("00:12:04"), "{out}");
284 - // No binding, so no way to open an overlay and nothing to put in one.
285 - assert!(!out.contains(OVERLAY_ID), "{out}");
299 + // A panel and no bindings, which is goingson's chrome, and the case
300 + // that used to lose the container: its Focus control calls a route
301 + // that answers `Over`, and the retarget aimed at an element the
302 + // document did not have.
303 + assert!(out.contains(OVERLAY_ID), "{out}");
304 + }
305 +
306 + #[test]
307 + fn the_overlay_container_says_what_it_is_and_says_it_outside_itself() {
308 + use quasi_router::Node;
309 +
310 + let chrome = Chrome::new().presenting("timer", Node::text("00:12:04"));
311 + let mut out = String::new();
312 + chrome_html(&chrome, &Emit::default(), &HashMap::new(), &mut out);
313 + // The annotation is for whoever finds an empty div in devtools, so it
314 + // has to survive the swap that fills the div. Before the element, not
315 + // within it.
316 + let comment = out.find("<!-- quasi: overlay").expect("annotated");
317 + let container = out.find("<div id=\"quasi-overlay\"").expect("emitted");
318 + assert!(comment < container, "{out}");
319 + assert!(out.contains("data-chrome=\"overlay\""), "{out}");
286 320 }
287 321
288 322 #[test]
@@ -296,7 +330,10 @@
296 330 assert!(out.contains("hx-target=\"#quasi-overlay\""), "{out}");
297 331 assert!(out.contains("from:body"), "{out}");
298 332 assert!(out.contains("aria-label=\"Search\""), "{out}");
299 - assert!(out.contains("<div id=\"quasi-overlay\"></div>"), "{out}");
333 + assert!(
334 + out.contains("<div id=\"quasi-overlay\" data-chrome=\"overlay\"></div>"),
335 + "{out}"
336 + );
300 337 }
301 338
302 339 #[test]