content split + copy audit + css polish: content (news/pages/uploads) moves to the ctao/content repo (build overlays it — two-repo build.sh, link-content.sh for local dev); portal copy trimmed to source-documents-only (dashboard empty states, minimal privacy/disclaimer, no mock badges); css: inverted selection on navy bands, featured-card text-track floor, 44px toc target, mobile search dates on own line, footer link row gap; DESIGN.md rewritten as a short handoff

This commit is contained in:
2026-09-03 18:54:01 +02:00
parent 2e6d374b4b
commit ec4ff5dfa2
93 changed files with 259 additions and 464 deletions
+27 -332
View File
@@ -1,337 +1,32 @@
# CTAO Science Portal — design system
# Design handoff
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.
All styling lives in **`src/styles/global.css`** — design tokens in `:root`
(colors, type scale, spacing, radius, shadows, `--header-h`) + plain-CSS
components below them. No frameworks, no preprocessors, no runtime JS for
presentation. Pages are `.astro` templates (plain HTML).
## Color tokens (brand-fixed — never add hues)
## Brand (official: CTAO Brand Guidelines, April 2024 — PDF in the project docs)
| 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 |
- Colors: Galaxy Blue `#00004A`, Cherenkov Blue `#00E4D8`, Moon Gray
`#F5F5F5`, Cosmic Azure `#007AFF` (links/focus), Interstellar Indigo
`#00009C`. Cherenkov is an ACCENT (brand book rations it — currently:
primary CTA, hero subtitle, logo flash); it fails AA as text on white.
- Type: **Inter** for everything, **Space Grotesk** (weight 500) only for
communicative headlines (h1/h2). Sizes come from the `--fs-*` scale.
- Logo files in `public/brand/`; always "CTAO", min 100px wide, no effects.
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.
## Practical notes (the non-obvious ones)
## 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, article headlines (news titles run long — full `--fs-h1` would fill the viewport) |
| `--fs-h1` | clamp(2.25–3.5rem) | Short page heads only ("News & Announcements") |
| `--fs-display` | clamp(2.5–4.75rem) | Home hero only |
Font tokens end with a generic (`sans-serif`); use them bare —
`var(--font-body), sans-serif` doubles the generic.
Font loading (prevents any navigation blink): BOTH families are inlined as
base64 `data:` URIs inside the render-blocking `global.css` — no separate
font fetch exists, so there is no swap window and no font flash between
navigations. Metric-matched Arial fallbacks (`Inter Fallback`,
`Space Grotesk Fallback` — size-adjust/ascent/descent computed from the real
font metrics, capsize method) cover only the pre-CSS paint. The standalone
`public/fonts/*.woff2` files are referenced ONLY by
`public/admin/preview.css` (CMS entry preview) — they look unused but aren't.
## 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 `<Base ambient>`: **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:
- `<nav class="toc toc--rail">` (≥1200px): sticky whitespace column with a
hairline LEFT rule, outside the 70ch column via the `.article-layout` grid;
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 `.ico` family), 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 — <team>") 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 —
never `display: flex` on the `<a>` (mark-splitting). Dates are plain muted
text, not chips.
- Overflow containment happens at the source: wide `.prose` tables scroll in
their own box, long words/URLs wrap via `overflow-wrap`. The
`html, 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 (`.ico` family: 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 build && npm run 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-mix` from 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-preference` block; 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 are
`defer`/`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/uploads` from layout/brand surfaces — copy to
`public/brand/` instead (uploads is content-synced).
- **`public/admin/preview.css` mirrors the `.prose` styles** so the CMS
editor preview matches articles 1:1 — when you touch `.prose` or its
tokens, update the mirror (both files carry a KEEP IN SYNC comment).
- Fonts ship **inlined as data: URIs** in global.css (no font requests, no
swap flash). The woff2 files in `public/fonts/` look unused but feed the
CMS preview — keep them.
- All `animation`/`transition` rules sit in the single
`prefers-reduced-motion: no-preference` block at the end of global.css.
- Scroll-driven effects need `vite.build.cssMinify: 'esbuild'`
(astro.config.mjs) — the default minifier breaks `animation-timeline`.
- `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading
progress); change the header height only through the token.
- Deterministic gate before pushing: `npm run check` (build + link check).