Files
politics-tracking/docs/design/design-system.md
T
simonandClaude Opus 5.5 456f9816f4 Redesign the web app from a written design concept
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>
2026-10-28 22:21:44 +01:00

17 KiB
Raw Blame History

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 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

<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:

  • 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.