Web UI rework. - Migrate BackupAlertsTable, BackupJobsTable, BackupRunsTable, BackupsPage, BackupDashboardWidget off @mui (shadcn Table + Badge severity variants: success=chart-2, warning=chart-3, destructive) - App.tsx IA: Backups now top-level nav (DatabaseBackup icon); Media surface primary at /media; /applications -> /media redirect in both route trees (mirrors /monitoring -> /observability) Gate: build + lint + test green.
25 KiB
Tasks — web-ui-rework
Phase: tasks. Concrete, reviewable implementation tasks for the MUI v9 → shadcn/ui + Tailwind v4 + lucide-react finish migration. Grounded in
proposal.md,exploration.md,spec.md, anddesign.md(all authoritative). No source changes in this phase — this file is the contractsdd-applyexecutes andsdd-verifychecks.Locked decisions (do not re-litigate): single design system — shadcn/ui, Tailwind v4, and lucide-react; Backups top-level nav item; surface renamed Media at
/mediawith/applications→ redirect (mirrors/monitoring→/observability); TanStack Table visibility-only parity (pagination, row selection, row click, column visibility — NO sorting, NO resizing); palette keeps#4f8cffwithchart-1..5repurposed as status/Grafana-link cues; comfortable density everywhere; Vitest + Testing Library harness added in slice 1; force-chained PRs, ≤400 changed lines/slice.
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~2,800–3,900 total (sum of per-slice ranges below) |
| 400-line budget risk | High (slices 1, 6, 7 sit at/over the boundary; 4–5 medium) |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 → PR 2 → PR 3 → PR 4 → PR 5 → PR 6a → PR 6b → PR 7a → PR 7b → PR 8 (sub-splits on size overruns) |
| Delivery strategy | auto-chain (force-chained per locked decision; sub-split a slice before exceeding 400) |
| Chain strategy | stacked-to-main |
Per-slice changed-line estimate
| Slice | Focus | Est. changed lines | ≤400? | Sub-split policy |
|---|---|---|---|---|
| 1 | Foundation (primitives, tanstack, Vitest, theme/badge) | ~400–580 | At/over — vendored shadcn primitives dominate | Likely split 1a (shadcn primitives + deps) → 1b (Vitest harness + theme.ts removal + success Badge + chart comments). Vendored generated primitives may also be taken as a size exception. |
| 2 | Shared components (11 blocks) | ~280–420 | Medium | If over, split 2a (cards/buttons/dialogs) → 2b (tables/panels). |
| 3 | Backups cluster + nav | ~250–400 | Likely OK | Single PR; split nav edit out if over. |
| 4 | Dashboard + Applications | ~300–450 | Medium | If over, split 4a (Applications, smaller) → 4b (Dashboard). |
| 5 | Settings + Actions | ~350–500 | Medium-High | If over, split 5a (Actions) → 5b (Settings). |
| 6 | Users (largest) | ~550–850 | No | Force split 6a (directory table + selection + Drawer/Sheet) → 6b (compose dialog + formatting actions + attachments). |
| 7 | DataGrid → TanStack (highest risk) | ~450–700 | No | Force split 7a (DataTable wrapper + tests + FileBrowser) → 7b (Media, server-driven pagination). |
| 8 | Cleanup + docs | ~80–160 | OK | Single PR. |
Confirmation: Slices 3 and 8 fit ≤400 as a single PR. Slices 1, 2, 4, 5 are "medium" — apply the sub-split-before-exceeding rule (measure the diff mid-slice; if it crosses 400, split at the next clean boundary). Slices 6 and 7 are confirmed over 400 and MUST be sub-split (6a/6b, 7a/7b) as shown. Every sub-PR keeps the build+lint+test gate below.
Headline: ~3,000 changed lines across ~10–12 chained PRs (8 base slices + mandatory Users/DataGrid sub-splits + optional Foundation/shared/Dashboard/Settings sub-splits on overrun). Recommendation: Force-chained, one PR per slice/sub-slice.
Plain-text guard lines
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
Decision needed: yes, because (a) Slice 1 vendored-shadcn-primitives size is at/over 400 (choose sub-split 1a/1b vs. size exception for generated code), and (b) Slices 6 and 7 are confirmed over 400 and must be sub-split — confirm the 6a/6b and 7a/7b boundaries before apply. These are delivery-mechanics decisions; the product/design content is fully locked in
spec.md/design.md.
Slice ordering rationale & dependencies
- Foundation — must be first: all later slices consume the new primitives,
@tanstack/react-table, the Vitest harness, thesuccessBadge variant, and the clearedtheme.ts. - Shared components — depends on 1; must precede pages so every page reuses one building-block language (anti-drift).
- Backups cluster + nav — depends on 2 (uses
Table/Badge/Tabs/shared cards); also lands the IA nav + route edits (/backupsitem,/applications→/media). - Dashboard + Applications — depends on 2 and 3 (IA/route edits for Media live here-or-3; Dashboard reuses
BackupDashboardWidgetfrom 3, shared cards from 2). - Settings + Actions — depends on 2 (shared dialog/cards/inputs); independent of 3/4 content.
- Users — depends on 2 (shared
Sheet/Table/Badge/Dialog); largest, sub-split. - DataGrid → TanStack — depends on 2 (
Tableprimitive), 4 (Media.tsxpage already MUI-migrated to plain primitives first so only the grid swaps), and the settledsuccess/status Badge cues. Deliberately last so the Table primitive, tokens, and Badge semantics are frozen. - Cleanup + docs — depends on all; removes the now-unused
@mui/*+@emotion/*deps only after every consumer is gone.
Dependency DAG: 1 → 2 → {3, 4, 5, 6} with 4 ← 3 (IA/route + backup widget);
{3,4,5,6} → 7 (7 needs pages' MUI shell already converted so only the grid swaps);
{1..7} → 8.
Universal slice exit gate (HARD — repeated for every slice)
A slice/sub-slice is not done until ALL of the following pass from frontend/:
npm run buildgreen (tsc -b+vite build).npm run lintgreen (eslint).npm testgreen (Vitest single-run) — applicable from slice 1 onward.- Existing legacy suites still pass:
node --test tests(users.test.mjs,userState.test.mjs). - Manual smoke of every migrated page in that slice (no behavior regression).
Each slice section restates this gate as its final task.
Slice 1 — Foundation (primitives, TanStack Table, Vitest, token/Badge cleanup)
Enables every later slice. No page behavior change yet. Target: ~400–580 changed lines — likely sub-split 1a/1b (primitives+deps vs. harness+cleanup) or vendored-primitives size exception. Decision: confirm split before apply.
- Add the missing shadcn primitives by running, from
frontend/:npx shadcn@latest add tabs table dialog input label checkbox switch progress separator avatar textarea dropdown-menu scroll-area(do NOT re-add already-present:button tooltip sheet card badge alert select skeleton collapsible). - Install the table dependency:
npm i @tanstack/react-table. - Remove orphaned charting deps from
frontend/package.json:rechartsandd3(verify zerofrom "recharts"/from "d3"/from "d3-*"imports infrontend/srcbefore removing; none expected). - Delete
frontend/src/theme.ts(no-opgetAppThemeshim) and remove every import oftheme/getAppThemeinfrontend/src(grep first, then delete). - Add the
successBadge variant tofrontend/src/components/ui/badge.tsx, drawing color from the repurposedchart-2token per design §2.3 (bg-chart-2/10 text-chart-2 … dark:bg-chart-2/20 …); optionally addwarning(chart-3) if a call site needs it. - Document the
chart-1..5role mapping as an inline comment block above the tokens in both the@themeblock and.darkblock offrontend/src/index.css(chart-1=info/brand, chart-2=success, chart-3=warning, chart-4=destructive, chart-5=neutral-accent). Do NOT change any token value; do NOT drop any token. Primary stays#4f8cff. - Configure the Vitest harness: create
frontend/vitest.config.ts(separate fromvite.config.ts) usingvitest/configdefineConfig,@vitejs/plugin-react,test.environment: "jsdom",test.globals: true,test.setupFiles: ["./src/test/setup.ts"], the@path alias fromtsconfig.app.json, andtest.include: ["src/**/*.{test,spec}.{ts,tsx}"](must NOT claim thefrontend/tests/*.mjsnode suites). - Create
frontend/src/test/setup.tsimporting@testing-library/jest-domfor matcher registration. - Install dev deps:
vitest,@testing-library/react,@testing-library/jest-dom,@testing-library/user-event,jsdom. - Add npm scripts to
frontend/package.json:"test": "vitest run","test:watch": "vitest","test:node": "node --test tests". - Add one trivial passing component test under
frontend/src/**to prove the harness (e.g. asuccessBadge renders with thechart-2cue). - Verify lucide-react
^1.14.0exports the names used by later slices (Pencil, X, Paperclip, Bold, Italic, Link, List, Mail, Send, Trash2, DatabaseBackup, ExternalLink); ifDatabaseBackupis missing, fall back toHardDrive/Archive(record the chosen fallback in the slice note). - Exit gate: no
theme/getAppThemeimports remain;npm run build+npm run lint+npm test+node --test testsall green.
Slice 2 — Shared components (lock the building-block language)
Migrate the 11 reusable blocks first to prevent cross-slice drift. Each keeps its exported API intact so downstream pages compile unchanged. ~280–420 lines → split 2a/2b if over.
- Migrate
frontend/src/components/SectionCard.tsx(Box/Card/CardContent/Stack/Typography → shadcnCardfamily + Tailwind stack; comfortable density,gap-4). - Migrate
frontend/src/components/SelectionRailCard.tsx(Box/Card/CardContent/Typography →Card+ Tailwind; preserveminHeight/scrollable body/footer props). - Migrate
frontend/src/components/TabbedCard.tsx(Box/Card/CardContent/Tabs → shadcnTabs(TabsList/TabsTrigger/TabsContent) on aCard). - Migrate
frontend/src/components/MetricCard.tsx(Card/CardContent/Typography → shadcnCard+ typography ramp: labeltext-sm, valuetext-lg font-semibold, subtexttext-xs text-muted-foreground). - Migrate
frontend/src/components/DiskSpaceCard.tsx(Box/Card/CardContent/Grid/LinearProgress/Stack/Typography →Card+ CSS grid + shadcnProgress; preserve used/free/total/percent breakdown). - Migrate
frontend/src/components/HoverEditButton.tsx(@mui/materialIconButton +@mui/icons-material/EditOutlined→Button variant="ghost" size="icon"+ lucidePencil; keep the hover-in visibility transition). - Migrate
frontend/src/components/DialogFooter.tsx(Box/Button/DialogActions →Buttonrow (flex flex-row items-center gap-2); preserve cancel/confirm/secondary-action props + busy/disabled labels). - Migrate
frontend/src/components/ConfirmDialog.tsx(Dialog/DialogContent/DialogTitle/Stack/Typography → shadcnDialogfamily +DialogFooterfrom this slice). - Migrate
frontend/src/components/LibraryOverview.tsx(Card/CardContent/Grid/Stack/Typography →Card+ responsive CSS gridgrid grid-cols-1 md:grid-cols-2 gap-4). - Migrate
frontend/src/components/NowPlaying.tsx(wrapper aroundSessionActivityPanel; keep the empty-state message contract) and migratefrontend/src/components/SessionActivityPanel.tsx(Button/Chip/Paper/Table family/Typography →Button/Badge/bordered surface/shadcnTablefamily; status → Badge variant mapping per design §2.3, healthy=success). - Add at least one behavioral component test per migrated block (co-located under the component's
__tests__/), e.g.MetricCardrenders label/value/subtext; status Badge variant mapping forSessionActivityPanel. - Exit gate: all 11 shared components MUI-free (
grep -rlE '@mui/(material|icons-material)' src/componentsreturns none of these files); exported APIs unchanged so pages still compile;npm run build+npm run lint+npm test+node --test testsgreen.
Slice 3 — Backups cluster + navigation/IA
~250–400 lines, likely a single PR. Depends on slice 2 (Table/Badge/Tabs/cards). This is where Backups becomes a top-level nav item and the Media/Applications route is reconciled.
- Migrate
frontend/src/components/BackupAlertsTable.tsx(Chip/Paper/Table family/FormControl/InputLabel/MenuItem/Select/Button →Badge(status cues), bordered surface, shadcnTablefamily, shadcnSelect; acknowledge button preserved; severity → Badge variant). - Migrate
frontend/src/components/BackupJobsTable.tsx(Chip/Paper/Table family →Badge+ shadcnTable; latest-run status + next-expected timing preserved). - Migrate
frontend/src/components/BackupRunsTable.tsx(Chip/FormControl/InputLabel/MenuItem/Paper/Select/Table family → shadcnSelect+Badge+Table; status filter + formatted duration/size/timestamp preserved). - Migrate
frontend/src/components/BackupDashboardWidget.tsx(Box/Card/CardContent/Chip/Typography → shadcnCard+Badge; total jobs / 24h success rate / active alerts / last-failed-time preserved). - Migrate
frontend/src/components/BackupsPage.tsx(Box/Tab/Tabs/Typography → shadcnTabs; tabs Jobs/Runs/Alerts behavior + acknowledge mutation preserved). - Apply the IA nav + route edits in
frontend/src/App.tsxper design §4: import a Backups icon (DatabaseBackup, or the slice-1-chosen fallback) from lucide-react; add a top-level{ path: "/backups", label: "Backups", icon: … }nav item (after Files, before Users); retarget the Media nav item from/applicationsto/media; in both route blocks add<Route path="/media" element={<Applications />} />and convert<Route path="/applications" …>to<Route path="/applications" element={<Navigate to="/media" replace />} />, mirroring the existing/monitoring→/observabilityredirect. - Add/extend component tests for the migrated Backups tables (status Badge variant mapping; alert acknowledge callback).
- Exit gate:
/backupsreachable from the sidebar;/applicationsredirects to/media; Backups cluster MUI-free;npm run build+npm run lint+npm test+node --test testsgreen.
Slice 4 — Dashboard + Media/Applications surface
~300–450 lines, medium. Depends on slices 2 and 3 (IA routes for Media + the
BackupDashboardWidgetfrom slice 3). Split 4a (Applications) → 4b (Dashboard) if over 400.
- Migrate
frontend/src/pages/Applications.tsx(Alert/Box/Card/CardContent/Chip/Grid/Stack/Tab/Typography →Alert/Card/Badge/responsive CSS grid/Tabs; Jellyfin library stats + Media tab preserved). - Migrate
frontend/src/pages/Dashboard.tsx(20 MUI components: Alert/Box/Button/Card/CardContent/Chip/Dialog/DialogContent/DialogTitle/FormControl/FormControlLabel/FormHelperText/Grid/InputLabel/MenuItem/Select/Stack/Switch/TextField/Typography → shadcnCard/CSS grid/Dialog/Select/Switch/Input+Label/Badge; shortcut CRUD (website/action/users), machine picker, NowPlaying + BackupDashboardWidget composition, comfortable density). - Preserve the Dashboard → Media navigation and shortcut deep-links under the reconciled
/mediaroute. - Add component tests for the migrated Dashboard (shortcut create/save/delete flow) and Applications (library stats render).
- Exit gate: Dashboard + Applications MUI-free and visually consistent;
npm run build+npm run lint+npm test+node --test testsgreen.
Slice 5 — Settings + Actions (form-heavy pair)
~350–500 lines, medium-high. Depends on slice 2. Keep uncontrolled/
useStateform parity — NO form library. Split 5a (Actions) → 5b (Settings) if over 400.
- Migrate
frontend/src/pages/Actions.tsx(19 MUI components incl. Tab/Tabs/Select/MenuItem/FormControl/InputLabel/Divider/Dialog → shadcnTabs/Select/Separator/Dialog; saved-task editor, machine selection, run history preserved). - Migrate
frontend/src/pages/Settings.tsx(18 MUI components incl. Grid/Switch/Checkbox/FormControlLabel/Tab/Select/Dialog → CSS grid/Switch/Checkbox/Label/Tabs/Select/Dialog; monitoring-machine CRUD, SSH-key management, SSH test/validation feedback, danger-zone reset, tabbed UI preserved). - Keep all current form behaviors (controlled
useState, SSH validation messages, ConfirmDialog integration from slice 2) — no form-library introduction. - Add component tests for the migrated Settings (machine save/delete confirm) and Actions (save/run task) where behavior is exercisable without live SSH.
- Exit gate: Settings + Actions MUI-free; forms behave as before;
npm run build+npm run lint+npm test+node --test testsgreen.
Slice 6 — Users (largest consumer; force sub-split 6a/6b)
~550–850 lines, confirmed over 400 — MUST sub-split. Depends on slice 2 (
Sheet/Table/Badge/Dialog/Avatar). Apply the icon map from design §5 (verify the 9 names at thelucide-reactpin, done in slice 1).
Slice 6a — directory table, selection, drawer
- Migrate
frontend/src/pages/UsersPage.impl.tsxdirectory surface: user table (Table/TableBody/TableCell/TableContainer/TableHead/TableRow+Checkbox/Chip/Avatar/Tooltip+LinearProgress) → shadcnTablefamily +Checkbox+Badge(status cues) +Avatar+Tooltip+Progress. - Replace MUI
Drawerwith shadcnSheet side="right"for the user detail drawer; preservebuildUserDrawerModelrendering. - Preserve selection-across-pagination semantics (selected-user-id set survives paging/filtering) and the search/filter logic (
mergeUsersWithActivity, row-level text matching). - Wire status → Badge variant mapping (healthy/activity =
successcue) consistently with design §2.3.
Slice 6b — compose dialog, formatting actions, attachments
- Migrate the compose dialog (
Dialog/DialogActions/DialogContent/DialogTitle+TextField/Divider/IconButton) → shadcnDialogfamily +Input/Textarea/Separator+Button variant="ghost" size="icon". - Replace the 9
@mui/icons-materialicons with lucide per design §5:Close→X,AttachFile→Paperclip,FormatBold→Bold,FormatItalic→Italic,Link→Link,FormatListBulleted→List,MailOutlined→Mail,Send→Send,DeleteOutlined→Trash2. - Preserve the rich-text compose behavior: subject + html body, markup insertion actions (bold/italic/link/list), file attachments (FormData), queue-status polling (
useUserMessageQueueStatus), and send (useSendUserMessage). - Add component tests for selection toggle, drawer open, and at least one compose formatting action.
- Exit gate (6a+6b):
UsersPage.impl.tsxfully MUI/icon-MUI-free; drawer, selection-across-pages, and compose/send behavior preserved;npm run build+npm run lint+npm test+node --test testsgreen on each sub-PR.
Slice 7 — DataGrid → TanStack Table (highest risk; force sub-split 7a/7b)
~450–700 lines, confirmed over 400 — MUST sub-split. Deliberately last so the
Tableprimitive, tokens, and Badge cues are frozen. Visibility-only parity: pagination (Media, server-driven), row selection, row click, column visibility. No sorting, no resizing. Depends on slices 2 and 4.
Slice 7a — DataTable wrapper + FileBrowser
- Create
frontend/src/components/ui/data-table.tsx: a generic wrapper over@/components/ui/tablebuilt on@tanstack/react-tableper design §3.1, exposingcolumns,data,getRowId,enableRowSelection/rowSelection/onRowSelectionChange,onRowClick,columnVisibility/onColumnVisibilityChange/enableColumnVisibilityToggle,enablePagination/manualPagination/pagination/onPaginationChange/pageSizeOptions/rowCount, andemptyMessage. - Wire
useReactTablewithgetCoreRowModel();getPaginationRowModel()only whenenablePagination && !manualPagination; controlledrowSelection+columnVisibility; nevergetSortedRowModel, neverenableColumnResizing/size. - Render a leading display selection column (header select-all-on-page via
Checkbox) only whenenableRowSelection; rowonClick → onRowClick?.(row.original)withcursor-pointer, selection-cell click stops propagation; column-visibility dropdown viaDropdownMenu+CheckboxwhenenableColumnVisibilityToggle. - Add component tests for
DataTable: row-selection toggle, column-visibility toggle, row-click callback fires (RED→GREEN before re-wiring pages). - Migrate
frontend/src/pages/FileBrowser.impl.tsxoff@mui/x-data-gridontoDataTable: buildfileColumns: ColumnDef<FileEntry>[]for the 5 columns (type, name, ext, size, modified);enableRowSelection;onRowClick→ selects the file for ffprobe preview (preserved);enableColumnVisibilityToggle; no pagination (full listing as today). Also migrate its remaining@mui/materialshell (Card/SectionCard/TabbedCard/Select/Input) to shadcn primitives. - Exit gate (7a):
DataTable+ FileBrowser on TanStack Table; FileBrowser row-click → ffprobe preview and column set (type/name/ext/size/modified) preserved;@mui/x-data-gridno longer imported by FileBrowser;npm run build+npm run lint+npm test+node --test testsgreen.
Slice 7b — Media (server-driven pagination)
- Migrate
frontend/src/pages/Media.tsxoff@mui/x-data-gridontoDataTable: buildmediaColumns: ColumnDef<MediaItem>[]for the 15 locked columns (title, series, season, episode, type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added, library, path). - Render
DataTablewithenableRowSelection,enablePagination+manualPagination+rowCount(driven byqueryResult.total),onRowClick → navigate('/files?path=…')(opens file browser at the item's path — preserved), andenableColumnVisibilityToggle(toggleable set must match the 15-column list above exactly). - Lift pagination + column-visibility state into the existing
usePersistentStatemedia state and feeduseMediaQuery({ limit, offset, … }); use a stable path-derivedgetRowIdso selection survives server-driven paging. - Also migrate the remaining
@mui/materialMedia shell (Card/SectionCard/Grid/Select/Input/LinearProgress) to shadcn primitives + CSS grid +Progress; preserve index-build controls + progress (stop/force-stop). - Add component tests asserting the toggleable column set equals the locked 15 and that row-click triggers the navigation callback.
- Exit gate (7b): Media on TanStack Table with pagination (server-driven, page-size + total-count + page-nav parity), row selection, row click → file browser, column-visibility parity;
@mui/x-data-gridno longer imported anywhere; no sorting, no resizing present;npm run build+npm run lint+npm test+node --test testsgreen; manual smoke of Media paging + row-click and FileBrowser row-click preview.
Slice 8 — Cleanup + docs (remove MUI/@emotion, final gates, docs)
~80–160 lines, single PR. Depends on all prior slices.
@mui/*+@emotion/*are removed only after every consumer is gone.
- Remove from
frontend/package.json:@mui/material,@mui/icons-material,@mui/x-data-grid,@emotion/react,@emotion/styled(runnpm install/regenerate the lockfile). - Grep-verify ZERO remaining imports: recursive search of
frontend/srcfor@mui/material,@mui/icons-material,@mui/x-data-grid,@emotion/react,@emotion/styledreturns no matches (hard gate for the spec "No MUI imports remain" scenario). - Grep-verify
recharts,d3,d3-*have zero imports and are absent frompackage.json(carry-over from slice 1; re-confirm). - Confirm
frontend/src/theme.tsdoes not exist and notheme/getAppThemeimport remains. - Run final gates from
frontend/:npm run build(tsc -b + vite build),npm run lint,npm test, andnode --test tests— all green. - Update
docs/REQUIREMENTS.mdperAGENTS.md: document the single design system (shadcn/ui + Tailwind v4 + lucide-react), the thin-dashboard observability model (no in-app charts; Grafana deep-links), TanStack tables (visibility-only parity), the reconciled IA (Backups top-level nav; Media at/mediawith/applicationsredirect), the repurposedchart-*status cues, and the removal of@mui/*/@emotion/*/recharts/d3/theme.ts. - Exit gate: recursive search of
frontend/srcfor@mui/*and@emotion/*returns zero; requirements doc updated;npm run build+npm run lint+npm test+node --test testsall green.
Acceptance cross-check (verification commands for sdd-verify)
cd frontend && npm run build→ must be green after every slice.cd frontend && npm run lint→ must be green after every slice.cd frontend && npm test→ must be green from slice 1 onward.cd frontend && node --test tests→ legacy suites (users.test.mjs,userState.test.mjs) stay green throughout.grep -rlE '@mui/(material|icons-material|x-data-grid)|@emotion/(react|styled)' frontend/src→ must return nothing after slice 8.grep -rlE 'recharts|from .d3|from .d3-' frontend/src→ must return nothing (re-confirm at slice 8).test ! -e frontend/src/theme.ts→ must succeed after slice 1.- Inspect
frontend/src/App.tsx:/mediacanonical route +/applications→<Navigate to="/media" replace />; a top-level Backups nav item present.