From 109e74db41beed9396da88b4e5af1136422e7a35 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 17 Jun 2026 12:33:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20slice=202=20=E2=80=94=20migra?= =?UTF-8?q?te=2011=20shared=20components=20to=20shadcn/Tailwind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/ConfirmDialog.tsx | 46 ++-- frontend/src/components/DialogFooter.tsx | 57 +++-- frontend/src/components/DiskSpaceCard.tsx | 166 ++++----------- frontend/src/components/HoverEditButton.tsx | 29 +-- frontend/src/components/LibraryOverview.tsx | 59 +++--- frontend/src/components/MetricCard.tsx | 50 ++--- frontend/src/components/SectionCard.tsx | 47 ++--- frontend/src/components/SelectionRailCard.tsx | 65 ++---- .../src/components/SessionActivityPanel.tsx | 199 ++++++------------ frontend/src/components/TabbedCard.tsx | 35 +-- .../__tests__/ConfirmDialog.test.tsx | 40 ++++ .../__tests__/DialogFooter.test.tsx | 52 +++++ .../__tests__/DiskSpaceCard.test.tsx | 22 ++ .../__tests__/HoverEditButton.test.tsx | 21 ++ .../__tests__/LibraryOverview.test.tsx | 35 +++ .../components/__tests__/MetricCard.test.tsx | 21 ++ .../components/__tests__/NowPlaying.test.tsx | 12 ++ .../components/__tests__/SectionCard.test.tsx | 27 +++ .../__tests__/SelectionRailCard.test.tsx | 33 +++ .../__tests__/SessionActivityPanel.test.tsx | 65 ++++++ .../components/__tests__/TabbedCard.test.tsx | 32 +++ .../changes/web-ui-rework/apply-progress.md | 162 ++++++++++++++ openspec/changes/web-ui-rework/tasks.md | 24 +-- 23 files changed, 830 insertions(+), 469 deletions(-) create mode 100644 frontend/src/components/__tests__/ConfirmDialog.test.tsx create mode 100644 frontend/src/components/__tests__/DialogFooter.test.tsx create mode 100644 frontend/src/components/__tests__/DiskSpaceCard.test.tsx create mode 100644 frontend/src/components/__tests__/HoverEditButton.test.tsx create mode 100644 frontend/src/components/__tests__/LibraryOverview.test.tsx create mode 100644 frontend/src/components/__tests__/MetricCard.test.tsx create mode 100644 frontend/src/components/__tests__/NowPlaying.test.tsx create mode 100644 frontend/src/components/__tests__/SectionCard.test.tsx create mode 100644 frontend/src/components/__tests__/SelectionRailCard.test.tsx create mode 100644 frontend/src/components/__tests__/SessionActivityPanel.test.tsx create mode 100644 frontend/src/components/__tests__/TabbedCard.test.tsx diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index 13e7c92..b56d649 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -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 ( - - {title} - - - - {message} - - + { + if (!next) onCancel(); + }} + > + + + {title} + {message} + + - ); } diff --git a/frontend/src/components/DialogFooter.tsx b/frontend/src/components/DialogFooter.tsx index e7e14c6..4ff2802 100644 --- a/frontend/src/components/DialogFooter.tsx +++ b/frontend/src/components/DialogFooter.tsx @@ -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 ( - - - - {secondaryAction} - - - +
+ + {secondaryAction ? ( +
+ {secondaryAction} +
+ ) : null} + +
); } diff --git a/frontend/src/components/DiskSpaceCard.tsx b/frontend/src/components/DiskSpaceCard.tsx index b08db54..9a4781c 100644 --- a/frontend/src/components/DiskSpaceCard.tsx +++ b/frontend/src/components/DiskSpaceCard.tsx @@ -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 ( - - - - - + +
+ + Disk space + + {usedPct} used +
+ +
+ {cells.map((cell) => ( +
- Disk space - - - {usedPct} used - - - - - - - - - - - - Used - - - {formatBytes(used)} - - - - - - - Free - - - {formatBytes(available)} - - - - - - - Total - - - {formatBytes(size)} - - - - - + + {cell.label} + + {cell.value} +
+ ))} +
); diff --git a/frontend/src/components/HoverEditButton.tsx b/frontend/src/components/HoverEditButton.tsx index 9e20dec..bf693fd 100644 --- a/frontend/src/components/HoverEditButton.tsx +++ b/frontend/src/components/HoverEditButton.tsx @@ -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 ( - e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onClick(); }} - sx={{ - opacity: 0, - transition: "opacity 120ms ease", - color: "text.secondary", - }} > - - + + ); } diff --git a/frontend/src/components/LibraryOverview.tsx b/frontend/src/components/LibraryOverview.tsx index 06f4328..fb0e2d0 100644 --- a/frontend/src/components/LibraryOverview.tsx +++ b/frontend/src/components/LibraryOverview.tsx @@ -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 ( - - - +
+
+

Movie libraries - - +

+
{movieLibs.map((lib) => ( - - - - {lib.library} - - + + + {lib.library} + Total: {lib.total.toLocaleString()} | Movies:{" "} {lib.movies.toLocaleString()} - + ))} - - - - +
+
+
+

TV libraries - - +

+
{tvLibs.map((lib) => ( - - - - {lib.library} - - + + + {lib.library} + Total: {lib.total.toLocaleString()} | Series:{" "} {lib.series.toLocaleString()} - + ))} - - - +
+
+
); } diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index fe4bb33..f673e67 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -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 ( - - - + + + {label} - - - {value} - - {subtext && ( - + + {value} + {subtext ? ( + {subtext} - - )} + + ) : null} ); diff --git a/frontend/src/components/SectionCard.tsx b/frontend/src/components/SectionCard.tsx index db05704..11e466f 100644 --- a/frontend/src/components/SectionCard.tsx +++ b/frontend/src/components/SectionCard.tsx @@ -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 ( - - - - - - - {title} - - {description ? ( - - {description} - - ) : null} - - {action} - - {children} - + + +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {action} +
+ {children}
); diff --git a/frontend/src/components/SelectionRailCard.tsx b/frontend/src/components/SelectionRailCard.tsx index 05e46ab..40dd443 100644 --- a/frontend/src/components/SelectionRailCard.tsx +++ b/frontend/src/components/SelectionRailCard.tsx @@ -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 ( - - - - - {title} - + +
+
+

{title}

{description ? ( - - {description} - +

{description}

) : null} - - {children} - {footer ? ( - - {footer} - - ) : null} - +
+
{children}
+ {footer ?
{footer}
: null} +
); } diff --git a/frontend/src/components/SessionActivityPanel.tsx b/frontend/src/components/SessionActivityPanel.tsx index f9a5db2..6eeb6ae 100644 --- a/frontend/src/components/SessionActivityPanel.tsx +++ b/frontend/src/components/SessionActivityPanel.tsx @@ -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 ( - - {emptyMessage} - - ); + return

