# CTAO Science Portal — design system Single source of truth for look-and-feel. All values live as tokens in `src/styles/global.css`; this file states the rules. Brand basis: CTAO Brand Guidelines (Apr 2024) + techniques observed on ctao.org (photography-led hero, nebula motion, generous spacing, 15/30/60 rounding, quiet-fade hovers). No frameworks, no JS for presentation. ## Color tokens (brand-fixed — never add hues) | Token | Value | Use | |---|---|---| | `--galaxy` | `#00004A` | Headings, header/footer bands, hero scrim | | `--cherenkov` | `#00E4D8` | ONLY: primary CTA, flash, hero subtitle, logo cluster (+ functional: ``, TOC active rule). Never small text on light bg | | `--moon` | `#F5F5F5` | Utility surfaces (bands, neutral hovers) | | `--azure` | `#007AFF` | Focus rings and field-focus borders ONLY (no hover borders) | | `--indigo` | `#00009C` | Gradient depth, link hover | | `--text` / `--muted` / `--border` / `--link` | derived | Body ink; `--link #0057C2` is azure darkened to pass AA | Accent discipline: Cherenkov is rationed to the four brand moments above. Status pills, category/date chips and metadata are plain muted text — the label carries the meaning, not the chrome. ## Typography | Role | Font | Rule | |---|---|---| | h1, h2 | Space Grotesk (`--font-display`) | Headlines ONLY (brand D.3.2), weight **500** (D.3.4 regular/medium), letter-spacing −0.025em, line-height 1.1 | | Everything else | Inter (`--font-body`) | Includes h3+ (weight 600), cards, UI | | Exception: `.card--featured h3` | Space Grotesk | The news-lead card IS a communicative headline (D.3.2 role, not outline level) — weight 500, `--fs-xl` | | Exception: `.panel-title` (h2) | Inter | Panel titles are UI labels, not headlines — weight 600, `--fs-l`. These are the ONLY two exceptions | Type scale — the ONLY font sizes in the codebase (no ad-hoc rem values): | Token | Value | Use | |---|---|---| | `--fs-xs` | 0.8125rem | Meta, badges, labels, eyebrow, dates | | `--fs-s` | 0.9rem | Secondary text, nav, forms, footer, TOC | | (base) | 1rem | Body, line-height 1.5 (1.65 only on `.prose`; 1.55 `.standfirst`; 1.45 cards/footer) | | `--fs-l` | 1.125rem | Card titles, standfirst, panel titles, hero subtitle | | `--fs-xl` | 1.5rem | Prose h2, featured title, auth h1 | | `--fs-h2` | clamp(1.75–2.5rem) | Section heads | | `--fs-h1` | clamp(2.25–3.5rem) | Page/article headlines | | `--fs-display` | clamp(2.5–4.75rem) | Home hero only | Always end `font-family` with a generic (`sans-serif`) — lint requirement. Font loading (decided after the navigation-blink incident): self-hosted latin woff2, preloaded in `Base.astro`, `font-display: swap` + metric-matched Arial fallbacks for BOTH families (`Inter Fallback`, `Space Grotesk Fallback` — size-adjust/ascent/descent computed from the real font metrics, capsize method). Never `optional`: its ~100ms window loses to per-navigation revalidation (dev/preview serve `Cache-Control: no-cache`), which randomly committed whole pageviews to Arial — the "fonts flash between navigations" bug. With swap + matched metrics every page converges on brand fonts and the swap is layout-neutral. ## Spacing scale (fluid; mobile ≈ min, desktop ≈ max) | Token | Value | Use | |---|---|---| | `--gutter` | `clamp(18px, 4vw, 28px)` | Container/menu side padding | | `--space-s` | `12px` | Chip gaps, small stacks | | `--space-m` | `24px` | Card/panel padding, grid gaps | | `--space-l` | `24–40px` | Article top, meta/cover margins, featured body | | `--space-xl` | `clamp(56px, 7vw, 120px)` | Section rhythm: every `.page` block, footer top | | `--space-band` | `clamp(72px, 10vw, 160px)` | Hero/auth band + landing-head padding | Rule: section/container/card padding comes from this table — no ad-hoc px. Prose-internal margins (h2/p/blockquote) are content-local and stay as written. ## Radius & elevation Radius: `--radius 16px` (cards/panels), `--radius-pill` (buttons, chips, inputs). Only two shadows exist, both Galaxy-tinted, tuned soft/large/low-alpha (2025/26 elevation convention: big blur, single-digit alpha at rest): | Token | Use | |---|---| | `--shadow-ambient` | Resting cards, panels, search rows | | `--shadow-lifted` | Hover/focus cards, dropdowns (suggest, menu, search row), auth card | Never write a literal `box-shadow`; compose focus rings as `0 0 0 2px var(--azure), var(--shadow-lifted)`. ## Surfaces ONE surface per page — surface differentiation is by page archetype, never a mid-page seam (consistency heuristic: a surface change must read as deliberate system, not "why is this one gray?"): - **Editorial pages** (`/news`, articles, static pages, home news section) are white — reading surfaces. - **Utility/app pages** (`/search`, `/proposals`, `/dashboard`, `/support`) are `.band--moon` from the landing head down to the footer — the app canvas against which borderless white cards/panels pop (standard app-vs-editorial surface split; home's Services section uses the same Moon band). - `/` and `/login` own the navy bands (photo hero / nebula). Cards and panels are always white; ON MOON they are borderless (white-on-moon separates by itself), on white they keep the hairline. Chrome is single-signal: hover = the elevate gesture (shadow + 2px lift as ONE motion); no hover borders, no cover zoom. ## Motion policy (CSS-only, Apple-tier restraint) ctao.org drifts looping nebula videos under Galaxy vignettes; our translation: 1. **Hero glow (home)** — the photo hero keeps its scrimmed photograph; a `--nebula-glow` layer (Cherenkov .30 / Azure .28, no indigo over photography) drifts above it inside the `.band-bg` clip frame: 16s alternate, transform-only, clearly perceptible within ~10s. `.band-bg` (first child of the band) owns `overflow: hidden` — NEVER put overflow on the band itself (it would clip the search-suggest dropdown). 2. **Auth nebula (/login)** — the full `--nebula` stack (Cherenkov .38 / Azure .33 / Indigo .80), same 16s drifting layer; static gradient is the reduced-motion fallback. 3. **Whole-page ambient** — `.has-ambient::before`: fixed brand-hue wash (Cherenkov .16 / Azure .12 / Indigo .10) drifting on a 36s alternate cycle. Opt-in via ``: **home only**. Body text NEVER sits on a moving gradient — cards, panels and bands are solid layers above it. 4. One-shot hero `rise` stagger; unified hover system (below). 5. **CSS scroll-driven effects** (2026 pass; `animation-timeline`, no JS — "no scroll-triggered motion" below means no JS/observer motion): card reveal on viewport entry (`view()`, entry 0–35%), header shadow fading in over the first 80px of scroll, article reading-progress bar (2px Cherenkov under the header — functional navigation state, same budget line as the TOC scrollspy). All behind `@supports (animation-timeline: view())`, compositor-only (the header shadow animates a pseudo-element's OPACITY, never box-shadow in keyframes), auto-killed by the reduce block. 6. Micro-interactions: nav underline-grow (2px white rule, `scaleX` 0→1 on hover, held on `aria-current`) and 1px press-down on `.btn`/navlink `:active`. Nothing else. **One hover language** (mid-pass audit: hovers were inconsistent per element; ctao.org uses a single quiet-fade token — we do the same): every interactive element transitions color/background/border/shadow on `--dur: 0.25s` + `--ease: ease-out` — buttons and links fade, cards/search rows elevate (shadow + 2px lift), pagination fills Moon. No width/layout animation. Rules: - ALL `animation`/`transition` declarations live inside the single `@media (prefers-reduced-motion: no-preference)` block at the end of global.css; the `reduce` block additionally force-disables everything. - NO cross-document view transitions (`@view-transition`). The root crossfade snapshots the incoming page at its first render opportunity — on slow networks that is a half-parsed page, so every navigation read as a full-page blink (the diagnosed symptom); scrolled→top navigations crossfade two unrelated frames even when it works. Navigation feel comes from static HTML + Astro prefetch, not from animating the swap. - Animate `transform`/`opacity` only (compositor-only); never background-position/box-shadow in keyframes. No `will-change`. No `filter: blur` on large layers and no `background-attachment: fixed` (broken on iOS). No JS, no scroll-triggered motion. - If an effect calls attention to itself as an effect, cut it. ## Photographic hero (home) Image-led hero per brand D.6 (photography theme: the project): LST-1 under the night sky, copied from the content library to `public/brand/hero.jpg` (NEVER link `public/uploads` directly — that directory is content-synced). Text safety comes from `--hero-scrim` (Galaxy, `to right`: .84 → .76 @52% → .44 @78% → .26); ≤640px a flat .80 scrim replaces it (text spans the full width on mobile). The logo never sits on the photograph — it stays in the header bar (B.1.2 satisfied by construction). Computed worst-case contrast over photo + scrim + drifting glow (glow fringe α .15 Cherenkov / core .28 Azure over the text zone; Cherenkov core is pinned ≥80% band width by the blob geometry + drift range): - Small-text zone (eyebrow/subtitle/CTA labels, ≤52% band width): white 7.68:1 · Cherenkov subtitle 4.79:1 · eyebrow `#C7CDE9` 4.88:1 (all ≥4.5). - Display h1 zone (≤72% band width): white ≥5.20:1 (large text needs 3.0). - Mobile flat scrim: white 8.47:1 · subtitle 5.28:1 · eyebrow 5.38:1. Recompute (script kept in the session scratchpad pattern: composite scrim → glow → WCAG luminance per pixel) if the photo, scrim stops, glow alphas or blob positions change. ## WCAG 2.1 AA notes (computed, worst-case animation frames) - Photo hero: see table above. Auth nebula over Galaxy: white 7.9:1, Cherenkov subtitle 4.9:1 at the worst blob frame; Cherenkov/Azure CORES stay in opposite corners. Ceilings: nebula ≤ .38 Cherenkov / ≤ .35 Azure; glow ≤ .30/.28; wash ≤ .16/.12/.10 — recompute if any alpha or placement changes. - Translucent header (`color-mix` 88% Galaxy + backdrop blur, solid fallback via `@supports`): worst underlying content (white) → bar ≈ rgb(31,31,96); white text 14.8:1, muted nav `#C7CDE9` 9.4:1, Cherenkov sub-brand 9.2:1. - On Moon: `--link` 6.1:1, `--muted` 7.1:1, `--text` 16.9:1. Ambient wash worst overlap: `--link` 5.3:1, `--muted` 6.2:1. - Focus visible everywhere; tap targets ≥ 44px; skip link first. ## Article TOC pattern Rendered only when ≥3 h2/h3. Two renderings of the same list (only one ever displayed), both in the quiet Stripe/Apple-docs voice — muted small links, Galaxy on hover, no heavy box: - `