# ASTERION design system

This document describes the design actually implemented in this repository so that another contributor can build a second ship-console screen that belongs to the same product. Values are taken from the source files named below; rendered behaviour was confirmed in Chromium at 320, 390, 768, 1024 and 1440 px (see `artifacts/validation.md` for coverage limits).

## Overview

ASTERION is a playable spaceship command deck for a general audience: dark bridge, cold cyan instrument light, a warm K-type star as the only warm accent, and dense but legible technical labels. It is a single page composed of instrument **panels** arranged around a central **star chart**. Hierarchy comes from three things: the panel header (mono code + title), the large tabular readout number, and the one filled accent action per view (the jump button).

System-wide rules:

- One dark theme only (`color-scheme: dark`). There is no light theme and none should be added without re-measuring every pair.
- Two typefaces: Space Grotesk for UI and prose, JetBrains Mono for anything technical (labels, codes, changing numbers).
- Cyan means interactive or "selected/route"; green, amber and red mean status and nothing else.
- Decorative atmosphere (star twinkle, route flow, scan lines, telltale lamps) is opt-in, pausable, and never carries meaning.

Page-specific arrangements (the three-column bridge, the chart in the centre) are documented under Layout; they are the pattern for a console screen, not a rule for a marketing page.

## Colors

Tokens live in `src/styles/tokens.css`. Primitives are named by hue and never used in components; components reference semantic roles only. Canonical notation is hex.

| Role token | Value | Use |
| --- | --- | --- |
| `--color-bg-page` | `#070b14` (`--navy-950`) | Page background, marker halo ring, focus-ring gap |
| `--color-bg-surface` | `#0d1424` (`--navy-900`) | Panels, masthead, dialogs, chart route label |
| `--color-bg-raised` | `#131c31` (`--navy-800`) | Cards inside panels: destinations, dials, detail card, log entries, schematic |
| `--color-bg-control` | `#1a2540` (`--navy-700`) | Secondary buttons, gauge tracks |
| `--color-bg-control-hover` | `#24314d` (`--navy-600`) | Secondary button hover |
| `--color-bg-selected` | `#124a5c` (`--cyan-700`) | Reserved for selected surfaces (the selected destination card uses `rgb(18 74 92 / 0.35)` over raised) |
| `--color-bg-scrim` | `rgb(7 11 20 / 0.82)` | Dialog backdrop and jump overlay |
| `--color-border` | `#24314d` | Panel and card borders, dividers, chart grid |
| `--color-border-strong` | `#35496e` | Button borders, orbits, tags, slider cap |
| `--color-border-selected` | `#5fd8f5` | Selected destination card border |
| `--color-text-primary` | `#e8eefc` | Body text, values, titles |
| `--color-text-secondary` | `#a9b6cf` | Descriptions, effects, captions |
| `--color-text-muted` | `#8593ad` | Mono labels, log timestamps, hints |
| `--color-text-on-accent` | `#06121c` | Text on the filled primary button |
| `--color-accent` / `--color-accent-solid` | `#5fd8f5` (`--cyan-300`) | Interactive emphasis: primary button fill, selected marker, route, panel codes, dial values |
| `--color-accent-solid-hover` | `#8ae4f8` | Primary button hover |
| `--color-accent-dim` | `#1b8fb0` | Text selection background |
| `--color-status-ok` | `#63e6a0` | Nominal: hull ≥ 90, fuel ≥ 30, low hazard, success log entries, "Ready" tag |
| `--color-status-warn` | `#f5c24b` | Caution: engines below 30, hold state, moderate hazard, drive bells, the star |
| `--color-status-danger` | `#ff7b7b` | Critical: hull < 70, fuel < 10, high hazard, reset confirm button |
| `--color-focus` | `#ffffff` | Focus ring |

