# Milestone 9: React SPA admin UI (PLAN §14, §3.14)

Goal: the complete admin UI — ten pages per PLAN.md:560, login, pause control, restart banner — built with Vite + React + TypeScript + Tailwind + TanStack Router/Query, embedded in the binary via the existing `-Dweb-dist` pipeline, with a frontend CI job.

Ground truth: PLAN.md:118-120 (auth UX), :138-140 (stack + embedding), :145/:579 (CI),
:454-456 (live view), :531 (restart banner), :560-562 (pages + requirements), :639 (size
budget), :641 (upstream health in UI); specs/milestone-8.md W8/W9 As-built (asset pipeline, SPA fallback, --web-dev); src/web/openapi.yaml (the API contract — authorative for every body shape; do NOT guess fields, read it).

## Rulings (binding)

1. **Stack per PLAN.md:138, not house style**: Vite + React 19 + TypeScript + Tailwind v4 (CSS-first config, no tailwind.config.js) + TanStack Router + TanStack Query. The reference repos use StyleX and no TanStack; PLAN is the source of truth here. SPA, no SSR, base `/`.
2. **Location**: everything under `web/` (PLAN.md:234): `web/package.json`, `web/src/`, `web/index.html`, build output `web/dist/` (gitignored). `web/dist-placeholder/` stays untouched and stays the `-Dweb-dist` default.
3. **Toolchain**: node >= 24 (`engines`), npm with committed `web/package-lock.json`. Dependencies exact-pinned (house style). Checks: `tsc --noEmit`, Prettier (house config: tabs, tabWidth 4, printWidth 120, semi, double quotes, trailing commas all), oxlint (zero-config), vitest + @testing-library/react + jsdom for tests. No ESLint, no Biome, no msw — tests stub `fetch`/`EventSource` by hand.
4. **No charting dependency.** The dashboard chart is a hand-rolled SVG component (stacked bars for queries/blocked/cached over the fixed zero-filled buckets, axis labels, hover tooltip via native `<title>` + a hover state). The data is small (60-168 buckets) and fixed-shape; a chart library is a liability with no payoff. This is complete, not a stub: axes, tooltip, empty state, responsive width.
5. **Router**: code-based route tree (no file-based codegen plugin). Every data route uses a TanStack Router loader that primes TanStack Query (`ensureQueryData`) — PLAN.md:562 "route loaders for initial fetch". Pending/error components on every route; TanStack Query handles cache/retry (no retry on 4xx; retry 429 after Retry-After).
6. **API layer**: one `src/lib/api.ts` typed client over `fetch` with `credentials: "same-origin"`. Types in `src/lib/types.ts` transcribed by hand from openapi.yaml (snake_case preserved; no codegen dependency). Error model: `ApiError{status, message, retryAfter?}` from the `{error}` envelope; 503 rendered distinctly from 500 ("server starting/degraded" vs "internal error").
7. **Auth flow**: `/login` route outside the app shell. On any 401 the query layer redirects to `/login?redirect=<path>`. Login POST with `auth_required=false` in the response → auth is off → navigate straight in (the login page short-circuits by probing once). Logout button in the shell (hidden when auth is off). Session state lives in a tiny auth store fed by responses, not a poll. Works with auth on AND off (PLAN.md:562) — with auth off the SPA never shows login.
8. **Pause control is global**: a shell-header widget (pause/resume with duration presets 60s/5m/30m/indefinite, countdown from `until`, `paused` disambiguates null `until`). Not a page.
9. **Live log page**: `EventSource` on `/api/queries/live`; ring buffer of 500 rows in memory, newest first; rows keyed by a monotonically increasing client counter (the SSE payload has NO id). On `error`/close the browser auto-reconnects (server sends `retry: 3000`); on reconnect the page re-syncs the gap via `GET /api/queries` (since = last seen ts) and shows a "stream resumed, N missed" notice. Pause-stream button (client-side freeze) included. 429 from the SSE cap → visible "too many live viewers" state, retry button.
10. **Query log page**: keyset pagination exactly per contract (`limit`, `before`, follow `next_before` until null; newest first). Filters: domain substring, client exact, blocked tri-state, since/until (datetime-local inputs → unix seconds). "Load more" appends; filter change resets the cursor.
11. **Settings page**: sections rendered from the GET envelope; a diff-based PUT sends ONLY changed fields (partial patch per contract). Every key is restart-required today: after a successful PUT of anything except `web.password`, set a persistent "restart to apply" banner (dismiss resets on next change; PLAN.md:531). `web.password` is a write-only field with confirm input; on change the API kills all sessions — the SPA expects the next request to 401 and routes to login. `web.auth_enabled` is read-only derived. `web.password_hash` is never sent.
12. **Blocklists page** shows per-source `skipped_regex_count` (PLAN.md:38) and source status (state, last_success, counts, last_error). "Update now" calls `POST /api/blocklists/update`, REPLACES list state from the 202 snapshot, disables the button while in flight.
13. **Dashboard**: stats totals + timeseries chart (period picker 1h/24h/7d/30d), upstream health table (PLAN.md:641), disk card from `/api/health` (state/free/db/log bytes) with the warning banner when `disk.state != "ok"` (PLAN.md:465), plus queries_dropped/writer_failed indicators. Auto-refresh via Query `refetchInterval` 30s (health 10s).
14. **Groups page** includes the group↔sources assignment editor (`PUT /api/groups/{id}/sources`, full-set checkboxes) and safe_search toggle. Default group (id 1) shows but blocks rename/delete client-side too (server 409s).
15. **Clients page**: table of all clients (`hand_edited` badge), edit name/group, no create (rows appear from DNS activity — say so in the empty state), delete with "re-materializes on next query" note. Client-prefixes editor on the same page: whole-list editing per the PUT contract.
16. **Local DNS page**: two tabs (records, forward zones), CRUD forms per contract. **Domain lookup page**: domain + group select → renders the full pipeline verdict (local_records, forward_zone, blocked/reason/matched/source_url, safe_search_rewrite).
17. **Errors and loading**: every route has skeleton/pending UI and an error boundary with the ApiError message + retry (PLAN.md:562). Mutations surface 400/409 messages inline at the form, 429 with countdown, 401 via the global redirect.
18. **Responsive**: sidebar nav collapses to a top bar + drawer under `md:`; tables get `overflow-x-auto` wrappers; the dashboard grid stacks. Desktop and mobile per PLAN.md:562 — no separate mobile pages.
19. **Formatting/l10n**: timestamps rendered in the browser locale from unix seconds (`Intl.DateTimeFormat`), byte counts humanized (KiB/MiB/GiB), µs durations shown as ms with one decimal. One `src/lib/format.ts`, tested.
20. **Size budget**: PLAN.md:639 — stripped static binary < 15 MB with assets (< 10 MB without → SPA budget ≈ 5 MB embedded, plain + .gz both count). React+TanStack+ Tailwind lands far under that; CI asserts the final cross binaries < 15 MB.
21. **Zig side is frozen.** No changes to src/, build.zig, tools/, or openapi.yaml. If the SPA reveals an API bug, STOP and report — do not work around silently. The only Zig-adjacent deliverables are ci.yml additions and `.gitignore` entries.
22. **CI**: new `frontend` job (setup-node@v4, `NODE_VERSION: "24"` env pin, `cache: npm`, `cache-dependency-path: web/package-lock.json`; `npm ci` → `prettier --check` → oxlint → `tsc --noEmit` → `vitest run` → `vite build`), uploading `web/dist` as an artifact is NOT needed — instead the existing `cross` job gains: setup-node, `npm ci && npm run build` (prefix web), then `zig build cross -Dweb-dist=web/dist`, then the existing static assert plus a size assert (< 15 MB per exe). The `test` job stays Zig-only.
23. **Accessibility floor**: semantic elements, labeled inputs, focus-visible styles, buttons not divs. No ARIA deep-dive beyond what semantics give.
24. **No new pages, no dark-mode toggle bikeshed** (Tailwind default palette, system `prefers-color-scheme` via CSS only), no i18n framework, no state library beyond TanStack Query + two tiny stores (auth, restart-banner) in React context.

