The header knows who is here; my cases; controls that scale with the text
Test / test (pull_request) Successful in 1m12s

A session link top right on a site with sign-in, and on a page with
pictures the wordmark as its own bar over the strip. A list may be
narrowed to the signed-in person's own records ({user.email}, from the
session), and an attachment follows the list that shows its record.
Form controls are sized in em: the date picker's icon and the file
picker's button scale and take the field's colours.

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 17:32:41 +02:00
co-authored by Claude Opus 5.5
parent 9a96386444
commit 6b72ae568a
9 changed files with 318 additions and 85 deletions
+7
View File
@@ -4,6 +4,13 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## Unreleased
- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after.
- "My cases": `where: { field: email, equals: "{user.email}" }` lists the records that carry the signed-in person's own address, filled from the session and never from the caller; nobody signed in matches nothing. An attachment is fetched only through a list that shows its record, so a list of my cases opens my files and nobody else's.
- Form controls scale with the text around them (em, not rem): fields, pills, buttons and labels follow a site's body size. The date picker's calendar icon is the size of the text beside it, and the file picker's button takes the field's colours instead of the browser's.
- Fixed: `maps` never reached the browser (`serde(skip)`), so a page with a map drew it on the server and dropped it on hydration.
## 0.6.1 (2026-09-30)
- Trainer: `documents` in a plan. A text the site carries word for word (bylaws, terms) becomes a page of its own where every section is one alternative: its heading the card's, its paragraphs the card's words, from a markdown file beside the plan. No model touches it.
+4
View File
@@ -263,6 +263,10 @@ A spec with neither `public: true` nor `requires_group` is
unreachable: reads fail closed, and mutations always need a group
whatever `public` says.
`where: { field: email, equals: "{user.email}" }` is "my cases": the
records carrying the signed-in person's own address, read from the
session. A file on such a record is theirs alone to open.
`states` and `where` narrow a listing. They matter most for a register
that is public by law — public about what was decided, not about what
was withdrawn — while the desk working the same bucket is a different
+100 -59
View File
@@ -396,61 +396,9 @@ fn QuestionView(
landing=question_id == "/"
site=site.clone()
current_id=question_id.clone()
user=user.clone()
slideshow=question.slideshow.clone()
/>
// Pictures across the page: the full width of the screen, each
// as wide as its own shape at the strip's height, the caption
// laid over its foot. Inert until the Swiper bundle upgrades
// the elements; without it, a row that scrolls sideways.
{question
.slideshow
.clone()
.map(|show| {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
})}
// A page that is about somewhere says where, under the
// question: a still from this origin, and the place in words
// for anyone who cannot see it.
@@ -895,7 +843,15 @@ mod gesture {
}
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
fn Hero(
title: String,
description: String,
landing: bool,
site: SiteConfig,
current_id: String,
#[prop(optional_no_strip)] user: Option<User>,
#[prop(optional_no_strip)] slideshow: Option<crate::content::Slideshow>,
) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
@@ -994,9 +950,37 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
)
});
// Who is here, and the way in or out, at the top of every page of
// a site that has sign-in at all.
let session = site.sign_in.then(|| {
let redirect = current_id.clone();
match user {
Some(user) => view! {
<nav class="session">
<span class="session-name">{user.display_name}</span>
<a href="/auth/logout" rel="external">{move || t(&lang().get(), "sign_out")}</a>
</nav>
}
.into_any(),
None => view! {
<nav class="session">
<a href=format!("/auth/login?redirect={redirect}") rel="external">{move || t(&lang().get(), "sign_in")}</a>
</nav>
}
.into_any(),
}
});
let has_slides = slideshow.is_some();
let mark = move || view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
};
view! {
<header class="hero" class:hero-module=has_module>
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
<Announcements current_id=current_id/>
{session}
{has_module
.then(|| {
view! {
@@ -1004,10 +988,13 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
<script type="module" inner_html=early_script.unwrap_or_default()></script>
}
})}
// With pictures, the wordmark is a bar of its own above
// them and the question comes under; without, the wordmark
// heads the copy as it always has.
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
<div class="hero-copy">
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
{(!has_slides).then(|| mark())}
<h1>{title}</h1>
<p inner_html=render_inline_markdown(&description)></p>
</div>
@@ -1015,6 +1002,59 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
}
}
/// Pictures across the page (`slideshow`): the full width of the
/// screen, each picture as wide as its own shape at the strip's height,
/// the caption laid over its foot. Inert until the Swiper bundle
/// upgrades the elements; without it, a row that scrolls sideways.
#[component]
fn SlideStrip(show: crate::content::Slideshow) -> impl IntoView {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
}
/// An alternative's banner imagery: nothing, a plain image, or - for
/// several urls - a Swiper Element card deck. The `swiper-container`/
/// `swiper-slide` custom elements render inert server-side and upgrade
@@ -2461,6 +2501,7 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
// own configuration, and the browser has to know before it decides
// whether to draw a map or ask for two numbers.
site.maps = crate::maps::enabled();
site.sign_in = state.oidc.enabled();
Ok(site)
}
+6 -1
View File
@@ -779,8 +779,13 @@ pub struct SiteConfig {
/// the way to the browser (see `get_site`), never from content, so
/// a `type: location` field knows whether to offer a map or two
/// number inputs.
#[serde(skip)]
#[serde(default)]
pub maps: bool,
/// Whether this instance has sign-in at all (a provider is
/// configured): set on the way to the browser like `maps`, so the
/// header knows whether to offer it.
#[serde(default)]
pub sign_in: bool,
/// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`).
+3
View File
@@ -22,6 +22,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
@@ -51,6 +52,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
@@ -98,6 +100,7 @@ mod tests {
"if_stuck",
"send",
"sign_in",
"sign_out",
"nothing_here_yet",
"nothing_here",
"back_to_start",
+59 -8
View File
@@ -39,7 +39,7 @@ pub async fn get_resource(
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.resource`-backed counterpart to `get_resource` -
@@ -83,7 +83,7 @@ pub async fn get_requirement_options(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.bind`-backed counterpart to
@@ -114,7 +114,7 @@ pub async fn get_requirement_binding(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
fetch_resource_value(&state, &bind.resource, &params).await
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
}
/// Scoped to the named alternative first, not flattened across all of
@@ -211,8 +211,12 @@ fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Valu
/// stamped on it. A missing field never matches: "everything that did
/// not say" is not something a page should be able to ask for by
/// accident.
///
/// `equals: "{user.email}"` is the signed-in person's own address: a
/// list of "my cases". It is filled from the session, never from the
/// caller, and with nobody signed in it matches nothing.
#[cfg(feature = "ssr")]
fn matches_filter(
pub(crate) fn matches_filter(
resource: &crate::content::ResourceSpec,
value: &serde_json::Value,
params: &std::collections::HashMap<String, String>,
@@ -220,19 +224,54 @@ fn matches_filter(
let Some(filter) = &resource.filter else {
return true;
};
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params))
.unwrap_or_else(|| filter.equals.clone());
let equals = if filter.equals.contains("{user.email}") {
match params.get(USER_EMAIL).filter(|e| !e.is_empty()) {
Some(email) => filter.equals.replace("{user.email}", email),
None => return false,
}
} else {
filter.equals.clone()
};
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
// Addresses compare without case; everything else as written.
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
let found = value
.get("responses")
.and_then(|r| r.get(&filter.field))
.or_else(|| value.get(&filter.field));
match found {
Some(serde_json::Value::String(s)) => s == &wanted,
Some(other) => other.to_string() == wanted,
Some(serde_json::Value::String(s)) => same(s),
Some(other) => same(&other.to_string()),
None => false,
}
}
/// The parameter a `where` reads the signed-in person's address from.
/// Set by the server from the session; a caller's own is thrown away.
#[cfg(feature = "ssr")]
pub(crate) const USER_EMAIL: &str = "__user_email";
/// The caller's parameters with the session's person put in and
/// anything pretending to be them taken out.
#[cfg(feature = "ssr")]
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
params.retain(|k, _| !k.starts_with("__"));
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
}
params
}
#[cfg(feature = "ssr")]
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
use crate::auth::{User, SESSION_USER_KEY};
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
Err(_) => None,
};
with_person(params, user.as_ref())
}
#[cfg(feature = "ssr")]
async fn fetch_resource_value(
state: &crate::server::AppState,
@@ -559,6 +598,18 @@ mod tests {
assert!(!matches_filter(&children, &theirs, &params));
// A record that did not say is not everyone's child.
assert!(!matches_filter(&children, &neither, &params));
// "My cases": the session's address, never the caller's.
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
let asked = with_person(forged.clone(), Some(&kari));
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
// Nobody signed in: the forged parameter is dropped, and nothing matches.
let anonymous = with_person(forged, None);
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
// No filter, no narrowing: every resource written before this
// existed still lists its whole bucket.
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
+5
View File
@@ -50,6 +50,11 @@ const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Whether a provider is configured.
pub fn enabled(&self) -> bool {
self.inner.is_some()
}
/// Sign-in disabled: what a content-only instance has, and what a
/// test builds an `AppState` with.
pub fn disabled() -> Self {
+11 -6
View File
@@ -277,13 +277,15 @@ pub async fn attachment(
// cannot be used to learn which buckets, records or fields exist.
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
if !may_read_bucket(&state, &session, &bucket).await {
return Err(missing());
}
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?;
let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
// Through a list the caller may read, and one that shows this
// record: a list of "my cases" opens my files and nobody else's.
if !may_read_record(&state, &session, &bucket, &as_listed).await {
return Err(missing());
}
let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?;
let key = answer
.responses
.get(&field)
@@ -331,20 +333,23 @@ pub async fn attachment(
/// Whether this caller may see this bucket at all: whether any feature
/// in the loaded content lists it and the policy lets them read that
/// feature. The desk that shows the record is what grants the file.
async fn may_read_bucket(
async fn may_read_record(
state: &AppState,
session: &tower_sessions::Session,
bucket: &str,
record: &serde_json::Value,
) -> bool {
use crate::auth::{User, SESSION_USER_KEY};
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
let principal = crate::access::Principal::from_user(user.as_ref());
let params = crate::resource::with_person(Default::default(), user.as_ref());
let access = state.access.load();
state.questions.load().values().any(|question| {
question.alternatives.iter().any(|alternative| {
alternative.features.iter().any(|feature| {
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, &params))
&& access.allows(
&principal,
crate::access::Action::Read,
+123 -11
View File
@@ -173,6 +173,61 @@ main.not-found {
gap: 1rem;
}
/* Who is here and the way in or out: top right of every page, on a
site that has sign-in. */
.session {
position: absolute;
top: 1rem;
right: 1.5rem;
z-index: 5;
display: flex;
gap: 0.9rem;
align-items: baseline;
font-size: 0.88rem;
}
.session a {
color: var(--ink);
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
}
.session a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
.session-name {
color: var(--ink-dim);
}
/* A page with pictures: the wordmark is a bar of its own, the strip
runs the full width under it, and the question follows. */
.hero-slides {
max-width: none;
padding: 0 0 2rem;
align-items: stretch;
gap: 0;
}
.hero-bar {
display: flex;
align-items: center;
padding: 0.9rem 1.5rem;
}
.hero-slides .slideshow {
margin-bottom: 2rem;
}
.hero-slides .hero-copy {
width: 100%;
max-width: 46rem;
margin: 0 auto;
padding: 0 1.5rem;
box-sizing: border-box;
}
.wordmark {
display: inline-flex;
opacity: 0.85;
@@ -742,7 +797,7 @@ main.not-found {
.feature p {
color: var(--ink-dim);
font-size: 0.95rem;
font-size: 0.95em;
}
.field {
@@ -753,7 +808,7 @@ main.not-found {
}
.field-label {
font-size: 0.85rem;
font-size: 0.85em;
font-weight: 600;
color: var(--ink-dim);
}
@@ -763,17 +818,74 @@ main.not-found {
margin-left: 0.2em;
}
/* Controls take their size from the text around them (em, not rem):
a site that sets its body larger, or a card that sets itself
smaller, gets fields, pills and buttons to match, and everything
inside a control - the date picker's icon, the file picker's button
- scales with the control. */
input,
textarea {
font: inherit;
color: var(--ink);
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 0.6rem;
padding: 0.65rem 0.8rem;
border-radius: 0.6em;
padding: 0.65em 0.8em;
width: 100%;
}
/* The calendar icon at the size of the text it sits beside, and a
target worth aiming at. */
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
min-height: 2.9em;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
width: 1.35em;
height: 1.35em;
padding: 0.2em;
margin-left: 0.4em;
cursor: pointer;
opacity: 0.75;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover,
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* The file picker's own button, in the field's colours rather than
the browser's: it follows whatever ink the field has, so it holds
on a light field and a dark one alike. */
input[type="file"] {
padding: 0.45em;
cursor: pointer;
}
input[type="file"]::file-selector-button {
font: inherit;
font-size: 0.85em;
font-weight: 600;
color: inherit;
background: color-mix(in srgb, currentColor 9%, transparent);
border: 0.06rem solid color-mix(in srgb, currentColor 28%, transparent);
border-radius: 999rem;
padding: 0.4em 1em;
margin-right: 0.8em;
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
input[type="file"]::file-selector-button:hover {
background: var(--accent-soft);
border-color: var(--accent);
}
textarea {
min-height: 5rem;
resize: vertical;
@@ -1034,14 +1146,14 @@ textarea:focus {
}
.alt-submit {
font-family: var(--sans);
font-family: inherit;
font-weight: 600;
font-size: 0.95rem;
font-size: 0.95em;
color: var(--paper);
background: var(--accent);
border: none;
border-radius: 0.7rem;
padding: 0.75rem 1.4rem;
border-radius: 0.7em;
padding: 0.75em 1.4em;
cursor: pointer;
transition: filter 120ms, transform 120ms;
/* A gateway alternative renders this as an <a> (navigation, no POST);
@@ -1247,13 +1359,13 @@ textarea:focus {
}
.select-option {
font-family: var(--sans);
font-size: 0.85rem;
font-family: inherit;
font-size: 0.85em;
color: var(--ink);
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 999rem;
padding: 0.4rem 0.9rem;
padding: 0.45em 1em;
cursor: pointer;
transition: border-color 120ms, color 120ms, background 120ms;
}