Skip to main content

max / goingson

The task screen renders markdown and its subtask ticks are the write
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:28 UTC
Signed with PGP, not checked
Commit: 4c6d07ebcb86310a572fe315a191fee63466bcf3
Parent: c489e2b
2 files changed, +70 insertions, -53 deletions
@@ -239,29 +239,32 @@
239 239
240 240 /// The metadata section.
241 241 ///
242 - /// # The second finding, closed against a member
242 + /// # The second finding, closed with a member
243 243 ///
244 244 /// **A description carries text and this task carries markdown.**
245 245 /// `TaskResponse::description_html` is `docengine::render_standard` and the JS
246 - /// drops it into a `.markdown-content` div. Nothing in the vocabulary names rich
247 - /// text and it must not be smuggled in as a string the renderer trusts. The raw
248 - /// description goes in as text, correct and lossy.
246 + /// drops it into a `.markdown-content` div. The port put the raw source in as
247 + /// [`Node::text`], correct and lossy: the user read `**bold**` rather than bold.
249 248 ///
250 - /// This was makeover-layout `25822137`, found on the projects card, and this
251 - /// site was filed as its second consumer. Counting properly on 2026-08-09 took
252 - /// it back to one: the projects card wants rich text in a *row part*, which the
253 - /// row ruling refuses outright, so this standalone node was the only live
254 - /// consumer — in one app, Balanced Breakfast having none. One consumer is not
255 - /// evidence and the finding closed without a member.
249 + /// `25822137`, closed 2026-08-09 as `Node::Rich`, which carries the markdown
250 + /// **source**. That is what made the member defensible where `Node::Html` was
251 + /// not: every renderer has an honest answer because each renders the source its
252 + /// own way, and nothing in a description is ever markup, so `Node::Text`'s
253 + /// escaping guarantee is untouched.
256 254 ///
257 - /// `Region::Bespoke` remains available here if this ever needs to render rich,
258 - /// and is the shape to reach for; it is not taken now because a bespoke region
259 - /// hands the space back to the JS, which is what describing the screen was for.
255 + /// It closed against a member first, on the two-app count, and reopened when
256 + /// that rule was retired the same day. Only this half came back — the projects
257 + /// card wants rich text in a row part, which the row ruling refuses on
258 + /// structural grounds that the rule change did not touch.
259 + ///
260 + /// One deliberate difference from the shipped screen: quasi-webview renders
261 + /// through docengine's strict preset rather than the standard one the JS uses,
262 + /// so raw HTML inside a description reads as text. A shared renderer taking
263 + /// what a user typed should be the safer of the two.
260 264 fn metadata(task: &Task) -> Vec<Node> {
261 265 let mut out = badges(task);
262 266 if !task.description.is_empty() {
263 - // Text, not the rendered HTML. See the note above.
264 - out.push(Node::text(&task.description));
267 + out.push(Node::rich(&task.description));
265 268 }
266 269 let rows = details(task);
267 270 if !rows.is_empty() {
@@ -272,36 +275,39 @@
272 275
273 276 /// One subtask.
274 277 ///
275 - /// # The third finding
278 + /// # The third finding, closed
276 279 ///
277 - /// **A tick that means something has no route.** `Row::selected` says whether a
278 - /// row is ticked and whether it can be; nothing says what ticking it *calls*.
279 - /// That was right for the case it was added for — goingson's bulk-selection
280 - /// checkboxes are client-side state feeding a later bulk action — and a subtask
281 - /// is the other case: the tick is the write.
280 + /// **A tick that means something has no route.** `Row::selected` said whether a
281 + /// row is ticked and whether it can be, and nothing said what ticking it
282 + /// *calls*. That was right for the case it was added for — goingson's
283 + /// bulk-selection checkboxes are client state feeding a later bulk action — and
284 + /// a subtask is the other case: the tick is the write.
282 285 ///
283 - /// So the tick here is drawn and inert, and the toggle is an `Act` beside it.
284 - /// Every fact survives, the affordance does not: the user clicks a button
285 - /// labelled "Done" where the shipped screen has a checkbox they click directly.
286 - /// Filed as makeover-layout `14612ed8`.
286 + /// The port drew the tick inert and put the toggle on a button beside it, so
287 + /// every fact survived and the affordance did not: a user clicking a button
288 + /// labelled "Done" next to a checkbox that ignores clicks.
287 289 ///
288 - /// A linked subtask is disabled in the JS because its state follows the task it
289 - /// links to. `Act::disabled` says that, so this one is described exactly.
290 + /// `14612ed8`, closed 2026-08-09 as `Row::toggle`, on quasi-router rather than
291 + /// makeover-layout. The vocabulary has no notion of an action at all, so "what
292 + /// this calls" was never a thing it could say; `Row::activate` was already the
293 + /// precedent sitting next to it. This is the small half of the finding — the
294 + /// other 13 sites are standalone controls, and `Field::changes` is theirs.
295 + ///
296 + /// A linked subtask keeps the button: its state follows the task it links to, so
297 + /// it is not tickable at all, and `Act::disabled` is what says that. Describing
298 + /// it as a tick that refuses to move would be the same defect the other way
299 + /// round.
290 300 fn subtask_row(task: TaskId, subtask: &Subtask) -> Row {
291 - let mut row = Row::new(&subtask.text).selectable(subtask.is_completed);
301 + let action = Action::post(format!("/tasks/{task}/subtasks/{}/toggle", subtask.id));
302 +
292 303 if subtask.linked_task_id.is_some() {
293 - row = row.token(Tag::badge("Linked"));
304 + return Row::new(&subtask.text)
305 + .selectable(subtask.is_completed)
306 + .token(Tag::badge("Linked"))
307 + .act(Act::new(if subtask.is_completed { "Undo" } else { "Done" }, action).disabled());
294 308 }
295 309
296 - let toggle = Act::new(
297 - if subtask.is_completed { "Undo" } else { "Done" },
298 - Action::post(format!("/tasks/{task}/subtasks/{}/toggle", subtask.id)),
299 - );
300 - row.act(if subtask.linked_task_id.is_some() {
301 - toggle.disabled()
302 - } else {
303 - toggle
304 - })
310 + Row::new(&subtask.text).toggling(subtask.is_completed, action)
305 311 }
306 312
307 313 /// The subtasks section.
@@ -174,9 +174,10 @@
174 174 }
175 175
176 176 #[tokio::test]
177 - async fn a_subtask_tick_is_drawn_and_the_toggle_is_a_button_beside_it() {
178 - // The third finding. `Row::selected` says ticked and tickable and nothing
179 - // says what ticking calls, so the affordance and the write come apart.
177 + async fn a_subtask_tick_is_the_write_rather_than_a_button_beside_it() {
178 + // The third finding, closed. It asserted the workaround until 2026-08-09 —
179 + // a tick drawn inert with the route on a button next to it — and
180 + // `Row::toggle` is what changed. `14612ed8`.
180 181 let state = state().await;
181 182 let task = add(&state, "Has subtasks");
182 183 state
@@ -186,12 +187,15 @@
186 187
187 188 let page = html(get(&state, &format!("/tasks/{}", task.id)));
188 189
189 - // The tick is there.
190 190 assert!(page.contains("type=\"checkbox\""));
191 - // And it calls nothing: the route is on a button next to it.
192 - assert!(page.contains("Done"));
193 191 assert!(page.contains("/subtasks/"));
194 192 assert!(page.contains("/toggle"));
193 + // The route is on the tick itself, and it fires on the value changing
194 + // rather than on the row being activated.
195 + assert!(page.contains("hx-trigger=\"change\""));
196 + // The button the workaround needed is gone, so there is one affordance for
197 + // one write rather than two for one.
198 + assert!(!page.contains(">Done<"));
195 199 }
196 200
197 201 #[tokio::test]
@@ -308,7 +312,7 @@
308 312 }
309 313
310 314 #[tokio::test]
311 - async fn toggling_a_subtask_ticks_it_and_the_button_offers_the_way_back() {
315 + async fn toggling_a_subtask_ticks_it_and_the_tick_is_the_way_back() {
312 316 let state = state().await;
313 317 let task = add(&state, "Has subtasks");
314 318 let subtask = state
@@ -325,7 +329,10 @@
325 329
326 330 assert!(page.contains("Subtasks 1/1"));
327 331 assert!(page.contains(" checked"));
328 - assert!(page.contains("Undo"));
332 + // The way back was an "Undo" button until `14612ed8` closed. It is the same
333 + // tick, unticked, which is what the shipped screen has always offered.
334 + assert!(page.contains("/toggle"));
335 + assert!(!page.contains(">Undo<"));
329 336 }
330 337
331 338 #[tokio::test]
@@ -410,10 +417,10 @@
410 417 }
411 418
412 419 #[tokio::test]
413 - async fn a_description_arrives_as_text_and_never_as_markup() {
414 - // The second finding, which is makeover-layout `25822137` meeting its second
415 - // consumer. The JS renders `descriptionHtml` through docengine; the
416 - // description carries the raw markdown as text, correct and lossy.
420 + async fn a_description_renders_as_markdown_and_still_cannot_become_arbitrary_markup() {
421 + // The second finding, closed. It asserted the lossy form until 2026-08-09 —
422 + // the user read `**bold**` rather than bold — and `Node::Rich` is what
423 + // changed. `25822137`.
417 424 let state = state().await;
418 425 let task = state
419 426 .tasks
@@ -421,15 +428,19 @@
421 428 DESKTOP_USER_ID,
422 429 NewTask::builder("Documented")
423 430 .title("Documented")
424 - .description("A **bold** claim")
431 + .description("A **bold** claim\n\n<script>alert(1)</script>")
425 432 .build(),
426 433 )
427 434 .unwrap();
428 435
429 436 let page = html(get(&state, &format!("/tasks/{}", task.id)));
430 437
431 - assert!(page.contains("A **bold** claim"));
432 - assert!(!page.contains("<strong>"));
438 + assert!(page.contains("<strong>bold</strong>"));
439 + // The member carries source, so what a user typed is still the renderer's
440 + // to decide about. This is the same guarantee `Node::Text` gives, kept.
441 + // The body of the script rather than the tag: the document shell carries
442 + // htmx's own `<script>`, so the tag alone is not the question.
443 + assert!(!page.contains("alert(1)"));
433 444 }
434 445
435 446 #[tokio::test]