From 743f4a856335fb27aee48a0c270d7c3fac7bf14e Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 24 Sep 2026 20:40:03 +0200 Subject: [PATCH] build: every feature says something; a lift carries words, not absences Five features were emitted empty, as layout: a door's strip, a desk's list, an invited list, a public list, an invite form. Each now has a title and a line that says what a row is and what the buttons under it do, or what the list leaves out - true from the plan before a model writes a word, and a slot the model can improve. Desk cards, their links and the desk page itself get real fallbacks too. The lift no longer copies an empty live string over a fallback, which would have emptied every new slot on the next rebuild. Co-Authored-By: Claude Opus 5 (1M context) --- src/build.rs | 56 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/build.rs b/src/build.rs index b074c22..4b811c4 100644 --- a/src/build.rs +++ b/src/build.rs @@ -692,9 +692,13 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { "description": plan.about, "opener": { "heading": "What we do", "description": "", "features": does }, "shopping": { "heading": "Have a look around", "description": "", "items": shopping }, + // A door's feature says what is behind it, from the needs + // that sit there: a card that leads somewhere has to say + // where, or it is a button with decoration around it. "doors": plan.doors.iter().map(|d| (d.id.clone(), json!({ "heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue", "icon": "", "for_whom": "", + "behind": format!("{}.", capitalize(&plan.needs_behind(&d.id).iter().map(|n| n.wants.as_str()).collect::>().join(", "))), }))).collect::>(), "needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::>(), }), @@ -744,14 +748,23 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { .into_iter() .map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to))))) .collect(); - (bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves })) + // The list itself says what a row is and what the + // buttons under it do; the card's own description says + // what the cases are. + let note = if moves.is_empty() { + "Each row is one case and the stage it has reached. Another desk moves these.".to_string() + } else { + "Each row is one case. The buttons record what you have just done with it.".to_string() + }; + let from_whom = format!("Sent in by {}.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")); + (bucket.to_string(), json!({ "heading": humanize(bucket), "description": from_whom, "moves": moves, "list_title": humanize(bucket), "list_note": note })) }) .collect(); let links: Map = plan .needs .iter() .filter(|n| n.enters_as.as_deref() == Some(group.as_str())) - .map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" }))) + .map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": format!("For {}.", n.who), "button": "Open" }))) .collect(); // One invite form per group this desk may fill, and the list of // invites sent, when it may fill any. @@ -764,6 +777,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { "description": format!("They get an account, a place in {}, and a one-time link to set their own password or passkey.", label_of(plan, g)), "button": "Invite", "form_title": "Who", + "form_description": "Their name, and the address the account and its one-time link go to.".to_string(), "labels": { "name": "Name", "email": "Email" }, })) }) @@ -771,11 +785,17 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { let invited = if invites.is_empty() { Value::Null } else { - json!({ "heading": "Invitations sent", "description": "Each invite with the link it made. Mark it done when the person is in.", "done": "Is in" }) + json!({ + "heading": "Invitations sent", + "description": "Each invite with the link it made. Mark it done when the person is in.", + "done": "Is in", + "list_title": "Who has been invited", + "list_note": "Each row is one invitation, with the one-time link while it is unused.", + }) }; words.insert( format!("questions/{group}/index.yaml"), - json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited }), + json!({ "question": "What needs doing?", "description": format!("Everything {} looks after.", label_of(plan, &group).to_lowercase()), "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited }), ); } // A public view per bucket the plan opens: a read-only list. @@ -787,6 +807,10 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { "description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), "heading": humanize(bucket), "list_description": "", + // A public list says what a row is and what it leaves + // out, since the reader cannot know what was pruned. + "list_title": humanize(bucket), + "list_note": "Each row is one case and the stage it has reached. Names and contact details are not shown here.", "back": "Back", }), ); @@ -1099,6 +1123,9 @@ fn slots_in(value: &Value, key: &str, parent: &str) -> Value { (_, "road_icon" | "who_icon") => "an icon name from the list at the end that fits this section; or empty".to_string(), (_, "who_title") => "a short title, two to four words, for the line that says who reads it".to_string(), (_, "list_description") => "one line over a public list of what people have sent in, or empty".to_string(), + (_, "list_title") => "a short title, two to four words, naming the list itself".to_string(), + (_, "list_note") => "one sentence saying what a single row is, and what the buttons under it do or what the list leaves out".to_string(), + (_, "behind") => "one sentence naming what a reader can do behind this door, in their words".to_string(), (_, "who_description") => "one sentence: who reads what they send, and how to reach them meanwhile".to_string(), ("hints", field) => format!("an example answer for the field `{field}`, as a placeholder; or empty"), (_, other) if other.contains("->") => format!("a button, two to four words, for a case going {other}"), @@ -1278,8 +1305,9 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result { // An icon slot holds the bare name; the file carries `lucide:`. let word = if t.ends_with("/icon\u{3}") { live.trim_start_matches("lucide:") } else { live.as_str() }; - found.insert(t.clone(), word.to_string()); + // A lift carries words across, not absences: where the live + // page said nothing, the fallback stands. Otherwise a slot + // that has since been given something to say would be + // emptied again by every rebuild. + if !word.trim().is_empty() { + found.insert(t.clone(), word.to_string()); + } } _ => {} }