From cfb050d3eff966ecce31d55dacff780c6d52bbda Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 17:16:50 +0200 Subject: [PATCH 1/2] A slideshow across a page: pictures the full width of the screen, captions over them slideshow on a page: max_height in svh, slides with an image (a path in the content repo or https), a caption laid over the picture's foot, and optionally the picture's size so its place is held before it loads. Swiper upgrades it; before that it is a row that scrolls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 2 ++ docs/content-reference.md | 20 +++++++++++++ src/app.rs | 56 +++++++++++++++++++++++++++++++++- src/content/handlers.rs | 7 ++++- src/content/mod.rs | 61 +++++++++++++++++++++++++++++++++++++ src/content/validate.rs | 19 ++++++++++++ style/main.css | 63 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 226 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f17c9..f367257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ content repos pin the tag they run (see the end of this file). - 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. +- `slideshow` on a page: a strip of pictures the full width of the screen under the question, `max_height` in svh, each picture with its caption laid over it. Swiper when the bundle has loaded, a sideways-scrolling row before. `/site/` serves `jpeg`, and lets a browser keep photographs for an hour. + ## 0.6.0 (2026-09-30) - One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of `portal`: `portal serve` (the default), `portal lint`, and `portal plan`, `build`, `answer`, `rate`, `prefer`, `score`, `simulate`, `compare`, `report`, `generate`. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bare `portal` binary beside the tarball, so a content repo's CI downloads it and runs `portal lint`. No more iris releases; the pins that exist keep working. The trainer's `plans/`, `intakes/`, `engines/`, `sectors/` and its reference prompt live here now; its `runs/` stay in uhhm/portal-trainer. Docs gained "Training a site" and "The lint". diff --git a/docs/content-reference.md b/docs/content-reference.md index 05d7333..5540bec 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -76,6 +76,26 @@ nearest `_section.yaml`. `event` makes a page an occasion with a time; opening any `type: location` field on the page there rather than on the whole world. +### Pictures across the page + +`slideshow` lays a strip of pictures under the question, the full +width of the screen: every picture as tall as the strip and as wide as +its own shape, swiped or arrowed through, its caption over its foot. + +```yaml +slideshow: + max_height: 60 # svh; 60 unless set, between 20 and 100 + slides: + - image: images/built/01.webp # a path in the content repo, or https:// + caption: The canteen at the theatre, renewed + width: 3207 # the picture's own size, optional: + height: 1400 # holds its place before it has loaded +``` + +Pictures in the content repo are served from this origin under +`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an +hour. Keep them web-sized: around 1400 pixels tall is plenty. + ## An alternative One choice. Never two actions behind one heading: if two things can be diff --git a/src/app.rs b/src/app.rs index 5699d77..c0f9a85 100644 --- a/src/app.rs +++ b/src/app.rs @@ -369,7 +369,7 @@ fn QuestionView( // Swiper Element only registers its custom elements when its // (vendored, ~180KB) script runs - loaded once per page, and only // on pages where some alternative actually declares a deck. - let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1); + let needs_swiper = question.slideshow.is_some() || question.alternatives.iter().any(|a| a.images.len() > 1); // MapLibre is vendored and large, so it loads only where a field // actually asks for a place, and only where this instance can // serve tiles at all. @@ -397,6 +397,60 @@ fn QuestionView( site=site.clone() current_id=question_id.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. + + + >() + 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! { + +
+ slide.caption.clone() + {(!caption.trim().is_empty()).then(|| view! {
{caption}
})} +
+
+ } + .into_any() + } + /> +
+ } + })} // 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. diff --git a/src/content/handlers.rs b/src/content/handlers.rs index 569a20f..732d483 100644 --- a/src/content/handlers.rs +++ b/src/content/handlers.rs @@ -74,14 +74,19 @@ pub async fn site_asset_handler( Some("svg") => "image/svg+xml", Some("json") => "application/json", Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", Some("webp") => "image/webp", Some("avif") => "image/avif", Some("ico") => "image/x-icon", Some("woff2") => "font/woff2", _ => "application/octet-stream", }; + // Photographs are large and do not change under a name, so a + // browser may keep them for an hour; everything else revalidates, + // so a content push shows on reload. + let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" }; ( - [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")], + [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)], bytes, ) .into_response() diff --git a/src/content/mod.rs b/src/content/mod.rs index d9f619b..8d53346 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -95,6 +95,12 @@ pub struct Question { /// rather than on the whole world. #[serde(default)] pub place: Option, + /// Pictures across the page, under the question: a strip the full + /// width of the screen that a visitor swipes through, each picture + /// with its caption laid over it. What the business has built, + /// shown rather than described. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slideshow: Option, } /// A point a page is about. `zoom` is a map zoom level, 0 (the globe) @@ -625,6 +631,50 @@ pub struct Requirement { /// listing may ask about a record's own contents: one field, one /// value, compared as strings. Anything more is a query language, and /// a jq filter is already there for shaping what comes back. +/// A strip of pictures across a page (`Question.slideshow`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Slideshow { + /// How tall the strip may be, in hundredths of the small viewport + /// height (`svh`): 60 is six tenths of the screen. Every picture is + /// that tall and as wide as its own shape makes it. + #[serde(default = "default_slideshow_height")] + pub max_height: u8, + pub slides: Vec, +} + +fn default_slideshow_height() -> u8 { + 60 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Slide { + /// A path in the content repo (`images/built/01.webp`, served from + /// this origin under `/site/`) or an `https://` address. + pub image: String, + /// Laid over the foot of the picture, and its alternative text. + #[serde(default)] + pub caption: String, + /// The picture's own size in pixels, when known: the browser then + /// holds its place in the strip before it has loaded, so pictures + /// further along can wait until they are swiped to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, +} + +impl Slide { + /// Where the browser fetches the picture: https as given, anything + /// else as a content-repo path under `/site/`. + pub fn src(&self) -> String { + if self.image.starts_with("https://") || self.image.starts_with("/site/") { + self.image.clone() + } else { + format!("/site/{}", self.image.trim_start_matches('/')) + } + } +} + /// A link from every listed record to a page about it (`ResourceSpec.link`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RowLink { @@ -1904,6 +1954,17 @@ alternatives: assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None)); } + #[test] + fn a_slide_is_fetched_from_this_origin_unless_it_is_https() { + let q: Question = serde_yaml::from_str("name: Q?\nslideshow:\n slides:\n - { image: images/built/01.webp, caption: The canteen, width: 3207, height: 1400 }\n - { image: \"https://cdn.example/a.jpg\" }\n").unwrap(); + let show = q.slideshow.unwrap(); + assert_eq!(show.max_height, 60); + assert_eq!(show.slides[0].src(), "/site/images/built/01.webp"); + assert_eq!(show.slides[0].width, Some(3207)); + assert_eq!(show.slides[1].src(), "https://cdn.example/a.jpg"); + assert_eq!(show.slides[1].caption, ""); + } + #[test] fn markdown_drops_html_and_unsafe_links() { assert_eq!(render_inline_markdown("x z"), "x y z"); diff --git a/src/content/validate.rs b/src/content/validate.rs index 83f0732..35f8f16 100644 --- a/src/content/validate.rs +++ b/src/content/validate.rs @@ -146,6 +146,25 @@ pub fn validate_questions( } } + // A slideshow shows something, at a height that leaves the page + // its question: between a fifth and the whole of the screen. + for question in questions.values() { + if let Some(show) = &question.slideshow { + if show.slides.is_empty() { + anyhow::bail!("question {:?}: slideshow has no slides", question.id); + } + if !(20..=100).contains(&show.max_height) { + anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height); + } + for slide in &show.slides { + let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/')); + if !ok { + anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image); + } + } + } + } + // A mail to the record this one belongs to goes through a field of // the form: `{notice.email}` needs a `type: record` field called // `notice` on every form that records into the bucket, or the mail diff --git a/style/main.css b/style/main.css index 7597ffb..61dd9f4 100644 --- a/style/main.css +++ b/style/main.css @@ -269,6 +269,69 @@ main.not-found { /* position + z-index on these three: everything that scrolls over the sticky canvas must be a positioned box above the hero's z-index 0, or the (positioned) hero would paint over it. */ +/* Pictures across the page (`slideshow`): the full width of the + screen, every picture the strip's height and as wide as its own + shape, never wider than the screen. The caption floats over the + foot of its picture. */ +.slideshow { + --swiper-navigation-color: #fff; + --swiper-navigation-size: 1.8rem; + position: relative; + z-index: 1; + display: block; + width: 100%; + height: var(--slideshow-height, 60svh); + max-height: var(--slideshow-height, 60svh); + margin: 0 0 3rem; +} + +/* Before the Swiper bundle has upgraded the element, or without it: + a row that scrolls sideways. */ +.slideshow:not(:defined) { + display: flex; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; +} + +.slideshow swiper-slide { + flex: none; + width: auto; + height: 100%; +} + +.slide { + position: relative; + height: 100%; + margin: 0; +} + +.slide img { + display: block; + height: 100%; + width: auto; + /* A picture that has not loaded and says nothing of its size still + takes a place, or the whole strip would sit in view at once. */ + min-width: 12rem; + max-width: 100vw; + object-fit: cover; + background: var(--line); +} + +.slide figcaption { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 2.4rem 1rem 0.9rem; + color: #fff; + font-size: 0.95rem; + line-height: 1.35; + text-shadow: 0 0.06rem 0.2rem rgba(0, 0, 0, 0.6); + background: linear-gradient(to top, rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0)); + pointer-events: none; +} + .alternatives { position: relative; z-index: 1; -- 2.54.0 From c1a8433640eb0dce1f90796dc9074f8931edb23b Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 17:17:55 +0200 Subject: [PATCH 2/2] Slideshow: brace the closure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index c0f9a85..8155fea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -423,7 +423,7 @@ fn QuestionView( navigation="true" > >() + each={move || show.slides.clone().into_iter().enumerate().collect::>()} key=|(_, slide)| slide.image.clone() children=|(n, slide): (usize, crate::content::Slide)| { let caption = slide.caption.clone(); -- 2.54.0