build: the front door opens modestly, and a featured role must have a move

`does:` on a plan, at most two features, each `for:` a need: the first
card of the front door says what the business does, one role each,
headed by what that role gets, and asks for nothing. `shows:` is the
second card, portal resource blocks passed through verbatim plus every
public view. Only then the offers, as more alternatives on the same
page; the front-door warning moves to seven and stops recommending
doors. `Plan::spectators()` warns when a featured need gives the
submitter neither a move nor a view. uhhm.no rounds 4 and 5 (flat,
at least as good per persona) and Tomter Vel flat (worse: ten offers
on one page, the close pairs collide) are in runs/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 18:24:23 +02:00
co-authored by Claude Fable 5.1
parent 9e511b5b57
commit 9e7f16c489
148 changed files with 13367 additions and 579 deletions
+212 -14
View File
@@ -57,11 +57,53 @@ pub struct Plan {
/// no admin session, a member of the business vouching for a person.
#[serde(default)]
pub invites_by: Vec<String>,
/// What the business does, said plainly, one feature each: the
/// front door's first card, which asks for nothing. A stranger
/// reads it and decides whether to trust the place before any
/// button is offered. Empty means one feature per public need,
/// from its `wants`.
#[serde(default)]
pub does: Vec<Does>,
/// Things a visitor may look at before asking for anything - the
/// window shopping: a list of what has been built, releases, a
/// live count - each a portal resource block, passed through as
/// written. Public views of buckets (`watched_by: [public]`) are
/// listed here on their own.
#[serde(default)]
pub shows: Vec<Show>,
pub needs: Vec<PlanNeed>,
#[serde(default)]
pub personas: Vec<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Does {
pub id: String,
/// The fallback description, in the business's words.
pub text: String,
/// The need this feature speaks for: one role, so the opener is
/// one or two roles meeting, not a catalogue.
#[serde(default, rename = "for")]
pub for_need: Option<String>,
}
/// Features the opener may carry. One or two, each for a role: past
/// that the opener is a menu again, and a menu is what it is there
/// to spare the visitor.
const MAX_OPENER: usize = 2;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Show {
pub id: String,
#[serde(default)]
pub text: String,
/// Portal's `resource:` block for a feature, verbatim (source,
/// jq, public, empty). The lint holds it to portal's schema.
pub resource: Value,
}
fn english() -> String {
"en".to_string()
}
@@ -209,6 +251,27 @@ impl Plan {
bail!("door {:?}: that path is taken by the builder's own pages", door.id);
}
}
let mut cards = BTreeSet::new();
for id in self.does.iter().map(|d| &d.id).chain(self.shows.iter().map(|s| &s.id)) {
if !is_id(id) || !cards.insert(id) {
bail!("does/shows {id:?}: id must be unique ASCII lowercase with - or _");
}
}
for show in &self.shows {
if !show.resource.is_object() || show.resource.get("source").is_none() {
bail!("shows {:?}: resource must be portal's resource block, with a source", show.id);
}
}
if self.does.len() > MAX_OPENER {
bail!("does: the opener advertises one or two features, one role each; {} is a menu", self.does.len());
}
for d in &self.does {
if let Some(need) = &d.for_need {
if !self.needs.iter().any(|n| &n.id == need) {
bail!("does {:?}: `for` names {need:?}, which is not one of the needs", d.id);
}
}
}
let mut labelled = BTreeSet::new();
for g in &self.groups {
if !is_id(&g.id) || !labelled.insert(&g.id) || g.label.trim().is_empty() {
@@ -334,6 +397,26 @@ impl Plan {
Ok(())
}
/// The house and its players: a role the opener invites in has to
/// see its win, and the machine has to give it something to do
/// after it sits down - a move of its own (`moves_by: submitter`)
/// or at least a view of the table (`watched_by: [public]`). A
/// featured role with neither is a spectator, and the build says
/// so; it does not refuse, since the plan may mean it.
pub fn spectators(&self) -> Vec<String> {
self.does
.iter()
.filter_map(|d| d.for_need.as_ref().and_then(|id| self.needs.iter().find(|n| &n.id == id)))
.filter(|n| !n.moves_by.values().any(|m| m.iter().any(|w| w == SUBMITTER)) && !n.watched_by.iter().any(|g| g == PUBLIC))
.map(|n| {
format!(
"the opener invites {} in, and after sending they play nothing: give need {:?} a move of theirs (moves_by: {{<stage>: [submitter, ...]}}) or a public view (watched_by: [public])",
n.who, n.id
)
})
.collect()
}
/// What sits on the front door: each door, and each public need
/// with no door.
fn front_door_count(&self) -> usize {
@@ -464,11 +547,52 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
};
let mut words = BTreeMap::new();
let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none() && n.door.is_none()).collect();
// The opener: what the business does, asking for nothing. From
// the plan's `does`, or one feature per public need.
let does: Map<String, Value> = if plan.does.is_empty() {
// One or two roles, the first the plan names: the visitor the
// business exists for, and the next one it serves.
plan.needs
.iter()
.filter(|n| n.enters_as.is_none())
.take(MAX_OPENER)
.map(|n| (n.id.clone(), json!({ "heading": humanize(&n.wants), "description": format!("For {}.", n.who), "icon": "" })))
.collect()
} else {
// A feature for a role is headed by that role's win: what
// the need says they want.
plan.does
.iter()
.map(|d| {
let heading = d
.for_need
.as_ref()
.and_then(|id| plan.needs.iter().find(|n| &n.id == id))
.map(|n| humanize(&n.wants))
.unwrap_or_else(|| humanize(&d.id));
(d.id.clone(), json!({ "heading": heading, "description": d.text, "icon": "" }))
})
.collect()
};
let mut shopping: Map<String, Value> =
plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect();
for (bucket, needs) in public_buckets(plan) {
shopping.insert(
format!("status-{bucket}"),
json!({
"heading": format!("See all {}", humanize(bucket).to_lowercase()),
"description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
"icon": "list",
}),
);
}
words.insert(
"questions/index.yaml".to_string(),
json!({
"question": "What brings you here?",
"description": plan.about,
"opener": { "heading": "What we do", "description": "", "features": does },
"shopping": { "heading": "Have a look around", "description": "", "items": shopping },
"doors": plan.doors.iter().map(|d| (d.id.clone(), json!({
"heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue",
"icon": "", "for_whom": "",
@@ -602,7 +726,7 @@ fn decide_path(need: &PlanNeed) -> String {
const SUBMITTER: &str = "submitter";
/// Alternatives on one page before a visitor stops reading them.
const MAX_FRONT_DOOR: usize = 5;
const MAX_FRONT_DOOR: usize = 7;
/// Who presses the button into `to`.
fn movers<'a>(need: &'a PlanNeed, to: &str) -> Vec<&'a str> {
@@ -829,10 +953,47 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
}).collect::<Vec<_>>() }),
);
// The front door: one alternative per door, then one per need the
// public brings straight to it.
// The front door opens modestly: first what the business does,
// in features and no button; then what a visitor may look at;
// and only then, further down, one alternative per door and one
// per need the public brings straight to it. A stranger decides
// to trust the place before being asked for anything.
let front = &words["questions/index.yaml"];
let mut alternatives: Vec<Value> = plan
let card = |w: &Value| {
let mut f = json!({ "name": s(&w["heading"]), "description": s(&w["description"]) });
if let Some(icon) = icon_name(s(&w["icon"])) {
f["icon"] = json!(icon);
}
f
};
let does: Vec<Value> = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect();
let mut alternatives: Vec<Value> = vec![json!({
"name": s(&front["opener"]["heading"]),
"description": s(&front["opener"]["description"]),
"features": does,
})];
let mut shopping: Vec<Value> = plan
.shows
.iter()
.map(|show| {
let mut f = card(&front["shopping"]["items"][&show.id]);
f["resource"] = show.resource.clone();
f
})
.collect();
for (bucket, _) in public_buckets(plan) {
let mut f = card(&front["shopping"]["items"][&format!("status-{bucket}")]);
f["description"] = json!(format!("{} [{}](/status/{bucket})", s(&f["description"]).trim(), s(&front["shopping"]["items"][&format!("status-{bucket}")]["heading"])));
shopping.push(f);
}
if !shopping.is_empty() {
alternatives.push(json!({
"name": s(&front["shopping"]["heading"]),
"description": s(&front["shopping"]["description"]),
"features": shopping,
}));
}
alternatives.extend(plan
.doors
.iter()
.map(|d| {
@@ -853,8 +1014,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
alt["features"] = json!([f]);
}
alt
})
.collect();
}));
alternatives.extend(
plan.needs
.iter()
@@ -863,7 +1023,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
);
if plan.front_door_count() > MAX_FRONT_DOOR {
eprintln!(
"build: the front door offers {} alternatives; past {MAX_FRONT_DOOR} a visitor stops reading. Put needs behind doors.",
"build: the front door offers {} alternatives after the opener; past {MAX_FRONT_DOOR} a visitor stops reading. Fewer offers, or needs that share one.",
plan.front_door_count()
);
}
@@ -1099,7 +1259,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
let purpose = if path.contains("/decide/") {
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
} else if path == "questions/index.yaml" {
"the front door: the first page any visitor sees. `question` is the page's one question. Under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor: its heading must let that visitor recognise themselves from the heading alone. Under `needs`, each entry is one alternative with a form behind it."
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card, things a visitor may look at before asking for anything. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
} else if path.starts_with("questions/status/") {
"a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list."
} else if plan.doors.iter().any(|d| door_path(d) == path) {
@@ -1142,6 +1302,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?)
.with_context(|| format!("parsing {plan_path}"))?;
plan.validate()?;
for warning in plan.spectators() {
eprintln!("build: {warning}");
}
// Words, in layers: the fallbacks; what an existing repo already
// says (hand edits survive a rebuild); explicit overrides for new
@@ -1269,7 +1432,7 @@ mod tests {
front["needs"]["survey"]["hints"]["email"] = json!("you@example.no");
let files = render(&plan, &words).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let alt = &page["alternatives"][0];
let alt = &page["alternatives"][1];
assert_eq!(alt["consequence"][0], "Book a look");
assert_eq!(alt["encouragements"][0], "We call within two days.");
// Two context sections from the plan come before the form.
@@ -1357,18 +1520,53 @@ mod tests {
assert!(plan.validate().unwrap_err().to_string().contains("required `email`"));
}
#[test]
fn the_front_door_opens_with_what_we_do_and_a_look_around_before_any_button() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoes:\n - { id: fix, text: We fix roofs., for: leak }\n - { id: keep, text: We keep them fixed. }\nshows:\n - id: built\n text: Roofs we have done.\n resource: { public: true, source: { kind: url, url: https://b.example/roofs.json } }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n watched_by: [public]\n stages: [open, fixed]\n done_when: [fixed]\n max_fields: 1\n collects:\n - { name: where }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: roofers\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let alts = front["alternatives"].as_array().unwrap();
// The opener: two features, no action, no form.
assert_eq!(alts[0]["name"], "What we do");
assert!(alts[0].get("action").is_none());
assert_eq!(alts[0]["features"].as_array().unwrap().len(), 2);
assert_eq!(alts[0]["features"][0]["description"], "We fix roofs.");
// The look around: the resource verbatim, and the public view linked.
assert_eq!(alts[1]["name"], "Have a look around");
assert!(alts[1].get("action").is_none());
assert_eq!(alts[1]["features"][0]["resource"]["source"]["kind"], "url");
assert!(alts[1]["features"][1]["description"].as_str().unwrap().contains("](/status/leaks)"));
// Only then the offers.
assert_eq!(alts[2]["record_as"], "leaks");
assert_eq!(alts[3]["record_as"], "applicants");
// The role the opener invites in is headed by its win, and
// must have something to play: the owner watches the public
// list, so no warning; take that away and the build says so.
assert_eq!(alts[0]["features"][0]["name"], "A dry house");
assert!(plan.spectators().is_empty());
let spectator: Plan = serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap();
assert!(spectator.spectators()[0].contains("play nothing"));
// Three features is a menu; a `for` must name a need.
let three = yaml.replace(" - { id: keep, text: We keep them fixed. }\n", " - { id: keep, text: x }\n - { id: more, text: y }\n");
assert!(serde_yaml::from_str::<Plan>(&three).unwrap().validate().unwrap_err().to_string().contains("one or two"));
let bad = yaml.replace("for: leak", "for: nothing");
assert!(serde_yaml::from_str::<Plan>(&bad).unwrap().validate().unwrap_err().to_string().contains("nothing"));
}
#[test]
fn a_door_is_a_first_question_with_its_needs_on_a_page_of_their_own() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoors:\n - { id: your-building, who: an owner with a building }\nneeds:\n - id: survey\n who: owners\n wants: a look\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: build\n who: owners\n wants: a price\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: pms\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The front door: the door, then the direct need.
// The front door: the opener, then the door, then the direct need.
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let names: Vec<&str> = front["alternatives"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Your building", "a job"]);
assert_eq!(front["alternatives"][0]["action"], "/your-building");
assert!(front["alternatives"][0].get("record_as").is_none());
assert_eq!(names, ["What we do", "Your building", "a job"]);
assert_eq!(front["alternatives"][1]["action"], "/your-building");
assert!(front["alternatives"][1].get("record_as").is_none());
// The door's page holds both forms and is not gated.
let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap();
assert_eq!(door["alternatives"].as_array().unwrap().len(), 2);
@@ -1398,7 +1596,7 @@ mod tests {
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let field = &page["alternatives"][0]["features"][2]["requirements"][1];
let field = &page["alternatives"][1]["features"][2]["requirements"][1];
assert_eq!(field["options"], json!(["Trafikkforhold", "Elvesti"]));
assert_eq!(field["multiple"], true);
// Without labelled groups there is nothing to draw from.