A slideshow across a page: pictures the full width of the screen, captions over them
Test / test (pull_request) Failing after 1m5s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 17:16:50 +02:00
co-authored by Claude Opus 5.5
parent fc708c3546
commit cfb050d3ef
7 changed files with 226 additions and 2 deletions
+2
View File
@@ -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. - 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) ## 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". - 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".
+20
View File
@@ -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 opening any `type: location` field on the page there rather than on
the whole world. 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 ## An alternative
One choice. Never two actions behind one heading: if two things can be One choice. Never two actions behind one heading: if two things can be
+55 -1
View File
@@ -369,7 +369,7 @@ fn QuestionView(
// Swiper Element only registers its custom elements when its // Swiper Element only registers its custom elements when its
// (vendored, ~180KB) script runs - loaded once per page, and only // (vendored, ~180KB) script runs - loaded once per page, and only
// on pages where some alternative actually declares a deck. // 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 // MapLibre is vendored and large, so it loads only where a field
// actually asks for a place, and only where this instance can // actually asks for a place, and only where this instance can
// serve tiles at all. // serve tiles at all.
@@ -397,6 +397,60 @@ fn QuestionView(
site=site.clone() site=site.clone()
current_id=question_id.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.
<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 // A page that is about somewhere says where, under the
// question: a still from this origin, and the place in words // question: a still from this origin, and the place in words
// for anyone who cannot see it. // for anyone who cannot see it.
+6 -1
View File
@@ -74,14 +74,19 @@ pub async fn site_asset_handler(
Some("svg") => "image/svg+xml", Some("svg") => "image/svg+xml",
Some("json") => "application/json", Some("json") => "application/json",
Some("png") => "image/png", Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp", Some("webp") => "image/webp",
Some("avif") => "image/avif", Some("avif") => "image/avif",
Some("ico") => "image/x-icon", Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2", Some("woff2") => "font/woff2",
_ => "application/octet-stream", _ => "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, bytes,
) )
.into_response() .into_response()
+61
View File
@@ -95,6 +95,12 @@ pub struct Question {
/// rather than on the whole world. /// rather than on the whole world.
#[serde(default)] #[serde(default)]
pub place: Option<Place>, pub place: Option<Place>,
/// 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<Slideshow>,
} }
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe) /// 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 /// listing may ask about a record's own contents: one field, one
/// value, compared as strings. Anything more is a query language, and /// value, compared as strings. Anything more is a query language, and
/// a jq filter is already there for shaping what comes back. /// 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<Slide>,
}
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<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}
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`). /// A link from every listed record to a page about it (`ResourceSpec.link`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RowLink { pub struct RowLink {
@@ -1904,6 +1954,17 @@ alternatives:
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None)); 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] #[test]
fn markdown_drops_html_and_unsafe_links() { fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z"); assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
+19
View File
@@ -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 // A mail to the record this one belongs to goes through a field of
// the form: `{notice.email}` needs a `type: record` field called // the form: `{notice.email}` needs a `type: record` field called
// `notice` on every form that records into the bucket, or the mail // `notice` on every form that records into the bucket, or the mail
+63
View File
@@ -269,6 +269,69 @@ main.not-found {
/* position + z-index on these three: everything that scrolls over the /* 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, sticky canvas must be a positioned box above the hero's z-index 0,
or the (positioned) hero would paint over it. */ 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 { .alternatives {
position: relative; position: relative;
z-index: 1; z-index: 1;