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..8155fea 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! {
+
+
+
+ {(!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;