Approved design for migrating the admin frontend off ~7,100 lines of hand-written CSS onto MUI, themed to the existing brand, dark/light preserved, modern 'soft-SaaS shell + dense tables' look, rolled out incrementally (foundation-first, always shippable). Verified via 4-way adversarial review (editorial, MUI-docs accuracy, codebase-fit, completeness) with fixes applied. Also gitignores .superpowers/ brainstorm mockups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
28 KiB
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 filevariables.css(164) andplan.cssare 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 bysrc/context/ThemeContext.tsx(useTheme()/toggleTheme(), which callsdocument.documentElement.setAttribute("data-theme", …)).Sidebar.tsx,AdminLayout.tsx, andLogin.tsxonly consume it viauseTheme(). - 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-domare^18.3.1but@types/react/@types/react-domare^19.x. MUI's component types are sensitive to the@types/reactmajor 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 | A focused CRUD page (Vozidla or Uživatelé) |
| 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.tsbuilds the MUI theme from the tokens:- Palette:
primary= brand red (#d63031dark /#c73030light), plussuccess/warning/error/infomapped to existing semantic tokens. - Typography: Urbanist for
h1–h6, Plus Jakarta Sans for body, DM Mono for numeric/monospace contexts. - Shape/spacing:
borderRadiusand spacing from the token scale. componentsoverrides: the Soft-SaaS defaults (rounded cards, pill buttons, soft shadows, tinted chips) baked in so every MUI component is on-brand by default.
- Palette:
- 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:
This emits
createTheme({ cssVariables: { colorSchemeSelector: "[data-theme='%s']" }, colorSchemes: { light: true, dark: true }, // …palette/typography/components });[data-theme="light"]{…}and[data-theme="dark"]{…}, matching the attributeThemeContextalready writes. One attribute drives MUI and legacy CSS → no second toggle, 1:1 dark parity. - One attribute owner.
ThemeContextremains the sole writer ofdata-themeon<html>. ItstoggleTheme()must also call MUI'ssetMode()so MUI's internal color-scheme state stays in sync with the attribute. Do not also enable MUI'sInitColorSchemeScriptattribute-writing (avoid two writers). Wired in Phase 0.
4.2 Coexistence (the safety mechanism)
- Use MUI's
ScopedCssBaselinearound migrated page subtrees only, so MUI's CSS reset applies inside migrated pages without disturbing unmigrated ones still onbase.css. No global reset until teardown. - The
CssVarsProvider/ThemeProvidermust sit above everyScopedCssBaseline, anddata-thememust 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(formatCurrencycs-CZ +Kč/€,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 isdd.MM.yyyy, notdd.mm.rrrr. Apply viaLocalizationProviderdateFormats(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)
- 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. - Tokens move, don't die.
variables.cssis rewritten intotheme.ts(TypeScript). With MUI's CSS-variable mode, MUI emits those tokens as CSS custom properties at runtime — so the hand-writtenvariables.cssfile is retired, but the values live on in TS as the single source. - 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.
- Plan/gantt grid (
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 invariables.css/theme.tsin 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 shadow0 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), radius999px, shadow0 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.64remuppercase, letter-spacing.05em, muted; DM Mono right-aligned numeric cells; row hover#faf9f7; row borders#f5f3f1; container is a 16px soft card. - Fonts: Urbanist 600–800 (headings), Plus Jakarta Sans 400–700 (body), DM Mono 400–500 (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/reactwith the React 18 runtime (§14); writetheme.tsfrom tokens and apply the token-level palette refresh app-wide (§7); author the dark palette; wireScopedCssBaseline+ theThemeContext↔setMode()sync; build thesrc/admin/ui/kit; add the dev-only/ui-kitshowcase route (guarded byimport.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 oflayout.css. Login is NOT touched here — it is a sibling route outsideAdminLayout(AdminApp.tsx:100) with its own full-screen layout; see Phase 3. - Phase 2 — Pilot CRUD page (Vozidla or Uživatelé). 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. The non-pilot of Users/Vehicles, 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.cssintotheme.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 +cleanQuillHtmlsanitization 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 --noEmitandvite buildpass. /ui-kitdev 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 buildgreen; no new console errors; shipped. - Overall: every page migrated; all per-feature CSS deleted (incl.
responsive.css);variables.cssretired intotheme.ts; only the slim bespoke-widget CSS remains; the dev-only/ui-kitroute 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 +cleanQuillHtmlsanitization 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+colorSchemesAPI against the installed version's docs. - Reconcile
@types/reactwith 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-fnscsadapter:DatePicker(date),DatePickermonth/year views (replacingshowMonthYearPicker),TimePicker(replacingshowTimeSelectOnly). - Final pilot page choice between Vozidla and Uživatelé.
- Audit the 8
useModalLockcall sites and decide migrate-now vs keep-until-page-migrates for each. - Bundle-size budget/target to check against after Phase 0.
Note:
CLAUDE.mdreferences a hook nameduseListDatathat does not exist (the real hook isusePaginatedQuery); this spec uses the correct name. CorrectingCLAUDE.mdis advisable but outside this spec's scope.