## Sessions

F1 first (scaffold), then F2 (api/lib) sequential on F1, then F3-F8 parallel (pages; disjoint files), then F9 (CI + embed + smoke) after all.

---

## Session F1: scaffold + shell

Owns: `web/package.json`, `web/package-lock.json`, `web/index.html`, `web/vite.config.ts`, `web/tsconfig*.json`, `web/.oxlintrc.json` (only if needed), `web/src/main.tsx`, `web/src/routes.tsx` (route tree with lazy page imports and placeholder page stubs F3-F8 replace), `web/src/shell/` (layout, sidebar/topbar nav, `PauseWidget` SLOT — an import of `../features/pause/PauseWidget` that F8 fills; F1 ships the real widget file with a disabled placeholder), `web/src/styles.css` (tailwind), `.gitignore` additions (web/dist, web/node_modules), Prettier config in package.json (house values), scripts: dev/build/typecheck/lint/format/test.

Vite: `@vitejs/plugin-react`, tailwind v4 via `@tailwindcss/vite`, build target baseline-widely-available (vite 8 default), no proxy needed for build; dev proxy `/api` + `/metrics` → `http://127.0.0.1:8080` for `vite dev` against a running nxdns. Route tree: `/login` bare; shell routes `/`, `/queries`, `/live`, `/clients`, `/groups`, `/blocklists`, `/rules`, `/local-dns`, `/lookup`, `/settings`.

