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:
co-authored by
Claude Fable 5.1
parent
9e511b5b57
commit
9e7f16c489
+212
-14
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user