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).
This commit is contained in:
Developer
2026-06-26 20:11:02 +00:00
parent ec57eff59a
commit a8dfbd5dc6
45 changed files with 3441 additions and 4711 deletions
+186
View File
@@ -0,0 +1,186 @@
# Slice 1 Review — `mobile-responsive-parity` (Shared primitives)
Reviewer: fresh adversarial pass. Date: 2026-06-26.
Scope: primitives only (useIsMobile, MobileCardRow, SheetForm, HoverEditButton
extension, mobile-touch-target CSS, App.tsx refactor). No page-level changes.
## Commands run (all green)
| Command | Result |
|---|---|
| `cd frontend && npm run lint` | pass (0 errors; 2 pre-existing warnings in `UsersPage.impl.tsx`, untouched by this slice) |
| `cd frontend && npm run build` | pass (tsc + vite; 1975 modules) |
| `cd frontend && npm run test` | pass (25 files / 83 tests) |
No staged files (`git diff --cached` empty). Unstaged: App.tsx, HoverEditButton.tsx,
HoverEditButton.test.tsx, index.css. Untracked: useIsMobile.ts, mobile-card.tsx,
mobile-card.test.tsx, sheet-form.tsx, sheet-form.test.tsx.
---
## Correct (with evidence)
- **useIsMobile matches design.** `MOBILE_QUERY = "(max-width: 768px)"` is the
same query the old inline `App.tsx` code used; SSR guard added
(`typeof window !== "undefined"`); listener add/remove correct.
`frontend/src/hooks/useIsMobile.ts:4,12-23`.
- **App.tsx refactor is behavior-preserving.** The inline `useState`+`useEffect`
block is replaced 1:1 by `useIsMobile()`; `Sidebar` still receives the same
boolean and renders `null` when mobile (`App.tsx:110`, `isMobile``null`);
margin-left branch and `MobileDrawer`/`TopBar` untouched. `App.tsx:317-331`.
- **HoverEditButton default (`mobile="always"`) is correct and non-regressive.**
Default stack `md:opacity-0 md:transition-opacity md:duration-100 md:ease-out
md:group-hover:opacity-100` → always visible below `md`, hover-revealed at
`md:`+. Legacy `&:hover .rail-edit { opacity: 1 }` CSS in Actions/Settings
still resolves (specificity 0,2,0 beats the `md:opacity-0` utility 0,1,0), so
desktop hover-reveal is doubly guaranteed. `HoverEditButton.tsx:43-47`.
`mobile="hover"` restores the old `opacity-0 … group-hover:opacity-100`.
- **mobile-touch-target CSS is correctly scoped.** `@media (max-width: 767px)`
aligns exactly with Tailwind `md:` (min-width: 768px); the rule is unlayered
plain CSS so it outranks Tailwind's layered `min-h-*` utilities on mobile and
is inert at `md:`+. `index.css:113-118`.
- **SheetForm layout matches design.** Flex column (`flex h-[100dvh] … flex-col
gap-0 p-0`), header `shrink-0`, body `flex-1 overflow-y-auto`, footer
`shrink-0` — sticky achieved via flex, not `position: sticky` (correct, given
Radix Sheet uses transforms). `h-[100dvh]` not `h-screen`. Close (X) wired to
`onCancel`. `showCloseButton={false}` avoids a duplicate Radix close button.
`sheet-form.tsx:36-75`.
- **SheetForm accessibility.** Uses `SheetTitle` (satisfies Radix Dialog's
required title). `sheet-form.tsx:46-48`.
- **TypeScript / generics.** `MobileCardRow<T>` as a function declaration is
valid in `.tsx` (the `<T,>` disambiguation rule only applies to arrow
functions). No `any`; `MobileCardField<T>.render: (row: T) => ReactNode`.
Build is clean.
- **HoverEditButton tests guard the actual mechanism** (class composition), not
just rendering — asserts `md:opacity-0`/`md:group-hover:opacity-100` present
and standalone `opacity-0` absent for the default, and the inverse for
`mobile="hover"`. `HoverEditButton.test.tsx:22-40`.
- **SheetForm tests cover behavior**: save, cancel, close→onCancel, isPending
disables Save + shows "Saving…". `sheet-form.test.tsx`.
---
## Confirmed issues (must-fix before commit)
### B1 — Duplicate React keys in `MobileCardRow` (all rows share one key)
`frontend/src/components/ui/mobile-card.tsx:60` and `:75`:
```tsx
rows.map((row, index) => {
...
return <button key={primary?.key ?? index} ...>
```
`primary` is a **field descriptor**, so `primary.key` is the field name string
(e.g. `"title"`), not a row identifier. Every row therefore renders with the
same key (e.g. `key="title"`), producing React's "Encountered two children with
the same key" warning on every multi-row render. This is not caught by the
current tests (they don't assert on `console.error`).
Real-world impact: incorrect reconciliation — stateful controls rendered inside
the `actions` slot (or future per-card inputs) can attach to the wrong row after
edits/reorders. It also pollutes the console, which masks real warnings.
Minimal fix: key by `index` (these card lists are static, not animated/reordered):
```tsx
key={index}
```
Preferred fix for the later Users-selection slice: add an optional
`getRowId?: (row: T) => string` prop and fall back to `index`:
```tsx
key={getRowId?.(row) ?? index}
```
Either resolves the bug. The current `primary?.key ?? index` expression is never
the right value for a multi-row list.
---
## Suggestions (non-blocking)
### S1 — Dirty-state / outside-click confirm not addressed in SheetForm
Spec **R4.5** requires the Sheet to "not close on outside-click while the form
is dirty (confirm prompt)", and task **1.3** lists "Dirty-state confirm on
outside click" under the SheetForm slice. The shipped primitive forwards
`onOpenChange` straight to Radix, so Escape / overlay click closes immediately
with no confirm. Radix also fires `onOpenChange(false)` on Escape.
The design's SheetForm prop list does **not** include `isDirty`, so the design
intent appears to be consumer-side dirty handling (slices 68). That is
reasonable, but it means the task 1.3 wording is over-specified relative to the
design. Recommend either:
- (a) add an opt-in `isDirty?: boolean` (or `onInterceptClose?`) prop to
SheetForm and gate `onOpenChange`/Escape here, or
- (b) explicitly document in this slice that dirty-confirm is owned by each
form consumer and drop it from task 1.3.
Not a Slice-1 blocker (no form consumers exist yet), but resolve the
spec/task/design inconsistency before slices 68 land so R4.5 isn't silently
dropped.
### S2 — Missing test cases for MobileCardRow edge behavior
`mobile-card.test.tsx` covers the happy paths well, but gaps remain:
- **Empty `rows`** — no assertion that an empty list renders nothing / no crash.
- **No `primary` field** — code path at `mobile-card.tsx:60` (`primary ? … :
null`) is untested; a card with zero primary fields should still render the
`dl` stack without a title.
- **Duplicate-key regression guard** — once B1 is fixed, add an assertion
(e.g. `vi.spyOn(console, "error")`) that rendering ≥2 rows emits no
duplicate-key warning, so this class of bug is caught in future.
### S3 — `::before` variant of `mobile-touch-target` omitted
Design's CSS snippet also targeted `.mobile-touch-target::before` (for
padding-only hit-area expansion via a pseudo-element). Implementation only
targets `.mobile-touch-target`. Not needed for the current direct-on-button
usage, but if a later slice needs to enlarge a small badge's hit area without
growing its visual box, the `::before` rule will need adding. Track for slice 9.
### S4 — SheetForm missing `SheetDescription` (minor Radix a11y warning)
Radix Dialog emits a console warning when a `DialogDescription` is absent.
SheetForm renders a title but no description. Non-blocking (the form is still
operable), but adding `<SheetDescription className="sr-only">…</SheetDescription>`
(or `aria-describedby={undefined}` on the content) silences it. Consider for
slices 68 when real form bodies are wired.
### S5 — Boundary nuance between `useIsMobile` and `mobile-touch-target`
`useIsMobile` matches `max-width: 768px` (true at exactly 768px), while
`.mobile-touch-target` uses `max-width: 767px` (false at exactly 768px) to align
with Tailwind `md:` (min-width: 768px). At exactly 768px, `isMobile === true`
but touch-target sizing does not apply. This is pre-existing (the old App.tsx
used the same 768px query) and the design specifies both values explicitly, so
it is not a regression — just an inherent 1px seam. No action needed unless you
want to harmonize the hook to `max-width: 767px` in a follow-up.
---
## Per-task acceptance map
| Task | Status | Notes |
|---|---|---|
| 1.1 useIsMobile | ✅ | matches design; SSR-safe |
| 1.2 MobileCardRow | ⚠️ | **B1** duplicate keys; tests otherwise adequate |
| 1.3 SheetForm | ⚠️ partial | layout correct; dirty-confirm not implemented (S1) |
| 1.4 HoverEditButton extend | ✅ | default + legacy mode correct; desktop not regressed |
| 1.5 mobile-touch-target | ✅ | correctly scoped; `::before` deferred (S3) |
| 1.6 App.tsx refactor | ✅ | exact shell behavior preserved |
---
## Verdict: **fix-then-commit**
One confirmed must-fix (**B1**: duplicate React keys in `MobileCardRow`). It is
a one-line change (key by `index`, or add `getRowId`). After that fix and a
re-run of `npm run test`, Slice 1 is safe to commit. The suggestions (S1S5)
are non-blocking and can be tracked into the form/table slices where they
become relevant.