Acceptance: `npm ci && npm run build` produces `web/dist` with `/index.html`; typecheck/lint/format clean; placeholder pages render; nav works with keyboard.

### F1 As built

Pinned: react 19.2.8, @tanstack/react-router 1.170.18, @tanstack/react-query 5.101.4, vite 8.1.5, typescript 6.0.3, tailwindcss 4.3.3 (@tailwindcss/vite), vitest 4.1.10, @testing-library/react 16.3.2, jsdom 29.1.1, prettier 3.9.6, oxlint 1.75.0. All exact. ~/.npmrc enforces min-release-age=7 (newest gate-clearing versions chosen) and ignore-scripts=true (works; native bins are optionalDependencies). TS6 deprecates baseUrl — tsconfig.app.json uses `paths` without it. Dist: 344K (main chunk 278.5 kB / 88.2 kB gz, 11 lazy page chunks, CSS 8.8 kB). `zig build -Dweb-dist=web/dist` exit 0.
- Alias `@/*` → `web/src/*` (tsconfig paths + vite resolve.alias).
- Router: code-based in src/routes.tsx; `createAppRouter(history?)` exported (tests pass createMemoryHistory); `Register` declared so `Link to` is typed; pages wired via lazyRouteComponent at stable paths — every page keeps a default export at its path; page sessions never edit routes.tsx. Shell = pathless layout route id "shell"; /login hangs off root outside it; defaultPreload "intent".
- Shell: AppShell renders PauseWidget (default export, no props) from features/pause/PauseWidget, header right; F8 replaces the file in place. VersionFooter inside AppShell.tsx (F2 wires /api/version). Restart banner has no premade slot — F8 mounts it in AppShell (sequential edit). Nav: aria-current + activeProps; drawer under md: with aria-expanded/controls.
- Vitest lives in vite.config.ts (jsdom, globals: true; tsconfig types include vitest/globals). lint = `oxlint src vite.config.ts`; prettier ignores dist/, dist-placeholder/, package-lock.json (.prettierignore).
- Page filenames: QueryLogPage/LiveLogPage per F4 naming; LoginPage placeholder in src/auth/ (F2 replaces).

---

## Session F2: API layer + auth + query plumbing

Owns: `web/src/lib/` (`api.ts`, `types.ts`, `format.ts`, `queryClient.ts`, `queries.ts` — queryOptions per resource, mutation helpers with invalidation), `web/src/auth/` (store, `LoginPage`, 401 redirect wiring), tests for api/format/ pagination/settings-diff helpers.

types.ts transcribed from src/web/openapi.yaml — every shape the pages consume (QueryRow, QueriesPage, StatsTotals, Timeseries, Lookup, UpstreamHealth, Health, Version, Group, Blocklist, SourceStatus, Rule, LocalRecord, ForwardZone, Client, ClientPrefix, Upstream, SettingsEnvelope, Pause, Login). ApiError per ruling 6; 401 hook per ruling 7; 429 retry per ruling 5; settings diff builder per ruling 11 (pure function, tested).

Acceptance: vitest green; typecheck clean; login/logout round trip works against a live `nxdns run` (manual smoke; document the transcript).

### F2 As built

