20 KiB
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: <mark>, 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--moonfrom 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/loginown 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:
- Hero glow (home) — the photo hero keeps its scrimmed photograph; a
--nebula-glowlayer (Cherenkov .30 / Azure .28, no indigo over photography) drifts above it inside the.band-bgclip frame: 16s alternate, transform-only, clearly perceptible within ~10s..band-bg(first child of the band) ownsoverflow: hidden— NEVER put overflow on the band itself (it would clip the search-suggest dropdown). - Auth nebula (/login) — the full
--nebulastack (Cherenkov .38 / Azure .33 / Indigo .80), same 16s drifting layer; static gradient is the reduced-motion fallback. - 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<Base ambient>: home only. Body text NEVER sits on a moving gradient — cards, panels and bands are solid layers above it. - One-shot hero
risestagger; unified hover system (below). - 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. - Micro-interactions: nav underline-grow (2px white rule,
scaleX0→1 on hover, held onaria-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/transitiondeclarations live inside the single@media (prefers-reduced-motion: no-preference)block at the end of global.css; thereduceblock 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/opacityonly (compositor-only); never background-position/box-shadow in keyframes. Nowill-change. Nofilter: bluron large layers and nobackground-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
#C7CDE94.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-mix88% Galaxy + backdrop blur, solid fallback via@supports): worst underlying content (white) → bar ≈ rgb(31,31,96); white text 14.8:1, muted nav#C7CDE99.4:1, Cherenkov sub-brand 9.2:1. - On Moon:
--link6.1:1,--muted7.1:1,--text16.9:1. Ambient wash worst overlap:--link5.3:1,--muted6.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:
<nav class="toc toc--rail">(≥1200px): sticky whitespace column with a hairline LEFT rule, outside the 70ch column via the.article-layoutgrid; sticky offset 80px (56px header + 24), own scroll when long.<details class="toc toc--inline">(<1200px): light disclosure row ("On this page" + chevron from the.icofamily), comfortable tap rows.
Scrollspy (Starlight pattern, minimal vanilla): an IntersectionObserver band
under the sticky header maps the visible prose block to its governing heading
and sets aria-current="true" on the TOC link — Galaxy + 600, and on the rail
a 2px Cherenkov segment over the hairline. Functional navigation state, not
decoration (outside the one-accent budget), same precedent as the search
<mark> highlight (Cherenkov tint) and the .pagination current pill.
The TOC works fully without JS.
Header
56px bar (translucent Galaxy via color-mix + backdrop blur where supported,
solid Galaxy fallback — see WCAG notes). One row always: .nav > * is
flex: none, only the spacer flexes; nothing can wrap. Tiers: ≥1025px full
nav · ≤1024px disclosure menu (<details>) · ≤640px sub-brand hidden ·
≤480px icon-only menu · ≤360px compact paddings. Logo ≥22px tall everywhere
(≈105px wide — brand minimum 100px digital).
Header search — decided by research, not aesthetics: NN/g's magnifying-glass
-icon findings (icon-only search measurably reduces discoverability and use;
show a visible box when search is an important task) beat the icon-only
minimal header for this content-heavy, search-required portal (SPEC §3.3.1).
Desktop ≥1025px: a VISIBLE quiet field (hairline on navy, 140–200px flex —
the only flexing item, so the row never wraps). ≤1024px: the 44px magnifier
<details> reveals a search row under the bar (NN/g mobile-search pattern).
Both are plain GET /search (full no-JS path); <details name="header-panel">
makes search + menu a native exclusive pair. /search stays the full-results
and deep-link target. The footer "Search" link STAYS: SPEC §3.3.1 literally
places "a search functionality" in the bottom menu, and the footer is the
end-of-page fallback (NN/g footers) — one quiet word, clean compliance trail.
Sign in is a ghost/outline pill — Cherenkov fill is reserved for the page's
primary CTA.
Nav content rule: the header nav reflects end-user tasks only (NN/g). The content editor is internal-team tooling (SPEC §3.2) → discreet "Content editor" utility link in the footer; /admin keeps working as the direct URL.
Content & microcopy
- Card metadata must differentiate (NN/g): cards show date · reading time (body words ÷ 220 wpm, computed at build). Category and author remain on the article page as one quiet muted meta line (category · author · date · read time — plain text, no chips).
- Honesty lives at the INTERACTION POINT (REQUIREMENTS §5): whisper badges ("Mock — <what's missing>", "Planned integration — ") appear where a mock interaction actually happens — /proposals panel, /dashboard, /login, /support page head. Navigation tiles carry no status chrome; planned services are subdued tiles (muted, no link) with "— planned" folded into the description. There is NO phase banner (removed as clutter — the footer demo note + interaction-point badges carry the disclosure).
- Terminology fixed: "Sign in" (never Log in), "Editor", "Search", "News". Buttons start with a verb, sentence case. Real ellipsis (…), never "...".
- Search links (
.suggest a,.search-results a) lay out as flowing text — neverdisplay: flexon the<a>(mark-splitting). Dates are plain muted text, not chips. - Overflow containment happens at the source: wide
.prosetables scroll in their own box, long words/URLs wrap viaoverflow-wrap. Thehtml, body { overflow-x: clip }rule is a regression guard, not a fix. - Articles end with
.post-nav(Newer ← → Older). Lead image is borderless and breaks out of the text column ≥900px. Image captions: italic muted line under the image.
Element rules (condensed)
| Element | Rule | Source |
|---|---|---|
| Header nav | End-user tasks only; order News · Data · Proposals · Dashboard · Support | NN/g serial-position |
| Header search | Icon-reveal row, all widths; plain GET /search | NN/g site search |
| Hero (home) | Photo (LST under night sky) + Galaxy scrim + eyebrow + headline + Cherenkov subtitle + CTA pair: primary "Browse news & announcements" (/news — the spec-first, fully real service; SPEC §3.1/§3.3.1), ghost "Explore the data ↗" (real external tool). Mocks never get hero billing. Nothing else — no teaser, no widgets, no second search | Apple hero restraint, SPEC §3.1 |
| Badges | Quiet hairline pill, muted sentence case, Galaxy dot on mocks; ONLY at mock interaction points | REQUIREMENTS §5 |
| Cards | Cover · title (clamp 3) · desc (clamp 2) · date · read time; borderless on Moon; hover = elevate only | NN/g metadata |
| Pagination | Newer/Older + windowed numbers, 44px targets, aria-current, Moon hover fill. Page 1 holds 25 items (featured lead + 24 = even 3-col rows), later pages 24 — custom slicing, paginate() can't vary size | NN/g pagination |
| Article | Back "← All news"; h1 → standfirst → muted meta line → borderless breakout cover → inline TOC → prose → post-nav | Guardian/BBC anatomy |
| Forms | Visible labels above fields; placeholders are examples only; buttons start with a verb | GOV.UK forms, NN/g |
| Proposals | Integration landing, NOT a submission form — PHS is APC's product, the portal only integrates it: intro + bullets of the portal's contributions (menu entry + AAI single sign-on hand-off, Dashboard status feedback, news notifications) + disabled primary CTA ("… — integration planned", real disabled semantics, Moon fill); whisper badges stay |
SPEC §3.3.3 |
| Login | One line + Cherenkov primary CTA + one small-print line; whisper badge top-right of the card (in flow ≤480px); no reassurance prose | auth brevity |
| Footer | Identity line → Contact · Search · RSS · Disclaimer · Privacy · Site settings · Content editor → © line | NN/g footers, SPEC §3.3.1 |
| Search results | Links lay out as flowing text (mark-safe); plain muted date; "Press Enter for all results" as a real list row (aria-hidden like group labels — the status line announces) | — |
Page composition (archetypes)
Every page follows one of three archetypes; a new page picks one instead of
inventing a section order. The navy band is reserved for / and /login.
| Archetype | Pages | Order |
|---|---|---|
| Portal landing | / |
Photo hero (h1 + subtitle + CTA pair) → Services tiles (Moon) → Latest news 3-up + "All news" (white) |
| Section landing | /news, /search, /proposals, /dashboard, /support |
Landing head (.page-head: display Galaxy h1 + standfirst, --space-band top padding, no eyebrow, no navy band) → content → footer. The WHOLE page sits on its archetype surface: white (editorial /news) or Moon (utility) — head included, one surface per page |
| Document | /news/<slug>, /pages/<slug> |
Back link → h1 → standfirst → meta → cover → inline TOC → prose (→ post-nav on articles); no hero band — the document IS the page |
- Home is task-first (NN/g): Services directly under the hero; the news section below is the browse layer.
- Document order is press anatomy (Guardian/BBC/Reuters). The inline TOC sits between lead image and body.
- Back affordances: articles "← All news", static pages "← Home". Auth
(
/login) is the sole exception to the archetypes: one focused card on the nebula band, no hero, no sections. - Service tiles carry a stroke glyph (
.icofamily: currentColor, round caps, aria-hidden; 26px Galaxy) ABOVE the label — never instead of it. Glyphs are wayfinding, not decoration.
Serving the demo
Phone/tunnel demos run the production build: npm run demo
(= astro build && astro preview, same port 4321 so the tunnel URL keeps
working). astro dev through the tunnel adds HMR + dev-toolbar module
traffic and serves everything no-cache — every navigation re-negotiates
assets over the tunnel, which reads as blinking/slow paints that do NOT
exist in the built site. Dev mode is for local editing only.
Do not
- No new hues, tints, or grays — derive via
color-mixfrom brand tokens only. - No Space Grotesk outside h1/h2 (sole exception:
.card--featured h3— see Typography); headline weight stays 500 (brand D.3.4). - No font sizes outside the type-scale tokens; no ad-hoc spacing/shadow/radius.
- No animation/transition outside the
no-preferenceblock; no JS motion; no transition durations/easings outside--dur/--ease; no@view-transition(see Motion policy). - No parser-blocking
<script>in the body — enhancement scripts aredefer/type="module"(a sync script stalls rendering below the header on every navigation). - No dependencies/frameworks (CSS or JS). No removal of mock disclosure at interaction points. No phase banner — do not reintroduce it.
- No Cherenkov outside: primary CTA, flash, hero subtitle, logo cluster (+ the two functional states listed under Color tokens). No hover borders.
- Don't lighten text over navy/photo bands below the computed ceilings above.
- No clip-path/CSS approximations of brand assets — the flash is the real SVG path from the official logo, or nothing.
- Decoration budget: max ONE accent element per view (currently: footer flash, static). No twinkles, floating shapes, or stacked decorations.
- Never link
public/uploadsfrom layout/brand surfaces — copy topublic/brand/instead (uploads is content-synced).