From 8376c68f8fa0a9d60c766f7ff4899381967e543d Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 1 Oct 2026 09:33:27 +0200 Subject: [PATCH 1/4] wordmark_ink: an SVG wordmark drawn in the page's own ink, following light and dark everywhere Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 4 ++++ docs/content-reference.md | 1 + src/app.rs | 27 +++++++++++++++++++++++---- src/content/mod.rs | 8 ++++++++ style/main.css | 14 ++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95523ea..9ac94fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.6.11 (2026-10-01) + +- `wordmark_ink: true` in `site.yaml`: an SVG wordmark is drawn in the page's own ink, as a mask over the text colour, so it follows the page's light and dark in every browser. A wordmark shown as an `` only knows the system's setting, and some browsers don't pass even that on. Opacity in the file still shows, so a lighter line stays lighter. Opt-in, for one-colour marks; a mark with a colour of its own keeps its colours. + ## 0.6.10 (2026-09-30) - Trainer: who a business is gets a page of its own ("who are we?", `/hvem` in Norwegian), with nothing to do on it. It has one card per need that `presents` (the people, the work, each shown as picture cards) and one for the plan's `values`. The front door leads there instead of carrying it all. diff --git a/docs/content-reference.md b/docs/content-reference.md index e232167..27aeee6 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -24,6 +24,7 @@ on every hot reload, and in CI through `iris check`. ```yaml title: Vestmark kommune # the browser title and the header wordmark: /site/logo.svg # an image instead of the title +wordmark_ink: true # a one-colour SVG mark drawn in the page's own ink wordmark_invert: true # flip it in dark mode wordmark_height: 2.4 # rem lang: no # the interface language: en or no diff --git a/src/app.rs b/src/app.rs index ed3e9af..86e33c4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -973,10 +973,29 @@ fn Hero( } }); let has_slides = slideshow.is_some(); - let mark = move || view! { - - site_title.clone() - + // In the page's ink (`wordmark_ink`): a span the file masks, sized + // by the file's own proportions through an invisible copy of it, + // and named for screen readers as the image would have been. + let ink = site.wordmark_ink; + let mark = move || { + if ink { + let mask = format!("--wordmark-src:url(\"{}\")", wordmark.replace('"', "%22")); + view! { + + + + + + } + .into_any() + } else { + view! { + + site_title.clone() + + } + .into_any() + } }; view! { diff --git a/src/content/mod.rs b/src/content/mod.rs index 188951e..000622a 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -767,6 +767,14 @@ pub struct SiteConfig { /// intrinsic pixel height. #[serde(default)] pub wordmark_height: Option, + /// Draw an SVG wordmark in the page's own ink instead of its own + /// colours: the file becomes a mask over the text colour, so it + /// follows the page's light and dark (and any theme a site adds), + /// where an `` only ever knows the system's. Opacity in the + /// file still shows, so a lighter line stays lighter. For a + /// one-colour mark; a mark with a colour of its own keeps `false`. + #[serde(default)] + pub wordmark_ink: bool, /// BCP 47-ish language code for the portal's own chrome strings /// ("Asked by", the nav lead...) and the html lang attribute. /// `None` means "en"; unknown codes fall back to English per key. diff --git a/style/main.css b/style/main.css index eda4044..71c74a6 100644 --- a/style/main.css +++ b/style/main.css @@ -244,6 +244,20 @@ main.not-found { display: block; } +/* A wordmark in the page's ink (site.yaml `wordmark_ink`): the file is a + mask over the text colour. The invisible image inside gives the span + the file's own size. */ +.wordmark-ink-mark { + display: block; + background: var(--ink); + -webkit-mask: var(--wordmark-src) center / contain no-repeat; + mask: var(--wordmark-src) center / contain no-repeat; +} + +.wordmark-ink-mark img { + visibility: hidden; +} + .hero-copy h1 { font-size: 2.1rem; } From 1929d2f44e5e5d16b943e77dec62ba137a8690bf Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 1 Oct 2026 09:33:31 +0200 Subject: [PATCH 2/4] Release 0.6.11 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6365796..4282df8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.6.10" +version = "0.6.11" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index ddaab50..7bc2cc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "portal" -version = "0.6.10" +version = "0.6.11" edition = "2021" [lib] From 8f24e5483cdd084ee4772be86fa1ebd9e3d1dc30 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 1 Oct 2026 10:41:27 +0200 Subject: [PATCH 3/4] Trainer: tell the words model who opens each mail Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 4 ++++ src/trainer/build.rs | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac94fe..b8d582d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.6.12 (2026-10-01) + +- Trainer: the words model is told who opens each mail: the person who sent the case, the person who posted the record an answer is about, or someone at the business. Mails to the business about another person's case used to be written as if to that person ("you will hear from us"); on Gullberg, three of nine were. + ## 0.6.11 (2026-10-01) - `wordmark_ink: true` in `site.yaml`: an SVG wordmark is drawn in the page's own ink, as a mask over the text colour, so it follows the page's light and dark in every browser. A wordmark shown as an `` only knows the system's setting, and some browsers don't pass even that on. Opacity in the file still shows, so a lighter line stays lighter. Opt-in, for one-colour marks; a mark with a colour of its own keeps its colours. diff --git a/src/trainer/build.rs b/src/trainer/build.rs index e9c842b..2ee2cc7 100644 --- a/src/trainer/build.rs +++ b/src/trainer/build.rs @@ -2524,6 +2524,8 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) - 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(), }; + // Who reads each mail. A mail to the business about someone else's + // case was written as if to the person who sent it ("you will hear + // from us") until the model was told who opens it. + let recipients: Vec = plan + .needs + .iter() + .flat_map(|n| { + n.mails.iter().map(move |(stage, to)| { + let reader = match to.as_str() { + "submitter" => "the person who sent it".to_string(), + "owner" => "the person who posted the record this answers".to_string(), + address => format!("someone at the business ({address}), about another person's case; write to them, not to the person who sent it"), + }; + format!("- `{}` entering `{stage}`: read by {reader}", n.lands_in) + }) + }) + .collect(); + let mails_purpose = format!( + "{MAILS_PURPOSE}\n\nWho opens each mail:\n{}", + recipients.join("\n") + ); 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." + mails_purpose.as_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" { @@ -3223,6 +3247,14 @@ mod tests { assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select")); } + #[test] + fn the_words_model_is_told_who_opens_each_mail() { + let plan: Plan = serde_yaml::from_str("business: Vel\ncontact: { name: S, email: s@v.example }\nmail: { from: v@v.example }\nneeds:\n - id: notice\n who: en nabo\n wants: et oppslag\n lands_in: oppslag\n handled_by: redaktor\n stages: [open, tatt_ned]\n done_when: [tatt_ned]\n mails: { open: s@v.example }\n watched_by: [public]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: answer\n who: en nabo\n wants: svare\n about: notice\n lands_in: svar\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n mails: { open: owner }\n max_fields: 1\n collects:\n - { name: email, type: email }\n").unwrap(); + let prompt = words_prompt(&plan, "aggregates.yaml", &json!({}), None); + assert!(prompt.contains("`oppslag` entering `open`: read by someone at the business (s@v.example)"), "{prompt}"); + assert!(prompt.contains("`svar` entering `open`: read by the person who posted the record this answers")); + } + #[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(); From cc07cd321d9492672bc7d87ba40719a0c3346b83 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 1 Oct 2026 10:41:31 +0200 Subject: [PATCH 4/4] Release 0.6.12 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4282df8..938eb5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.6.11" +version = "0.6.12" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 7bc2cc6..e9e4c6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "portal" -version = "0.6.11" +version = "0.6.12" edition = "2021" [lib]