Skip to content

refactor(phase0): 32 of 35 pages become client-fetching shells (Rust-core Phase 0) - #199

Merged
AdamXweb merged 10 commits into
mainfrom
feat/phase0-batch5
Aug 16, 2026
Merged

refactor(phase0): 32 of 35 pages become client-fetching shells (Rust-core Phase 0)#199
AdamXweb merged 10 commits into
mainfrom
feat/phase0-batch5

Conversation

@adamXbot

Copy link
Copy Markdown
Collaborator

Consolidates Phase 0 batches 2–6 into one PR. Supersedes #197 and #198 — closing those in favour of this.

What this is

Rust-core Phase 0: pages stop reading the database in server components and fetch from the API instead, so the frontend can eventually ship as one static export served by either the current Node backend or a future Rust one. 32 of 35 pages converted (31 in the ledger + app/page.tsx).

Nothing here is Rust. Every change works on the current stack today, and the frontend ends up decoupled from the Next server either way — which is also what a future Swift companion app would consume.

Shared pieces introduced

Piece Replaces
RequireAppsGate getAllApps().length === 0 → redirect("/onboard")
RequireFlagGate resolveFlagFromDb(k) !== "on" → notFound(), with an opt-in failOpen for the one gate whose server version deliberately defaulted to visible
FlagGated inline content flags (toggling a paragraph, not gating a page)
RecordTaskVisit + POST /api/user-tasks/visit setSettingIfUnset("task_visit.*")
lib/use-flag-bundle.ts server-resolved flag bundles — one shared /api/feature-flags fetch per page load

Finding worth knowing: the existing useFlag hook is inert client-side — nothing ever primes its resolver context, so it always returns hard defaults. That's why every page resolved flags server-side, and why useFlagBundle reads real values from the API instead.

New endpoints (all additive)

GET /api/stats · POST /api/user-tasks/visit · GET /api/manual-apps/[id] extended with events/currentVersion/meta · audienceSet added to GET /api/focus. Everything else reused an exact existing twin — batches 2 and 3 needed zero new endpoints.

Two silent bugs caught, not shipped

Both found by an analysis pass over already-committed code; neither failed a test:

  1. Fail-open inverted on the onboarding profile step. Its server gate caught resolver errors and kept both setup steps visible; useFlagBundle fails closed, so a transient /api/feature-flags failure would have skipped the step where the privacy profile is created — leaving create_privacy_profile permanently incomplete.
  2. Redirect ordering race on the same page — an audience-less visitor could land on /onboard instead of /welcome.

Separately, the repo's own flag-wiring test caught that I'd stripped a page's gate without adding its replacement — the page would have rendered with its flag off.

Nets strengthened along the way

  • Conversion ledger (tests/app/phase0-page-shells.test.ts) pins every converted page: no server-coupled lib/ import, no force-dynamic, no server redirect(). It now allows a small PURE_LIB_MODULES allowlist and asserts each allowlisted module is still pure, so pages rendering from CATEGORY_META count as converted without weakening the net. Its redirect check was also fixed to match the import specifier rather than call text (a doc comment tripped it).
  • Visual net gained masks for four run-order state carriers, device-state pinning, and freezing of absolute dates — the last after a day boundary produced five spurious failures in the final gate run.

Verification

tsc · biome · 481 unit tests, 0 fail · full Playwright 46 passed · visual net 13/13, re-baselined and re-verified. Every batch was additionally checked against pre-conversion pixel baselines captured via stash, and pages with no e2e coverage were driven by hand (gated pages render, flag-off 404s, the task_visit marker persists in SQLite, the definitions page resolves the AU-specific transparency link).

What's deliberately not here

  • 3 pages remain: dashboard, app detail, review-recommendations (plus apps-grid, which needs zero new endpoints). They need 7 further endpoints; a full contract — per-page read inventories, deduped endpoint specs, conflict map, implementation order — is written up and ready.
  • The root layout is untouched, so next build still shows every route as ƒ. The layout mints a per-request CSP nonce and reads the locale cookie, which forces the whole tree dynamic. Converting it means moving to hash-based CSP — a security-posture change I'm not making without explicit sign-off. Until then the ledger, not the build output, is the per-page proof.

