Pages move the way their addresses do; a record with a picture is a card; the trainer says who a business is
Test / test (pull_request) Successful in 1m13s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 19:32:49 +02:00
co-authored by Claude Opus 5.5
parent 748241709a
commit 309e30f36d
10 changed files with 1008 additions and 451 deletions
+89 -2
View File
@@ -57,6 +57,8 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
// Before the router, so it sees a link's click first.
<script src="/page-motion.js" defer=true></script>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
@@ -1949,12 +1951,16 @@ fn ResourceValue(
}
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
// Cards are shown in the order they were put up, the way a
// team or a gallery is read; a list of cases, newest first.
let cards = transitions.is_empty() && answers.iter().all(|a| picture_of(a, bucket.as_deref()).is_some());
answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE;
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
open(&a.state).cmp(&open(&b.state)).then(when)
});
return view! {
<div class="answer-list">
<div class="answer-list" class:answer-cards=cards>
<For
each=move || answers.clone()
key=|a| a.id.clone()
@@ -2232,6 +2238,23 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
/// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase();
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"].iter().any(|ext| lower.ends_with(ext))
}
/// A record that carries a picture, in a list nobody works from, is
/// shown rather than read: the picture, then what it says, first field
/// as its heading. A team, a gallery of what has been built.
fn picture_of(answer: &Answer, bucket: Option<&str>) -> Option<String> {
let bucket = bucket?;
answer.responses.as_object()?.iter().find_map(|(key, value)| {
let name = uploaded_filename(value.as_str()?)?;
is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id))
})
}
#[component]
fn AnswerRow(
feature_name: String,
@@ -2260,6 +2283,11 @@ fn AnswerRow(
} else {
available.is_empty()
};
if transitions.is_empty() {
if let Some(picture) = picture_of(&answer, bucket.as_deref()) {
return AnswerCard(answer, picture, link).into_any();
}
}
let responses = answer
.responses
.as_object()
@@ -2310,6 +2338,17 @@ fn AnswerRow(
// A stored file shows as its name, and opens.
if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) {
let href = format!("{base}/{key}");
if is_picture(filename) {
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value" href=href.clone() rel="external">
<img class="answer-thumb" src=href.clone() alt=filename.to_string() loading="lazy" />
</a>
</div>
}
.into_any();
}
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
@@ -2412,6 +2451,36 @@ fn AnswerRow(
</Show>
</article>
}
.into_any()
}
/// A record shown as a card: its picture, its first field as the
/// heading and the others under it, and nothing a reader has no use
/// for - no field names, no state, no time.
#[allow(non_snake_case)]
fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl IntoView {
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
let texts: Vec<String> = answer
.responses
.as_object()
.into_iter()
.flatten()
.filter_map(|(_, v)| v.as_str())
.filter(|v| uploaded_filename(v).is_none() && !v.trim().is_empty())
.map(str::to_string)
.collect();
let heading = texts.first().cloned().unwrap_or_default();
let rest: Vec<String> = texts.into_iter().skip(1).collect();
view! {
<article class="answer-row answer-card">
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
<div class="answer-card-text">
<h4 class="answer-card-heading">{heading}</h4>
{rest.into_iter().map(|t| view! { <p>{t}</p> }).collect_view()}
</div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article>
}
}
/// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric-
@@ -3085,3 +3154,21 @@ mod responsible_tests {
assert!(!is_responsible(None, &board));
}
}
#[cfg(test)]
mod picture_tests {
use super::*;
#[test]
fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
let answer: Answer = serde_json::from_value(serde_json::json!({
"id": "r1", "question_id": "kontor/ansatt", "alternative": "a", "state": "open", "submitted_ms": 1,
"responses": { "name": "Kari", "portrett": "0f8fad5b-d9cb-469f-a165-70867728950e-kari.jpg", "cv": "0f8fad5b-d9cb-469f-a165-70867728950e-cv.pdf" }
}))
.unwrap();
assert_eq!(picture_of(&answer, Some("ansatte")).as_deref(), Some("/attachment/ansatte/r1/portrett"));
// Without a bucket there is nowhere to fetch it from.
assert_eq!(picture_of(&answer, None), None);
}
}
+11
View File
@@ -231,6 +231,7 @@ async fn serve() -> anyhow::Result<()> {
let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root);
let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
let app = Router::new()
.route("/auth/login", get(oidc::login))
@@ -284,6 +285,16 @@ async fn serve() -> anyhow::Result<()> {
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
// Same name every release, so revalidated like /pkg.
.route_service(
"/page-motion.js",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new(page_motion)),
)
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
.leptos_routes_with_context(
+65 -10
View File
@@ -95,6 +95,13 @@ pub struct Plan {
/// from its `wants`.
#[serde(default)]
pub does: Vec<Does>,
/// What the business stands on, in its own words: values, the
/// experience behind it. Each is a feature of the front door's
/// "who we are" card, beside the needs that `present` - the people
/// who work there. Nothing rewords the facts; the words model only
/// heads them.
#[serde(default)]
pub values: 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
@@ -206,7 +213,7 @@ pub fn sections_of(markdown: &str) -> Vec<Section> {
let mut heading: Option<String> = None;
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
let mut close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
let close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
if let Some(h) = heading.take() {
if !paragraphs.is_empty() {
out.push(Section { heading: h, text: std::mem::take(paragraphs) });
@@ -411,6 +418,13 @@ pub struct PlanNeed {
/// taken down; a register shows what was decided.
#[serde(default)]
pub public_states: Vec<String>,
/// Its records are something the business shows, not cases it
/// works: the people who work there, what it has built. With
/// `watched_by: [public]`, the front door shows every one of them
/// in the "who we are" card, and a `picture` field shows as the
/// picture.
#[serde(default)]
pub presents: bool,
pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
@@ -488,7 +502,7 @@ impl Plan {
}
}
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file"];
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file", "picture"];
fn is_id(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
@@ -629,8 +643,11 @@ impl Plan {
// small application and the firm's large one, and is one
// page either way. A bucket only a role ever writes to has
// its group's desk and nothing to publish.
// Unless it presents: the people are entered from inside
// precisely so that everyone can see them.
if need.watched_by.iter().any(|g| g == PUBLIC)
&& need.enters_as.is_some()
&& !need.presents
&& !self
.needs
.iter()
@@ -731,6 +748,9 @@ impl Plan {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
}
}
if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by"));
}
if !need.public_states.is_empty() {
if !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by"));
@@ -1113,7 +1133,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
};
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) {
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
shopping.insert(
format!("status-{bucket}"),
json!({
@@ -1131,6 +1151,15 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"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>>(),
},
// 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
@@ -1589,9 +1618,13 @@ const FRONT_DOOR_LATEST: usize = 3;
/// and at most `limit` records when given. Portal filters by `states`
/// while listing, before `jq` runs, so the limit counts shown records.
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>, answer: &str) -> Value {
let mut shown: BTreeSet<&str> = BTreeSet::new();
for n in needs {
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
// In the order the form asks for them: the first is what a row, or
// a card, is headed by.
let mut shown: Vec<&str> = Vec::new();
for f in needs.iter().flat_map(|n| n.collects.iter()).filter(|f| f.is_public()) {
if !shown.contains(&f.name.as_str()) {
shown.push(f.name.as_str());
}
}
let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default();
// One record per yielded value: portal collects what the filter
@@ -1864,6 +1897,12 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
let parent = plan.parent_of(need);
let mut requirements: Vec<Value> = need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind });
// A picture is a file the browser can show: a phone offers its
// camera roll and sends a JPEG, whatever it keeps them as.
if f.kind == "picture" {
r["type"] = json!("file");
r["accept"] = json!("image/jpeg,image/png,image/webp");
}
if f.optional { r["optional"] = json!(true); }
if f.kind == "select" { r["options"] = json!(select_options(plan, f)); }
if f.multiple { r["multiple"] = json!(true); }
@@ -2024,7 +2063,23 @@ 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.
for (bucket, needs) in public_buckets(plan) {
// 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() {
alternatives.push(json!({
"name": text(&front["who"]["heading"]),
"description": text(&front["who"]["description"]),
"features": who,
}));
}
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
let mut f = card(w);
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
@@ -2448,7 +2503,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
} 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. 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 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."
} else if path.starts_with("questions/status/") {
status_purpose.as_str()
} else if plan.doors.iter().any(|d| door_path(d) == path) {
@@ -2627,7 +2682,7 @@ mod tests {
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false,
collects: vec![], max_fields: 3,
}
}
@@ -3037,7 +3092,7 @@ mod tests {
let resource = &view["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["public"], true);
assert!(resource.get("requires_group").is_none());
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {what: .what, where: .where}");
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {where: .where, what: .what}");
assert!(view.get("qualifies").is_none());
// The followup page points people at the list.
let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap();
+7
View File
@@ -184,6 +184,13 @@ 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.
- **Show the work.** `slideshow` on the plan is portal's own block,
carried onto the front door as written: a strip of pictures under
the question, each with its caption.