The header knows who is here; my cases; controls that scale with the text
Test / test (pull_request) Successful in 1m12s
Test / test (pull_request) Successful in 1m12s
A session link top right on a site with sign-in, and on a page with
pictures the wordmark as its own bar over the strip. A list may be
narrowed to the signed-in person's own records ({user.email}, from the
session), and an attachment follows the list that shows its record.
Form controls are sized in em: the date picker's icon and the file
picker's button scale and take the field's colours.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9a96386444
commit
6b72ae568a
@@ -4,6 +4,13 @@ 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).
|
||||
|
||||
## Unreleased
|
||||
|
||||
- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after.
|
||||
- "My cases": `where: { field: email, equals: "{user.email}" }` lists the records that carry the signed-in person's own address, filled from the session and never from the caller; nobody signed in matches nothing. An attachment is fetched only through a list that shows its record, so a list of my cases opens my files and nobody else's.
|
||||
- Form controls scale with the text around them (em, not rem): fields, pills, buttons and labels follow a site's body size. The date picker's calendar icon is the size of the text beside it, and the file picker's button takes the field's colours instead of the browser's.
|
||||
- Fixed: `maps` never reached the browser (`serde(skip)`), so a page with a map drew it on the server and dropped it on hydration.
|
||||
|
||||
## 0.6.1 (2026-09-30)
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -263,6 +263,10 @@ A spec with neither `public: true` nor `requires_group` is
|
||||
unreachable: reads fail closed, and mutations always need a group
|
||||
whatever `public` says.
|
||||
|
||||
`where: { field: email, equals: "{user.email}" }` is "my cases": the
|
||||
records carrying the signed-in person's own address, read from the
|
||||
session. A file on such a record is theirs alone to open.
|
||||
|
||||
`states` and `where` narrow a listing. They matter most for a register
|
||||
that is public by law — public about what was decided, not about what
|
||||
was withdrawn — while the desk working the same bucket is a different
|
||||
|
||||
+100
-59
@@ -396,61 +396,9 @@ fn QuestionView(
|
||||
landing=question_id == "/"
|
||||
site=site.clone()
|
||||
current_id=question_id.clone()
|
||||
user=user.clone()
|
||||
slideshow=question.slideshow.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.
|
||||
@@ -895,7 +843,15 @@ mod gesture {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
|
||||
fn Hero(
|
||||
title: String,
|
||||
description: String,
|
||||
landing: bool,
|
||||
site: SiteConfig,
|
||||
current_id: String,
|
||||
#[prop(optional_no_strip)] user: Option<User>,
|
||||
#[prop(optional_no_strip)] slideshow: Option<crate::content::Slideshow>,
|
||||
) -> impl IntoView {
|
||||
// Only the landing page gets a piece - it's the one page a hero is
|
||||
// actually "the" hero for; every other page gets the plain header.
|
||||
// WHAT the piece is belongs to the content repo: site.yaml's
|
||||
@@ -994,9 +950,37 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
|
||||
)
|
||||
});
|
||||
|
||||
// Who is here, and the way in or out, at the top of every page of
|
||||
// a site that has sign-in at all.
|
||||
let session = site.sign_in.then(|| {
|
||||
let redirect = current_id.clone();
|
||||
match user {
|
||||
Some(user) => view! {
|
||||
<nav class="session">
|
||||
<span class="session-name">{user.display_name}</span>
|
||||
<a href="/auth/logout" rel="external">{move || t(&lang().get(), "sign_out")}</a>
|
||||
</nav>
|
||||
}
|
||||
.into_any(),
|
||||
None => view! {
|
||||
<nav class="session">
|
||||
<a href=format!("/auth/login?redirect={redirect}") rel="external">{move || t(&lang().get(), "sign_in")}</a>
|
||||
</nav>
|
||||
}
|
||||
.into_any(),
|
||||
}
|
||||
});
|
||||
let has_slides = slideshow.is_some();
|
||||
let mark = move || view! {
|
||||
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
|
||||
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
|
||||
</a>
|
||||
};
|
||||
|
||||
view! {
|
||||
<header class="hero" class:hero-module=has_module>
|
||||
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
|
||||
<Announcements current_id=current_id/>
|
||||
{session}
|
||||
{has_module
|
||||
.then(|| {
|
||||
view! {
|
||||
@@ -1004,10 +988,13 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
|
||||
<script type="module" inner_html=early_script.unwrap_or_default()></script>
|
||||
}
|
||||
})}
|
||||
// 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> })}
|
||||
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
|
||||
<div class="hero-copy">
|
||||
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
|
||||
<img src=wordmark alt=site_title style=wordmark_style/>
|
||||
</a>
|
||||
{(!has_slides).then(|| mark())}
|
||||
<h1>{title}</h1>
|
||||
<p inner_html=render_inline_markdown(&description)></p>
|
||||
</div>
|
||||
@@ -1015,6 +1002,59 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
|
||||
}
|
||||
}
|
||||
|
||||
/// Pictures across the page (`slideshow`): the full width of the
|
||||
/// screen, each picture 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.
|
||||
#[component]
|
||||
fn SlideStrip(show: crate::content::Slideshow) -> impl IntoView {
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
/// An alternative's banner imagery: nothing, a plain image, or - for
|
||||
/// several urls - a Swiper Element card deck. The `swiper-container`/
|
||||
/// `swiper-slide` custom elements render inert server-side and upgrade
|
||||
@@ -2461,6 +2501,7 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
||||
// own configuration, and the browser has to know before it decides
|
||||
// whether to draw a map or ask for two numbers.
|
||||
site.maps = crate::maps::enabled();
|
||||
site.sign_in = state.oidc.enabled();
|
||||
Ok(site)
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -779,8 +779,13 @@ pub struct SiteConfig {
|
||||
/// the way to the browser (see `get_site`), never from content, so
|
||||
/// a `type: location` field knows whether to offer a map or two
|
||||
/// number inputs.
|
||||
#[serde(skip)]
|
||||
#[serde(default)]
|
||||
pub maps: bool,
|
||||
/// Whether this instance has sign-in at all (a provider is
|
||||
/// configured): set on the way to the browser like `maps`, so the
|
||||
/// header knows whether to offer it.
|
||||
#[serde(default)]
|
||||
pub sign_in: bool,
|
||||
/// Who this site's mail comes from. Absent means the site sends
|
||||
/// nothing: a state's `mail:` and the invite mail are logged and
|
||||
/// skipped, never half-sent (see `mail.rs`).
|
||||
|
||||
@@ -22,6 +22,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("en", "if_stuck") => " if you get stuck.",
|
||||
("en", "send") => "Send",
|
||||
("en", "sign_in") => "Sign in",
|
||||
("en", "sign_out") => "Sign out",
|
||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||
("en", "nothing_here") => "Nothing here",
|
||||
("en", "back_to_start") => "back to the start",
|
||||
@@ -51,6 +52,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("no", "if_stuck") => " om du står fast.",
|
||||
("no", "send") => "Send",
|
||||
("no", "sign_in") => "Logg inn",
|
||||
("no", "sign_out") => "Logg ut",
|
||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||
("no", "nothing_here") => "Ingenting her",
|
||||
("no", "back_to_start") => "tilbake til start",
|
||||
@@ -98,6 +100,7 @@ mod tests {
|
||||
"if_stuck",
|
||||
"send",
|
||||
"sign_in",
|
||||
"sign_out",
|
||||
"nothing_here_yet",
|
||||
"nothing_here",
|
||||
"back_to_start",
|
||||
|
||||
+59
-8
@@ -39,7 +39,7 @@ pub async fn get_resource(
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
||||
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
|
||||
fetch_resource_value(&state, resource, ¶ms).await
|
||||
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
|
||||
}
|
||||
|
||||
/// The `Requirement.resource`-backed counterpart to `get_resource` -
|
||||
@@ -83,7 +83,7 @@ pub async fn get_requirement_options(
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
|
||||
fetch_resource_value(&state, resource, ¶ms).await
|
||||
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
|
||||
}
|
||||
|
||||
/// The `Requirement.bind`-backed counterpart to
|
||||
@@ -114,7 +114,7 @@ pub async fn get_requirement_binding(
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
|
||||
fetch_resource_value(&state, &bind.resource, ¶ms).await
|
||||
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
|
||||
}
|
||||
|
||||
/// Scoped to the named alternative first, not flattened across all of
|
||||
@@ -211,8 +211,12 @@ fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Valu
|
||||
/// stamped on it. A missing field never matches: "everything that did
|
||||
/// not say" is not something a page should be able to ask for by
|
||||
/// accident.
|
||||
///
|
||||
/// `equals: "{user.email}"` is the signed-in person's own address: a
|
||||
/// list of "my cases". It is filled from the session, never from the
|
||||
/// caller, and with nobody signed in it matches nothing.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn matches_filter(
|
||||
pub(crate) fn matches_filter(
|
||||
resource: &crate::content::ResourceSpec,
|
||||
value: &serde_json::Value,
|
||||
params: &std::collections::HashMap<String, String>,
|
||||
@@ -220,19 +224,54 @@ fn matches_filter(
|
||||
let Some(filter) = &resource.filter else {
|
||||
return true;
|
||||
};
|
||||
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params))
|
||||
.unwrap_or_else(|| filter.equals.clone());
|
||||
let equals = if filter.equals.contains("{user.email}") {
|
||||
match params.get(USER_EMAIL).filter(|e| !e.is_empty()) {
|
||||
Some(email) => filter.equals.replace("{user.email}", email),
|
||||
None => return false,
|
||||
}
|
||||
} else {
|
||||
filter.equals.clone()
|
||||
};
|
||||
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
|
||||
// Addresses compare without case; everything else as written.
|
||||
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
|
||||
let found = value
|
||||
.get("responses")
|
||||
.and_then(|r| r.get(&filter.field))
|
||||
.or_else(|| value.get(&filter.field));
|
||||
match found {
|
||||
Some(serde_json::Value::String(s)) => s == &wanted,
|
||||
Some(other) => other.to_string() == wanted,
|
||||
Some(serde_json::Value::String(s)) => same(s),
|
||||
Some(other) => same(&other.to_string()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter a `where` reads the signed-in person's address from.
|
||||
/// Set by the server from the session; a caller's own is thrown away.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(crate) const USER_EMAIL: &str = "__user_email";
|
||||
|
||||
/// The caller's parameters with the session's person put in and
|
||||
/// anything pretending to be them taken out.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
|
||||
params.retain(|k, _| !k.starts_with("__"));
|
||||
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
|
||||
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
|
||||
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
|
||||
Err(_) => None,
|
||||
};
|
||||
with_person(params, user.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn fetch_resource_value(
|
||||
state: &crate::server::AppState,
|
||||
@@ -559,6 +598,18 @@ mod tests {
|
||||
assert!(!matches_filter(&children, &theirs, ¶ms));
|
||||
// A record that did not say is not everyone's child.
|
||||
assert!(!matches_filter(&children, &neither, ¶ms));
|
||||
|
||||
// "My cases": the session's address, never the caller's.
|
||||
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
|
||||
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
|
||||
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
|
||||
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
|
||||
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
|
||||
let asked = with_person(forged.clone(), Some(&kari));
|
||||
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
|
||||
// Nobody signed in: the forged parameter is dropped, and nothing matches.
|
||||
let anonymous = with_person(forged, None);
|
||||
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
|
||||
// No filter, no narrowing: every resource written before this
|
||||
// existed still lists its whole bucket.
|
||||
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
||||
|
||||
@@ -50,6 +50,11 @@ const NONCE_KEY: &str = "oidc_nonce";
|
||||
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
||||
|
||||
impl Oidc {
|
||||
/// Whether a provider is configured.
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.inner.is_some()
|
||||
}
|
||||
|
||||
/// Sign-in disabled: what a content-only instance has, and what a
|
||||
/// test builds an `AppState` with.
|
||||
pub fn disabled() -> Self {
|
||||
|
||||
+11
-6
@@ -277,13 +277,15 @@ pub async fn attachment(
|
||||
// cannot be used to learn which buckets, records or fields exist.
|
||||
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
|
||||
|
||||
if !may_read_bucket(&state, &session, &bucket).await {
|
||||
return Err(missing());
|
||||
}
|
||||
|
||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
|
||||
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
|
||||
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?;
|
||||
let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
|
||||
// Through a list the caller may read, and one that shows this
|
||||
// record: a list of "my cases" opens my files and nobody else's.
|
||||
if !may_read_record(&state, &session, &bucket, &as_listed).await {
|
||||
return Err(missing());
|
||||
}
|
||||
let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?;
|
||||
let key = answer
|
||||
.responses
|
||||
.get(&field)
|
||||
@@ -331,20 +333,23 @@ pub async fn attachment(
|
||||
/// Whether this caller may see this bucket at all: whether any feature
|
||||
/// in the loaded content lists it and the policy lets them read that
|
||||
/// feature. The desk that shows the record is what grants the file.
|
||||
async fn may_read_bucket(
|
||||
async fn may_read_record(
|
||||
state: &AppState,
|
||||
session: &tower_sessions::Session,
|
||||
bucket: &str,
|
||||
record: &serde_json::Value,
|
||||
) -> bool {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
|
||||
let principal = crate::access::Principal::from_user(user.as_ref());
|
||||
let params = crate::resource::with_person(Default::default(), user.as_ref());
|
||||
let access = state.access.load();
|
||||
state.questions.load().values().any(|question| {
|
||||
question.alternatives.iter().any(|alternative| {
|
||||
alternative.features.iter().any(|feature| {
|
||||
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
|
||||
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, ¶ms))
|
||||
&& access.allows(
|
||||
&principal,
|
||||
crate::access::Action::Read,
|
||||
|
||||
+123
-11
@@ -173,6 +173,61 @@ main.not-found {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.session a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
border-bottom: 0.06rem solid var(--line);
|
||||
}
|
||||
|
||||
.session a:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.session-name {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
max-width: none;
|
||||
padding: 0 0 2rem;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hero-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.9rem 1.5rem;
|
||||
}
|
||||
|
||||
.hero-slides .slideshow {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-slides .hero-copy {
|
||||
width: 100%;
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
display: inline-flex;
|
||||
opacity: 0.85;
|
||||
@@ -742,7 +797,7 @@ main.not-found {
|
||||
|
||||
.feature p {
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -753,7 +808,7 @@ main.not-found {
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
@@ -763,17 +818,74 @@ main.not-found {
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
/* Controls take their size from the text around them (em, not rem):
|
||||
a site that sets its body larger, or a card that sets itself
|
||||
smaller, gets fields, pills and buttons to match, and everything
|
||||
inside a control - the date picker's icon, the file picker's button
|
||||
- scales with the control. */
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
border: 0.06rem solid var(--line);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 0.6em;
|
||||
padding: 0.65em 0.8em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The calendar icon at the size of the text it sits beside, and a
|
||||
target worth aiming at. */
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="datetime-local"] {
|
||||
min-height: 2.9em;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator,
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
width: 1.35em;
|
||||
height: 1.35em;
|
||||
padding: 0.2em;
|
||||
margin-left: 0.4em;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The file picker's own button, in the field's colours rather than
|
||||
the browser's: it follows whatever ink the field has, so it holds
|
||||
on a light field and a dark one alike. */
|
||||
input[type="file"] {
|
||||
padding: 0.45em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="file"]::file-selector-button {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, currentColor 9%, transparent);
|
||||
border: 0.06rem solid color-mix(in srgb, currentColor 28%, transparent);
|
||||
border-radius: 999rem;
|
||||
padding: 0.4em 1em;
|
||||
margin-right: 0.8em;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
input[type="file"]::file-selector-button:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
@@ -1034,14 +1146,14 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.alt-submit {
|
||||
font-family: var(--sans);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.95em;
|
||||
color: var(--paper);
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.75rem 1.4rem;
|
||||
border-radius: 0.7em;
|
||||
padding: 0.75em 1.4em;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms, transform 120ms;
|
||||
/* A gateway alternative renders this as an <a> (navigation, no POST);
|
||||
@@ -1247,13 +1359,13 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.select-option {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
border: 0.06rem solid var(--line);
|
||||
border-radius: 999rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
padding: 0.45em 1em;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user