{emptyMessage}

; } return ( - - - - - - User - - - State - - - Title / Type - - - Device - - - Transcoding - +
+
+ + + User + State + Title / Type + Device + Transcoding {onSelectSession ? ( - - Action - + Action ) : null} - + - + - + {buildStatusSummary(sessions)} - + {sessions.map((session) => { - const state = String(session.state || "") - .trim() - .toLowerCase(); const sessionLabel = formatStateLabel(session.state); return ( onSelectSession(session) : undefined } > - - +
{session.user || userFallback} - - +
{session.session_id} - +
- - + + + {sessionLabel} + - - + +
{session.title || "(idle)"} - - +
+
{session.type || "—"} - +
- - + +
{session.device || "Unknown device"} - +
- - + + {session.transcoding === "yes" ? session.transcoding_type ? `yes (${session.transcoding_type})` : "yes" : "no"} - + {onSelectSession ? ( - +
-
+ ); } diff --git a/frontend/src/components/TabbedCard.tsx b/frontend/src/components/TabbedCard.tsx index bdd23ff..14df821 100644 --- a/frontend/src/components/TabbedCard.tsx +++ b/frontend/src/components/TabbedCard.tsx @@ -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 ( - - - onChange(String(next))} - variant="scrollable" - scrollButtons="auto" - allowScrollButtonsMobile - sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }} - > - {tabs} - - {children} - + + onChange(String(next))}> +
+ {tabs} +
+
{children}
+
); } diff --git a/frontend/src/components/__tests__/ConfirmDialog.test.tsx b/frontend/src/components/__tests__/ConfirmDialog.test.tsx new file mode 100644 index 0000000..8bead29 --- /dev/null +++ b/frontend/src/components/__tests__/ConfirmDialog.test.tsx @@ -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( + , + ); + 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( + {}} + onConfirm={() => {}} + />, + ); + expect(screen.queryByText("Hidden")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/DialogFooter.test.tsx b/frontend/src/components/__tests__/DialogFooter.test.tsx new file mode 100644 index 0000000..990edc8 --- /dev/null +++ b/frontend/src/components/__tests__/DialogFooter.test.tsx @@ -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( + , + ); + 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( + {}} + 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( + {}} + onConfirm={() => {}} + confirmLabel="OK" + secondaryAction={} + />, + ); + expect( + screen.getByRole("button", { name: "Test SSH" }), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/DiskSpaceCard.test.tsx b/frontend/src/components/__tests__/DiskSpaceCard.test.tsx new file mode 100644 index 0000000..f0f040b --- /dev/null +++ b/frontend/src/components/__tests__/DiskSpaceCard.test.tsx @@ -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( + , + ); + 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(); + }); +}); diff --git a/frontend/src/components/__tests__/HoverEditButton.test.tsx b/frontend/src/components/__tests__/HoverEditButton.test.tsx new file mode 100644 index 0000000..1afab15 --- /dev/null +++ b/frontend/src/components/__tests__/HoverEditButton.test.tsx @@ -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(); + const button = screen.getByRole("button", { name: "Edit" }); + await userEvent.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("honors a custom label", () => { + render( {}} label="Rename machine" />); + expect( + screen.getByRole("button", { name: "Rename machine" }), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/LibraryOverview.test.tsx b/frontend/src/components/__tests__/LibraryOverview.test.tsx new file mode 100644 index 0000000..c64b98d --- /dev/null +++ b/frontend/src/components/__tests__/LibraryOverview.test.tsx @@ -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(); + 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(); + }); +}); diff --git a/frontend/src/components/__tests__/MetricCard.test.tsx b/frontend/src/components/__tests__/MetricCard.test.tsx new file mode 100644 index 0000000..bcfdf13 --- /dev/null +++ b/frontend/src/components/__tests__/MetricCard.test.tsx @@ -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( + , + ); + 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(); + expect(screen.getByText("Series")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.queryByText(/subtext/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/NowPlaying.test.tsx b/frontend/src/components/__tests__/NowPlaying.test.tsx new file mode 100644 index 0000000..7a33fd4 --- /dev/null +++ b/frontend/src/components/__tests__/NowPlaying.test.tsx @@ -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(); + expect( + screen.getByText("No recent user activity sessions right now."), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/SectionCard.test.tsx b/frontend/src/components/__tests__/SectionCard.test.tsx new file mode 100644 index 0000000..c88ab72 --- /dev/null +++ b/frontend/src/components/__tests__/SectionCard.test.tsx @@ -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( + Add} + > +

Body content

+
, + ); + 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(children); + expect(screen.getByText("Only title")).toBeInTheDocument(); + expect(screen.getByText("children")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/SelectionRailCard.test.tsx b/frontend/src/components/__tests__/SelectionRailCard.test.tsx new file mode 100644 index 0000000..48265bd --- /dev/null +++ b/frontend/src/components/__tests__/SelectionRailCard.test.tsx @@ -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( + New task} + > +
Task A
+
, + ); + 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(body); + expect(screen.getByText("No footer")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/SessionActivityPanel.test.tsx b/frontend/src/components/__tests__/SessionActivityPanel.test.tsx new file mode 100644 index 0000000..62e6020 --- /dev/null +++ b/frontend/src/components/__tests__/SessionActivityPanel.test.tsx @@ -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 { + 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(); + const badge = screen.getByText("Playing"); + expect(badge.getAttribute("data-variant")).toBe("success"); + }); + + it("maps paused → warning and idle → secondary", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Paused").getAttribute("data-variant")).toBe( + "warning", + ); + rerender(); + expect(screen.getByText("Idle").getAttribute("data-variant")).toBe( + "secondary", + ); + }); + + it("renders the empty-state message when there are no sessions", () => { + render( + , + ); + expect(screen.getByText("Nothing playing.")).toBeInTheDocument(); + }); + + it("calls onSelectSession on row click and on the action button", async () => { + const onSelectSession = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByText("alice")); + expect(onSelectSession).toHaveBeenCalledTimes(1); + await userEvent.click( + screen.getByRole("button", { name: "Open in Users" }), + ); + expect(onSelectSession).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/components/__tests__/TabbedCard.test.tsx b/frontend/src/components/__tests__/TabbedCard.test.tsx new file mode 100644 index 0000000..329797a --- /dev/null +++ b/frontend/src/components/__tests__/TabbedCard.test.tsx @@ -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( + + Jellyfin + , + + Nextcloud + , + ]} + > +

Body

+
, + ); + expect(screen.getByText("Jellyfin")).toBeInTheDocument(); + expect(screen.getByText("Body")).toBeInTheDocument(); + + await userEvent.click(screen.getByText("Nextcloud")); + expect(onChange).toHaveBeenCalledWith("nextcloud"); + }); +}); diff --git a/openspec/changes/web-ui-rework/apply-progress.md b/openspec/changes/web-ui-rework/apply-progress.md index c2ecf38..f39ebe1 100644 --- a/openspec/changes/web-ui-rework/apply-progress.md +++ b/openspec/changes/web-ui-rework/apply-progress.md @@ -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 `` elements + as the `tabs` prop, which now render inside a shadcn ``. MUI `` + outside an MUI `` 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 4–5 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 3–8, 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 3–8 for the verbatim unchecked list. diff --git a/openspec/changes/web-ui-rework/tasks.md b/openspec/changes/web-ui-rework/tasks.md index f4e11e3..441e020 100644 --- a/openspec/changes/web-ui-rework/tasks.md +++ b/openspec/changes/web-ui-rework/tasks.md @@ -125,18 +125,18 @@ Each slice section restates this gate as its final task. > exported API intact so downstream pages compile unchanged. ~280–420 lines → split > 2a/2b if over. -- [ ] Migrate `frontend/src/components/SectionCard.tsx` (Box/Card/CardContent/Stack/Typography → 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. ---