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

26 KiB
Raw Blame History

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:

@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:

// 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):

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), onRowClicknavigate('/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:

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:

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:

<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. DatabaseBackupHardDrive) 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:

// 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/TextFieldInput) 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 (TextFieldInput, 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

    • DrawerSheet + 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.