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.
This commit is contained in:
Developer
2026-06-17 12:33:47 +00:00
parent dd778d8850
commit 109e74db41
23 changed files with 830 additions and 469 deletions
+27 -19
View File
@@ -1,12 +1,17 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
} from "@/components/ui/dialog";
import { DialogFooter } from "./DialogFooter";
/**
* Reusable confirmation dialog built on the shadcn Dialog family and the
* shared `DialogFooter`. Same exported props as the MUI version; Esc / overlay
* click routes to `onCancel` via `onOpenChange`.
*/
export function ConfirmDialog({
open,
title,
@@ -25,23 +30,26 @@ export function ConfirmDialog({
busy?: boolean;
}) {
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Stack>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</DialogContent>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</Dialog>
);
}
+41 -16
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Button, DialogActions } from "@mui/material";
import { Button } from "@/components/ui/button";
interface DialogFooterProps {
onCancel: () => void;
@@ -14,6 +14,28 @@ interface DialogFooterProps {
secondaryAction?: ReactNode;
}
/**
* Resolve the legacy MUI color/variant props onto a shadcn Button variant so
* the exported API stays unchanged for consuming pages (ConfirmDialog here,
* plus Dashboard/Settings/Actions in later slices).
*/
function resolveConfirmVariant(
color: DialogFooterProps["confirmColor"],
variant: DialogFooterProps["confirmVariant"],
): "default" | "outline" | "ghost" | "destructive" {
if (color === "error") return "destructive";
if (variant === "outlined") return "outline";
if (variant === "text") return "ghost";
return "default";
}
/**
* Dialog action row: cancel + optional secondary action + confirm.
*
* Renders a horizontal Button row (`flex flex-row items-center gap-2`).
* Preserves cancel/confirm/secondary-action props and the busy/disabled label
* contract (renders `confirmBusyLabel` when provided, else `confirmLabel`).
*/
export function DialogFooter({
onCancel,
cancelLabel = "Cancel",
@@ -27,20 +49,23 @@ export function DialogFooter({
secondaryAction,
}: DialogFooterProps) {
return (
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onCancel}>{cancelLabel}</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{secondaryAction}
<Button
variant={confirmVariant}
color={confirmColor}
disabled={confirmDisabled}
startIcon={confirmStartIcon}
onClick={onConfirm}
>
{confirmBusyLabel ?? confirmLabel}
</Button>
</Box>
</DialogActions>
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>
{cancelLabel}
</Button>
{secondaryAction ? (
<div className="flex flex-row items-center gap-2">
{secondaryAction}
</div>
) : null}
<Button
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
disabled={confirmDisabled}
onClick={onConfirm}
>
{confirmStartIcon}
{confirmBusyLabel ?? confirmLabel}
</Button>
</div>
);
}
+45 -121
View File
@@ -1,12 +1,5 @@
import {
Box,
Card,
CardContent,
Grid,
LinearProgress,
Stack,
Typography,
} from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
interface Props {
used: number;
@@ -27,127 +20,58 @@ function formatBytes(bytes: number): string {
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
/**
* Progress-bar class (full static strings so Tailwind's scanner emits them).
* `chart-2`=success, `chart-3`=warning, `destructive`=error, per design §2.3.
*/
function progressBarClass(pct: number): string {
if (pct < 70) {
return "h-3 [&_[data-slot=progress-indicator]]:bg-chart-2";
}
if (pct < 90) {
return "h-3 [&_[data-slot=progress-indicator]]:bg-chart-3";
}
return "h-3 [&_[data-slot=progress-indicator]]:bg-destructive";
}
/**
* Dashboard card that summarizes the configured media disk.
*
* It intentionally keeps the progress bar inside the card so the capacity
* signal, raw byte values, and free-space breakdown stay visually grouped.
* Keeps the progress bar inside the card so the capacity signal, raw byte
* values, and free-space breakdown stay visually grouped. The used / free /
* total / percent breakdown is preserved verbatim from the MUI version.
*/
export function DiskSpaceCard({ used, available, size, usedPct }: Props) {
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
const cells = [
{ label: "Used", value: formatBytes(used) },
{ label: "Free", value: formatBytes(available) },
{ label: "Total", value: formatBytes(size) },
];
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<Stack spacing={1.5}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
<Card className="h-full">
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-sm uppercase tracking-wide text-muted-foreground">
Disk space
</span>
<span className="text-lg font-semibold">{usedPct} used</span>
</div>
<Progress value={pct} className={progressBarClass(pct)} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{cells.map((cell) => (
<div
key={cell.label}
className="flex h-full flex-col items-center justify-center gap-1 rounded-lg bg-muted/50 p-3 text-center"
>
Disk space
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
}}
>
{usedPct} used
</Typography>
</Box>
<Box sx={{ width: "100%" }}>
<LinearProgress
variant="determinate"
value={pct}
color={barColor}
sx={{
height: 12,
borderRadius: 999,
bgcolor: "action.hover",
"& .MuiLinearProgress-bar": {
borderRadius: 999,
},
}}
/>
</Box>
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Used
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(used)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Free
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(available)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(size)}
</Typography>
</Box>
</Grid>
</Grid>
</Stack>
<span className="text-xs text-muted-foreground">
{cell.label}
</span>
<span className="text-sm font-semibold">{cell.value}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
+17 -12
View File
@@ -1,32 +1,37 @@
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { IconButton } from "@mui/material";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
interface HoverEditButtonProps {
onClick: () => void;
label?: string;
}
/**
* Hover-to-reveal edit affordance.
*
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
* existing hover-reveal rules in consuming pages (Actions, Settings) still
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
* + lucide `Pencil`. Same exported props/display name.
*/
export function HoverEditButton({
onClick,
label = "Edit",
}: HoverEditButtonProps) {
return (
<IconButton
className="rail-edit"
<Button
variant="ghost"
size="icon-sm"
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
aria-label={label}
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
sx={{
opacity: 0,
transition: "opacity 120ms ease",
color: "text.secondary",
}}
>
<EditOutlinedIcon fontSize="inherit" />
</IconButton>
<Pencil />
</Button>
);
}
+30 -29
View File
@@ -1,56 +1,57 @@
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
import type { LibraryCount } from "../types";
interface Props {
libraries: LibraryCount[];
}
/**
* Two-column overview of movie and TV libraries on a responsive CSS grid
* (`grid grid-cols-1 md:grid-cols-2 gap-4`). Same exported props as the MUI
* version; the per-library counts render verbatim.
*/
export function LibraryOverview({ libraries }: Props) {
const movieLibs = libraries.filter((l) => l.type === "movies");
const tvLibs = libraries.filter((l) => l.type === "tvshows");
return (
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h4 className="text-sm font-semibold text-muted-foreground">
Movie libraries
</Typography>
<Stack spacing={1.5}>
</h4>
<div className="flex flex-col gap-4">
{movieLibs.map((lib) => (
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
<Card key={lib.library}>
<CardContent className="flex flex-col gap-1">
<span className="text-base font-semibold">{lib.library}</span>
<span className="text-sm text-muted-foreground">
Total: {lib.total.toLocaleString()} | Movies:{" "}
{lib.movies.toLocaleString()}
</Typography>
</span>
</CardContent>
</Card>
))}
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
</div>
</div>
<div className="flex flex-col gap-4">
<h4 className="text-sm font-semibold text-muted-foreground">
TV libraries
</Typography>
<Stack spacing={1.5}>
</h4>
<div className="flex flex-col gap-4">
{tvLibs.map((lib) => (
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
<Card key={lib.library}>
<CardContent className="flex flex-col gap-1">
<span className="text-base font-semibold">{lib.library}</span>
<span className="text-sm text-muted-foreground">
Total: {lib.total.toLocaleString()} | Series:{" "}
{lib.series.toLocaleString()}
</Typography>
</span>
</CardContent>
</Card>
))}
</Stack>
</Grid>
</Grid>
</div>
</div>
</div>
);
}
+15 -35
View File
@@ -1,4 +1,4 @@
import { Card, CardContent, Typography } from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
interface Props {
label: string;
@@ -6,44 +6,24 @@ interface Props {
subtext?: string;
}
/**
* Compact metric tile: label / value / optional subtext on the comfortable
* density ramp (label `text-sm`, value `text-lg font-semibold`, subtext
* `text-xs text-muted-foreground`). Same exported props as the MUI version.
*/
export function MetricCard({ label, value, subtext }: Props) {
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent
sx={{
p: { xs: 1.5, sm: 2 },
display: "flex",
flexDirection: "column",
gap: 0.5,
height: "100%",
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase", lineHeight: 1.2 }}
>
<Card className="h-full">
<CardContent className="flex h-full flex-col gap-1.5">
<span className="text-sm uppercase leading-tight tracking-wide text-muted-foreground">
{label}
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
lineHeight: 1.15,
}}
>
{value}
</Typography>
{subtext && (
<Typography
variant="caption"
color="text.secondary"
sx={{ whiteSpace: "pre-line", display: "block", lineHeight: 1.35 }}
>
</span>
<span className="text-lg font-semibold leading-tight">{value}</span>
{subtext ? (
<span className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
{subtext}
</Typography>
)}
</span>
) : null}
</CardContent>
</Card>
);
+20 -27
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
interface SectionCardProps {
title: string;
@@ -8,6 +8,13 @@ interface SectionCardProps {
children: ReactNode;
}
/**
* Titled section surface built on the shadcn Card family.
*
* Comfortable density: `gap-4` between the header row and the body. Exports
* the same props/display name as the prior MUI implementation so every
* consuming page compiles unchanged.
*/
export function SectionCard({
title,
description,
@@ -15,32 +22,18 @@ export function SectionCard({
children,
}: SectionCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.25}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{description ? (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
) : null}
</Box>
{action}
</Box>
{children}
</Stack>
<Card className="gap-4">
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-base font-semibold">{title}</h3>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action}
</div>
{children}
</CardContent>
</Card>
);
+19 -46
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Typography } from "@mui/material";
import { Card } from "@/components/ui/card";
interface SelectionRailCardProps {
title: string;
@@ -7,65 +7,38 @@ interface SelectionRailCardProps {
children: ReactNode;
footer?: ReactNode;
minHeight?: number;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
bodySx?: object;
}
/**
* Selection-rail surface: titled header, scrollable body, optional footer.
*
* Preserves the exported props (`minHeight`, `footer`, and the legacy `*Sx`
* no-op passthroughs) so consuming pages (Actions, Settings) compile
* unchanged. The scrollable body and footer contract are preserved.
*/
export function SelectionRailCard({
title,
description,
children,
footer,
minHeight = 420,
contentSx,
bodySx,
}: SelectionRailCardProps) {
return (
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
<CardContent
sx={{
p: 0,
display: "flex",
flexDirection: "column",
minHeight,
...contentSx,
}}
>
<Box
sx={{
px: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
bgcolor: "action.hover",
}}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 800, letterSpacing: 0.2 }}
>
{title}
</Typography>
<Card className="h-fit self-start py-0" style={{ minHeight }}>
<div className="flex flex-col" style={{ minHeight }}>
<div className="border-b bg-muted/50 px-4 py-3">
<h4 className="text-sm font-semibold tracking-wide">{title}</h4>
{description ? (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</Box>
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
{footer ? (
<Box
sx={{
p: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
{footer}
</Box>
) : null}
</CardContent>
</div>
<div className="flex-1 overflow-y-auto">{children}</div>
{footer ? <div className="border-t bg-card p-3">{footer}</div> : null}
</div>
</Card>
);
}
+64 -135
View File
@@ -1,15 +1,13 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Typography,
} from "@mui/material";
} from "@/components/ui/table";
import type { NowPlayingSession } from "../types";
interface Props {
@@ -19,6 +17,23 @@ interface Props {
onSelectSession?: (session: NowPlayingSession) => void;
}
type SessionStateVariant = "success" | "warning" | "secondary";
/**
* Map a session state onto a Badge variant per design §2.3.
*
* `playing` (active/healthy) → `success` (chart-2), `paused` → `warning`
* (chart-3), anything else (idle/unknown) → `secondary` (neutral accent).
*/
function sessionStateVariant(state: string): SessionStateVariant {
const normalized = String(state || "")
.trim()
.toLowerCase();
if (normalized === "playing") return "success";
if (normalized === "paused") return "warning";
return "secondary";
}
function formatStateLabel(state: string): string {
const normalized = String(state || "")
.trim()
@@ -68,177 +83,91 @@ export function SessionActivityPanel({
const userFallback = selectedUserLabel || "Unknown user";
if (!sessions.length) {
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
}
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{
maxHeight: 280,
borderColor: "divider",
borderRadius: 1,
overflowX: "auto",
}}
>
<Table
size="small"
stickyHeader
aria-label="Session activity details"
sx={{ minWidth: 880 }}
>
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 160,
}}
>
User
</TableCell>
<TableCell
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
>
State
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Title / Type
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Device
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 118,
}}
>
Transcoding
</TableCell>
<div className="max-h-[280px] overflow-auto rounded-lg border border-border">
<Table aria-label="Session activity details" className="min-w-[880px]">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead className="min-w-[160px]">User</TableHead>
<TableHead className="w-[82px]">State</TableHead>
<TableHead className="min-w-[140px]">Title / Type</TableHead>
<TableHead className="min-w-[140px]">Device</TableHead>
<TableHead className="w-[118px]">Transcoding</TableHead>
{onSelectSession ? (
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 150,
}}
>
Action
</TableCell>
<TableHead className="w-[150px]">Action</TableHead>
) : null}
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
<TableRow>
<TableRow className="bg-card hover:bg-card">
<TableCell
colSpan={onSelectSession ? 6 : 5}
sx={{ py: 0.75, bgcolor: "background.paper" }}
className="bg-card py-3"
>
<Typography variant="caption" color="text.secondary">
<span className="text-xs text-muted-foreground">
{buildStatusSummary(sessions)}
</Typography>
</span>
</TableCell>
</TableRow>
{sessions.map((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
const sessionLabel = formatStateLabel(session.state);
return (
<TableRow
key={session.session_id}
hover
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
className={onSelectSession ? "cursor-pointer" : undefined}
onClick={
onSelectSession ? () => onSelectSession(session) : undefined
}
>
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
<Typography
variant="body2"
noWrap
<TableCell className="min-w-[160px]">
<div
className="truncate text-sm"
title={session.user || userFallback}
>
{session.user || userFallback}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
</div>
<div
className="truncate text-xs text-muted-foreground"
title={session.session_id}
>
{session.session_id}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Chip
size="small"
label={sessionLabel}
color={
state === "playing"
? "primary"
: state === "paused"
? "warning"
: "default"
}
variant={
state === "playing" || state === "paused"
? "filled"
: "outlined"
}
/>
<TableCell className="whitespace-nowrap">
<Badge variant={sessionStateVariant(session.state)}>
{sessionLabel}
</Badge>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography
variant="body2"
noWrap
title={session.title || ""}
>
<TableCell className="min-w-[140px]">
<div className="truncate text-sm" title={session.title || ""}>
{session.title || "(idle)"}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
</div>
<div className="truncate text-xs text-muted-foreground">
{session.type || "—"}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography variant="body2" noWrap>
<TableCell className="min-w-[140px]">
<div className="truncate text-sm">
{session.device || "Unknown device"}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Typography variant="body2" noWrap>
<TableCell className="whitespace-nowrap">
<span className="text-sm">
{session.transcoding === "yes"
? session.transcoding_type
? `yes (${session.transcoding_type})`
: "yes"
: "no"}
</Typography>
</span>
</TableCell>
{onSelectSession ? (
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<TableCell className="whitespace-nowrap">
<Button
size="small"
variant="outlined"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
onSelectSession(session);
@@ -253,6 +182,6 @@ export function SessionActivityPanel({
})}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
+18 -17
View File
@@ -1,38 +1,39 @@
import type { ReactElement, ReactNode } from "react";
import { Box, Card, CardContent, Tabs } from "@mui/material";
import { Card } from "@/components/ui/card";
import { Tabs, TabsList } from "@/components/ui/tabs";
interface TabbedCardProps {
value: string;
onChange: (value: string) => void;
tabs: ReactElement[];
children: ReactNode;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
tabsSx?: object;
}
/**
* Card surface with a line-style tab bar on top and a content area below.
*
* `value`/`onChange` stay string-typed (controlled) and the `tabs` prop stays
* `ReactElement[]`, so consuming pages compile unchanged. The page owns the
* rendered content from `children` keyed off `value`, exactly as before.
*/
export function TabbedCard({
value,
onChange,
tabs,
children,
contentSx,
tabsSx,
}: TabbedCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 0 }}>
<Tabs
value={value}
onChange={(_, next) => onChange(String(next))}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
>
{tabs}
</Tabs>
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
</CardContent>
<Card className="gap-0 py-0">
<Tabs value={value} onValueChange={(next) => onChange(String(next))}>
<div className="border-b px-2">
<TabsList variant="line">{tabs}</TabsList>
</div>
<div className="p-4">{children}</div>
</Tabs>
</Card>
);
}
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConfirmDialog } from "../ConfirmDialog";
describe("ConfirmDialog", () => {
it("renders the title and message and wires confirm/cancel", async () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Delete machine?"
message="This cannot be undone."
confirmLabel="Delete"
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("renders nothing when closed", () => {
render(
<ConfirmDialog
open={false}
title="Hidden"
message="nope"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DialogFooter } from "../DialogFooter";
describe("DialogFooter", () => {
it("renders cancel/confirm labels and wires both callbacks", async () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<DialogFooter
onCancel={onCancel}
cancelLabel="Cancel"
onConfirm={onConfirm}
confirmLabel="Save"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("prefers the busy label and maps confirmColor=error to destructive", () => {
render(
<DialogFooter
onCancel={() => {}}
onConfirm={() => {}}
confirmLabel="Delete"
confirmBusyLabel="Deleting…"
confirmColor="error"
/>,
);
const confirm = screen.getByRole("button", { name: "Deleting…" });
expect(confirm).toBeInTheDocument();
expect(confirm.getAttribute("data-variant")).toBe("destructive");
});
it("renders the secondary action when provided", () => {
render(
<DialogFooter
onCancel={() => {}}
onConfirm={() => {}}
confirmLabel="OK"
secondaryAction={<button type="button">Test SSH</button>}
/>,
);
expect(
screen.getByRole("button", { name: "Test SSH" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { DiskSpaceCard } from "../DiskSpaceCard";
describe("DiskSpaceCard", () => {
it("preserves the used / free / total breakdown and percent headline", () => {
render(
<DiskSpaceCard
used={500000000000}
available={500000000000}
size={1000000000000}
usedPct="50"
/>,
);
expect(screen.getByText(/50 used/i)).toBeInTheDocument();
expect(screen.getByText("Used")).toBeInTheDocument();
expect(screen.getByText("Free")).toBeInTheDocument();
expect(screen.getByText("Total")).toBeInTheDocument();
// Disk space label
expect(screen.getByText(/disk space/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HoverEditButton } from "../HoverEditButton";
describe("HoverEditButton", () => {
it("fires onClick and exposes the default aria-label", async () => {
const onClick = vi.fn();
render(<HoverEditButton onClick={onClick} />);
const button = screen.getByRole("button", { name: "Edit" });
await userEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("honors a custom label", () => {
render(<HoverEditButton onClick={() => {}} label="Rename machine" />);
expect(
screen.getByRole("button", { name: "Rename machine" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { LibraryOverview } from "../LibraryOverview";
import type { LibraryCount } from "../../types";
const libraries: LibraryCount[] = [
{
library: "Films",
type: "movies",
movies: 100,
series: 0,
episodes: 0,
total: 100,
},
{
library: "Shows",
type: "tvshows",
movies: 0,
series: 12,
episodes: 240,
total: 252,
},
];
describe("LibraryOverview", () => {
it("renders movie and TV library cards with their counts", () => {
render(<LibraryOverview libraries={libraries} />);
expect(screen.getByText("Movie libraries")).toBeInTheDocument();
expect(screen.getByText("TV libraries")).toBeInTheDocument();
expect(screen.getByText("Films")).toBeInTheDocument();
expect(screen.getByText(/Total: 100 \| Movies: 100/)).toBeInTheDocument();
expect(screen.getByText("Shows")).toBeInTheDocument();
expect(screen.getByText(/Total: 252 \| Series: 12/)).toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricCard } from "../MetricCard";
describe("MetricCard", () => {
it("renders the label, value, and subtext on the comfortable ramp", () => {
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();
});
it("omits subtext when not provided", () => {
render(<MetricCard label="Series" value="42" />);
expect(screen.getByText("Series")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.queryByText(/subtext/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { NowPlaying } from "../NowPlaying";
describe("NowPlaying", () => {
it("renders the dashboard empty-state message contract when there are no sessions", () => {
render(<NowPlaying sessions={[]} />);
expect(
screen.getByText("No recent user activity sessions right now."),
).toBeInTheDocument();
});
});
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SectionCard } from "../SectionCard";
describe("SectionCard", () => {
it("renders title, description, action, and children", () => {
render(
<SectionCard
title="Shortcuts"
description="Quick links"
action={<button type="button">Add</button>}
>
<p>Body content</p>
</SectionCard>,
);
expect(screen.getByText("Shortcuts")).toBeInTheDocument();
expect(screen.getByText("Quick links")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
expect(screen.getByText("Body content")).toBeInTheDocument();
});
it("renders without a description or action", () => {
render(<SectionCard title="Only title">children</SectionCard>);
expect(screen.getByText("Only title")).toBeInTheDocument();
expect(screen.getByText("children")).toBeInTheDocument();
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SelectionRailCard } from "../SelectionRailCard";
describe("SelectionRailCard", () => {
it("renders the title, body, and footer and honors minHeight", () => {
render(
<SelectionRailCard
title="Saved tasks"
description="Pick one"
minHeight={200}
footer={<button type="button">New task</button>}
>
<div>Task A</div>
</SelectionRailCard>,
);
expect(screen.getByText("Saved tasks")).toBeInTheDocument();
expect(screen.getByText("Task A")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "New task" }),
).toBeInTheDocument();
// minHeight is applied to the Card via inline style.
const card = screen
.getByText("Saved tasks")
.closest("[data-slot='card']") as HTMLElement | null;
expect(card?.style.minHeight).toBe("200px");
});
it("renders without a footer", () => {
render(<SelectionRailCard title="No footer">body</SelectionRailCard>);
expect(screen.getByText("No footer")).toBeInTheDocument();
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SessionActivityPanel } from "../SessionActivityPanel";
import type { NowPlayingSession } from "../../types";
function session(
overrides: Partial<NowPlayingSession> = {},
): NowPlayingSession {
return {
user: "alice",
title: "Movie",
type: "Movie",
state: "playing",
transcoding: "no",
transcoding_type: "",
device: "Web",
session_id: "s1",
...overrides,
};
}
describe("SessionActivityPanel", () => {
it("maps a playing (healthy) session to the success Badge variant", () => {
render(<SessionActivityPanel sessions={[session({ state: "playing" })]} />);
const badge = screen.getByText("Playing");
expect(badge.getAttribute("data-variant")).toBe("success");
});
it("maps paused → warning and idle → secondary", () => {
const { rerender } = render(
<SessionActivityPanel sessions={[session({ state: "paused" })]} />,
);
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
"warning",
);
rerender(<SessionActivityPanel sessions={[session({ state: "idle" })]} />);
expect(screen.getByText("Idle").getAttribute("data-variant")).toBe(
"secondary",
);
});
it("renders the empty-state message when there are no sessions", () => {
render(
<SessionActivityPanel sessions={[]} emptyMessage="Nothing playing." />,
);
expect(screen.getByText("Nothing playing.")).toBeInTheDocument();
});
it("calls onSelectSession on row click and on the action button", async () => {
const onSelectSession = vi.fn();
render(
<SessionActivityPanel
sessions={[session({ state: "playing" })]}
onSelectSession={onSelectSession}
/>,
);
await userEvent.click(screen.getByText("alice"));
expect(onSelectSession).toHaveBeenCalledTimes(1);
await userEvent.click(
screen.getByRole("button", { name: "Open in Users" }),
);
expect(onSelectSession).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,32 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabbedCard } from "../TabbedCard";
import { TabsTrigger } from "@/components/ui/tabs";
describe("TabbedCard", () => {
it("renders the provided tab triggers and reports selection changes", async () => {
const onChange = vi.fn();
render(
<TabbedCard
value="jellyfin"
onChange={onChange}
tabs={[
<TabsTrigger key="jellyfin" value="jellyfin">
Jellyfin
</TabsTrigger>,
<TabsTrigger key="nextcloud" value="nextcloud">
Nextcloud
</TabsTrigger>,
]}
>
<p>Body</p>
</TabbedCard>,
);
expect(screen.getByText("Jellyfin")).toBeInTheDocument();
expect(screen.getByText("Body")).toBeInTheDocument();
await userEvent.click(screen.getByText("Nextcloud"));
expect(onChange).toHaveBeenCalledWith("nextcloud");
});
});
@@ -168,3 +168,165 @@ unblocked now that all primitives, TanStack Table, the Vitest harness, the `succ
- `npm audit` reports 8 vulnerabilities in the dependency tree (pre-existing across the
MUI/emotion/react stack); not introduced by this slice and out of scope.
- `DatabaseBackup` from lucide-react is available — Slice 3 nav should use it directly.
## Slice 2 — Shared components (lock the building-block language) — COMPLETE
All 12 Slice-2 task lines in `tasks.md` are now `- [x]`. The 11 shared components
are MUI-free and the exported APIs are preserved so every consuming page still
compiles unchanged (no `frontend/src/pages/*` file was edited this slice).
### Status context consumed
- `applyState` reported by the status engine: **blocked** (`blockedReasons`:
domain specs missing/partial; legacy flat `spec.md` present without domain
specs). This is a **planning-completeness** gap, not a safety/`actionContext`
blocker. `actionContext`: `mode: repo-local`, `allowedEditRoots: ["/home/user/Manage_01"]`,
`warnings: []` — safe.
- This run executed the explicitly delegated **Slice 2 (Shared components)**
scope per the parent acceptance contract. Slice-2 work is fully specified in
`tasks.md` + `design.md` (§1 mapping table, §2 typography ramp, §2.3 success
Badge variant) and does not depend on the missing domain specs. The
`instructions.apply` line says "Implement only unchecked tasks from the tasks
artifact." → proceeded under the parent's explicit slice delegation.
- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md`
(Slice 2: 0 → 12 `[x]`). Cumulative change task progress: 13 → **25/71** complete.
### What was migrated (10 files rewritten; 1 already-clean)
- `SectionCard.tsx` → shadcn `Card`/`CardContent`; `gap-4` comfortable density;
section title `text-base font-semibold`, description `text-sm text-muted-foreground`.
- `SelectionRailCard.tsx` → `Card` with titled header / scrollable body / footer.
Preserved `minHeight` (inline style), `footer`, scrollable body. Legacy
`contentSx`/`bodySx` MUI-sx props **retained in the interface as no-ops** so
Actions/Settings compile unchanged.
- `TabbedCard.tsx` → shadcn `Tabs` (`TabsList variant="line"`) on a `Card`.
`value`/`onChange` stay string-typed (controlled); `tabs` stays `ReactElement[]`;
legacy `contentSx`/`tabsSx` retained as no-op props.
- `MetricCard.tsx` → `Card`/`CardContent` on the design ramp: label `text-sm`,
value `text-lg font-semibold`, subtext `text-xs text-muted-foreground`.
- `DiskSpaceCard.tsx` → `Card` + CSS grid (`grid-cols-1 sm:grid-cols-3 gap-4`)
- shadcn `Progress`; used/free/total/percent breakdown preserved; the progress
color cue (chart-2 success / chart-3 warning / destructive) is emitted via full
static `[&_[data-slot=progress-indicator]]:bg-*` class strings (verified present
in the built CSS).
- `HoverEditButton.tsx` → `Button variant="ghost" size="icon-sm"` + lucide `Pencil`.
Kept the `rail-edit` class + `opacity-0`/`transition-opacity` base so the existing
hover-reveal rules in Actions/Settings (`&:hover .rail-edit { opacity: 1 }`)
still target it until those pages migrate (slices 5). MUI `IconButton`+
`EditOutlined` removed.
- `DialogFooter.tsx` → horizontal `Button` row (`flex flex-row items-center
gap-2`). **All legacy props preserved**: `confirmColor` (`error`→`destructive`
variant) and `confirmVariant` (`outlined`→`outline`, `text`→`ghost`, else
`default`) are mapped internally onto shadcn variants; `confirmBusyLabel`,
`confirmDisabled`, `confirmStartIcon`, `secondaryAction`, `cancelLabel` all honored.
- `ConfirmDialog.tsx` → shadcn `Dialog` family (`DialogContent`/`DialogHeader`/
`DialogTitle`/`DialogDescription`) reusing the migrated `DialogFooter`. Esc /
overlay click routes to `onCancel` via `onOpenChange`. Same exported props.
- `LibraryOverview.tsx` → `Card`/`CardContent` on a responsive CSS grid
(`grid grid-cols-1 md:grid-cols-2 gap-4`); movie/TV counts render verbatim.
- `SessionActivityPanel.tsx` → shadcn `Table` family on a bordered rounded
scrollable surface + `Badge` (status variant map per design §2.3: `playing`→
`success`/chart-2, `paused`→`warning`/chart-3, idle/other→`secondary`) +
`Button` for the action. Row-click + action-button callbacks, status summary
row, and transcoding formatting preserved.
- `NowPlaying.tsx` → **already MUI-free** (it only imports `../types` +
`./SessionActivityPanel`); left unchanged. Its empty-state message contract
("No recent user activity sessions right now.") is exercised by a new test.
### Component tests added (11 files, co-located under `src/components/__tests__/`)
- `MetricCard` (label/value/subtext + subtext-omitted), `DiskSpaceCard` (used/
free/total + percent headline), `HoverEditButton` (onClick fires, custom label),
`DialogFooter` (cancel/confirm callbacks, busy label, error→destructive,
secondary action), `ConfirmDialog` (title/message render + confirm/cancel,
closed renders nothing), `SectionCard` (title/description/action/children),
`SelectionRailCard` (title/body/footer + `minHeight` applied), `TabbedCard`
(renders triggers + reports selection change), `LibraryOverview` (movie/TV
cards + counts), `SessionActivityPanel` (status→Badge variant mapping
playing/paused/idle, empty-state, row-click + action-button callbacks),
`NowPlaying` (dashboard empty-state message contract).
### Files changed (tracked)
Modified (10 components):
- `frontend/src/components/{SectionCard,SelectionRailCard,TabbedCard,MetricCard,
DiskSpaceCard,HoverEditButton,DialogFooter,ConfirmDialog,LibraryOverview,
SessionActivityPanel}.tsx`
- `openspec/changes/web-ui-rework/tasks.md` (Slice 2 checkboxes 0 → 12 `[x]`)
Added (new, 11 test files):
- `frontend/src/components/__tests__/{MetricCard,DiskSpaceCard,HoverEditButton,
DialogFooter,ConfirmDialog,SectionCard,SelectionRailCard,TabbedCard,
LibraryOverview,SessionActivityPanel,NowPlaying}.test.tsx`
Untouched (no-unintended-edits respected): **no `frontend/src/pages/*` file
edited this slice** — `git status --porcelain frontend/src/pages` is empty.
`NowPlaying.tsx` is unchanged (already MUI-free). No `components/ui/*` primitive
was modified.
### Commands run (validation) — all green
- `grep -rlE '@mui/(material|icons-material)' <11 files>` → **ALL 11 MUI-FREE**.
- `cd frontend && npm run build` → **PASS** (`tsc -b` + `vite build`).
- `cd frontend && npm run lint` → **PASS** (0 errors; the only 2 items are the
pre-existing `react-hooks/exhaustive-deps` **warnings** in `UsersPage.impl.tsx`,
out of Slice-2 scope).
- `cd frontend && npm test` → **PASS** (Vitest: **12 files, 22 tests** passed;
11 new component tests + the slice-1 Badge smoke test).
- `cd frontend && npm run test:node` → **PASS** (legacy node:test: 4 tests, 0 fail).
- Verified Tailwind emitted the DiskSpaceCard `[data-slot=progress-indicator]`
chart-2/3/destructive utilities into the built CSS (arbitrary-variant classes
written as full static strings).
### API-preserving compromises (design constraints accepted to keep pages compiling)
1. **`SelectionRailCard` / `TabbedCard` legacy `*Sx` props.** Kept
`contentSx`/`bodySx`/`tabsSx` in the prop interfaces as documented no-ops
(MUI `sx` objects have no Tailwind equivalent). They are intentionally **not**
destructured into locals (avoids `no-unused-vars`) and are ignored at render.
Consumers (Actions, Settings) pass them today and compile unchanged.
2. **`DialogFooter` color/variant mapping.** `confirmColor`/`confirmVariant` are
MUI-only concepts; they are retained on the API and mapped to shadcn Button
variants (`error`→`destructive`, `outlined`→`outline`, `text`→`ghost`). All
current consumers pass at most `confirmColor="error"` and `secondaryAction`,
which map cleanly. No consumer passes `confirmVariant` explicitly today.
3. **`HoverEditButton` hover reveal.** Implemented with a Tailwind `opacity-0`/
`transition-opacity` base (not inline style) so the not-yet-migrated pages'
MUI-sx `&:hover .rail-edit { opacity: 1 }` rules still override it on hover
(higher specificity) during the interim. The `rail-edit` class is preserved.
4. **`SessionActivityPanel` status→Badge mapping.** `playing` (active/healthy)
→ `success` per the task's "healthy=`success`" + design §2.3; `paused`→`warning`;
idle/unknown→`secondary`. This is the documented cue map (no app semantics
changed — the prior MUI Chip used primary/warning/default coloring).
### Carry-over risk / top risk for slice 3 (and interim)
- **`TabbedCard` interim tab-bar rendering.** Until Applications (slice 4) and
Settings/Actions (slice 5) migrate, those pages still pass MUI `<Tab>` elements
as the `tabs` prop, which now render inside a shadcn `<TabsList>`. MUI `<Tab>`
outside an MUI `<Tabs>` does **not** throw (renders with a console warning) and
the page content still swaps via the page's external `value` state, but the
tab "active" highlight is cosmetic-only until those pages migrate. **Build /
lint / test are unaffected.** This is an expected interim state of the chained
model and resolves fully once slices 45 land. (Pages are intentionally not
edited in slice 2.)
- **Slice-2 PR budget.** Review churn ≈ 775 lines on the 10 rewritten components
(303 ins / 472 del; mostly MUI teardown) + 335 lines of new tests ≈ ~1,110
changed lines, **over the ≤400 budget**. The forecast authorizes sub-split 2a
(cards/buttons/dialogs) → 2b (tables/panels) on overrun. The parent delegated
the whole slice as one unit and owns the commit/PR, so this run delivered it
in one piece; the parent may split 2a/2b at PR time or take a size exception
(mirroring the slice-1 precedent). Functionally the slice is complete and all
gates are green.
- Overall change `applyState` remains **blocked** on missing domain specs (legacy
flat `spec.md`); does not block Slice 2 (done) but should be resolved before
`sdd-verify`/archive.
### Remaining tasks (Slices 38, 46 unchecked)
Slice 2 is complete (25/71 tasks). Next in dependency order: **Slice 3 — Backups
cluster + nav/IA** (uses slice-2 `Table`/`Badge`/`Tabs`/cards and lands the
`/backups` nav item + `/applications`→`/media` redirect; `DatabaseBackup` icon
confirmed available). See `tasks.md` Slices 38 for the verbatim unchecked list.
+12 -12
View File
@@ -125,18 +125,18 @@ Each slice section restates this gate as its final task.
> 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/EditOutlined``Button 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.
- [x] Migrate `frontend/src/components/SectionCard.tsx` (Box/Card/CardContent/Stack/Typography → shadcn `Card` family + Tailwind stack; comfortable density, `gap-4`).
- [x] Migrate `frontend/src/components/SelectionRailCard.tsx` (Box/Card/CardContent/Typography → `Card` + Tailwind; preserve `minHeight`/scrollable body/footer props).
- [x] Migrate `frontend/src/components/TabbedCard.tsx` (Box/Card/CardContent/Tabs → shadcn `Tabs` (`TabsList`/`TabsTrigger`/`TabsContent`) on a `Card`).
- [x] 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`).
- [x] Migrate `frontend/src/components/DiskSpaceCard.tsx` (Box/Card/CardContent/Grid/LinearProgress/Stack/Typography → `Card` + CSS grid + shadcn `Progress`; preserve used/free/total/percent breakdown).
- [x] Migrate `frontend/src/components/HoverEditButton.tsx` (`@mui/material` IconButton + `@mui/icons-material/EditOutlined``Button variant="ghost" size="icon"` + lucide `Pencil`; keep the hover-in visibility transition).
- [x] 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).
- [x] Migrate `frontend/src/components/ConfirmDialog.tsx` (Dialog/DialogContent/DialogTitle/Stack/Typography → shadcn `Dialog` family + `DialogFooter` from this slice).
- [x] 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`).
- [x] 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`).
- [x] 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`.
- [x] **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.
---