Files
manage/openspec/changes/web-ui-rework/tasks.md
T
Developer 109e74db41 feat(frontend): slice 2 — migrate 11 shared components to shadcn/Tailwind
Web UI rework. Shared-components slice (drift prevention):
- Migrate SectionCard, SelectionRailCard, TabbedCard, MetricCard,
  DiskSpaceCard, HoverEditButton, DialogFooter, ConfirmDialog,
  LibraryOverview, NowPlaying, SessionActivityPanel off @mui
- HoverEditButton: MUI IconButton + EditOutlined -> Button + lucide Pencil
- Status mapping uses the success Badge variant (chart-2) for healthy
- Exported APIs preserved so consuming pages still compile (no page edits)
- 11 behavioral Vitest component tests added

Gate: build + lint + test green.
2026-06-17 12:33:47 +00:00

25 KiB
Raw Blame History

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, and design.md (all authoritative). No source changes in this phase — this file is the contract sdd-apply executes and sdd-verify checks.

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 /media with /applications → redirect (mirrors /monitoring/observability); TanStack Table visibility-only parity (pagination, row selection, row click, column visibility — NO sorting, NO resizing); palette keeps #4f8cff with chart-1..5 repurposed 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,8003,900 total (sum of per-slice ranges below)
400-line budget risk High (slices 1, 6, 7 sit at/over the boundary; 45 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) ~400580 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) ~280420 Medium If over, split 2a (cards/buttons/dialogs) → 2b (tables/panels).
3 Backups cluster + nav ~250400 Likely OK Single PR; split nav edit out if over.
4 Dashboard + Applications ~300450 Medium If over, split 4a (Applications, smaller) → 4b (Dashboard).
5 Settings + Actions ~350500 Medium-High If over, split 5a (Actions) → 5b (Settings).
6 Users (largest) ~550850 No Force split 6a (directory table + selection + Drawer/Sheet) → 6b (compose dialog + formatting actions + attachments).
7 DataGrid → TanStack (highest risk) ~450700 No Force split 7a (DataTable wrapper + tests + FileBrowser) → 7b (Media, server-driven pagination).
8 Cleanup + docs ~80160 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 ~1012 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

  1. Foundation — must be first: all later slices consume the new primitives, @tanstack/react-table, the Vitest harness, the success Badge variant, and the cleared theme.ts.
  2. Shared components — depends on 1; must precede pages so every page reuses one building-block language (anti-drift).
  3. Backups cluster + nav — depends on 2 (uses Table/Badge/Tabs/shared cards); also lands the IA nav + route edits (/backups item, /applications/media).
  4. Dashboard + Applications — depends on 2 and 3 (IA/route edits for Media live here-or-3; Dashboard reuses BackupDashboardWidget from 3, shared cards from 2).
  5. Settings + Actions — depends on 2 (shared dialog/cards/inputs); independent of 3/4 content.
  6. Users — depends on 2 (shared Sheet/Table/Badge/Dialog); largest, sub-split.
  7. DataGrid → TanStack — depends on 2 (Table primitive), 4 (Media.tsx page already MUI-migrated to plain primitives first so only the grid swaps), and the settled success/status Badge cues. Deliberately last so the Table primitive, tokens, and Badge semantics are frozen.
  8. 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 build green (tsc -b + vite build).
  • npm run lint green (eslint).
  • npm test green (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: ~400580 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: recharts and d3 (verify zero from "recharts" / from "d3" / from "d3-*" imports in frontend/src before removing; none expected).
  • Delete frontend/src/theme.ts (no-op getAppTheme shim) and remove every import of theme / getAppTheme in frontend/src (grep first, then delete).
  • Add the success Badge variant to frontend/src/components/ui/badge.tsx, drawing color from the repurposed chart-2 token per design §2.3 (bg-chart-2/10 text-chart-2 … dark:bg-chart-2/20 …); optionally add warning (chart-3) if a call site needs it.
  • Document the chart-1..5 role mapping as an inline comment block above the tokens in both the @theme block and .dark block of frontend/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 from vite.config.ts) using vitest/config defineConfig, @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}"] (must NOT claim the frontend/tests/*.mjs node suites).
  • Create frontend/src/test/setup.ts importing @testing-library/jest-dom for 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. a success Badge renders with the chart-2 cue).
  • Verify lucide-react ^1.14.0 exports the names used by later slices (Pencil, X, Paperclip, Bold, Italic, Link, List, Mail, Send, Trash2, DatabaseBackup, ExternalLink); if DatabaseBackup is missing, fall back to HardDrive/Archive (record the chosen fallback in the slice note).
  • Exit gate: no theme/getAppTheme imports remain; npm run build + npm run lint + npm test + node --test tests all 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. ~280420 lines → split 2a/2b if over.

  • Migrate frontend/src/components/SectionCard.tsx (Box/Card/CardContent/Stack/Typography → shadcn Card family + Tailwind stack; comfortable density, gap-4).
  • Migrate frontend/src/components/SelectionRailCard.tsx (Box/Card/CardContent/Typography → Card + Tailwind; preserve minHeight/scrollable body/footer props).
  • Migrate frontend/src/components/TabbedCard.tsx (Box/Card/CardContent/Tabs → shadcn Tabs (TabsList/TabsTrigger/TabsContent) on a Card).
  • Migrate frontend/src/components/MetricCard.tsx (Card/CardContent/Typography → shadcn Card + typography ramp: label text-sm, value text-lg font-semibold, subtext text-xs text-muted-foreground).
  • Migrate frontend/src/components/DiskSpaceCard.tsx (Box/Card/CardContent/Grid/LinearProgress/Stack/Typography → Card + CSS grid + shadcn Progress; preserve used/free/total/percent breakdown).
  • Migrate frontend/src/components/HoverEditButton.tsx (@mui/material IconButton + @mui/icons-material/EditOutlinedButton variant="ghost" size="icon" + lucide Pencil; keep the hover-in visibility transition).
  • Migrate frontend/src/components/DialogFooter.tsx (Box/Button/DialogActions → Button row (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 → shadcn Dialog family + DialogFooter from this slice).
  • Migrate frontend/src/components/LibraryOverview.tsx (Card/CardContent/Grid/Stack/Typography → Card + responsive CSS grid grid grid-cols-1 md:grid-cols-2 gap-4).
  • Migrate frontend/src/components/NowPlaying.tsx (wrapper around SessionActivityPanel; keep the empty-state message contract) and migrate frontend/src/components/SessionActivityPanel.tsx (Button/Chip/Paper/Table family/Typography → Button/Badge/bordered surface/shadcn Table family; 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. MetricCard renders label/value/subtext; status Badge variant mapping for SessionActivityPanel.
  • Exit gate: all 11 shared components MUI-free (grep -rlE '@mui/(material|icons-material)' src/components returns none of these files); exported APIs unchanged so pages still compile; npm run build + npm run lint + npm test + node --test tests green.

Slice 3 — Backups cluster + navigation/IA

~250400 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, shadcn Table family, shadcn Select; acknowledge button preserved; severity → Badge variant).
  • Migrate frontend/src/components/BackupJobsTable.tsx (Chip/Paper/Table family → Badge + shadcn Table; latest-run status + next-expected timing preserved).
  • Migrate frontend/src/components/BackupRunsTable.tsx (Chip/FormControl/InputLabel/MenuItem/Paper/Select/Table family → shadcn Select + Badge + Table; status filter + formatted duration/size/timestamp preserved).
  • Migrate frontend/src/components/BackupDashboardWidget.tsx (Box/Card/CardContent/Chip/Typography → shadcn Card + Badge; total jobs / 24h success rate / active alerts / last-failed-time preserved).
  • Migrate frontend/src/components/BackupsPage.tsx (Box/Tab/Tabs/Typography → shadcn Tabs; tabs Jobs/Runs/Alerts behavior + acknowledge mutation preserved).
  • Apply the IA nav + route edits in frontend/src/App.tsx per 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 /applications to /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/observability redirect.
  • Add/extend component tests for the migrated Backups tables (status Badge variant mapping; alert acknowledge callback).
  • Exit gate: /backups reachable from the sidebar; /applications redirects to /media; Backups cluster MUI-free; npm run build + npm run lint + npm test + node --test tests green.

Slice 4 — Dashboard + Media/Applications surface

~300450 lines, medium. Depends on slices 2 and 3 (IA routes for Media + the BackupDashboardWidget from 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 → shadcn Card/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 /media route.
  • 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 tests green.

Slice 5 — Settings + Actions (form-heavy pair)

~350500 lines, medium-high. Depends on slice 2. Keep uncontrolled/useState form 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 → shadcn Tabs/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 tests green.

Slice 6 — Users (largest consumer; force sub-split 6a/6b)

~550850 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 the lucide-react pin, done in slice 1).

Slice 6a — directory table, selection, drawer

  • Migrate frontend/src/pages/UsersPage.impl.tsx directory surface: user table (Table/TableBody/TableCell/TableContainer/TableHead/TableRow + Checkbox/Chip/Avatar/Tooltip + LinearProgress) → shadcn Table family + Checkbox + Badge (status cues) + Avatar + Tooltip + Progress.
  • Replace MUI Drawer with shadcn Sheet side="right" for the user detail drawer; preserve buildUserDrawerModel rendering.
  • 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 = success cue) consistently with design §2.3.

