Who a business is gets a page of its own; inside forms ask real questions; build names untried needs
Test / test (pull_request) Successful in 1m12s
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
19b7750292
commit
e6c4f67db7
+120
-23
@@ -1177,21 +1177,31 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if has_who_page(plan) {
|
||||
words.insert(
|
||||
who_page_path(plan),
|
||||
json!({
|
||||
"question": loc(lang, "Who are we?", "Hvem er vi?"),
|
||||
"description": "",
|
||||
"presents": presenting_buckets(plan).into_iter().map(|b| (b.to_string(), json!({
|
||||
"heading": humanize(b), "description": "", "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."),
|
||||
}))).collect::<Map<String, Value>>(),
|
||||
"values": {
|
||||
"heading": loc(lang, "What we stand for", "Det vi står for"),
|
||||
"description": "",
|
||||
"items": plan.values.iter().map(|v| (v.id.clone(), json!({ "heading": humanize(&v.id), "description": v.text, "icon": "" }))).collect::<Map<String, Value>>(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
words.insert(
|
||||
"questions/index.yaml".to_string(),
|
||||
json!({
|
||||
"question": "What brings you here?",
|
||||
"description": plan.about,
|
||||
"opener": { "heading": "What we do", "description": "", "features": does },
|
||||
"who": {
|
||||
"heading": loc(lang, "Who we are", "Hvem vi er"),
|
||||
"description": "",
|
||||
"items": plan.values.iter().map(|v| (v.id.clone(), json!({ "heading": humanize(&v.id), "description": v.text, "icon": "" })))
|
||||
.chain(public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(bucket, _)| (format!("people-{bucket}"), json!({
|
||||
"heading": humanize(bucket), "description": "", "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."), "icon": "",
|
||||
}))))
|
||||
.collect::<Map<String, Value>>(),
|
||||
},
|
||||
// The way to the page about who the business is.
|
||||
"who": { "heading": loc(lang, "Who we are", "Hvem vi er"), "description": "", "button": loc(lang, "Meet us", "Bli kjent med oss") },
|
||||
// Named for what it shows, never an invitation to look.
|
||||
"shopping": { "heading": format!("{} {}", loc(lang, "Latest from", "Siste fra"), plan.business), "description": "", "items": shopping },
|
||||
// A door's feature says what is behind it, from the needs
|
||||
@@ -2115,20 +2125,14 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
.collect();
|
||||
// A public list is shown, not pointed at: its newest few records
|
||||
// inline, and a link to the whole list.
|
||||
// Who the business is: its values in its own words, and every
|
||||
// record of what it presents - the people - shown, not listed.
|
||||
let mut who: Vec<Value> = plan.values.iter().map(|v| card(&front["who"]["items"][&v.id])).collect();
|
||||
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)) {
|
||||
let w = &front["who"]["items"][&format!("people-{bucket}")];
|
||||
let mut f = card(w);
|
||||
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), None, "");
|
||||
who.push(f);
|
||||
}
|
||||
if !who.is_empty() {
|
||||
// Who the business is has a page of its own (see `who_page`);
|
||||
// the front door only leads there.
|
||||
if has_who_page(plan) {
|
||||
alternatives.push(json!({
|
||||
"name": text(&front["who"]["heading"]),
|
||||
"description": text(&front["who"]["description"]),
|
||||
"features": who,
|
||||
"action": format!("/{}", who_page_id(plan)),
|
||||
"consequence": button(&front["who"]["button"]),
|
||||
}));
|
||||
}
|
||||
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
|
||||
@@ -2186,6 +2190,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
front_page["slideshow"] = slideshow.clone();
|
||||
}
|
||||
files.insert("questions/index.yaml".into(), front_page);
|
||||
if has_who_page(plan) {
|
||||
files.insert(who_page_path(plan), who_page(plan, words));
|
||||
}
|
||||
|
||||
// A door's page: a nav page (index.yaml), public, holding the
|
||||
// forms of the needs behind it.
|
||||
@@ -2519,6 +2526,74 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
|
||||
const DESK_PURPOSE: &str = "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons.";
|
||||
|
||||
/// The page about who the business is: a question with nothing to do
|
||||
/// on it, only things to read. One card per need that `presents` (the
|
||||
/// people, the work), each showing every record as a card, and one for
|
||||
/// the business's own statements (`values`), carried word for word.
|
||||
fn who_page(plan: &Plan, words: &BTreeMap<String, Value>) -> Value {
|
||||
let w = &words[&who_page_path(plan)];
|
||||
let mut alternatives: Vec<Value> = Vec::new();
|
||||
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)) {
|
||||
let pw = &w["presents"][bucket];
|
||||
alternatives.push(json!({
|
||||
"name": text(&pw["heading"]),
|
||||
"description": text(&pw["description"]),
|
||||
"features": [{ "name": "", "description": "", "resource": public_resource(bucket, &needs, s(&pw["empty"]), None, "") }],
|
||||
}));
|
||||
}
|
||||
if !plan.values.is_empty() {
|
||||
let vw = &w["values"];
|
||||
alternatives.push(json!({
|
||||
"name": text(&vw["heading"]),
|
||||
"description": text(&vw["description"]),
|
||||
"features": plan.values.iter().map(|v| {
|
||||
let iw = &vw["items"][&v.id];
|
||||
let mut f = json!({ "name": text(&iw["heading"]), "description": text(&iw["description"]) });
|
||||
if let Some(icon) = icon_name(s(&iw["icon"])) {
|
||||
f["icon"] = json!(icon);
|
||||
}
|
||||
f
|
||||
}).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
json!({
|
||||
"name": text(&w["question"]),
|
||||
"description": text(&w["description"]),
|
||||
"responsible": { "name": plan.contact.name, "contact": plan.contact.email },
|
||||
"alternatives": alternatives,
|
||||
})
|
||||
}
|
||||
|
||||
fn presenting_buckets(plan: &Plan) -> Vec<&str> {
|
||||
public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(b, _)| b).collect()
|
||||
}
|
||||
|
||||
fn has_who_page(plan: &Plan) -> bool {
|
||||
!plan.values.is_empty() || !presenting_buckets(plan).is_empty()
|
||||
}
|
||||
|
||||
fn who_page_id(plan: &Plan) -> String {
|
||||
loc(&plan.language, "who", "hvem")
|
||||
}
|
||||
|
||||
fn who_page_path(plan: &Plan) -> String {
|
||||
format!("questions/{}.yaml", who_page_id(plan))
|
||||
}
|
||||
|
||||
/// The needs no persona tries, paused ones aside. A need nobody plays
|
||||
/// is one whose page is only ever read for the first time by the
|
||||
/// person it is for: the forms a business fills in from the inside
|
||||
/// are the easy ones to forget, and where "is this my own card?" goes
|
||||
/// unasked.
|
||||
pub fn untried_needs(plan: &Plan) -> Vec<&str> {
|
||||
plan.needs
|
||||
.iter()
|
||||
.filter(|n| !n.paused)
|
||||
.filter(|n| !plan.personas.iter().any(|p| p.get("need").and_then(|v| v.as_str()) == Some(n.id.as_str())))
|
||||
.map(|n| n.id.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
|
||||
let voice = match tone {
|
||||
Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."),
|
||||
@@ -2543,20 +2618,33 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
if !own.is_empty() {
|
||||
desk_purpose.push_str(&format!(" On this page, the list(s) {} show the signed-in person ONLY the cases they sent in themselves: they are following their own case, not working through other people's, so head those lists as theirs ('Your cases') and say nothing of a queue.", own.iter().map(|b| format!("`{b}`")).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
// A form a role fills in from inside: its page still asks a
|
||||
// question, the one the form answers, and says where the answer
|
||||
// goes - a heading like "Your card on the front page" names a
|
||||
// place, and the colleague reading it does not know what they are
|
||||
// being asked.
|
||||
let own_form = plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}.yaml", n.id)));
|
||||
let mut inside_purpose = "a page only one role inside the business can open, holding one form. `question` is a real question, the one the form answers, asked of the colleague in their own terms (\"What has been delivered?\"), never a label or the name of a place; where their answer ends up - who sees it, on which page - belongs in `page_description`.".to_string();
|
||||
if own_form.is_some_and(|n| n.one_per_person) {
|
||||
inside_purpose.push_str(" This form is the colleague's own record, one each: it opens filled in with what they have, and saving replaces it, so ask about them (\"How do you present yourself?\"), say where it shows, and make the button save.");
|
||||
}
|
||||
// A form a role enters from inside is sent by a colleague at work,
|
||||
// not by a visitor waiting on an answer.
|
||||
let sent_purpose = match plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}-sent.yaml", n.id))) {
|
||||
Some(n) => format!("a page shown right after someone in the group `{}` has sent a form from inside the business. The reader is that colleague, at work, not a visitor: nobody comes back to them. Say what has now happened to what they entered, and who it reaches.", n.enters_as.as_deref().unwrap_or_default()),
|
||||
None => "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next.".to_string(),
|
||||
};
|
||||
let purpose = if path == "site.yaml" {
|
||||
let who_purpose = "the page about who the business is, with nothing to do on it, only things to read. `question` asks it as a question (\"Who are we?\"). Under `presents`, each entry heads one list shown as cards with a picture each - the people who work there, the work they have done - loosely, as a person would introduce them. `values` heads the business's own statements, which are its facts: head them, never add to them.".to_string();
|
||||
let purpose = if path == who_page_path(plan) {
|
||||
who_purpose.as_str()
|
||||
} else if path == "site.yaml" {
|
||||
"the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own."
|
||||
} else if path == "aggregates.yaml" {
|
||||
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation."
|
||||
} else 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. 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: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. `who`, when there is one, is who the business is: its heading asks or names that plainly ('Who we are'), each item under it heads one of the business's own statements (values, experience) or the list of its people, and those statements are the business's facts - head them, never add to them. 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."
|
||||
"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: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. `who`, when there is one, is a card that leads to the page about who the business is: its heading asks that plainly, and `button` goes there. 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/") {
|
||||
status_purpose.as_str()
|
||||
} else if plan.doors.iter().any(|d| door_path(d) == path) {
|
||||
@@ -2568,7 +2656,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
|
||||
sent_purpose.as_str()
|
||||
} else {
|
||||
"a page only one role inside the business can open, holding one form."
|
||||
inside_purpose.as_str()
|
||||
};
|
||||
let mut context: Vec<String> = plan
|
||||
.doors
|
||||
@@ -2689,6 +2777,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
// The words as used, so the next rebuild can start from them.
|
||||
std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?;
|
||||
println!("content repo written to {}", out.display());
|
||||
for need in untried_needs(&plan) {
|
||||
eprintln!("note: no persona tries need {need:?}; nobody will find out whether its page reads right until a person does");
|
||||
}
|
||||
|
||||
if let Some(lint) = value("--lint") {
|
||||
let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?;
|
||||
@@ -3132,6 +3223,12 @@ mod tests {
|
||||
assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_need_no_persona_tries_is_named() {
|
||||
let plan: Plan = serde_yaml::from_str("business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: ask\n who: a customer\n wants: an answer\n lands_in: asks\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: card\n who: one of us\n wants: to be seen\n enters_as: desk\n lands_in: cards\n handled_by: desk\n stages: [open, gone]\n done_when: [gone]\n max_fields: 1\n collects:\n - { name: name }\n - id: later\n who: someone\n wants: something\n paused: true\n lands_in: later\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 1\n collects:\n - { name: email, type: email }\npersonas:\n - { id: p, need: ask, text: I need an answer }\n").unwrap();
|
||||
assert_eq!(untried_needs(&plan), vec!["card"], "the inside need is untried; the paused one is not asked for");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_public_watcher_gets_a_read_only_page_without_contact_details() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: traffic\n who: someone who saw a hazard\n wants: at det tas opp\n lands_in: meldinger\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n watched_by: [board, public]\n max_fields: 2\n collects:\n - { name: where }\n - { name: what, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: name, optional: true, private: true }\n";
|
||||
|
||||
+10
-7
@@ -184,13 +184,16 @@ rest. Each is one line in the plan, and the builder does the wiring:
|
||||
- **Carry a text word for word.** `documents` puts bylaws or terms on
|
||||
a page of their own, one card per section, from a markdown file
|
||||
beside the plan. Nothing rewords them.
|
||||
- **Say who you are.** `values` are the business's own statements -
|
||||
what it stands on, the experience behind it - carried word for word
|
||||
into a "who we are" card on the front door. A need with `presents:
|
||||
true` and `watched_by: [public]` puts its records in that card, all
|
||||
of them, as cards: the people who work there, entered from inside
|
||||
(`enters_as`), each with a `picture` field that shows as their
|
||||
portrait.
|
||||
- **Say who you are.** A need with `presents: true` and
|
||||
`watched_by: [public]` (the people who work there, the work they
|
||||
have done, each with a `picture` field) and `values` (the business's
|
||||
own statements, carried word for word) make a page of their own,
|
||||
"who are we?", with nothing to do on it: one card per presented list
|
||||
and one for the values. The front door leads there.
|
||||
- **Give every need someone to play it.** A persona per need,
|
||||
including the ones a business fills in from the inside (a delivery,
|
||||
a card on the front page): a page nobody plays is first read by the
|
||||
person it is for. `portal build` names the needs no persona tries.
|
||||
- **Let people keep their own.** `one_per_person: true` on a need
|
||||
entered from inside gives each signed-in person one record there:
|
||||
their own, which the form opens with and which sending again
|
||||
|
||||
Reference in New Issue
Block a user