plan: intake to plan by a model; doors, labelled groups, the whole card
Work from another session, committed as found (tests green): - `plan` reads an intake into a plan.yaml (a model decides needs, stages, forms and budgets; a person reads the result before build). - Plans gain `doors` (a first question before the forms, when a front door would carry more than five needs), `groups` with labels that a select may draw its options from (`options_from: groups`), `private` fields kept off public pages, `multiple` selects, and `max_fields` as what the intake said the person will tolerate. - `card.rs`: an alternative as the visitor meets it, in text, so the answering visitor and `rate` read the served card, not the heading. - `rate` reads followup pages from the repo and reports "what happens next" as not measured rather than zero when it cannot. 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
baf36319b7
commit
255aeb4f4b
+101
@@ -0,0 +1,101 @@
|
||||
//! An alternative as the visitor meets it, in text.
|
||||
//!
|
||||
//! Portal renders a card: the heading, the description, a button, one
|
||||
//! or two encouragements, and for each section an icon, a title, a
|
||||
//! line of guidance and the fields with their labels and placeholders.
|
||||
//! A visitor is oriented by all of it, not the heading alone. The
|
||||
//! answering visitor and `rate` both read this rendering, so the
|
||||
//! scored page is the served page; the gutted twin keeps the card's
|
||||
//! shape and none of its words.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One option on a page. `full` is the card as rendered; otherwise the
|
||||
/// heading alone, which is what a visitor who reads nothing else sees.
|
||||
pub fn alternative(alt: &Value, full: bool) -> String {
|
||||
let mut out = format!("- {}", s(&alt["name"]));
|
||||
if !full {
|
||||
out.push('\n');
|
||||
return out;
|
||||
}
|
||||
let d = s(&alt["description"]).trim();
|
||||
if !d.is_empty() {
|
||||
out.push_str(&format!(": {d}"));
|
||||
}
|
||||
out.push('\n');
|
||||
if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) {
|
||||
out.push_str(&format!(" Button: {button}\n"));
|
||||
}
|
||||
for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
out.push_str(&format!(" {e}\n"));
|
||||
}
|
||||
for f in alt["features"].as_array().into_iter().flatten() {
|
||||
let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim());
|
||||
let mut line = String::from(" ");
|
||||
if !icon.is_empty() {
|
||||
line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon)));
|
||||
}
|
||||
if !name.is_empty() {
|
||||
line.push_str(name);
|
||||
}
|
||||
if !desc.is_empty() {
|
||||
line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " }));
|
||||
}
|
||||
if !line.trim().is_empty() {
|
||||
out.push_str(line.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
let fields: Vec<String> = f["requirements"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|r| {
|
||||
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
|
||||
let hint = s(&r["placeholder"]).trim();
|
||||
let opt = if r["optional"] == true { " (optional)" } else { "" };
|
||||
if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") }
|
||||
})
|
||||
.collect();
|
||||
if !fields.is_empty() {
|
||||
out.push_str(&format!(" Asks for: {}\n", fields.join("; ")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The page around its options: what it asks and says of itself.
|
||||
pub fn page_head(page: &Value) -> String {
|
||||
let mut out = format!("{}\n", s(&page["name"]));
|
||||
let d = s(&page["description"]).trim();
|
||||
if !d.is_empty() {
|
||||
out.push_str(&format!("{d}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_card_reads_as_the_visitor_sees_it_and_a_heading_alone_is_a_heading_alone() {
|
||||
let alt = json!({
|
||||
"name": "Get a price", "description": "For owners.",
|
||||
"consequence": ["Send it"], "encouragements": ["We answer within two days."],
|
||||
"features": [{"name": "Your brief", "icon": "lucide:file-text", "description": "A few lines are enough.",
|
||||
"requirements": [{"name": "email", "label": "Email", "placeholder": "you@example.no"},
|
||||
{"name": "address", "label": "Address", "optional": true}]}]
|
||||
});
|
||||
let full = alternative(&alt, true);
|
||||
assert!(full.starts_with("- Get a price: For owners.\n"));
|
||||
assert!(full.contains("Button: Send it") && full.contains("We answer within two days."));
|
||||
assert!(full.contains("[icon file-text] Your brief: A few lines are enough."));
|
||||
assert!(full.contains("Asks for: Email e.g. you@example.no; Address (optional)"));
|
||||
assert_eq!(alternative(&alt, false), "- Get a price\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user