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.
|
- 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".
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user