a8dfbd5dc6
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).
187 lines
8.7 KiB
Markdown
187 lines
8.7 KiB
Markdown
# 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 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` (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 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 `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 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.
|