Skip to main content

max / quasi

Add quasi-webview: a screen description in, an htmx document out
Author: Max Johnson <me@maxj.phd> · 2026-08-08 23:25 UTC
Signed with PGP, not checked
Commit: a97a2b0c61bbe72cb7fdbf9e3f5c4476503be07b
Parent: 7434f76
7 files changed, +1344 insertions, -5 deletions
M Cargo.lock +42 -5
@@ -1718,12 +1718,39 @@
1718 1718 source = "registry+https://github.com/rust-lang/crates.io-index"
1719 1719 checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1720 1720
1721 + [[package]]
1722 + name = "makeover-geometry"
1723 + version = "0.6.2"
1724 + source = "registry+https://github.com/rust-lang/crates.io-index"
1725 + checksum = "c4e0cbec6b86c225f5d4a793437fee457b9bf8e93786ee3af0212d668ad8f132"
1726 +
1721 1727 [[package]]
1722 1728 name = "makeover-layout"
1723 1729 version = "0.8.2"
1724 1730 source = "registry+https://github.com/rust-lang/crates.io-index"
1725 1731 checksum = "9883c75a9d26fce10be2b979c01a74f8c07513800a70ec6bf598de1e7d411f1b"
1726 1732
1733 + [[package]]
1734 + name = "makeover-touch"
1735 + version = "0.4.0"
1736 + source = "registry+https://github.com/rust-lang/crates.io-index"
1737 + checksum = "369083d008157d6d95cd2ac0b8dbfee8689d80eb4c2883b48c2ef627a0116fdb"
1738 + dependencies = [
1739 + "makeover-geometry",
1740 + "makeover-layout",
1741 + ]
1742 +
1743 + [[package]]
1744 + name = "makeover-webview"
1745 + version = "0.17.1"
1746 + source = "registry+https://github.com/rust-lang/crates.io-index"
1747 + checksum = "c8cfb1a75dcdafc4eab17ca9d1e2ae59f66dea218832c604c1cde5c01a1a799c"
1748 + dependencies = [
1749 + "makeover-geometry",
1750 + "makeover-layout",
1751 + "makeover-touch",
1752 + ]
1753 +
1727 1754 [[package]]
1728 1755 name = "markup5ever"
1729 1756 version = "0.38.0"
@@ -1865,7 +1892,7 @@
1865 1892 source = "registry+https://github.com/rust-lang/crates.io-index"
1866 1893 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
1867 1894 dependencies = [
1868 - "proc-macro-crate 1.3.1",
1895 + "proc-macro-crate 2.0.2",
1869 1896 "proc-macro2",
1870 1897 "quote",
1871 1898 "syn 2.0.119",
@@ -2353,6 +2380,16 @@
2353 2380 "tauri",
2354 2381 ]
2355 2382
2383 + [[package]]
2384 + name = "quasi-webview"
2385 + version = "0.1.0"
2386 + dependencies = [
2387 + "makeover-layout",
2388 + "makeover-webview",
2389 + "quasi-http",
2390 + "quasi-router",
2391 + ]
2392 +
2356 2393 [[package]]
2357 2394 name = "quick-xml"
2358 2395 version = "0.38.4"
@@ -4503,10 +4540,6 @@
4503 4540 name = "tagtree"
4504 4541 version = "0.4.0"
4505 4542
4506 - [[patch.unused]]
4507 - name = "docengine"
4508 - version = "0.4.0"
4509 -
4510 4543 [[patch.unused]]
4511 4544 name = "synckit-client"
4512 4545 version = "0.8.0"
@@ -4514,3 +4547,7 @@
4514 4547 [[patch.unused]]
4515 4548 name = "synckit-config"
4516 4549 version = "0.2.0"
4550 +
4551 + [[patch.unused]]
4552 + name = "docengine"
4553 + version = "0.4.0"
M Cargo.toml +1
@@ -6,6 +6,7 @@
6 6 "crates/quasi-http",
7 7 "crates/quasi-router",
8 8 "crates/quasi-tauri",
9 + "crates/quasi-webview",
9 10 ]
10 11
11 12 [workspace.dependencies]
@@ -1,0 +1,19 @@
1 + [package]
2 + name = "quasi-webview"
3 + version = "0.1.0"
4 + description = "The webview renderer for quasi: a screen description in, an htmx document out"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 + quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 + makeover-layout = "0.8.2"
19 + makeover-webview = "0.17.1"
@@ -1,0 +1,152 @@
1 + //! The webview renderer for [`quasi_router`].
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! # Why this is not in `makeover-webview`
6 + //!
7 + //! A [`Screen`](quasi_router::Screen) renderer has to import `quasi-router`,
8 + //! and the makeover/quasi boundary settled 2026-08-08 is audience: a makeover
9 + //! crate is something another developer might use on its own, and quasi is what
10 + //! you use once you are committed to the whole stack. A `makeover-webview` that
11 + //! depended on quasi would stop passing its own test.
12 + //!
13 + //! So the split runs along that line and not along "who emits markup".
14 + //! `makeover-webview` owns the pieces whose description is settled and shared —
15 + //! fields, cell containers, the narrowing rules, the escaping — and this crate
16 + //! owns screen assembly, regions, and the transport. Every piece it can borrow
17 + //! from over there, it does: there is no second field emitter here.
18 + //!
19 + //! # What phase B is, once the pieces exist
20 + //!
21 + //! Assembly, mostly. The vocabulary is closed at two arrangements, seven region
22 + //! kinds and twelve node variants, and none of them is a widget: the admission
23 + //! test in [`quasi_router::screen`] is that a node composes something
24 + //! `makeover-layout` already names. So this crate has no opinions to hold, and
25 + //! the file to read for the interesting ones is [`node`], where htmx enters in
26 + //! a single function.
27 + //!
28 + //! # The transport is replaceable, and that is measurable here
29 + //!
30 + //! Nobody hand-writes `hx-post`. Decision 13 parks the fixi question on the
31 + //! grounds that a transport nothing authors by hand is a transport that can be
32 + //! swapped, and the check on that claim is that
33 + //! [`node::action_attrs`](node) is the only place in this crate naming htmx at
34 + //! all. A test asserts it.
35 +
36 + mod node;
37 + mod shell;
38 +
39 + #[cfg(test)]
40 + mod tests;
41 +
42 + pub use crate::shell::Shell;
43 + pub use makeover_webview::Emit;
44 +
45 + use makeover_layout::Arrangement;
46 + use quasi_http::Render;
47 + use quasi_router::{Node, Screen};
48 +
49 + /// A renderer that answers HTML.
50 + ///
51 + /// Holds the two things a webview needs and a description never carries: where
52 + /// the host's assets live, and what to prefix class names with. Both are
53 + /// values rather than constants because they are the parts that genuinely
54 + /// differ between an axum route and a Tauri custom-protocol handler, and
55 + /// neither is anything the router can know.
56 + #[derive(Debug, Clone, Default)]
57 + pub struct Webview {
58 + /// The document around a screen.
59 + pub shell: Shell,
60 + /// Class naming, shared with `makeover-webview`'s stylesheet half so the
61 + /// emitted markup and the emitted CSS agree on every name.
62 + pub emit: Emit,
63 + }
64 +
65 + impl Webview {
66 + /// A renderer with the default shell and class naming.
67 + #[must_use]
68 + pub fn new() -> Self {
69 + Self::default()
70 + }
71 +
72 + /// A renderer serving its assets from under this prefix.
73 + #[must_use]
74 + pub fn under(prefix: &str) -> Self {
75 + Self {
76 + shell: Shell::under(prefix),
77 + emit: Emit::default(),
78 + }
79 + }
80 +
81 + /// Use this shell, chaining.
82 + #[must_use]
83 + pub fn with_shell(mut self, shell: Shell) -> Self {
84 + self.shell = shell;
85 + self
86 + }
87 +
88 + /// Use this class naming, chaining.
89 + #[must_use]
90 + pub fn with_emit(mut self, emit: Emit) -> Self {
91 + self.emit = emit;
92 + self
93 + }
94 +
95 + /// The class naming the arrangement of a screen's regions.
96 + ///
97 + /// Two, because our apps have two. A third arrives when an app has one,
98 + /// and not before: naming arrangements an app has not asked for is how a
99 + /// description becomes a framework.
100 + fn arrangement_class(arrangement: Arrangement) -> &'static str {
101 + match arrangement {
102 + Arrangement::ListDetail { tabbed: false } => "list-detail",
103 + Arrangement::ListDetail { tabbed: true } => "list-detail-tabbed",
104 + Arrangement::SidebarContent => "sidebar-content",
105 + }
106 + }
107 + }
108 +
109 + impl Render for Webview {
110 + fn screen(&self, screen: &Screen) -> String {
111 + let mut out = String::with_capacity(1024);
112 + self.shell.open(&screen.title, &mut out);
113 +
114 + out.push_str("<main class=\"");
115 + out.push_str(&node::class(
116 + Self::arrangement_class(screen.arrangement),
117 + &self.emit,
118 + ));
119 + out.push_str("\">");
120 +
121 + // Notices before the regions, because a notice belongs to the screen
122 + // rather than to a place in it, and the first thing in the document is
123 + // the one place that is true of. Where they visually land is the
124 + // stylesheet's answer.
125 + if !screen.notices.is_empty() {
126 + out.push_str("<div class=\"");
127 + out.push_str(&node::class("notices", &self.emit));
128 + out.push_str("\">");
129 + for notice in &screen.notices {
130 + node::node_html(notice, self.shell.morphs(), &self.emit, &mut out);
131 + }
132 + out.push_str("</div>");
133 + }
134 +
135 + for slot in &screen.slots {
136 + node::slot_html(slot, self.shell.morphs(), &self.emit, &mut out);
137 + }
138 +
139 + out.push_str("</main>");
140 + Shell::close(&mut out);
141 + out
142 + }
143 +
144 + fn fragment(&self, node: &Node) -> String {
145 + // No shell, by definition: a fragment is the inside of one element and
146 + // htmx puts it there. The router already said which element through
147 + // `HX-Retarget`, so nothing here needs to know.
148 + let mut out = String::with_capacity(256);
149 + node::node_html(node, self.shell.morphs(), &self.emit, &mut out);
150 + out
151 + }
152 + }
@@ -1,0 +1,579 @@
1 + //! Nodes to markup.
2 + //!
3 + //! Every function here takes a piece of [`quasi_router`]'s screen tree and
4 + //! pushes markup onto a buffer. Nothing returns a `Result`: a description that
5 + //! exists is renderable by construction, which is the property the owned mirror
6 + //! in `quasi-router` was built to have.
7 + //!
8 + //! # Where the htmx goes in
9 + //!
10 + //! In exactly one function, [`action_attrs`]. An [`Action`] is a method, a path
11 + //! and some params, and turning that into `hx-get` / `hx-post` / `hx-vals` is
12 + //! the whole of what "htmx is the transport" means in code. Nothing else in
13 + //! this file knows the word htmx, so decision 13's claim that the transport is
14 + //! replaceable is a claim about one function rather than about the crate.
15 + //!
16 + //! No `hx-target` is ever emitted. Decision 7 puts the target on the response,
17 + //! where `quasi-http` sets `HX-Retarget` from
18 + //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router
19 + //! is the only party that knows what it just changed. A control that also named
20 + //! a target would be a second party deciding one thing.
21 +
22 + use std::fmt::Write as _;
23 +
24 + use makeover_layout as layout;
25 + use makeover_webview::Emit;
26 + use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
27 + use makeover_webview::list::{Cell, cells_html};
28 + use quasi_router::screen::{Act, Cells, Node, Row, Slot};
29 + use quasi_router::{Action, Method, Params};
30 +
31 + /// The class-name prefix, applied through [`Emit::class_prefix`].
32 + pub(crate) fn class(name: &str, opts: &Emit) -> String {
33 + format!("{}{name}", opts.class_prefix)
34 + }
35 +
36 + /// Write a `class="..."` attribute, prefixed.
37 + fn class_attr(names: &[&str], opts: &Emit, out: &mut String) {
38 + out.push_str(" class=\"");
39 + for (i, name) in names.iter().enumerate() {
40 + if i > 0 {
41 + out.push(' ');
42 + }
43 + out.push_str(&escape(&class(name, opts)));
44 + }
45 + out.push('"');
46 + }
47 +
48 + /// The class suffix naming a tone.
49 + ///
50 + /// [`Tone::Neutral`] has none: ordinary content is the default, and a class
51 + /// meaning "nothing unusual" is a class on every element in the document.
52 + fn tone_class(tone: layout::Tone) -> Option<&'static str> {
53 + match tone {
54 + layout::Tone::Neutral => None,
55 + layout::Tone::Info => Some("tone-info"),
56 + layout::Tone::Success => Some("tone-success"),
57 + layout::Tone::Warning => Some("tone-warning"),
58 + layout::Tone::Danger => Some("tone-danger"),
59 + }
60 + }
61 +
62 + /// JSON-encode a string, for an `hx-vals` payload.
63 + ///
64 + /// Small enough to own. Pulling in a JSON crate to write object literals of
65 + /// strings would be the larger decision, and the encoder a renderer needs is
66 + /// this: the six characters JSON requires escaped, plus a `\u00XX` form for the
67 + /// rest of the C0 range. The result is then HTML-escaped by the caller, because
68 + /// it lands in an attribute.
69 + fn json_string(text: &str, out: &mut String) {
70 + out.push('"');
71 + for ch in text.chars() {
72 + match ch {
73 + '"' => out.push_str("\\\""),
74 + '\\' => out.push_str("\\\\"),
75 + '\n' => out.push_str("\\n"),
76 + '\r' => out.push_str("\\r"),
77 + '\t' => out.push_str("\\t"),
78 + c if (c as u32) < 0x20 => {
79 + let _ = write!(out, "\\u{:04x}", c as u32);
80 + }
81 + c => out.push(c),
82 + }
83 + }
84 + out.push('"');
85 + }
86 +
87 + /// The params as an `hx-vals` object.
88 + fn json_object(params: &Params) -> String {
89 + let mut json = String::from("{");
90 + for (i, (name, value)) in params.iter().enumerate() {
91 + if i > 0 {
92 + json.push(',');
93 + }
94 + json_string(name, &mut json);
95 + json.push(':');
96 + json_string(value, &mut json);
97 + }
98 + json.push('}');
99 + json
100 + }
101 +
102 + /// The transport attributes for one action.
103 + ///
104 + /// `hx-vals` rather than a hand-built query string, for both methods. htmx
105 + /// folds them into the query string for a GET and into the body for a POST, so
106 + /// one code path covers both, and no part of this stack concatenates a `?`.
107 + /// That is where escaping bugs live, and the description layer's own reason for
108 + /// carrying [`Action::params`] as values rather than as text.
109 + pub(crate) fn action_attrs(action: &Action, morphs: bool, out: &mut String) {
110 + let verb = match action.method {
111 + Method::Get => " hx-get=\"",
112 + Method::Post => " hx-post=\"",
113 + };
114 + out.push_str(verb);
115 + out.push_str(&escape(&action.path));
116 + out.push('"');
117 +
118 + if !action.params.is_empty() {
119 + out.push_str(" hx-vals=\"");
120 + out.push_str(&escape(&json_object(&action.params)));
121 + out.push('"');
122 + }
123 +
124 + if morphs {
125 + // Decision 7's slack: a morph preserves focus, scroll and input state
126 + // through a swap, so a whole-Screen answer stops being destructive.
127 + out.push_str(" hx-swap=\"morph\"");
128 + }
129 + }
130 +
131 + /// A control that calls a route.
132 + pub(crate) fn act_html(act: &Act, morphs: bool, opts: &Emit, out: &mut String) {
133 + let mut classes = vec!["act"];
134 + if let Some(tone) = tone_class(act.tone) {
135 + classes.push(tone);
136 + }
137 +
138 + out.push_str("<button type=\"button\"");
139 + class_attr(&classes, opts, out);
140 +
141 + match act.state {
142 + Some(layout::State::Disabled) => {
143 + // Disabled and emitting no transport, rather than disabled and
144 + // still carrying the address. A control that stops answering input
145 + // should also stop being a request waiting to be re-enabled from
146 + // the console.
147 + out.push_str(" disabled");
148 + }
149 + Some(layout::State::Focus) => {
150 + out.push_str(" autofocus");
151 + action_attrs(&act.action, morphs, out);
152 + }
153 + // `State` is `#[non_exhaustive]`, so a member added upstream lands
154 + // here. Emitting the transport is the right default for anything that
155 + // is not a suppression: a state this renderer has not learned yet
156 + // should leave the control working, not silently inert.
157 + _ => action_attrs(&act.action, morphs, out),
158 + }
159 +
160 + out.push('>');
161 + out.push_str(&escape(&act.label));
162 + out.push_str("</button>");
163 + }
164 +
165 + /// One row of a list.
166 + fn row_html(row: &Row, morphs: bool, opts: &Emit, out: &mut String) {
167 + let mut classes = vec!["row"];
168 + if row.selected {
169 + classes.push("row-selected");
170 + }
171 +
172 + out.push_str("<li");
173 + 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.
177 + out.push_str(" aria-current=\"true\"");
178 + }
179 + out.push('>');
180 +
181 + // The primary is a control when selecting the row does something, and plain
182 + // text when it does not. Emitting a button either way would give a screen
183 + // reader an affordance that answers nothing.
184 + match &row.activate {
185 + Some(action) => {
186 + out.push_str("<button type=\"button\"");
187 + class_attr(&["row-activate"], opts, out);
188 + action_attrs(action, morphs, out);
189 + out.push('>');
190 + out.push_str(&escape(&row.primary));
191 + out.push_str("</button>");
192 + }
193 + None => {
194 + out.push_str("<span");
195 + class_attr(&["row-primary"], opts, out);
196 + out.push('>');
197 + out.push_str(&escape(&row.primary));
198 + out.push_str("</span>");
199 + }
200 + }
201 +
202 + if let Some(secondary) = &row.secondary {
203 + out.push_str("<span");
204 + class_attr(&["row-secondary"], opts, out);
205 + out.push('>');
206 + out.push_str(&escape(secondary));
207 + out.push_str("</span>");
208 + }
209 +
210 + if let Some(meta) = &row.meta {
211 + out.push_str("<span");
212 + class_attr(&["row-meta"], opts, out);
213 + out.push('>');
214 + out.push_str(&escape(meta));
215 + out.push_str("</span>");
216 + }
217 +
218 + if !row.actions.is_empty() {
219 + out.push_str("<span");
220 + class_attr(&["row-actions"], opts, out);
221 + out.push('>');
222 + for act in &row.actions {
223 + act_html(act, morphs, opts, out);
224 + }
225 + out.push_str("</span>");
226 + }
227 +
228 + out.push_str("</li>");
229 + }
230 +
231 + /// One row of a table.
232 + fn cells_row_html(
233 + cells: &Cells,
234 + columns: &[quasi_router::screen::Column],
235 + morphs: bool,
236 + opts: &Emit,
237 + out: &mut String,
238 + ) {
239 + let mut classes = vec!["table-row"];
240 + if cells.selected {
241 + classes.push("table-row-selected");
242 + }
243 +
244 + out.push_str("<div role=\"row\"");
245 + class_attr(&classes, opts, out);
246 + if cells.selected {
247 + out.push_str(" aria-current=\"true\"");
248 + }
249 + if let Some(action) = &cells.activate {
250 + action_attrs(action, morphs, out);
251 + }
252 + out.push('>');
253 +
254 + // The cell contents are escaped here and handed over as Markup, which is
255 + // makeover-webview's contract: it owns the structure, the caller owns what
256 + // goes in. Ours is always text from a description, so it is always escaped,
257 + // and `cells_html` is what knows the column classes and the narrowing.
258 + let escaped: Vec<String> = cells.values.iter().map(|value| escape(value)).collect();
259 + let borrowed: Vec<layout::Column<'_>> = columns
260 + .iter()
261 + .map(quasi_router::screen::Column::as_layout)
262 + .collect();
263 + let cells: Vec<Cell<'_>> = borrowed
264 + .iter()
265 + .zip(escaped.iter())
266 + .map(|(column, value)| Cell::new(column.name, Markup(value)))
267 + .collect();
268 + out.push_str(&cells_html(&borrowed, &cells, opts));
269 +
270 + out.push_str("</div>");
271 + }
272 +
273 + /// One thing on a screen.
274 + pub(crate) fn node_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) {
275 + match node {
276 + Node::Heading { level, text } => {
277 + let tag = match level {
278 + layout::Heading::Page => "h1",
279 + layout::Heading::Section => "h2",
280 + layout::Heading::Subsection => "h3",
281 + };
282 + let _ = write!(out, "<{tag}");
283 + class_attr(&["heading"], opts, out);
284 + out.push('>');
285 + out.push_str(&escape(text));
286 + let _ = write!(out, "</{tag}>");
287 + }
288 +
289 + Node::Text { text, tone } => {
290 + let mut classes = vec!["text"];
291 + if let Some(tone) = tone_class(*tone) {
292 + classes.push(tone);
293 + }
294 + out.push_str("<p");
295 + class_attr(&classes, opts, out);
296 + out.push('>');
297 + out.push_str(&escape(text));
298 + out.push_str("</p>");
299 + }
300 +
301 + Node::Act(act) => act_html(act, morphs, opts, out),
302 +
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),
310 +
311 + Node::Notice { kind, tone, text } => {
312 + let mut classes = vec![match kind {
313 + layout::Notice::Toast => "toast",
314 + layout::Notice::Banner => "banner",
315 + }];
316 + if let Some(tone) = tone_class(*tone) {
317 + classes.push(tone);
318 + }
319 + out.push_str("<div");
320 + class_attr(&classes, opts, out);
321 + // A danger or warning notice interrupts; anything else waits for a
322 + // pause. The description already says which through its tone, so
323 + // the renderer does not need a second field to be told.
324 + let assertive = matches!(tone, layout::Tone::Danger | layout::Tone::Warning);
325 + if assertive {
326 + out.push_str(" role=\"alert\"");
327 + } else {
328 + out.push_str(" role=\"status\" aria-live=\"polite\"");
329 + }
330 + out.push('>');
331 + out.push_str(&escape(text));
332 + out.push_str("</div>");
333 + }
334 +
335 + Node::Form {
336 + action,
337 + submit,
338 + fields,
339 + } => {
340 + out.push_str("<form");
341 + class_attr(&["form"], opts, out);
342 + action_attrs(action, morphs, out);
343 + out.push('>');
344 + for field in fields {
345 + // The field's own markup is makeover-webview's, unchanged. A
346 + // second field emitter here is the divergence phase A existed
347 + // to end, and it would be the same anatomy with a different
348 + // escaping story.
349 + field.with_layout(|borrowed| {
350 + out.push_str(&field_html(&borrowed, &Filling::of(Value::Absent), opts));
351 + });
352 + }
353 + out.push_str("<button type=\"submit\"");
354 + class_attr(&["act", "act-submit"], opts, out);
355 + out.push('>');
356 + out.push_str(&escape(submit));
357 + out.push_str("</button></form>");
358 + }
359 +
360 + Node::List { rows } => {
361 + out.push_str("<ul");
362 + class_attr(&["list"], opts, out);
363 + out.push('>');
364 + for row in rows {
365 + row_html(row, morphs, opts, out);
366 + }
367 + out.push_str("</ul>");
368 + }
369 +
370 + Node::Table { columns, rows } => {
371 + let borrowed: Vec<layout::Column<'_>> = columns
372 + .iter()
373 + .map(quasi_router::screen::Column::as_layout)
374 + .collect();
375 + // No inline grid tracks. `makeover_webview::list::narrowing_css`
376 + // emits the track list and the per-column hiding together, per
377 + // breakpoint, from these same columns; writing tracks here as well
378 + // would put the widest layout in the markup where it outranks the
379 + // narrow rules, which is goingson's mobile bug reintroduced from
380 + // the other side. The markup owes the cells their column classes
381 + // and nothing more, and `cells_html` is what knows those.
382 + out.push_str("<div role=\"table\"");
383 + class_attr(&["table"], opts, out);
384 + out.push('>');
385 +
386 + out.push_str("<div role=\"row\"");
387 + class_attr(&["table-head"], opts, out);
388 + out.push('>');
389 + for column in &borrowed {
390 + out.push_str("<span role=\"columnheader\"");
391 + class_attr(&["table-heading"], opts, out);
392 + out.push('>');
393 + out.push_str(&escape(column.name));
394 + out.push_str("</span>");
395 + }
396 + out.push_str("</div>");
397 +
398 + for cells in rows {
399 + cells_row_html(cells, columns, morphs, opts, out);
400 + }
401 + out.push_str("</div>");
402 + }
403 +
404 + Node::Select {
405 + kind,
406 + options,
407 + chosen,
408 + action,
409 + } => select_html(*kind, options, chosen.as_deref(), action.as_ref(), morphs, opts, out),
410 +
411 + Node::Region(slot) => slot_html(slot, morphs, opts, out),
412 + }
413 + }
414 +
415 + /// 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 + ) {
427 + let mut classes = vec![match kind {
428 + layout::Token::Badge => "badge",
429 + layout::Token::Chip { .. } => "chip",
430 + }];
431 + if let Some(tone) = tone_class(tone) {
432 + classes.push(tone);
433 + }
434 + if latched {
435 + classes.push("chip-latched");
436 + }
437 +
438 + // A badge answers no click, so it is not a button however it is styled.
439 + // The description says which through the kind, which is the whole reason
440 + // the two are separate members rather than one with a flag.
441 + let interactive = kind.interactive() && action.is_some();
442 + if interactive {
443 + out.push_str("<button type=\"button\"");
444 + } else {
445 + out.push_str("<span");
446 + }
447 + class_attr(&classes, opts, out);
448 +
449 + if interactive {
450 + if latched {
451 + // A chip standing for a filter is on or off, which is what
452 + // aria-pressed means. Its latched class carries the same fact
453 + // visually through Depth::pressed.
454 + out.push_str(" aria-pressed=\"true\"");
455 + }
456 + if let Some(action) = action {
457 + action_attrs(action, morphs, out);
458 + }
459 + }
460 +
461 + out.push('>');
462 + out.push_str(&escape(label));
463 + if matches!(kind, layout::Token::Chip { removable: true }) {
464 + out.push_str("<span");
465 + class_attr(&["chip-remove"], opts, out);
466 + out.push_str(" aria-hidden=\"true\"></span>");
467 + }
468 + if interactive {
469 + out.push_str("</button>");
470 + } else {
471 + out.push_str("</span>");
472 + }
473 + }
474 +
475 + /// A control that picks between things.
476 + fn select_html(
477 + kind: layout::Selector,
478 + options: &[quasi_router::screen::Choice],
479 + chosen: Option<&str>,
480 + action: Option<&Action>,
481 + morphs: bool,
482 + opts: &Emit,
483 + out: &mut String,
484 + ) {
485 + let group = match kind {
486 + layout::Selector::Segmented => "segmented",
487 + layout::Selector::Toggle => "toggle",
488 + layout::Selector::Tabs => "tabs",
489 + };
490 +
491 + out.push_str("<div");
492 + class_attr(&[group], opts, out);
493 + // Tabs are navigation between panes, which is a tablist. The other two pick
494 + // a value and are a group of buttons.
495 + if matches!(kind, layout::Selector::Tabs) {
496 + out.push_str(" role=\"tablist\"");
497 + } else {
498 + out.push_str(" role=\"group\"");
499 + }
500 + out.push('>');
Lines truncated
@@ -1,0 +1,169 @@
1 + //! The document around a screen.
2 + //!
3 + //! Settled 2026-08-08 (Max): the renderer owns the document and the host
4 + //! injects what it knows. The alternative was each adapter supplying its own
5 + //! `<html>` and the renderer filling the inside, which is honest about axum and
6 + //! Tauri resolving assets differently and costs two heads to keep in step —
7 + //! the divergence the stack exists to end. A served page and a
8 + //! custom-protocol page differ in where their assets live and in nothing else,
9 + //! so where assets live is the parameter and the rest is emitted once.
10 + //!
11 + //! What the host owes is small enough to list: three asset URLs, a language,
12 + //! and whatever else belongs in its own head. Everything with an opinion in it
13 + //! — the htmx config from [`quasi_http::htmx`], the morph extension, the
14 + //! viewport, where the body's classes come from — is here, because a host that
15 + //! could get those wrong is a host that can diverge.
16 +
17 + use makeover_webview::form::escape;
18 +
19 + /// The parts of a document only the host knows.
20 + ///
21 + /// A [`Default`] shell is a valid one: the asset paths are what a server
22 + /// mounting its static directory at `/static` already serves, which is what
23 + /// both MNW and multithreaded do today, and the Tauri adapter overrides them
24 + /// with its own scheme.
25 + #[derive(Debug, Clone, PartialEq, Eq)]
26 + pub struct Shell {
27 + /// The document language, for `<html lang>`.
28 + pub lang: String,
29 + /// Where htmx is served from.
30 + pub htmx_src: String,
31 + /// Where the idiomorph htmx extension is served from.
32 + ///
33 + /// `None` drops both the script and the `hx-ext` attribute, which is the
34 + /// honest way to run without it: emitting `hx-swap="morph"` with no
35 + /// extension loaded makes htmx fall back to `innerHTML` silently, and a
36 + /// silent fallback to the destructive behaviour is the one outcome
37 + /// decision 7 was avoiding.
38 + pub morph_src: Option<String>,
39 + /// Stylesheets, in link order.
40 + pub stylesheets: Vec<String>,
41 + /// Markup appended to the head verbatim. Not escaped.
42 + ///
43 + /// The escape hatch for what no description will ever name: a favicon, a
44 + /// preconnect, a theme bootstrap that has to run before first paint. It is
45 + /// last in the head so it can override anything above it.
46 + pub head: Option<String>,
47 + /// Classes added to `<body>`, space-separated.
48 + pub body_class: Option<String>,
49 + }
50 +
51 + impl Default for Shell {
52 + fn default() -> Self {
53 + Self {
54 + lang: "en".into(),
55 + htmx_src: "/static/htmx.min.js".into(),
56 + morph_src: Some("/static/idiomorph-ext.min.js".into()),
57 + stylesheets: Vec::new(),
58 + head: None,
59 + body_class: None,
60 + }
61 + }
62 + }
63 +
64 + impl Shell {
65 + /// A shell serving its assets from under this prefix.
66 + ///
67 + /// The common case said once: `Shell::under("/assets")` rather than three
68 + /// paths written out, each of which could disagree with the others.
69 + #[must_use]
70 + pub fn under(prefix: &str) -> Self {
71 + let prefix = prefix.trim_end_matches('/');
72 + Self {
73 + htmx_src: format!("{prefix}/htmx.min.js"),
74 + morph_src: Some(format!("{prefix}/idiomorph-ext.min.js")),
75 + ..Self::default()
76 + }
77 + }
78 +
79 + /// Add a stylesheet, chaining.
80 + #[must_use]
81 + pub fn styled(mut self, href: impl Into<String>) -> Self {
82 + self.stylesheets.push(href.into());
83 + self
84 + }
85 +
86 + /// Append markup to the head, chaining. Not escaped.
87 + #[must_use]
88 + pub fn with_head(mut self, markup: impl Into<String>) -> Self {
89 + self.head = Some(match self.head.take() {
90 + Some(existing) => format!("{existing}{}", markup.into()),
91 + None => markup.into(),
92 + });
93 + self
94 + }
95 +
96 + /// Run without idiomorph, chaining.
97 + #[must_use]
98 + pub fn without_morph(mut self) -> Self {
99 + self.morph_src = None;
100 + self
101 + }
102 +
103 + /// Whether responses may ask for a morph swap.
104 + ///
105 + /// Read by the node emitter rather than assumed, so that turning the
106 + /// extension off changes what is emitted instead of leaving an attribute
107 + /// naming a swap nothing implements.
108 + #[must_use]
109 + pub fn morphs(&self) -> bool {
110 + self.morph_src.is_some()
111 + }
112 +
113 + /// Everything from `<!doctype>` to the open `<body>` tag.
114 + pub(crate) fn open(&self, title: &str, out: &mut String) {
115 + out.push_str("<!doctype html><html lang=\"");
116 + out.push_str(&escape(&self.lang));
117 + out.push_str("\"><head><meta charset=\"utf-8\">");
118 + out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
119 + out.push_str("<title>");
120 + out.push_str(&escape(title));
121 + out.push_str("</title>");
122 +
123 + // Decision 9's gap, closed in one place. A 4xx that does not swap is a
124 + // banner the user never sees, so this tag is required rather than a
125 + // refinement, and it is a tag rather than a script so it survives a
126 + // `script-src` with no `unsafe-inline`.
127 + out.push_str(quasi_http::htmx::CONFIG_META);
128 +
129 + for href in &self.stylesheets {
130 + out.push_str("<link rel=\"stylesheet\" href=\"");
131 + out.push_str(&escape(href));
132 + out.push_str("\">");
133 + }
134 +
135 + // Deferred, so the parser is never blocked and the extension is
136 + // registered before htmx processes the body either way.
137 + out.push_str("<script src=\"");
138 + out.push_str(&escape(&self.htmx_src));
139 + out.push_str("\" defer></script>");
140 + if let Some(src) = &self.morph_src {
141 + out.push_str("<script src=\"");
142 + out.push_str(&escape(src));
143 + out.push_str("\" defer></script>");
144 + }
145 +
146 + if let Some(head) = &self.head {
147 + out.push_str(head);
148 + }
149 +
150 + out.push_str("</head><body");
151 + if self.morphs() {
152 + // Registered once on the body rather than per element: the
153 + // extension is inherited, and an app that has to remember it per
154 + // control is an app that will forget it.
155 + out.push_str(" hx-ext=\"morph\"");
156 + }
157 + if let Some(class) = &self.body_class {
158 + out.push_str(" class=\"");
159 + out.push_str(&escape(class));
160 + out.push('"');
161 + }
162 + out.push('>');
163 + }
164 +
165 + /// The close of what [`open`](Self::open) opened.
166 + pub(crate) fn close(out: &mut String) {
167 + out.push_str("</body></html>");
168 + }
169 + }
@@ -1,0 +1,461 @@
1 + //! What the renderer promises, asserted.
2 + //!
3 + //! Two kinds of test here, and the second is the interesting one. The first
4 + //! checks that a description comes out as the markup it should. The second
5 + //! checks the architectural claims the design rests on — that no `hx-target`
6 + //! is ever emitted, that htmx appears in exactly one function, that a
7 + //! description's text cannot become markup — because those are the properties
8 + //! that would decay silently, one convenient exception at a time.
9 +
10 + use makeover_layout as layout;
11 + use quasi_http::Render;
12 + use quasi_router::screen::{Act, Cells, Choice, Column, Field, Row};
13 + use quasi_router::{Action, Node, RegionKind, Screen, Slot};
14 +
15 + use crate::{Shell, Webview};
16 +
17 + fn render(screen: &Screen) -> String {
18 + Webview::new().screen(screen)
19 + }
20 +
21 + fn fragment(node: &Node) -> String {
22 + Webview::new().fragment(node)
23 + }
24 +
25 + #[test]
26 + fn a_screen_is_a_whole_document() {
27 + let html = render(&Screen::list_detail("Tasks", false));
28 + assert!(html.starts_with("<!doctype html><html lang=\"en\">"));
29 + assert!(html.contains("<title>Tasks</title>"));
30 + assert!(html.ends_with("</body></html>"));
31 + }
32 +
33 + #[test]
34 + fn a_fragment_is_not() {
35 + let html = fragment(&Node::text("hello"));
36 + assert!(!html.contains("<html"));
37 + assert!(!html.contains("<body"));
38 + assert_eq!(html, "<p class=\"text\">hello</p>");
39 + }
40 +
41 + #[test]
42 + fn the_document_carries_the_response_handling_config() {
43 + // Decision 9's gap. Without this a 4xx does not swap and the notice the
44 + // adapter carefully classified reaches nobody.
45 + let html = render(&Screen::list_detail("Tasks", false));
46 + assert!(html.contains(quasi_http::htmx::CONFIG_META));
47 + assert!(html.contains(r#"{"code":"[45]..","swap":true,"error":true}"#));
48 + }
49 +
50 + #[test]
51 + fn the_title_is_escaped_into_the_head() {
52 + let html = render(&Screen::list_detail("</title><script>x()</script>", false));
53 + assert!(!html.contains("<script>x()"));
54 + assert!(html.contains("&lt;/title&gt;"));
55 + }
56 +
57 + #[test]
58 + fn morph_is_emitted_only_when_the_extension_is_loaded() {
59 + let with = render(&Screen::list_detail("Tasks", false));
60 + assert!(with.contains("hx-ext=\"morph\""));
61 +
62 + let without = Webview::new()
63 + .with_shell(Shell::default().without_morph())
64 + .screen(&Screen::list_detail("Tasks", false));
65 + assert!(!without.contains("hx-ext=\"morph\""));
66 + assert!(!without.contains("idiomorph"));
67 +
68 + // The pair that matters: no element may ask for a swap the page cannot
69 + // perform. htmx falls back to innerHTML silently, and silently destructive
70 + // is the outcome decision 7 exists to avoid.
71 + let screen = Screen::list_detail("Tasks", false)
72 + .with(Slot::new("main", RegionKind::Pane).with(Node::act("Go", Action::get("/go"))));
73 + let quiet = Webview::new()
74 + .with_shell(Shell::default().without_morph())
75 + .screen(&screen);
76 + assert!(!quiet.contains("hx-swap"));
77 + }
78 +
79 + #[test]
80 + fn an_action_becomes_the_verb_it_names() {
81 + let get = fragment(&Node::act("Open", Action::get("/tasks/1")));
82 + assert!(get.contains("hx-get=\"/tasks/1\""));
83 + assert!(!get.contains("hx-post"));
84 +
85 + let post = fragment(&Node::act("Delete", Action::post("/tasks/1/delete")));
86 + assert!(post.contains("hx-post=\"/tasks/1/delete\""));
87 + assert!(!post.contains("hx-get"));
88 + }
89 +
90 + #[test]
91 + fn params_travel_as_hx_vals_not_as_a_query_string() {
92 + let action = Action::get("/tasks").with("filter", "open").with("sort", "due");
93 + let html = fragment(&Node::act("Filter", action));
94 +
95 + assert!(html.contains("hx-get=\"/tasks\""));
96 + assert!(!html.contains('?'));
97 + assert!(html.contains("hx-vals=\"{&quot;filter&quot;:&quot;open&quot;,&quot;sort&quot;:&quot;due&quot;}\""));
98 + }
99 +
100 + #[test]
101 + fn a_param_value_cannot_escape_the_attribute_or_the_json() {
102 + let action = Action::post("/search").with("q", "\" onload=\"steal()");
103 + let html = fragment(&Node::act("Search", action));
104 +
105 + assert!(!html.contains("onload=\"steal()"));
106 + assert!(html.contains("\\&quot;"));
107 +
108 + let action = Action::post("/search").with("q", "a\\b\nc");
109 + let html = fragment(&Node::act("Search", action));
110 + assert!(html.contains("a\\\\b\\nc"));
111 + }
112 +
113 + #[test]
114 + fn no_control_ever_names_its_own_target() {
115 + // Decision 7: the response says what it replaces, through HX-Retarget,
116 + // because the router is the only party that knows what it just changed.
117 + let screen = Screen::list_detail("Tasks", false)
118 + .with(
119 + Slot::new("list", RegionKind::Pane)
120 + .with(Node::list([Row::new("One").activate(Action::get("/tasks/1"))]))
121 + .with(Node::act("New", Action::post("/tasks"))),
122 + )
123 + .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")));
124 +
125 + let html = render(&screen);
126 + assert!(!html.contains("hx-target"));
127 + assert!(!html.contains("hx-retarget"));
128 + }
129 +
130 + #[test]
131 + fn htmx_enters_in_exactly_one_function() {
132 + // The check on decision 13's claim that the transport is replaceable. If
133 + // this fails, `hx-` has leaked out of `action_attrs` and swapping htmx for
134 + // fixi stopped being a one-function change.
135 + let source = include_str!("node.rs");
136 + let emitted = source
137 + .lines()
138 + .filter(|line| line.contains("\" hx-") || line.contains("hx-ext"))
139 + .count();
140 + let in_action_attrs = source
141 + .split("pub(crate) fn action_attrs")
142 + .nth(1)
143 + .expect("action_attrs exists")
144 + .split("\n/// ")
145 + .next()
146 + .expect("the function has a body")
147 + .lines()
148 + .filter(|line| line.contains("\" hx-"))
149 + .count();
150 + assert_eq!(
151 + emitted, in_action_attrs,
152 + "every emitted hx- attribute should come from action_attrs"
153 + );
154 + assert!(in_action_attrs >= 3, "verb, vals and swap at least");
155 + }
156 +
157 + #[test]
158 + fn a_disabled_control_carries_no_address() {
159 + let act = Act::new("Delete", Action::post("/tasks/1/delete")).disabled();
160 + let html = fragment(&Node::Act(act));
161 +
162 + assert!(html.contains("disabled"));
163 + assert!(!html.contains("hx-post"));
164 + }
165 +
166 + #[test]
167 + fn a_row_is_a_control_only_when_selecting_it_does_something() {
168 + let live = fragment(&Node::list([Row::new("One").activate(Action::get("/1"))]));
169 + assert!(live.contains("<button type=\"button\" class=\"row-activate\""));
170 +
171 + let inert = fragment(&Node::list([Row::new("One")]));
172 + assert!(!inert.contains("<button"));
173 + assert!(inert.contains("<span class=\"row-primary\">One</span>"));
174 + }
175 +
176 + #[test]
177 + fn a_selected_row_says_so_to_a_screen_reader() {
178 + let html = fragment(&Node::list([Row {
179 + selected: true,
180 + ..Row::new("One")
181 + }]));
182 + assert!(html.contains("aria-current=\"true\""));
183 + assert!(html.contains("row-selected"));
184 + }
185 +
186 + #[test]
187 + fn a_badge_is_not_a_button_and_a_chip_is() {
188 + let badge = fragment(&Node::Token {
189 + kind: layout::Token::Badge,
190 + label: "3".into(),
191 + tone: layout::Tone::Info,
192 + latched: false,
193 + action: Some(Action::get("/x")),
194 + });
195 + // A badge answers no click however it is dressed, so it emits no transport
196 + // even when a description hands it an action.
197 + assert!(!badge.contains("<button"));
198 + assert!(!badge.contains("hx-get"));
199 +
200 + let chip = fragment(&Node::Token {
201 + kind: layout::Token::Chip { removable: false },
202 + label: "open".into(),
203 + tone: layout::Tone::Neutral,
204 + latched: true,
205 + action: Some(Action::get("/x")),
206 + });
207 + assert!(chip.contains("<button"));
208 + assert!(chip.contains("aria-pressed=\"true\""));
209 + assert!(chip.contains("hx-get=\"/x\""));
210 + }
211 +
212 + #[test]
213 + fn a_select_sends_its_value_under_the_one_agreed_name() {
214 + let html = fragment(&Node::Select {
215 + kind: layout::Selector::Tabs,
216 + options: vec![
217 + Choice {
218 + value: "open".into(),
219 + label: "Open".into(),
220 + },
221 + Choice {
222 + value: "done".into(),
223 + label: "Done".into(),
224 + },
225 + ],
226 + chosen: Some("open".into()),
227 + action: Some(Action::get("/tasks")),
228 + });
229 +
230 + assert!(html.contains("role=\"tablist\""));
231 + assert!(html.contains("aria-selected=\"true\""));
232 + assert!(html.contains("aria-selected=\"false\""));
233 + assert!(html.contains(&format!("&quot;{}&quot;:&quot;open&quot;", Node::SELECTED)));
234 + assert!(html.contains(&format!("&quot;{}&quot;:&quot;done&quot;", Node::SELECTED)));
235 + }
236 +
237 + #[test]
238 + fn a_pending_region_says_it_is_waiting() {
239 + let screen = Screen::list_detail("Tasks", false)
240 + .with(Slot::new("detail", RegionKind::Pane).pending());
241 + assert!(render(&screen).contains("aria-busy=\"true\""));
242 + }
243 +
244 + #[test]
245 + fn a_bespoke_region_is_a_place_and_nothing_else() {
246 + // Decision 4: the renderer hands the space over under the name the app
247 + // chose and never interprets it.
248 + let screen = Screen::list_detail("Tasks", false)
249 + .with(Slot::bespoke("player", "media-player"));
250 + let html = render(&screen);
251 +
252 + assert!(html.contains("id=\"player\""));
253 + assert!(html.contains("data-bespoke=\"media-player\""));
254 + // Empty. Whatever fills it is the app's, per host.
255 + assert!(html.contains("data-bespoke=\"media-player\"></div>"));
256 + }
257 +
258 + #[test]
259 + 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));
262 + assert!(render(&screen).contains("id=\"feed-list\""));
263 + }
264 +
265 + #[test]
266 + fn a_modal_says_it_takes_input_until_dismissed() {
267 + let screen =
268 + Screen::list_detail("Tasks", false).with(Slot::new("confirm", RegionKind::Modal));
269 + let html = render(&screen);
270 + assert!(html.contains("role=\"dialog\""));
271 + assert!(html.contains("aria-modal=\"true\""));
272 + }
273 +
274 + #[test]
275 + fn a_table_addresses_its_cells_by_column_never_by_position() {
276 + let columns = vec![
277 + Column::new("Name").width(layout::Width::Fill),
278 + Column::new("Size").width(layout::Width::Content),
279 + ];
280 + let html = fragment(&Node::Table {
281 + columns,
282 + rows: vec![Cells::new(["kick.wav", "2.1 MB"])],
283 + });
284 +
285 + assert!(html.contains("role=\"table\""));
286 + assert!(html.contains("role=\"columnheader\""));
287 + assert!(html.contains("kick.wav"));
288 +
289 + // The tracks are the stylesheet's, emitted by `narrowing_css` alongside the
290 + // hiding rules. Inline tracks here would be the widest layout outranking
291 + // the narrow ones from inside the markup.
292 + assert!(!html.contains("grid-template-columns"));
293 + assert!(!html.contains("style="));
294 + }
295 +
296 + #[test]
297 + fn a_cell_value_is_text_and_cannot_become_markup() {
298 + let html = fragment(&Node::Table {
299 + columns: vec![Column::new("Name").width(layout::Width::Fill)],
300 + rows: vec![Cells::new(["<img src=x onerror=alert(1)>"])],
301 + });
302 + assert!(!html.contains("<img"));
303 + assert!(html.contains("&lt;img"));
304 + }
305 +
306 + #[test]
307 + fn a_form_borrows_its_fields_rather_than_emitting_them_twice() {
308 + let html = fragment(&Node::Form {
309 + action: Action::post("/tasks"),
310 + submit: "Save".into(),
311 + fields: vec![Field::new(layout::FieldKind::Text, "title", "Title").required()],
312 + });
313 +
314 + assert!(html.contains("hx-post=\"/tasks\""));
315 + assert!(html.contains("name=\"title\""));
316 + assert!(html.contains("required"));
317 + assert!(html.contains("<button type=\"submit\""));
318 + // The anatomy is makeover-webview's, so the label association it emits is
319 + // the one every app already gets.
320 + assert!(html.contains("<label"));
321 + }
322 +
323 + #[test]
324 + fn a_notice_interrupts_only_when_its_tone_says_to() {
325 + let danger = fragment(&Node::banner(layout::Tone::Danger, "Disk full"));
326 + assert!(danger.contains("role=\"alert\""));
327 +
328 + let info = fragment(&Node::toast(layout::Tone::Info, "Saved"));
329 + assert!(info.contains("role=\"status\""));
330 + assert!(info.contains("aria-live=\"polite\""));
331 + }
332 +
333 + #[test]
334 + fn a_screens_notices_come_before_its_regions() {
335 + let screen = Screen::list_detail("Tasks", false)
336 + .with(Slot::new("list", RegionKind::Pane))
337 + .saying(Node::toast(layout::Tone::Success, "Saved"));
338 + let html = render(&screen);
339 +
340 + let notice = html.find("Saved").expect("the notice is rendered");
341 + let region = html.find("id=\"list\"").expect("the region is rendered");
342 + assert!(notice < region);
343 + }
344 +
345 + #[test]
346 + fn every_arrangement_has_a_class_and_they_differ() {
347 + let plain = render(&Screen::list_detail("A", false));
348 + let tabbed = render(&Screen::list_detail("A", true));
349 + let sidebar = render(&Screen::sidebar_content("A"));
350 +
351 + assert!(plain.contains("class=\"list-detail\""));
352 + assert!(tabbed.contains("class=\"list-detail-tabbed\""));
353 + assert!(sidebar.contains("class=\"sidebar-content\""));
354 + }
355 +
356 + #[test]
357 + fn a_class_prefix_reaches_every_emitted_name() {
358 + let emit = crate::Emit {
359 + class_prefix: "qs-",
360 + ..crate::Emit::default()
361 + };
362 +
363 + let screen = Screen::list_detail("Tasks", false).with(
364 + Slot::new("list", RegionKind::Pane)
365 + .with(Node::page("Tasks"))
366 + .with(Node::list([Row::new("One")])),
367 + );
368 + let html = Webview::new().with_emit(emit).screen(&screen);
369 +
370 + assert!(html.contains("class=\"qs-list-detail\""));
371 + assert!(html.contains("qs-region qs-pane"));
372 + assert!(html.contains("qs-heading"));
373 + assert!(html.contains("qs-list"));
374 + // No unprefixed leftovers: a name that missed the prefix is a rule in the
375 + // generated stylesheet that matches nothing.
376 + assert!(!html.contains("class=\"list\""));
377 + assert!(!html.contains("class=\"heading\""));
378 + }
379 +
380 + #[test]
381 + fn the_shell_serves_its_assets_from_where_the_host_says() {
382 + let html = Webview::under("/assets").screen(&Screen::list_detail("A", false));
383 + assert!(html.contains("src=\"/assets/htmx.min.js\""));
384 + assert!(html.contains("src=\"/assets/idiomorph-ext.min.js\""));
385 +
386 + // The Tauri case: a custom scheme, which is the whole reason this is a
387 + // parameter and not a constant.
388 + let tauri = Webview::under("quasi://localhost/assets").screen(&Screen::list_detail("A", false));
389 + assert!(tauri.contains("src=\"quasi://localhost/assets/htmx.min.js\""));
390 + }
391 +
392 + #[test]
393 + fn stylesheets_link_in_the_order_they_were_added() {
394 + let shell = Shell::default().styled("/a.css").styled("/b.css");
395 + let html = Webview::new().with_shell(shell).screen(&Screen::list_detail("A", false));
396 +
397 + let a = html.find("/a.css").expect("a is linked");
398 + let b = html.find("/b.css").expect("b is linked");
399 + assert!(a < b);
400 + }
401 +
402 + #[test]
403 + fn injected_head_markup_lands_last_so_it_can_override() {
404 + 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));
406 +
407 + let icon = html.find("/f.png").expect("the icon is linked");
408 + let htmx = html.find("htmx.min.js").expect("htmx is linked");
409 + assert!(htmx < icon);
410 + assert!(icon < html.find("</head>").expect("the head closes"));
411 + }
412 +
413 + #[test]
414 + 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 + );
419 + let html = render(&screen);
420 +
421 + let outer = html.find("id=\"outer\"").expect("outer renders");
422 + let inner = html.find("id=\"inner\"").expect("inner renders");
423 + assert!(outer < inner);
424 + assert!(html.contains("in</p></div></div>"));
425 + }
426 +
427 + #[test]
428 + fn text_from_a_description_can_never_become_markup() {
429 + // The property that has to hold across every variant, because a
430 + // description's strings come from application state. Checked over the whole
431 + // tree rather than per node, so a variant added without escaping fails
432 + // here rather than in production.
433 + let hostile = "<script>alert(1)</script>";
434 + let screen = Screen::list_detail(hostile, false)
435 + .saying(Node::banner(layout::Tone::Danger, hostile))
436 + .with(
437 + Slot::new("s", RegionKind::Pane)
438 + .with(Node::page(hostile))
439 + .with(Node::text(hostile))
440 + .with(Node::act(hostile, Action::get("/x")))
441 + .with(Node::list([Row::new(hostile)
442 + .secondary(hostile)
443 + .meta(hostile)
444 + .act(Act::new(hostile, Action::post("/y")))]))
445 + .with(Node::Token {
446 + kind: layout::Token::Chip { removable: true },
447 + label: hostile.into(),
448 + tone: layout::Tone::Neutral,
449 + latched: false,
450 + action: Some(Action::get("/z")),
451 + }),
452 + );
453 +
454 + let html = render(&screen);
455 + assert!(!html.contains("<script>"));
456 + // Ten sinks: the title, the notice, the heading, the prose, the act's
457 + // label, the row's four parts, and the chip's label. Counted rather than
458 + // merely checked for absence, so a variant that silently stops rendering
459 + // its text fails here too.
460 + assert_eq!(html.matches("&lt;script&gt;").count(), 10);
461 + }