Slice 6b — compose dialog, formatting actions, attachments

  • Migrate the compose dialog (Dialog/DialogActions/DialogContent/DialogTitle + TextField/Divider/IconButton) → shadcn Dialog family + Input/Textarea/Separator + Button variant="ghost" size="icon".
  • Replace the 9 @mui/icons-material icons 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.tsx fully MUI/icon-MUI-free; drawer, selection-across-pages, and compose/send behavior preserved; npm run build + npm run lint + npm test + node --test tests green on each sub-PR.

Slice 7 — DataGrid → TanStack Table (highest risk; force sub-split 7a/7b)

~450700 lines, confirmed over 400 — MUST sub-split. Deliberately last so the Table primitive, 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/table built on @tanstack/react-table per design §3.1, exposing columns, data, getRowId, enableRowSelection/rowSelection/onRowSelectionChange, onRowClick, columnVisibility/onColumnVisibilityChange/enableColumnVisibilityToggle, enablePagination/manualPagination/pagination/onPaginationChange/pageSizeOptions/rowCount, and emptyMessage.
  • Wire useReactTable with getCoreRowModel(); getPaginationRowModel() only when enablePagination && !manualPagination; controlled rowSelection + columnVisibility; never getSortedRowModel, never enableColumnResizing/size.
  • Render a leading display selection column (header select-all-on-page via Checkbox) only when enableRowSelection; row onClick → onRowClick?.(row.original) with cursor-pointer, selection-cell click stops propagation; column-visibility dropdown via DropdownMenu + Checkbox when enableColumnVisibilityToggle.
  • 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.tsx off @mui/x-data-grid onto DataTable: build fileColumns: 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/material shell (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-grid no longer imported by FileBrowser; npm run build + npm run lint + npm test + node --test tests green.

Slice 7b — Media (server-driven pagination)

  • Migrate frontend/src/pages/Media.tsx off @mui/x-data-grid onto DataTable: build mediaColumns: 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 DataTable with enableRowSelection, enablePagination + manualPagination + rowCount (driven by queryResult.total), onRowClick → navigate('/files?path=…') (opens file browser at the item's path — preserved), and enableColumnVisibilityToggle (toggleable set must match the 15-column list above exactly).
  • Lift pagination + column-visibility state into the existing usePersistentState media state and feed useMediaQuery({ limit, offset, … }); use a stable path-derived getRowId so selection survives server-driven paging.
  • Also migrate the remaining @mui/material Media 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-grid no longer imported anywhere; no sorting, no resizing present; npm run build + npm run lint + npm test + node --test tests green; manual smoke of Media paging + row-click and FileBrowser row-click preview.

Slice 8 — Cleanup + docs (remove MUI/@emotion, final gates, docs)

~80160 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 (run npm install/regenerate the lockfile).
  • Grep-verify ZERO remaining imports: recursive search of frontend/src for @mui/material, @mui/icons-material, @mui/x-data-grid, @emotion/react, @emotion/styled returns no matches (hard gate for the spec "No MUI imports remain" scenario).
  • Grep-verify recharts, d3, d3-* have zero imports and are absent from package.json (carry-over from slice 1; re-confirm).
  • Confirm frontend/src/theme.ts does not exist and no theme/getAppTheme import remains.
  • Run final gates from frontend/: npm run build (tsc -b + vite build), npm run lint, npm test, and node --test tests — all green.
  • Update docs/REQUIREMENTS.md per AGENTS.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 /media with /applications redirect), the repurposed chart-* status cues, and the removal of @mui/*/@emotion/*/recharts/d3/theme.ts.
  • Exit gate: recursive search of frontend/src for @mui/* and @emotion/* returns zero; requirements doc updated; npm run build + npm run lint + npm test + node --test tests all 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: /media canonical route + /applications<Navigate to="/media" replace />; a top-level Backups nav item present.