A slideshow across a page #9
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
+55
-1
@@ -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.
|
||||
<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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -95,6 +95,12 @@ pub struct Question {
|
||||
/// rather than on the whole world.
|
||||
#[serde(default)]
|
||||
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)
|
||||
@@ -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<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`).
|
||||
#[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 <script>y</script> z"), "x y z");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user