lib/{types,api,queryClient,queries,format,settingsDiff}.ts + tests (22 green); auth/{store.tsx,LoginPage.tsx}; routes.tsx gained context+loaders+default pending/error components; main.tsx providers; AppShell gained logout button + live VersionFooter. Live-server smoke verified every consumed shape with auth on AND off; no API bugs. Main chunk 319 kB (100 kB gz).
- api.ts: `request<T>` core (same-origin, 204→void, `{error}` envelope, Retry-After on 429), `requestText`, functions for all 55 route-method pairs, list envelopes unwrapped to arrays, `liveQueriesUrl` for F4.
- queryClient: staleTime 30s; no retry on 4xx except 429 (max 2, delay=retryAfter); QueryCache+MutationCache onError → `/login?redirect=<path+search>` on 401 (skipped on /login).
- queries.ts factories: healthQuery (10s refetch), versionQuery, statsQuery/ timeseriesQuery(period), upstreamHealthQuery (30s), queriesQuery(filter), lookupQuery(domain, groupId?), groupsQuery, groupSourcesQuery(id), blocklistsQuery, rulesQuery, localRecordsQuery, forwardZonesQuery, clientsQuery, clientPrefixesQuery, upstreamsQuery, pauseQuery, settingsQuery. Mutations: `xxxMutation(queryClient)` → useMutation options; invalidation map in the F2 report; NOTE `blocklistsUpdateNowMutation` seeds `queryKeys.blocklistSources` from the 202 snapshot — the ONLY feed for source status (no GET exists); F6 reads that key.
- Auth store: `useAuth()` → {authRequired: bool|null, probe(), login(password), logout()}. Probe = POST login with empty password (no status GET), StrictMode-deduped; authRequired mirrored in sessionStorage `nxdns_auth_required`.
- Loaders prime: dashboard stats+timeseries("24h")+health+upstreamHealth; queries `queriesQuery({})` (F4 "load more" calls api.getQueries imperatively and appends); clients clients+prefixes+groups; groups groups+blocklists; rules rules+groups; local-dns records+zones; lookup groups; settings settings; live none. Pages use the same factory the loader primed (useSuspenseQuery/useQuery).
- Router error component is ApiError-aware (503 "starting or degraded", 429 countdown, ≥500 generic); Retry = router.invalidate().
- Page-test pattern: wrap in AuthProvider+QueryClientProvider, pass the same qc to `createAppRouter(history, qc)`, stub fetch per-URL (see AppShell.test.tsx).
- api.ts sends `{}` on bodyless POSTs (logout, blocklists/update) — belt-and-braces over the W9 server fix.

---

## Session F3: Dashboard + SVG chart

Owns: `web/src/features/dashboard/` (page, StatCards, TimeseriesChart (SVG, ruling 4), UpstreamHealthTable, DiskCard, HealthBanners) + chart unit tests (bucket→bar math, empty state).

## Session F4: Query log + Live log

Owns: `web/src/features/queries/` (QueryLogPage, filters, cursor pagination per ruling
10) and `web/src/features/live/` (LiveLogPage per ruling 9: EventSource wrapper hook with injected EventSource for tests, ring buffer, gap re-sync, freeze, cap-hit state). Tests: ring buffer, gap-resync math, EventSource hook with a fake.

## Session F5: Clients + Groups

Owns: `web/src/features/clients/` (table, edit dialog, prefixes editor per ruling 15) and `web/src/features/groups/` (list, create/rename/delete, safe_search, sources assignment per ruling 14).

## Session F6: Blocklists + Rules

Owns: `web/src/features/blocklists/` (ruling 12) and `web/src/features/rules/` (table with group/kind/action columns, create form with pattern kind select, delete).

## Session F7: Local DNS + Lookup

Owns: `web/src/features/local/` (records + zones tabs, CRUD forms) and `web/src/features/lookup/` (ruling 16).

## Session F8: Settings + Pause widget

Owns: `web/src/features/settings/` (ruling 11: section forms, diff PUT, restart banner store + banner component mounted in the shell via F1's slot, password flow) and `web/src/features/pause/PauseWidget.tsx` (REPLACES F1's placeholder; ruling 8). Tests: diff builder edge cases (nested partial, password only, no-op), banner logic.

### F3-F8 As built (page wave)

