Files
portal/style/main.css
T

1660 lines
37 KiB
CSS
Raw Permalink Normal View History

/* Same self-hosted Quicksand used for headings on Gitea/the homepage
dashboard elsewhere in this stack (see
~/repos/infrastructure/gitea-custom-header.tmpl) - reused as-is
(same files, same unicode-range split) rather than pulled from a
font CDN.
font-display: optional, not swap, on all three: swap always shows
the fallback font first then reflows to Quicksand once it loads,
which is a real layout shift if that swap lands after the page is
already scrolled past that heading - reported as "text jumps" on
real mobile Safari. optional skips the swap entirely unless the
font is already cached/ready almost immediately, trading an
occasional fallback-font render for zero shift. */
@font-face {
font-family: "Quicksand";
font-style: normal;
font-weight: 600;
font-display: optional;
src: url("/fonts/quicksand-semibold-latin.woff2") format("woff2");
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;
}
2026-08-09 21:24:43 +02:00
@font-face {
font-family: "Quicksand";
font-style: normal;
font-weight: 600;
font-display: optional;
src: url("/fonts/quicksand-semibold-latin-ext.woff2") format("woff2");
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;
}
2026-08-09 21:24:43 +02:00
@font-face {
font-family: "Quicksand";
font-style: normal;
font-weight: 600;
font-display: optional;
src: url("/fonts/quicksand-semibold-vietnamese.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
:root {
/* the one brand color - everything else is neutral. A muted,
desaturated echo of the YES canvas's cyan ink: same family as the
canvas so chrome harmonizes with it, but dusty enough to read as a
quiet spot color on dark stock rather than compete. Legible
against near-black both as text and under #0a0a0a button text. */
--accent: #8ec2c0;
--accent-soft: rgba(142, 194, 192, 0.16);
--paper: #0a0a0a;
--ink: #ededed;
--ink-dim: #9a9a9a;
--line: #2a2a2a;
/* Cards float over a sticky hero piece - translucent so the piece
reads faintly through them (paired with backdrop-filter on
.alt-card), shadowed so their edge against the animated backdrop
is a soft lift rather than a hard contrast line. */
2026-08-17 18:05:20 +02:00
--card-bg: rgba(22, 22, 22, 0.35);
--card-bg-opaque: rgba(22, 22, 22, 0.84);
--card-shadow: rgba(0, 0, 0, 0.5);
--error: #ff6b6b;
/* Behind .hero-copy: dark halo on dark stock, paper halo on light. */
--hero-glow: rgba(0, 0, 0, 0.8);
--heading: "Quicksand", var(--sans);
--sans: -apple-system, "SF Pro Text", ui-sans-serif, "Segoe UI", system-ui, sans-serif;
--radius: 1.1rem;
/* The single place absolute units are allowed: this IS the base every
other length derives from (rem/em everywhere else - px is banned in
stylesheets). First line is the fallback for engines without lvmin. */
font-size: 17px;
font-size: max(12px, calc(2.1lvmin + 3pt));
}
/* Light theme: the same muted-press idea on white stock - warm paper,
near-black ink, and the accent deepened from dusty cyan to a teal
ink that actually carries as text on light paper (the dark theme's
#8ec2c0 washes out there). Canvas-drawing modules (gesture.js, and
content-shipped hero modules) mirror this palette on their own,
since canvas paint can't read CSS vars. */
@media (prefers-color-scheme: light) {
:root {
--accent: #47807e;
--accent-soft: rgba(71, 128, 126, 0.16);
--paper: #f6f5f1;
--ink: #1d1d1b;
--ink-dim: #6d6c66;
--line: #d9d7cf;
2026-08-17 18:05:20 +02:00
--card-bg: rgba(252, 251, 248, 0.35);
--card-bg-opaque: rgba(252, 251, 248, 0.84);
--card-shadow: rgba(50, 48, 42, 0.18);
--error: #a83a32;
--hero-glow: rgba(246, 245, 241, 0.85);
}
/* The house wordmark SVG is a hardcoded white stroke (also used
raw in dark contexts elsewhere) - flip it to ink here rather
than fork the asset. A content-provided logo keeps its colors. */
.wordmark-house img {
filter: invert(0.92);
}
}
2026-08-09 21:24:43 +02:00
* {
box-sizing: border-box;
}
2026-08-09 21:24:43 +02:00
html,
body {
margin: 0;
padding: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
line-height: 1.55;
}
2026-08-09 21:24:43 +02:00
h1,
h2,
h3 {
font-family: var(--heading);
font-weight: 600;
line-height: 1.2;
margin: 0 0 0.4em;
}
2026-08-09 21:24:43 +02:00
p {
margin: 0 0 0.9em;
}
2026-08-09 21:24:43 +02:00
a {
color: var(--accent);
}
2026-08-09 21:24:43 +02:00
::selection {
background: var(--accent-soft);
color: var(--ink);
}
2026-08-09 21:24:43 +02:00
main.loading,
main.not-found {
min-height: 60vh;
/* svh so wrapper browsers that resize the webview as their toolbar
animates (Firefox/Chrome iOS) don't reflow this placeholder - the
small viewport is a fixed value. Same ladder as .hero-piece. */
min-height: 60svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
color: var(--ink-dim);
}
/* hero */
.hero {
max-width: 46rem;
margin: 0 auto;
padding: 4rem 1.5rem 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 1rem;
}
/* Who is here and the way in or out: top right of every page, on a
site that has sign-in. */
.session {
position: absolute;
top: 1rem;
right: 1.5rem;
z-index: 5;
display: flex;
gap: 0.9rem;
align-items: baseline;
font-size: 0.88rem;
}
.session a {
color: var(--ink);
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
}
.session a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
.session-name {
color: var(--ink-dim);
}
/* A page with pictures: the wordmark is a bar of its own, the strip
runs the full width under it, and the question follows. */
.hero-slides {
max-width: none;
padding: 0 0 2rem;
align-items: stretch;
gap: 0;
}
.hero-bar {
display: flex;
align-items: center;
padding: 0.9rem 1.5rem;
}
.hero-slides .slideshow {
margin-bottom: 2rem;
}
.hero-slides .hero-copy {
width: 100%;
max-width: 46rem;
margin: 0 auto;
padding: 0 1.5rem;
box-sizing: border-box;
}
.wordmark {
display: inline-flex;
opacity: 0.85;
transition: opacity 120ms;
}
2026-08-09 21:24:43 +02:00
.wordmark:hover {
opacity: 1;
}
.wordmark img {
height: 2.1rem;
width: auto;
display: block;
}
2026-08-09 21:24:43 +02:00
.hero-copy h1 {
font-size: 2.1rem;
}
.hero-copy p {
color: var(--ink-dim);
font-size: 1.05rem;
max-width: 46ch;
2026-09-01 17:10:44 +02:00
/* The measure cap shrinks the box below the copy column; without
auto margins the box left-anchors and its centered text centers
in the wrong frame. */
margin-inline: auto;
}
/* A content-shipped hero module (site.yaml hero.kind: module) draws
into .hero-piece; the module owns its look (it may restyle .hero
itself). Portal only reserves the box so the copy doesn't jump when
the module mounts - plain-vh fallback first, as everywhere. */
.hero-module .hero-piece {
position: relative;
width: 100%;
min-height: 55vh;
min-height: 55svh;
}
/* announcements - live event pages, a strip above the hero copy.
Header, not footer: the one place a page claims attention before
the question is asked. */
.announce {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem 1.2rem;
margin: -2rem 0 0.5rem;
font-size: 0.88rem;
position: relative;
z-index: 1;
}
.announce-item {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.7rem;
padding: 0.45rem 0.9rem;
border: 0.06rem solid var(--line);
border-left: 0.2rem solid var(--accent);
border-radius: 0.3rem;
color: var(--ink);
text-decoration: none;
background: var(--paper);
transition: border-color 120ms;
}
.announce-item:hover,
.announce-item[aria-current="page"] {
border-color: var(--accent);
}
.announce-name {
font-weight: 600;
}
.announce-when {
color: var(--ink-dim);
}
.announce-relative {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
}
/* alternatives */
/* position + z-index on these three: everything that scrolls over the
sticky canvas must be a positioned box above the hero's z-index 0,
or the (positioned) hero would paint over it. */
/* Pictures across the page (`slideshow`): the full width of the
screen, every picture the strip's height and as wide as its own
shape, never wider than the screen. The caption floats over the
foot of its picture. */
.slideshow {
--swiper-navigation-color: #fff;
--swiper-navigation-size: 1.8rem;
position: relative;
z-index: 1;
display: block;
width: 100%;
height: var(--slideshow-height, 60svh);
max-height: var(--slideshow-height, 60svh);
margin: 0 0 3rem;
}
/* Before the Swiper bundle has upgraded the element, or without it:
a row that scrolls sideways. */
.slideshow:not(:defined) {
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
}
.slideshow swiper-slide {
flex: none;
width: auto;
height: 100%;
}
.slide {
position: relative;
height: 100%;
margin: 0;
}
.slide img {
display: block;
height: 100%;
width: auto;
/* A picture that has not loaded and says nothing of its size still
takes a place, or the whole strip would sit in view at once. */
min-width: 12rem;
max-width: 100vw;
object-fit: cover;
background: var(--line);
}
.slide figcaption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2.4rem 1rem 0.9rem;
color: #fff;
font-size: 0.95rem;
line-height: 1.35;
text-shadow: 0 0.06rem 0.2rem rgba(0, 0, 0, 0.6);
background: linear-gradient(to top, rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0));
pointer-events: none;
}
.alternatives {
position: relative;
z-index: 1;
2026-08-13 14:46:30 +02:00
max-width: 75ch;
margin: 0 auto 5rem;
padding: 0 1.5rem;
display: grid;
gap: 1.25rem;
}
.question-nav,
.question-responsible {
position: relative;
z-index: 1;
}
@media (max-width: 43.75rem) {
2026-08-09 21:24:43 +02:00
.alternatives {
padding: 0;
gap: 3.5rem;
}
.alt-card {
border-width: 0;
border-radius: 0rem;
}
}
2026-09-29 07:05:39 +02:00
/* The way down the tree: one level toward the front door, from any
page, as a line at the foot of it. */
2026-09-29 07:05:39 +02:00
.down-the-tree-foot {
position: relative;
z-index: 1;
text-align: center;
margin: 2.6rem auto 0;
font-size: 0.95rem;
}
.down-the-tree-foot a {
color: var(--ink);
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
}
.down-the-tree-foot a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Only for the person a page names as responsible: a quiet card at
the foot of their own page for suggesting a change to it. */
.suggest-edit {
position: relative;
z-index: 1;
max-width: 44rem;
margin: 2.4rem auto 0;
padding: 1.2rem 1.4rem;
border: 0.06rem dashed var(--line);
border-radius: var(--radius);
background: var(--card-bg);
}
.suggest-edit h2 {
font-size: 1.05rem;
margin: 0 0 0.4rem;
}
.suggest-edit p {
color: var(--ink-dim);
margin: 0 0 0.8rem;
}
.suggest-edit textarea {
width: 100%;
margin-bottom: 0.7rem;
}
.suggest-edit h3 {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-dim);
margin: 1.4rem 0 0.5rem;
}
.suggest-edit-mine {
list-style: none;
margin: 0;
padding: 0;
}
.suggest-edit-mine li {
display: flex;
gap: 0.8rem;
align-items: baseline;
padding: 0.5rem 0;
border-top: 0.06rem solid var(--line);
}
.suggest-edit-state {
flex: none;
min-width: 6.5rem;
font-size: 0.8rem;
color: var(--ink-dim);
}
.suggest-edit-state[data-state="done"] {
color: var(--ink);
font-weight: 600;
}
.suggest-edit-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
2026-08-12 23:09:55 +02:00
.question-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: baseline;
gap: 0.5rem 1.2rem;
margin: 2.2rem auto 0;
max-width: 44rem;
padding: 0 1rem;
font-size: 0.88rem;
}
.question-nav-lead {
color: var(--ink-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
}
.question-nav a {
color: var(--ink-dim);
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
2026-08-12 23:09:55 +02:00
}
.question-nav a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Meta information above the question nav - dimmer and smaller than
anything the question itself says. */
.question-responsible {
display: block;
text-align: center;
color: var(--ink-dim);
opacity: 0.7;
font-size: 0.85rem;
line-height: 1.6;
margin: 2.2rem 1rem 0;
}
2026-08-09 21:24:43 +02:00
.question-responsible a {
color: var(--ink-dim);
}
.question-responsible a:hover {
color: var(--accent);
}
.alt-card {
background: var(--card-bg);
/* Frosted glass over the animated canvas behind: the blur is what
keeps text on a translucent card readable while the piece still
reads through it in color and motion. */
-webkit-backdrop-filter: blur(0.8rem);
backdrop-filter: blur(0.8rem);
border: 0.06rem solid var(--line);
border-radius: var(--radius);
padding: 1.75rem 1.9rem;
box-shadow: 0 1.1rem 2.4rem var(--card-shadow);
}
/* Without backdrop blur, 0.55-alpha cards put text straight over the
moving canvas - illegible. Engines lacking the filter get a
near-opaque card instead. */
@supports not ((backdrop-filter: blur(1rem)) or (-webkit-backdrop-filter: blur(1rem))) {
.alt-card {
background: var(--card-bg-opaque);
}
}
.alt-image {
display: block;
width: 100%;
2026-09-01 21:42:20 +02:00
height: auto;
/* Natural aspect, not a cropped band; a very tall image is still
bounded so it can't tower. The deck below overrides for its
fixed-height cards. */
max-height: 32rem;
object-fit: contain;
border-radius: calc(var(--radius) - 0.3rem);
margin-bottom: 1.1rem;
}
/* Sized down from full card width so the cards effect's fanned-out
neighbors stay inside the card - swiper positions slides absolutely,
so the container needs its own height. Before the bundle upgrades
the element (or without JS) slides stack as plain blocks; capping
the container height keeps that fallback from towering. */
.alt-image-deck {
display: block;
width: min(100%, 22rem);
height: 14rem;
margin: 0 auto 1.1rem;
overflow: hidden;
}
.alt-image-deck .alt-image {
height: 100%;
max-height: none;
margin-bottom: 0;
}
2026-08-09 21:24:43 +02:00
.alt-card h2 {
font-size: 1.3rem;
}
2026-08-09 21:24:43 +02:00
.alt-description {
color: var(--ink-dim);
}
.alt-encouragement {
font-size: 0.9rem;
color: var(--accent);
font-style: italic;
margin: 0;
}
.alt-cta {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.2rem;
margin-top: 1.4rem;
}
.alt-encouragements {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
2026-08-09 21:24:43 +02:00
.features {
display: grid;
gap: 1.1rem;
margin-top: 0.6rem;
}
.feature {
border-left: 0.18rem solid var(--feature-accent, transparent);
}
.feature-icon {
width: 1.3rem;
height: 1.3rem;
2026-08-30 16:56:18 +02:00
margin-top: 0.1rem;
background-color: var(--feature-accent, currentColor);
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
-webkit-mask-position: center;
}
2026-08-30 16:56:18 +02:00
/* With an icon the feature is two columns: the icon in the first,
everything else - name, description, fields, resources - in the
second, so every line of text shares one left edge instead of
wrapping back under the icon. */
.feature:has(> .feature-icon) {
display: grid;
grid-template-columns: 1.3rem minmax(0, 1fr);
column-gap: 0.7rem;
align-items: start;
}
.feature:has(> .feature-icon) > .feature-icon {
grid-column: 1;
grid-row: 1;
}
.feature:has(> .feature-icon) > :not(.feature-icon) {
grid-column: 2;
}
2026-08-09 21:24:43 +02:00
.feature h3 {
font-size: 1rem;
margin-bottom: 0.15em;
}
/* voice field (voice.js): a record button, a status line, a preview */
.voice-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.voice-button {
font: inherit;
padding: 0.55rem 1.1rem;
border: 0.06rem solid var(--accent);
border-radius: 2rem;
background: transparent;
color: var(--ink);
cursor: pointer;
}
.voice-button:hover:not(:disabled) {
background: var(--accent);
color: var(--paper);
}
.voice-button.recording {
background: var(--accent);
color: var(--paper);
animation: voice-pulse 1.2s ease-in-out infinite;
}
.voice-button:disabled {
opacity: 0.5;
cursor: default;
}
@keyframes voice-pulse {
50% { opacity: 0.6; }
}
.voice-status {
font-size: 0.85rem;
color: var(--ink-dim);
}
.voice-preview,
.item-card-audio {
display: block;
width: 100%;
max-width: 28rem;
margin-top: 0.4rem;
}
/* display:block above would beat the hidden attribute */
.voice-preview[hidden] {
display: none;
}
/* links inside markdown descriptions */
.alt-description a,
.feature p a {
color: inherit;
text-decoration: none;
border-bottom: 0.06rem solid var(--accent);
}
.alt-description a:hover,
.feature p a:hover {
color: var(--accent);
}
/* images inside markdown descriptions: full-width figures in the
card's flow, framed like the rest of the press sheet */
.alt-description img,
.item-card-description img,
.feature p img {
display: block;
width: 100%;
margin: 0.6rem 0 0.2rem;
border-radius: 0.5rem;
border: 0.06rem solid var(--line);
}
2026-09-01 21:36:57 +02:00
/* Content hint via a markdown image title (`![alt](url "right 9rem")`):
float and shrink so text wraps around a portrait. */
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left,
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
width: auto;
max-width: 45%;
}
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left {
float: left;
margin: 0.2rem 1.1rem 0.5rem 0;
}
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
float: right;
margin: 0.2rem 0 0.5rem 1.1rem;
}
.alt-description code,
.feature p code {
font-size: 0.9em;
}
2026-08-09 21:24:43 +02:00
.feature p {
color: var(--ink-dim);
font-size: 0.95em;
2026-08-09 21:24:43 +02:00
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.6rem;
}
.field-label {
font-size: 0.85em;
font-weight: 600;
color: var(--ink-dim);
}
2026-08-09 21:24:43 +02:00
.required {
color: var(--accent);
margin-left: 0.2em;
}
/* Controls take their size from the text around them (em, not rem):
a site that sets its body larger, or a card that sets itself
smaller, gets fields, pills and buttons to match, and everything
inside a control - the date picker's icon, the file picker's button
- scales with the control. */
2026-08-09 21:24:43 +02:00
input,
textarea {
font: inherit;
color: var(--ink);
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 0.6em;
padding: 0.65em 0.8em;
width: 100%;
}
/* The calendar icon at the size of the text it sits beside, and a
target worth aiming at. */
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
min-height: 2.9em;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
width: 1.35em;
height: 1.35em;
padding: 0.2em;
margin-left: 0.4em;
cursor: pointer;
opacity: 0.75;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover,
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* The file picker's own button, in the field's colours rather than
the browser's: it follows whatever ink the field has, so it holds
on a light field and a dark one alike. */
input[type="file"] {
padding: 0.45em;
cursor: pointer;
}
input[type="file"]::file-selector-button {
font: inherit;
font-size: 0.85em;
font-weight: 600;
color: inherit;
background: color-mix(in srgb, currentColor 9%, transparent);
border: 0.06rem solid color-mix(in srgb, currentColor 28%, transparent);
border-radius: 999rem;
padding: 0.4em 1em;
margin-right: 0.8em;
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
input[type="file"]::file-selector-button:hover {
background: var(--accent-soft);
border-color: var(--accent);
}
2026-08-09 21:24:43 +02:00
textarea {
min-height: 5rem;
resize: vertical;
}
/* A bound textarea holds loaded source text - monospace, roomy, no
soft-wrap surprises. */
textarea.bound {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.85rem;
min-height: 16rem;
white-space: pre;
overflow-x: auto;
}
2026-08-09 21:24:43 +02:00
input:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 0.18rem var(--accent-soft);
}
/* One placeholder treatment for every input-like surface: solid
ink-dim, no translucency. opacity: 1 also undoes the UA's own
placeholder dimming (Firefox) and ProseKit's 0.3 on its
placeholder pseudo-element, which read far dimmer than the
native inputs next to it. */
::placeholder,
.prosekit-editor .prosekit-placeholder::before,
.prosekit-editor.prosekit-placeholder::before {
color: var(--ink-dim);
opacity: 1;
}
.prosekit-wrap {
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 0.6rem;
overflow: hidden;
}
/* The gesture drawing field (gesture.js). Stroke/ghost/echo colors
live in the JS module's own palette table - canvas can't read these
custom properties - so palette changes go there AND here. */
.gesture-wrap {
position: relative;
width: 100%;
}
/* The canvas is created by JS only after wasm hydration - reserve its
3/2 box until then so content below doesn't jump. Scoped to :empty
on purpose: on the mounted wrap (a stretched item inside the flex
.field) the same aspect-ratio turned the echo strip's extra height
into extra WIDTH and the whole card ballooned past the viewport
after the first stroke. */
.gesture-wrap:empty {
aspect-ratio: 3 / 2;
}
.gesture-canvas {
display: block;
width: 100%;
aspect-ratio: 3 / 2;
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 0.6rem;
cursor: crosshair;
/* Load-bearing on touch: without it the browser pans instead of
letting the stroke happen. */
touch-action: none;
}
/* Results overlay the canvas instead of growing the widget - the
page never jumps when places arrive (or don't). */
.gesture-echoes {
position: absolute;
left: 0.6rem;
right: 0.6rem;
bottom: 0.6rem;
display: flex;
justify-content: center;
gap: 0.5rem;
pointer-events: none;
}
.gesture-echoes:empty {
display: none;
}
.gesture-echo {
width: 3rem;
height: 3rem;
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 0.4rem;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: auto;
box-shadow: 0 0.15rem 0.6rem rgba(0, 0, 0, 0.18);
}
.gesture-echo.shown {
opacity: var(--echo-strength, 1);
}
/* places are pickable: the pick re-derives the input's key */
.gesture-echo {
cursor: pointer;
transition: opacity 0.5s ease, border-color 120ms, transform 120ms;
}
.gesture-echo:hover,
.gesture-echo:focus-visible {
border-color: var(--accent);
outline: none;
}
.gesture-echo.selected {
border-color: var(--accent);
box-shadow: 0 0 0 0.12rem var(--accent);
transform: translateY(-0.1rem);
}
.gesture-empty {
position: absolute;
left: 0;
right: 0;
bottom: 0.7rem;
margin: 0;
text-align: center;
font-size: 0.85rem;
color: var(--ink-dim);
pointer-events: none;
}
.gesture-empty:empty {
display: none;
}
.gesture-status {
position: absolute;
top: 0.55rem;
right: 0.55rem;
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--ink-dim);
opacity: 0.35;
transition: opacity 0.3s ease, background 0.3s ease;
}
.gesture-status.live {
background: var(--accent);
opacity: 0.9;
}
.prosekit-toolbar {
display: flex;
gap: 0.2rem;
padding: 0.4rem;
border-bottom: 0.06rem solid var(--line);
flex-wrap: wrap;
}
.prosekit-toolbar button {
font: inherit;
font-size: 0.9rem;
color: var(--ink-dim);
background: transparent;
border: 0.06rem solid transparent;
border-radius: 0.4rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
line-height: 1;
}
2026-08-09 21:24:43 +02:00
.prosekit-toolbar button:hover {
color: var(--ink);
border-color: var(--line);
}
.prosekit-toolbar button.is-active {
color: var(--paper);
background: var(--accent);
}
.prosekit-editor {
font: inherit;
color: var(--ink);
padding: 0.65rem 0.8rem;
min-height: 5rem;
}
.prosekit-editor:focus {
outline: none;
}
.prosekit-wrap:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 0.18rem var(--accent-soft);
}
/* The editor's whole type scale, in rem. ProseKit's own typography.css
is deliberately not loaded (it sizes content in fixed px - see
mountEditor in prosekit-editor.js), so these rules are the only
typography the editor gets, on top of UA defaults (em-based, they
scale fine for lists/blockquote/code). NOTE: editor.mount() turns the
.prosekit-editor div itself into the .ProseMirror root, so content
elements are its direct children - no .ProseMirror in the selector. */
.prosekit-editor p {
font-size: 1rem;
line-height: 1.5;
2026-08-09 21:24:43 +02:00
margin: 0 0 0.6em;
}
.prosekit-editor p:last-child {
2026-08-09 21:24:43 +02:00
margin-bottom: 0;
}
.prosekit-editor h1 {
font-size: 2.5rem;
line-height: 1.1;
margin: 0.9em 0 0.1em;
}
.prosekit-editor h2 {
font-size: 1.875rem;
line-height: 1.3;
margin: 1.05em 0 0.13em;
}
.prosekit-editor h3 {
font-size: 1.5rem;
line-height: 1.33;
margin: 0.9em 0 0.04em;
}
.prosekit-editor h4 {
font-size: 1.25rem;
line-height: 1.3;
margin: 0.8em 0 0.05em;
}
.prosekit-editor h5 {
font-size: 1.125rem;
line-height: 1.22;
margin: 0.78em 0 0.06em;
}
.prosekit-editor h6 {
font-size: 1rem;
line-height: 1.25;
margin: 0.75em 0 0.06em;
}
.prosekit-editor :is(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 0;
}
.alt-submit {
font-family: inherit;
font-weight: 600;
font-size: 0.95em;
color: var(--paper);
background: var(--accent);
border: none;
border-radius: 0.7em;
padding: 0.75em 1.4em;
cursor: pointer;
transition: filter 120ms, transform 120ms;
2026-09-01 20:01:28 +02:00
/* A gateway alternative renders this as an <a> (navigation, no POST);
kill the link chrome so it reads as the button it looks like. */
text-decoration: none;
display: inline-block;
}
2026-08-09 21:24:43 +02:00
.alt-submit:hover {
filter: brightness(1.08);
}
2026-08-09 21:24:43 +02:00
.alt-submit:active {
transform: translateY(0.06em);
2026-08-09 21:24:43 +02:00
}
.alt-submit:disabled {
filter: grayscale(0.5) brightness(0.85);
cursor: wait;
}
/* A content-disabled alternative (`disabled: true`): announced, not
yet takeable - reads as a promise, not as a stuck form. */
.alt-submit.soon:disabled {
cursor: not-allowed;
opacity: 0.6;
2026-08-09 21:24:43 +02:00
}
.gate-card {
text-align: center;
}
/* resources */
2026-08-09 21:24:43 +02:00
.resource {
margin-top: 0.6rem;
}
2026-08-09 21:24:43 +02:00
.resource-loading,
.resource-empty {
color: var(--ink-dim);
font-size: 0.9rem;
}
2026-08-09 21:24:43 +02:00
.resource-error {
color: var(--error);
2026-08-09 21:24:43 +02:00
font-size: 0.9rem;
}
.resource-raw {
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 0.6rem;
padding: 0.8rem;
font-size: 0.82rem;
overflow-x: auto;
white-space: pre-wrap;
}
2026-08-09 21:24:43 +02:00
.answer-list {
display: grid;
gap: 0.8rem;
}
.answer-row {
border: 0.06rem solid var(--line);
border-radius: 0.8rem;
padding: 1rem 1.1rem;
display: grid;
gap: 0.6rem;
}
2026-08-09 21:24:43 +02:00
.answer-row.decided {
opacity: 0.55;
}
.answer-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.5rem 1rem;
}
2026-08-09 21:24:43 +02:00
.answer-field {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.answer-field-key {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-dim);
}
2026-08-09 21:24:43 +02:00
.answer-field-value {
font-size: 0.92rem;
word-break: break-word;
}
.answer-meta {
display: flex;
gap: 0.8rem;
align-items: center;
font-size: 0.78rem;
color: var(--ink-dim);
}
.answer-state {
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
color: var(--accent);
}
/* A read-only list whose records carry a picture: shown, not read. */
.answer-cards {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
align-items: start;
}
.answer-card {
padding: 0;
overflow: hidden;
gap: 0;
}
.answer-picture {
display: block;
width: 100%;
aspect-ratio: 4 / 5;
object-fit: cover;
background: var(--line);
}
.answer-card-text {
padding: 0.9rem 1.1rem 1.1rem;
display: grid;
gap: 0.35rem;
}
.answer-card-heading {
margin: 0;
font-size: 1.05rem;
}
.answer-card-text p {
margin: 0;
font-size: 0.92rem;
color: var(--ink-dim);
white-space: pre-line;
}
.answer-card-text p:first-of-type {
color: var(--ink);
font-weight: 600;
}
.answer-card .answer-link {
margin: 0 1.1rem 1.1rem;
}
/* A picture on a desk's record: small, and it opens. */
.answer-thumb {
display: block;
max-width: 8rem;
max-height: 8rem;
border-radius: 0.4rem;
object-fit: cover;
}
/* Why a send was refused, under its button. */
.alt-error {
margin: 0.6em 0 0;
color: var(--accent);
font-size: 0.92em;
}
/* The file already on a card of one's own, above the picker. */
.field-current {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6em;
font-size: 0.9em;
color: var(--ink-dim);
}
/* A stored file on a record: its name, and it opens. */
.answer-file {
color: var(--accent);
text-decoration: none;
border-bottom: 0.06rem solid var(--accent);
}
2026-09-30 06:47:36 +02:00
/* The page about a listed record, when the list offers one. */
.answer-link {
display: inline-block;
margin-top: 0.4rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
border-bottom: 0.1rem solid var(--accent);
}
.answer-link:hover {
color: var(--ink);
border-bottom-color: var(--ink);
}
.answer-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.6rem;
}
2026-08-09 21:24:43 +02:00
.item-list {
display: grid;
gap: 0.8rem;
}
.item-card {
border: 0.06rem solid var(--line);
border-radius: 0.8rem;
padding: 1rem 1.1rem;
display: grid;
gap: 0.4rem;
}
.item-card-title {
font-family: var(--sans);
font-weight: 600;
font-size: 1rem;
}
2026-08-09 21:24:43 +02:00
.item-card-title a {
color: var(--ink);
text-decoration: none;
}
2026-08-09 21:24:43 +02:00
.item-card-title a:hover {
color: var(--accent);
text-decoration: underline;
}
.item-card-description {
font-size: 0.9rem;
color: var(--ink-dim);
/* The card is a grid with its own gap; the global p bottom margin
would stack on top of it. */
margin: 0;
2026-08-09 21:24:43 +02:00
}
.item-card-fields {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
margin-top: 0.2rem;
}
2026-08-09 21:24:43 +02:00
.item-card-field {
font-size: 0.82rem;
color: var(--ink-dim);
}
.item-card-field-key {
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.68rem;
margin-right: 0.3rem;
}
2026-08-09 21:24:43 +02:00
.select-field {
margin-top: 0.3rem;
}
2026-08-09 21:24:43 +02:00
.select-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.select-option {
font-family: inherit;
font-size: 0.85em;
color: var(--ink);
background: var(--paper);
border: 0.06rem solid var(--line);
border-radius: 999rem;
padding: 0.45em 1em;
cursor: pointer;
transition: border-color 120ms, color 120ms, background 120ms;
}
2026-08-09 21:24:43 +02:00
.select-option:hover {
border-color: var(--accent);
color: var(--accent);
}
.select-option.selected {
border-color: var(--accent);
color: var(--paper);
background: var(--accent);
}
.select-option:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 40rem) {
2026-08-09 21:24:43 +02:00
.hero {
padding: 3rem 1.25rem 2rem;
}
.alt-card {
padding: 1.4rem 1.3rem;
}
.alt-cta {
flex-direction: column;
align-items: flex-start;
}
}
/* A `type: location` field: the map fills the field, and the pin is the
centre of it - panning works the same with a finger, a mouse or the
arrow keys, and there is nothing to miss by a few pixels. MapLibre
draws into .map-canvas after hydration, so the box is reserved up
front the way .gesture-wrap reserves its own. */
.map-wrap {
position: relative;
width: 100%;
}
.map-wrap:empty {
aspect-ratio: 3 / 2;
}
.map-canvas {
display: block;
width: 100%;
aspect-ratio: 3 / 2;
border: 0.06rem solid var(--line);
border-radius: 0.2rem;
overflow: hidden;
}
/* The pin sits over the exact centre of the canvas and takes no
pointer events, so every drag goes to the map under it. Its point is
the bottom tip, hence the upward shift of its own height. */
.map-pin {
position: absolute;
top: 50%;
left: 50%;
width: 0.9rem;
height: 0.9rem;
margin: -1.35rem 0 0 -0.45rem;
border: 0.18rem solid var(--ink);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
pointer-events: none;
background: var(--paper);
}
.map-readout {
margin: 0.3rem 0 0;
font-size: 0.85em;
color: var(--ink-dim);
}
/* A stored location in a list: a still image from this origin, never a
live map - twenty rows of WebGL would cost more than the page. */
.map-still {
display: block;
width: 100%;
max-width: 24rem;
height: auto;
border: 0.06rem solid var(--line);
border-radius: 0.2rem;
}
.map-coords {
display: block;
margin-top: 0.2rem;
font-size: 0.85em;
}
.answer-field-map .answer-field-value {
display: block;
}
/* Replies that came back by mail, under the record they belong to.
They move nothing, so there is no control here - only who wrote,
when, and what they said. */
.answer-notes {
list-style: none;
margin: 0.4rem 0 0;
padding: 0 0 0 0.7rem;
border-left: 0.12rem solid var(--line);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.answer-note-who {
font-weight: 600;
}
.answer-note-time {
margin-left: 0.4rem;
font-size: 0.85em;
color: var(--ink-dim);
}
.answer-note-text {
margin: 0.15rem 0 0;
white-space: pre-wrap;
}
/* A page that is about a place: the map sits under the question, at
the width of the text it belongs to. */
.question-place {
margin: 0.8rem 0 0;
}
.question-place figcaption {
margin-top: 0.3rem;
font-size: 0.9em;
color: var(--ink-dim);
}
/* A loading slot holds its line's worth of space and says nothing.
The words that belong here have not arrived yet, and "loading…" is
not one of them. */
.resource-loading {
min-height: 1.4em;
}
/* Pages move the way their addresses do (public/page-motion.js):
deeper comes in from the right, back up from the left, a page at the
same depth from below or above. The cards leave and arrive as
islands of their own, one after another. Transforms and opacity
only. */
@media (prefers-reduced-motion: no-preference) {
:root {
--pm-duration: 340ms;
--pm-ease: cubic-bezier(0.22, 0.8, 0.24, 1);
--pm-step: 45ms;
}
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: var(--pm-duration);
animation-timing-function: var(--pm-ease);
animation-fill-mode: both;
}
/* A named card has only an old or only a new picture; its group must
not also morph. */
::view-transition-group(*) {
animation-duration: var(--pm-duration);
}
}
@keyframes pm-out-left { to { transform: translateX(-18%); opacity: 0; } }
@keyframes pm-out-right { to { transform: translateX(18%); opacity: 0; } }
@keyframes pm-out-up { to { transform: translateY(-10%); opacity: 0; } }
@keyframes pm-out-down { to { transform: translateY(10%); opacity: 0; } }
@keyframes pm-in-right { from { transform: translateX(18%); opacity: 0; } }
@keyframes pm-in-left { from { transform: translateX(-18%); opacity: 0; } }
@keyframes pm-in-below { from { transform: translateY(10%); opacity: 0; } }
@keyframes pm-in-above { from { transform: translateY(-10%); opacity: 0; } }
/* The cards, one step apart: out first, in after the page has begun. */
@media (prefers-reduced-motion: no-preference) {
:root[data-motion="forward"]::view-transition-old(*) { animation-name: pm-out-left; }
:root[data-motion="forward"]::view-transition-new(*) { animation-name: pm-in-right; }
:root[data-motion="back"]::view-transition-old(*) { animation-name: pm-out-right; }
:root[data-motion="back"]::view-transition-new(*) { animation-name: pm-in-left; }
:root[data-motion="down"]::view-transition-old(*) { animation-name: pm-out-up; }
:root[data-motion="down"]::view-transition-new(*) { animation-name: pm-in-below; }
:root[data-motion="up"]::view-transition-old(*) { animation-name: pm-out-down; }
:root[data-motion="up"]::view-transition-new(*) { animation-name: pm-in-above; }
::view-transition-old(pm-out-1) { animation-delay: calc(0 * var(--pm-step)); }
::view-transition-new(pm-in-1) { animation-delay: calc(90ms + 0 * var(--pm-step)); }
::view-transition-old(pm-out-2) { animation-delay: calc(1 * var(--pm-step)); }
::view-transition-new(pm-in-2) { animation-delay: calc(90ms + 1 * var(--pm-step)); }
::view-transition-old(pm-out-3) { animation-delay: calc(2 * var(--pm-step)); }
::view-transition-new(pm-in-3) { animation-delay: calc(90ms + 2 * var(--pm-step)); }
::view-transition-old(pm-out-4) { animation-delay: calc(3 * var(--pm-step)); }
::view-transition-new(pm-in-4) { animation-delay: calc(90ms + 3 * var(--pm-step)); }
::view-transition-old(pm-out-5) { animation-delay: calc(4 * var(--pm-step)); }
::view-transition-new(pm-in-5) { animation-delay: calc(90ms + 4 * var(--pm-step)); }
::view-transition-old(pm-out-6) { animation-delay: calc(5 * var(--pm-step)); }
::view-transition-new(pm-in-6) { animation-delay: calc(90ms + 5 * var(--pm-step)); }
::view-transition-old(pm-out-7) { animation-delay: calc(6 * var(--pm-step)); }
::view-transition-new(pm-in-7) { animation-delay: calc(90ms + 6 * var(--pm-step)); }
::view-transition-old(pm-out-8) { animation-delay: calc(7 * var(--pm-step)); }
::view-transition-new(pm-in-8) { animation-delay: calc(90ms + 7 * var(--pm-step)); }
}