Measured contrast (WCAG 2, rendered pairs, from the verification run): primary text on surface 15.8:1, secondary on raised 8.3:1, muted on raised 5.5:1 and on control 4.9:1 (`.presets__numbers`, the tightest pair), accent on raised 10.2:1, on-accent text on accent 11.4:1, warn on the composited hold background 9.9:1. Every measured text pair exceeds 4.5:1. Semi-transparent tints (`.hold`, `.arrived`, selected card) are always composited over `--color-bg-surface` or `--color-bg-raised`; do not place them over the chart.

Gradients (page glow, star glow, slider track) use the sRGB default deliberately; they are decorative.

## Typography

Fonts are bundled from Fontsource as latin-only variable `.woff2` files, declared in `src/styles/tokens.css`:

- `--font-ui`: `'Space Grotesk Variable'`, weights 300–700 available, used at 400, 600 and 700.
- `--font-mono`: `'JetBrains Mono Variable'`, weights 100–800 available, used at 400, 500 and 600.

Root smoothing is set once in `src/styles/base.css` (`html`). Loaded-font checks passed in the verification run (`document.fonts.check`).

Size scale (rem, 16px base) and roles:

| Token | px | Role |
| --- | --- | --- |
| `--text-xs` | 12 | `.label` mono uppercase labels (letter-spacing `0.08em`, weight 500), tags, log time |
| `--text-sm` | 13 | Captions, effect lines, hints, footer |
| `--text-base` | 14 | UI body: log text, hold list, stats values, buttons |
| `--text-md` | 16 | Descriptions, dial labels, destination names, modal text, wide primary button |
| `--text-lg` | 18 | Panel titles (`h2`), detail title (`h3`) |
| `--text-xl` | 22 | Dial values, modal titles |
| `--text-2xl` | 28 | Ship name `h1` (letter-spacing `0.12em`, weight 700); 22 px below 46 rem |
| `--text-3xl` | 36 | Jump overlay stage title |

Line heights: `--leading-tight` 1.1 for headings and titles, `--leading-body` 1.5 for everything that wraps. Headings use `text-wrap: balance`; descriptions and hints use `text-wrap: pretty`; the detail description is capped at `48ch`. Every changing number carries `.readout` (`font-variant-numeric: tabular-nums`, mono). Copy is stored in natural case; uppercase comes from `text-transform` on `.label` and `.tag`. Labels default to `white-space: nowrap`, relaxed inside `.stats` and `.gauge` where a second line beats clipping.

## Layout

Spacing tokens (`--space-1` … `--space-10`: 4, 8, 12, 16, 20, 24, 32, 40 px). Panels pad with `--space-4`; groups inside a panel are separated by `--space-4`, items within a group by `--space-2`/`--space-3`, so inter-group gaps are at least twice intra-group gaps. Page gutter is `--page-gutter` (16 px) with `env(safe-area-inset-bottom)` on the bottom; content is capped at `--page-max` 1600 px and centred.

Layout primitives in `src/styles/app.css`:

- `.deck`: page column (masthead, `main.bridge`, footer).
- `.bridge`: CSS grid with named areas. DOM order is the mobile reading order: chart, destinations, power, jump, status, log.
  - Below 46 rem (736 px): one column, in DOM order.
  - 46–71.99 rem: two columns, `chart chart / destinations power / jump status / log log`.
  - 72 rem (1152 px) and up: `destinations chart side / log jump side` with columns `minmax(300px, 22rem) minmax(0, 1fr) minmax(300px, 22rem)`. `.bridge__side` wraps power and status as one flex column; below 72 rem it is `display: contents` so power and status keep their own areas and reading order.
- `.masthead`: grid areas `identity readouts actions`; below 72 rem readouts drop to a second row; below 46 rem all three stack and align to the leading edge.
- `.stats`: two-column definition list (`.stats--inline` auto-fits 7 rem columns; `.stats--rows` is a one-column key/value list with dividers).
- `.status-grid`: two gauges per row, one below 22 rem.

