Compare commits

..
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 738dbac7fe Test fixtures: every form has the action the lint now requires
Test / test (pull_request) Successful in 21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:46:24 +02:00
bl 98f93f5ad5 Merge pull request 'A form with no action is refused; portal iris' (#27) from form-needs-action into main
docs / deploy (push) Successful in 3s
Test / test (push) Failing after 1m7s
2026-10-01 19:42:03 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 4049809aa4 The lint refuses a form with no action, which would have no button; portal iris names the lint
Test / test (pull_request) Failing after 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:41:56 +02:00
Bendik Aagaard Lynghaug b43ff57eab Release 0.6.15
Test / test (push) Successful in 1m14s
Publish release / publish (push) Successful in 2m5s
2026-10-01 18:06:16 +02:00
bl ed235cd134 Merge pull request 'An invite may carry its own mail' (#26) from invite-mail into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m52s
2026-10-01 16:06:14 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 cc9f1c5d87 An invite may carry its own mail, with the form's fields and a spoken date and time
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 18:06:12 +02:00
bl 01abf79005 Merge pull request 'Klingenberg Bygg plan: Fredrik's caption corrections' (#25) from kb-fredrik-captions into main
Test / test (push) Successful in 1m9s
2026-10-01 15:57:10 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 506bb712fe Klingenberg Bygg plan: Fredrik's two caption corrections from the live site
Test / test (pull_request) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 17:57:08 +02:00
Bendik Aagaard Lynghaug 74fd2d1750 Release 0.6.14
Test / test (push) Successful in 1m11s
Publish release / publish (push) Successful in 1m57s
2026-10-01 17:47:51 +02:00
bl ae521c0fa2 Merge pull request 'Less room above the wordmark' (#24) from hero-top-space into main
docs / deploy (push) Successful in 11s
Test / test (push) Successful in 1m13s
2026-10-01 15:47:47 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d0ee06f48c Less room above the wordmark, now that who is here sits on its line
Test / test (pull_request) Successful in 1m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 17:47:45 +02:00
Bendik Aagaard Lynghaug 6a9be3bef4 Release 0.6.13
Test / test (push) Successful in 1m5s
Publish release / publish (push) Successful in 1m48s
2026-10-01 11:40:32 +02:00
bl 49be010e8e Merge pull request 'Who is here on the wordmark's line; signed-in people aren't asked for their name' (#23) from prefill-everywhere into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m5s
2026-10-01 09:40:29 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 78f42e6f8a Who is here on the wordmark's line; every form carries a signed-in person's name and address
Test / test (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 11:40:27 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9b50c9cab8 Gullberg: i Ås, ikke på Ås
Test / test (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 11:09:08 +02:00
bl 858a66d84e Merge pull request 'GULLBERG sanatorium: plan, words, wordmark; a board may publish its own list' (#20) from gullberg-plan into main
Test / test (push) Successful in 1m5s
2026-10-01 08:44:09 +00:00
18 changed files with 184 additions and 30 deletions
+18
View File
@@ -4,6 +4,24 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## 0.6.16 (2026-10-01)
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
- `portal iris` is the lint's name, after the iris that keeps bad content out of the portal. `portal lint` is the same command and keeps working.
## 0.6.15 (2026-10-01)
- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good.
## 0.6.14 (2026-10-01)
- Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space.
## 0.6.13 (2026-10-01)
- Who is here (the name and "sign out", or "sign in") sits on the wordmark's own line instead of being pinned to the corner of the page, where it read as a row of its own. With a picture strip it is in the wordmark's bar; without, it is level with the mark at the right edge. On a narrow screen it drops under the mark.
- Trainer: every form, public ones included, carries a signed-in person's name and address from the login instead of asking for them; a visitor still sees the fields. Only the inside forms did before, so someone signed in filled in their own name and address again on a public form.
## 0.6.12 (2026-10-01)
- Trainer: the words model is told who opens each mail: the person who sent the case, the person who posted the record an answer is about, or someone at the business. Mails to the business about another person's case used to be written as if to that person ("you will hear from us"); on Gullberg, three of nine were.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.12"
version = "0.6.15"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.12"
version = "0.6.15"
edition = "2021"
[lib]
+4
View File
@@ -125,6 +125,10 @@ answer:
from: [open, incomplete] # defaults to the initial state alone
label: Send them
invite: { group: case_officers } # onboard someone into Kanidm
# or with words of its own, using any field of the form:
# invite:
# group: guests
# mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }
```
`self_transition.from` is what keeps a person in dialogue with their
+5 -1
View File
@@ -4,11 +4,15 @@ The iris over a site. A content push is an incoming wormhole; nothing
comes through until it checks out.
```
portal lint --path questions # a local checkout
portal iris --path questions # a local checkout
portal lint --repo https://project.uhhm.no/uhhm/questions
portal lint replay --path questions --nats nats://127.0.0.1:14222
```
`portal iris` is the lint's name, after the iris that keeps bad content
out of the portal; `portal lint` runs the same thing, so workflows that
already call it keep working.
It was its own binary, iris, until portal 0.6: the same code, released
as portal's twin. Now it is the `portal` binary's `lint` command, so a
content repo's CI downloads the release and runs it:
+1 -1
View File
@@ -73,7 +73,7 @@ needs:
# Offentlig: noen søker rom. Studenter går foran; styret ser hva den
# som søker gjør til daglig, og velger etter det.
- id: leie-rom
who: en som trenger et rimelig rom på Ås, oftest en student ved NMBU
who: en som trenger et rimelig rom i Ås, oftest en student ved NMBU
wants: et rom å bo på til en leie som går opp, med studenter først i køen
lands_in: romsoknader
handled_by: styret
+2 -2
View File
@@ -289,7 +289,7 @@ slideshow:
- image: images/bildeserie/20.webp
width: 1867
height: 1400
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i 10 år.
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i minst 10 år.
- image: images/bildeserie/21.webp
width: 1867
height: 1400
@@ -309,4 +309,4 @@ slideshow:
- image: images/bildeserie/25.webp
width: 1487
height: 1400
caption: pH-test av borprøver i mur fra forskjellige dybder
caption: pH-test av borprøver i betong fra forskjellige dybder
+1 -1
View File
@@ -242,7 +242,7 @@
}
},
"questions/index.yaml": {
"question": "Trenger du et rimelig rom på Ås?",
"question": "Trenger du et rimelig rom i Ås?",
"description": "Gullberg er et gammelt hus bak Ås kirke, et kort stykke fra NMBU. Eierne driver det selv, og Klingenberg Bygg forvalter huset og vedlikeholdet.",
"opener": {
"heading": "Et rimelig sted å bo, nær NMBU",
+14 -4
View File
@@ -998,10 +998,12 @@ fn Hero(
}
};
let top = view! { <div class="hero-top">{mark()}{session}</div> };
let (bar_top, copy_top) = if has_slides { (Some(top), None) } else { (None, Some(top)) };
view! {
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
<Announcements current_id=current_id/>
{session}
{has_module
.then(|| {
view! {
@@ -1012,10 +1014,12 @@ fn Hero(
// With pictures, the wordmark is a bar of its own above
// them and the question comes under; without, the wordmark
// heads the copy as it always has.
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
// Who is here sits on the wordmark's own line, not on a row
// of its own above it.
{bar_top.map(|top| view! { <div class="hero-bar">{top}</div> })}
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
<div class="hero-copy">
{(!has_slides).then(|| mark())}
{copy_top}
<h1>{title}</h1>
<p inner_html=render_inline_markdown(&description)></p>
</div>
@@ -3078,10 +3082,16 @@ pub async fn submit_answer(
let Some((name, who)) = crate::people::who_for(&responses) else {
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
};
let invited = people
let mut invited = people
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
.await
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
// This invite's own words, and the form they draw on: kept with
// the invite, so sending it again says the same thing.
invited.mail = invite.mail.clone();
if invited.mail.is_some() {
invited.fields = responses.as_object().cloned().unwrap_or_default();
}
if let Some(map) = responses.as_object_mut() {
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
}
+13 -3
View File
@@ -391,6 +391,13 @@ pub struct Alternative {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invite {
pub group: String,
/// This invite's own mail, in place of the site's (`mail.invite`):
/// an invitation to a conversation at an hour, say. Besides
/// `{name}`, `{link}`, `{expires}`, `{username}`, `{group}`,
/// `{site}` and `{site_name}`, every field of the form is a
/// placeholder; a date and time is written out in words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail: Option<MailTemplate>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -1316,6 +1323,7 @@ alternatives:
public: true
source: {{ kind: kv, bucket: things }}
- name: Object
action: /object
record_as: things
features:
- name: Yours
@@ -1413,6 +1421,7 @@ id: /t
name: Where is it?
alternatives:
- name: A
action: /t
features:
- name: Where
requirements:
@@ -1516,6 +1525,7 @@ id: /b
name: B
alternatives:
- name: A
action: /b
features:
- name: ""
requirements:
@@ -1557,7 +1567,7 @@ alternatives:
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
"id: /g\nname: G\nalternatives:\n - name: A\n action: /g\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
@@ -1828,7 +1838,7 @@ alternatives:
fn recorded_buckets_must_be_attended() {
let write_only = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n".to_string(),
)];
let questions = build_questions(&write_only).unwrap();
let err = validate_questions(&questions, &Default::default()).unwrap_err();
@@ -1839,7 +1849,7 @@ alternatives:
let with_desk = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: inbox\n".to_string(),
),
(
"review/index.yaml".to_string(),
+14
View File
@@ -512,6 +512,20 @@ pub fn validate_questions(
}
for question in questions.values() {
for alternative in &question.alternatives {
// A form is sent with its button, and the button is only
// drawn for an alternative that says where to go afterwards:
// without `action` the person fills it in and cannot send it.
let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
&& alternative.action.is_none()
&& alternative.self_transition.is_none()
{
anyhow::bail!(
"question {:?} alternative {:?}: it has a form to send but no `action`, so no button is drawn; give it `action:`, the page to go to once it is sent (its own page is fine)",
question.id,
alternative.name
);
}
if alternative.one_per_person && alternative.record_as.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
+35 -1
View File
@@ -416,6 +416,14 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_default();
let mut vars = serde_json::Map::new();
// The form's own fields first, so the invite's fixed placeholders
// win a clash; a date and time reads as one.
for (key, value) in &invited.fields {
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
if let Some(text) = text {
vars.insert(key.clone(), text.into());
}
}
vars.insert("name".into(), invited.display_name.clone().into());
vars.insert("username".into(), invited.username.clone().into());
vars.insert("questions".into(), list.into());
@@ -553,6 +561,25 @@ pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &s
/// Built in, since portal knows everything about it; `mail.invite` in
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{expires}`.
/// A form's date and time (`2026-10-06T15:00`, from a `datetime-local`
/// field) as a person says it: "tirsdag 6. oktober kl. 15:00", "Tuesday
/// 6 October at 15:00". Anything else is left as it is.
pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
use chrono::{Datelike, NaiveDateTime, Timelike};
let t = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").ok()?;
let day = t.weekday().num_days_from_monday() as usize;
let month = t.month0() as usize;
Some(if norwegian {
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
} else {
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
})
}
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
@@ -560,7 +587,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
return;
};
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
(None, true, true) => (
"Du er invitert inn i {group} på {site_name}".to_string(),
@@ -662,6 +689,13 @@ mod tests {
assert_eq!(vars["state"], "fixed");
}
#[test]
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
assert_eq!(spoken_datetime("Anders Kluge", true), None);
}
#[test]
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
+4 -3
View File
@@ -1,5 +1,6 @@
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
/// `portal lint` checks a content repo the way the site reads it;
/// `portal iris` checks a content repo the way the site reads it (the
/// iris keeps bad content out of the portal; `portal lint` is the same);
/// `portal plan`, `build`, `report` and the rest train one. The lint
/// and the trainer are the same code as the server, at the same
/// version, so they never disagree about what content means.
@@ -8,7 +9,7 @@
async fn main() -> anyhow::Result<()> {
let argv: Vec<String> = std::env::args().skip(1).collect();
match argv.first().map(String::as_str) {
Some("lint") => return portal::lint::run(argv[1..].to_vec()).await,
Some("iris" | "lint") => return portal::lint::run(argv[1..].to_vec()).await,
Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => {
// The trainer runs models through shell commands and waits
// on them; off the runtime's threads, as a CLI should.
@@ -19,7 +20,7 @@ async fn main() -> anyhow::Result<()> {
return Ok(());
}
Some("serve") | None => {}
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, iris (or lint), plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
}
serve().await
}
+2
View File
@@ -254,6 +254,8 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
reset_link: None,
link_expires_ms: None,
created,
mail: None,
fields: Default::default(),
already_member: !created,
};
match existing {
+8
View File
@@ -127,6 +127,12 @@ pub struct Invited {
/// the membership is not portal's to take back.
#[serde(default)]
pub already_member: bool,
/// The invite's own mail and the form it was sent from, kept with
/// the invite so that sending it again sends the same words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail: Option<crate::content::MailTemplate>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub fields: serde_json::Map<String, serde_json::Value>,
}
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
@@ -367,6 +373,8 @@ impl Kanidm {
reset_link,
link_expires_ms,
created,
mail: None,
fields: Default::default(),
already_member,
})
}
+2
View File
@@ -410,6 +410,8 @@ mod tests {
reset_link: None,
link_expires_ms: None,
created: true,
mail: None,
fields: Default::default(),
already_member,
},
)
+6 -7
View File
@@ -1953,13 +1953,12 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
if f.multiple { r["multiple"] = json!(true); }
let hint = s(&w["hints"][&f.name]).trim();
if !hint.is_empty() && f.kind != "select" { r["placeholder"] = json!(hint); }
// Someone signed in is not asked for what the site knows: on a
// group's page they always are, and answering a record they
// may be.
if parent.is_some() || need.enters_as.is_some() {
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
}
// Someone signed in is not asked for what the site knows, on any
// form: portal carries their name and address and the fields take
// no room (`content::fill_from_user`). A visitor sees the fields
// as usual, so a public form loses nothing by it.
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
r
}).collect();
// The record this answer is about, carried in from the page's own
+53 -5
View File
@@ -162,10 +162,13 @@ main.not-found {
/* hero */
/* No room is kept at the top for who is here: that sits on the
wordmark's own line now (.hero-top), and the 4rem it used to need
read as dead space above the mark on a tall, narrow screen. */
.hero {
max-width: 46rem;
margin: 0 auto;
padding: 4rem 1.5rem 2.5rem;
padding: 1.75rem 1.5rem 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
@@ -176,10 +179,6 @@ main.not-found {
/* Who is here and the way in or out: top right of every page, on a
site that has sign-in. */
.session {
position: absolute;
top: 1rem;
right: 1.5rem;
z-index: 5;
display: flex;
gap: 0.9rem;
align-items: baseline;
@@ -201,6 +200,55 @@ main.not-found {
color: var(--ink-dim);
}
/* The wordmark's line, with who is here on it: the mark centred and the
session at the right edge, level with the mark. A skin that sets
everything on one left edge spreads the two apart instead (see
Klingenberg Bygg's and Gullberg's). On a narrow screen the session
drops under the mark rather than over it. */
.hero-top {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.hero-top .session {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.hero-bar .hero-top {
justify-content: space-between;
}
.hero-bar .hero-top .session {
position: static;
transform: none;
}
@media (max-width: 34rem) {
.hero {
padding-top: 1.25rem;
}
.hero-bar {
padding-block: 0.75rem;
}
.hero-top {
flex-wrap: wrap;
}
.hero-top .session {
position: static;
transform: none;
}
}
/* A page with pictures: the wordmark is a bar of its own, the strip
runs the full width under it, and the question follows. */
.hero-slides {