Files
app/docs/superpowers/plans/2026-06-06-mui-migration-phase-0-foundation.md
2026-06-06 19:24:11 +02:00

681 lines
21 KiB
Markdown
Raw 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.
# MUI Migration — Phase 0: Theming Foundation — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up the MUI theming foundation — install MUI, build `theme.ts` (light+dark from existing tokens), wire the provider + `data-theme` color-scheme bridge + `ThemeContext` sync, apply the token-level palette refresh app-wide, and ship a dev-only `/ui-kit` route proving the theme on the first wrappers (`Button`/`Card`/`TextField`).
**Architecture:** MUI's CSS-variable theme is configured with `colorSchemeSelector: "[data-theme='%s']"` so it reads the same `data-theme` attribute `ThemeContext` already writes — one toggle drives both MUI and legacy CSS. A tiny sync component mirrors the attribute into MUI's JS color-scheme state. `ScopedCssBaseline` scopes MUI's reset to migrated subtrees (here, only `/ui-kit`); legacy pages keep `base.css`.
**Tech Stack:** React 18.3, Vite 8, TypeScript (strict), MUI v7 (`@mui/material`) + Emotion + MUI X v8 pickers, Vitest (node).
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§3, §4, §7 token-level layer, §8 Phase 0).
**Scope note:** This plan is Phase 0 ONLY. The rest of the `ui/` kit (Modal, Toast, Select, Switch, Tabs, DataTable, DatePicker), the AppShell (Phase 1), and the Vozidla pilot (Phase 2) are separate plans.
---
## File Structure
| File | Responsibility | Action |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------ |
| `package.json` | MUI/Emotion/X-pickers deps; `@types/react` reconciled to 18 | Modify |
| `src/admin/theme.ts` | The MUI theme: cssVariables + colorSchemeSelector, light/dark palettes, typography, shape, component overrides | Create |
| `src/admin/theme.test.ts` | Unit test asserting theme palette/typography/shape values | Create |
| `src/admin/ui/MuiProvider.tsx` | Wraps children in MUI `ThemeProvider` + renders the sync component | Create |
| `src/admin/ui/MuiColorSchemeSync.tsx` | Mirrors `useTheme()` (custom) → MUI `useColorScheme().setMode` | Create |
| `src/admin/ui/Button.tsx`, `Card.tsx`, `TextField.tsx` | Thin brand wrappers over MUI (stable app-facing API) | Create |
| `src/admin/ui/index.ts` | Barrel export for the kit | Create |
| `src/admin/pages/UiKit.tsx` | Dev-only showcase, wrapped in `ScopedCssBaseline` | Create |
| `src/admin/AdminApp.tsx` | Mount `MuiProvider`; add DEV-only `/ui-kit` route | Modify |
| `src/admin/variables.css` | Token-level palette refresh (canvas, chip tints) — app-wide | Modify |
---
## Task 1: Install dependencies and reconcile React types
**Files:**
- Modify: `package.json` (via npm)
- [ ] **Step 1: Install MUI + Emotion + X pickers**
Run (date-fns ^4 is already a dependency):
```bash
npm install @mui/material@^7 @emotion/react@^11 @emotion/styled@^11 @mui/x-date-pickers@^8
```
Expected: packages added to `dependencies`; no peer-dependency errors that block install.
- [ ] **Step 2: Reconcile `@types/react` to the React 18 runtime**
The repo runs `react@^18.3.1` but pins `@types/react@^19`. MUI's component types are sensitive to the `@types/react` major. Align the types to the runtime:
```bash
npm install -D @types/react@^18.3 @types/react-dom@^18.3
```
Expected: devDependencies now show `@types/react@^18.3.x`.
- [ ] **Step 3: Verify the project still type-checks and builds**
Run:
```bash
npx tsc -b --noEmit
npm run build:client
```
Expected: both succeed with no errors (this is the pre-change baseline; if `tsc` was already failing, capture that before proceeding).
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json
git commit -m "build(mui): add MUI v7 + Emotion + X pickers; align @types/react to 18"
```
---
## Task 2: Create `theme.ts` with light/dark palettes (TDD)
**Files:**
- Create: `src/admin/theme.ts`
- Test: `src/admin/theme.test.ts`
Token values are taken from `src/admin/variables.css` (`[data-theme="light"]` / `[data-theme="dark"]`).
- [ ] **Step 1: Write the failing test**
Create `src/admin/theme.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { theme } from "./theme";
describe("MUI theme", () => {
it("uses the brand fonts", () => {
expect(theme.typography.fontFamily).toContain("Plus Jakarta Sans");
expect(String(theme.typography.h1.fontFamily)).toContain("Urbanist");
});
it("maps the brand red per color scheme", () => {
expect(theme.colorSchemes.light.palette.primary.main).toBe("#c73030");
expect(theme.colorSchemes.dark.palette.primary.main).toBe("#d63031");
});
it("maps the refreshed light canvas + paper", () => {
expect(theme.colorSchemes.light.palette.background.default).toBe("#f4f3f1");
expect(theme.colorSchemes.light.palette.background.paper).toBe("#ffffff");
});
it("maps semantic colors per scheme", () => {
expect(theme.colorSchemes.light.palette.error.main).toBe("#b91c1c");
expect(theme.colorSchemes.dark.palette.success.main).toBe("#22c55e");
});
it("uses the base radius of 10", () => {
expect(theme.shape.borderRadius).toBe(10);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: FAIL — `Cannot find module './theme'` (or `theme` is undefined).
- [ ] **Step 3: Implement `theme.ts` (palettes/typography/shape only — overrides come in Task 3)**
Create `src/admin/theme.ts`:
```ts
import { createTheme } from "@mui/material/styles";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif";
const FONT_MONO = "'DM Mono', Menlo, monospace";
export const theme = createTheme({
cssVariables: {
// Read the SAME attribute ThemeContext already writes (`data-theme`).
// The "%s" placeholder is required; value must start with "." or "[".
colorSchemeSelector: "[data-theme='%s']",
},
colorSchemes: {
light: {
palette: {
mode: "light",
primary: { main: "#c73030" },
success: { main: "#15803d" },
warning: { main: "#b45309" },
error: { main: "#b91c1c" },
info: { main: "#1d4ed8" },
background: { default: "#f4f3f1", paper: "#ffffff" },
text: { primary: "#1a1a1a", secondary: "#555555" },
divider: "rgba(0,0,0,0.1)",
},
},
dark: {
palette: {
mode: "dark",
primary: { main: "#d63031" },
success: { main: "#22c55e" },
warning: { main: "#f59e0b" },
error: { main: "#ef4444" },
info: { main: "#3b82f6" },
background: { default: "#0f0f0f", paper: "#1a1a1a" },
text: { primary: "#ffffff", secondary: "#a0a0a0" },
divider: "rgba(255,255,255,0.08)",
},
},
},
shape: { borderRadius: 10 },
typography: {
fontFamily: FONT_BODY,
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 },
},
});
// Re-exported for tasks/components that need the mono stack.
export const fonts = {
body: FONT_BODY,
heading: FONT_HEADING,
mono: FONT_MONO,
};
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/admin/theme.ts src/admin/theme.test.ts
git commit -m "feat(mui): theme.ts with light/dark palettes, brand fonts, data-theme selector"
```
---
## Task 3: Add component overrides (the "Soft-SaaS" defaults)
**Files:**
- Modify: `src/admin/theme.ts`
- Test: `src/admin/theme.test.ts`
- [ ] **Step 1: Add a failing assertion for the overrides**
Append to `src/admin/theme.test.ts` (inside the `describe`):
```ts
it("makes buttons pill-shaped and cards 16px by default", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
expect(btn?.borderRadius).toBe(999);
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
expect(card?.borderRadius).toBe(16);
});
```
- [ ] **Step 2: Run to verify it fails**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: FAIL — `theme.components` is undefined / `borderRadius` is undefined.
- [ ] **Step 3: Add `components` to the `createTheme` call**
In `src/admin/theme.ts`, add a `components` key to the `createTheme({...})` object (after `typography`):
```ts
components: {
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: { borderRadius: 999, paddingInline: "0.95rem" },
containedPrimary: {
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
boxShadow:
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
},
},
},
MuiChip: {
styleOverrides: { root: { borderRadius: 999, fontWeight: 700 } },
},
},
```
- [ ] **Step 4: Run to verify it passes**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: PASS (6 tests).
- [ ] **Step 5: Commit**
```bash
git add src/admin/theme.ts src/admin/theme.test.ts
git commit -m "feat(mui): soft-SaaS component defaults (pill buttons, 16px cards, pill chips)"
```
---
## Task 4: MUI provider + color-scheme sync
**Files:**
- Create: `src/admin/ui/MuiColorSchemeSync.tsx`
- Create: `src/admin/ui/MuiProvider.tsx`
- Modify: `src/admin/AdminApp.tsx`
- [ ] **Step 1: Create the sync component**
Create `src/admin/ui/MuiColorSchemeSync.tsx`:
```tsx
import { useEffect } from "react";
import { useColorScheme } from "@mui/material/styles";
import { useTheme as useAppTheme } from "../../context/ThemeContext";
/**
* ThemeContext is the single owner of the `data-theme` attribute. This mirrors
* its value into MUI's JS color-scheme state so theme.applyStyles('dark') and
* useColorScheme().mode stay in sync with the attribute the CSS selector reads.
*/
export default function MuiColorSchemeSync() {
const { theme } = useAppTheme();
const { mode, setMode } = useColorScheme();
useEffect(() => {
if (mode !== theme) setMode(theme as "light" | "dark");
}, [theme, mode, setMode]);
return null;
}
```
- [ ] **Step 2: Create the provider wrapper**
Create `src/admin/ui/MuiProvider.tsx`:
```tsx
import type { ReactNode } from "react";
import { ThemeProvider } from "@mui/material/styles";
import { theme } from "../theme";
import MuiColorSchemeSync from "./MuiColorSchemeSync";
export default function MuiProvider({ children }: { children: ReactNode }) {
return (
<ThemeProvider theme={theme} defaultMode="dark">
<MuiColorSchemeSync />
{children}
</ThemeProvider>
);
}
```
- [ ] **Step 3: Mount the provider at the top of `AdminApp`**
In `src/admin/AdminApp.tsx`, add the import after the other component imports (e.g. after line 10):
```tsx
import MuiProvider from "./ui/MuiProvider";
```
Then wrap the existing returned tree. Change:
```tsx
return (
<AuthProvider>
```
to:
```tsx
return (
<MuiProvider>
<AuthProvider>
```
and change the matching closing tag at the end of the JSX:
```tsx
</AuthProvider>
);
```
to:
```tsx
</AuthProvider>
</MuiProvider>
);
```
- [ ] **Step 4: Verify the app builds and renders with no console errors**
Run:
```bash
npx tsc -b --noEmit
npm run build:client
```
Expected: both succeed. (The user runs the dev server separately — do not start it. Verification of live render happens via the `/ui-kit` route in Task 6.)
- [ ] **Step 5: Commit**
```bash
git add src/admin/ui/MuiProvider.tsx src/admin/ui/MuiColorSchemeSync.tsx src/admin/AdminApp.tsx
git commit -m "feat(mui): mount MUI provider + sync color scheme to data-theme"
```
---
## Task 5: First UI-kit wrappers + barrel
**Files:**
- Create: `src/admin/ui/Button.tsx`, `src/admin/ui/Card.tsx`, `src/admin/ui/TextField.tsx`, `src/admin/ui/index.ts`
- [ ] **Step 1: Create the wrappers**
Create `src/admin/ui/Button.tsx`:
```tsx
import MuiButton, { type ButtonProps } from "@mui/material/Button";
/** App Button: defaults to the brand primary contained style. */
export default function Button(props: ButtonProps) {
return <MuiButton variant="contained" color="primary" {...props} />;
}
```
Create `src/admin/ui/Card.tsx`:
```tsx
import MuiCard, { type CardProps } from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
export default function Card({ children, ...props }: CardProps) {
return (
<MuiCard {...props}>
<CardContent>{children}</CardContent>
</MuiCard>
);
}
```
Create `src/admin/ui/TextField.tsx`:
```tsx
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
/** App TextField: small + outlined + full width by default. */
export default function TextField(props: TextFieldProps) {
return <MuiTextField size="small" variant="outlined" fullWidth {...props} />;
}
```
- [ ] **Step 2: Create the barrel**
Create `src/admin/ui/index.ts`:
```ts
export { default as MuiProvider } from "./MuiProvider";
export { default as Button } from "./Button";
export { default as Card } from "./Card";
export { default as TextField } from "./TextField";
```
- [ ] **Step 3: Verify type-check**
Run:
```bash
npx tsc -b --noEmit
```
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/admin/ui/Button.tsx src/admin/ui/Card.tsx src/admin/ui/TextField.tsx src/admin/ui/index.ts
git commit -m "feat(mui): first ui-kit wrappers (Button, Card, TextField) + barrel"
```
---
## Task 6: Dev-only `/ui-kit` showcase route
**Files:**
- Create: `src/admin/pages/UiKit.tsx`
- Modify: `src/admin/AdminApp.tsx`
- [ ] **Step 1: Create the showcase page**
Create `src/admin/pages/UiKit.tsx`:
```tsx
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import { Button, Card, TextField } from "../ui";
import { useTheme } from "../../context/ThemeContext";
export default function UiKit() {
const { theme, toggleTheme } = useTheme();
return (
<ScopedCssBaseline>
<Box sx={{ p: 4, minHeight: "100vh", bgcolor: "background.default" }}>
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
<Typography variant="h4">UI Kit</Typography>
<Button color="inherit" variant="outlined" onClick={toggleTheme}>
Theme: {theme}
</Button>
</Stack>
<Stack spacing={3} sx={{ maxWidth: 520 }}>
<Card>
<Typography variant="h6" gutterBottom>
Faktury
</Typography>
<Typography color="text.secondary" gutterBottom>
12 vystavených tento měsíc
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<Chip label="Zaplaceno" color="success" size="small" />
<Chip label="Po splatnosti" color="error" size="small" />
</Stack>
<TextField
label="Hledat fakturu"
placeholder="Číslo nebo klient…"
/>
<Box mt={2}>
<Button>Nová faktura</Button>
</Box>
</Card>
</Stack>
</Box>
</ScopedCssBaseline>
);
}
```
- [ ] **Step 2: Register the route, DEV-only**
In `src/admin/AdminApp.tsx`, add the lazy import alongside the other lazy pages (after line 11's eager imports / with the `lazy(...)` block):
```tsx
const UiKit = lazy(() => import("./pages/UiKit"));
```
Then, inside `<Routes>`, add a sibling route to `login` (so the showcase renders without the shell), guarded so it is dropped from production builds:
```tsx
{
import.meta.env.DEV && <Route path="ui-kit" element={<UiKit />} />;
}
```
- [ ] **Step 3: Verify build (prod) excludes it and type-check passes**
Run:
```bash
npx tsc -b --noEmit
npm run build:client
```
Expected: both succeed. `import.meta.env.DEV` is `false` in the production build, so the route is tree-shaken.
- [ ] **Step 4: Manual + Playwright verification**
The user runs the dev server. Once it's up, verify at `/ui-kit`:
- Card, button (red gradient, pill), chips (tinted), and text field render in the Soft-SaaS style.
- Clicking **Theme: dark/light** flips `data-theme` and **both MUI components AND the page background** swap — proving the `colorSchemeSelector` bridge works.
- No new console errors.
Capture baseline screenshots with the available Playwright tooling (light + dark) and save under `docs/superpowers/visual-baselines/phase-0-ui-kit-{light,dark}.png`.
- [ ] **Step 5: Commit**
```bash
git add src/admin/pages/UiKit.tsx src/admin/AdminApp.tsx
git commit -m "feat(mui): dev-only /ui-kit showcase proving the theme + data-theme bridge"
```
---
## Task 7: Token-level palette refresh (app-wide)
**Files:**
- Modify: `src/admin/variables.css`
Per spec §7, the token-level palette refresh is applied app-wide so migrated and legacy pages share the refreshed palette. The only token change for Phase 0 is the light canvas (the new look's `#f4f3f1` vs current `#f5f4f2`); chip/structure changes are component-level (MUI) and need no token edit.
- [ ] **Step 1: Update the light canvas token**
In `src/admin/variables.css`, inside the `[data-theme="light"]` block, change:
```css
--bg-primary: #f5f4f2;
```
to:
```css
--bg-primary: #f4f3f1;
```
- [ ] **Step 2: Verify build**
Run:
```bash
npm run build:client
```
Expected: succeeds.
- [ ] **Step 3: Manual check**
With the dev server (user-run), confirm in light mode the app canvas is the refreshed `#f4f3f1` and nothing else shifted structurally. (This is the deliberate, minor app-wide color change noted in the spec — not a no-op.)
- [ ] **Step 4: Commit**
```bash
git add src/admin/variables.css
git commit -m "style(tokens): refresh light canvas to #f4f3f1 (two-layer refresh, token level)"
```
---
## Task 8: Phase-0 release verification
**Files:** none (verification + tag)
- [ ] **Step 1: Full type-check, unit tests, and build**
Run:
```bash
npx tsc -b --noEmit
npx vitest run src/admin/theme.test.ts
npm run build
```
Expected: `tsc` clean; 6 theme tests pass; server + client build succeed.
- [ ] **Step 2: Backend test suite still green (server untouched)**
Run:
```bash
npm test
```
Expected: existing `auth` + `numbering` suites pass (Phase 0 changed no server code).
- [ ] **Step 3: Definition-of-done checklist (manual, dev server)**
- `/ui-kit` renders Button/Card/TextField/Chip in the Soft-SaaS style in **both** themes.
- Theme toggle flips `data-theme` and swaps MUI + legacy colors together; MUI's `useColorScheme().mode` matches (no console warnings).
- No new console errors on any existing page (the provider mount + token refresh are non-breaking).
- Light canvas is `#f4f3f1`.
- Production build excludes the `/ui-kit` route.
- [ ] **Step 4: Tag/release per the project release process**
Follow `CLAUDE.md` "Release Process" (bump `package.json`, build, tag, deploy) when shipping Phase 0. No Prisma migration is involved (frontend-only).
---
## Self-review notes (for the controller)
- **Spec coverage:** Phase 0 items from spec §8 are all covered — deps + `@types/react` (Task 1), `theme.ts` light/dark (Tasks 23), provider + `ScopedCssBaseline` + `ThemeContext``setMode` sync (Tasks 4, 6), dev-only `/ui-kit` (Task 6), token-level refresh (Task 7). The full `ui/` kit, AppShell, and Vozidla are explicitly deferred to later plans (scope note).
- **Type consistency:** `theme` (named export) is used consistently in `theme.test.ts`, `MuiProvider.tsx`. `useTheme` refers to the app's `ThemeContext` (aliased `useAppTheme` in the sync component to avoid colliding with MUI's `useTheme`). Wrappers are default exports re-exported via the barrel.
- **Open risk to verify in Task 1/6:** exact MUI v7 API for `cssVariables.colorSchemeSelector` + `ThemeProvider` `defaultMode` — confirm against the installed version's docs; if `defaultMode` belongs on a different provider in the installed version, adjust in Task 4.