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) <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-24 20:40:03 +02:00
co-authored by Claude Opus 5
parent c6d0701639
commit 743f4a8563
+45 -11
View File
@@ -692,9 +692,13 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"description": plan.about, "description": plan.about,
"opener": { "heading": "What we do", "description": "", "features": does }, "opener": { "heading": "What we do", "description": "", "features": does },
"shopping": { "heading": "Have a look around", "description": "", "items": shopping }, "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!({ "doors": plan.doors.iter().map(|d| (d.id.clone(), json!({
"heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue", "heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue",
"icon": "", "for_whom": "", "icon": "", "for_whom": "",
"behind": format!("{}.", capitalize(&plan.needs_behind(&d.id).iter().map(|n| n.wants.as_str()).collect::<Vec<_>>().join(", "))),
}))).collect::<Map<_, _>>(), }))).collect::<Map<_, _>>(),
"needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(), "needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}), }),
@@ -744,14 +748,23 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
.into_iter() .into_iter()
.map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to))))) .map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to)))))
.collect(); .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::<Vec<_>>().join(" or "));
(bucket.to_string(), json!({ "heading": humanize(bucket), "description": from_whom, "moves": moves, "list_title": humanize(bucket), "list_note": note }))
}) })
.collect(); .collect();
let links: Map<String, Value> = plan let links: Map<String, Value> = plan
.needs .needs
.iter() .iter()
.filter(|n| n.enters_as.as_deref() == Some(group.as_str())) .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(); .collect();
// One invite form per group this desk may fill, and the list of // One invite form per group this desk may fill, and the list of
// invites sent, when it may fill any. // invites sent, when it may fill any.
@@ -764,6 +777,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"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)), "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", "button": "Invite",
"form_title": "Who", "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" }, "labels": { "name": "Name", "email": "Email" },
})) }))
}) })
@@ -771,11 +785,17 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
let invited = if invites.is_empty() { let invited = if invites.is_empty() {
Value::Null Value::Null
} else { } 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( words.insert(
format!("questions/{group}/index.yaml"), 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. // A public view per bucket the plan opens: a read-only list.
@@ -787,6 +807,10 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")), "description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
"heading": humanize(bucket), "heading": humanize(bucket),
"list_description": "", "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", "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(), (_, "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(), (_, "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_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(), (_, "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"), ("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}"), (_, 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<String, Value>) -> anyhow::Result<BT
// ride an empty section, which portal renders as a // ride an empty section, which portal renders as a
// labelled strip on the card. // labelled strip on the card.
let (icon, for_whom) = (icon_name(s(&dw["icon"])), s(&dw["for_whom"]).trim()); let (icon, for_whom) = (icon_name(s(&dw["icon"])), s(&dw["for_whom"]).trim());
if icon.is_some() || !for_whom.is_empty() { let behind = s(&dw["behind"]).trim();
let mut f = json!({ "name": for_whom, "description": "" }); if icon.is_some() || !for_whom.is_empty() || !behind.is_empty() {
let mut f = json!({ "name": for_whom, "description": behind });
if let Some(icon) = icon { f["icon"] = json!(icon); } if let Some(icon) = icon { f["icon"] = json!(icon); }
alt["features"] = json!([f]); alt["features"] = json!([f]);
} }
@@ -1366,7 +1394,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"description": s(&bw["description"]), "description": s(&bw["description"]),
"action": format!("/{group}"), "action": format!("/{group}"),
"consequence": [s(&w["confirm"])], "consequence": [s(&w["confirm"])],
"features": [{ "name": "", "description": "", "resource": { "features": [{ "name": s(&bw["list_title"]), "description": s(&bw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket }, "source": { "kind": "kv", "bucket": bucket },
"requires_group": group, "requires_group": group,
// This group's buttons only. The same bucket sits on // This group's buttons only. The same bucket sits on
@@ -1402,7 +1430,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"action": format!("/{group}"), "action": format!("/{group}"),
"consequence": [s(&iw["button"])], "consequence": [s(&iw["button"])],
"invite": { "group": target }, "invite": { "group": target },
"features": [{ "name": s(&iw["form_title"]), "description": "", "requirements": [ "features": [{ "name": s(&iw["form_title"]), "description": s(&iw["form_description"]), "requirements": [
{ "name": "name", "label": s(&iw["labels"]["name"]) }, { "name": "name", "label": s(&iw["labels"]["name"]) },
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" }, { "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" },
]}], ]}],
@@ -1414,7 +1442,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"description": s(&lw["description"]), "description": s(&lw["description"]),
"action": format!("/{group}"), "action": format!("/{group}"),
"consequence": [s(&w["confirm"])], "consequence": [s(&w["confirm"])],
"features": [{ "name": "", "description": "", "resource": { "features": [{ "name": s(&lw["list_title"]), "description": s(&lw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": "portal_invites" }, "source": { "kind": "kv", "bucket": "portal_invites" },
"requires_group": group, "requires_group": group,
"transitions": [{ "from": "open", "to": "done", "label": s(&lw["done"]) }], "transitions": [{ "from": "open", "to": "done", "label": s(&lw["done"]) }],
@@ -1457,7 +1485,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
{ {
"name": s(&w["heading"]), "name": s(&w["heading"]),
"description": s(&w["list_description"]), "description": s(&w["list_description"]),
"features": [{ "name": "", "description": "", "resource": { "features": [{ "name": s(&w["list_title"]), "description": s(&w["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket }, "source": { "kind": "kv", "bucket": bucket },
"public": true, "public": true,
"jq": projection, "jq": projection,
@@ -2227,7 +2255,13 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::
(serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => { (serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => {
// An icon slot holds the bare name; the file carries `lucide:`. // 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() }; 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());
}
} }
_ => {} _ => {}
} }