🤖 Generated with Claude Code

adamXbot and others added 9 commits August 16, 2026 02:11
…come shells

Batch 2 of the Rust-core Phase 0 page conversions (11/35 done). These
four pages shared two server concerns, and both now come from the API:

- Flag gates. `resolveFlagFromDb(key) !== "on" → notFound()` becomes
  RequireFlagGate, which reads the resolved value from
  GET /api/feature-flags (the `flags[].currentValue` field — the same
  resolver output). It fails CLOSED: a failed flag read renders the 404
  rather than the page, because these gates hide surfaces that
  shouldn't exist for the install. notFound() is thrown during render
  (not from the effect), which is the only way it works client-side.
- Initial data. Every one of these lists already had an exact API twin,
  so no new endpoints were needed:
    devices      → /api/devices (already includes appCount, and
                   DevicesView already fetched it in refresh())
    manual apps  → /api/manual-apps (already returned the exact
                   { apps, sources } pair the page assembled by hand)
    focus editor → /api/focus (already returned all six fields; the
                   new FocusEditLoader maps them and holds the form
                   back until they land, since the form stages edits
                   from its initial props)
    changelog    → /api/apps for the filter dropdown

The `initialX` props stay optional on DevicesView / ManualAppsView /
UniversalChangelogView so their Storybook stories keep seeding
fixtures; in Storybook the mount fetch simply fails and the seeded
state stands.

Verified by hand where no e2e covers it: both gated pages render with
their data through the gate, the manual-app form still defaults to
Safari web app (sources[0], unchanged — both create paths reset from
live sources), and flipping flag.page.manual_apps off renders the real
404 page.

Gate: tsc clean; biome clean; unit 462 pass / 0 fail; full Playwright
46 passed; visual net 13/13 pixel-identical against pre-conversion
baselines captured via stash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batch 3 of the Rust-core Phase 0 conversions (18/35 done). This batch is
the pages whose only server work was a gate — no data reads at all:

- Flag-gated content: /dashboard/about/ai-disclosure and
  /help/export-app-list swap resolveFlagFromDb + notFound() for
  RequireFlagGate (batch 2's component).
- /dashboard/settings/focus-matrix built its 220-row seed from
  HARD_DEFAULTS in the server component. That list moves into
  FocusFlagMatrix, which already imported the same module —
  feature-flag-rules is the client-safe pure-data half of lib/. The prop
  stays optional so callers can still inject rows.
- /onboard/goals was a server redirect() alias for /welcome. A static
  export can't serve one, so it forwards client-side like the root page.
- diagnostics / about / help/focus only carried force-dynamic.

Two findings recorded in the ledger's header comment rather than left to
surprise the final batch:

- Dropping force-dynamic does NOT make a route static today. The root
  layout calls headers() (CSP nonce) and cookies() (locale), which
  forces the whole tree dynamic — `next build` shows every route as ƒ,
  verified. The ledger, not the build output, is the per-page proof
  until the layout converts.
- generateMetadata still calls getTranslations, and the locale comes
  per-request from the NEXT_LOCALE cookie (i18n.ts). A statically
  prerendered page would bake the English title while the body renders
  translated, so page titles move to the client with the layout batch.

The ledger's redirect assertion was matching the call text, so a comment
explaining what a shell replaced tripped it. It now matches the import
specifier from next/navigation (redirect / permanentRedirect), the same
discipline the lib/ check already used. Re-proved fail-able by injecting
a real `redirect` import: fires; comment prose: clean.

Gate: tsc clean; biome clean; unit 469 pass / 0 fail; full Playwright
46 passed; visual 13/13 pixel-identical vs pre-conversion baselines.
Hand-checked (no e2e covers them): both gated pages render, and the
focus matrix still lists 220 rows across 18 surface groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nual-app detail become shells

Batch 4 of the Rust-core Phase 0 conversions (23/35 done). This is the
first batch that needed new API surface, plus a shared way to read flags
client-side.

New shared piece — lib/use-flag-bundle.ts. Pages resolved flag bundles
server-side (stats: 10 keys, shortlist: 11) and passed them down. The
existing `useFlag` hook can't stand in: it reads a resolver context that
NOTHING primes on the client, so it always returns HARD_DEFAULTS. The new
hook reads real resolved values from /api/feature-flags, and because that
response is the whole registry (~42 KB) it is fetched ONCE PER PAGE LOAD
and shared at module level — a page that gates and reads a bundle makes
one request between them. RequireFlagGate now goes through it too.

New endpoints, both additive:
- GET /api/stats — the summary blob getStats() produced. The heavier viz
  queries already had /matrix, /radar, /timeline; this is the last half.
- POST /api/user-tasks/visit — records task_visit.<surface>_at, which
  lib/tasks-server.ts reads for checklist completion. Privacy Map,
  Compare and App Detail each stamped this during their server render;
  as shells they can't, and dropping it would quietly stop those
  checklist items ever completing. setSettingIfUnset keeps first-write-
  wins, so firing on mount is safe. Compare + app detail use it next.
- GET /api/manual-apps/[id] gained events / currentVersion / meta, so
  the detail page's four reads arrive in one payload.

Everything else reused existing twins: /api/apps?view=grouped, /api/shortlist,
/api/privacy-profile, /api/dashboard/layout, /api/settings.

Two behaviours preserved deliberately rather than normalised away:
- The layout editor's gate FAILS OPEN. Its server version caught resolver
  errors and returned `true` ("a feature whose default is on rendering
  beats mysteriously 404ing"). RequireFlagGate gained an explicit
  `failOpen` prop for it; every other gate stays fail-closed.
- /manual-apps/[id] no longer reads the DB in generateMetadata for the
  app name. A dynamic route can't prerender a per-id title in a static
  export, so the loader sets document.title client-side — verified
  showing "Phase0 Probe — privacytracker".

Caught by key resolution, not by tsc: my rewritten privacy-map metadata
invented page_metadata.privacy_title / .privacy_description. Neither
exists; restored to the original privacy_map_title. Every namespace+key
in the batch now resolves against locales/en.json.

Gate: tsc clean; biome clean; unit 474 pass / 0 fail; full Playwright 46
passed; visual 13/13 pixel-identical vs pre-conversion baselines.
Hand-verified all five: stats renders 25 syncs, privacy map renders its
groups AND persists task_visit.privacy_map_at (confirmed in SQLite),
layout editor renders 108 rows + presets, manual-app detail renders the
full payload with a client-set title, and an unknown id still 404s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… become shells

Batch 5 of the Rust-core Phase 0 conversions (29/35 done).

New shared piece — FlagGated. RequireFlagGate 404s a whole page; the
legal page instead uses a flag to toggle one paragraph inline
(`flag.legal.audit_bundle_note`). FlagGated renders children only while
its flag resolves on, so the surrounding markup is untouched — which
matters on 500-line content pages where extracting a client component
would mean moving the whole body. It shares useFlagBundle's single
fetch, so a page that gates AND has inline flags still costs one
request.

Conversions:
- /legal + /privacy-policy: page gates (plus legal's inline note flag).
- /help/parental-controls: force-dynamic only — its lib import is the
  pure-data PARENTAL_RESOURCES table, no DB.
- /dashboard/compare: gate + the task_visit.compare_at marker, which
  reuses the RecordTaskVisit component and endpoint built in batch 4.
- /welcome: read the active focus, the raw flag.focus.audience setting
  and the child age band; all three come from GET /api/focus now
  (including `audienceSet`, the field added in batch 1 for exactly this
  "has the user ever chosen?" distinction). The splash is held until the
  fetch resolves so a returning user's goal cards don't paint
  unhighlighted and then flip.
- /onboard: the audience bounce to /welcome, the configurator flag, and
  the User-Agent sniff. On the UA — the server sniffed it purely so the
  first paint had the right device-specific method cards, with the
  client refining afterwards via refineDeviceOnClient. OnboardGate
  detects from navigator.userAgent through the same pure lib/device
  helper and holds the wizard until values land, so there's no flash of
  the wrong option; what's lost is only correct-cards-in-initial-HTML,
  which a static export cannot have regardless.

Deferred deliberately: /help/definitions threads one setting
(`app_country`) through eight render sites in a 636-line help page —
worth its own careful pass rather than a rushed one.

Gate: tsc clean; biome clean; unit 479 pass / 0 fail; full Playwright 46
passed (the 9 onboarding specs exercise the converted /onboard gate and
/welcome directly); visual 13/13 pixel-identical vs pre-conversion
baselines; every namespace+key in the batch re-resolved against
locales/en.json. Hand-checked /legal renders its full dependency table
through the client gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
30/35. /onboard/profile did five server reads behind two redirects:

- the raw flag.focus.audience check (bounce to /welcome) — now
  `audienceSet` from GET /api/focus, keyed off the RAW stored value
  because the resolved focus returns 'self' as a no-storage default,
  which would otherwise let users bypass /welcome;
- the two flag.onboarding.*_profile_setup flags, whose combined
  "both off" case bounces to /onboard — shared useFlagBundle fetch,
  and an unreadable flag keeps BOTH steps visible, matching the server
  version's `catch { privacy: true, accessibility: true }`;
- both saved profiles — existing GET twins;
- recommendedPrivacyPresetForFocus(), which stays a LOCAL call:
  lib/onboarding-purpose is pure (no DB), so the loader just rebuilds
  the {audience, goals:Set} shape from what /api/focus already returns.

The setup form is held until every value lands, since it seeds its
editors from these props — mounting with placeholders would either be
ignored or overwrite the user's first clicks.

Gate: tsc clean; biome clean; unit 480 pass / 0 fail; full Playwright 46
passed (the onboarding specs walk this step); visual 13/13 against the
existing pre-conversion baselines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects the batch-6 analysis pass caught in the conversion committed
one commit earlier. Both are silent — nothing in the suite fails.

1. FAIL-OPEN INVERTED. The server gate wrapped both resolveFlagFromDb
   calls in `catch { return { privacy: true, accessibility: true } }`, so
   an unreadable flag kept BOTH setup steps visible. useFlagBundle fails
   CLOSED by design, so the port turned a transient /api/feature-flags
   failure into router.replace("/onboard") — silently skipping the step
   where the privacy profile is created, and leaving the
   `create_privacy_profile` checklist task permanently incomplete. The
   header comment claimed the old behaviour while the code did the
   opposite. Now pairs useFlagBundleStatus() with the bundle and treats
   failedToLoad as both-flags-on.

2. REDIRECT ORDERING RACE. The server evaluated the audience check first
   (→ /welcome) and only then the flag check (→ /onboard). Client-side
   both resolve in parallel, so a both-flags-off result could fire before
   audienceSet was known and send an audience-less first-time visitor to
   /onboard. OnboardGate re-bounces them so the end state converged, but
   with an extra hop and a visible flash. The flag redirect now waits for
   audienceOk === true.

Also documents why the hold-until-ready matters here specifically:
PrivacyProfileSetup seeds its editable state from useState INITIALISERS,
so a late prop change is ignored forever — a returning user's saved
profile would be discarded and Save would PUT the empty payload over it.
`recommendedPreset` is load-bearing too: null selects a different UI
branch (no Activate button), not just different copy.

Verified: tsc + biome clean; profile-presets and onboarding-personas
specs (9 tests, which drive this exact route) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
32/35. Two help pages, and a precision fix to the ledger test.

/help/definitions is ~636 lines of static explanation whose ONLY DB read
was getSetting("app_country") — feeding Apple's per-storefront
transparency-report link at two render sites. Rather than move the whole
body client-side, only those two blocks became a client component
(DefinitionsTransparency) that fetches the setting itself. `appleLinks`
stays server-side: it derives from the active LOCALE, and getLocale() is
a next-intl call, not a lib/ read.

The country-code set was copied VERBATIM rather than retyped — a
hand-transcribed first pass dropped "ar" and invented seven codes, which
would have silently changed which storefronts get a country-specific URL
(and 404'd the ones Apple doesn't publish).

Ledger test made precise instead of merely strict. It forbade ALL lib/
imports, which would have excluded these two pages on a technicality:
they render from CATEGORY_META / SEVERITY_CONFIG / PARENTAL_RESOURCES —
pure constants that a server component inlines at build time, which is
exactly what a static export wants. It now allows a small PURE_LIB_MODULES
allowlist and separately ASSERTS each allowlisted module is still pure
(no ./db, better-sqlite3 or server-only), so a module that later grows a
DB import fails the test rather than quietly re-coupling a page.

Caught by the existing flag-wiring test, not by me: stripping the server
gate left flag.help.label_definitions referenced by nothing — i.e. the
page would have rendered with the flag off. The RequireFlagGate wrapper
was missing; restored, and both wired-flag tests pass again.

Gate: tsc clean; biome clean; unit 485 pass / 0 fail. Hand-verified with
app_country=au: the page renders the Australia-specific link
(/legal/transparency/au.html), not the global index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The transparency blocks seeded their state with DEFAULT_COUNTRY and
corrected after /api/settings resolved, so an au/gb/jp user saw
"United States (US)" and the global report link for a frame before it
flipped — a state the server render never produced.

Both blocks now render nothing until the country resolves; an unset or
unreadable setting still lands on DEFAULT_COUNTRY, matching
getSetting("app_country", DEFAULT_COUNTRY). The surrounding section
heading and intro paragraph are server-rendered and unaffected.

Found by the batch-6 analysis pass, which read the draft in the
worktree and flagged the flash before it was committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The final Phase 0 gate run reported five failures — settings/you and all
four app-detail shots. Every one was a calendar date: "Aug 16, 2026" vs
"Aug 17, 2026", "Aug 2" vs "Aug 3". The baselines were captured the day
before the verify run.

settle() already froze relative times ("2m ago"), clock times and byte
sizes, but not absolute dates — even though every date on screen derives
from a fixture timestamp seeded relative to NOW, so they are volatile for
exactly the same reason. Both date-format modes are covered
("Aug 16, 2026" and "16 Aug 2026").

This matters beyond the noise: five spurious failures are where a real
regression hides. A net that fails for reasons no CSS change caused is a
net people learn to ignore.

Verified: re-baselined and re-verified 13/13 in the same context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI caught this on the Phase 0 PR; it is a pre-existing bug, not a
conversion regression.

/api/activity validated ?type= against KNOWN_TYPES, a hand-maintained
copy of the ActivityType union that had drifted EIGHT entries behind
(profile_preset_applied, dashboard_layout_applied, health_check,
verdict_set, verdict_cleared, bulk_verdict_set, migration,
annotation_*, queue_session_completed, bundle_imported). parseType()
returns undefined for an unrecognised value, and an undefined filter
means "no filter" — so `?type=profile_preset_applied` answered with the
UNFILTERED feed rather than a filtered or empty one. Any caller of those
newer types got whatever happened to be newest.

Why it surfaced now: tests/e2e/profile-presets.spec.ts asserts the newest
row of a type-filtered query. Locally the 24h health check (first run 60s
after boot) had not fired during the suite, so the newest row happened to
be the expected one and the broken filter was invisible. CI's server
lives long enough for the health_check row to land and win.

Fix at the source: ACTIVITY_TYPES is now a runtime array in
lib/activity.ts with the union DERIVED from it, and the route uses that
array directly. The two can no longer drift.

tests/app/activity-type-filter.test.ts guards the property rather than
the list contents: it asserts the route derives its allowlist instead of
restating one, plus a spot-check that the ten types whose absence caused
this are present. Proved fail-able by restoring a literal allowlist —
the guard fires; with the fix it passes.

Gate: tsc, biome, 487 unit (0 fail), full Playwright 46 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AdamXweb
AdamXweb merged commit fe42dc1 into main Aug 16, 2026
13 checks passed
@AdamXweb
AdamXweb deleted the feat/phase0-batch5 branch August 16, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants