# 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 `h1–h6`, 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:
  ```ts
  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 `Chip`s, 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 + `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 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 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/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 `ThemeContext`↔`setMode()` 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.
