| 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 |
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.
|