Files
app/docs/superpowers/specs/2026-06-06-css-mui-migration-design.md
BOHA 6b76d4336e docs(spec): lock pilot = Vozidla; confirm two-layer refresh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:48:26 +02:00

28 KiB
Raw Permalink Blame History

CSS → MUI Migration — Design Spec

Date: 2026-06-06 Status: Approved design, pending implementation plan Topic: Replace hand-written CSS with MUI (Material UI), themed to the existing brand, with a modern "Soft-SaaS + dense tables" look, rolled out incrementally.


1. Goal

Migrate the admin frontend off its ~7,100 lines of hand-written CSS onto MUI (Material UI) as the styling system, while simultaneously delivering a modern visual refresh. Dark/light theming and the existing design tokens are preserved (they already exist and work). The migration is incremental and always shippable — never a frozen big-bang.

This is explicitly a styling + component-layer migration. Application behavior, data flow, API contracts, and business logic remain identical (one deliberate exception: modal accessibility — see §5 / §11). Backend code is not touched.


2. Current state (audit findings, 2026-06-06)

The premise "we want dark/light mode" is largely already met. The audit found:

  • 19 CSS files, ~7,101 lines under src/admin/. Largest: components.css (1,224), plan.css (1,152), offers.css (641), layout.css (582), dashboard.css (525), forms.css (510), attendance.css (455), base.css (411). Smallest: responsive.css (6 lines, effectively an empty placeholder). The token file variables.css (164) and plan.css are special-cased in §6.
  • A complete CSS-variable token system in variables.css: spacing scale, colors, radii, motion durations, fonts, safe-area insets.
  • A full dark + light theme via [data-theme="dark"] / [data-theme="light"]. The attribute is owned and written by src/context/ThemeContext.tsx (useTheme() / toggleTheme(), which calls document.documentElement.setAttribute("data-theme", …)). Sidebar.tsx, AdminLayout.tsx, and Login.tsx only consume it via useTheme().
  • Distinctive fonts already chosen: Urbanist (headings), Plus Jakarta Sans (body), DM Mono (mono).
  • Stack: React 18.3, Vite 8, TypeScript (strict), TanStack Query v5, framer-motion (used broadly — see §9: modals, toasts, the AdminLayout logout transition, dashboard cards, Login), react-datepicker, react-quill-new, Leaflet, @dnd-kit. No Tailwind / MUI / Emotion direct deps; no PostCSS config (PostCSS ships only transitively under Vite).
  • Type skew (pre-existing): react/react-dom are ^18.3.1 but @types/react/@types/react-dom are ^19.x. MUI's component types are sensitive to the @types/react major version — see §11 / §14.

Implication: the migration is not about adding theming; it's about replacing the CSS plumbing and modernizing the look while carrying the existing tokens forward.


3. Locked decisions

Decision Choice
Goal Both — re-plumb CSS and modern visual refresh
Approach Component library (not a utility framework)
Library MUI (Material UI) — target v7 + MUI X v8 (pickers), themed to the brand
Look "Soft-SaaS shell (B) + dense data tables (D)" — airy modern chrome, dense efficient tables
Theming Existing CSS-var tokens become the source; one data-theme attribute drives both MUI and legacy CSS (mechanism verified against MUI v7 docs — §4.1)
Roll-out Incremental, foundation-first; each phase is one or more normal releases
Pilot page Vozidla (vehicles) — focused CRUD pilot
Heavy tables MUI Table styled dense, reusing existing useTableSort + usePaginatedQuery hooks (no DataGrid, no paid license)

4. Architecture

The tokens become the single shared palette that feeds both worlds during the migration, so migrated and unmigrated pages stay on the same colors/typography. Structure modernizes per page (see §7 for the token-level vs component-level split).

