Files
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

306 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.