The star chart stage (`.chart__stage`) keeps a 4:3 aspect with a 360 px minimum height, and grows to fill the centre column on desktop (520 px minimum). The SVG viewBox is set to the stage's CSS size by a `ResizeObserver` (`useStageSize` in `src/components/StarChart.tsx`), so SVG geometry and the HTML marker buttons share one pixel coordinate space at every width and chart text renders at real pixel sizes. Points are stored as percentages in `src/data/ship.ts`. Markers whose `x` exceeds 75 % flip their label to the leading side (`.chart__marker--flip`).

Overflow: `body` is `overflow-x: hidden` as a guard; the verification run found no element wider than the viewport at 320, 390, 768, 1024 or 1440 px. The log list scrolls internally (`max-height: 22rem`, `overscroll-behavior: contain`). Logical properties (`inset-inline-start`, `margin-inline-end`, `padding-inline-start`) are used for direction-dependent spacing; RTL was not rendered.

## Elevation & depth

Three tonal layers do most of the work: page → surface → raised → control, each one step lighter. Borders (`--color-border`) mark structure on panels and cards; state borders use the accent or a status colour. Shadows are reserved for elevation:

- `--shadow-panel`: `0 1px 0 rgb(255 255 255 / 0.04) inset, 0 12px 32px rgb(0 0 0 / 0.35)` on panels and the masthead.
- `--shadow-dialog`: stronger version on dialogs, the jump card and modals.
- `--glow-accent`: `0 0 18px rgb(95 216 245 / 0.35)` only on the primary button and the ship glyph (as `drop-shadow`).

Overlays: `dialog::backdrop` and `.jump-overlay` use `--color-bg-scrim`. Z-order: chart SVG (0) → ship marker (2) → marker buttons (3) → jump overlay (50) → skip link (100); native `<dialog>` sits in the top layer. The scan-line texture on the chart is a `::after` at 2.5 % opacity with `pointer-events: none`.

## Shapes

Radii are concentric (outer = inner + padding): `--radius-1` 4 px (tags), `--radius-2` 8 px (buttons, log entries), `--radius-3` 12 px (cards inside panels), `--radius-4` 16 px (panels, masthead, dialogs, chart stage), `--radius-pill` for slider tracks and chart markers. Borders are 1 px; selected and warning states keep 1 px and change colour. Focus is a 2 px solid white outline offset 2 px with a 2 px page-colour gap (`:focus-visible` in `src/styles/base.css`), switching to `Highlight` under forced colours.

## Components

All components are React function components with plain CSS classes; there is no component library.

