Files
manage/.pi-tmp/slice5-review.md
T
Developer a8dfbd5dc6 Cleanup: delete dead top-level pages + update docs (Slice 11)
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).
2026-06-26 20:11:02 +00:00

190 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.matchMedia` is always a function, so the added predicate is always `true` and **behavior is identical**.
- In jsdom (no native `matchMedia`) the change converts a hard `TypeError` ("window.matchMedia is not a function") into a graceful `false` (desktop). This is strictly safer — it can only turn a crash into a non-crash.
- All existing consumers (`App.tsx`, `Dashboard`, `Media`, `FileBrowser`) already stub `matchMedia` in their test files, so their tests are unaffected. Confirmed no regression path for slices 14.
**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_id` expression and `onClick={() => 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** at `UsersPage.impl.tsx:827` for the compose `DialogContent` full-screen class. Breakpoint unchanged (`(max-width: 900px)`).
- `isMobile` (768px) → used **only** at `UsersPage.impl.tsx:511` for the table/card branch.
- Verified no stray references to the old local name remain (`grep` confirms 2 distinct symbols, correctly wired).
---
## 4. Backups cards (3 components) ✅
- **BackupAlertsTable:** mobile branch renders `MobileCardRow` with primary=message + severity/type/created; **Ack action preserved** in the `actions` slot (`mobile-touch-target`, calls `onAcknowledge(a.id)`; hidden when `acknowledged`). Desktop `<Table>` block is byte-identical (diff is purely additive before the `return`).
- **BackupJobsTable:** mobile branch builds `JobCardRow[]` (joins `latestRuns` exactly 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** the `isMobile ? … : …` 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, same `statusVariant`).
Spec R3.2 (primary + 35 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:
```html
<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>` with `onClick`. 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 (with `stopPropagation`). 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 passes `actions` but no `onRowClick`), 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 (`onAcknowledge` called 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** `toggleUserSelected` round-trip nor that the checkbox `stopPropagation` prevents 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.tsx` is 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 in `mobile-card.tsx` (render clickable card as `div role="button"` or lift `actions` out of the button).
- **Suggestions (non-blocking):**
- Add a UsersPage mobile test asserting `toggleUserSelected` round-trip + checkbox `stopPropagation`.
- Add a `BackupJobsTable` mobile test (currently zero coverage on a touched file).
- Refresh the stale "MUI useMediaQuery" comment in `UsersPage.test.tsx`.
- **Verified clean:** `useIsMobile` hardening (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.
```acceptance-report
{
"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."
}
```