/* 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; } @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; } @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 the sticky YES canvas - 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. */ --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). yes.js mirrors this palette on its own (matchMedia in setupTheme) 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; --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 wordmark SVG is a hardcoded white stroke (also used raw in dark contexts elsewhere) - flip it to ink here rather than fork the asset. */ .wordmark img { filter: invert(0.92); } /* overlay against near-white paper resolves to ~white and the raster ghost vanishes; multiply lets the light theme's gray YES (see rasterizeText's themed repaint in yes.js) show as ink. */ .hero-canvas #rasterCanvas { mix-blend-mode: multiply; } } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); line-height: 1.55; } h1, h2, h3 { font-family: var(--heading); font-weight: 600; line-height: 1.2; margin: 0 0 0.4em; } p { margin: 0 0 0.9em; } a { color: var(--accent); } ::selection { background: var(--accent-soft); color: var(--ink); } main.loading, main.not-found { min-height: 60vh; 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; } .wordmark { display: inline-flex; opacity: 0.85; transition: opacity 120ms; } .wordmark:hover { opacity: 1; } .wordmark img { height: 2.1rem; width: auto; display: block; } .hero-copy h1 { font-size: 2.1rem; } .hero-copy p { color: var(--ink-dim); font-size: 1.05rem; max-width: 46ch; } /* the actual "YES - Rasterized Lines" piece live at uhhm.no (see public/yes.js) - landing page only. Full-viewport so it's a real moment, not a thumbnail; hero-copy overlays near the bottom rather than interrupting the canvas. */ .hero-yes { /* Sticky at the viewport top for the whole scroll (its containing block is the page itself), so the piece stays animating behind everything that follows - the translucent cards scroll over it and it shows through them and in the gaps around them. z-index 0 so positioned content below can stack above with z-index 1. */ position: sticky; top: 0; z-index: 0; max-width: none; width: 100%; /* svh here is only the pre-JS/no-JS fallback (and first paint before yes.js's constructor runs). Once yes.js mounts, it overwrites this with an inline `height: ` frozen from a single measurement - see setupCanvas()'s comment in yes.js for why: on real mobile Safari, content bottom-aligned inside this box kept sliding down as the address bar collapsed even with a spec'd-stable viewport unit here, so the box's actual height can't be trusted to stay put on that unit alone. An inline style set from JS always wins the cascade over this rule, so that frozen number is what actually governs once the page is interactive. Plain-vh fallback declared first - an engine without svh support ignores the invalid second line rather than falling through to auto height, which would collapse this to the height of its in-flow content and clip the canvas via overflow:hidden below. */ height: 100vh; height: 100svh; padding: 0; justify-content: flex-end; overflow: hidden; } .hero-canvas { position: absolute; inset: 0; } .hero-canvas canvas { position: absolute; top: 0; left: 0; cursor: pointer; } .hero-canvas #rasterCanvas { mix-blend-mode: overlay; opacity: 0.5; } .hero-yes .hero-copy { position: relative; z-index: 1; padding: 0 1.5rem 3.5rem; gap: 0.6rem; text-shadow: 0 0.12em 1.4em var(--hero-glow); } /* The gesture hero (site.yaml hero.kind: gesture - redoal.com's landing): a tall drawing surface where the visitor's stroke and ambient echoes from the relay share the stage. Not sticky like the YES piece - drawing and scrolling fight over the same finger. */ .hero-gesture .hero-draw { position: relative; width: 100%; max-width: 46rem; } .hero-gesture .gesture-canvas { aspect-ratio: auto; /* Plain-vh fallback first, same reasoning as .hero-yes's height. */ height: 55vh; height: 55svh; border: none; background: transparent; } .hero-gesture .gesture-status { top: auto; bottom: 0.55rem; right: 0.55rem; } /* 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. */ .alternatives { position: relative; z-index: 1; 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) { .alternatives { padding: 0; gap: 3.5rem; } .alt-card { border-width: 0; border-radius: 0rem; } } .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); } .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; } .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%; max-height: 14rem; object-fit: cover; 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; } .alt-card h2 { font-size: 1.3rem; } .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; } .features { display: grid; gap: 1.1rem; margin-top: 0.6rem; } .feature { border-left: 0.18rem solid var(--feature-accent, transparent); } .feature-icon { float: left; width: 1.3rem; height: 1.3rem; margin: 0.1rem 0.6rem 0.4rem 0; 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; } .feature h3 { font-size: 1rem; margin-bottom: 0.15em; } .feature p { color: var(--ink-dim); font-size: 0.95rem; } .field { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.6rem; } .field-label { font-size: 0.85rem; font-weight: 600; color: var(--ink-dim); } .required { color: var(--accent); margin-left: 0.2em; } input, textarea { font: inherit; color: var(--ink); background: var(--paper); border: 0.06rem solid var(--line); border-radius: 0.6rem; padding: 0.65rem 0.8rem; width: 100%; } 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; } 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; } .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; } .gesture-echoes { display: flex; gap: 0.5rem; margin-top: 0.5rem; min-height: 3rem; } .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; } .gesture-echo.shown { opacity: var(--echo-strength, 1); } .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; } .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; margin: 0 0 0.6em; } .prosekit-editor p:last-child { 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: var(--sans); font-weight: 600; font-size: 0.95rem; color: var(--paper); background: var(--accent); border: none; border-radius: 0.7rem; padding: 0.75rem 1.4rem; cursor: pointer; transition: filter 120ms, transform 120ms; } .alt-submit:hover { filter: brightness(1.08); } .alt-submit:active { transform: translateY(0.06em); } .alt-submit:disabled { filter: grayscale(0.5) brightness(0.85); cursor: wait; } .gate-card { text-align: center; } /* resources */ .resource { margin-top: 0.6rem; } .resource-loading, .resource-empty { color: var(--ink-dim); font-size: 0.9rem; } .resource-error { color: var(--error); 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; } .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; } .answer-row.decided { opacity: 0.55; } .answer-fields { display: grid; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); gap: 0.5rem 1rem; } .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); } .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); } .answer-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem; } .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; } .item-card-title a { color: var(--ink); text-decoration: none; } .item-card-title a:hover { color: var(--accent); text-decoration: underline; } .item-card-description { font-size: 0.9rem; color: var(--ink-dim); } .item-card-fields { display: flex; flex-wrap: wrap; gap: 0.4rem 0.9rem; margin-top: 0.2rem; } .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; } .select-field { margin-top: 0.3rem; } .select-options { display: flex; flex-wrap: wrap; gap: 0.5rem; } .select-option { font-family: var(--sans); font-size: 0.85rem; color: var(--ink); background: var(--paper); border: 0.06rem solid var(--line); border-radius: 999rem; padding: 0.4rem 0.9rem; cursor: pointer; transition: border-color 120ms, color 120ms, background 120ms; } .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) { .hero { padding: 3rem 1.25rem 2rem; } .alt-card { padding: 1.4rem 1.3rem; } .alt-cta { flex-direction: column; align-items: flex-start; } }