Skip to main content

max / goingson

Use the vocabulary the two ports asked for Four findings came out of describing projects and contacts. Three are now said properly rather than worked around, and the fourth stays open. Both rows carry tokens instead of joining two trailing facts into meta. A project keeps its status tone through status_tone, the Rust half of utils.js:statusTone, and this is the copy that survives when the JS screen goes. A contact's tags are chips that filter the grid by themselves, which is the click contacts.js already binds on the same badge and which a joined string could not express at all. A contact row is selectable, which it could not be while Row::selected meant the detail pane's own pointer. That is Row::current now. A social handle's URL is a real link through Action::external, so it is something to follow rather than something to copy. Still open: a description carries text and a project card carries markdown. Filed as makeover-layout 25822137. The three tests that asserted the workarounds now assert the fix. One of them would have survived the change while proving nothing: it checked for the absence of `<a href="` and the renderer emits `<a class="act" href=`, so it passed for the wrong reason. Asserting the absence of a literal string is only as good as the string.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 01:58 UTC
Signed with PGP, not checked
Commit: 1c059d5c0de087ec1698681d656e4cd94cde95e2
Parent: d84b383
4 files changed, +185 insertions, -108 deletions
@@ -9,8 +9,14 @@
9 9 //!
10 10 //! Where the projects screen was a grid of one flat record, this one has
11 11 //! sub-collections, and it is the first to describe a row that acts on itself
12 - //! rather than only selecting. What it could not say is recorded in [`row_for`]
13 - //! and [`link_row`].
12 + //! rather than only selecting.
13 + //!
14 + //! It found three things the description layer could not say, and all three are
15 + //! closed as of 2026-08-08 rather than worked around: a row now carries tokens
16 + //! (`RowPart::Tokens`, makeover-layout 0.9.0), a row's tick is distinct from the
17 + //! app's own pointer (`Row::selected` against `Row::current`), and an action can
18 + //! go somewhere outside the app (`Destination::External`). [`row_for`] and
19 + //! [`link_row`] carry the detail of what each one replaced.
14 20 //!
15 21 //! # The shape
16 22 //!
@@ -37,7 +43,7 @@
37 43 #![allow(clippy::needless_pass_by_value)]
38 44
39 45 use goingson_core::{Contact, ContactId};
40 - use quasi_router::screen::{Act, Row};
46 + use quasi_router::screen::{Act, Row, Tag};
41 47 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
42 48
43 49 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -62,31 +68,31 @@
62 68
63 69 /// One contact as a row.
64 70 ///
65 - /// # What the description could not say
71 + /// # Two findings, both closed by makeover-layout 0.9.0
66 72 ///
67 - /// **A card carries five facts and a row holds three.** The same gap
68 - /// [`super::projects::row_for`] found, biting harder rather than differently.
69 - /// `renderCard` shows a display name, a nickname in quotes, a company, a primary
70 - /// email and a strip of tag badges; `Row` is `primary`, `secondary`, `meta`. The
71 - /// nickname joins the name and the tags join `meta` behind the email, which
72 - /// keeps every fact and loses the shape: tags read as trailing text rather than
73 - /// as the pills they are. `Node::Token` says exactly the right thing and still
74 - /// exists only as a node in its own right, never inside a row. Naming it in
75 - /// `makeover-layout` first is what the admission test requires.
73 + /// This card was the evidence for two gaps when the screen was first described,
74 + /// and both are now said properly rather than worked around.
76 75 ///
77 - /// **A row cannot be selected without being activated.** The card carries a bulk
78 - /// checkbox (`contacts.toggleSelection`, feeding `bulk-actions.js`), and the
79 - /// vocabulary has `Row::selected` — which means "this is the row the detail pane
80 - /// is showing", not "the user ticked it". One is the app's state and the other
81 - /// is the user's, and there is currently one word for both. Left out here rather
82 - /// than smuggled in as an `Act`, so the gap stays visible.
76 + /// **The tags were joined into `meta` as text**, behind the primary email,
77 + /// because a row had one trailing slot. They are [`Tag`]s now, against
78 + /// `RowPart::Tokens`, and each one is a chip that filters the grid by itself —
79 + /// which is what `contacts.js` does when a badge is clicked, and which the
80 + /// joined string could not express at all. The email stays in `meta`, where a
81 + /// plain fact belongs.
83 82 ///
84 - /// **The avatar is dropped.** `getInitials` derives two letters from the display
85 - /// name and the card shows them in a circle. That is a rendering of the primary
86 - /// text, not a fact about the contact, so it belongs to the renderer and there
87 - /// is nothing for the description to say. Recorded because it is the one thing
88 - /// here that is absent by being correct rather than by being missing.
89 - fn row_for(contact: &Contact, selected: bool) -> Row {
83 + /// **The bulk checkbox had nowhere to go.** `Row::selected` meant "the detail
84 + /// pane is showing this", so there was one word for the app's pointer and the
85 + /// user's tick. The two are now `current` and `selected`, and this row is
86 + /// selectable because the screen has bulk actions.
87 + ///
88 + /// # What is still absent, and correctly
89 + ///
90 + /// **The avatar.** `getInitials` derives two letters from the display name and
91 + /// the card shows them in a circle. That is a rendering of the primary text
92 + /// rather than a fact about the contact, so it belongs to the renderer and there
93 + /// is nothing for a description to say. Recorded because it is absent by being
94 + /// correct, not by being missing.
95 + fn row_for(contact: &Contact, current: bool, ticked: bool) -> Row {
90 96 let name = match contact.nickname.as_deref() {
91 97 Some(nickname) if !nickname.is_empty() => {
92 98 format!("{} \"{}\"", contact.display_name, nickname)
@@ -94,26 +100,25 @@
94 100 _ => contact.display_name.clone(),
95 101 };
96 102
97 - let mut row = Row::new(name);
103 + let mut row = Row::new(name).selectable(ticked);
98 104
99 105 if let Some(affiliation) = affiliation(contact) {
100 106 row = row.secondary(affiliation);
101 107 }
102 108
103 - // Email first, because it is what the card leads its second line with, and
104 - // tags after it. See the note above for what this loses.
105 - let mut meta: Vec<String> = Vec::new();
106 109 if let Some(email) = contact.primary_email() {
107 - meta.push(email.to_owned());
108 - }
109 - if !contact.tags.is_empty() {
110 - meta.push(contact.tags.join(", "));
111 - }
112 - if !meta.is_empty() {
113 - row = row.meta(meta.join(" · "));
110 + row = row.meta(email);
114 111 }
115 112
116 - row.selected = selected;
113 + for tag in &contact.tags {
114 + // A tag filters the grid by itself, which is the click `contacts.js`
115 + // already binds on the same badge. Not latched: a row's tag says what
116 + // the contact carries, and whether that tag is the active filter is the
117 + // band's business rather than this row's.
118 + row = row.token(Tag::chip(tag, list_action(None, Some(tag))));
119 + }
120 +
121 + row.current = current;
117 122 row.activate = Some(Action::get(format!("/contacts/{}", contact.id)));
118 123 row
119 124 }
@@ -138,7 +143,9 @@
138 143 }
139 144
140 145 Ok(Node::list(
141 - contacts.iter().map(|contact| row_for(contact, false)),
146 + contacts
147 + .iter()
148 + .map(|contact| row_for(contact, false, false)),
142 149 ))
143 150 }
144 151
@@ -207,13 +214,8 @@
207 214 // is always on screen.
208 215 for in_use in tags_in_use(state)? {
209 216 let latched = tag == Some(in_use.as_str());
210 - band = band.with(Node::Token {
211 - kind: makeover_layout::Token::Chip { removable: false },
212 - label: in_use.clone(),
213 - tone: makeover_layout::Tone::Neutral,
214 - latched,
215 - action: Some(list_action(search, (!latched).then_some(in_use.as_str()))),
216 - });
217 + let action = list_action(search, (!latched).then_some(in_use.as_str()));
218 + band = band.with(Node::Token(Tag::chip(&in_use, action).latched(latched)));
217 219 }
218 220
219 221 Ok(Screen::list_detail("Contacts", false)
@@ -231,26 +233,30 @@
231 233
232 234 /// One entry in a sub-collection, with the control that removes it.
233 235 ///
234 - /// # What the description could not say
236 + /// # The third finding, also closed
235 237 ///
236 - /// **A row cannot carry a link.** A social handle and a custom field both have
237 - /// an optional `url`, and the modal renders each as an anchor through
238 - /// `safeUrl`. `Act` calls a route in this app; an external address is not a
239 - /// route, and there is no node that means "somewhere else". The URL goes into
240 - /// the trailing text so the fact survives, which makes it something to copy
241 - /// rather than something to follow. `makeover-layout` naming a link node is the
242 - /// fix; smuggling one in as an `Act` whose action happens to be absolute would
243 - /// make every renderer guess.
244 - fn link_row(primary: String, meta: Option<&str>, url: Option<&str>, remove: Action) -> Row {
238 + /// A social handle and a custom field both carry an optional `url`, and the
239 + /// modal renders each as an anchor through `safeUrl`. When this screen was first
240 + /// described there was nothing to say about that: an `Action` was a route, and an
241 + /// address outside the app is not one. The URL went into the trailing text,
242 + /// which made it something to copy rather than something to follow.
243 + ///
244 + /// [`Action::external`] is the fix, and the renderer emits an anchor for it
245 + /// rather than a button. Note what it is *not*: an `Act` whose path happens to
246 + /// start with `https`. The renderer branches on the destination's variant, never
247 + /// on the shape of the string, because that is how a route called
248 + /// `/https-setup` ends up opening a browser.
249 + fn link_row(primary: String, label: Option<&str>, url: Option<&str>, remove: Action) -> Row {
245 250 let mut row = Row::new(primary);
246 251
247 - // The URL and any label share the trailing slot, url last so a long address
248 - // does not push the label off the end of it.
249 - let mut trailing: Vec<&str> = Vec::new();
250 - trailing.extend(meta);
251 - trailing.extend(url);
252 - if !trailing.is_empty() {
253 - row = row.meta(trailing.join(" · "));
252 + if let Some(label) = label {
253 + row = row.meta(label);
254 + }
255 +
256 + // The address is a place to go rather than a fact about the entry, so it is
257 + // a control and not trailing text.
258 + if let Some(url) = url {
259 + row.actions.push(Act::new("Open", Action::external(url)));
254 260 }
255 261
256 262 row.actions
@@ -8,7 +8,9 @@
8 8 //! above](super) for why both exist at once.
9 9 //!
10 10 //! Two things a real screen needed that the description layer could not say
11 - //! turned up here immediately, and are recorded in [`row_for`].
11 + //! turned up here immediately. The first is closed: a row carries tokens as of
12 + //! makeover-layout 0.9.0, so the two badges keep their tone. The second, rich
13 + //! text in a description, is still open. Both are recorded in [`row_for`].
12 14 //!
13 15 //! # The shape
14 16 //!
@@ -29,7 +31,7 @@
29 31 #![allow(clippy::needless_pass_by_value)]
30 32
31 33 use goingson_core::{Project, ProjectStatus, ProjectType};
32 - use quasi_router::screen::{Act, Row};
34 + use quasi_router::screen::{Act, Row, Tag};
33 35 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
34 36
35 37 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -72,23 +74,37 @@
72 74 }
73 75 }
74 76
77 + /// The tone a status badge wears.
78 + ///
79 + /// The Rust half of `utils.js:statusTone`, which is the one table the whole
80 + /// frontend maps statuses through (decided 2026-08-06, task 724a0667). Kept in
81 + /// step by hand while both exist, and this is the copy that survives when the JS
82 + /// screen goes.
83 + ///
84 + /// Archived returning neutral is deliberate there and here: it is not news.
85 + const fn status_tone(status: &ProjectStatus) -> makeover_layout::Tone {
86 + match status {
87 + ProjectStatus::Active => makeover_layout::Tone::Info,
88 + ProjectStatus::OnHold => makeover_layout::Tone::Warning,
89 + ProjectStatus::Completed => makeover_layout::Tone::Success,
90 + ProjectStatus::Archived => makeover_layout::Tone::Neutral,
91 + }
92 + }
93 +
75 94 /// One project as a row.
76 95 ///
77 - /// # The two things the description cannot say
96 + /// # The first finding, now closed
78 97 ///
79 - /// Both found here, on the first real screen, which is what the proving ground
80 - /// was for.
98 + /// **A row carried one trailing fact and this card has two.** `RowPart` was
99 + /// `Primary | Secondary | Meta | Actions`, so the type badge and the status badge
100 + /// were joined into `meta` as "Side Project · On Hold": both facts kept, the tone
101 + /// lost, and a status reading as text where `projects.js` colours it.
81 102 ///
82 - /// **A row carries one trailing fact and this card has two.**
83 - /// `makeover_layout::RowPart` is `Primary | Secondary | Meta | Actions`, taken
84 - /// from Balanced Breakfast as the consumer that had all four. A project card
85 - /// carries a type badge *and* a status badge, and the status badge is toned:
86 - /// `projects.js` runs `statusTone(status)` and colours it. Joined into `meta`
87 - /// here, which keeps both facts and loses the tone — a status reads as text
88 - /// rather than as green or amber. `Node::Token` exists and says exactly the
89 - /// right thing, but only as a node in its own right, never inside a row.
90 - /// Naming it in `makeover-layout` first is what the admission test requires,
91 - /// so this is a finding rather than a patch.
103 + /// makeover-layout 0.9.0 added `RowPart::Tokens` for exactly this, and it is what
104 + /// this row uses now. The status keeps its tone through [`status_tone`], which is
105 + /// the Rust half of the same table the JS reads.
106 + ///
107 + /// # The second finding, still open
92 108 ///
93 109 /// **A description carries text and this card carries markdown.**
94 110 /// `ProjectResponse::description_html` is `docengine::render_standard`, and the
@@ -97,20 +113,18 @@
97 113 /// door through which a description becomes a templating language. The raw
98 114 /// description goes into `secondary` as text. A `Region::Bespoke` is the
99 115 /// vocabulary's own answer for a place the app fills itself, and it is the
100 - /// shape this wants if it turns out to matter.
101 - fn row_for(project: &Project, selected: bool) -> Row {
102 - let mut row = Row::new(&project.name).meta(format!(
103 - "{} · {}",
104 - type_label(&project.project_type),
105 - status_label(&project.status)
106 - ));
116 + /// shape this wants if it turns out to matter. Filed as `25822137`.
117 + fn row_for(project: &Project, current: bool) -> Row {
118 + let mut row = Row::new(&project.name)
119 + .token(Tag::badge(type_label(&project.project_type)))
120 + .token(Tag::badge(status_label(&project.status)).tone(status_tone(&project.status)));
107 121
108 122 if !project.description.is_empty() {
109 123 // Text, not the rendered HTML. See the note above.
110 124 row = row.secondary(&project.description);
111 125 }
112 126
113 - row.selected = selected;
127 + row.current = current;
114 128 row.activate = Some(Action::get(format!("/projects/{}", project.id)));
115 129 row
116 130 }
@@ -200,13 +214,9 @@
200 214 // The filter surfaces only when sharing is in play, which is the rule
201 215 // `projects.js` already applies to the same control.
202 216 if shared > 0 || shared_only {
203 - band = band.with(Node::Token {
204 - kind: makeover_layout::Token::Chip { removable: false },
205 - label: "Shared only".into(),
206 - tone: makeover_layout::Tone::Neutral,
207 - latched: shared_only,
208 - action: Some(list_action(!shared_only, show_retired)),
209 - });
217 + band = band.with(Node::Token(
218 + Tag::chip("Shared only", list_action(!shared_only, show_retired)).latched(shared_only),
219 + ));
210 220 }
211 221
212 222 if dormant > 0 {
@@ -189,10 +189,12 @@
189 189 }
190 190
191 191 #[tokio::test]
192 - async fn a_row_carries_the_email_and_the_tags_as_one_trailing_fact() {
193 - // The vocabulary gap, asserted so the workaround is visible rather than
194 - // silently correct: five facts, three slots, and the tags read as text
195 - // rather than as pills. See `row_for`'s note.
192 + async fn a_row_carries_its_tags_as_tokens_and_its_email_as_the_plain_fact() {
193 + // Was `a_row_carries_the_email_and_the_tags_as_one_trailing_fact`, which
194 + // asserted the workaround: both joined into `meta` as
195 + // "ada@example.com · friend". makeover-layout 0.9.0 gave a row somewhere to
196 + // put tokens, so the two facts are now the two different kinds of thing they
197 + // always were.
196 198 let state = state().await;
197 199 let contact = add(&state, "Ada");
198 200 state
@@ -213,7 +215,30 @@
213 215 .unwrap();
214 216
215 217 let html = fragment_html(get(&state, "/contacts/list", Params::new()));
216 - assert!(html.contains("ada@example.com · friend"));
218 +
219 + // The email is a plain trailing fact and stays one.
220 + assert!(html.contains("class=\"row-meta\">ada@example.com</span>"));
221 + // The tag is a token, in the token strip, and clicking it filters the grid.
222 + assert!(html.contains("class=\"row-tokens\""));
223 + assert!(html.contains("friend"));
224 + assert!(html.contains("hx-vals=\"{&quot;tag&quot;:&quot;friend&quot;}\""));
225 + // And no longer joined into one string.
226 + assert!(!html.contains("ada@example.com · friend"));
227 + }
228 +
229 + #[tokio::test]
230 + async fn a_contact_row_can_be_ticked_without_being_the_current_one() {
231 + // The other closed gap. `Row::selected` used to mean "the detail pane is
232 + // showing this", so a bulk checkbox had no way to be described. It is now
233 + // the user's tick, and `current` is the app's pointer.
234 + let state = state().await;
235 + add(&state, "Ada");
236 +
237 + let html = fragment_html(get(&state, "/contacts/list", Params::new()));
238 + assert!(html.contains("type=\"checkbox\""));
239 + // Selectable but not ticked, and not the current row either.
240 + assert!(!html.contains(" checked"));
241 + assert!(!html.contains("aria-current"));
217 242 }
218 243
219 244 #[tokio::test]
@@ -263,8 +288,12 @@
263 288 }
264 289
265 290 #[tokio::test]
266 - async fn a_social_handles_url_is_text_because_a_row_cannot_carry_a_link() {
267 - // The other vocabulary gap, asserted for the same reason. See `link_row`.
291 + async fn a_social_handles_url_is_a_real_link_that_leaves_the_app() {
292 + // Was `a_social_handles_url_is_text_because_a_row_cannot_carry_a_link`.
293 + // Worth noting how that test would have survived this change: it asserted
294 + // `!html.contains("<a href=\"...")`, and the renderer emits
295 + // `<a class="act" href=...`, so it passed for the wrong reason. Asserting
296 + // the absence of a literal string is only as good as the string.
268 297 let state = state().await;
269 298 let contact = add(&state, "Ada");
270 299 state
@@ -286,9 +315,15 @@
286 315 Params::new(),
287 316 ));
288 317 assert!(html.contains("Mastodon: @ada"));
289 - assert!(html.contains("https://example.com/@ada"));
290 - // Something to copy, not something to follow.
291 - assert!(!html.contains("<a href=\"https://example.com/@ada\""));
318 +
319 + // An anchor, not a button, and not htmx's business: nothing swaps and no
320 + // route is called.
321 + assert!(html.contains("href=\"https://example.com/@ada\""));
322 + assert!(html.contains("rel=\"noopener noreferrer\""));
323 + assert!(!html.contains("hx-get=\"https://example.com/@ada\""));
324 +
325 + // The removal beside it is still a route, so both kinds coexist in one row.
326 + assert!(html.contains(&format!("hx-post=\"/contacts/{}/social/", contact.id)));
292 327 }
293 328
294 329 #[tokio::test]
@@ -144,10 +144,12 @@
144 144 }
145 145
146 146 #[tokio::test]
147 - async fn a_row_carries_both_badges_as_one_trailing_fact() {
148 - // The vocabulary gap, asserted so the workaround is visible rather than
149 - // silently correct: two badges, one `meta`, and the status tone gone. See
150 - // `row_for`'s note.
147 + async fn a_row_carries_both_badges_as_tokens_and_keeps_the_status_tone() {
148 + // Was `a_row_carries_both_badges_as_one_trailing_fact`, which asserted the
149 + // workaround: "Side Project · On Hold" joined into `meta`, and
150 + // `!html.contains("tone-warning")` pinning down the loss. makeover-layout
151 + // 0.9.0's `RowPart::Tokens` is what this screen's finding asked for, and
152 + // this is the assertion inverted.
151 153 let state = state().await;
152 154 add(&state, "Mine", ProjectStatus::OnHold);
153 155
@@ -156,8 +158,32 @@
156 158 };
157 159 let html = quasi_webview::Webview::new().fragment(&node);
158 160
159 - assert!(html.contains("Side Project · On Hold"));
160 - assert!(!html.contains("tone-warning"));
161 + assert!(html.contains("class=\"row-tokens\""));
162 + assert!(html.contains("Side Project"));
163 + assert!(html.contains("On Hold"));
164 + // The thing the join could not keep. `utils.js:statusTone` maps OnHold to
165 + // warning, and now so does the description.
166 + assert!(html.contains("tone-warning"));
167 + assert!(!html.contains("Side Project · On Hold"));
168 + }
169 +
170 + #[tokio::test]
171 + async fn only_the_status_badge_is_toned() {
172 + // A type is not news and takes no tone, which is what keeps a row from
173 + // being a row of colours. Archived is the same case on the status side:
174 + // `statusTone` returns nothing for it deliberately.
175 + let state = state().await;
176 + add(&state, "Done", ProjectStatus::Archived);
177 +
178 + let Response::Fragment { node, .. } =
179 + answer(&state, "/projects/list", Params::new().with("retired", "1"))
180 + else {
181 + panic!("a fragment");
182 + };
183 + let html = quasi_webview::Webview::new().fragment(&node);
184 +
185 + assert!(html.contains("Archived"));
186 + assert!(!html.contains("tone-"));
161 187 }
162 188
163 189 #[tokio::test]