All six sessions green; 100 web tests total after the wave; main chunk 318 kB (99 kB gz), pages as lazy chunks.
- **F3 Dashboard**: chartLayout.ts pure layout (layoutTimeseries, niceTicks 1/2/5) + TimeseriesChart({data}) — self-measuring SVG stacked bars (blocked/cached/other, other = queries−blocked−cached clamped ≥0), sr-only data table, role="img". Axis ticks use a short Intl formatter (formatTime is tooltip/sr-only only). UpstreamHealthTable shows total_failures; success_rate ×100 (0..1 verified in pool.zig). HealthBanners: disk warn/critical (critical text per PLAN.md:466), writer_failed, queries_dropped>0, all role="alert". 12 tests.
- **F4 Query/Live**: QueryLogPage exports QueryCells/QueryTableHead/BlockedCell, reused by LiveLogPage (both F4-owned). qtype.ts names 19 codes, TYPE<n> fallback. Filters in component state (not URL). ringBuffer.ts: 500 newest-first + mergeGap (dedup key ts|domain|client_ip|qtype|blocked|upstream — SSE rows have no id; same-second identical queries can over-dedup, accepted at household scale). useLiveQueries hook (injected EventSourceLike factory + fetchSince): states connecting/open/retrying/capped, gap re-sync getQueries({since, limit:500}) — gaps >500 replace the buffer wholesale; CAP_ERROR_THRESHOLD=3 consecutive errors → capped state (EventSource cannot see 429; message says cap OR unreachable), open resets the counter. Freeze = display-only snapshot; the ring keeps filling; Resume swaps to current. 25 tests.
- **F5 Clients/Groups**: prefixEditor.ts pure reducer (priority "" omitted → server default 100); baseline resets only on save/discard (dirty edits never clobbered by refetch). clientUpdateMutation takes {id, edit} (spec prompt said input — code wins). Groups: safe_search toggle PUTs with unchanged name (server 409s only on actual rename of default — verified in groups.zig); default group protections client-side + visible note; sourceSet.ts toggle/sameSet. 18 tests.
- **F6 Blocklists/Rules**: rule enums confirmed kind=[exact,wildcard], action=[allow,block]. Status section reads queryKeys.blocklistSources via queryClient.getQueryData at render (no GET exists; mutation setQueryData re-renders); edit PUT preserves is_suggested. 3 tests.
- **F7 Local/Lookup**: rtype=[A,AAAA,CNAME], ttl optional default 300. SPEC CORRECTION: Lookup.local_records is a BOOLEAN (ruling 16 said "list" loosely) — rendered Yes/No; verdict priority local > blocked > forwarded > allowed (pipeline order per lookup.zig), verdictOf exported. Local mutations live, no banner. 4 tests.
- **F8 Settings/Pause**: patchRequiresRestart(patch) exported — banner raised for any patch except bare web.password; restartBanner.ts is a useSyncExternalStore MODULE store (accepted deviation from ruling 24's "context": keeps the AppShell edit to import + mount). Number inputs NaN-guard blocks Save. PauseWidget: refetchInterval 5s only while paused, 1s countdown, formatRemaining exported. 16 tests.
- **Orchestrator integration**: the wave produced three near-identical inline-error components (clients+groups InlineError, local FormError) — hoisted post-wave to `src/lib/InlineError.tsx` (the richer variant: 400/409 verbatim, 429 countdown, 503 distinct, non-Error → "could not reach"), seven imports updated, duplicates deleted. Dashboard's InlineError (query-level, onRetry) is a different component and stays local. Full chain re-verified green after the hoist.

---

## Session F9: CI + embed + smoke (after F3-F8)

Owns: `.gitea/workflows/ci.yml` (ruling 22). Steps: verify `npm ci` reproducibility, frontend job, cross-job additions with `-Dweb-dist=web/dist` + size assert. Smoke (report transcript): `npm run build`; `zig build -Dweb-dist=web/dist`; boot; curl `/` returns the SPA index (not the placeholder); a deep link (`/settings`) returns 200 index; an asset serves gzip with ETag/304; login via browser-shaped curl flow still works; SIGTERM 0. Also `zig build test` and `-Dintegration` still green (the Zig tests embed the dist too — W10 static tests must still pass against the SPA dist; if one pins placeholder content, report it, do not edit Zig).

### F9 As built

ci.yml +77/-5: top-level `NODE_VERSION: "24"`; new `frontend` job (setup-node@v4, cache npm on web/package-lock.json; npm ci → format:check → lint → typecheck → `npm test` → build); `cross` job builds the SPA then `zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe` — RULED deviation: build.zig has no strip option and Debug exes are 87/32 MB (25.6 MB stripped), so the PLAN:639 budget is only meaningful for release builds; PLAN:50 calls these release binaries. Size assert strips COPIES via binutils (zig objcopy --strip-all is unimplemented in 0.16; aarch64 needs binutils-aarch64-linux-gnu, apt-installed conditionally). Measured ReleaseSafe: raw ~24.2 MB each; stripped x86_64 5,501,624 bytes (PASS < 15 MB), aarch64 PT_LOAD total 4.87 MB (~5 MB stripped, asserted in CI). `test` job stays Zig-only on the placeholder dist. Smoke (embedded SPA): / serves the vite index (not the placeholder), /settings deep link 200, main chunk served gzip with ETag then 304 on If-None-Match, login round trip (401 → cookie → 200 → logout → 401), SIGTERM exit 0. Finding, fixed post-session under an orchestrator ruling (narrow Zig-freeze exception): the W8 test at static.zig:413 pinned placeholder strings and failed with any real dist — rewritten dist-agnostic (structural asserts: /index.html exists, text/html, gz siblings have base entries, quoted etags, no duplicate paths). Import CLI note: the config file is positional on `nxdns import` (no --config flag).

---

## Module layout (new)

web/{package.json, package-lock.json, index.html, vite.config.ts, tsconfig*.json}, web/src/{main.tsx, routes.tsx, styles.css}, web/src/shell/*, web/src/lib/*, web/src/auth/*, web/src/features/{dashboard,queries,live,clients,groups,blocklists, rules,local,lookup,settings,pause}/*.

## File ownership

F1 scaffold+shell+routes; F2 lib+auth; F3-F8 exactly their feature dirs (routes.tsx lazy imports point at stable paths F1 fixes up-front, so page sessions never edit routes.tsx; F8 alone replaces the PauseWidget file F1 created); F9 ci.yml. Orchestrator: spec, integration wiring if any. Parallel sessions never share a file.

## Acceptance (milestone complete)

- [ ] `npm ci`, format/lint/typecheck/vitest, `vite build` all green in web/.
- [ ] All ten pages implemented per rulings; login + pause + restart banner work.
- [ ] `zig build cross -Dweb-dist=web/dist` green; exes < 15 MB; W10 suite still green.
- [ ] F9 smoke transcript: SPA served embedded, deep link 200, gzip+ETag, SIGTERM 0.
- [ ] CI has the frontend job and the cross-job embed per ruling 22.
- [ ] Spec As-built synced per session.

## Review (Codex, As built)

Round 1: 7 important + 4 minor, all fixed.
- auth: AuthProvider probes once on mount when authRequired is null (dedupe kept; sessionStorage fast path, probe overwrites); logout swallows ONLY 401 — other errors rethrow and AppShell's LogoutButton renders them via InlineError, navigating only on success; LoginPage 429 is a ticking lockout (submit + Enter disabled until zero).
- queries: load-more generation counter — filter apply/clear increments; then/catch/ finally discard stale completions (no old-filter rows, no stale cursor); imperative 401s route through `handleUnauthorized` (newly exported from lib/queryClient).
- live: gap-resync 401 → handleUnauthorized (never resyncFailed); entering capped fires a one-shot injectable probeSession (default GET /api/pause) so an expired session redirects to login instead of reading as "capped".
- settings: fieldset disabled while the PUT is pending (no silently dropped edits); errors via InlineError.
- pause: mutation errors rendered via InlineError in both branches (were silent); stale error resets on pause-state flip.
- blocklists/rules: all mutation errors via InlineError (429 countdown); form error props are Error|null now.
- Ripple: the auth mount probe legitimately fires POST /api/auth/login before page fetches — LocalDnsPage.test's first-POST assertion narrowed to match by URL. After fixes: 24 files / 120 web tests green; Zig suite with the fresh dist 0 failed.

Round 2: 1 important + 1 minor, both fixed. (a) keepPreviousData left the OLD filter's cursor clickable during the placeholder window — loadMore and the button now bail on base.isPlaceholderData (chosen over isFetching so background refetches of the current key stay usable); regression test proves no new-filter/old-cursor request. (b) safeRedirect accepted backslash network paths — now `/^\/(?![/\\])/.test(raw) && !raw.includes("\\")`, with unit cases.

Round 3: no findings. Final: 121 web tests green.

## Anti-requirements

No SSR, no chart library, no codegen (openapi→TS), no msw, no ESLint, no i18n, no dark-mode toggle, no WebSocket, no service worker/PWA, no Zig changes, no docs/api rendering (Phase 10), no DoH/DoT UI (Phase 9 adds settings sections it already has).
