# Slice 2 — Dashboard mobile layout (worker output) ## Files changed | File | Status | Lines | |------|--------|------| | `frontend/src/pages/Dashboard.tsx` | modified | +128 / -7 | | `frontend/src/pages/__tests__/Dashboard.test.tsx` | modified | +178 / -2 | **Total: ~306 changed lines** (299 insertions, 7 deletions). Under the 400-line budget. ## What was implemented ### 2.1 — Single-column grid below md Below `md` (`isMobile === true`), widgets render inside `MobileWidgetSections` which uses `grid grid-cols-1 gap-4`. At `md` and above, the existing flat `visibleWidgets.map(...)` renders unchanged — no grid wrapper is introduced on desktop (preserving the exact previous behavior). ### 2.2 — Section anchor bar Below `md`, a horizontally scrollable pill bar (`MobileWidgetSections` anchor bar) groups widgets by section. Clicking a pill calls `document.getElementById(...).scrollIntoView({ behavior: "smooth", block: "start" })`. Each section renders with `scroll-mt-16` so the sticky TopBar doesn't cover the heading. **Section-to-widget mapping:** - **Observability** (Activity icon): service-bound widgets whose service_type is `alertmanager`, `prometheus`, or `grafana`. - **Media** (Monitor icon): service-bound widgets whose service_type is `jellyfin`. - **Backups** (DatabaseBackup icon): built-in widgets with `widget_kind === "backups"`. - **Custom** (LayoutDashboard icon): built-in `static`, `ssh_tasks`, `nextcloud`, and any unmatched widget. Section order: Observability → Media → Backups → Custom. Empty sections are not rendered. Icons match the existing nav (`App.tsx` `navItems`): Activity for Observability, Monitor for Media, DatabaseBackup for Backups. ### 2.3 — Tests Extended `Dashboard.test.tsx` with 3 new tests (6 total, all passing): 1. **Mobile renders single column with anchor bar**: verifies Observability/Media/Backups sections appear, Custom does NOT (empty section hidden), all widgets render. 2. **Desktop hides anchor bar**: verifies no section headings or pills at desktop width. 3. **Anchor pill jumps via scrollIntoView**: spies on `Element.prototype.scrollIntoView`, clicks the Media pill, asserts the spy was called. **matchMedia mock**: Added `setMatchMedia(matches: boolean)` helper that stubs `window.matchMedia` for the `(max-width: 768px)` query. Called in `beforeEach` with `false` (desktop default). Each mobile test calls `setMatchMedia(true)`. ## Validation ``` npm run lint → 0 errors, 2 pre-existing warnings (UsersPage.impl.tsx, unrelated) npm run build → ✓ built in 855ms (tsc -b + vite) npm run test → 25 files / 89 tests passed (was 86; +3 new) ``` ## Deviations from design 1. **Desktop path preserved as bare map (no grid wrapper)**. The design pseudocode said `grid grid-cols-1 md:grid-cols-*`. The actual existing desktop code has no grid — it's a flat `visibleWidgets.map(...)` inside a `flex flex-col gap-4` parent. The task explicitly said "preserve whatever the current code does" and "do NOT change desktop behavior". Adding a grid wrapper (even `grid-cols-1`) around the desktop path would be a structural change. So the `isMobile` branch renders `MobileWidgetSections` (which has its own `grid grid-cols-1`) on mobile, and the bare map on desktop. Desktop DOM is byte-for-byte identical to before. 2. **Section headings (`