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.
26 KiB
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, andspec.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
@themetokens + lucide-react only. Zero@mui/*/@emotion/*/recharts/d3at the end;theme.tsdeleted 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→/observabilityredirect inApp.tsx. Auth (auth.ts,react-oidc-context) andreact-router-domstructure otherwise unchanged. - TanStack Table: visibility-only parity — pagination, row selection, row click, column visibility. No sorting, no resizing.
- Palette: primary stays
#4f8cff;chart-1..5tokens 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/reactintroduced 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; consistentgap-4between cards. - Single surface treatment: one border weight (
border-border) and one radius (rounded-lg=--radius0.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-4between cards;space-y-4for stacked sections. - Typography ramp:
- Page title —
text-lg font-semibold(rendered byTopBarfromnavItems). - Section title —
text-base font-semibold. - Body —
text-sm. - Caption / helper —
text-xs text-muted-foreground.
- Page title —
- 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 whenenablePaginationand notmanualPagination. WhenmanualPaginationis true (Media), the table renders the supplied slice androwCountdrives the pager; the page index/size are lifted to the parent and feeduseMediaQuerylimit/offset. - Selection:
enableRowSelection: truegated by the prop;rowSelectionstate is controlled by the parent. - Column visibility:
columnVisibilitystate controlled by the parent; aDropdownMenu+Checkboxlist renders the toggle UI whenenableColumnVisibilityToggle. - Row click:
<TableRow onClick={() => onRowClick?.(row.original)}>withclassName="cursor-pointer"whenonRowClickis set; selection column click stops propagation so checkboxes do not trigger navigation. - Selection column: a leading display
ColumnDefrendering aCheckbox(header = select-all-on-page viatable.getIsSomeRowsSelected()/getIsSelected()), present only whenenableRowSelection.
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:
enableColumnResizingunset; 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 byqueryResult.total),onRowClick→navigate('/files?path=…'), andenableColumnVisibilityToggle. Pagination and column-visibility state persist via the existingusePersistentStatemedia state, feedinguseMediaQuery({ 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. 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 fromvite.config.tsto keep the build config clean), usingdefineConfigfromvitest/config,@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}"]so Vitest does not claim thefrontend/tests/*.mjsnode suites. - Setup file:
frontend/src/test/setup.tsimporting@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.
-
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, removerecharts+d3frompackage.json, deletefrontend/src/theme.ts(and its callers), and introduce the §6 Vitest harness + a trivial passing test. Add thesuccessBadge variant +chart-*role comments (§2). Exit: notheme/getAppThemeimports remain; build + lint +vitest rungreen; no behavior change yet. -
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 usesCard/Badge/Button/Table/Tabs/Dialogper §1. Exit: all shared components MUI-free; one component test per migrated block; downstream pages still compile against the same exported APIs. -
Backups cluster + nav. Migrate
BackupAlertsTable,BackupJobsTable,BackupRunsTable,BackupsPage,BackupDashboardWidget(allTablefamilyBadgestatus cues), and apply the §4navItems+ route edits to surface/backupsas a top-level item (verifyDatabaseBackup/fallback icon). Exit:/backupsreachable from the sidebar;/applicationsredirects to/media; build + lint green.
-
Dashboard + Media/Applications surface. Migrate
Dashboard.tsx(20 MUI components →Card/Grid→CSS-grid/Dialog/Select/Switch/TextField→Input) andApplications.tsx(9 →Card/grid/Tabs). Exit: both pages MUI-free and visually consistent; build + lint green. -
Settings + Actions. Migrate
Settings.tsx(18) andActions.tsx(19) — the form-heavy pair (TextField→Input,Select,Switch,Checkbox,Tabs,Dialog). Keep uncontrolled/useStateform parity (no form library). Exit: both pages MUI-free; forms behave as before; build + lint green. -
Users (largest). Migrate
UsersPage.impl.tsx(25 MUI components + 9 iconsDrawer→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.
-
DataGrid → TanStack Table (highest risk, last). Build the §3
DataTablewrapper on shadcnTable, then rebuildMedia.tsxandFileBrowser.impl.tsxagainst 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-gridno longer imported; build + lint green. -
Cleanup + docs. Remove
@mui/*+@emotion/*frompackage.json, run a finaltsc -b+eslint+vitest run+ existingnode --test, and updatedocs/REQUIREMENTS.md(single design system, thin-dashboard observability, TanStack tables,/media+/applicationsredirect, Backups nav, removed deps). Exit: recursive search offrontend/srcfor@mui/*/@emotion/*returns zero; requirements doc updated; all gates green.
8. Review / judgment risks
-
DataGrid slice sequenced last (high). Slice 7 carries the single largest behavior-parity surface (Media pagination is server-driven via
useMediaQuerylimit/offset, and selection must survive paging via a stablegetRowId). It is deliberately last soTable, 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 theDataTablewrapper (selection toggle, column visibility toggle, row-click callback) in slice 7 before re-wiring the pages. -
UsersPage sub-split likelihood (high).
UsersPage.impl.tsxis 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. -
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. -
lucide-react version risk (low–medium).
^1.14.0is an unusual major. The 10 mapped icons (plusDatabaseBackupfor Backups) must be verified at the pin before the consuming slices (3 and 6). The already-migratedObservabilityPage(12 icons) proves common names resolve; verification is a slice-1 foundation task with a fallback list. -
Test-coverage gaps (medium). Today there is no component harness; legacy
node --testsuites 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 theDataTablefeatures.