Files
portal/src/trainer/card.rs
T
Bendik Aagaard LynghaugandClaude Fable 5.1 96b3746643
Test / test (pull_request) Successful in 1m7s
One binary: the lint and the trainer are commands of portal
portal serve runs a site; portal lint is what iris was; portal plan,
build, report and the rest are what portal-trainer was. One source at
one version, so the three never disagree about what content means.
Both histories are merged in (subtree). The release attaches the bare
binary for a content repo's CI. The trainer's plans, intakes, engines
and reference prompt come along; its runs stay in the old repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:29:30 +02:00

102 lines
4.0 KiB
Rust

//! 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");
}
}