Tokens (colors, fonts, radii, motion, dark/light)   ← refreshed once, early
      │   (identical palette feeds both sides)
      ├───────────────►  legacy CSS  ── unmigrated pages        ┐
      │                                                          ├─ coexist, same palette
      └──►  MUI theme (createTheme + colorSchemes) ──────────────┘
                 │
                 ▼
        Foundation UI kit  (src/admin/ui/*)  ──►  migrated pages

4.1 Theming bridge (src/admin/theme.ts)

  • One theme.ts builds the MUI theme from the tokens:
    • Palette: primary = brand red (#d63031 dark / #c73030 light), plus success/warning/error/info mapped to existing semantic tokens.
    • Typography: Urbanist for h1h6, Plus Jakarta Sans for body, DM Mono for numeric/monospace contexts.
    • Shape/spacing: borderRadius and spacing from the token scale.
    • components overrides: the Soft-SaaS defaults (rounded cards, pill buttons, soft shadows, tinted chips) baked in so every MUI component is on-brand by default.
  • Dark/light keeps the existing toggle (verified against MUI v7 docs). MUI's CSS theme variables support a custom color-scheme selector that targets the existing attribute exactly:
    createTheme({
      cssVariables: { colorSchemeSelector: "[data-theme='%s']" },
      colorSchemes: { light: true, dark: true },
      // …palette/typography/components
    });
    
    This emits [data-theme="light"]{…} and [data-theme="dark"]{…}, matching the attribute ThemeContext already writes. One attribute drives MUI and legacy CSS → no second toggle, 1:1 dark parity.
  • One attribute owner. ThemeContext remains the sole writer of data-theme on <html>. Its toggleTheme() must also call MUI's setMode() so MUI's internal color-scheme state stays in sync with the attribute. Do not also enable MUI's InitColorSchemeScript attribute-writing (avoid two writers). Wired in Phase 0.

4.2 Coexistence (the safety mechanism)

  • Use MUI's ScopedCssBaseline around migrated page subtrees only, so MUI's CSS reset applies inside migrated pages without disturbing unmigrated ones still on base.css. No global reset until teardown.
  • The CssVarsProvider/ThemeProvider must sit above every ScopedCssBaseline, and data-theme must live on a common ancestor (<html>, as today) so the [data-theme='%s'] selector resolves inside scoped subtrees.
  • Styling engine: MUI default (Emotion). SPA only (Vite, no SSR), so no SSR-cache complexity.

5. Foundation UI kit (src/admin/ui/)

Principle: pages never import @mui directly. A thin wrapper kit bakes in brand defaults and keeps the current component APIs and Czech labels, so migrating a page is mostly swapping imports, not rewriting logic, and tuning a default touches one file, not 57.

Today → Foundation kit (src/admin/ui/) MUI base
FormModal / ConfirmModal <Modal> — preserves FormModal's full prop set (submitDisabled, subtitle, loading); ConfirmModal needs only title/message/type/confirmVariant/loading Dialog
.admin-table* + useTableSort + usePaginatedQuery <DataTable> (the dense "D" look) Table
AdminDatePicker (react-datepicker) <DatePicker>cs locale, dd.MM.yyyy MUI X Pickers (free)
.admin-btn <Button> (pill / red-gradient default) Button
.admin-form-* inputs/selects <TextField> / <Select> / <Switch> MUI form controls
AlertContext toasts <Toast> — keeps useAlert() API Snackbar
AdminLayout sidebar/topbar <AppShell> — the soft-SaaS shell Box / Drawer
KPI tiles <KpiCard> / <Card> Card
Tabs <Tabs> Tabs

Modal behavior change (deliberate a11y gain — not a 1:1 match). Today's modals use useModalLock (a ref-counted body-scroll lock, used in 8 places, including non-FormModal spots like Dashboard, OfferDetail, ReceivedInvoices, WarehouseItemDetail) plus role="dialog", but have no focus trap, no initial-focus, no return-focus. MUI Dialog adds scroll-lock and a focus trap + return-focus. This is an intentional improvement; it is called out so it isn't a surprise. Audit all 8 useModalLock call sites — those not backed by FormModal/ConfirmModal either migrate to <Modal> or keep useModalLock until their page migrates. Verify stacked/nested modal scroll-lock still works.

5.1 Dense <DataTable> (centerpiece)

MUI Table styled to the compact "D" look — uppercase micro headers, DM Mono right-aligned numerics, tinted status Chips, subtle row hover — wired to the existing useTableSort (column sort) and usePaginatedQuery (list + pagination) hooks. The current .admin-table-responsive horizontal-scroll wrapper carries over as MUI's TableContainer.

  • Numbers stay formatted by the existing utilities. Numeric/currency cells continue to render through src/admin/utils/formatters.ts (formatCurrency cs-CZ + /, formatKm, czechPlural). <DataTable> only styles (DM Mono, right-align); it never re-formats values.
  • A mobile card-view variant for the densest tables is a future enhancement (out of scope for the first pass).

5.2 Date pickers

Swap AdminDatePicker → MUI X Date Pickers (free Community tier; only Range Pickers are Pro, which we don't need) with the date-fns adapter and cs locale. AdminDatePicker is used in three variants, all covered by Community:

Current (react-datepicker) → MUI X Format (date-fns tokens)
date DatePicker dd.MM.yyyy
showMonthYearPicker DatePicker with month/year views MM/yyyy
showTimeSelectOnly TimePicker HH:mm

Format-token correction: date-fns tokens are case-sensitive — MM = month, mm = minutes, yyyy = calendar year (rrrr = local-week-year, wrong). The format is dd.MM.yyyy, not dd.mm.rrrr. Apply via LocalizationProvider dateFormats (e.g. { keyboardDate: "dd.MM.yyyy" }).

Retires a custom component and the react-datepicker dependency; MUI's responsive picker handles touch/mobile.


6. CSS end-state (explicit)

  1. Gone — ~90% of files (~80% of lines). All per-feature CSS files deleted as their pages migrate: buttons, forms, tables, invoices, offers, attendance, dashboard, warehouse, login, settings, filemanager, pagination, datepicker, layout, base, components, responsive (the 6-line placeholder). What they expressed becomes the MUI theme + sx/styled.
  2. Tokens move, don't die. variables.css is rewritten into theme.ts (TypeScript). With MUI's CSS-variable mode, MUI emits those tokens as CSS custom properties at runtime — so the hand-written variables.css file is retired, but the values live on in TS as the single source.
  3. Stays — a small residue for the genuinely-bespoke widgets MUI does not provide:
    • Plan/gantt grid (plan.css, ~1,152 lines, slimmed) — kept as a custom component with a trimmed stylesheet (largest survivor; ~16% of today's lines, hence "~80% of lines removed" not 90%).
    • Quill rich-text editor and Leaflet map — kept, with small theming CSS.
    • These surviving stylesheets reference the MUI-emitted CSS variables, so they stay in sync with the theme and dark/light.

Stated precisely: custom React components stay (their logic is the value); standard ones are re-skinned via MUI and lose their custom CSS; styling comes from the MUI theme; the only custom CSS remaining is the slim stylesheets for the 3 bespoke widgets, which still read the MUI theme tokens. Literally-zero custom CSS would require rebuilding the plan grid from MUI primitives — explicitly out of scope (YAGNI).


7. Aesthetic spec ("Soft-SaaS + dense", validated 2026-06-06)

The refresh is delivered in two layers, which is how it reconciles with the "shared palette" coexistence model:

  • Token-level (palette) — applies app-wide, early. These are small refinements to the existing tokens (e.g. light canvas #f5f4f2 → #f4f3f1, refined status-chip tints). They are updated once in variables.css/theme.ts in Phase 0, so both legacy and migrated pages share the refreshed palette. This means Phase 0 does shift colors slightly app-wide — it is not visually a no-op — but layout/structure is unchanged.
  • Component-level (the "modern look") — per page, via MUI. The distinctive new look (red-gradient pill buttons, 16px soft-shadow rounded cards, the soft-SaaS shell, dense tables) is delivered through MUI component styling as each page migrates. Unmigrated pages keep their current structure on the refreshed palette.

So §4's "coexist" promise is same palette/typography, not pixel-identical structure.

Concrete values implementers must reproduce (light shown):

  • Canvas: #f4f3f1. Surfaces/cards: #ffffff, radius 16px, soft shadow 0 6px 20px rgba(20,20,40,.06), 0 1px 2px rgba(0,0,0,.03).
  • Sidebar: transparent on canvas; active item = white pill with soft shadow + red text/icon.
  • Top bar: Urbanist 800 page title; pill search field; circular icon buttons; gradient avatar; primary action = red-gradient pill button.
  • Primary button: linear-gradient(135deg,#e23a3a,#c01f1f), radius 999px, shadow 0 5px 14px rgba(214,48,49,.32).
  • Status chips: pill, soft tinted — paid #eafaf0/#2b8a3e, overdue #fdecec/#c92a2a, draft #fbf3dd/#92760b.
  • Dense table: padding ~.5rem 1rem; headers .64rem uppercase, letter-spacing .05em, muted; DM Mono right-aligned numeric cells; row hover #faf9f7; row borders #f5f3f1; container is a 16px soft card.
  • Fonts: Urbanist 600800 (headings), Plus Jakarta Sans 400700 (body), DM Mono 400500 (figures).

Dark mode. Several §7 values are new and have no current dark counterpart, so dark is authored in theme.ts during Phase 0 by deriving from these light values + the existing dark surfaces (canvas #0a0a0a/#0f0f0f, glass borders rgba(255,255,255,.08), dark chip tints), not literally "mirrored."

Visual references (persisted, gitignored): .superpowers/brainstorm/9839-1780760811/content/aesthetic-directions.html, refined-hybrid-faktury.html.


8. Migration phasing

Each phase is one or more normal v1.x releases (realistic total: ~a dozen releases). Each migrated page deletes its own CSS file.

  • Phase 0 — Foundation. Install MUI v7 + Emotion + MUI X v8 Pickers; reconcile @types/react with the React 18 runtime (§14); write theme.ts from tokens and apply the token-level palette refresh app-wide (§7); author the dark palette; wire ScopedCssBaseline + the ThemeContextsetMode() sync; build the src/admin/ui/ kit; add the dev-only /ui-kit showcase route (guarded by import.meta.env.DEV, tree-shaken from prod). Layout/structure unchanged; colors shift slightly app-wide. Deletes no per-page CSS yet.
  • Phase 1 — <AppShell> (chrome). Migrate sidebar/topbar/theme-toggle to the soft-SaaS shell. Every page inside the AdminLayout route sits in the new frame; unmigrated content still uses its own CSS inside it. Retires most of layout.css. Login is NOT touched here — it is a sibling route outside AdminLayout (AdminApp.tsx:100) with its own full-screen layout; see Phase 3.
  • Phase 2 — Pilot CRUD page (Vozidla / vehicles). Full end-to-end: list, dense <DataTable>, filters, create/edit <Modal>. Battle-tests and tunes the foundation. Deletes that page's CSS.
  • Phase 3 — Simple CRUD + Login. Uživatelé (users), Projects, Settings, and Login (migrated standalone — keeps its own full-screen layout, theme toggle, and 2FA two-step + shake/animate-out flow; deletes login.css).
  • Phase 4 — List-heavy (Offers, Invoices lists). Hammers <DataTable>. Invoice/offer on-screen detail (Quill, the detail page) re-themed, not rebuilt. (Generated PDFs are out of scope — §13.)
  • Phase 5 — Attendance & Leave/Trips (grids + forms).
  • Phase 6 — Warehouse, then Plan + Dashboard polish.
  • Phase 7 — Teardown. Delete leftover legacy CSS; retire variables.css into theme.ts; only the slim bespoke-widget CSS remains.

At no point is the app frozen or un-shippable.


9. Bespoke components & animation

Bespoke components stay custom, re-themed (their logic is the value):

  • Plan/gantt grid (plan.css) — keep the grid; repaint from tokens; wrap its controls in the kit.
  • Leaflet maps — keep; align container/control styling to the theme.
  • Quill rich-text (react-quill-new) — keep; wrap as <RichTextEditor>; theme the toolbar/editor. Server-side DOMPurify + cleanQuillHtml sanitization on the PDF and render paths is unaffected and must remain.
  • @dnd-kit drag-drop — keep (behavior, not styling).

Animation (framer-motion). framer-motion is used in ~40 files, not just modals. Decision: keep framer-motion for the animations MUI does not cover — the AdminLayout logout transition (scale + blur + fade), dashboard card entrances, Login transitions, and plan interactions. MUI's own transitions replace framer-motion only inside the components that become MUI (Dialog for modals, Snackbar for toasts). When migrating AdminLayout<AppShell> and the dashboard, consciously re-create or drop their framer-motion animations (don't lose them silently). useReducedMotion continues to gate bespoke animations; MUI honors prefers-reduced-motion via its theme transitions.


10. Testing & verification

This is a UI restyle, so behavior must stay identical (except the deliberate modal a11y gain, §5).

  • Backend/API tests (auth, numbering) unaffected; run each phase to confirm the server is untouched — they stay green.
  • Per-page manual verification checklist (primary gate): every CRUD action, filter, sort, pagination, and modal works as before; modal focus order / ESC / backdrop-close behave; dark and light both render; mobile + desktop (responsive parity, §11); zero new console errors; tsc --noEmit and vite build pass.
  • /ui-kit dev route: renders every foundation component in both themes — instant visual QA when tuning the theme; living reference for page migration. Dev-only (stripped from prod).
  • Playwright smoke + before/after screenshots: a thin per-page script loads the page, runs the core flow, captures light+dark screenshots to diff against the pre-migration baseline. (Playwright is already available in the environment.)

11. Risks & mitigations

Risk Mitigation
Bundle/runtime cost (Emotion) Measure after Phase 0; tree-shake; acceptable for an internal admin app
MUI reset clashing with legacy base.css mid-migration ScopedCssBaseline around migrated subtrees; provider above it; migrate the shell early
Dark/light drift between MUI and legacy CSS One data-theme attribute (owned by ThemeContext) + setMode() sync drives both
Modal behavior change (focus trap, return-focus) Intentional a11y gain; audit the 8 useModalLock sites; add focus/ESC/backdrop to the per-page checklist
Responsive parity — 72 media queries live in the CSS files being deleted Re-express each page's breakpoints via MUI theme breakpoints / responsive sx when that page migrates — part of per-page DoD; review high-MQ files (layout, components, plan) explicitly
@types/react 19 vs React 18.3 skew Reconcile types to the React 18 runtime in Phase 0 before building the kit, so the tsc --noEmit gate isn't blocked by pre-existing skew
Czech dates in MUI X Pickers date-fns cs locale, dd.MM.yyyy / MM/yyyy / HH:mm — verified in Phase 0
Czech number/currency formatting Tables keep formatters.ts (formatCurrency, formatKm); DataTable only styles
Per-phase rollback Each phase is a self-contained, independently-revertable PR (page migration + its CSS deletion together). Coexistence means reverting one migrated page doesn't affect others. Phase 1 (AppShell) is the highest-blast-radius phase — ship behind extra verification; revert = git revert the phase PR + redeploy
Scope creep over a long migration Strict phase discipline; restyle only — never refactor logic in the same PR

12. Definition of done

  • Per page: rendered via the ui/ kit; its CSS file deleted; all interactions verified; its media queries re-expressed via MUI breakpoints/sx; modal focus/ESC/backdrop verified; dark + light + mobile + desktop pass; tsc + vite build green; no new console errors; shipped.
  • Overall: every page migrated; all per-feature CSS deleted (incl. responsive.css); variables.css retired into theme.ts; only the slim bespoke-widget CSS remains; the dev-only /ui-kit route reflects the full theme (and stays stripped from prod).

13. Out of scope / deferred

  • Puppeteer PDF templates (src/routes/admin/invoices-pdf.ts, offers-pdf.ts, orders-pdf.ts) use their own self-contained inline <style> + print fonts, independent of the admin stylesheet — NOT part of this migration and not aligned to the MUI theme. (DOMPurify + cleanQuillHtml sanitization on those paths is unaffected.) Phase 4's "detail re-themed" refers to the on-screen detail page only.
  • Rebuilding the plan/gantt grid from MUI primitives (kept custom-but-themed).
  • Mobile card-view table variant (future enhancement).
  • Any backend, API, or business-logic change.
  • Replacing Leaflet, Quill, or @dnd-kit (kept).

14. Open items to confirm during planning (Phase 0)

  • Confirm MUI v7 + MUI X v8 as the pinned versions and re-verify the cssVariables.colorSchemeSelector + colorSchemes API against the installed version's docs.
  • Reconcile @types/react with the React 18.3 runtime (or deliberately validate the chosen MUI version against React-18-runtime-with-React-19-types) before committing the kit.
  • Verify each of the three MUI X picker variants under the date-fns cs adapter: DatePicker (date), DatePicker month/year views (replacing showMonthYearPicker), TimePicker (replacing showTimeSelectOnly).- Audit the 8 useModalLock call sites and decide migrate-now vs keep-until-page-migrates for each.
  • Bundle-size budget/target to check against after Phase 0.

Note: CLAUDE.md references a hook named useListData that does not exist (the real hook is usePaginatedQuery); this spec uses the correct name. Correcting CLAUDE.md is advisable but outside this spec's scope.