Files
portal/DESIGN.md
T

298 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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). 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.
- 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.
## 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 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).