clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

Rebuild the profile page on Pico CSS, and make user styling checkable

Dami committed 6a8ca6b
The first UI was a bespoke stylesheet: custom type, hand-drawn leaders,
colour used as meaning. It is removed. The page is now semantic HTML
styled by Pico CSS 2.1.1, set in Open Sans at 400 and 700, both vendored
under web/vendor/ so the page makes no third-party request.

The reason is not taste. profiles.custom_css was reserved for user
styling from the start, and user styling is only safe if "is this valid?"
has a mechanical answer. Against a bespoke stylesheet it does not — the
answer depends on which class the author happened to name and what it
happened to do. Against Pico it does: the whole page is drawn from
--pico-* variables, so a theme can be a list of assignments to named
variables with a declared grammar per property.

That is what theme.rs is. Every property is checked against a whitelist
of variables the vendored Pico actually defines, and every value against
a grammar, once, on write. Two properties fall out of the design rather
than being bolted onto it:

  - A theme has no selectors. It cannot position, hide or overlay
    anything, and cannot make one profile impersonate another part of the
    site. It recolours and re-spaces the template; that is all it can do.

  - Every accepted value is drawn from [0-9a-z#%.,/()+- ], so it cannot
    carry <, " or }. The rendered rule is safe to inline in a <style>
    element by construction, not by remembering to escape it.

The theme block is the last stylesheet in the document, so it wins ties
on document order — no user declaration ever has to out-specify Pico,
which a selector-based design would have forced.

Refusals name the offending declaration:

  --pico-primary: wine;      expected a colour, e.g. `#7b3fe4`, …
  --pico-primary-color: red; not a theme property — did you mean
                             `--pico-primary`?
  body { color: red }        `{` is not allowed: a theme is a list of
                             `--pico-…: value;` declarations
  --pico-font-family: …      not a theme property; the typeface is fixed

Open Sans is a variable font here, declared twice at 400 and 700 rather
than as a range, so Pico's occasional request for 600 resolves to 700
instead of rendering an in-between instance. Two weights, by construction.

GET /api/theme/properties serves the list from the binary that enforces
it, so the website, `devsite theme properties` and the docs cannot drift
from what is accepted. Editing is either the Appearance panel on your own
profile or `devsite theme set|show|clear`.

Two bugs found by driving a real browser rather than by reading the code:

  - An iframe parsed with the `hidden` attribute never renders a srcdoc
    assigned later, and no reassignment recovers it. The viewer now builds
    its iframe per open, which also destroys the previous service's
    document instead of blanking it.

  - Pico styles `ul li` directly, and a directly-matching rule beats an
    inherited one however specific, so `list-style` has to be set on the
    items rather than the list.

68 tests. The new ones are adversarial in the same spirit as
tests/authz.rs: a theme that tries to become a stylesheet, escape its own
rule, or name a property outside the whitelist.

See docs/profile-template.md for the template, the whitelist and the
grammars.
commit
6a8ca6bd7104fc41c58fa5e995e424cd66d79313
parent
4801ca95df7fca90181cca1b120b3b7c1f6c1fc7
author
Dami <dami.etoile@gmail.com>
16 files changed, +1408 -540
MREADME.md26+-
Mcrates/devsite-cli/src/client.rs10+
Mcrates/devsite-cli/src/main.rs80+++++
Mcrates/devsite-server/src/api.rs83+++++
Mcrates/devsite-server/src/db.rs45+++
Mcrates/devsite-server/src/main.rs1+
Acrates/devsite-server/src/theme.rs484++++++++++++++++++++++++++++++
Mdocs/milestones.md45++-
Adocs/profile-template.md164++++++++++
Mweb/app.js427++++++++++++++++++--------
Mweb/index.html54++--
Aweb/site.css156++++++++++
Dweb/styles.css369-----------------------
Aweb/vendor/fonts/open-sans-latin-ext.woff2bin
Aweb/vendor/fonts/open-sans-latin.woff2bin
Aweb/vendor/pico.min.css4+
M README.md +23 -3
@@ -63,9 +63,24 @@ devsite profile create @dami
6363 devsite link add --name "Klot Ski" --url https://klot.ski --public6464 devsite expose http://127.0.0.1:4101 --name "Hermes" --private6565 devsite expose http://127.0.0.1:4102 --name "Agent" --share @frank66+devsite theme set my-theme.css6667 devsite daemon run6768 ```6869 70+### The profile page71+72+Semantic HTML styled by [Pico CSS], set in Open Sans at 400 and 700. Both are73+vendored under `web/vendor/`, so the page makes no third-party request.74+75+Personalisation is real CSS, but not a stylesheet: a theme is a list of76+assignments to named Pico variables, each checked against a declared value77+grammar before it is stored. No selectors, no properties, no `!important` — so a78+theme can recolour and re-space the page and can do nothing else. The template it79+applies to, the whitelist, and the grammars are in80+[`docs/profile-template.md`](docs/profile-template.md).81+82+[Pico CSS]: https://picocss.com83+6984 ### Configuration7085 7186 | Variable | Default | Notes |
@@ -98,7 +113,7 @@ cloudflared tunnel login # once, opens a browser
98113 | `crates/devsite-server` | Control plane: axum + SQLite, Shoo auth, capability issuance. |99114 | `crates/devsite-cli` | `devsite` — login, profile, link, expose, daemon run. |100115 | `crates/devsite-fixture` | Serves one HTML file on a port; stands in for a real service. |101-| `web/` | The site. `web/pkg/` is generated by `scripts/build-wasm.sh`. |116+| `web/` | The site. `web/vendor/` is Pico and Open Sans; `web/pkg/` is generated by `scripts/build-wasm.sh`. |102117 103118 `devsite-client` compiling both ways is deliberate: the authorization matrix is proven by104119 fast native tests running the exact code the browser executes.
@@ -109,10 +124,12 @@ fast native tests running the exact code the browser executes.
109124 cargo test --workspace110125 ```111126 112-51 tests. The interesting ones are `crates/devsite-daemon/tests/authz.rs`, which drives127+68 tests. The interesting ones are `crates/devsite-daemon/tests/authz.rs`, which drives113128 real Iroh against a real local service and asserts that forged, expired, misaddressed,114129 rebound, replayed and unknown-resource capabilities are each refused — and that a denial115-tells the caller nothing about which of those it was.130+tells the caller nothing about which of those it was. `theme.rs` carries the other131+adversarial set: a theme that tries to become a stylesheet, escape its rule, or name a132+property outside the whitelist.116133 117134 ## Security notes118135
@@ -124,6 +141,9 @@ tells the caller nothing about which of those it was.
124141 ranges and `.ts.net`, so a daemon cannot be turned into a launderer for its owner's IP.125142 - Fetched pages render in `sandbox="allow-scripts"` **without** `allow-same-origin`, giving126143 them an opaque origin with no access to dev.site's DOM, storage or cookies.144+- A profile theme is a whitelist of Pico variables with per-property value grammars, so145+ the alphabet of a stored value cannot contain `<`, `"` or `}`. User styling cannot146+ position, hide or overlay anything, and cannot make one profile impersonate another.127147 - Session tokens are stored only as SHA-256 hashes.128148 - `issue-session` is an operator command requiring shell access; there is no129149 network-reachable authentication bypass.
M crates/devsite-cli/src/client.rs +10 -0
M crates/devsite-cli/src/main.rs +80 -0
@@ -51,6 +51,9 @@ enum Command {
5151 #[arg(long, value_name = "@handle")]5252 share: Vec<String>,5353 },54+ /// Profile appearance.55+ #[command(subcommand)]56+ Theme(ThemeCommand),5457 /// Show what this machine is configured to serve.5558 Status,5659 /// Daemon control.
@@ -82,6 +85,36 @@ enum DaemonCommand {
8285 Run,8386 }8487 88+/// A theme is a list of `--pico-*` declarations, checked by the control plane89+/// against a fixed whitelist. There is no theme dropdown, and no arbitrary CSS.90+#[derive(Subcommand)]91+enum ThemeCommand {92+ /// Print the theme currently applied to your profile.93+ Show,94+ /// Replace your theme with declarations read from a file, or `-` for stdin.95+ Set { file: String },96+ /// Remove your theme and go back to the defaults.97+ Clear,98+ /// List every property a theme may set, and what each one accepts.99+ Properties,100+}101+102+#[derive(Deserialize)]103+struct ThemeResponse {104+ css: String,105+}106+107+#[derive(Deserialize)]108+struct ThemeProperties {109+ properties: Vec<ThemeProperty>,110+}111+112+#[derive(Deserialize)]113+struct ThemeProperty {114+ name: String,115+ accepts: String,116+}117+85118 #[derive(Deserialize)]86119 struct PubKeyResponse {87120 public_key: String,
@@ -125,6 +158,7 @@ async fn main() -> Result<()> {
125158 private,126159 share,127160 } => expose(&paths, &cli.server, origin, &name, public, private, share).await,161+ Command::Theme(command) => theme(&paths, &cli.server, command).await,128162 Command::Status => status(&paths),129163 Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server).await,130164 }
@@ -275,6 +309,52 @@ async fn expose(
275309 Ok(())276310 }277311 312+async fn theme(paths: &Paths, server: &str, command: ThemeCommand) -> Result<()> {313+ // The property list is public: it is the documentation, and it comes from314+ // the binary that enforces it rather than from a copy kept here.315+ if let ThemeCommand::Properties = command {316+ let api = ControlPlane::new(server, None);317+ let listing: ThemeProperties = api.get("/api/theme/properties").await?;318+ for property in listing.properties {319+ println!("{:<44} {}", property.name, property.accepts);320+ }321+ return Ok(());322+ }323+324+ let config = load_authenticated(paths)?;325+ let api = ControlPlane::new(server, config.session_token.clone());326+327+ match command {328+ ThemeCommand::Properties => unreachable!("handled above, before authentication"),329+ ThemeCommand::Show => {330+ let theme: ThemeResponse = api.get("/api/theme").await?;331+ if theme.css.trim().is_empty() {332+ println!("no theme set — run `devsite theme set <file>`");333+ } else {334+ print!("{}", theme.css);335+ }336+ }337+ ThemeCommand::Set { file } => {338+ let css = if file == "-" {339+ std::io::read_to_string(std::io::stdin())?340+ } else {341+ std::fs::read_to_string(&file).with_context(|| format!("reading {file}"))?342+ };343+ // The server is the only validator. Rejections arrive as its own344+ // message — "`--pico-primary: wine` — expected a colour, e.g. …" —345+ // and are printed as-is rather than re-worded here.346+ let saved: ThemeResponse = api.put("/api/theme", &serde_json::json!({ "css": css })).await?;347+ print!("{}", saved.css);348+ println!("theme saved");349+ }350+ ThemeCommand::Clear => {351+ let _: ThemeResponse = api.put("/api/theme", &serde_json::json!({ "css": "" })).await?;352+ println!("theme cleared");353+ }354+ }355+ Ok(())356+}357+278358 fn visibility_str(visibility: Visibility) -> &'static str {279359 match visibility {280360 Visibility::Public => "public",
M crates/devsite-server/src/api.rs +83 -0
@@ -17,6 +17,7 @@ use crate::auth::{self, ShooVerifier};
1717 use crate::db::{Db, DaemonPresence, ResourceKind};1818 use crate::issuer::Issuer;1919 use crate::policy::{can_view, Visibility};20+use crate::theme;2021 2122 pub struct AppState {2223 pub db: Mutex<Db>,
@@ -172,6 +173,23 @@ pub struct ProfileResponse {
172173 pub is_owner: bool,173174 pub entries: Vec<ProfileEntry>,174175 pub shared_with_me: Vec<ProfileEntry>,176+ /// The owner's theme: validated `--pico-*` assignments, in the order they177+ /// were written. The page turns these into one rule scoped to the profile.178+ pub theme: Vec<theme::Declaration>,179+}180+181+#[derive(Deserialize)]182+pub struct ThemeRequest {183+ /// Declarations only — `--pico-border-radius: 0.5rem;` and so on. Anything184+ /// that is not on the whitelist is a 400 with a reason, never a silent drop.185+ pub css: String,186+}187+188+#[derive(Serialize)]189+pub struct ThemeResponse {190+ /// The theme as stored, after normalisation.191+ pub css: String,192+ pub theme: Vec<theme::Declaration>,175193 }176194 177195 #[derive(Deserialize)]
@@ -199,6 +217,8 @@ pub fn router(state: Shared) -> Router {
199217 .route("/api/profile", post(claim_handle))200218 .route("/api/resources", post(create_resource).get(list_resources))201219 .route("/api/daemon/heartbeat", post(heartbeat))220+ .route("/api/theme", get(read_theme).put(write_theme))221+ .route("/api/theme/properties", get(theme_properties))202222 .route("/api/profile/{handle}", get(profile))203223 .route("/api/capability", post(capability))204224 .with_state(state)
@@ -409,6 +429,56 @@ async fn heartbeat(
409429 Ok(StatusCode::NO_CONTENT)410430 }411431 432+// -- themes --------------------------------------------------------------------433+434+/// The properties a theme may set, and what each one accepts.435+///436+/// Served by the same binary that enforces the list, so the CLI, the website and437+/// the docs cannot drift from what is actually accepted.438+async fn theme_properties() -> impl IntoResponse {439+ Json(serde_json::json!({440+ "properties": theme::properties()441+ .map(|(name, accepts)| serde_json::json!({ "name": name, "accepts": accepts }))442+ .collect::<Vec<_>>()443+ }))444+}445+446+async fn read_theme(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<impl IntoResponse> {447+ let account = require_account(&state, &headers)?;448+ let css = {449+ let db = state.db.lock().unwrap();450+ db.custom_css(account).map_err(ApiError::internal)?451+ };452+ let css = css.unwrap_or_default();453+ Ok(Json(ThemeResponse {454+ theme: theme::parse(&css).unwrap_or_default(),455+ css,456+ }))457+}458+459+async fn write_theme(460+ State(state): State<Shared>,461+ headers: HeaderMap,462+ Json(body): Json<ThemeRequest>,463+) -> ApiResult<impl IntoResponse> {464+ let account = require_account(&state, &headers)?;465+466+ // Validated here, once, before anything is stored. Every later read — the467+ // profile page, the CLI, this endpoint — can then treat the column as468+ // already-checked rather than re-deciding what is safe.469+ let declarations = theme::parse(&body.css).map_err(ApiError::bad_request)?;470+ let css = theme::to_css(&declarations);471+472+ let db = state.db.lock().unwrap();473+ db.set_custom_css(account, (!css.is_empty()).then_some(css.as_str()))474+ .map_err(ApiError::internal)?;475+476+ Ok(Json(ThemeResponse {477+ css,478+ theme: declarations,479+ }))480+}481+412482 async fn profile(413483 State(state): State<Shared>,414484 headers: HeaderMap,
@@ -467,11 +537,24 @@ async fn profile(
467537 }468538 }469539 540+ // Stored themes are canonical, so re-parsing them here is cheap and always541+ // succeeds. It is not merely a formality: if a property is ever retired from542+ // the whitelist, a profile styled with it degrades to the default rather543+ // than serving a rule the current build no longer stands behind.544+ let theme = match db.custom_css(owner.id).map_err(ApiError::internal)? {545+ Some(css) => theme::parse(&css).unwrap_or_else(|err| {546+ tracing::warn!(handle = %handle, "stored theme no longer validates: {err}");547+ Vec::new()548+ }),549+ None => Vec::new(),550+ };551+470552 Ok(Json(ProfileResponse {471553 handle,472554 is_owner: viewer == Some(owner.id),473555 entries,474556 shared_with_me,557+ theme,475558 }))476559 }477560
M crates/devsite-server/src/db.rs +45 -0
@@ -385,6 +385,36 @@ impl Db {
385385 .collect()386386 }387387 388+ // -- profiles ---------------------------------------------------------------389+390+ /// The account's stored theme, as the canonical declaration text written by391+ /// `crate::theme::to_css`. Nothing else is ever written to this column.392+ pub fn custom_css(&self, account: AccountId) -> Result<Option<String>> {393+ Ok(self394+ .conn395+ .query_row(396+ "SELECT custom_css FROM profiles WHERE account_id = ?1",397+ params![account.to_string()],398+ |row| row.get::<_, Option<String>>(0),399+ )400+ .optional()?401+ .flatten()402+ .filter(|css| !css.trim().is_empty()))403+ }404+405+ /// Replace the account's theme. `None` clears it.406+ ///407+ /// Upserts because the profile row is created when a handle is claimed, and408+ /// an account that predates that is still allowed to have a theme.409+ pub fn set_custom_css(&self, account: AccountId, css: Option<&str>) -> Result<()> {410+ self.conn.execute(411+ "INSERT INTO profiles (account_id, custom_css) VALUES (?1, ?2)412+ ON CONFLICT(account_id) DO UPDATE SET custom_css = excluded.custom_css",413+ params![account.to_string(), css],414+ )?;415+ Ok(())416+ }417+388418 // -- daemon presence --------------------------------------------------------389419 390420 pub fn record_heartbeat(
@@ -610,6 +640,21 @@ mod tests {
610640 assert_ne!(a, b, "names are scoped to their owner");611641 }612642 643+ #[test]644+ fn a_theme_round_trips_and_can_be_cleared() {645+ let (db, dami, _) = seeded();646+ assert!(db.custom_css(dami.id).unwrap().is_none());647+648+ db.set_custom_css(dami.id, Some("--pico-primary: #7b3fe4;\n")).unwrap();649+ assert_eq!(650+ db.custom_css(dami.id).unwrap().as_deref(),651+ Some("--pico-primary: #7b3fe4;\n")652+ );653+654+ db.set_custom_css(dami.id, None).unwrap();655+ assert!(db.custom_css(dami.id).unwrap().is_none());656+ }657+613658 #[test]614659 fn handles_are_matched_case_insensitively() {615660 let (db, dami, _) = seeded();
M crates/devsite-server/src/main.rs +1 -0
A crates/devsite-server/src/theme.rs +484 -0
@@ -0,0 +1,484 @@
1+//! User themes.2+//!3+//! A profile's look is not a stylesheet. It is a list of assignments to named4+//! Pico variables, each with a declared value grammar — so "is this theme5+//! valid?" is a question with a mechanical answer rather than a judgement call.6+//!7+//! Two things follow from that, and both are the point:8+//!9+//! - **It cannot break the page.** No selectors, no properties, no at-rules, no10+//! `!important`. A theme can only recolour and re-space what the profile11+//! template already lays out; it cannot position, hide, or overlay anything.12+//! - **It cannot inject.** Every property name is one of the `&'static str`s in13+//! [`PROPERTIES`], and every value has passed a grammar whose alphabet is14+//! `[0-9a-z#%.,/()+- ]`. Neither can carry `<`, `"` or `}`, so the emitted15+//! rule is safe to inline in a `<style>` element without further escaping.16+//!17+//! The variables below all exist in the vendored Pico 2.1.1. A name that Pico18+//! does not define would be accepted, stored, and do nothing — which is exactly19+//! the silent failure this list is here to prevent.20+21+use serde::Serialize;22+23+/// Longest accepted source, before parsing. A theme is a few dozen short lines.24+pub const MAX_INPUT: usize = 4096;25+/// Longest accepted value. `oklch(62.8% 0.258 29.23)` is 24 characters.26+pub const MAX_VALUE: usize = 64;27+28+/// One validated assignment. `property` is borrowed from [`PROPERTIES`], so a29+/// stored declaration cannot name anything outside the table.30+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]31+pub struct Declaration {32+ pub property: &'static str,33+ pub value: String,34+}35+36+/// The grammar a property's value must satisfy.37+enum Kind {38+ /// Hex, `rgb()`/`rgba()`/`hsl()`/`hsla()`/`oklch()`, a CSS colour name,39+ /// `transparent` or `currentcolor`.40+ Color,41+ /// A non-negative number with a unit, or bare `0`.42+ Length,43+ /// A non-negative unitless number.44+ Number,45+ /// One of a fixed set of words.46+ Keyword(&'static [&'static str]),47+}48+49+/// The one key that is not a Pico variable: it chooses which of Pico's own50+/// palettes the profile starts from, by setting `data-theme` on the page.51+pub const SCHEME: &str = "--devsite-scheme";52+53+/// Every property a theme may set, with the grammar of its value.54+///55+/// Ordered as it is documented: scheme, then colour, then metrics, then type.56+const PROPERTIES: &[(&str, Kind)] = &[57+ (SCHEME, Kind::Keyword(&["light", "dark", "auto"])),58+ // -- surfaces and text ---------------------------------------------------59+ ("--pico-background-color", Kind::Color),60+ ("--pico-color", Kind::Color),61+ ("--pico-muted-color", Kind::Color),62+ ("--pico-muted-border-color", Kind::Color),63+ ("--pico-border-color", Kind::Color),64+ ("--pico-text-selection-color", Kind::Color),65+ // -- accents -------------------------------------------------------------66+ ("--pico-primary", Kind::Color),67+ ("--pico-primary-background", Kind::Color),68+ ("--pico-primary-hover", Kind::Color),69+ ("--pico-primary-hover-background", Kind::Color),70+ ("--pico-primary-inverse", Kind::Color),71+ ("--pico-primary-underline", Kind::Color),72+ ("--pico-primary-focus", Kind::Color),73+ ("--pico-secondary", Kind::Color),74+ ("--pico-secondary-background", Kind::Color),75+ ("--pico-secondary-hover", Kind::Color),76+ ("--pico-secondary-inverse", Kind::Color),77+ ("--pico-contrast", Kind::Color),78+ ("--pico-contrast-background", Kind::Color),79+ ("--pico-contrast-inverse", Kind::Color),80+ // -- headings ------------------------------------------------------------81+ ("--pico-h1-color", Kind::Color),82+ ("--pico-h2-color", Kind::Color),83+ ("--pico-h3-color", Kind::Color),84+ ("--pico-h4-color", Kind::Color),85+ ("--pico-h5-color", Kind::Color),86+ ("--pico-h6-color", Kind::Color),87+ // -- blocks --------------------------------------------------------------88+ ("--pico-card-background-color", Kind::Color),89+ ("--pico-card-border-color", Kind::Color),90+ ("--pico-card-sectioning-background-color", Kind::Color),91+ ("--pico-code-background-color", Kind::Color),92+ ("--pico-code-color", Kind::Color),93+ ("--pico-mark-background-color", Kind::Color),94+ ("--pico-mark-color", Kind::Color),95+ ("--pico-blockquote-border-color", Kind::Color),96+ // `ins` and `del` also carry the online/offline state of a service.97+ ("--pico-ins-color", Kind::Color),98+ ("--pico-del-color", Kind::Color),99+ // -- form elements -------------------------------------------------------100+ ("--pico-form-element-background-color", Kind::Color),101+ ("--pico-form-element-border-color", Kind::Color),102+ ("--pico-form-element-color", Kind::Color),103+ // -- metrics -------------------------------------------------------------104+ ("--pico-border-radius", Kind::Length),105+ ("--pico-border-width", Kind::Length),106+ ("--pico-outline-width", Kind::Length),107+ ("--pico-spacing", Kind::Length),108+ ("--pico-block-spacing-vertical", Kind::Length),109+ ("--pico-block-spacing-horizontal", Kind::Length),110+ ("--pico-typography-spacing-vertical", Kind::Length),111+ ("--pico-form-element-spacing-vertical", Kind::Length),112+ ("--pico-form-element-spacing-horizontal", Kind::Length),113+ ("--pico-nav-element-spacing-vertical", Kind::Length),114+ ("--pico-nav-element-spacing-horizontal", Kind::Length),115+ ("--pico-text-underline-offset", Kind::Length),116+ ("--pico-font-size", Kind::Length),117+ ("--pico-line-height", Kind::Number),118+ // The typeface is fixed: Open Sans, at one of its two weights. There is no119+ // --pico-font-family here, and that is deliberate.120+ ("--pico-font-weight", Kind::Keyword(&["400", "700"])),121+ ("--pico-text-decoration", Kind::Keyword(&["none", "underline"])),122+];123+124+/// Read a theme, or say precisely what is wrong with it.125+///126+/// The error is written for the person who typed the CSS, because it is shown127+/// to them verbatim by both the website and the CLI.128+pub fn parse(input: &str) -> Result<Vec<Declaration>, String> {129+ if input.len() > MAX_INPUT {130+ return Err(format!("a theme may be at most {MAX_INPUT} characters"));131+ }132+133+ let source = strip_comments(input)?;134+ let mut declarations: Vec<Declaration> = Vec::new();135+136+ for chunk in source.split(';') {137+ let chunk = chunk.trim();138+ if chunk.is_empty() {139+ continue;140+ }141+ if let Some(bad) = chunk.chars().find(|c| "{}@<>\"'\\".contains(*c)) {142+ return Err(format!(143+ "`{bad}` is not allowed: a theme is a list of `--pico-…: value;` \144+ declarations, not a stylesheet"145+ ));146+ }147+ if chunk.contains('!') {148+ return Err("`!important` is not allowed; a theme never has to out-rank anything".into());149+ }150+151+ let (name, value) = chunk152+ .split_once(':')153+ .ok_or_else(|| format!("`{chunk}` is missing a `:`"))?;154+ let name = name.trim().to_ascii_lowercase();155+ let value = normalize_whitespace(value);156+157+ if value.is_empty() {158+ return Err(format!("`{name}` has no value"));159+ }160+ if value.len() > MAX_VALUE {161+ return Err(format!("the value of `{name}` is too long"));162+ }163+164+ let (property, kind) = PROPERTIES165+ .iter()166+ .find(|(known, _)| *known == name)167+ .ok_or_else(|| unknown_property(&name))?;168+169+ if !kind.accepts(&value) {170+ return Err(format!("`{name}: {value}` — expected {}", kind.describe()));171+ }172+173+ // Last one wins, as it would in a real rule block, but the theme keeps174+ // the order it was written in. Collapsing duplicates here is also what175+ // bounds the result: a theme can never hold more declarations than176+ // PROPERTIES has entries, however long its source is.177+ match declarations.iter_mut().find(|d| d.property == *property) {178+ Some(existing) => existing.value = value,179+ None => declarations.push(Declaration { property, value }),180+ }181+ }182+183+ Ok(declarations)184+}185+186+/// Render declarations back to the canonical text stored in the database.187+pub fn to_css(declarations: &[Declaration]) -> String {188+ declarations189+ .iter()190+ .map(|d| format!("{}: {};\n", d.property, d.value))191+ .collect()192+}193+194+/// Every property a theme may set, for `devsite theme properties` and the docs.195+pub fn properties() -> impl Iterator<Item = (&'static str, String)> {196+ PROPERTIES.iter().map(|(name, kind)| (*name, kind.describe()))197+}198+199+fn unknown_property(name: &str) -> String {200+ // A near miss is nearly always a typo, or a real Pico variable that is not201+ // offered here, so point at the most specific thing that is.202+ let stem = name.trim_start_matches("--pico-").trim_start_matches("--");203+ let closest = PROPERTIES204+ .iter()205+ .map(|(known, _)| *known)206+ .filter(|known| {207+ let known_stem = known.trim_start_matches("--pico-").trim_start_matches("--");208+ stem.len() >= 4 && (stem.contains(known_stem) || known_stem.contains(stem))209+ })210+ .max_by_key(|known| known.len());211+212+ match closest {213+ Some(known) => format!("`{name}` is not a theme property — did you mean `{known}`?"),214+ None => format!("`{name}` is not a theme property; run `devsite theme properties` for the list"),215+ }216+}217+218+impl Kind {219+ fn accepts(&self, value: &str) -> bool {220+ match self {221+ Kind::Color => is_color(value),222+ Kind::Length => is_length(value),223+ Kind::Number => is_number(value),224+ Kind::Keyword(allowed) => allowed.contains(&value.to_ascii_lowercase().as_str()),225+ }226+ }227+228+ fn describe(&self) -> String {229+ match self {230+ Kind::Color => "a colour, e.g. `#7b3fe4`, `rgb(123 63 228)` or `rebeccapurple`".into(),231+ Kind::Length => "a length, e.g. `0.5rem`, `12px` or `0`".into(),232+ Kind::Number => "a number, e.g. `1.5`".into(),233+ Kind::Keyword(allowed) => format!("one of {}", allowed.join(", ")),234+ }235+ }236+}237+238+// -- value grammars -----------------------------------------------------------239+240+fn is_color(value: &str) -> bool {241+ let value = value.to_ascii_lowercase();242+243+ if value == "transparent" || value == "currentcolor" {244+ return true;245+ }246+ if let Some(hex) = value.strip_prefix('#') {247+ return matches!(hex.len(), 3 | 4 | 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit());248+ }249+ if let Some((function, rest)) = value.split_once('(') {250+ let Some(args) = rest.strip_suffix(')') else {251+ return false;252+ };253+ return matches!(function, "rgb" | "rgba" | "hsl" | "hsla" | "oklch")254+ && !args.trim().is_empty()255+ // No nested functions, no var(), no url(): a flat list of numbers.256+ && args257+ .bytes()258+ .all(|b| b.is_ascii_digit() || b" .,%/+-".contains(&b))259+ && args.split([',', ' ', '/']).filter(|a| !a.is_empty()).count() <= 4;260+ }261+ NAMED_COLORS.binary_search(&value.as_str()).is_ok()262+}263+264+fn is_length(value: &str) -> bool {265+ if value == "0" {266+ return true;267+ }268+ const UNITS: &[&str] = &["px", "rem", "em", "ch", "ex", "vw", "vh", "vmin", "vmax", "%"];269+ UNITS.iter().any(|unit| {270+ value271+ .strip_suffix(unit)272+ .is_some_and(|number| !number.is_empty() && is_number(number))273+ })274+}275+276+/// Non-negative, and no exponents: `1e9rem` is a length, but not a useful one.277+fn is_number(value: &str) -> bool {278+ !value.is_empty()279+ && value.bytes().all(|b| b.is_ascii_digit() || b == b'.')280+ && value.matches('.').count() <= 1281+ && value != "."282+ && value.len() <= 8283+}284+285+fn strip_comments(input: &str) -> Result<String, String> {286+ let mut out = String::with_capacity(input.len());287+ let mut rest = input;288+ while let Some(start) = rest.find("/*") {289+ out.push_str(&rest[..start]);290+ let after = &rest[start + 2..];291+ let end = after.find("*/").ok_or("a comment is not closed")?;292+ // A comment separates tokens, so it cannot simply vanish.293+ out.push(' ');294+ rest = &after[end + 2..];295+ }296+ out.push_str(rest);297+ Ok(out)298+}299+300+fn normalize_whitespace(value: &str) -> String {301+ value.split_whitespace().collect::<Vec<_>>().join(" ")302+}303+304+/// The CSS named colours, sorted for `binary_search`.305+const NAMED_COLORS: &[&str] = &[306+ "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black",307+ "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse",308+ "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan",309+ "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta",310+ "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen",311+ "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink",312+ "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen",313+ "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow",314+ "grey", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender",315+ "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan",316+ "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink", "lightsalmon",317+ "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue",318+ "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine",319+ "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue",320+ "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream",321+ "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange",322+ "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred",323+ "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple",324+ "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell",325+ "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen",326+ "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white",327+ "whitesmoke", "yellow", "yellowgreen",328+];329+330+#[cfg(test)]331+mod tests {332+ use super::*;333+334+ fn css(input: &str) -> Result<String, String> {335+ parse(input).map(|d| to_css(&d))336+ }337+338+ #[test]339+ fn named_colors_are_sorted_for_binary_search() {340+ assert!(NAMED_COLORS.windows(2).all(|w| w[0] < w[1]));341+ }342+343+ #[test]344+ fn accepts_a_plain_theme() {345+ let theme = css("--pico-primary: #7b3fe4;\n--pico-border-radius: 0.5rem;").unwrap();346+ assert_eq!(theme, "--pico-primary: #7b3fe4;\n--pico-border-radius: 0.5rem;\n");347+ }348+349+ #[test]350+ fn accepts_every_documented_color_form() {351+ for value in [352+ "#fff",353+ "#ffff",354+ "#7b3fe4",355+ "#7b3fe4cc",356+ "rgb(123 63 228)",357+ "rgba(123, 63, 228, 0.5)",358+ "hsl(262 76% 57%)",359+ "oklch(62.8% 0.258 29.23)",360+ "rebeccapurple",361+ "transparent",362+ "currentcolor",363+ ] {364+ assert!(365+ parse(&format!("--pico-primary: {value};")).is_ok(),366+ "{value} should be a colour"367+ );368+ }369+ }370+371+ #[test]372+ fn rejects_values_that_only_look_like_colors() {373+ for value in [374+ "url(https://example.com/x.png)",375+ "var(--pico-color)",376+ "rgb(calc(1 + 1) 0 0)",377+ "#12345",378+ "#gggggg",379+ "notacolour",380+ "rgb(1 2 3 4 5)",381+ ] {382+ assert!(383+ parse(&format!("--pico-primary: {value};")).is_err(),384+ "{value} should be refused"385+ );386+ }387+ }388+389+ #[test]390+ fn rejects_anything_that_is_not_a_declaration() {391+ // The whole point of the whitelist: a theme cannot become a stylesheet.392+ for source in [393+ "body { display: none }",394+ "--pico-primary: red } body { position: fixed",395+ "@import url(evil.css);",396+ "--pico-primary: red !important;",397+ "</style><script>alert(1)</script>",398+ "--pico-primary: red; /* unterminated",399+ ] {400+ assert!(parse(source).is_err(), "{source:?} should be refused");401+ }402+ }403+404+ #[test]405+ fn rejects_properties_outside_the_table() {406+ // Real CSS properties are refused as firmly as invented ones: a theme407+ // may only assign the variables the template is built out of.408+ for property in ["position", "display", "--pico-icon-close", "--anything"] {409+ assert!(410+ parse(&format!("{property}: none;")).is_err(),411+ "{property} should be refused"412+ );413+ }414+ }415+416+ #[test]417+ fn suggests_the_nearest_property_on_a_typo() {418+ let error = parse("--pico-primary-color: red;").unwrap_err();419+ assert!(error.contains("--pico-primary"), "unhelpful error: {error}");420+ }421+422+ #[test]423+ fn the_typeface_is_not_negotiable() {424+ assert!(parse("--pico-font-family: Comic Sans MS;").is_err());425+ assert!(parse("--pico-font-weight: 400;").is_ok());426+ assert!(parse("--pico-font-weight: 700;").is_ok());427+ // Open Sans is vendored at exactly two weights; anything else would be428+ // rounded to one of them anyway, so say so rather than pretend.429+ assert!(parse("--pico-font-weight: 600;").is_err());430+ }431+432+ #[test]433+ fn lengths_need_a_unit_and_numbers_must_not_have_one() {434+ assert!(parse("--pico-border-radius: 0;").is_ok());435+ assert!(parse("--pico-border-radius: 0.5rem;").is_ok());436+ assert!(parse("--pico-border-radius: 8;").is_err());437+ assert!(parse("--pico-border-radius: -1rem;").is_err());438+ assert!(parse("--pico-line-height: 1.5;").is_ok());439+ assert!(parse("--pico-line-height: 1.5rem;").is_err());440+ }441+442+ #[test]443+ fn comments_and_spacing_survive_normalisation() {444+ let theme = css("/* my colours */\n --pico-primary : rgb( 1 , 2 , 3 ) ;\n").unwrap();445+ assert_eq!(theme, "--pico-primary: rgb( 1 , 2 , 3 );\n");446+ }447+448+ #[test]449+ fn a_comment_cannot_glue_two_tokens_together() {450+ // `--pico/**/-primary` must not become a valid property name.451+ assert!(parse("--pico/**/-primary: red;").is_err());452+ }453+454+ #[test]455+ fn the_last_assignment_wins_without_reordering() {456+ let theme = css("--pico-primary: red; --pico-color: blue; --pico-primary: green;").unwrap();457+ assert_eq!(theme, "--pico-primary: green;\n--pico-color: blue;\n");458+ }459+460+ #[test]461+ fn validated_output_is_safe_to_inline_in_a_style_element() {462+ // Not a claim about escaping — a claim about the alphabet. Whatever463+ // survives parsing cannot contain a character that ends a style block.464+ let source = "--pico-primary: #abc; --pico-spacing: 1rem; --devsite-scheme: dark;";465+ for declaration in parse(source).unwrap() {466+ assert!(declaration467+ .value468+ .bytes()469+ .all(|b| b.is_ascii_alphanumeric() || b"#%.,/()+- ".contains(&b)));470+ }471+ }472+473+ #[test]474+ fn oversized_input_is_refused_before_parsing() {475+ let huge = "--pico-primary: red;".repeat(MAX_INPUT);476+ assert!(parse(&huge).is_err());477+ }478+479+ #[test]480+ fn an_empty_theme_is_valid_and_empty() {481+ assert_eq!(parse("").unwrap(), vec![]);482+ assert_eq!(parse(" \n /* nothing */ ;;; ").unwrap(), vec![]);483+ }484+}
M docs/milestones.md +39 -6
@@ -104,9 +104,42 @@ daemon has no effect until it restarts. It fails safe — the control plane repo
104104 resource offline rather than issuing capabilities for something unreachable — but config105105 hot-reload is the obvious fix.106106 107-## Next — UI108-109-The data plane and authorization model are done and proven. Remaining work is the profile110-page itself: the MySpace-style user CSS the `profiles.custom_css` column is reserved for,111-and the surfaces that currently have none — claiming a handle, managing exposures, and112-seeing who a service is shared with.107+## M7 — The profile page ✅108+109+The first UI was a bespoke stylesheet: custom type, hand-drawn leaders, colour used as110+meaning. It has been removed. The page is now semantic HTML styled by Pico CSS 2.1.1, set111+in Open Sans at 400 and 700, both vendored so nothing is fetched from a third party.112+113+The reason is not taste. `profiles.custom_css` was reserved for user styling from the114+start, and user styling is only safe if "is this valid?" has a mechanical answer. Against115+a bespoke stylesheet it does not: the answer depends on which class the author happened to116+name and what it happened to do. Against Pico it does — the whole page is drawn from117+`--pico-*` variables, so a theme can be a list of assignments to named variables with118+per-property value grammars, checked in `theme.rs` and stored canonically.119+120+What follows from that:121+122+- **A theme has no selectors.** It cannot position, hide or overlay anything, and cannot123+ make one profile impersonate another part of the site. It recolours and re-spaces the124+ template; that is the whole of its power.125+- **The theme block is the last stylesheet in the document**, so it wins ties on document126+ order. No user declaration has to out-specify Pico, which is what a selector-based127+ design would have forced.128+- **The alphabet is the injection defence.** Every accepted value is `[0-9a-z#%.,/()+- ]`,129+ so it cannot carry `<`, `"` or `}`. The rendered rule is safe to inline in `<style>` by130+ construction rather than by remembering to escape it.131+- **The whitelist is checked against the vendored Pico.** A variable Pico does not define132+ would be accepted, stored, and silently do nothing — a bug with no error message.133+- **Two weights, by construction.** Open Sans is a variable font here, declared twice at134+ 400 and 700 rather than as a range, so Pico's occasional request for 600 resolves to 700135+ instead of rendering an in-between instance.136+137+`GET /api/theme/properties` serves the list from the binary that enforces it, so the138+website, `devsite theme properties` and the docs cannot drift from what is accepted.139+140+See `docs/profile-template.md`.141+142+## Next143+144+Surfaces that still have none: managing exposures from the website, and seeing who a145+service is shared with.
A docs/profile-template.md +164 -0
@@ -0,0 +1,164 @@
1+# The profile template2+3+A dev.site profile is one fixed piece of semantic HTML styled by [Pico CSS] and4+nothing else. Personalisation happens by assigning Pico's own variables, not by5+writing rules — which is what makes "is this theme valid?" a question the server6+can answer mechanically, in `crates/devsite-server/src/theme.rs`.7+8+[Pico CSS]: https://picocss.com9+10+## What the page is made of11+12+| Layer | File | Role |13+| --- | --- | --- |14+| Framework | `web/vendor/pico.min.css` | Pico 2.1.1, vendored. Styles semantic elements directly. |15+| Site | `web/site.css` | The typeface, and the few layout rules Pico has no opinion about. No colours. |16+| Theme | `<style id="profile-theme">` | One rule, written at render time, holding only `--pico-*` assignments. |17+18+They load in that order, so a theme wins ties on document order alone. No user19+declaration ever has to out-specify anything, and none of them can, because a20+theme contains no selectors.21+22+**Type is fixed.** Open Sans, vendored at `web/vendor/fonts/`, at 400 and 700 and23+no other weight. The file is a variable font, so it is declared twice at those two24+weights rather than as a range: a request for 600 — which Pico makes in a couple25+of places — resolves to 700 instead of rendering an in-between instance. There is26+no `--pico-font-family` in the whitelist.27+28+## The template29+30+Rendered by `web/app.js`. A theme is only meaningful against this structure, so31+changes here and changes to the whitelist belong in the same commit.32+33+```html34+<body>35+ <header class="container">36+ <nav>…wordmark…<ul id="session">…</ul></nav>37+ </header>38+39+ <main class="container" id="main">40+ <article id="profile">41+ <hgroup>42+ <h1>@dami</h1>43+ <p>2 services · 1 link</p>44+ </hgroup>45+46+ <!-- one section per visibility, omitted when empty -->47+ <section class="group" data-visibility="public">48+ <h2>Public</h2>49+ <ul class="entries">50+ <!-- a link goes somewhere else, so it is an anchor -->51+ <li class="entry" data-kind="link">52+ <a href="https://klot.ski" target="_blank" rel="noopener noreferrer">Klot Ski</a>53+ <small class="state">klot.ski ↗</small>54+ </li>55+ <!-- a service opens here, so it is a button -->56+ <li class="entry" data-kind="service" data-state="online">57+ <button class="outline">Hermes</button>58+ <small class="state">online</small>59+ </li>60+ </ul>61+ </section>62+ <!-- data-visibility is one of: public · private · shared · shared-with-me -->63+ </article>64+65+ <!-- owner only -->66+ <details class="appearance">…theme editor…</details>67+ </main>68+69+ <dialog id="viewer">…the service, in a sandboxed iframe…</dialog>70+</body>71+```72+73+Reachability is carried by `data-state` on the entry, and drawn with Pico's own74+`--pico-ins-color` and `--pico-del-color` — so a theme that restyles inserted and75+deleted text restyles online and offline with it, rather than leaving one green76+smear behind.77+78+## What a theme may set79+80+The list lives in `PROPERTIES` in `theme.rs` and is served at81+`GET /api/theme/properties`, which is also what `devsite theme properties`82+prints. Every name in it is a variable the vendored Pico actually defines; a name83+Pico does not define would be accepted, stored, and quietly do nothing, which is84+the failure mode the list exists to prevent.85+86+| Group | Properties | Value |87+| --- | --- | --- |88+| Scheme | `--devsite-scheme` | `light`, `dark` or `auto` |89+| Surfaces | `--pico-background-color`, `--pico-color`, `--pico-muted-color`, `--pico-muted-border-color`, `--pico-border-color`, `--pico-text-selection-color` | colour |90+| Accents | `--pico-primary` and its `-background`, `-hover`, `-hover-background`, `-inverse`, `-underline`, `-focus`; `--pico-secondary` and its `-background`, `-hover`, `-inverse`; `--pico-contrast` and its `-background`, `-inverse` | colour |91+| Headings | `--pico-h1-color` … `--pico-h6-color` | colour |92+| Blocks | `--pico-card-background-color`, `--pico-card-border-color`, `--pico-card-sectioning-background-color`, `--pico-code-background-color`, `--pico-code-color`, `--pico-mark-background-color`, `--pico-mark-color`, `--pico-blockquote-border-color`, `--pico-ins-color`, `--pico-del-color` | colour |93+| Form elements | `--pico-form-element-background-color`, `--pico-form-element-border-color`, `--pico-form-element-color` | colour |94+| Metrics | `--pico-border-radius`, `--pico-border-width`, `--pico-outline-width`, `--pico-spacing`, `--pico-block-spacing-vertical`, `--pico-block-spacing-horizontal`, `--pico-typography-spacing-vertical`, `--pico-form-element-spacing-vertical`, `--pico-form-element-spacing-horizontal`, `--pico-nav-element-spacing-vertical`, `--pico-nav-element-spacing-horizontal`, `--pico-text-underline-offset`, `--pico-font-size` | length |95+| Type | `--pico-line-height` | number |96+| | `--pico-font-weight` | `400` or `700` |97+| | `--pico-text-decoration` | `none` or `underline` |98+99+`--devsite-scheme` is the one key that is not a Pico variable. It sets100+`data-theme` on the page, which is how Pico chooses between its own light and101+dark palettes; the rest of the theme then adjusts that starting point.102+103+### Value grammars104+105+| Kind | Accepts |106+| --- | --- |107+| colour | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`; `rgb()`, `rgba()`, `hsl()`, `hsla()`, `oklch()` over plain numbers; a CSS colour name; `transparent`; `currentcolor` |108+| length | a non-negative number with `px`, `rem`, `em`, `ch`, `ex`, `vw`, `vh`, `vmin`, `vmax` or `%`, or bare `0` |109+| number | a non-negative unitless number |110+111+`var()`, `calc()` and `url()` are refused everywhere. Every accepted value is112+drawn from the alphabet `[0-9a-z#%.,/()+- ]`, so it cannot carry `<`, `"` or `}`113+— the rendered rule is safe to inline in a `<style>` element as a property of the114+grammar, not of an escaping step someone has to remember.115+116+## What a theme cannot do117+118+Refused outright, with a message naming the reason:119+120+- selectors, braces, at-rules, and `!important`121+- ordinary CSS properties — `position`, `display`, `content` and the rest122+- Pico variables outside the list, including `--pico-font-family` and the123+ `--pico-icon-*` data URIs124+125+So a theme can recolour and re-space the template, and that is all. It cannot126+position anything, hide anything, overlay anything, or load anything. A profile127+you visit cannot use its theme to disguise itself as another part of the site.128+129+## Setting one130+131+From the website, on your own profile, under **Appearance**. Or:132+133+```bash134+devsite theme properties # what you may set135+devsite theme show136+devsite theme set my-theme.css # or `-` for stdin137+devsite theme clear138+```139+140+```css141+/* my-theme.css */142+--devsite-scheme: dark;143+--pico-primary: #7b3fe4;144+--pico-primary-hover: #8f5bea;145+--pico-border-radius: 0.5rem;146+--pico-font-weight: 400;147+```148+149+Rejections are specific and name the offending declaration:150+151+```152+--pico-primary: wine; → `--pico-primary: wine` — expected a colour, e.g. `#7b3fe4`, …153+--pico-primary-color: → `--pico-primary-color` is not a theme property — did you mean `--pico-primary`?154+body { color: red } → `{` is not allowed: a theme is a list of `--pico-…: value;` declarations155+```156+157+## Storage158+159+Validated once, on write, by `PUT /api/theme`, and stored in160+`profiles.custom_css` as canonical text — one `property: value;` per line. Every161+later read treats the column as already-checked rather than re-deciding what is162+safe. It is re-parsed on render anyway, so that a property retired from the163+whitelist degrades a profile to the defaults instead of serving a rule the164+current build no longer stands behind.
M web/app.js +290 -137
@@ -1,4 +1,9 @@
11 // dev.site — profile rendering, Shoo sign-in, and opening a local service over Iroh.2+//3+// The markup this file produces is a contract, not an implementation detail: a4+// profile's theme is a list of --pico-* assignments that only mean anything5+// against the elements below. Keep it semantic, keep it documented, and change6+// it in step with docs/profile-template.md.27 38 const SHOO = 'https://shoo.dev';49 const $ = (id) => document.getElementById(id);
@@ -106,161 +111,287 @@ async function completeSignIn() {
106111 return { session, returnTo };107112 }108113 114+// -- themes --------------------------------------------------------------------115+116+/// Apply a profile's theme.117+///118+/// The server has already checked every property against its whitelist and every119+/// value against a grammar, so this is only assembly: one rule, scoped to the120+/// profile, holding nothing but custom-property assignments. It is the last121+/// stylesheet in the document, so equal specificity is enough — no user rule122+/// ever has to out-rank Pico.123+function applyTheme(handle, declarations = []) {124+ const root = document.documentElement;125+ // Belt and braces: handles are already restricted to this alphabet server-side.126+ const scope = String(handle).replace(/[^A-Za-z0-9_-]/g, '');127+ root.dataset.profile = scope;128+129+ // The one key that is not a Pico variable — it picks which of Pico's own130+ // palettes the profile starts from.131+ const scheme = declarations.find((d) => d.property === '--devsite-scheme')?.value;132+ if (scheme === 'light' || scheme === 'dark') {133+ root.dataset.theme = scheme;134+ } else {135+ delete root.dataset.theme;136+ }137+138+ const body = declarations139+ .filter((d) => d.property !== '--devsite-scheme')140+ .map((d) => ` ${d.property}: ${d.value};`)141+ .join('\n');142+ $('profile-theme').textContent = body143+ ? `[data-profile="${scope}"] {\n${body}\n}\n`144+ : '';145+}146+147+function clearTheme() {148+ delete document.documentElement.dataset.profile;149+ delete document.documentElement.dataset.theme;150+ $('profile-theme').textContent = '';151+}152+109153 // -- rendering -----------------------------------------------------------------110154 111155 function renderSession() {112156 const session = $('session');113157 if (!me) {114- session.innerHTML = '<a id="signin">sign in</a>';115- $('signin').addEventListener('click', startSignIn);158+ session.innerHTML = '<li><a href="#" id="signin" role="button" class="outline">Sign in</a></li>';159+ $('signin').addEventListener('click', (e) => { e.preventDefault(); startSignIn(); });116160 return;117161 }118- const where = me.handle ? `<a href="/@${esc(me.handle)}">@${esc(me.handle)}</a>` : 'no handle yet';119- session.innerHTML = `${where}`;162+ session.innerHTML = me.handle163+ ? `<li><a href="/@${esc(me.handle)}">@${esc(me.handle)}</a></li>`164+ : '<li><small>no handle yet</small></li>';120165 }121166 122-function entryRow({ name, state, className, accentClass, href, onClick, disabled }) {123- const tag = href ? 'a' : 'button';124- const el = document.createElement(tag);125- el.className = `entry ${accentClass} ${className || ''}`.trim();126- if (href) { el.href = href; el.target = '_blank'; el.rel = 'noopener noreferrer'; }127- if (disabled) el.disabled = true;128- el.innerHTML = `129- <span class="name">${name}</span>130- <span class="leader"></span>131- <span class="state">${state}</span>`;132- if (onClick && !disabled) el.addEventListener('click', onClick);133- return el;134-}135-136-function group(title, kind, rows) {137- const section = document.createElement('section');138- section.className = `group ${kind}`;139- const head = document.createElement('div');140- head.className = 'group-head';141- head.innerHTML = `<h2>${esc(title)}</h2><span class="rule"></span>`;142- section.append(head);143- if (rows.length === 0) {144- const empty = document.createElement('p');145- empty.className = 'empty';146- empty.textContent = 'nothing here';147- section.append(empty);167+/// One row of a profile: a name on the left, its state on the right.168+///169+/// Links are anchors and services are buttons, because a link goes somewhere and170+/// a service opens here. Nothing about that changes with the theme.171+function entry({ kind, name, state, online, href, onClick }) {172+ const li = document.createElement('li');173+ li.className = 'entry';174+ li.dataset.kind = kind;175+ if (online !== undefined) li.dataset.state = online ? 'online' : 'offline';176+177+ if (href) {178+ li.innerHTML =179+ `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${name}</a>` +180+ `<small class="state">${esc(state)}</small>`;148181 } else {149- rows.forEach((row) => section.append(row));182+ li.innerHTML =183+ `<button class="outline${online ? '' : ' secondary'}"${online ? '' : ' disabled'}>${name}</button>` +184+ `<small class="state">${esc(state)}</small>`;185+ if (online && onClick) li.querySelector('button').addEventListener('click', onClick);150186 }151- return section;187+ return li;152188 }153189 154-function serviceState(entry) {155- return entry.online ? '<em>online</em>' : '<em>offline</em>';190+function group(title, visibility, rows) {191+ const section = document.createElement('section');192+ section.className = 'group';193+ section.dataset.visibility = visibility;194+ section.innerHTML = `<h2>${esc(title)}</h2>`;195+196+ const list = document.createElement('ul');197+ list.className = 'entries';198+ rows.forEach((row) => list.append(row));199+ section.append(list);200+ return section;156201 }157202 158203 function renderProfile(profile) {204+ applyTheme(profile.handle, profile.theme);159205 main.innerHTML = '';160206 161- const identity = document.createElement('div');162- identity.className = 'identity';163- identity.innerHTML = `164- <h1 class="handle"><span class="at">@</span>${esc(profile.handle)}</h1>165- <p class="sub">${profile.is_owner ? 'your profile' : 'profile'}</p>`;166- main.append(identity);207+ const article = document.createElement('article');208+ article.id = 'profile';209+210+ const counts = [211+ profile.entries.filter((e) => e.kind === 'service').length,212+ profile.entries.filter((e) => e.kind === 'link').length,213+ ];214+ const summary = [215+ counts[0] === 1 ? '1 service' : `${counts[0]} services`,216+ counts[1] === 1 ? '1 link' : `${counts[1]} links`,217+ ].join(' · ');218+219+ const heading = document.createElement('hgroup');220+ heading.innerHTML = `<h1>@${esc(profile.handle)}</h1><p>${summary}</p>`;221+ article.append(heading);167222 168223 const buckets = { public: [], private: [], shared: [] };169- for (const entry of profile.entries) {170- const accent = `is-${entry.visibility}`;171- if (entry.kind === 'link') {172- buckets.public.push(entryRow({173- name: esc(entry.name),174- state: `${esc(new URL(entry.url).host)} ↗`,175- accentClass: accent,176- href: entry.url,224+ for (const item of profile.entries) {225+ if (item.kind === 'link') {226+ buckets.public.push(entry({227+ kind: 'link',228+ name: esc(item.name),229+ state: `${new URL(item.url).host} ↗`,230+ href: item.url,177231 }));178232 } else {179- buckets[entry.visibility].push(entryRow({180- name: esc(entry.name),181- state: serviceState(entry),182- accentClass: accent,183- className: entry.online ? 'online' : 'offline',184- disabled: !entry.online,185- onClick: () => openService(entry),233+ buckets[item.visibility].push(entry({234+ kind: 'service',235+ name: esc(item.name),236+ state: item.online ? 'online' : 'offline',237+ online: item.online,238+ onClick: () => openService(item),186239 }));187240 }188241 }189242 190- if (buckets.public.length) main.append(group('public', 'public', buckets.public));191- if (buckets.private.length) main.append(group('private', 'private', buckets.private));192- if (buckets.shared.length) main.append(group('shared', 'shared', buckets.shared));243+ const titles = { public: 'Public', private: 'Private', shared: 'Shared' };244+ for (const visibility of ['public', 'private', 'shared']) {245+ if (buckets[visibility].length) {246+ article.append(group(titles[visibility], visibility, buckets[visibility]));247+ }248+ }193249 194250 if (profile.shared_with_me.length) {195- const rows = profile.shared_with_me.map((entry) => entryRow({196- name: `<span class="owner">@${esc(entry.owner_handle)}’s</span> ${esc(entry.name)}`,197- state: serviceState(entry),198- accentClass: 'is-shared',199- className: entry.online ? 'online' : 'offline',200- disabled: !entry.online,201- onClick: () => openService(entry),251+ const rows = profile.shared_with_me.map((item) => entry({252+ kind: 'service',253+ name: `${esc(item.name)} <small>from @${esc(item.owner_handle)}</small>`,254+ state: item.online ? 'online' : 'offline',255+ online: item.online,256+ onClick: () => openService(item),202257 }));203- main.append(group('shared with me', 'shared', rows));258+ article.append(group('Shared with me', 'shared-with-me', rows));204259 }205260 206- if (!main.querySelector('.entry')) {261+ if (!article.querySelector('.entry')) {207262 const empty = document.createElement('p');208- empty.className = 'empty';209- empty.textContent = 'nothing published yet';210- main.append(empty);263+ empty.innerHTML = '<small>Nothing published yet.</small>';264+ article.append(empty);211265 }266+267+ main.append(article);268+ if (profile.is_owner) main.append(appearanceEditor(profile));269+}270+271+/// The theme editor, on your own profile only.272+///273+/// It deliberately shows the stored text rather than a set of colour pickers:274+/// the thing being edited really is CSS, and the server's refusals are specific275+/// enough to be worth reading.276+function appearanceEditor(profile) {277+ const details = document.createElement('details');278+ details.className = 'appearance';279+ details.innerHTML = `280+ <summary>Appearance</summary>281+ <form>282+ <label for="theme-css">283+ Pico variables, as declarations. Everything else is refused.284+ <textarea id="theme-css" name="css" rows="8" spellcheck="false"285+ placeholder="--devsite-scheme: dark;&#10;--pico-primary: #7b3fe4;&#10;--pico-border-radius: 0.5rem;"></textarea>286+ </label>287+ <small id="theme-note">288+ <a href="/api/theme/properties" target="_blank" rel="noopener">What you may set</a>289+ </small>290+ <fieldset role="group">291+ <button type="submit">Save</button>292+ <button type="button" class="secondary outline" id="theme-clear">Clear</button>293+ </fieldset>294+ </form>`;295+296+ const form = details.querySelector('form');297+ const field = details.querySelector('#theme-css');298+ const note = details.querySelector('#theme-note');299+ field.value = profile.theme300+ .map((d) => `${d.property}: ${d.value};`)301+ .join('\n');302+303+ const save = async (css) => {304+ try {305+ const saved = await api('/api/theme', { method: 'PUT', body: JSON.stringify({ css }) });306+ field.value = saved.css.trimEnd();307+ field.removeAttribute('aria-invalid');308+ note.innerHTML = '<span class="ok">Saved.</span>';309+ applyTheme(profile.handle, saved.theme);310+ } catch (err) {311+ field.setAttribute('aria-invalid', 'true');312+ note.innerHTML = `<span class="error">${esc(err.message)}</span>`;313+ }314+ };315+316+ form.addEventListener('submit', (e) => { e.preventDefault(); save(field.value); });317+ details.querySelector('#theme-clear').addEventListener('click', () => save(''));318+ return details;212319 }213320 214321 // -- opening a service ---------------------------------------------------------215322 216323 const HOPS = ['this browser', 'iroh relay', 'the daemon', 'the service'];217324 218-function paintPath(reached) {219- $('path').innerHTML = HOPS.map((hop, i) => {220- const cls = i < reached ? 'done' : i === reached ? 'live' : '';221- const sep = i < HOPS.length - 1 ? '<span class="sep">→</span>' : '';222- return `<span class="hop ${cls}">${hop}</span>${sep}`;223- }).join(' ');325+function paintHops(reached) {326+ $('viewer-hops').innerHTML = HOPS.map((hop, i) => {327+ const state = i < reached ? 'yes' : i === reached ? 'now' : 'no';328+ const arrow = i < HOPS.length - 1 ? ' →' : '';329+ return `<li data-reached="${state}"><small>${hop}${arrow}</small></li>`;330+ }).join('');331+}332+333+/// Put the fetched page on screen, in an iframe built for this one service.334+///335+/// Building it here rather than reusing one from the markup is not tidiness: an336+/// iframe that is `display:none` when its `srcdoc` is first assigned never337+/// renders that document, and no later assignment recovers it. A fresh element338+/// also means each service starts in a brand-new opaque-origin document, and339+/// closing the viewer destroys it rather than blanking it.340+function mountFrame(html) {341+ const frame = document.createElement('iframe');342+ // allow-scripts WITHOUT allow-same-origin: the fetched page gets an opaque343+ // origin, so it can run its own code but cannot reach dev.site's DOM, storage344+ // or cookies.345+ frame.setAttribute('sandbox', 'allow-scripts');346+ frame.title = 'Local service';347+ frame.srcdoc = html;348+ $('viewer-body').replaceChildren(frame);349+}350+351+/// Pico's modal convention: the `open` attribute on the dialog, and a lock class352+/// on the document while it is up.353+function openViewer() {354+ document.documentElement.classList.add('modal-is-open', 'modal-is-opening');355+ $('viewer').setAttribute('open', '');356+ setTimeout(() => document.documentElement.classList.remove('modal-is-opening'), 400);224357 }225358 226359 function closeViewer() {227- const viewer = $('viewer');228- viewer.classList.remove('open');229- viewer.hidden = true;230- $('frame').srcdoc = '';360+ document.documentElement.classList.remove('modal-is-open', 'modal-is-opening');361+ $('viewer').removeAttribute('open');362+ $('viewer-body').replaceChildren();231363 }232364 233-async function openService(entry) {234- const viewer = $('viewer');365+async function openService(item) {235366 const status = $('viewer-status');236367 237- viewer.hidden = false;238- viewer.classList.add('open');239- $('viewer-title').innerHTML = entry.owner_handle240- ? `<span class="of">@${esc(entry.owner_handle)}’s</span> ${esc(entry.name)}`241- : esc(entry.name);242- $('frame').srcdoc = '';243- status.className = 'viewer-status';244- status.textContent = 'creating a browser endpoint';245- paintPath(0);368+ $('viewer-title').textContent = item.owner_handle369+ ? `${item.name} — @${item.owner_handle}`370+ : item.name;371+ $('viewer-body').replaceChildren();372+ status.removeAttribute('aria-invalid');373+ status.setAttribute('aria-busy', 'true');374+ status.textContent = 'Creating a browser endpoint';375+ paintHops(0);376+ openViewer();246377 247378 try {248379 if (!endpoint) endpoint = await BrowserEndpoint.create();249- paintPath(1);380+ paintHops(1);250381 251- status.textContent = 'requesting a capability';382+ status.textContent = 'Requesting a capability';252383 // The capability is bound to this endpoint's key; the daemon checks that binding253384 // against the authenticated peer of the connection.254385 const grant = await api('/api/capability', {255386 method: 'POST',256387 body: JSON.stringify({257- resource_id: entry.resource_id,388+ resource_id: item.resource_id,258389 browser_endpoint_id: endpoint.endpointId,259390 }),260391 });261392 262- paintPath(2);263- status.textContent = 'connecting through the relay';393+ paintHops(2);394+ status.textContent = 'Connecting through the relay';264395 const html = await endpoint.fetchPage(265396 grant.daemon_endpoint_id,266397 grant.relay_url,
@@ -268,11 +399,13 @@ async function openService(entry) {
268399 '/',269400 );270401 271- paintPath(3);272- $('frame').srcdoc = html;273- status.classList.add('hidden');402+ paintHops(3);403+ mountFrame(html);404+ status.removeAttribute('aria-busy');405+ status.textContent = '';274406 } catch (err) {275- status.className = 'viewer-status err';407+ status.removeAttribute('aria-busy');408+ status.setAttribute('aria-invalid', 'true');276409 status.textContent = String(err.message || err);277410 }278411 }
@@ -280,55 +413,64 @@ async function openService(entry) {
280413 // -- setup views ---------------------------------------------------------------281414 282415 function renderSignedOut() {416+ clearTheme();283417 main.innerHTML = `284- <div class="block">285- <h1>Your public work and your <em>private</em> local services, on one page.</h1>286- <p>287- Private services stay on your machine. Nothing is deployed, no port is opened, and288- the page you are reading never carries their traffic.289- </p>290- <button class="action" id="start">sign in</button>291- </div>`;418+ <article>419+ <hgroup>420+ <h1>Your public work and your private local services, on one page.</h1>421+ <p>422+ Private services stay on your machine. Nothing is deployed, no port is opened,423+ and the page you are reading never carries their traffic.424+ </p>425+ </hgroup>426+ <button id="start">Sign in</button>427+ </article>`;292428 $('start').addEventListener('click', startSignIn);293429 }294430 295431 function renderClaimHandle() {432+ clearTheme();296433 main.innerHTML = `297- <div class="block">298- <h1>Choose a <em>handle</em>.</h1>299- <p>It becomes the address of your profile. Letters, digits, hyphens and underscores.</p>300- <div class="field">301- <input id="handle" placeholder="dami" autocomplete="off" spellcheck="false">302- <button class="action" id="claim">claim</button>303- </div>304- <p class="error" id="handle-error" hidden></p>305- </div>`;306-307- const submit = async () => {308- const error = $('handle-error');309- error.hidden = true;434+ <article>435+ <hgroup>436+ <h1>Choose a handle.</h1>437+ <p>It becomes the address of your profile. Letters, digits, hyphens and underscores.</p>438+ </hgroup>439+ <form id="claim-form">440+ <fieldset role="group">441+ <input id="handle" name="handle" placeholder="dami" autocomplete="off" spellcheck="false"442+ aria-label="Handle" required>443+ <button type="submit">Claim</button>444+ </fieldset>445+ <small id="claim-note"></small>446+ </form>447+ </article>`;448+449+ $('claim-form').addEventListener('submit', async (e) => {450+ e.preventDefault();451+ const field = $('handle');452+ const note = $('claim-note');310453 try {311454 const result = await api('/api/profile', {312455 method: 'POST',313- body: JSON.stringify({ handle: $('handle').value }),456+ body: JSON.stringify({ handle: field.value }),314457 });315458 location.assign(`/@${result.handle}`);316459 } catch (err) {317- error.textContent = err.message;318- error.hidden = false;460+ field.setAttribute('aria-invalid', 'true');461+ note.innerHTML = `<span class="error">${esc(err.message)}</span>`;319462 }320- };321- $('claim').addEventListener('click', submit);322- $('handle').addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });463+ });323464 }324465 466+/// Shown once, immediately after signing in, and never persisted.325467 function renderCliToken(token) {326- const block = document.createElement('div');327- block.className = 'token-box';328- block.innerHTML = `329- <p>To configure this machine, run <code style="display:inline;color:var(--ink-dim)">devsite login</code> and paste:</p>330- <code>${esc(token)}</code>`;331- main.querySelector('.block')?.append(block);468+ const article = document.createElement('article');469+ article.innerHTML = `470+ <header><strong>Configure this machine</strong></header>471+ <p>Run <code>devsite login</code> and paste:</p>472+ <pre><code class="token">${esc(token)}</code></pre>`;473+ main.append(article);332474 }333475 334476 // -- routing -------------------------------------------------------------------
@@ -337,11 +479,10 @@ async function route() {
337479 const path = location.pathname;338480 339481 if (path === '/auth/callback') {340- main.innerHTML = '<div class="block"><h1>Signing you in…</h1></div>';482+ main.innerHTML = '<article aria-busy="true">Signing you in…</article>';341483 try {342484 const { session, returnTo } = await completeSignIn();343485 me = { account_id: session.account_id, handle: session.handle };344- // Keep the CLI token in memory only for this render; it is not persisted anywhere.345486 history.replaceState({}, '', session.handle ? `/@${session.handle}` : '/');346487 renderSession();347488 if (!session.handle) {
@@ -352,7 +493,11 @@ async function route() {
352493 renderCliToken(session.token);353494 void returnTo;354495 } catch (err) {355- main.innerHTML = `<div class="block"><h1>Sign-in failed.</h1><p class="error">${esc(err.message)}</p><button class="action" id="retry">try again</button></div>`;496+ main.innerHTML = `497+ <article>498+ <hgroup><h1>Sign-in failed.</h1><p class="error">${esc(err.message)}</p></hgroup>499+ <button id="retry">Try again</button>500+ </article>`;356501 $('retry').addEventListener('click', startSignIn);357502 }358503 return;
@@ -380,7 +525,14 @@ async function showProfile(handle) {
380525 try {381526 renderProfile(await api(`/api/profile/${encodeURIComponent(handle)}`));382527 } catch (err) {383- main.innerHTML = `<div class="block"><h1>No such profile.</h1><p>@${esc(handle)} ${esc(err.message === '404' ? 'does not exist' : err.message)}</p></div>`;528+ clearTheme();529+ main.innerHTML = `530+ <article>531+ <hgroup>532+ <h1>No such profile.</h1>533+ <p>@${esc(handle)} ${esc(err.message === '404' ? 'does not exist' : err.message)}</p>534+ </hgroup>535+ </article>`;384536 }385537 }386538
@@ -403,4 +555,5 @@ $('viewer').addEventListener('click', (e) => { if (e.target === $('viewer')) clo
403555 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeViewer(); });404556 405557 BrowserEndpoint = await loadEndpointModule();558+main.removeAttribute('aria-busy');406559 await route();
M web/index.html +29 -25
@@ -4,34 +4,38 @@
44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width, initial-scale=1">66 <title>dev.site</title>7-<link rel="preconnect" href="https://fonts.googleapis.com">8-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>9-<link rel="stylesheet" href="/styles.css">7+<!-- Pico first, this site's few additions second, and the profile's own theme8+ last. The theme block only ever assigns --pico-* variables, so landing last9+ is all it needs to win: no user rule ever has to out-specify anything. -->10+<link rel="stylesheet" href="/vendor/pico.min.css">11+<link rel="stylesheet" href="/site.css">12+<style id="profile-theme"></style>1013 </head>1114 <body>12-<div class="shell">13- <header class="masthead">14- <a class="wordmark" href="/">dev<span>.site</span></a>15- <div class="session" id="session"></div>16- </header>17- <main id="main"></main>18-</div>1915 20-<div class="viewer" id="viewer" hidden>21- <div class="viewer-panel">22- <div class="viewer-bar">23- <div class="viewer-title" id="viewer-title"></div>24- <button class="close" id="viewer-close" aria-label="Close">✕</button>25- </div>26- <p class="path" id="path"></p>27- <div class="viewer-body">28- <!-- allow-scripts WITHOUT allow-same-origin: the fetched page gets an opaque origin,29- so it can run its own code but cannot reach dev.site's DOM, storage or cookies. -->30- <iframe id="frame" sandbox="allow-scripts" title="Local service"></iframe>31- <div class="viewer-status" id="viewer-status">connecting</div>32- </div>33- </div>34-</div>16+<header class="container">17+ <nav>18+ <ul>19+ <li><a href="/" class="contrast"><strong>dev.site</strong></a></li>20+ </ul>21+ <ul id="session"></ul>22+ </nav>23+</header>24+25+<main class="container" id="main" aria-busy="true"></main>26+27+<dialog id="viewer">28+ <article>29+ <header>30+ <button aria-label="Close" rel="prev" id="viewer-close"></button>31+ <strong id="viewer-title"></strong>32+ </header>33+ <ul class="hops" id="viewer-hops"></ul>34+ <!-- The iframe is built in app.js, once per service, and removed on close. -->35+ <div id="viewer-body"></div>36+ <p id="viewer-status"></p>37+ </article>38+</dialog>3539 3640 <script type="module" src="/app.js"></script>3741 </body>
A web/site.css +156 -0
@@ -0,0 +1,156 @@
1+/* dev.site — everything that is not Pico.2+ *3+ * Pico 2 does the work. This file supplies the typeface, the handful of layout4+ * rules Pico has no opinion about, and nothing else. Anything added here has to5+ * earn its place against a Pico element or class that would have done the job.6+ *7+ * Colours are deliberately absent: every colour on the page comes from a8+ * --pico-* variable, which is what makes a user theme a small, checkable list9+ * of variable assignments rather than a stylesheet. See docs/profile-template.md.10+ *11+ * The one literal colour is the white behind the viewer's iframe. A fetched page12+ * is not part of the profile — it gets the white a browser tab would have given13+ * it, rather than showing a stranger's theme through its own background.14+ */15+16+/* Open Sans, vendored. The file is a variable font covering 300–800, but it is17+ * declared twice at fixed weights instead of as a range, so that a request for18+ * any other weight — Pico asks for 600 in places — snaps to 700 rather than19+ * rendering an in-between instance. Two weights, by construction. */20+@font-face {21+ font-family: 'Open Sans';22+ src: url('/vendor/fonts/open-sans-latin.woff2') format('woff2');23+ font-weight: 400;24+ font-style: normal;25+ font-display: swap;26+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;27+}28+@font-face {29+ font-family: 'Open Sans';30+ src: url('/vendor/fonts/open-sans-latin.woff2') format('woff2');31+ font-weight: 700;32+ font-style: normal;33+ font-display: swap;34+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;35+}36+@font-face {37+ font-family: 'Open Sans';38+ src: url('/vendor/fonts/open-sans-latin-ext.woff2') format('woff2');39+ font-weight: 400;40+ font-style: normal;41+ font-display: swap;42+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;43+}44+@font-face {45+ font-family: 'Open Sans';46+ src: url('/vendor/fonts/open-sans-latin-ext.woff2') format('woff2');47+ font-weight: 700;48+ font-style: normal;49+ font-display: swap;50+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;51+}52+53+:root {54+ --pico-font-family-sans-serif: 'Open Sans', system-ui, sans-serif;55+ --pico-font-family: var(--pico-font-family-sans-serif);56+}57+58+/* -- layout Pico has no opinion about ------------------------------------- */59+60+body > main {61+ padding-block: var(--pico-block-spacing-vertical);62+}63+64+/* A group heading labels a list; it is not a heading in a document. Still an65+ * h2 — the outline should read public / private / shared — but sized and66+ * coloured as the label it is, so the profile name stays the loudest thing. */67+.group > h2 {68+ margin-bottom: calc(var(--pico-typography-spacing-vertical) * 0.5);69+ color: var(--pico-muted-color);70+ font-size: 1rem;71+}72+.group + .group {73+ margin-top: var(--pico-block-spacing-vertical);74+}75+76+/* A list of things on a profile: name on the left, state on the right.77+ * `list-style` is set on the items, not the list: Pico styles `ul li` directly,78+ * and a directly-matching rule beats an inherited one however specific. */79+.entries {80+ margin: 0;81+ padding: 0;82+}83+.entries > li {84+ list-style: none;85+ display: flex;86+ gap: var(--pico-spacing);87+ align-items: center;88+ justify-content: space-between;89+ padding-block: calc(var(--pico-spacing) * 0.5);90+ border-bottom: var(--pico-border-width) solid var(--pico-muted-border-color);91+}92+.entries > li:last-child {93+ border-bottom: 0;94+}95+/* Entry actions are buttons and links; neither should carry a block margin here. */96+.entries > li > :is(a, button, [role='button']) {97+ margin-bottom: 0;98+}99+.entries .state {100+ white-space: nowrap;101+}102+103+/* Reachability reuses Pico's own semantic colours, so a user theme that changes104+ * --pico-ins-color changes this too. */105+.entries > li[data-state='online'] .state {106+ color: var(--pico-ins-color);107+}108+.entries > li[data-state='offline'] .state {109+ color: var(--pico-del-color);110+}111+112+/* -- the viewer ------------------------------------------------------------ */113+114+/* Wider than Pico's 700px modal: this one holds a whole web page. */115+#viewer > article {116+ max-width: min(64rem, 100%);117+}118+#viewer-body iframe {119+ display: block;120+ width: 100%;121+ height: min(60vh, 40rem);122+ border: var(--pico-border-width) solid var(--pico-muted-border-color);123+ border-radius: var(--pico-border-radius);124+ background: #fff;125+}126+#viewer .hops {127+ display: flex;128+ flex-wrap: wrap;129+ gap: 0.4rem;130+ margin: 0 0 var(--pico-spacing);131+ padding: 0;132+}133+#viewer .hops > li {134+ list-style: none;135+}136+#viewer .hops > li[data-reached='yes'] {137+ color: var(--pico-color);138+}139+#viewer .hops > li[data-reached='now'] {140+ color: var(--pico-primary);141+}142+143+/* Refusals and confirmations. Pico's `ins` and `del` colours, without their144+ * underline and strikethrough — a rejected declaration has to stay readable. */145+.error {146+ color: var(--pico-del-color);147+}148+.ok {149+ color: var(--pico-ins-color);150+}151+152+/* The token shown once after sign-in. Long, opaque, and meant to be copied. */153+.token {154+ word-break: break-all;155+ white-space: pre-wrap;156+}
D web/styles.css +0 -369
@@ -1,369 +0,0 @@
1-/* dev.site2- Set as an index, not a dashboard. Fraunces carries names and voice; IBM Plex Mono is3- reserved strictly for machine data — endpoint ids, origins, relay urls, state. Dotted4- leaders carry the eye from a name to its state the way a table of contents does.5- State is italic; visibility is grouping and colour. No badges, no chips, no dots. */6-7-@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&display=swap');8-9-:root {10- --paper: #0b0b0d;11- --paper-lift: #121216;12- --rule: #24242b;13- --rule-soft: #1a1a20;14-15- --ink: #ece9e4;16- --ink-dim: #9a968f;17- --ink-faint: #63605c;18-19- --private: #dfa03c;20- --shared: #6fc3d4;21- --live: #93c9a0;22- --dead: #c4776a;23-24- --display: 'Fraunces', Georgia, serif;25- --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;26-27- --gutter: clamp(1.5rem, 6vw, 5rem);28- color-scheme: dark;29-}30-31-* { box-sizing: border-box; margin: 0; padding: 0; }32-33-body {34- background: var(--paper);35- color: var(--ink);36- font-family: var(--mono);37- font-size: 14px;38- line-height: 1.65;39- min-height: 100vh;40- -webkit-font-smoothing: antialiased;41- text-rendering: optimizeLegibility;42-}43-44-.shell {45- max-width: 48rem;46- margin: 0 auto;47- padding: clamp(2rem, 6vh, 4.5rem) var(--gutter) 7rem;48-}49-50-/* -- masthead ------------------------------------------------------------- */51-52-.masthead {53- display: flex;54- justify-content: space-between;55- align-items: baseline;56- gap: 1.5rem;57- flex-wrap: wrap;58- padding-bottom: .9rem;59- border-bottom: 1px solid var(--rule);60-}61-62-.wordmark {63- font-family: var(--display);64- font-variation-settings: 'opsz' 14, 'SOFT' 0, 'WONK' 1;65- font-weight: 600;66- font-size: 1rem;67- letter-spacing: -.01em;68- color: var(--ink);69- text-decoration: none;70-}71-.wordmark span { color: var(--ink-faint); }72-.wordmark:hover span { color: var(--ink-dim); }73-74-.session {75- font-size: .78rem;76- color: var(--ink-faint);77- display: flex;78- align-items: baseline;79- gap: 1rem;80-}81-.session a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: .25em; text-decoration-thickness: .5px; cursor: pointer; }82-.session a:hover { color: var(--ink); }83-84-/* -- identity ------------------------------------------------------------- */85-86-.identity { padding: clamp(3rem, 11vh, 6rem) 0 3rem; }87-88-.handle {89- font-family: var(--display);90- /* Large optical size: Fraunces tightens spacing and sharpens contrast as opsz rises. */91- font-variation-settings: 'opsz' 144, 'SOFT' 0, 'WONK' 1;92- font-weight: 500;93- font-size: clamp(2.6rem, 11vw, 5rem);94- line-height: .95;95- letter-spacing: -.035em;96-}97-/* Set as a superior prefix rather than a full-size glyph. At display sizes Fraunces draws98- a wide, high-contrast @ whose ring competes with — and runs into — the name itself. */99-.handle .at {100- font-size: .34em;101- font-weight: 400;102- vertical-align: .95em;103- letter-spacing: 0;104- margin-right: .12em;105- color: var(--ink-faint);106-}107-108-.identity .sub {109- margin-top: 1.1rem;110- font-size: .8rem;111- color: var(--ink-faint);112- font-variant-numeric: tabular-nums;113-}114-115-/* -- sections ------------------------------------------------------------- */116-117-.group { margin-top: 3.25rem; }118-119-/* A section is announced by a shift in voice — italic, lowercase — with a rule running120- out from it to the margin. No uppercase label sitting above a heading. */121-.group-head {122- display: grid;123- grid-template-columns: auto 1fr;124- align-items: center;125- gap: .9rem;126- margin-bottom: .35rem;127-}128-.group-head h2 {129- font-family: var(--display);130- font-variation-settings: 'opsz' 24, 'SOFT' 0, 'WONK' 1;131- font-style: italic;132- font-weight: 400;133- font-size: 1.15rem;134- letter-spacing: -.01em;135- color: var(--ink-dim);136-}137-.group-head .rule { height: 1px; background: var(--rule); }138-139-.group.private .group-head h2 { color: var(--private); }140-.group.shared .group-head h2 { color: var(--shared); }141-142-/* -- entries -------------------------------------------------------------- */143-144-.entry {145- display: grid;146- grid-template-columns: auto 1fr auto;147- align-items: baseline;148- gap: .7rem;149- width: 100%;150- padding: .78rem 0;151- border: 0;152- background: transparent;153- color: inherit;154- font: inherit;155- text-align: left;156- text-decoration: none;157- cursor: pointer;158- border-bottom: 1px solid var(--rule-soft);159- transition: color .16s ease;160-}161-162-.entry .name {163- font-family: var(--display);164- font-variation-settings: 'opsz' 20, 'SOFT' 0, 'WONK' 1;165- font-weight: 400;166- font-size: 1.22rem;167- letter-spacing: -.015em;168- color: var(--ink);169- transition: color .16s ease;170-}171-.entry .name .owner { color: var(--ink-faint); font-style: italic; }172-173-/* Leaders. A dotted rule, lifted onto the baseline, filling whatever space is left. */174-.entry .leader {175- border-bottom: 1px dotted var(--rule);176- transform: translateY(-.34em);177- min-width: 1.5rem;178-}179-180-.entry .state {181- font-family: var(--mono);182- font-size: .74rem;183- letter-spacing: .02em;184- color: var(--ink-faint);185- font-variant-numeric: tabular-nums;186- white-space: nowrap;187-}188-/* Reachability reads as a state of being, so it is set in italic rather than boxed. */189-.entry .state em { font-style: italic; }190-.entry.online .state em { color: var(--live); }191-.entry.offline .state em { color: var(--dead); }192-193-.entry:hover .name, .entry:focus-visible .name { color: var(--accent, var(--ink)); }194-.entry:hover .leader, .entry:focus-visible .leader { border-bottom-color: var(--rule-soft); }195-.entry:focus-visible { outline: none; }196-.entry:hover .state, .entry:focus-visible .state { color: var(--ink-dim); }197-198-.entry.is-private { --accent: var(--private); }199-.entry.is-shared { --accent: var(--shared); }200-.entry.is-public { --accent: var(--ink); }201-202-.entry[disabled] { cursor: not-allowed; }203-.entry[disabled]:hover .name { color: var(--ink); }204-205-.empty {206- padding: 1.2rem 0;207- font-size: .82rem;208- color: var(--ink-faint);209- font-style: italic;210- font-family: var(--display);211- font-variation-settings: 'opsz' 14;212-}213-214-/* -- viewer --------------------------------------------------------------- */215-216-.viewer {217- position: fixed;218- inset: 0;219- z-index: 40;220- background: #06060899;221- backdrop-filter: blur(7px);222- display: none;223- padding: clamp(1rem, 4vh, 3rem) var(--gutter);224-}225-.viewer.open { display: grid; place-items: center; }226-227-.viewer-panel {228- width: min(68rem, 100%);229- height: min(46rem, 86vh);230- background: var(--paper-lift);231- border: 1px solid var(--rule);232- display: grid;233- grid-template-rows: auto auto 1fr;234- overflow: hidden;235- box-shadow: 0 30px 90px -25px #000d;236- animation: rise .3s cubic-bezier(.2, .9, .2, 1);237-}238-@keyframes rise {239- from { opacity: 0; transform: translateY(10px); }240- to { opacity: 1; transform: none; }241-}242-243-.viewer-bar {244- display: flex;245- align-items: baseline;246- justify-content: space-between;247- gap: 1rem;248- padding: .9rem 1.15rem;249- border-bottom: 1px solid var(--rule);250-}251-.viewer-title {252- font-family: var(--display);253- font-variation-settings: 'opsz' 20, 'WONK' 1;254- font-size: 1.05rem;255- font-weight: 400;256-}257-.viewer-title .of { color: var(--ink-faint); font-style: italic; }258-259-.close {260- border: 0; background: transparent; color: var(--ink-faint);261- cursor: pointer; font-family: var(--mono); font-size: .95rem; line-height: 1;262- padding: .3rem .45rem;263-}264-.close:hover { color: var(--ink); }265-266-/* The signal path, set as a sentence. The hop the connection has reached is in full267- contrast; the rest stays dim. The rule beneath fills as it advances. */268-.path {269- padding: .7rem 1.15rem .8rem;270- border-bottom: 1px solid var(--rule);271- font-size: .72rem;272- color: var(--ink-faint);273- display: flex;274- align-items: baseline;275- gap: .5rem;276- flex-wrap: wrap;277-}278-.path .hop { transition: color .35s ease; position: relative; }279-.path .hop.live { color: var(--shared); }280-.path .hop.done { color: var(--ink-dim); }281-.path .sep { color: var(--rule); }282-283-.viewer-body { position: relative; background: #fff; }284-.viewer-body iframe { width: 100%; height: 100%; border: 0; display: block; }285-286-.viewer-status {287- position: absolute; inset: 0;288- display: grid; place-items: center;289- background: var(--paper-lift);290- color: var(--ink-dim);291- font-family: var(--display);292- font-variation-settings: 'opsz' 18;293- font-style: italic;294- font-size: .95rem;295- text-align: center;296- padding: 2rem;297-}298-.viewer-status.err { color: var(--dead); font-style: normal; font-family: var(--mono); font-size: .82rem; }299-.viewer-status.hidden { display: none; }300-301-/* -- prose blocks (sign-in, setup) ---------------------------------------- */302-303-.block { max-width: 34rem; padding-top: clamp(2.5rem, 9vh, 5rem); }304-.block h1 {305- font-family: var(--display);306- font-variation-settings: 'opsz' 72, 'SOFT' 0, 'WONK' 1;307- font-weight: 400;308- font-size: clamp(1.9rem, 5.5vw, 2.9rem);309- line-height: 1.05;310- letter-spacing: -.03em;311- margin-bottom: .9rem;312-}313-.block h1 em { font-style: italic; color: var(--ink-dim); }314-.block p {315- color: var(--ink-dim);316- font-size: .88rem;317- margin-bottom: 1.6rem;318- max-width: 30rem;319-}320-321-.field { display: flex; gap: .6rem; flex-wrap: wrap; align-items: stretch; }322-.field input {323- flex: 1 1 12rem;324- padding: .55rem .7rem;325- background: var(--paper);326- border: 1px solid var(--rule);327- color: var(--ink);328- font-family: var(--mono);329- font-size: .88rem;330-}331-.field input:focus { outline: none; border-color: var(--ink-faint); }332-333-button.action {334- font-family: var(--mono);335- font-size: .78rem;336- padding: .55rem 1.05rem;337- border: 1px solid var(--rule);338- background: var(--paper-lift);339- color: var(--ink);340- cursor: pointer;341- transition: border-color .16s ease, color .16s ease;342-}343-button.action:hover { border-color: var(--ink-faint); }344-button.action.quiet { background: transparent; color: var(--ink-faint); }345-button.action.quiet:hover { color: var(--ink); }346-347-.token-box {348- margin-top: 1.6rem;349- padding-top: 1rem;350- border-top: 1px solid var(--rule);351- font-size: .78rem;352- color: var(--ink-faint);353-}354-.token-box p { font-size: .78rem; margin-bottom: .6rem; }355-.token-box code {356- display: block;357- font-family: var(--mono);358- color: var(--private);359- word-break: break-all;360- font-size: .74rem;361- line-height: 1.7;362-}363-364-.note { color: var(--ink-faint); font-size: .78rem; margin-top: 1.2rem; font-style: italic; font-family: var(--display); font-variation-settings: 'opsz' 14; }365-.error { color: var(--dead); font-size: .82rem; margin-top: 1rem; }366-367-@media (prefers-reduced-motion: reduce) {368- *, *::before, *::after { animation: none !important; transition: none !important; }369-}
A web/vendor/fonts/open-sans-latin-ext.woff2 +0 -0

Binary file.

A web/vendor/fonts/open-sans-latin.woff2 +0 -0

Binary file.

A web/vendor/pico.min.css +4 -0
@@ -0,0 +1,4 @@
1+@charset "UTF-8";/*!2+ * Pico CSS ✨ v2.1.1 (https://picocss.com)3+ * Copyright 2019-2025 - Licensed under MIT4+ */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}\ No newline at end of file