# 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, `
` 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 `` (`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`). `` 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 ``` `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
……
{t('date')}
…
``` `.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: ``. 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 `
`; 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.` 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.