refactor(phase0): 32 of 35 pages become client-fetching shells (Rust-core Phase 0) - #199
Merged
Conversation
…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>
This was referenced Aug 16, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
RequireAppsGategetAllApps().length === 0 → redirect("/onboard")RequireFlagGateresolveFlagFromDb(k) !== "on" → notFound(), with an opt-infailOpenfor the one gate whose server version deliberately defaulted to visibleFlagGatedRecordTaskVisit+POST /api/user-tasks/visitsetSettingIfUnset("task_visit.*")lib/use-flag-bundle.ts/api/feature-flagsfetch per page loadFinding worth knowing: the existing
useFlaghook 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 whyuseFlagBundlereads real values from the API instead.New endpoints (all additive)
GET /api/stats·POST /api/user-tasks/visit·GET /api/manual-apps/[id]extended withevents/currentVersion/meta·audienceSetadded toGET /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:
useFlagBundlefails closed, so a transient/api/feature-flagsfailure would have skipped the step where the privacy profile is created — leavingcreate_privacy_profilepermanently incomplete./onboardinstead 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
tests/app/phase0-page-shells.test.ts) pins every converted page: no server-coupledlib/import, noforce-dynamic, no serverredirect(). It now allows a smallPURE_LIB_MODULESallowlist and asserts each allowlisted module is still pure, so pages rendering fromCATEGORY_METAcount as converted without weakening the net. Itsredirectcheck was also fixed to match the import specifier rather than call text (a doc comment tripped it).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, thetask_visitmarker persists in SQLite, the definitions page resolves the AU-specific transparency link).What's deliberately not here
next buildstill 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