Design concept and design system in docs/design/: principles (evidence
first, neutral, fair to the people shown, plain language, time as a
dimension, accessible and private), information architecture, core
patterns and a concept for every page; tokens with checked contrast,
component catalogue, content and accessibility rules.
web:
- neutral ink-indigo accent instead of flag red; party colours only as
small markers next to the name; light/dark theme with a switcher
- stylesheets split into tokens, base, layout, components, print
- new header (search, filter count, language, theme, mobile menu) and
footer; breadcrumbs, page headers, tabs with counts, filter bars that
collapse on phones, pagination, notices and empty states
- claim cards with source meter, legal stage, innocence notice inside
the card and a permalink; claim pages with citation (/angaben/{id})
- profiles with role timeline and key figures; party and organisation
dashboards; vote list with position chips; calendar agenda on phones;
tables that stack into cards; loading and error pages; favicon
backend:
- GET /api/v1/claims/{id} and MCP tool get_claim for permalinks
- parties list: evaluate person_status once instead of per party
(30 s -> 1 s on the sample database, identical result)
- parliament calendar: HTML-decode titles ("84. Sitzung")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
17 KiB
Markdown
306 lines
17 KiB
Markdown
# Politrack design system
|
||
|
||
The implementation reference for the web app: tokens, components, CSS conventions, content rules and
|
||
recipes. The concept behind it (goals, principles, page concepts) is in [README.md](README.md); how to add
|
||
pages and claim types step by step is in [contributing/frontend.md](../contributing/frontend.md).
|
||
|
||
## 1. Architecture
|
||
|
||
```
|
||
web/src/styles/
|
||
tokens.css design tokens (custom properties), light + dark
|
||
base.css element defaults: typography, links, tables, forms, buttons, text utilities
|
||
layout.css page frame (header, menu, footer) and layout utilities
|
||
components.css one section per component in src/components
|
||
print.css print: content only, link targets written out
|
||
web/src/components/ server components (a few client components, marked 'use client')
|
||
web/src/app/icon.svg favicon (the brand mark)
|
||
```
|
||
|
||
All five stylesheets are imported once, in order, by `src/app/[locale]/layout.tsx`.
|
||
|
||
* **Plain global CSS** with custom properties: no CSS framework, no CSS-in-JS, no npm dependency beyond
|
||
Next.js, React and next-intl. The UI must keep working where npm is slow or blocked.
|
||
* **Class names**: one block per component in kebab-case (`.claim`, `.claim-head`, `.vote-row`);
|
||
variants with `--` (`.badge--positive`, `.notice--legal`); states with `is-` (`.is-current`,
|
||
`.is-disputed`); `aria-current` for the active link or tab.
|
||
* **Only tokens** for colour, spacing, radius and type. The exceptions are deliberate: party colours
|
||
(from the data), platform brand colours, and the favicon.
|
||
* **Server first.** Pages and most components are async server components. Client components exist only
|
||
where the browser must act: `ThemeSwitcher`, `LocaleSwitcher`, `NavLink`, `SettingsForm`, the error page.
|
||
* **No JavaScript needed** for navigation, filters, tabs, disclosures or the mobile menu: links, GET forms,
|
||
`<details>` and one checkbox toggle.
|
||
|
||
## 2. Tokens
|
||
|
||
### Colour
|
||
|
||
| Token | Light | Dark | Use |
|
||
|---|---|---|---|
|
||
| `--paper` | `#f6f5f1` | `#131311` | page background |
|
||
| `--surface` | `#ffffff` | `#1b1a17` | cards, panels, tables, header |
|
||
| `--surface-2` | `#efede7` | `#24231f` | subtle fills: chips, table heads, compact stats |
|
||
| `--surface-3` | `#e4e1d9` | `#2e2c27` | avatar placeholder, skeleton highlight |
|
||
| `--line` | `#dcd8cf` | `#36332d` | borders and rules |
|
||
| `--line-strong` | `#b9b3a6` | `#57534a` | emphasised rules, decorative outlines |
|
||
| `--line-field` | `#878073` | `#7d786f` | form field borders, switch track (≥ 3:1) |
|
||
| `--ink` | `#1c1b18` | `#ecebe6` | text |
|
||
| `--ink-2` | `#47443d` | `#c9c5bb` | secondary text |
|
||
| `--ink-3` | `#66625a` | `#a29d92` | muted text, meta data |
|
||
| `--accent` | `#4338ca` | `#a5b4fc` | links, focus, active navigation, bars |
|
||
| `--accent-hover` | `#3730a3` | `#c7d2fe` | hover of links and buttons |
|
||
| `--accent-soft` / `--accent-ink` | `#ebeafd` / `#312e81` | `#25264d` / `#c7d2fe` | info notices, accent badges |
|
||
| `--on-accent` | `#ffffff` | `#16163a` | text on accent buttons |
|
||
| `--mark` / `--mark-ink` | `#fde68a` / `#1c1b18` | `#5a4b10` / `#fff6d5` | highlighter: quotes, search hits |
|
||
| `--positive` / `-soft` | `#146c43` / `#e2f2e8` | `#74d3a2` / `#11301f` | "dafür", "angenommen", verified |
|
||
| `--negative` / `-soft` | `#b3261e` / `#fbe9e6` | `#ff9d90` / `#3c1612` | "dagegen", "abgelehnt" |
|
||
| `--caution` / `-soft` | `#8a5300` / `#fcf0d4` | `#f2c35c` / `#36290c` | legal matters, disputed, dissent, review |
|
||
| `--focus` | `#4338ca` | `#a5b4fc` | focus ring |
|
||
|
||
**Contrast** (WCAG 2.2: text ≥ 4.5, UI parts ≥ 3; computed from the values above):
|
||
|
||
| Pair | Use | Light | Dark |
|
||
|---|---|---|---|
|
||
| `--ink` on `--paper` | body text | 15.8 | 15.6 |
|
||
| `--ink-2` on `--surface` | secondary text | 9.7 | 10.1 |
|
||
| `--ink-3` on `--paper` | muted text on the page | 5.6 | 6.9 |
|
||
| `--ink-3` on `--surface-2` | muted text on fills | 5.2 | 5.8 |
|
||
| `--accent` on `--surface` | links on cards | 7.9 | 8.7 |
|
||
| `--on-accent` on `--accent` | primary button | 7.9 | 8.7 |
|
||
| `--accent-ink` on `--accent-soft` | info notice, accent badge | 9.6 | 9.6 |
|
||
| `--mark-ink` on `--mark` | highlighted search hit | 13.8 | 7.9 |
|
||
| `--positive` on `--positive-soft` | positive badge | 5.6 | 7.9 |
|
||
| `--negative` on `--negative-soft` | negative badge | 5.6 | 8.0 |
|
||
| `--caution` on `--caution-soft` | caution badge | 5.6 | 8.6 |
|
||
| `--line-field` on `--surface` | form field border | 3.9 | 4.0 |
|
||
|
||
`--line` and `--line-strong` are decorative (card edges, rules) and are never the only way to recognise a
|
||
control. Re-run the numbers when you change a colour; the check is twenty lines of Python (relative
|
||
luminance per WCAG).
|
||
|
||
**Party colours** come from the data (`organization.color`) and are used only through
|
||
`partyStyle(color)` → `--party`, by `.party-dot`, `.party-band` (a 4 rem bar), `.party-card` (a 4 px top
|
||
edge) and party bars in the role timeline. Never as text colour, never as a large background, always next
|
||
to the party's name. Dots get a thin ring so dark party colours stay visible on the dark theme.
|
||
|
||
**Platform colours** (`.platform-bluesky` …) follow the same rule: a dot next to the platform name.
|
||
|
||
### Typography
|
||
|
||
System font stack (`--font-sans`): fast, private (no font downloads) and native on every device.
|
||
`--font-mono` for identifiers (file numbers, Firmenbuch numbers, item references).
|
||
|
||
| Token | Size | Use |
|
||
|---|---|---|
|
||
| `--text-xs` | 0.75 rem | meta data, badges, axis labels |
|
||
| `--text-sm` | 0.875 rem | secondary text, tables, filters |
|
||
| `--text-base` | 1 rem | body |
|
||
| `--text-md` | 1.125 rem | ledes, search field |
|
||
| `--text-lg` | 1.25 rem | h2 |
|
||
| `--text-xl` | 1.5 rem | key figures, claim title on its permalink page |
|
||
| `--text-2xl` | clamp(1.625 → 2.125 rem) | h1 |
|
||
| `--text-display` | clamp(2 → 3 rem) | home headline |
|
||
|
||
Line height 1.55 for text, 1.2 for headings. Headings use `text-wrap: balance`. Running text, list items,
|
||
table cells and headings hyphenate (`hyphens: auto` with the document's `lang`), which keeps long German
|
||
compounds from breaking layouts on phones.
|
||
|
||
### Spacing, shape, depth
|
||
|
||
* Spacing on a 4 px grid: `--space-1` 0.25 rem · `-2` 0.5 · `-3` 0.75 · `-4` 1 · `-5` 1.5 · `-6` 2 ·
|
||
`-7` 3 · `-8` 4 rem.
|
||
* Radius: `--radius-sm` 4 px (chips, position badges) · `--radius` 8 px (fields, buttons, notices) ·
|
||
`--radius-lg` 12 px (cards, panels, tables) · `--radius-pill`.
|
||
* Depth: borders first. `--shadow-sm` on hover of link cards; `--shadow` only for overlays (menus,
|
||
sticky action bar).
|
||
|
||
### Layout
|
||
|
||
* `--page-max` 76 rem content width, `--prose-max` 44 rem for reading, `--gutter` clamp(1–2 rem),
|
||
`--header-h` 3.5 rem.
|
||
* Breakpoints (media queries can't use custom properties, so they are written out): **640 px** (stacked
|
||
tables, collapsed filters, one-column role timeline), **768 px** (calendar → agenda), **1024 px** (full
|
||
header navigation, aside columns), **1360 px** (header tool labels).
|
||
|
||
### Theming
|
||
|
||
The light theme is defined on `:root`. The dark theme is defined twice with the same values: once in
|
||
`@media (prefers-color-scheme: dark)` for `:root:not([data-theme='light'])`, once for
|
||
`:root[data-theme='dark']`. `ThemeSwitcher` sets `data-theme` and the `pt_theme` cookie; the layout reads
|
||
the cookie, so the server renders the chosen theme without a flash. **A new token goes into all three
|
||
blocks.**
|
||
|
||
## 3. Icons
|
||
|
||
`<Icon name="…" />` (`src/components/Icon.tsx`): inline SVG on a 24 px grid, 1.75 px strokes in
|
||
`currentColor`, decorative (`aria-hidden`) unless you pass `label`. Sizes follow the text (`1em`) unless a
|
||
component sets them.
|
||
|
||
Available: `search person people flag building euro vote calendar message book history sliders external
|
||
document check checks alert scale globe sun moon monitor arrow-right arrow-left chevron-down
|
||
chevron-right link quote mic plane briefcase landmark info close menu pin news question pencil pie
|
||
activity shield code`.
|
||
|
||
To add one, append a list of path strings (and `c:cx,cy,r` for circles) to `PATHS`. Draw it yourself from
|
||
simple geometry rather than copying a set with a licence to track. Claim types map to icons in
|
||
`TYPE_ICONS` (`badges.tsx`).
|
||
|
||
`<BrandMark />` is the logo: brackets around a dot (a citation with its evidence). `src/app/icon.svg` is the
|
||
same mark as the favicon.
|
||
|
||
## 4. Components
|
||
|
||
| Component | File | Use |
|
||
|---|---|---|
|
||
| `SiteHeader`, `SiteFooter` | `SiteHeader.tsx`, `SiteFooter.tsx` | page frame; navigation from `nav.ts` |
|
||
| `NavLink` (client) | `NavLink.tsx` | navigation link with `aria-current` for the section |
|
||
| `ThemeSwitcher`, `LocaleSwitcher` (client) | | system → light → dark; same page in the other language |
|
||
| `PageHeader`, `Breadcrumbs`, `SectionHead` | `PageHeader.tsx` | title block; crumbs (home is added); h2 with an "Alle …" link |
|
||
| `Tabs` | `Tabs.tsx` | link tabs with counts; scroll shadows when they overflow |
|
||
| `FilterBar`, `Field`, `countActive` | `FilterBar.tsx` | GET filter form, collapses on phones |
|
||
| `Pagination` | `Pagination.tsx` | previous / page n / next (the API has no totals) |
|
||
| `Notice`, `EmptyState` | `Notice.tsx` | notes with tone `note`/`info`/`caution`/`legal`; empty results |
|
||
| `ExternalLink` | `ExternalLink.tsx` | link to a source: new tab, no referrer, announced to screen readers |
|
||
| `TypeLabel`, `SourceMeter` | `badges.tsx` | claim type with icon; source count / disputed |
|
||
| `PartyTag`, `StatusBadge` | `badges.tsx` | party dot + short name; person status |
|
||
| `PositionChip`, `ResultBadge` | `badges.tsx` | vote position (optionally per party); accepted/rejected |
|
||
| `PlatformBadge`, `platformName` | `badges.tsx` | social platform |
|
||
| `Avatar`, `PersonCard` | `Avatar.tsx`, `PersonCard.tsx` | portrait or initials; person in a grid |
|
||
| `StatGrid`, `BarList`, `ProportionBar` | `charts.tsx` | key figures; ranked bars; shares of a whole |
|
||
| `Timeline`, `DateRangeForm` | `Timeline.tsx` | activity per month (bars link to the month); date filter |
|
||
| `RoleTimeline` | `RoleTimeline.tsx` | offices and memberships on a year axis |
|
||
| `ClaimList`, `ClaimCard`, `EvidenceList` | `ClaimList.tsx` | the claim card and its sources |
|
||
| `ClaimDetails`, `OrgLink` | `ClaimDetails.tsx` | type-specific details (registry `DETAILS`) |
|
||
| `MoneyTable` | `MoneyTable.tsx` | money flows in four columns (date + kind, from, to, amount + source chips); `self` mutes the page's own organisation |
|
||
| `VoteList`, `VoteLegend` | `VoteList.tsx` | votes (vote items or one person's votes); `compact` for panels |
|
||
| `SocialFeed` | `SocialFeed.tsx` | post cards, no embeds |
|
||
| `SearchForm` | `SearchForm.tsx` | the big search field (optional date range) |
|
||
| `SettingsForm` (client) | `SettingsForm.tsx` | "Meine Filter" |
|
||
|
||
### The claim card
|
||
|
||
`ClaimCard` renders, in this order: head (type label, date, subject, source meter), legal stage row,
|
||
summary, `ClaimDetails`, innocence notice (`needsInnocenceNotice()`), review notice, topics, and the foot
|
||
with the evidence disclosure and the permalink. `standalone` is the permalink page variant: the summary
|
||
becomes the `h1` and the sources are expanded. Rules:
|
||
|
||
* Never render a claim without its evidence.
|
||
* Don't remove or move the innocence notice; it belongs inside the card.
|
||
* Show the subject only when the list mixes subjects (`showSubject={false}` on profiles).
|
||
* When the English UI shows a German summary, it is marked `lang="de"`.
|
||
|
||
### Filters
|
||
|
||
```tsx
|
||
<FilterBar id="money" active={countActive(sp.kind, sp.q)} resetHref="/geld" hidden={{org: sp.org}}>
|
||
<Field label={t('kindLabel')}><select name="kind" defaultValue={sp.kind ?? ''}>…</select></Field>
|
||
<Field label={t('nameLabel')} wide><input type="search" name="q" defaultValue={sp.q} /></Field>
|
||
</FilterBar>
|
||
```
|
||
|
||
`active` drives the count badge, the reset link and whether the phone toggle starts open. Hidden values
|
||
keep context (an organisation, the current tab) across submits.
|
||
|
||
### Tables
|
||
|
||
```tsx
|
||
<div className="table-wrap">
|
||
<table className="table-stack">
|
||
<thead><tr><th scope="col">{t('date')}</th>…</tr></thead>
|
||
<tbody><tr><td data-label={t('date')}>…</td>…</tr></tbody>
|
||
</table>
|
||
</div>
|
||
```
|
||
|
||
`.table-wrap` gives the frame and horizontal scrolling. `.table-stack` turns rows into labelled cards
|
||
below 640 px, using each cell's `data-label`. Numbers: `className="num"` (right-aligned, tabular).
|
||
Amounts: `<span className="amount">`. Keep tables to four or five columns: secondary values (a kind badge, source
|
||
chips) go into the cell they belong to rather than into a column of their own.
|
||
|
||
### Charts
|
||
|
||
All charts are HTML and CSS, with the numbers as text:
|
||
|
||
* `StatGrid` is a `<dl>`; values can link to the list behind them.
|
||
* `BarList` scales bars to the largest value in the list. Use it for rankings of the same unit only, and
|
||
label each row's kind (`meta`).
|
||
* `ProportionBar` has `role="img"` with a text alternative, plus a legend with counts.
|
||
* `Timeline` bars are links with labels ("März 2026: 12").
|
||
* `RoleTimeline` labels every bar with its years. Roles without a start date are listed in the roles panel
|
||
only.
|
||
|
||
## 5. Layout utilities
|
||
|
||
| Class | Does |
|
||
|---|---|
|
||
| `.page` | the main column (set by the layout) |
|
||
| `.stack`, `.cluster` | vertical stack with gap; wrapping row with gap |
|
||
| `.grid-2` | two columns ≥ 22 rem, one column below |
|
||
| `.grid-cards` | auto-fill card grid; `--card-min` (default 17 rem) |
|
||
| `.layout-aside` | main + 20 rem aside from 1024 px |
|
||
| `.section`, `.section-head` | vertical rhythm between sections; h2 row with a "more" link |
|
||
| `.prose` | 44 rem reading width |
|
||
| `.panel` | aside box with an h2 |
|
||
| `.link-list`, `.name-list`, `.meta-list` | ruled list; multi-column names; definition grid |
|
||
| `.muted`, `.small`, `.xsmall`, `.strong`, `.num`, `.mono`, `.nowrap`, `.block`, `.visually-hidden` | text utilities |
|
||
|
||
## 6. Content
|
||
|
||
### Voice and wording
|
||
|
||
Sachlich and precise; "du"; gender-inclusive with a colon (Politiker:innen, Funktionär:innen). Say what the
|
||
source says, with the source.
|
||
|
||
| Instead of | Write |
|
||
|---|---|
|
||
| "X kassierte 25.000 €" | "Laut Rechnungshof erhielt X 25.000 € (Spende)" |
|
||
| "Skandal um …", "dubios" | the fact and its source; no adjectives that judge |
|
||
| "X ist korrupt", "Anklage!" | the procedural stage ("Ermittlungsverfahren") + the innocence notice |
|
||
| "X und Y sind verbandelt" | "X ist laut Firmenbuch Geschäftsführer von Y" + the connection note |
|
||
| "X hat 33.000 Follower" (from somewhere) | only data we collected, with its source and date |
|
||
|
||
### Numbers, dates, money
|
||
|
||
* Use `formatDate`, `formatMonth`, `formatPeriod`, `formatNumber`, `formatMoney`, `formatMoneyCompact`
|
||
from `lib/format.ts`. They use de-AT and en-GB conventions (`12. März 2026`, `€ 25.000`,
|
||
`1,2 Mio. €`, `2. Halbjahr 2025`).
|
||
* Exact amounts in tables, compact amounts in summaries and bar lists (exact value in the `title`).
|
||
* Pass numbers to ICU messages (`{count, plural, …}`), never pre-formatted strings. Pass IDs as strings,
|
||
so they don't get thousands separators.
|
||
* Never add up different kinds of money into one figure without saying so.
|
||
|
||
### Interface text
|
||
|
||
* Every key goes into both `messages/de.json` and `messages/en.json`; German first.
|
||
* Labels are short nouns ("Quellen", "Filter"); buttons are verbs ("Anwenden", "Speichern").
|
||
* Links say where they go ("Alle Abstimmungen", "Original ansehen"), never "hier klicken".
|
||
* Keep a dash with the preceding word using a non-breaking space where a line break would start a line
|
||
with "–" (the home headline does this).
|
||
|
||
## 7. Accessibility checklist (for every UI change)
|
||
|
||
- [ ] Headings in order, one `h1`; landmarks intact; new sections have `aria-labelledby`.
|
||
- [ ] Everything works with the keyboard and shows the focus ring.
|
||
- [ ] Nothing relies on colour alone: badges have words, positions have icons, charts have numbers.
|
||
- [ ] Contrast of new colours checked (§2).
|
||
- [ ] External links use `ExternalLink` (new tab announced).
|
||
- [ ] Images: `alt=""` when decorative (the name is next to the portrait), real text otherwise.
|
||
- [ ] Phone width (390 px): no horizontal scrolling, tables stack, nothing cut off.
|
||
- [ ] Both themes look right; both languages fit (English is shorter, German compounds are longer).
|
||
|
||
## 8. Recipes
|
||
|
||
**An index page.** Use `PageHeader` → `FilterBar` → results (`grid-cards`, a `table-wrap`, or
|
||
`ClaimList`) → `Pagination` → a source `Notice`. The persons and organisations pages are the templates.
|
||
|
||
**A profile section.** Use a `section` with `aria-labelledby` and a `SectionHead` (with `more` pointing to
|
||
the pre-filtered explorer), then the list component. Put secondary facts into a `.panel` in the aside.
|
||
|
||
**Showing a new claim type.** Add a renderer to `DETAILS` in `ClaimDetails.tsx`, an icon to `TYPE_ICONS`
|
||
in `badges.tsx`, and the label `Claims.type.<type>` in both message files.
|
||
|
||
**A new chart.** Prefer `StatGrid`, `BarList` or `ProportionBar`. If you need something else, build it from
|
||
HTML/CSS with the numbers as text, add its section to `components.css`, and document it here.
|
||
|
||
**A new token.** Add it to all three blocks in `tokens.css`, check its contrast, and add it to §2.
|