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>
17 KiB
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; how to add pages and claim types step by step is in 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 withis-(.is-current,.is-disputed);aria-currentfor 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-10.25 rem ·-20.5 ·-30.75 ·-41 ·-51.5 ·-62 ·-73 ·-84 rem. - Radius:
--radius-sm4 px (chips, position badges) ·--radius8 px (fields, buttons, notices) ·--radius-lg12 px (cards, panels, tables) ·--radius-pill. - Depth: borders first.
--shadow-smon hover of link cards;--shadowonly for overlays (menus, sticky action bar).
Layout
--page-max76 rem content width,--prose-max44 rem for reading,--gutterclamp(1–2 rem),--header-h3.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
<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
<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:
StatGridis a<dl>; values can link to the list behind them.BarListscales bars to the largest value in the list. Use it for rankings of the same unit only, and label each row's kind (meta).ProportionBarhasrole="img"with a text alternative, plus a legend with counts.Timelinebars are links with labels ("März 2026: 12").RoleTimelinelabels 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,formatMoneyCompactfromlib/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.jsonandmessages/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 havearia-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.