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

187 lines
8.7 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 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.