max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+466 insertions,
-96 deletions
| @@ -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=\"{"filter":"open","sort":"due"}\"")); | |
| 99 | + | assert!(html.contains( | |
| 100 | + | "hx-vals=\"{"filter":"open","sort":"due"}\"" | |
| 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); |