Skip to content

Modernize frontend: Vite + React 19 + TS + Tailwind + shadcn - #70

Merged
cafca merged 84 commits into
masterfrom
claude/upbeat-curie-04b643
May 9, 2026
Merged

Modernize frontend: Vite + React 19 + TS + Tailwind + shadcn#70
cafca merged 84 commits into
masterfrom
claude/upbeat-curie-04b643

Conversation

@cafca

@cafca cafca commented Apr 22, 2026

Copy link
Copy Markdown
Owner

Summary

Full frontend modernization from CRA + React 16 + Flow + Semantic UI → Vite + React 19 + TypeScript (strict) + Fomantic-UI CSS, following the plan in ~/.claude/plans/modernize-the-frontend-stack-mellow-wren.md. CSS-fidelity follow-up plan in ~/.claude/plans/port-missing-legacy-css-rules.md.

What changed

Toolchain

  • Vite build replaces react-scripts; Node 22 pinned via .nvmrc and engines
  • TypeScript strict mode with @/* path alias; Flow removed
  • ESLint flat config (jsx-a11y, react-hooks) + Prettier; Vitest + RTL for unit tests
  • Playwright visual-regression harness under client/e2e/ with fixtures and goldens per viewport (360/600/768/1024/1440/1920)

App

  • React Router v7 route tree (src/routes.tsx), lazy-loaded per route
  • RootLayout adds a ScrollToTop so navigation resets scroll (RR7 doesn't by default; legacy did)
  • TanStack Query + persisted cache replaces hand-rolled fetch/localStorage
  • Typed API wrapper (src/lib/api.ts) + useX hooks per endpoint family; lib/dates.ts normalises the API's "YYYY-MM-DD HH:MM:SS Z" format to ISO before parsing
  • All 13 views ported 1:1 from legacy (class → function + hooks)
  • Each routed view's outer container is a <main class="… app-main"> so every page has exactly one semantic main element; the wrapping <main> in RootLayout was removed
  • All shared components ported (PositionChart, Thesis, DataLabel, Legend, Map, Search, SuggestionsGrid, SourcesFooter…)

UI library decision

  • Kept Fomantic-UI CSS verbatim instead of swapping to Tailwind/shadcn (preserves the legacy visual language with no per-component porting cost)
  • Custom FilterDropdown component for the TagView filter bar — replaces semantic-ui-react's jQuery <Dropdown> widget with a Fomantic-styled <div> widget so the open menu picks up the same panel/border styling as legacy. Has open/close, click-outside-to-close, keyboard activation (Enter/Space), and aria-listbox semantics.

CSS port

  • Every view + component CSS file is eagerly imported from main.tsx so all rules load on every route (correctness > the small lazy-split perf benefit; rules tied to classes shared between routes were getting orphaned otherwise)
  • .electionContainer rules hoisted to index.css (shared between Election and Quiz)
  • .inlineLogo / .source a / .ui.message#methodik moved to a new SourcesFooter.css — colocated with the component that uses them
  • views/Thesis.css ported in full from src.legacy/views/thesis/styles.css (was empty)
  • index.css cleaned of duplicates and unintended divergences from legacy: removed body { font-family: Roboto } (legacy left body unset, inheriting Fomantic's Lato), removed redundant .ui.medium.header { 1.28571429em } (Fomantic ships the same value), removed duplicate @media (max-width: 650px) .electionContainer h1 block
  • app-main margin spec matches legacy .App > .container { margin-top: 42px / 1em@767 }
  • Wikidata/Wikipedia floated headers: override Fomantic's .ui.header > .image:not(.icon) { width: 2.5em } so the labels fit their text; restore legacy inline marginRight: -10.5px; hide label text below 768px
  • Filter dropdown caret floats right at ≤767 (margin-left: auto; flex parent doesn't honour float)
  • Legend.css: small-legend cells equal 1/3 width via fixed table-layout

Accessibility

  • Icon-only <button>s get aria-label (TagBadge close, ThesisCompact close)
  • Header dropdown trigger is role="button" tabIndex={0} with keyboard activation
  • FilterDropdown is a real listbox (role="listbox", aria-expanded, items as role="option" with aria-selected, focusable when open, Enter/Space activates)
  • <button> reset for .ui.menu button.item so buttons we render as menu items don't ship native chrome but still get a visible focus ring via :focus-visible

Infra

  • Dockerfile rewritten: node:22-alpine builder, nginx prod stage serves dist/
  • CI bumped to Node 22 with npm ci && lint && typecheck && test && build; sanity-build of the Dockerfile in CI
  • Reusable release.yml (from master) updated to the new toolchain — npm, VITE_API_ROOT, uploads client/dist
  • REACT_APP_API_ROOTVITE_API_ROOT everywhere (compose, CI, deploy)

Dep swaps

  • momentdate-fns
  • react-copy-to-clipboard → native navigator.clipboard
  • Kept fuse.js and wikidata-sdk (now upgraded)
  • Dropped react-autobind, flow-bin, semantic-ui-*, react-snap (follow-up: Add vite-react-ssg for static prerendering #76 to restore prerendering via vite-react-ssg), swagger-ui (replaced by swagger-ui-react), the old service worker, and the legacy CRA jest mock

Reviewer notes

  • Legacy source preserved under client/src.legacy/ for reference during the visual-fidelity pass
  • Visual-regression goldens committed under client/e2e/__screenshots__/; KNOWN_ISSUES.md tracked the 768-viewport quiz legend overlap (fixed earlier in the branch)
  • Master was merged in; conflicts in ci.yml, Dockerfile, package.json, package-lock.json, index.css, yarn.lock were resolved keeping the new toolchain. Master's reusable release-workflow refactor (ci: split Release into reusable workflow; drop client image publish #68) is preserved and updated for Vite output paths.

Test plan

  • npm run typecheck clean
  • npm run lint clean
  • npm run test -- --run green
  • npm run build succeeds
  • npm run preview — smoke-test routes: /, /wahlen/…, /quiz/…, /themen/…, /daten/, /legal/, /404
  • Top-margin gap menu→content: 42px ≥768, 1em ≤767
  • Body text computes to Lato; only <h1> to Roboto
  • Filter dropdown on /themen/<slug>/ opens with Fomantic styling; caret floats right at ≤767
  • Wiki labels visible at ≥768; icon-only ≤767; never overflow their floated wrapper
  • Navigation resets scroll position
  • Docker build with --build-arg VITE_API_ROOT=… works; SPA fallback serves index.html
  • CI green on Node 22

cafca added 30 commits April 21, 2026 14:14
Adds a standalone client/e2e/ npm workspace for Phase 0 visual-regression
baselines. Isolated from client/package.json so the frozen CRA toolchain
stays untouched.

Includes playwright.config.ts (six viewports, 1% diff tolerance), a
page.route mock that serves recorded fixtures plus stubs Fathom, a
Date/animation stabilizer, a record-fixtures helper, and README.
Snapshots of the local Docker API (http://127.0.0.1:3001/v3) for the
handful of endpoints the CRA client hits while rendering the captured
routes: /base, /elections/1, /elections/1/1, /thesis/WOM-001-01, /quiz/1,
and /tags/wirtschaft.

/elections/1/1 and /tags/wirtschaft do not exist in the local dev
database; the wirtschaft payload is synthesized from a real tag with the
slug rewritten so the Tag view renders meaningful content.
routes.spec.ts captures 12 routes per viewport (72 full-page shots),
with a scrollHeight-stability wait to dodge Semantic UI late layout
shifts. interactive.spec.ts adds two mid-state captures: a quiz
mid-flow after two answers and a position-chart hover.
84 golden PNGs: 12 routes x 6 viewports plus 2 interactive captures
x 6 viewports. Committed so CI / future modernization passes can diff
against the current CRA appearance.
cafca added 26 commits April 22, 2026 12:09
Ports src.legacy/components/footer/index.jsx to the modern stack:
inverted segment (bg segment-inverted #1b1c1d, 5em top margin + 5em
vertical padding), centered 1127px container, 3 divided columns
(Letzte Wahlen / Über Metawahl / Gebiete) with left-aligned lists,
bold underlined hover links, BMBF + OKFN sponsor logos (inverted
filters), horizontal divider, bottom row with email + Impressum +
Datenschutzerklärung.

Recent elections + territory list derive from useBase; renders empty
slots until base data resolves. Kept every piece of German copy from
the legacy version verbatim.
Replace the minimal border-b header with a Semantic-style horizontal
menu bar: white background, 1px border-b and 0 1px 2px shadow in
rgba(34,36,38,.15), max-w 1127px centered. Items are flex children
with 13px × 16px padding, font-weight 700, left-divider borders in
rgba(34,36,38,.1), and an active bg rgba(0,0,0,.05) driven by
NavLink.

Search moves into the trailing slot on the right, same divider
treatment.
Legacy hero uses a ~32px bold headline; modern was text-2xl md:text-3xl
(24/30px, semibold) which read as cramped. Match the legacy weight +
size and soften the sub-heading to ink-muted 15/16px normal.
Semantic UI treats links as inheriting the surrounding color unless
explicitly styled. Match that: drop the global a { color: blue }
rule in favor of color:inherit, so navigation text like Landing's
'Alle Wahlen' heading stays dark by default. Callers that want the
accent apply text-semantic-blue explicitly.

Also bump Landing's 'Wie Metawahl funktioniert' and 'Alle Wahlen'
column sub-headings from semibold to bold, matching the 18px/700
weight of the legacy h3.
- add vite-plugin-svgr for inline SVG imports, preserve .st2/.st5 hover paint
- hero h1 24px / h2 18px bold with Semantic line-height 1.28571429em
- new LabeledIconButton matches Semantic basic labeled-icon pattern
…us, suggestions title, map sizing, footer alignment)
Phase A teardown + Landing re-port. Per plan change: ship the Semantic
UI CSS bundle (fomantic-ui-css) and author views with Semantic
classnames directly, instead of reproducing the look in Tailwind tokens.

- remove @tailwindcss/vite, tailwindcss, tailwind-merge, tailwindcss-animate
- remove shadcn primitives (components/ui/*, components.json, lib/utils.ts)
- remove shadcn deps (class-variance-authority, cmdk, lucide-react,
  next-themes, radix-ui, sonner) and swagger-ui-react
- import fomantic-ui-css/semantic.min.css in main.tsx
- restore legacy index.css overrides (a:not(.item) color #333 !important etc.)
- re-port Landing, Header, Footer, RootLayout, Map, SuggestionsGrid
  using plain tags + Semantic classnames (no semantic-ui-react dep)
- stub remaining views with Placeholder pending per-view re-ports
- drop /__lab route; harness retires once all views pass VR

Keeps modern toolchain: Vite, React 19, TS strict, TanStack Query,
React Router v7, Vitest, Playwright VR.
h2 explainer blocks now 1.28571429em per design. Header search input
replaced with real Search component backed by useBase() + Fuse.js,
matching legacy tag+territory fuzzy search behavior.
Body and form controls now render in Roboto (legacy @font-face files
live in public/fonts). Headers keep Fomantic's Lato.

Search in both desktop and mobile menus is now the menu item itself
('small right aligned item' class), matching legacy markup. The prior
extra <div class='item'> wrapper stacked padding and made the menu bar
visibly taller.
Legacy client/src.legacy/index.css only overrides h1 globally; body,
menus, headers, form controls all inherit Semantic's Lato. The prior
commit flipped that around. Revert to legacy rule and keep the Roboto
@font-face declarations so the per-view Roboto overrides
(.headerCount2 on ElectionList, .thesesCount on TagOverview) still
render correctly when those views land.
- SEO uses React 19 native head tags (no react-helmet dep needed).
- Legal: full static content from legacy, Semantic Container/Header/image.
- NotFound: header + Search.
- DataOverview: lazy-loaded swagger-ui-react + bundled theme CSS.
- Landing.css: scope display:block on .logo to max-width 400px (fixes
  360 viewport h1 line-box overflow without disturbing desktop).
- a11y: swap non-interactive elements with onClick for buttons or add role/keyboard handlers (TagBadge delete, ThesisCompact close, Thesis sources, Header dropdown, Search tag result)
- react-hooks/set-state-in-effect: derive relatedState directly in Thesis; drop unused sendTagChanges args
- react-hooks/exhaustive-deps: memoize theses / electionsById defaults in Election, Quiz, TagView
- eslint: allow underscore-prefixed unused args/vars
All 13 routes x 6 viewports + 2 interactive captures drifted from pre-modernization baseline due to the shared-component + view ports (React 19 + Fomantic verbatim).

Re-run against fresh goldens: 78/78 pass.
…elpers

API returns space-separated dates with trailing ` Z` which parseISO rejects.
Normalize to strict ISO 8601 before parsing; return empty/NaN for invalid
input instead of throwing RangeError from format().
- DataLabel: constrain <img> size + nowrap so 'Wikipedia'/'Wikidata' text
  stays inline with the icon (was wrapping under barcode)
- TagView: give native <select> the 'ui dropdown' class so Fomantic styles
  the caret/borders instead of browser default chrome
- Election: swap quiz CTA from 'compact' to 'basic left labeled icon'
  to match the landing hero buttons
- RootLayout: wrap <Outlet /> in <main> with margin-top so pages breathe
  below the menu bar (/wahlen/ was flush against it)
- index.css: add .ui.medium.header baseline + electionContainer h1
  shrinkage at <=650px so election titles don't overflow on narrow phones
Carries the post-port CSS fidelity work that surfaced one regression
at a time during VR review:

- TagView: replace native <select> filter dropdowns with a Fomantic-
  styled FilterDropdown component so the open menu picks up the same
  white panel + bordered item styling as legacy. Drop the inline
  `border: none` that was hiding the closed-state border.
- Wiki labels: override Fomantic's `.ui.header > .image:not(.icon)
  { width: 2.5em }` so the floated wikipedia/wikidata labels expand
  to fit their text. Restore legacy inline `marginRight: -10.5px` on
  WikidataLabel. Hide label text below 768px via a higher-specificity
  selector that survives the new Wikidata text rule.
- Routing: add ScrollToTop to RootLayout — RR7 doesn't reset scroll
  on navigation, legacy did.
- Eager-import every view + component CSS from main.tsx so all rules
  load on every route. Lazy route splitting was orphaning rules tied
  to classes shared between routes (`.electionContainer`, `.inlineLogo`).
- Move shared rules to their semantic owner: SourcesFooter.css for
  `.inlineLogo` / `.source a` / `.ui.message#methodik`; index.css for
  `.electionContainer` rules used by both Election and Quiz.
- Port missing legacy rules from `src.legacy/views/thesis/styles.css`
  into a new `views/Thesis.css` (relatedHeader, contentLoaded,
  legend table margins, h1 scaling at 500px).
- Drop divergent body/menu/header font-family overrides — match
  legacy where only h1 is globally Roboto and everything else stays
  Lato (Fomantic default).
- Convert per-view top container `<div>` → `<main>` so each route
  renders one semantic main element. Remove the wrapping <main> in
  RootLayout.
- A11y: button reset for `.ui.menu button.item` so buttons we render
  as menu items don't ship native chrome; aria roles on FilterDropdown.
- Filter dropdown caret: `margin-left: auto` ≤767 (flex parent doesn't
  honour `float`).
- Date helper handles API format `"YYYY-MM-DD HH:MM:SS Z"`.
- Legend small-table cells: equal 1/3 width via fixed table-layout.
Conflicts resolved:
- ci.yml: keep Vite/npm/Node 22 client step from this branch; adopt
  master's reusable release.yml workflow refactor (drops client image
  publish; client now ships as a static bundle).
- release.yml: update bundled-from-master workflow to match the new
  toolchain — node 22, npm ci, VITE_API_ROOT, upload `client/dist`
  instead of `client/build`.
- Dockerfile: keep `node:22-alpine` pin (master had `node:lts-alpine`).
- package.json + package-lock.json: keep this branch's React 19 / Vite /
  TS / Tailwind / shadcn rewrite.
- yarn.lock: kept deleted (npm now).
- index.css: keep modern @font-face (woff2/woff/ttf only) — master
  re-added IE6-IE8 .eot/.svg fallbacks via dependency bumps.

Cleanup also done in this commit:
- Drop dead `client/src/__mocks__/swagger-ui.js` (CRA jest mock — we
  use vitest + swagger-ui-react now).
- Add keyboard handlers + tabIndex on FilterDropdown menu items so
  jsx-a11y rules pass.
cafca added 2 commits May 9, 2026 11:59
- Remove `client/src.legacy/` (legacy CRA reference no longer needed)
- Remove `client/e2e/KNOWN_ISSUES.md` (stale, no open items)
- Remove `client/src/main.test.tsx` (placeholder skeleton test)
- Remove `client/e2e/scripts/harvest-tokens.mjs` + tokens.json
  (token-translation tool only needed for the discarded Tailwind path)
- Strip "legacy", "phase", and similar transition references from
  source comments and READMEs; comments now describe the status quo.
- Drop `src.legacy` from eslint, prettier, vitest, and Vite config.
- Update `client/e2e/README.md` to describe the current Vite setup;
  drop CRA / Node 10 / Phase 0 references.
- Add tests for `lib/dates.ts`, `lib/thesis.ts`, `lib/uuid.ts`.
- Shim `localStorage` / `sessionStorage` in the vitest setup file.
  jsdom 29 + vitest 4 ships an empty object on `window.localStorage`
  with no Storage methods; the shim restores in-memory get/set/clear.
- Regenerate Playwright goldens to match the current rendering.
  78/78 VR tests now pass clean.
Local development uses npm run dev directly (Node 22 via .nvmrc).
Nothing in compose.yml or CI ever built the dev target. The deps and
builder stages are still shared, so cache behaviour is unchanged.
@cafca
cafca merged commit 65a73b4 into master May 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant