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).
8.7 KiB
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 inlineApp.tsxcode 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+useEffectblock is replaced 1:1 byuseIsMobile();Sidebarstill receives the same boolean and rendersnullwhen mobile (App.tsx:110,isMobile→null); margin-left branch andMobileDrawer/TopBaruntouched.App.tsx:317-331. - HoverEditButton default (
mobile="always") is correct and non-regressive. Default stackmd:opacity-0 md:transition-opacity md:duration-100 md:ease-out md:group-hover:opacity-100→ always visible belowmd, hover-revealed atmd:+. Legacy&:hover .rail-edit { opacity: 1 }CSS in Actions/Settings still resolves (specificity 0,2,0 beats themd:opacity-0utility 0,1,0), so desktop hover-reveal is doubly guaranteed.HoverEditButton.tsx:43-47.mobile="hover"restores the oldopacity-0 … group-hover:opacity-100. - mobile-touch-target CSS is correctly scoped.
@media (max-width: 767px)aligns exactly with Tailwindmd:(min-width: 768px); the rule is unlayered plain CSS so it outranks Tailwind's layeredmin-h-*utilities on mobile and is inert atmd:+.index.css:113-118. - SheetForm layout matches design. Flex column (
flex h-[100dvh] … flex-col gap-0 p-0), headershrink-0, bodyflex-1 overflow-y-auto, footershrink-0— sticky achieved via flex, notposition: sticky(correct, given Radix Sheet uses transforms).h-[100dvh]noth-screen. Close (X) wired toonCancel.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). Noany;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-100present and standaloneopacity-0absent for the default, and the inverse formobile="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:
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):
key={index}
Preferred fix for the later Users-selection slice: add an optional
getRowId?: (row: T) => string prop and fall back to index:
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 6–8). 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(oronInterceptClose?) prop to SheetForm and gateonOpenChange/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 6–8 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
primaryfield — code path atmobile-card.tsx:60(primary ? … : null) is untested; a card with zero primary fields should still render thedlstack 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 6–8 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 (S1–S5)
are non-blocking and can be tracked into the form/table slices where they
become relevant.