Delete the old top-level page files whose content was migrated into service-page tabs in slices 5-9: - pages/Media.tsx, Applications.tsx (-> MediaTab) - pages/FileBrowser.tsx, FileBrowser.impl.tsx (-> FilesTab) - pages/Actions.tsx (-> ActionsTab) - pages/Users.tsx, UsersPage.impl.tsx (replaced by Authentik tabs) - components/BackupsPage.tsx (-> JobsTab) - components/ObservabilityPage.tsx (split into Alerts/Links/Metrics tabs) - hooks/useUsers.ts (orphaned after Users page deletion) - the corresponding page test files (Media, FileBrowser, Applications, Actions, UsersPage) that tested the deleted pages directly. The service-tab components are the live implementations; ServicePage renders them. No live code references the deleted files. Docs: append an Information Architecture section to REQUIREMENTS.md documenting the services-as-hub model (nav shape, service-page tabs, service type registry, Users->Authentik, Observability split, legacy route 404s, empty state). Add a CHANGELOG entry under [Unreleased]. 92 frontend tests pass (was 112; -20 deleted page tests); 271 backend tests pass; lint/build green. Refs openspec/changes/services-as-hub-ia/ (tasks slice 11).
14 KiB
Slice 5 Review — Users + Backups mobile card layout
Change: mobile-responsive-parity · Slice: 5 (Users + Backups tables)
Reviewer mode: fresh adversarial · Date: 2026-06-26
Verdict: fix-then-commit (one real HTML-validity issue; everything else clean)
Commands run (all green)
| Command | Result | Notes |
|---|---|---|
npm run lint |
✅ 0 errors | Only 2 pre-existing react-hooks/exhaustive-deps warnings in UsersPage.impl.tsx (lines 149/188). Verified identical on HEAD (lines 120/159) — not introduced by this slice. |
npm run build |
✅ built | tsc -b typecheck clean (build runs it). |
npm run test |
✅ 25 files / 102 tests | All pass. |
git diff --cached --stat |
empty | No staged files. |
1. useIsMobile hardening — SAFE ✅
frontend/src/hooks/useIsMobile.ts: added typeof window.matchMedia === "function" to both the lazy useState initializer and the useEffect guard.
- In real browsers
window.matchMediais always a function, so the added predicate is alwaystrueand behavior is identical. - In jsdom (no native
matchMedia) the change converts a hardTypeError("window.matchMedia is not a function") into a gracefulfalse(desktop). This is strictly safer — it can only turn a crash into a non-crash. - All existing consumers (
App.tsx,Dashboard,Media,FileBrowser) already stubmatchMediain their test files, so their tests are unaffected. Confirmed no regression path for slices 1–4.
Conclusion: safe across all consumers; no regression.
2. UsersPage desktop Table — preserved EXACTLY ✅
I programmatically extracted the <Table aria-label="Users table">…</Table> block from HEAD and from the working tree and diffed them token-for-token.
- The only differences are line re-wrapping caused by deeper indentation (e.g. the
checked || selectedUser?.jellyfin_id === row.jellyfin_idexpression andonClick={() => setSearchParams({ user: row.jellyfin_id })}wrap onto more lines). Every token, attribute, and child is present in both. - Columns preserved (10): select-all checkbox header, User, Email, Activity, Type (hidden md), Jellyseerr, Role (hidden md), Permissions, Reqs (hidden md), Contact (hidden md).
- Header checkbox
toggleVisibleSelection— preserved. - Row checkbox
toggleUserSelected+onClick={(event) => event.stopPropagation()}+aria-label— preserved. - Row click
onClick={() => setSearchParams({ user: row.jellyfin_id })}— preserved. - Avatar (
AvatarImage/AvatarFallback), username fallback, all<Badge>variants,data-state="selected",cursor-pointer— all preserved.
Conclusion: the desktop branch is the original table re-indented one level deeper into the : ( else arm. No prop, column, or handler was dropped. Diff stat (207 ins / 149 del) is dominated by this re-indentation; the true behavioral delta is small (cards branch + userCardFields + useComposeViewport rename).
3. Compose hook rename — correct ✅
The file-local 900px useIsMobile was renamed useComposeViewport; the shared 768px useIsMobile (from hooks/) now drives the directory-table branch.
isComposeMobile(900px) → used only atUsersPage.impl.tsx:827for the composeDialogContentfull-screen class. Breakpoint unchanged ((max-width: 900px)).isMobile(768px) → used only atUsersPage.impl.tsx:511for the table/card branch.- Verified no stray references to the old local name remain (
grepconfirms 2 distinct symbols, correctly wired).
4. Backups cards (3 components) ✅
- BackupAlertsTable: mobile branch renders
MobileCardRowwith primary=message + severity/type/created; Ack action preserved in theactionsslot (mobile-touch-target, callsonAcknowledge(a.id); hidden whenacknowledged). Desktop<Table>block is byte-identical (diff is purely additive before thereturn). - BackupJobsTable: mobile branch builds
JobCardRow[](joinslatestRunsexactly as the desktop row does) with primary=name + source/schedule/last-status. No per-row action exists in the desktop original, so none is "lost". Desktop table unchanged. - BackupRunsTable: status-filter
<Select>is rendered outside theisMobile ? … : …ternary, so it stays available on both layouts (correct — filter preserved on mobile). Mobile card primary=job_id + status/duration/size/started. The desktop<Table>is re-indented into the: (else arm but content is identical (same 5 columns, same formatters, samestatusVariant).
Spec R3.2 (primary + 3–5 fields) satisfied for all three. Spec R3.6 (desktop unchanged) satisfied.
5. UsersPage mobile selection — INVALID HTML NESTING (confirmed issue)
MobileCardRow renders the card as a <button type="button"> whenever onRowClick is set (mobile-card.tsx:82). The UsersPage mobile branch passes both onRowClick (opens drawer) and an actions slot containing a Radix <Checkbox>, which itself renders a <button role="checkbox">. Result:
<button> <!-- card -->
…
<button role="checkbox"></button> <!-- selection checkbox -->
</button>
This is invalid HTML (<button> cannot contain interactive <button>).
The review brief asks whether this is "a real runtime bug or acceptable parity with the existing desktop pattern." Findings:
- The desktop-parity argument does not hold. On desktop the row is a
<TableRow>→<tr>withonClick. A<tr>is not a<button>, so nesting a checkbox inside it is valid. The mobile variant introduces a new<button>-in-<button>nesting that does not exist on desktop. - Runtime impact: browsers perform error-correction by closing the outer
<button>before the inner one starts. The 102 tests pass (jsdom does not enforce this), and in practice the card body still receives taps while the checkbox still toggles (withstopPropagation). So it functions — but only by accident of browser error-recovery. It is fragile, fails HTML validation, and is an a11y issue (nested interactive elements). - This pattern is not present in the other two card usages in this slice (BackupJobs/Runs pass no
onRowClick; Alerts passesactionsbut noonRowClick), so it is isolated to UsersPage.
Recommended fix (small, localized): in MobileCardRow, when onRowClick is set, render the outer element as a <div role="button" tabIndex={0} with onClick + onKeyDown (Enter/Space) instead of a <button>; or move the actions slot outside the clickable button element. Either keeps the 44px tap target and the stopPropagation semantics while producing valid HTML. This also improves on the desktop pattern rather than replicating its weakest aspect.
Severity: I am calling this must-fix before commit because (a) the brief specifically flagged it, (b) it is invalid DOM, and (c) the fix is tiny and contained to mobile-card.tsx (already shipped in Slice 1, so fixing it here benefits every future card consumer too).
6. Test quality
- BackupAlertsTable.test.tsx: new mobile tests assert primary text + Ack button round-trip (
onAcknowledgecalled with id). Real behavior. ✅ - BackupRunsTable.test.tsx: asserts job_id primary + Status/Duration labels on mobile. Does not exercise the status filter on mobile, but coverage is adequate. ✅
- UsersPage.test.tsx: asserts display-name primary + Activity label per card on mobile. Does NOT assert
toggleUserSelectedround-trip nor that the checkboxstopPropagationprevents the drawer opening — the two behaviors the brief specifically called out. The implementation is present and correct, but the assertions are missing. (Suggestion, not a blocker.) - BackupJobsTable: no test file exists, so the worker skipped it.
BackupJobsTable.tsxis a touched file with zero direct test coverage. The card logic mirrors the other two and is low-risk, but AC8 ("at least one Vitest test per touched page/component asserting <768 and ≥768") is not fully met for this component. (Suggestion.)
Minor: UsersPage.test.tsx:12 comment still says "MUI useMediaQuery (still used by the compose dialog, slice 6b)" — stale after the rename to useComposeViewport (no longer MUI). Cosmetic.
7. Diff size
UsersPage +207 / -149. Subtracting the re-indented desktop Table block (~149 deletions re-added as ~180 insertions one indent level deeper), the genuine behavioral delta is: userCardFields constant (~22 lines), the mobile MobileCardRow branch (~30 lines), the useComposeViewport rename (3 lines), and isComposeMobile usage. Confirmed: actual behavioral change is small; the bulk is re-indentation, as the brief expected.
Summary
- Blocker / confirmed issue (must-fix):
MobileCardRow+ UsersPage produce<button>nesting a Radix<button>checkbox — invalid HTML; "desktop parity" justification does not hold (desktop uses<tr>). Fix inmobile-card.tsx(render clickable card asdiv role="button"or liftactionsout of the button). - Suggestions (non-blocking):
- Add a UsersPage mobile test asserting
toggleUserSelectedround-trip + checkboxstopPropagation. - Add a
BackupJobsTablemobile test (currently zero coverage on a touched file). - Refresh the stale "MUI useMediaQuery" comment in
UsersPage.test.tsx.
- Add a UsersPage mobile test asserting
- Verified clean:
useIsMobilehardening (no regression), desktop UsersPage Table preserved exactly (token-identical), compose 900px breakpoint preserved, all 3 Backups desktop tables byte-identical, status filter + Ack action preserved on mobile, lint/build/test green, no staged files.
Verdict: fix-then-commit — resolve the single button-in-button HTML validity issue (localized to mobile-card.tsx), then this slice is good to commit. The two test-coverage suggestions can land in the same commit or a follow-up.
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Slice 5 implements Users + 3 Backups mobile card layouts per spec R3.1/R3.2/R3.3 and tasks 5.1/5.2/5.3 without widening scope (no backend, no new product behavior, no sm: breakpoint). Desktop layouts preserved exactly; only presentation-layer parity added."
}
],
"changedFiles": [
"frontend/src/hooks/useIsMobile.ts",
"frontend/src/components/BackupAlertsTable.tsx",
"frontend/src/components/BackupJobsTable.tsx",
"frontend/src/components/BackupRunsTable.tsx",
"frontend/src/components/__tests__/BackupAlertsTable.test.tsx",
"frontend/src/components/__tests__/BackupRunsTable.test.tsx",
"frontend/src/pages/UsersPage.impl.tsx",
"frontend/src/pages/__tests__/UsersPage.test.tsx"
],
"testsAddedOrUpdated": [
"frontend/src/components/__tests__/BackupAlertsTable.test.tsx",
"frontend/src/components/__tests__/BackupRunsTable.test.tsx",
"frontend/src/pages/__tests__/UsersPage.test.tsx"
],
"commandsRun": [
{
"command": "cd frontend && npm run lint",
"result": "passed",
"summary": "0 errors; 2 pre-existing react-hooks/exhaustive-deps warnings (verified identical on HEAD, not introduced by slice 5)."
},
{
"command": "cd frontend && npm run build",
"result": "passed",
"summary": "vite build + tsc -b typecheck clean; 1976 modules transformed."
},
{
"command": "cd frontend && npm run test",
"result": "passed",
"summary": "25 test files / 102 tests passed (vitest)."
},
{
"command": "git diff --cached --stat",
"result": "passed",
"summary": "Empty — no staged files."
}
],
"validationOutput": [
"useIsMobile guard safe: only changes jsdom (crash->false); real browsers unchanged; no regression to App/Dashboard/Media/FileBrowser consumers.",
"UsersPage desktop <Table> block token-diffed against HEAD: identical except line re-wrapping from deeper indentation; all 10 columns, header/row checkboxes, toggleVisibleSelection/toggleUserSelected, setSearchParams row click, Avatar, all Badge variants preserved.",
"Compose 900px breakpoint preserved via useComposeViewport rename; isMobile (768px) used only for table branch, isComposeMobile (900px) only for compose dialog.",
"BackupRunsTable status-filter Select rendered outside isMobile ternary -> preserved on mobile+desktop; BackupAlertsTable Ack action preserved in card actions slot; BackupJobsTable has no per-row actions to lose.",
"Desktop Backups tables byte-identical (Alerts/Jobs additive only; Runs re-indented into else arm, content equal)."
],
"residualRisks": [
"BLOCKER: MobileCardRow renders a <button> and UsersPage nests a Radix Checkbox (<button>) inside it when onRowClick is set -> invalid HTML (button-in-button). Functions via browser error-correction but fails validation and is an a11y issue. Desktop parity argument does not hold (desktop row is a <tr>, not a button). Fix in frontend/src/components/ui/mobile-card.tsx.",
"BackupJobsTable.tsx is a touched file with no test file -> zero direct coverage; AC8 not fully met for this component.",
"UsersPage mobile test does not assert toggleUserSelected round-trip nor checkbox stopPropagation (behaviors are implemented but untested).",
"Stale comment in UsersPage.test.tsx references 'MUI useMediaQuery' after the useComposeViewport rename (cosmetic)."
],
"noStagedFiles": true,
"diffSummary": "Slice 5 adds mobile MobileCardRow branches to UsersPage + 3 Backups tables, renames the local 900px compose hook to useComposeViewport to avoid collision with the shared 768px useIsMobile, and hardens useIsMobile with a typeof matchMedia guard. UsersPage diff is ~356 lines but mostly re-indentation of the preserved desktop Table into an isMobile ternary else branch; true behavioral delta is small.",
"reviewFindings": [
"blocker: frontend/src/components/ui/mobile-card.tsx:82 + frontend/src/pages/UsersPage.impl.tsx:511-530 — <button> card nests Radix <button> Checkbox (invalid HTML); fix MobileCardRow to render clickable card as div role=button or lift actions slot out of the button.",
"suggestion: add UsersPage mobile test for toggleUserSelected round-trip + stopPropagation.",
"suggestion: add BackupJobsTable mobile test (touched file, currently untested).",
"suggestion: refresh stale 'MUI useMediaQuery' comment in UsersPage.test.tsx:12."
],
"manualNotes": "Verdict: fix-then-commit. The single button-in-button issue is localized to mobile-card.tsx (a Slice-1 primitive) and benefits all future card consumers once fixed. Everything else (useIsMobile safety, desktop non-regression, compose breakpoint, Backups parity, lint/build/test) is verified clean."
}