Files
manage/.pi-tmp/slice1-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

8.7 KiB
Raw Blame History

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, isMobilenull); 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:

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 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.