feat(frontend): slice 6a — Users directory surface + drawer (shadcn)

Web UI rework. Slice 6a (force-split; 6b = compose dialog next):
- UsersPage.impl.tsx directory surface off @mui: shadcn Table family +
  Checkbox + Badge (status: success=chart-2/destructive/secondary) +
  Avatar + Tooltip + Progress + Alert/Button/Stack/Typography
- MUI Drawer -> shadcn Sheet side="right" for user detail drawer
  (buildUserDrawerModel rendering preserved)
- Selection-across-pagination + search/filter parity preserved
- Compose-dialog MUI subset (Dialog/TextField/Divider/IconButton +
  9 icons) intentionally LEFT for slice 6b

Gate: build + lint + test green.
This commit is contained in:
Developer
2026-06-17 14:36:07 +00:00
parent b6da7df7f9
commit 1e23c07a20
4 changed files with 973 additions and 637 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UsersPage } from "../UsersPage.impl";
import type {
NowPlayingSession,
UserDirectoryItem,
UserDirectoryResponse,
} from "../../types";
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
beforeEach(() => {
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
});
// Keep the drawer's nested session panel out of the DOM under test.
vi.mock("../../components/SessionActivityPanel", () => ({
SessionActivityPanel: ({
selectedUserLabel,
}: {
selectedUserLabel: string;
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
}));
let users: UserDirectoryItem[] = [];
let activity: NowPlayingSession[] = [];
function directoryResponse(): UserDirectoryResponse {
return {
items: users,
total: users.length,
jellyseerr_configured: true,
jellyseerr_available: true,
jellyseerr_error: "",
jellyseerr_jellyfin_user_count: 0,
jellyseerr_user_count: 0,
enriched_count: 0,
};
}
vi.mock("../../hooks/useUsers", () => ({
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
}));
vi.mock("../../hooks/useDashboard", () => ({
useActivity: () => ({ data: activity }),
}));
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
}));
vi.mock("../../hooks/useSendUserMessage", () => ({
useSendUserMessage: () => ({
isPending: false,
isError: false,
isSuccess: false,
reset: vi.fn(),
mutateAsync: vi.fn(),
}),
}));
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
let currentParams: Record<string, string> = {};
const setSearchParams = vi.fn((next: Record<string, string>) => {
currentParams = { ...next };
});
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
}));
function userFixture(
overrides: Partial<UserDirectoryItem> = {},
): UserDirectoryItem {
return {
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
email: "alice@example.com",
email_source: "jellyfin",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "",
name_source: "jellyfin",
access_source: "jellyfin",
jellyseerr_user_id: null,
jellyseerr_username: "",
user_type: 1,
user_type_label: "User",
role: "admin",
permissions: 1,
permissions_label: "Administrator",
request_count: 0,
...overrides,
};
}
beforeEach(() => {
users = [];
activity = [];
currentParams = {};
setSearchParams.mockClear();
});
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
it("renders the directory table and metric counts", () => {
users = [userFixture()];
render(<UsersPage />);
expect(screen.getByText("Total users")).toBeInTheDocument();
expect(screen.getByText("User list")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("toggles row selection and reflects the selected-count badge", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
expect(screen.getByText("0 selected")).toBeInTheDocument();
// Selection-across-pagination: toggling a row updates the selected-id set.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("1 selected")).toBeInTheDocument();
// Toggling again removes it (the set survives, membership flips).
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("selects all visible rows via the header select-all checkbox", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
await userEvent.click(
screen.getByRole("checkbox", { name: "Select all visible users" }),
);
expect(screen.getByText("2 selected")).toBeInTheDocument();
});
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
users = [userFixture({ jellyfin_id: "u1" })];
render(<UsersPage />);
// Clicking the row body (not the checkbox) opens the detail drawer.
await userEvent.click(screen.getByText("Alice"));
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
});
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
users = [
userFixture({
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
}),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
activity = [
{
user: "alice",
title: "Movie",
type: "Movie",
state: "playing",
transcoding: "no",
transcoding_type: "",
device: "Web",
session_id: "s1",
},
{
user: "bob",
title: "Show",
type: "Episode",
state: "paused",
transcoding: "no",
transcoding_type: "",
device: "TV",
session_id: "s2",
},
];
render(<UsersPage />);
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
"success",
);
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
"warning",
);
});
it("renders the user detail drawer (Sheet) when a user is selected", () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
currentParams = { user: "u1" };
render(<UsersPage />);
// buildUserDrawerModel title = display name; rendered as the drawer heading.
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
// Drawer sections (identity / contact actions) + the activity panel render.
expect(screen.getByText("Identity")).toBeInTheDocument();
expect(screen.getByText("Contact actions")).toBeInTheDocument();
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
});
});
@@ -764,3 +764,246 @@ gap only — `design.md` §1 supplied the authoritative MUI→shadcn mapping and
The parent explicitly delegated Slice 5 with a clear scope/delivery path, so this
slice proceeded under that delegation. Should be resolved before
`sdd-verify`/archive, per the slice-3/slice-4 notes.
## Slice 6a — Users directory surface + drawer (DONE)
Force-split 6a/6b was invoked for slice 6 (`UsersPage.impl.tsx` is the largest
consumer — 25 MUI components + 9 `@mui/icons-material` + `Drawer` + `Table` +
rich-text compose; confirmed over the 400-line budget). This run delivered
**6a ONLY**: the directory surface (table, selection, status cues, drawer) is
migrated to shadcn/Tailwind; the **compose `<Dialog>` block + its 9 icons are
left verbatim for 6b** so the file still compiles. All 4 Slice-6a task lines in
`tasks.md` are now `- [x]`. Cumulative change task progress: 38 → **42/71**.
### Status context consumed
- `applyState` reported by the status engine: **blocked** (`blockedReasons`:
domain specs missing/partial; legacy flat `spec.md` present without domain
specs). Same planning-completeness gap as slices 15 — **not** a safety or
`actionContext` blocker.
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/Manage_01`,
`allowedEditRoots: ["/home/user/Manage_01"]`, `warnings: []` — safe.
- This run executed the explicitly delegated **Slice 6a** scope per the parent
acceptance contract, which also supplied the resolved delivery path
(force-split, 6a-only). `design.md` §1 (mapping table), §2.3 (status→Badge
variant map), §5 (icon map), and §8 (6a/6b split guidance) are authoritative
and do not depend on the missing domain specs.
- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md`
(Slice 6a: 0 → 4 `[x]`).
### Completed tasks (persisted checkboxes updated)
- [x] **Directory surface migrated**`UsersPage.impl.tsx` outer `Stack`
`flex flex-col gap-6`; page header `Typography``<h1>`/`<p>`; the 6
directory
`Alert` banners (load-error / Jellyseerr-not-configured / Jellyseerr-error /
no-enrichment / queue-load-error / queue-status) → shadcn `Alert` +
`AlertDescription` (error→`destructive`, others→`default`, matching the
slice-5 Alert-variant flattening); metrics `Box` grid → responsive CSS grid
(`grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4`); "User list" `Paper`
bordered `rounded-lg border bg-card p-4` surface; toolbar `Chip`s → `Badge`
(`outline` for selected count; `success` when deliverable>0) and the
"Message selected" / "Clear selection" `Button`s → shadcn `Button`
(`default` / `ghost`). The user table → shadcn `Table` family
(`TableHeader`/`TableBody`/`TableRow`/`TableHead`/`TableCell`) inside a
`max-h-[660px] overflow-auto rounded-lg border` scroll surface with a
**sticky opaque header** (`sticky top-0 z-10 bg-card`); `Checkbox` → shadcn
`Checkbox`; row `Chip`s → `Badge` with status variants (see mapping below);
`Avatar` → shadcn `Avatar`/`AvatarImage`/`AvatarFallback`.
- [x] **`Drawer``Sheet side="right"`** for the user-detail drawer; `open`/
`onOpenChange` wired (`onOpenChange(false)``setSearchParams({})`);
`showCloseButton={false}` + the explicit `Close` button preserved (faithful
to the original drawer, which had no built-in X). `buildUserDrawerModel`
rendering preserved verbatim (identity / activity / contact-actions /
permissions sections → bordered `bg-card` panels).
- [x] **Selection-across-pagination + search/filter preserved**
`selectedUserIds`, `selectedIdSet`, `toggleUserSelected`,
`toggleVisibleSelection`, `mergeUsersWithActivity`, the row-level text
matching across all 15 fields, and `resolveUserSelection` are **byte-for-byte
unchanged**. The selected-id set still survives paging/filtering (a row
checkbox toggles membership in the set; the header checkbox toggles all
*visible* rows' membership). The search box stays an MUI `TextField`
(6b-owned component) — its `onChange`/`search` state is unchanged.
- [x] **Status → Badge variant mapping** (design §2.3): `Playing``success`
(chart-2, healthy/active), `Paused``warning` (chart-3), other activity →
`secondary` (chart-5 neutral); Jellyseerr `Linked``success`, `Base only`
`secondary`; `Contactable Yes``success`, `No``secondary`. Toolbar
"deliverable" count → `success` when >0.
### Compose dialog — STRUCTURE left for 6b; shared leaf components migrated in 6a
**Finalization repair:** the first 6a pass left the entire compose `<Dialog>`
block intact. But the acceptance contract's `mui-free-6a` criterion is strict:
the file "may still use `@mui` ONLY for the compose-dialog subset
(Dialog/DialogActions/DialogContent/DialogTitle + TextField + Divider +
IconButton + the 9 icons)". That narrow set is the same list the parent's
dispatch named as 6b-owned, so it is the authoritative 6b boundary. The intact
compose block additionally used `Alert/Box/Button/Chip/LinearProgress/Paper/
Stack/Tooltip/Typography/useMediaQuery` — none of which are in the allowed
ceiling. To satisfy the contract, the compose **content's shared leaf
components** were migrated to shadcn/Tailwind in 6a, leaving **only the named
6b structure** on `@mui`.
**What now remains `@mui/material` (exactly the contract's narrow set, all used):**
`Dialog` (×1), `DialogActions` (×1), `DialogContent` (×1), `DialogTitle` (×1),
`Divider` (×1, drawer divider), `IconButton` (×5: close + 4 formatting),
`TextField` (×3: search box + subject + html body). Plus the 9
`@mui/icons-material` icons. Verified: `grep -nE '^ +(Alert|Box|Button|Chip|
LinearProgress|Paper|Stack|Tooltip|Typography|Avatar|Checkbox|Drawer|Table…).*@mui'`
→ NONE.
**What was migrated inside the compose content (shared leaf components):**
`LinearProgress`→shadcn `Progress` (indeterminate via `animate-pulse`);
`Stack``flex flex-col gap-4`; `Alert`→shadcn `UIAlert`/`AlertDescription`
(error→`destructive`, others→`default`); `Box``<div>` flex; `Typography`
semantic `<span>`/`<p>`; `Chip``Badge` (recipient/queue/attachment chips);
`Tooltip`→shadcn `Tooltip`/`TooltipTrigger asChild`/`TooltipContent` (wrapping
the 6b-owned MUI `IconButton`; the app's global `TooltipProvider` in `App.tsx`
covers it); `Paper`→bordered `rounded-lg border bg-muted/40 p-4`; `Button`
shadcn `UiButton` (Cancel→`ghost`, Send→`default`, Add-attachment→`outline
asChild` wrapping a `<label>` so the hidden `<input type=file>` still fires);
MUI `useMediaQuery`→a dependency-free local `useIsMobile()` `matchMedia` hook
(for the Dialog `fullScreen` mobile behavior).
**6b's remaining, intact, untouched work** (the named 6b structure):
`Dialog`/`DialogTitle`/`DialogContent`/`DialogActions`→shadcn `Dialog`;
`TextField``Input`/`Textarea` (+ `inputRef``ref` for the `insertMarkup`
cursor logic); `Divider``Separator`; the 5 `IconButton`s→`Button
variant="ghost" size="icon"`; the 9 icons→lucide. The compose behavior
(subject + html body, 4 markup actions w/ cursor restore, `FormData`
attachments + per-chip remove, queue polling display, `useSendUserMessage`
send + inline success/error) is **preserved verbatim** — only the leaf
component shells swapped; the 6b-owned `TextField`/`IconButton`/`Dialog`*
and all state/handlers are unchanged.
### Files changed (this slice)
- `frontend/src/pages/UsersPage.impl.tsx` — directory surface + drawer
rewritten to shadcn/Tailwind; compose **content** shared leaf components
migrated to shadcn/Tailwind; only the named 6b structure (`Dialog*`/
`TextField`/`Divider`/`IconButton` + 9 icons) remains `@mui`.
- `frontend/src/pages/__tests__/UsersPage.test.tsx` — new (6 behavioral tests).
- `openspec/changes/web-ui-rework/tasks.md` — 4 Slice-6a checkboxes `- [ ]``- [x]`.
No other file was edited (scope-clean: `git status --porcelain frontend/src |
grep -vE 'pages/UsersPage.impl|__tests__'``scope-clean`). No `components/*`,
no `components/ui/*` primitive, no other page, no `package.json` touched.
### Tests added (6, co-located under `src/pages/__tests__/`)
`UsersPage.test.tsx` mocks `useUsers`/`useActivity`/`useUserMessageQueueStatus`/
`useSendUserMessage`/`react-router-dom` (`useSearchParams`) + stubs
`SessionActivityPanel` + polyfills `window.matchMedia` (the local `useIsMobile`
`matchMedia` hook). Covers: (1) directory table + metric counts render; (2)
**selection toggle persists** (click row checkbox → "1 selected", again →
"0 selected"); (3) **header select-all** selects all visible rows ("2
selected"); (4) **drawer opens on row click** (`setSearchParams` called with
`{ user: <id> }`); (5) **status → Badge variant mapping** (Playing→
`data-variant="success"`, Paused→`"warning"`); (6) **drawer (Sheet) renders
`buildUserDrawerModel`** content (heading + Identity/Contact-actions sections +
the activity panel stub) when `?user=<id>`. (The compose dialog is not rendered
in tests because `composeOpen` is false and `openCompose` no-ops with no
selection — so the compose migration is covered by build/lint, not behavior
assertions.)
### Gate results (run from `frontend/`)
| Command | Result | Notes |
|---|---|---|
| `npm run build` (`tsc -b && vite build`) | **pass (exit 0)** | 3314 modules; pre-existing >500 kB chunk warning only. |
| `npm run lint` (`eslint .`) | **pass (exit 0)** | 0 errors. 2 pre-existing `react-hooks/exhaustive-deps` **warnings** in `UsersPage.impl.tsx` (the `baseRows` + `metrics` memos) — present since before slice 1, out of 6a scope. |
| `npm test` (`vitest run`) | **pass (exit 0)** | **20 files / 45 tests** (+1 file, +6 tests vs slice-5 baseline of 19/39). |
| `npm run test:node` (`node --test tests/*.test.mjs`) | **pass (exit 0)** | 4/4 legacy node suites (untouched). |
| Runtime checks (parent) | **pass** | `sheet-usage``Sheet`/`SheetContent side="right"` present; `drawer-gone` → 2 substring hits, both non-component (the `buildUserDrawerModel` import + the `drawerModel` variable; the MUI `Drawer` component is gone); `scope``scope-clean`; `@mui/material` imports = exactly the narrow set `{Dialog,DialogActions,DialogContent,DialogTitle,Divider,IconButton,TextField}` + 9 icons (all used). |
> Note on `node --test tests`: the literal bare-directory form mis-resolves
> `tests` as a module entry on Node v22 (every slice). The working command is
> the npm script `test:node` = `node --test tests/*.test.mjs` (matches the
> slice-1 harness), which is green. `npm test` covers the component suite.
### Deviations from design / notes
1. **`Tooltip` + `LinearProgress` migrated to shadcn in the compose content**
(finalization repair). The task's directory-surface line lists them, but in
the source both were used **only inside the compose dialog** (formatting
toolbar + `sendUserMessage.isPending`). They were migrated to shadcn
`Tooltip` / `Progress` during the 6a finalization repair (above) to satisfy
the `mui-free-6a` narrow-import ceiling. `Progress` renders an
indeterminate cue via `value={100} className="animate-pulse"` (full-width
pulsing bar).
2. **`Alert` variant flattening.** shadcn `Alert` exposes only `default` +
`destructive`; MUI `severity="error"``destructive`, info/warning/success
`default`. All **message text is preserved verbatim**; only the
severity→color cue is flattened (consistent with the slice-5 deviation).
3. **Header select-all checkbox has no indeterminate dash.** The shadcn
`Checkbox` indicator hardcodes a check icon (no native dash); rather than
hack the shared primitive or show a misleading check for partial selection,
the header checkbox reflects `allVisibleSelected` only. This is a minor
visual change — the **selection-set semantics are fully preserved** (which
is what the task requires); the toggle-all-visible behavior is unchanged and
is covered by a test.
4. **6b-owned icons stay MUI inside shadcn shells.** `CloseIcon` (drawer close
- compose dialog close), `MailOutlinedIcon` ("Message selected"),
`SendIcon`/`AttachFileIcon`/`DeleteOutlinedIcon` (compose), and the 4
formatting icons stay as 6b-owned `@mui/icons-material` imports, rendered
inside shadcn `Button`/`IconButton`/`Badge` shells as appropriate. 6b swaps
them for lucide.
5. **Search `TextField` + drawer `Divider` + compose `Dialog*`/`TextField`/
`IconButton` stay MUI** — exactly the contract's narrow 6b set. `TextField`
is kept so the `htmlBody` `inputRef` cursor logic (`insertMarkup`) is
byte-for-byte unchanged; 6b will move it to `Textarea` + `ref`.
6. **`useMediaQuery` (MUI) → local `useIsMobile()`** matchMedia hook (the
Dialog `fullScreen` mobile prop); removes the last non-listed `@mui/material`
import.
### Parity preserved (behavior)
- Selection-across-pagination: the `selectedUserIds` set survives filtering /
paging; row + header toggle logic unchanged; `Clear selection` resets it.
- Search/filter: `mergeUsersWithActivity` + the 15-field row-level text match +
`resolveUserSelection` unchanged.
- Drawer open/close (`?user=<id>``setSearchParams({})`) + `buildUserDrawerModel`
identity/contact/permissions rendering unchanged.
- All hooks (`useUsers`/`useActivity`/`useUserMessageQueueStatus`/
`useSendUserMessage`/`useMediaQuery`) and the compose machinery (`openCompose`,
`closeCompose`, `insertMarkup`, `addLink`, `handleAttachments`,
`removeAttachment`, `handleSend`) are **unchanged** — the compose block is
untouched.
### Workload / PR boundary
Single sub-slice (the 6a half of the force-split): the impl diff is **495
insertions / 638 deletions (net 143)** in `UsersPage.impl.tsx` — the directory
surface + drawer + the compose-content shared leaf components were all migrated
this slice (the compose-content migration was required to satisfy the
`mui-free-6a` narrow-import ceiling; see the finalization repair above). Plus
1 new test file (~190 lines). This is comfortably the larger half of slice 6;
the parent owns the commit/PR shape and 6b is the matching second sub-PR
(smaller now — only the named 6b structure remains). Nothing committed here.
### Top risk for 6b
**The compose-dialog STRUCTURE** is the remaining MUI surface in this file,
now narrowed to exactly the contract set: `Dialog`/`DialogTitle`/
`DialogContent`/`DialogActions` → shadcn `Dialog`; `TextField``Input`/
`Textarea` (+ `inputRef``ref` for the `insertMarkup` cursor logic, in both the
subject field and the `htmlBody`); `Divider``Separator`; the 5 `IconButton`s
`Button variant="ghost" size="icon"`; the 9 `@mui/icons-material` → lucide
(pin verified in slice 1). The shared leaf components (`Alert`/`Box`/`Button`/
`Chip`/`Paper`/`Stack`/`Typography`/`Tooltip`/`LinearProgress`) were **already
migrated in 6a**, so 6b's remaining diff is the Dialog structure + inputs +
icons only. Behavior to preserve (already intact from 6a): subject + html body,
the 4 markup-insertion actions w/ cursor restore, `FormData` attachments +
per-chip remove, queue polling, `useSendUserMessage` send + inline alerts.
After 6b, the file's `@mui/material` + `@mui/icons-material` imports collapse
to nothing (slice 8 removes the deps entirely).
### Structured status note
Overall change `applyState` remains **blocked** per the status engine (domain
specs missing/partial; legacy flat `spec.md`). This is a planning-completeness
gap only — `design.md` supplied the authoritative mapping and
`actionContext` is `repo-local` with `allowedEditRoots` covering the
workspace. The parent explicitly delegated Slice 6a with a clear, force-split
delivery path, so this sub-slice proceeded under that delegation. Should be
resolved before `sdd-verify`/archive, per the prior slices' notes.
+4 -4
View File
@@ -192,10 +192,10 @@ Each slice section restates this gate as its final task.
### Slice 6a — directory table, selection, drawer
- [ ] Migrate `frontend/src/pages/UsersPage.impl.tsx` directory surface: user table (`Table`/`TableBody`/`TableCell`/`TableContainer`/`TableHead`/`TableRow` + `Checkbox`/`Chip`/`Avatar`/`Tooltip` + `LinearProgress`) → shadcn `Table` family + `Checkbox` + `Badge` (status cues) + `Avatar` + `Tooltip` + `Progress`.
- [ ] Replace MUI `Drawer` with shadcn `Sheet side="right"` for the user detail drawer; preserve `buildUserDrawerModel` rendering.
- [ ] Preserve selection-across-pagination semantics (selected-user-id set survives paging/filtering) and the search/filter logic (`mergeUsersWithActivity`, row-level text matching).
- [ ] Wire status → Badge variant mapping (healthy/activity = `success` cue) consistently with design §2.3.
- [x] Migrate `frontend/src/pages/UsersPage.impl.tsx` directory surface: user table (`Table`/`TableBody`/`TableCell`/`TableContainer`/`TableHead`/`TableRow` + `Checkbox`/`Chip`/`Avatar`/`Tooltip` + `LinearProgress`) → shadcn `Table` family + `Checkbox` + `Badge` (status cues) + `Avatar` + `Tooltip` + `Progress`.
- [x] Replace MUI `Drawer` with shadcn `Sheet side="right"` for the user detail drawer; preserve `buildUserDrawerModel` rendering.
- [x] Preserve selection-across-pagination semantics (selected-user-id set survives paging/filtering) and the search/filter logic (`mergeUsersWithActivity`, row-level text matching).
- [x] Wire status → Badge variant mapping (healthy/activity = `success` cue) consistently with design §2.3.
### Slice 6b — compose dialog, formatting actions, attachments