Files
manage/openspec/changes/web-ui-rework/design.md
T
Developer dd778d8850 feat(frontend): slice 1 — foundation for MUI->shadcn migration
Web UI rework (openspec/changes/web-ui-rework). Foundation slice:
- Add @tanstack/react-table; remove orphaned recharts, d3
- Vendor shadcn primitives: tabs table dialog input label checkbox
  switch progress separator avatar textarea dropdown-menu scroll-area
- Add Vitest + @testing-library/react + jsdom harness; npm test script
- Delete no-op theme.ts shim; remove getAppTheme references
- Smoke test proves the harness

Gate: build + lint + test green.
2026-06-17 12:11:05 +00:00

466 lines
26 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.
# Design — web-ui-rework
> Phase: **design**. Technical design (not implementation) for finishing the
> MUI v9 → shadcn/ui + Tailwind v4 + lucide-react migration and reconciling the
> Manage information architecture to a single, coherent, thin-dashboard UI.
>
> Grounded in `exploration.md` (verified inventory), `proposal.md`, and
> `spec.md` (locked decisions). No source changes in this phase.
## 0. Locked design constraints (do not re-litigate)
These are settled by the spec and bound every decision below:
- **Single design system:** shadcn/ui + Tailwind v4 `@theme` tokens + lucide-react only. Zero `@mui/*` / `@emotion/*` / `recharts` / `d3` at the end; `theme.ts` deleted in slice 1.
- **IA:** Backups becomes a top-level nav item; the Media/Applications surface is named **"Media"** at canonical route **`/media`**; **`/applications` → redirect (replace) to `/media`**, mirroring the existing `/monitoring``/observability` redirect in `App.tsx`. Auth (`auth.ts`, `react-oidc-context`) and `react-router-dom` structure otherwise unchanged.
- **TanStack Table:** **visibility-only** parity — pagination, row selection, row click, column visibility. **No sorting, no resizing.**
- **Palette:** primary stays `#4f8cff`; `chart-1..5` tokens **repurposed** as status/Grafana-link semantic cues (not dropped).
- **Density:** comfortable everywhere (`p-4 md:p-6`, `gap-4`); **no compact mode**.
- **Test harness:** Vitest + `@testing-library/react` introduced in slice 1.
- **Delivery:** force-chained PRs, ≤400 changed lines/slice; ~8 slices per exploration §9.
- **Observability model:** thin dashboard, **no in-app charting**, Grafana deep-links preserved.
## 1. MUI → shadcn/ui + Tailwind component mapping table
Authoritative destination for every MUI component present across the 22
`@mui/material` consumers (see exploration §2a for per-file frequency). The 22
files follow this table verbatim; deviation requires a design-note in the
slice's tasks file.
| MUI component | Destination | shadcn primitive / Tailwind pattern | Status |
|---|---|---|---|
| `Typography` | semantic element + text utilities | `<h1>``<h6>`/`<p>`/`<span>` + `text-* font-*` per the §2 ramp | none needed |
| `Box` | `<div>` + utilities | `<div className="…">` with flex/grid utilities | none needed |
| `Stack` | `<div>` flex stack | `<div className="flex flex-col gap-4">` (or `flex-row gap-*`) | none needed |
| `Grid` | CSS grid `<div>` | `<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">` w/ responsive `md:`/`lg:` | none needed |
| `Paper` | bordered surface `<div>` or `Card` | `<div className="rounded-lg border bg-card">`; prefer `Card` where it already wraps a titled section | none needed |
| `Card` / `CardContent` | shadcn `Card` / `CardContent` | `@/components/ui/card` (Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter) | **present** |
| `Chip` | `Badge` | `@/components/ui/badge` (variants incl. new `success` — §2) | **present** |
| `Button` | `Button` | `@/components/ui/button` (`variant`, `size`) | **present** |
| `IconButton` | `Button size="icon"` | `<Button variant="ghost" size="icon">` (pattern already used in the shell) | **present** |
| `Tooltip` | `Tooltip` family | `@/components/ui/tooltip` (TooltipProvider/Tooltip/TooltipTrigger/TooltipContent) | **present** |
| `Alert` | `Alert` family | `@/components/ui/alert` (Alert, AlertTitle, AlertDescription) | **present** |
| `Table` / `TableHead` / `TableBody` / `TableRow` / `TableCell` / `TableContainer` | shadcn `Table` family | `@/components/ui/table` (Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption) | **add** |
| `Tabs` / `Tab` | shadcn `Tabs` | `@/components/ui/tabs` (Tabs, TabsList, TabsTrigger, TabsContent) | **add** |
| `Dialog` / `DialogTitle` / `DialogContent` / `DialogActions` | shadcn `Dialog` | `@/components/ui/dialog` (Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose) | **add** |
| `TextField` | `Input` (+ `Label`) | `@/components/ui/input`, `@/components/ui/label` | **add** |
| `Select` / `MenuItem` / `FormControl` / `InputLabel` | shadcn `Select` | `@/components/ui/select` (Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel) | **present** |
| `Switch` | `Switch` | `@/components/ui/switch` | **add** |
| `Checkbox` | `Checkbox` | `@/components/ui/checkbox` | **add** |
| `LinearProgress` | `Progress` | `@/components/ui/progress` (indeterminate via animated class) | **add** |
| `Divider` | `Separator` | `@/components/ui/separator` | **add** |
| `Avatar` | `Avatar` | `@/components/ui/avatar` (Avatar, AvatarImage, AvatarFallback) | **add** |
| `Drawer` | `Sheet side="right"` | `@/components/ui/sheet` (already used for mobile nav) | **present** |
| `FormControlLabel` | `Label` + control | `<Label>` wrapping the control; or sibling `<div className="flex items-center gap-2">` | **add** `Label` |
| `FormHelperText` | muted `<p>` | `<p className="text-xs text-muted-foreground">` | none needed |
| `DataGrid` (`@mui/x-data-grid`) | TanStack Table + `Table` | reusable `DataTable` wrapper (§3) on shadcn `Table` | **add** `@tanstack/react-table` |
**Shared Tailwind layout patterns (anti-drift reference for all 22 files):**
- **Vertical stack:** `<div className="flex flex-col gap-4">` (replaces MUI `<Stack direction="column" spacing={n}>`).
- **Horizontal row:** `<div className="flex flex-row items-center gap-2">`.
- **Responsive grid:** `<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">` (replaces MUI `<Grid>` + `<Grid item xs={…} md={…}>`).
- **Page section:** `SectionCard` (migrated in slice 2) wraps titled sections; consistent `gap-4` between cards.
- **Single surface treatment:** one border weight (`border-border`) and one radius (`rounded-lg` = `--radius` 0.625rem).
## 2. Token + theme design (`frontend/src/index.css`)
The existing Tailwind v4 `@theme` block is already a complete shadcn-style
system (verified against current source). The edits are surgical: keep the
palette, repurpose the chart tokens with documented roles, and add the `success`
Badge variant. `tailwind.config.cjs` stays minimal (content glob only) — no JS
theme config is introduced.
### 2.1 Palette — primary unchanged, `chart-1..5` repurposed
Primary remains `#4f8cff` (light + dark). The five `--color-chart-*` tokens are
retained at their current hues and documented in `index.css` as the **status /
Grafana-link semantic cue** source of truth. Single source of truth: Badge
variants and status code map to the `chart-N` tokens directly, **not** to
parallel `--color-success` aliases (which would drift).
| Token | Current value | Repurposed role | Consumed by |
|---|---|---|---|
| `--color-chart-1` | `#4f8cff` | **info** — default/informational status; also the Grafana deep-link brand cue (matches primary) | `Badge variant="default"`; Grafana-link `ExternalLink` affordance |
| `--color-chart-2` | `#22c55e` | **success** — healthy/OK (target up, run succeeded, resolved) | `Badge variant="success"` (new, §2.3) |
| `--color-chart-3` | `#f59e0b` | **warning** — pending/stale/missed-schedule | `Badge variant="warning"` (optional, map at call site) |
| `--color-chart-4` | `#ef4444` | **destructive** — error / firing alert (already aligns with `--color-destructive`) | `Badge variant="destructive"`; Alertmanager firing |
| `--color-chart-5` | `#8b5cf6` | **neutral-accent** — unknown/paused/secondary, and Grafana Explore deep-link cue (distinct from brand/destructive) | `Badge variant="secondary"` accents; Explore links |
**Concrete `index.css` edit shape:** add an inline comment block above the
`chart-1..5` lines in both the `@theme` block and `.dark` block documenting the
role mapping above. No token values change; no token is removed. Example:
```css
@theme {
/* …unchanged… */
/* Status / Grafana-link semantic cues — single source of truth for Badges.
chart-1=info/brand, chart-2=success, chart-3=warning,
chart-4=destructive, chart-5=neutral-accent. */
--color-chart-1: #4f8cff;
--color-chart-2: #22c55e;
--color-chart-3: #f59e0b;
--color-chart-4: #ef4444;
--color-chart-5: #8b5cf6;
}
```
### 2.2 Density + typography ramp (comfortable, no compact mode)
Codified as the standard every page follows (already matches the migrated
shell `<main className="p-4 md:p-6">` and `gap-4`):
- **Page padding:** `p-4 md:p-6` (the shell `<main>` already provides this; pages must not double-pad).
- **Card / section gap:** `gap-4` between cards; `space-y-4` for stacked sections.
- **Typography ramp:**
- Page title — `text-lg font-semibold` (rendered by `TopBar` from `navItems`).
- Section title — `text-base font-semibold`.
- Body — `text-sm`.
- Caption / helper — `text-xs text-muted-foreground`.
- **Radius:** one — `rounded-lg` (`--radius: 0.625rem`).
- **No compact/dense mode** anywhere: Media, Users, Backups runs all use the same comfortable row padding (`<TableCell className="py-3">`). No density toggle is introduced.
### 2.3 `success` Badge variant to add (`frontend/src/components/ui/badge.tsx`)
The current `badge.tsx` exposes variants `default | secondary | destructive |
outline | ghost | link` — there is **no success/healthy variant**. Add one,
mirroring the existing `destructive` soft-cue pattern and drawing color from the
repurposed `chart-2` token so the cue is the documented source of truth:
```ts
// inside badgeVariants variants: { …
success:
"bg-chart-2/10 text-chart-2 focus-visible:ring-chart-2/20 dark:bg-chart-2/20 dark:focus-visible:ring-chart-2/40 [a]:hover:bg-chart-2/20",
// optional warning (chart-3) may be added identically if a call site needs it
// }
```
Tailwind v4 resolves `chart-2` from the `--color-chart-2` `@theme` token, so
`text-chart-2` / `bg-chart-2/10` / `ring-chart-2/20` are valid utilities.
**Status → variant mapping (consumed consistently by Backups, Observability, Users):**
| Domain status | Badge variant | Cue token |
|---|---|---|
| healthy / OK / succeeded / up | `success` | `chart-2` |
| informational / default | `default` | `chart-1` |
| neutral / secondary / unknown | `secondary` | `chart-5` accent |
| pending / stale / warning | `warning` (add if needed) or `secondary` | `chart-3` |
| error / firing alert / failed | `destructive` | `chart-4` |
## 3. TanStack Table design — reusable `DataTable` wrapper
Both DataGrid surfaces (`Media.tsx`, `FileBrowser.impl.tsx`) are rebuilt on a
single headless `@tanstack/react-table` instance styled with the shadcn `Table`
primitive, behind a thin reusable wrapper. **Feature scope is visibility-only**
(locked): pagination, row selection, row click, column visibility. Sorting and
resizing are explicit non-goals.
### 3.1 Component shape
`frontend/src/components/ui/data-table.tsx` — a generic wrapper built on
`@/components/ui/table`. Props shape (design, not full impl):
```ts
import type { ColumnDef, OnChangeFn, RowSelectionState,
VisibilityState, PaginationState } from "@tanstack/react-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
// stable identity (Media needs path-derived id so selection survives paging)
getRowId?: (row: TData, index: number) => string;
// visibility-only feature set
enableRowSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onRowClick?: (row: TData) => void; // Media→open files; FileBrowser→preview
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
enableColumnVisibilityToggle?: boolean; // renders the column dropdown
// pagination (Media only; FileBrowser does not paginate)
enablePagination?: boolean;
manualPagination?: boolean; // Media: server-driven via limit/offset
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
pageSizeOptions?: number[];
rowCount?: number; // server total for Media
emptyMessage?: string;
}
```
Internally, `DataTable` calls `useReactTable` wired as follows:
- **Core:** `getCoreRowModel: getCoreRowModel()`.
- **Pagination:** `getPaginationRowModel: getPaginationRowModel()` only when `enablePagination` and **not** `manualPagination`. When `manualPagination` is true (Media), the table renders the supplied slice and `rowCount` drives the pager; the page index/size are lifted to the parent and feed `useMediaQuery` limit/offset.
- **Selection:** `enableRowSelection: true` gated by the prop; `rowSelection` state is controlled by the parent.
- **Column visibility:** `columnVisibility` state controlled by the parent; a `DropdownMenu` + `Checkbox` list renders the toggle UI when `enableColumnVisibilityToggle`.
- **Row click:** `<TableRow onClick={() => onRowClick?.(row.original)}>` with `className="cursor-pointer"` when `onRowClick` is set; selection column click stops propagation so checkboxes do not trigger navigation.
- **Selection column:** a leading display `ColumnDef` rendering a `Checkbox` (header = select-all-on-page via `table.getIsSomeRowsSelected()/getIsSelected()`), present only when `enableRowSelection`.
### 3.2 Column-def typing pattern
Columns are declared as `ColumnDef<TData>[]` with `accessorKey`/`accessorFn`,
`header`, and `cell` returning a styled `<TableCell>` child. Static widths use
Tailwind classes on the cell content (e.g. `className="w-[120px]`) — **no**
TanStack `size`/`enableColumnResizing` (locked out). No column sets
`enableSorting`; the table instance never receives `getSortedRowModel`.
### 3.3 Explicit non-goals (enforced)
- **No sorting:** no `getSortedRowModel`, no sortable header affordance, no sort indicators. Media's existing external filter controls (search/type/library/HDR) remain the only filtering path.
- **No resizing:** `enableColumnResizing` unset; no resize handles rendered.
### 3.4 Consumer contracts
**`pages/Media.tsx`** — builds `mediaColumns: ColumnDef<MediaItem>[]` for the 15
pre-rework columns (`title, series, season, episode, type, year, runtime_min,
size, bitrate, hdr, video, resolution, date_added, library, path`) and renders
`<DataTable>` with: `enableRowSelection`, `enablePagination` + `manualPagination`
- `rowCount` (driven by `queryResult.total`), `onRowClick`
`navigate('/files?path=…')`, and `enableColumnVisibilityToggle`. Pagination and
column-visibility state persist via the existing `usePersistentState` media
state, feeding `useMediaQuery({ limit, offset, … })`. The toggleable column set
matches the locked list in the spec scenario.
**`pages/FileBrowser.impl.tsx`** — builds `fileColumns: ColumnDef<FileEntry>[]`
for the 5 columns (`type, name, ext, size, modified`) and renders `<DataTable>`
with: `enableRowSelection`, `onRowClick` → selects the file for ffprobe preview,
and `enableColumnVisibilityToggle`. **No pagination** (the directory listing is
rendered in full, as today). Column-visibility state persists via the existing
browser state.
## 4. Navigation / IA design (`frontend/src/App.tsx`)
Three surgical edits, mirroring the established `/monitoring``/observability`
redirect pattern. Auth and routing structure are otherwise untouched.
### 4.1 `navItems` change
Current:
```ts
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/observability", label: "Observability", icon: Activity },
{ path: "/applications", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
```
Target — retarget Media to `/media`, **add a top-level Backups item**, and import
a lucide icon for it:
```ts
import { /* …existing…, */ DatabaseBackup } from "lucide-react";
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/observability", label: "Observability", icon: Activity },
{ path: "/media", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
```
**Icon choice + fallback:** `DatabaseBackup` is the semantic fit; if the pinned
`lucide-react@^1.14.0` (see §8 risk) does not export it, fall back to `HardDrive`
or `Archive` (both are long-standing exports). Verify the export before the
Backups slice (slice 3). Backups is placed after Files (operational grouping) and
before Users.
### 4.2 Route changes
In **both** route blocks (the OIDC-configured branch and the unauthenticated
branch), make `/media` the canonical element and convert `/applications` to a
replace-redirect, exactly mirroring `/monitoring`:
```tsx
<Route path="/media" element={<Applications />} />
<Route path="/applications" element={<Navigate to="/media" replace />} />
```
`/backups` already renders `<BackupsPage />`; no route addition is needed (only
the nav item). The page component remains `Applications` (the `Applications.tsx`
file rename is **out of scope** — non-goal: routing structure unchanged); only
the nav label + canonical route change.
## 5. Icon migration — MUI → lucide-react
The 10 distinct `@mui/icons-material` icons map as follows (from exploration §2c):
| File | MUI icon | lucide-react |
|---|---|---|
| `components/HoverEditButton.tsx` | `EditOutlined` | `Pencil` |
| `pages/UsersPage.impl.tsx` | `Close` | `X` |
| `pages/UsersPage.impl.tsx` | `AttachFile` | `Paperclip` |
| `pages/UsersPage.impl.tsx` | `FormatBold` | `Bold` |
| `pages/UsersPage.impl.tsx` | `FormatItalic` | `Italic` |
| `pages/UsersPage.impl.tsx` | `Link` | `Link` |
| `pages/UsersPage.impl.tsx` | `FormatListBulleted` | `List` |
| `pages/UsersPage.impl.tsx` | `MailOutlined` | `Mail` |
| `pages/UsersPage.impl.tsx` | `Send` | `Send` |
| `pages/UsersPage.impl.tsx` | `DeleteOutlined` | `Trash2` |
**Version-pin verification (required gate):** `package.json` pins
`lucide-react: ^1.14.0`, an unusual major. All 10 names above (plus
`DatabaseBackup`, `ExternalLink` already in use) are standard lucide exports,
but the exact pin must be verified to export them **before the UsersPage slice
(slice 6)** and the Backups icon (slice 3). Verification step: in a scratch
build, import each name from the pinned version; if any is missing, pick the
nearest lucide equivalent (e.g. `DatabaseBackup``HardDrive`) or bump the pin
within `^1.x` as a slice-1 foundation task. The icon set for the already-migrated
`ObservabilityPage` (12 icons) and shell is the proof that common names exist at
this pin.
## 6. Vitest setup design (slice 1)
A minimal component-test harness is introduced in the foundation slice. It must
coexist with — not replace — the existing `node --test` suites in
`frontend/tests/*.mjs`.
### 6.1 Config + scripts
- **Config file:** `frontend/vitest.config.ts` (separate from `vite.config.ts` to
keep the build config clean), using `defineConfig` from `vitest/config`,
`@vitejs/plugin-react`, `test.environment: "jsdom"`, `test.globals: true`,
`test.setupFiles: ["./src/test/setup.ts"]`, the `@` path alias from
`tsconfig.app.json`, and `test.include: ["src/**/*.{test,spec}.{ts,tsx}"]` so
Vitest does **not** claim the `frontend/tests/*.mjs` node suites.
- **Setup file:** `frontend/src/test/setup.ts` importing
`@testing-library/jest-dom` (matcher registration).
- **npm scripts (added to `package.json`):**
- `"test": "vitest run"` — single-run CI gate.
- `"test:watch": "vitest"` — watch mode for local dev.
- `"test:node": "node --test tests"` — keeps the existing node suites runnable explicitly (they also still run directly).
### 6.2 Dev dependencies added in slice 1
`vitest`, `@testing-library/react`, `@testing-library/jest-dom`,
`@testing-library/user-event`, `jsdom`. (Runtime deps untouched here.)
### 6.3 Example test shape (for a migrated component)
Co-located component test for a slice-2 shared block, asserting migrated
behavior — representative pattern every migrated component follows:
```tsx
// frontend/src/components/__tests__/MetricCard.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricCard } from "../MetricCard";
describe("MetricCard", () => {
it("renders the label and value", () => {
render(<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />);
expect(screen.getByText("Movies")).toBeInTheDocument();
expect(screen.getByText("1,234")).toBeInTheDocument();
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
});
});
```
Status-Badge mapping is a natural component-test target (e.g. assert a healthy
target renders `variant="success"`), satisfying the spec's "migrated components
have component tests" scenario.
## 7. Slice contracts (~8 slices; full table in exploration §9)
Each slice is independently reviewable and leaves `npm run build` + `npm run lint`
green. One-paragraph contracts follow; the authoritative file list is exploration §9.
1. **Foundation.** Add the §5 shadcn primitives (`tabs table dialog input label
checkbox switch progress separator avatar textarea dropdown-menu scroll-area`),
install `@tanstack/react-table`, remove `recharts` + `d3` from `package.json`,
delete `frontend/src/theme.ts` (and its callers), and introduce the §6 Vitest
harness + a trivial passing test. Add the `success` Badge variant + `chart-*`
role comments (§2). **Exit:** no `theme`/`getAppTheme` imports remain; build +
lint + `vitest run` green; no behavior change yet.
2. **Shared components.** Migrate the reusable building blocks to lock the shared
language before pages: `SectionCard`, `SelectionRailCard`, `TabbedCard`,
`MetricCard`, `DiskSpaceCard`, `HoverEditButton`, `DialogFooter`,
`ConfirmDialog`, `LibraryOverview`, `NowPlaying`/`SessionActivityPanel`. Each
uses `Card`/`Badge`/`Button`/`Table`/`Tabs`/`Dialog` per §1. **Exit:** all
shared components MUI-free; one component test per migrated block; downstream
pages still compile against the same exported APIs.
3. **Backups cluster + nav.** Migrate `BackupAlertsTable`, `BackupJobsTable`,
`BackupRunsTable`, `BackupsPage`, `BackupDashboardWidget` (all `Table` family
- `Badge` status cues), and apply the §4 `navItems` + route edits to surface
`/backups` as a top-level item (verify `DatabaseBackup`/fallback icon).
**Exit:** `/backups` reachable from the sidebar; `/applications` redirects to
`/media`; build + lint green.
4. **Dashboard + Media/Applications surface.** Migrate `Dashboard.tsx` (20 MUI
components → `Card`/`Grid`→CSS-grid/`Dialog`/`Select`/`Switch`/`TextField`→`Input`)
and `Applications.tsx` (9 → `Card`/grid/`Tabs`). **Exit:** both pages MUI-free
and visually consistent; build + lint green.
5. **Settings + Actions.** Migrate `Settings.tsx` (18) and `Actions.tsx` (19) —
the form-heavy pair (`TextField`→`Input`, `Select`, `Switch`, `Checkbox`,
`Tabs`, `Dialog`). Keep uncontrolled/`useState` form parity (no form library).
**Exit:** both pages MUI-free; forms behave as before; build + lint green.
6. **Users (largest).** Migrate `UsersPage.impl.tsx` (25 MUI components + 9 icons
- `Drawer`→`Sheet` + `Table` + rich-text compose). **High sub-split likelihood**
(§8): likely split into (a) directory table + selection + drawer, (b) compose
dialog + formatting actions + attachments, to stay ≤400 lines. Apply the §5
icon map (verify the 9 names at the pin). **Exit:** page MUI-free; drawer,
selection-across-pages, and compose/send behavior preserved; build + lint green.
7. **DataGrid → TanStack Table (highest risk, last).** Build the §3 `DataTable`
wrapper on shadcn `Table`, then rebuild `Media.tsx` and `FileBrowser.impl.tsx`
against it. Preserve pagination (Media, server-driven) / row selection / row
click / column visibility exactly (locked scenario columns). No sorting, no
resizing. **Exit:** both grids behaviorally at parity; `@mui/x-data-grid` no
longer imported; build + lint green.
8. **Cleanup + docs.** Remove `@mui/*` + `@emotion/*` from `package.json`, run a
final `tsc -b` + `eslint` + `vitest run` + existing `node --test`, and update
`docs/REQUIREMENTS.md` (single design system, thin-dashboard observability,
TanStack tables, `/media` + `/applications` redirect, Backups nav, removed
deps). **Exit:** recursive search of `frontend/src` for `@mui/*`/`@emotion/*`
returns zero; requirements doc updated; all gates green.
## 8. Review / judgment risks
1. **DataGrid slice sequenced last (high).** Slice 7 carries the single largest
behavior-parity surface (Media pagination is **server-driven** via
`useMediaQuery` limit/offset, and selection must survive paging via a stable
`getRowId`). It is deliberately last so `Table`, tokens, and Badge cues are
already settled; but manual smoke of Media pagination + row-click navigation
and FileBrowser row-click preview is mandatory at slice exit. Mitigation:
component-test the `DataTable` wrapper (selection toggle, column visibility
toggle, row-click callback) in slice 7 before re-wiring the pages.
2. **UsersPage sub-split likelihood (high).** `UsersPage.impl.tsx` is the largest
consumer (25 components + 9 icons + `Drawer` + rich-text compose). Expect to
split slice 6 into two PRs (table/drawer vs. compose). Judgment call at apply
time: split before exceeding 400 lines rather than after.
3. **Cross-slice drift mitigation (medium).** 22 files over ~8 slices risks
inconsistent layout primitives. Mitigated by (a) shared-components-first
(slice 2 before any page), (b) the §1 anti-drift Tailwind patterns, and (c)
the single `chart-*` color source of truth. Reviewers enforce §1 patterns as
the lint-supplement gate.
4. **lucide-react version risk (lowmedium).** `^1.14.0` is an unusual major.
The 10 mapped icons (plus `DatabaseBackup` for Backups) must be verified at
the pin before the consuming slices (3 and 6). The already-migrated
`ObservabilityPage` (12 icons) proves common names resolve; verification is a
slice-1 foundation task with a fallback list.
5. **Test-coverage gaps (medium).** Today there is no component harness; legacy
`node --test` suites cover only pure-JS transforms. The new Vitest harness
mitigates this, but coverage is only as good as what each slice writes — make
"at least one behavioral component test per migrated component" a hard slice
gate (spec scenario), especially for the status-Badge mapping and the
`DataTable` features.