- **`Panel`** (`src/components/ui/Panel.tsx`, `.panel`): `<section aria-labelledby>` with header (`code` mono label in accent, `title` as `h2`, optional `aside`) and body. Every instrument uses it. Codes follow `SYS · 0N`.
- **`Button`** (`src/components/ui/Button.tsx`, `.btn`): variants `primary` (filled accent, one per view), `secondary` (control fill + strong border, default), `ghost` (transparent, masthead actions, inactive presets), `danger` (outlined red, destructive confirm). Sizes `md` (44 px min) and `sm` (36 px). Modifier `.btn--wide` for the 52 px full-width jump action. States: hover only under `(hover: hover)`, `scale: 0.96` on press, `aria-pressed` gets the accent border, `disabled`/`aria-disabled="true"` at 0.55 opacity without shadow. Use `aria-disabled` when the reason must stay reachable (jump button); native `disabled` when the control is genuinely unavailable (marker buttons and fieldsets during a jump, +/− at the budget edge).
- **`Meter`** (`src/components/ui/Meter.tsx`, `.gauge`): labelled bar with `role="meter"`, tabular value, tone `ok | warn | danger | accent`, optional note.
- **`Modal`** (`src/components/ui/Modal.tsx`, `.modal`): native `<dialog>` opened with `showModal()`; background is inert, Escape closes, focus moves to `initialFocusSelector` (least destructive action on confirmations) and returns to the opener on close. `.modal--wide` for the help panel. Dialogs in `src/components/dialogs.tsx`: `ConfirmJumpDialog`, `ResetDialog`, `HelpDialog`.
- **Destination card** (`src/components/DestinationPanel.tsx`, `.dest`): a `<label>` wrapping a native radio (`.dest__input`), so the whole card is the hit target and arrow keys move selection. Selected state: accent border and tint; `.tag--here` marks the current position.
- **Chart marker** (`src/components/StarChart.tsx`, `.marker`): `<button aria-pressed>` with a 14 px dot anchored on the plotted point, name and code label, 44 px minimum hit area; `.marker--selected`, `.marker--here`, and `.chart__marker--flip`.
- **Power dial** (`src/components/PowerPanel.tsx`, `.dial`): label, tabular value, −/+ buttons (44 px), native range input whose `max` is `value + unallocated`, and an effect line (`aria-describedby`). `.dial--warn` when engines are below the jump minimum. Presets are `aria-pressed` buttons in a 2×2 grid (4 across at tablet widths).
- **Hold list / arrived banner** (`src/components/JumpPanel.tsx`, `.hold`, `.arrived`): amber-bordered group listing plain reasons; the arrived banner is green-bordered. The hold group is focusable (`tabIndex=-1`) so a blocked activation can land on it.
- **Tag** (`.tag`): mono uppercase chip; tones `ok`, `hold`, `here`, `low`, `moderate`, `high` set colour and border via `currentColor`.
- **Log entry** (`src/components/EventLog.tsx`, `.log__entry`): timestamp + text with a 2 px leading border in the entry tone; the list is `aria-live="polite"`.
- **Jump overlay** (`src/components/JumpOverlay.tsx`): `aria-hidden` visual sequence; the stable `role="status"` region in `src/App.tsx` carries the announcements.
- **Ship glyphs** (`src/components/ShipIcon.tsx`): `ShipIcon` (currentColor, rotates with heading) and `ShipSchematic` (status panel, hull tone and shield envelope).

Motion (`src/styles/app.css`): interactive transitions are 120 ms on named properties with `cubic-bezier(0.2, 0, 0, 1)`. Ambient keyframes (twinkle, route flow, pulse, lamp) run only inside `@media (prefers-reduced-motion: no-preference)` and only when `<html data-motion="on">`, controlled by `useAmbientMotion` (visitor toggle in the masthead, remembered in `localStorage`). The jump streaks and card entrance are also gated by the same media query; under reduced motion the sequence is text-only and shorter (`JUMP_TIMING` in `src/hooks/useSimulation.ts`).

## Do's and don'ts

- Start a new console screen from `.deck` → `.masthead` → `main.bridge` with named grid areas; put each instrument in a `Panel` with a mono code and a sentence-case title.
- Keep one `primary` button per screen. Everything else is `secondary` or `ghost`; `danger` only confirms a destructive reset.
- Use `.label` for every technical caption and `.readout` for every number that can change. Do not set `font-family` or `font-size` ad hoc; use the `--text-*` scale.
- Status colours are meaning, not decoration: green nominal, amber caution/hold, red critical. Do not use cyan for status or amber for interactive elements. The star's amber is the one deliberate exception and is non-interactive.
- Put decorative content under a visible "decorative" caption and `aria-hidden`, as the telltales do. Never make it look like a control.
- Explain a blocked action beside the control in plain sentences that say what to do; keep the control focusable with `aria-disabled` and point at the reasons.
- Wrap any new overlay in `Modal`; do not build a custom trap.
- Keep new motion behind `data-motion="on"` and the reduced-motion media query; interactive feedback must stay 150 ms or less.
- Do not add a light theme, a second accent hue, or unmeasured semi-transparent text backgrounds.

Recipe for another matching screen (for example an engineering deck): copy `src/App.tsx`'s structure; add panels with codes `ENG · 0N`; reuse `Meter` for telemetry, `.dial` for any budgeted control, `.stats--rows` for key/value readouts and `.log__entry` for history; add tokens to `src/styles/tokens.css` only for a role that has no token; run `npm run check` and the browser checks in `artifacts/validation.md` before committing `dist/`.
