) : (
-
- No shortcuts yet. Add a website now, then add action or user
- shortcuts later.
+
+
+ No shortcuts yet. Add a website now, then add action or user
+ shortcuts later.
+
)}
@@ -403,25 +421,23 @@ export function Dashboard() {
description="Live sessions and idle users from Jellyfin."
action={
jellyfinMachines.length > 1 ? (
-
-
+
+
) : jellyfinMachines.length === 1 ? (
-
+ {jellyfinMachines[0].name}
) : null
}
>
@@ -444,31 +460,19 @@ export function Dashboard() {
onClose={() => setShortcutDialogOpen(false)}
onSave={saveShortcutDraft}
/>
-
-
+ title="Delete shortcut?"
+ message="This cannot be undone. The shortcut will be removed from the dashboard."
+ confirmLabel="Delete"
+ onCancel={() => setDeleteShortcutId(null)}
+ onConfirm={() => {
+ if (deleteShortcutId) {
+ deleteShortcut.mutate(deleteShortcutId);
+ }
+ setDeleteShortcutId(null);
+ }}
+ />
+
);
}
diff --git a/frontend/src/pages/__tests__/Applications.test.tsx b/frontend/src/pages/__tests__/Applications.test.tsx
new file mode 100644
index 0000000..693e323
--- /dev/null
+++ b/frontend/src/pages/__tests__/Applications.test.tsx
@@ -0,0 +1,67 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Applications } from "../Applications";
+
+// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
+// so this slice-4 test stays focused on the migrated Applications shell and
+// does not pull the still-MUI DataGrid into the jsdom render.
+vi.mock("../Media", () => ({
+ Media: () =>
Media
,
+}));
+
+vi.mock("react-router-dom", () => ({
+ useSearchParams: () => [new URLSearchParams(), vi.fn()],
+}));
+
+vi.mock("../../hooks/useSettings", () => ({
+ useMonitoringSettings: () => ({
+ data: [
+ {
+ id: "m1",
+ name: "Main",
+ enabled: true,
+ services: ["jellyfin"],
+ },
+ ],
+ }),
+}));
+
+vi.mock("../../hooks/useDashboard", () => ({
+ useCounts: () => ({
+ data: { movies: 10, series: 5, episodes: 100 },
+ }),
+ useLibraries: () => ({
+ data: [
+ { library: "Movies", total: 10, movies: 10, series: 0 },
+ { library: "Shows", total: 5, movies: 0, series: 5 },
+ ],
+ }),
+}));
+
+describe("Applications", () => {
+ it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
+ render();
+
+ // Library stats header.
+ expect(screen.getByText("Library stats")).toBeInTheDocument();
+
+ // Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
+ expect(screen.getByText("115")).toBeInTheDocument();
+ expect(screen.getByText("Episodes")).toBeInTheDocument();
+
+ // Library rows render their per-library totals (unique strings).
+ expect(
+ screen.getByText(/Total 10 · Movies 10 · Series 0/),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/Total 5 · Movies 0 · Series 5/),
+ ).toBeInTheDocument();
+
+ // Tabs present.
+ expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
+
+ // The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
+ expect(screen.getByTestId("media-child")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx
new file mode 100644
index 0000000..f149a0b
--- /dev/null
+++ b/frontend/src/pages/__tests__/Dashboard.test.tsx
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Dashboard } from "../Dashboard";
+import type { DashboardShortcut } from "../../types";
+
+// Stub the composed widgets so the test exercises Dashboard's own behavior
+// (shortcut CRUD) without rendering the session panel or the backup query.
+vi.mock("../../components/NowPlaying", () => ({
+ NowPlaying: () => ,
+}));
+vi.mock("../../components/BackupDashboardWidget", () => ({
+ default: () => ,
+}));
+
+const navigate = vi.fn();
+vi.mock("react-router-dom", () => ({
+ useNavigate: () => navigate,
+}));
+
+vi.mock("../../hooks/useSettings", () => ({
+ useMonitoringSettings: () => ({ data: [] }),
+}));
+
+const saveShortcutMutate = vi.fn().mockResolvedValue({});
+const deleteShortcutMutate = vi.fn();
+
+let shortcuts: DashboardShortcut[] = [];
+
+vi.mock("../../hooks/useDashboard", () => ({
+ useActivity: () => ({ data: undefined }),
+ useDashboardShortcuts: () => ({ data: shortcuts }),
+ useSaveDashboardShortcut: () => ({ mutateAsync: saveShortcutMutate }),
+ useDeleteDashboardShortcut: () => ({ mutate: deleteShortcutMutate }),
+}));
+
+function websiteShortcut(
+ overrides: Partial = {},
+): DashboardShortcut {
+ return {
+ id: "s1",
+ label: "Wiki",
+ shortcut_type: "website",
+ enabled: true,
+ icon: "📚",
+ url: "example.com",
+ task_id: "",
+ machine_id: "",
+ user_id: "",
+ notes: "Team wiki",
+ created_at: 0,
+ updated_at: 0,
+ ...overrides,
+ } as DashboardShortcut;
+}
+
+beforeEach(() => {
+ navigate.mockReset();
+ saveShortcutMutate.mockClear();
+ deleteShortcutMutate.mockClear();
+ shortcuts = [];
+});
+
+describe("Dashboard", () => {
+ it("shows the empty-state alert when there are no shortcuts", () => {
+ render();
+ expect(screen.getByText(/No shortcuts yet/)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Add shortcut" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a shortcut card and deletes it via the confirm dialog", async () => {
+ shortcuts = [websiteShortcut()];
+ render();
+
+ expect(screen.getByText("Wiki")).toBeInTheDocument();
+
+ // Open the delete confirm.
+ await userEvent.click(screen.getByRole("button", { name: "Delete" }));
+ expect(screen.getByText("Delete shortcut?")).toBeInTheDocument();
+
+ // Confirm deletion -> delete mutation fires with the shortcut id.
+ const dialogs = screen.getAllByRole("button", { name: "Delete" });
+ // The card "Delete" plus the confirm "Delete"; confirm is the last one.
+ await userEvent.click(dialogs[dialogs.length - 1]);
+ expect(deleteShortcutMutate).toHaveBeenCalledTimes(1);
+ expect(deleteShortcutMutate).toHaveBeenCalledWith("s1");
+ });
+
+ it("creates a shortcut via the dialog and saves it", async () => {
+ render();
+
+ await userEvent.click(screen.getByRole("button", { name: "Add shortcut" }));
+
+ // Edit dialog opens in "New shortcut" mode.
+ expect(screen.getByText("New shortcut")).toBeInTheDocument();
+
+ // Fill the label and save.
+ await userEvent.type(screen.getByLabelText("Label"), "Grafana");
+ await userEvent.click(
+ screen.getByRole("button", { name: "Save shortcut" }),
+ );
+
+ expect(saveShortcutMutate).toHaveBeenCalledTimes(1);
+ const saved = saveShortcutMutate.mock.calls[0][0];
+ expect(saved.label).toBe("Grafana");
+ expect(saved.shortcut_type).toBe("website");
+ });
+});
diff --git a/openspec/changes/web-ui-rework/apply-progress.md b/openspec/changes/web-ui-rework/apply-progress.md
index c8ac1cf..fa11c6b 100644
--- a/openspec/changes/web-ui-rework/apply-progress.md
+++ b/openspec/changes/web-ui-rework/apply-progress.md
@@ -498,3 +498,121 @@ Dashboard + Applications surface** (depends on slices 2 + 3; reuses
Overall change `applyState` remains **blocked** on missing domain specs (legacy
flat `spec.md`); does not block Slice 3 (done) but should be resolved before
`sdd-verify`/archive.
+
+## Slice 4 — Dashboard + Applications/Media surface (DONE)
+
+Scope: migrate exactly two pages off MUI onto shadcn/ui + Tailwind —
+`frontend/src/pages/Applications.tsx` and `frontend/src/pages/Dashboard.tsx`.
+The still-MUI `` child (DataGrid, slice 7) is **left untouched**;
+only the Applications shell around it was migrated. All 5 Slice 4 task
+checkboxes in `tasks.md` are now `- [x]` (38/71 → **33+5 = 38/71** overall;
+remaining unchecked = Slices 5–8).
+
+### Files changed
+
+- `frontend/src/pages/Applications.tsx` — full rewrite (MUI → shadcn).
+- `frontend/src/pages/Dashboard.tsx` — full rewrite (MUI → shadcn).
+- `frontend/src/pages/__tests__/Applications.test.tsx` — new (1 test).
+- `frontend/src/pages/__tests__/Dashboard.test.tsx` — new (3 tests).
+- `openspec/changes/web-ui-rework/tasks.md` — 5 Slice 4 checkboxes `- [ ]` → `- [x]`.
+
+No other files touched (App.tsx, Media.tsx, FileBrowser.impl.tsx,
+Settings/Actions/UsersPage.impl.tsx, components/*, package.json all unchanged).
+
+### Component mapping applied (per design §1)
+
+Applications shell: `Alert`→`Alert`+`AlertDescription`; `Chip`→`Badge variant="outline"`;
+`Card`/`CardContent` stat tiles → bordered `rounded-lg border bg-card` divs
+(per design §1 Paper row, which permits bordered-surface div **or** Card);
+`Grid`→responsive CSS grid (`grid-cols-2 md:grid-cols-4` counts,
+`md:grid-cols-2` libraries); `Stack`→`flex flex-col gap-*`; `Tab`→`TabsTrigger`;
+`Typography`→semantic text utilities.
+
+Dashboard: `Alert`→`Alert`+`AlertDescription`; `Button`→shadcn `Button` (Open=`default`,
+Edit=`outline`, Delete=`destructive` to preserve the `color="error"` cue);
+`Card`/`CardContent`→shadcn `Card`/`CardContent`; `Chip`→`Badge variant="outline"`;
+Dialog family→shadcn `Dialog`/`DialogContent`/`DialogHeader`/`DialogTitle`;
+the **delete-confirm flow now reuses the shared `ConfirmDialog`** (slice 2)
+instead of a raw MUI `Dialog`+`DialogFooter`; `Select`/`MenuItem`/`FormControl`/
+`InputLabel`→shadcn `Select` family (Type picker + Jellyfin machine switcher);
+`Switch`→shadcn `Switch` (`onCheckedChange`); `TextField`+`FormControlLabel`+
+`FormHelperText`→`Input`+`Label`+muted `
` (factored into a local `Field` helper);
+`Grid`→CSS grid; `Stack`→`flex`; `Typography`→text utilities.
+
+### Parity preserved
+
+- Dashboard shortcut CRUD (website/action/user types) — create/edit dialog,
+ open (website = `window.open`, action/user = `navigate`), edit, delete-confirm.
+- Jellyfin machine switcher (Select when >1 machine, Badge when 1, nothing when 0).
+- `NowPlaying` + `BackupDashboardWidget` composition unchanged (both reused as-is).
+- Shortcut deep-links (`/actions?task=…`, `/users?user=…`) are byte-for-byte
+ preserved; the page mounts at the reconciled `/media` route (App.tsx, slice 3,
+ untouched). No backend contract changes.
+- Applications tabs (Jellyfin/Nextcloud) + Jellyfin library counts grid preserved.
+
+### How the still-MUI Media child is handled
+
+`Applications.tsx` keeps `import { Media } from "./Media";` and renders
+`` **exactly as before** inside the Jellyfin tab. Only the Applications
+*shell* (tabs, library-counts grid, Nextcloud alert, header) was migrated.
+`pages/Media.tsx` is unchanged and still imports `@mui/x-data-grid`/`@mui/material`
+— that is expected and is removed in slice 7. The page compiles because Media.tsx
+is untouched; the slice-4 Applications test mocks the child (`vi.mock("../Media")`)
+so it does not pull the DataGrid into jsdom.
+
+### Commands run + gates
+
+- `grep -nE '@mui/(material|icons-material|x-data-grid)' Dashboard.tsx Applications.tsx`
+ → **BOTH-MUI-FREE**.
+- `npx tsc --noEmit` → exit 0.
+- `npm run build` → exit 0 (built in 1.07s).
+- `npm run lint` → exit 0 (0 errors; the 2 warnings are pre-existing in
+ `UsersPage.impl.tsx`, slice 6 — not this slice's files).
+- `npm test` (vitest) → exit 0 (**17 files / 34 tests** pass; +4 new page tests).
+- `npm run test:node` (`node --test tests/*.test.mjs`) → exit 0 (4/4).
+
+**Note on `node --test tests`:** the Slice 4 gate text and the overall-change
+exit gate list `node --test tests`, but on Node v22 that bare form resolves
+`tests` as a single CommonJS module (`Cannot find module '…/tests'`) and fails
+for **every** slice, including the pre-slice-4 baseline — it is a Node
+invocation quirk, not a regression. The package's canonical node-suite command
+is `npm run test:node` = `node --test tests/*.test.mjs`, which is green (4/4).
+The slice is therefore gate-green under the package's own scripts.
+
+### Deviations from design
+
+- Stat/library tiles in Applications use bordered `div` surfaces instead of
+ nested shadcn `Card`s — explicitly permitted by design §1 ("Paper → bordered
+ surface `
` **or** `Card`"). Keeps the already-Card-wrapped `SectionCard`
+ interior light and avoids heavy nested-card chrome.
+- Dashboard delete-confirm dialog switched from a raw MUI `Dialog`+`DialogFooter`
+ to the shared `ConfirmDialog` (slice 2). Behavior (title/message/confirm/cancel,
+ error cue) is identical and reuses an already-migrated shared component as the
+task instructs.
+
+### Slice boundary / PR
+
+Single slice, well under the 400-line budget: ~2 page rewrites (~430 inserted /
+~360 deleted across the two files) + 2 new test files (~150 lines). No 4a/4b
+split needed. The parent owns the commit/PR; nothing committed here.
+
+### Top risk for slice 5
+
+**`Settings.tsx` and `Actions.tsx`** are the form-heavy pair (18 + 19 MUI
+components each, incl. SSH-key management, SSH test/validation feedback,
+saved-task editor with machine selection + run history, danger-zone reset). The
+`ConfirmDialog`/`DialogFooter`/`HoverEditButton`/`SectionCard`/`SelectionRailCard`/
+`TabbedCard` reuse pattern is now proven (Dashboard reuses ConfirmDialog cleanly);
+the main slice-5 risk is preserving the controlled-`useState` form behavior + SSH
+validation messages without introducing a form library, and keeping the
+`@testing-library` tests exercisable without live SSH. Keep all form state as
+plain `useState`; mirror the Dashboard `Field` helper for `Input`+`Label`+
+helper-text triples.
+
+### Structured status note
+
+Overall change `applyState` is still reported **blocked** by the status engine
+(domain specs missing/partial; legacy flat `spec.md`). This does not block the
+Slice 4 migration itself — `design.md` §1 provided the authoritative component
+mapping and `actionContext` is `repo-local` with `allowedEditRoots` covering the
+workspace. Should be resolved before `sdd-verify`/archive, per the slice-3 note.
diff --git a/openspec/changes/web-ui-rework/tasks.md b/openspec/changes/web-ui-rework/tasks.md
index 8d30c9c..afcb25b 100644
--- a/openspec/changes/web-ui-rework/tasks.md
+++ b/openspec/changes/web-ui-rework/tasks.md
@@ -163,11 +163,11 @@ Each slice section restates this gate as its final task.
> `BackupDashboardWidget` from slice 3). Split 4a (Applications) → 4b (Dashboard) if
> over 400.
-- [ ] Migrate `frontend/src/pages/Applications.tsx` (Alert/Box/Card/CardContent/Chip/Grid/Stack/Tab/Typography → `Alert`/`Card`/`Badge`/responsive CSS grid/`Tabs`; Jellyfin library stats + Media tab preserved).
-- [ ] Migrate `frontend/src/pages/Dashboard.tsx` (20 MUI components: Alert/Box/Button/Card/CardContent/Chip/Dialog/DialogContent/DialogTitle/FormControl/FormControlLabel/FormHelperText/Grid/InputLabel/MenuItem/Select/Stack/Switch/TextField/Typography → shadcn `Card`/CSS grid/`Dialog`/`Select`/`Switch`/`Input`+`Label`/`Badge`; shortcut CRUD (website/action/users), machine picker, NowPlaying + BackupDashboardWidget composition, comfortable density).
-- [ ] Preserve the Dashboard → Media navigation and shortcut deep-links under the reconciled `/media` route.
-- [ ] Add component tests for the migrated Dashboard (shortcut create/save/delete flow) and Applications (library stats render).
-- [ ] **Exit gate:** Dashboard + Applications MUI-free and visually consistent; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
+- [x] Migrate `frontend/src/pages/Applications.tsx` (Alert/Box/Card/CardContent/Chip/Grid/Stack/Tab/Typography → `Alert`/`Card`/`Badge`/responsive CSS grid/`Tabs`; Jellyfin library stats + Media tab preserved).
+- [x] Migrate `frontend/src/pages/Dashboard.tsx` (20 MUI components: Alert/Box/Button/Card/CardContent/Chip/Dialog/DialogContent/DialogTitle/FormControl/FormControlLabel/FormHelperText/Grid/InputLabel/MenuItem/Select/Stack/Switch/TextField/Typography → shadcn `Card`/CSS grid/`Dialog`/`Select`/`Switch`/`Input`+`Label`/`Badge`; shortcut CRUD (website/action/users), machine picker, NowPlaying + BackupDashboardWidget composition, comfortable density).
+- [x] Preserve the Dashboard → Media navigation and shortcut deep-links under the reconciled `/media` route.
+- [x] Add component tests for the migrated Dashboard (shortcut create/save/delete flow) and Applications (library stats render).
+- [x] **Exit gate:** Dashboard + Applications MUI-free and visually consistent; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
---