Phase 0 — App-shell cleanup alongside SUR-220
Phase 0 — App-shell cleanup alongside SUR-220
Target: merge with or immediately beneath the SUR-220 PR.
Audit source: docs/TECH_DEBT_AUDIT_2026-04-24.md
Owner: Claude Code
Branch strategy: one short stack of feature branches (see “PR shape” at the bottom). Do not combine all four into a single commit — they need to be independently revertable.
Why
App.jsx is 762 lines and continues to absorb concerns. Before SUR-220’s router refactor lands, extract the pieces that are independent of routing so SUR-220 touches a smaller surface. Three drive-by fixes piggyback because they touch the same files and are pure wins.
Do not change navigation semantics in this ticket. useUI view names, history-bridge behaviour, and the router.js pathname matcher all stay exactly as they are — those move in SUR-220 and SUR-247.
Tasks
Task 1 — Extract from App.jsx (target: < 250 lines)
Pull the following blocks out of src/App.jsx into their own files. Preserve every existing prop name and callback signature; downstream components and tests must not change beyond imports.
1a. src/components/UnlockScreen.jsx
- Move lines 36–171 of
App.jsx(the currentfunction UnlockScreen(...)at the top of the file). - Export as default.
- Replace the inline
style={{...}}hits with CSS classes insrc/styles.css(use existing tokens — no new colours). If a style is truly one-off, leave it but consolidate the repeating{ minHeight: 44, width: '100%' }and error-message styles. - No behaviour change.
1b. src/hooks/useMediaQuery.js
- New hook. Signature:
useMediaQuery(query: string): boolean. - Replace the
isDesktopLayout/isTabletLayoutresize listener inApp.jsx(lines 178–188) with two calls:const isDesktopLayout = useMediaQuery('(min-width: 1024px)')const isTabletLayout = useMediaQuery('(min-width: 640px) and (max-width: 1023.98px)')
- Also replace the ad-hoc
window.matchMedia('(hover: hover) and (pointer: fine)').matcheson line 422 withuseMediaQuery('(hover: hover) and (pointer: fine)'). - Also audit
src/hooks/useUI.js:4— the initialmobileViewuseswindow.innerWidth >= 640. Leave that line unchanged in this ticket (changing it risks regressing the SUR-201 tests); just note in the PR description that it will be removed by SUR-247. - Hook must be SSR-safe: default to
falsewhenwindowis undefined and update on mount viamatchMediawithaddEventListener('change', ...).
1c. src/components/AppGates.jsx
- Extract the gate ladder (lines 303–366 of
App.jsx):policyRoute,session === undefined,session === null,waitlistStatuscheck,!ready,encryptionCheckPending, passkey-enrollment gate, unlock gate, migration gate. - Props: all of the state values these gates read (session, waitlistStatus, ready, encryptionCheckPending, passkeyEnrolled, encryptionPromptSeen, encryptionReady, migrating, migrationProgress, migrationTotal, migrationError, online, addDevice*, transferRedeem*) plus the callbacks (onEnrolled, onSkip, onUnlock, onSignOut, onAddDevice, onRedeemTransfer, setSession).
- Children: the authenticated tree. If all gates pass, render
{children}; otherwise render the appropriate gate screen. - Keep the exact order of gates. The ordering is load-bearing (waitlist runs before encryption probe — see the comment at
App.jsx:315).
1d. src/components/ShellNavigation.jsx
- Extract
bottomNavItemsconstruction,desktopTabsconstruction, the<nav className="main-tab-row">block, and the<nav className="bottom-nav">block (lines 390–420, 461–471, 674–687). - Props:
mobileView,isDesktopLayout,isTabletLayout,canCapture, and thegoTo*callbacks fromuseUI. - Internally owns the
['home', 'sources', ...].includes(mobileView)active-state arrays. These move verbatim.
1e. src/components/NoteActionOverlay.jsx and src/components/IdeaActionOverlay.jsx
- Extract the four bottom blocks (lines 718–754 of
App.jsx):NoteActionSheet/NoteEditFormpair andIdeaActionSheet/IdeaEditFormpair. - Each overlay owns its own
useStateforactiveNote/activeAction(oractiveIdea/activeIdeaAction). Expose an imperative handle viaforwardRef+useImperativeHandle:open(note)/close().App.jsxthen holds a ref and callsnoteOverlayRef.current?.open(note)instead of managing four pieces of state itself. - Alternative if imperative handles feel wrong: extract as a
useActionSheet(onEdit, onDelete, ...)hook and keep the JSX rendered inApp.jsx. Prefer the component extraction if you can make the ref approach clean.
1f. Capture-animation state → useNoteForm
- Move
captureAnimImg,capturePreviewPendingRef,handleCaptureReady,handleCapturePreviewEnd,handleRetakeCapture(lines 231–260, 383–388) intosrc/hooks/useNoteForm.js. - Expose them on the hook’s return object. Rationale:
useNoteFormalready ownsclearCapture,handleImageBlob, and the capture lifecycle; animation state is part of that lifecycle.
Acceptance:
src/App.jsxis under 250 lines.npm run buildsucceeds with zero warnings.npm testpasses with no test file modifications beyond import-path updates.- No visible behaviour change when running
npm run devthrough: cold load, unlock, capture, edit-note, edit-idea, long-press menus, switching between mobile/tablet/desktop breakpoints.
Task 2 — Fix SUR-209 dead help links
Two one-line changes. The /#faq and /#what-is-surfc anchors resolve to nothing on app.surfc.app post-SUR-218.
Files:
src/components/ProfileScreen.jsx:205— changewindow.open('/#faq', ...)towindow.open('https://surfc.app/#faq', '_blank', 'noreferrer').src/components/SettingsModal.jsx:95— changehref="/#what-is-surfc"tohref="https://surfc.app/#what-is-surfc".
Acceptance:
- Both links open
surfc.appin a new tab. - Update
src/test/ProfileScreen.policyLinks.test.jsxif it asserts on the URL (it shouldn’t — that test is for/policies/*, not/#faq). - Reference SUR-209 in the commit message but this closes neither SUR-209 nor SUR-220 — it’s a stopgap until SUR-209 ships dedicated in-app help pages. Note this in the PR description.
Task 3 — Remove posthog-node from client dependencies
posthog-node is in dependencies in package.json (line 21). It’s a server-side SDK; shipping it in the PWA bundle is bundler waste.
Steps:
grep -r "posthog-node" src/ scripts/— identify every importer.- If anything under
src/imports it, stop and escalate — that’s a bug, not a cleanup. - If only
scripts/imports it, move it todevDependenciesinpackage.json. - If nothing imports it, remove it entirely.
- Run
npm run buildand comparedist/bundle size before/after. Record the delta in the PR description.
Acceptance:
posthog-nodeis not independencies.npm run buildsucceeds.npm run check:schemaand any otherscripts/*.jsentrypoints still work.- Bundle size delta reported in the PR.
Task 4 — Replace window.confirm unsynced-changes dialog
src/hooks/useAuth.js:308 currently blocks sign-out with a native window.confirm(...) when the outbox is non-empty. The native dialog is jarring and untestable in jsdom.
Approach:
- Create
src/components/UnsyncedChangesModal.jsx: simple modal with message, “Sign out and discard” button (destructive), “Cancel” button. Reuse the.modal-overlay/.modalstyles fromSettingsModal. - Lift the confirmation flow one level.
useAuth.handleSignOutchanges from:to a two-phase shape:if (outbox.length > 0 && !window.confirm(...)) return// proceed with sign-outasync function handleSignOut() {const outbox = await getOutbox()if (outbox.length > 0) return { needsConfirmation: true, outboxCount: outbox.length }return proceedWithSignOut()}async function confirmSignOutDiscardingChanges() { return proceedWithSignOut() } App.jsx(orProfileScreen, whereveronSignOutlives) renders<UnsyncedChangesModal>based on theneedsConfirmationreturn value.- Alternative shape: accept a
confirmcallback injection intouseAuthso the confirmation UI stays out of the hook. Pick whichever integrates more cleanly with the Task 1 extractions.
Acceptance:
- Signing out with an empty outbox: no modal, immediate sign-out.
- Signing out with items in outbox: modal appears, “Cancel” aborts, “Sign out and discard” proceeds (existing behaviour).
- No
window.confirmcalls anywhere insrc/. - Add a test in
src/test/that asserts the modal appears when outbox has items. Existing sign-out tests must still pass.
Guardrails
- Do not touch
src/hooks/useUI.jsview-state machinery,src/router.js, or the history-bridge tests. All of that moves in SUR-220 / SUR-247. - Do not introduce
react-router-domor any new runtime dependency — SUR-220 adds that.useMediaQueryis a hand-rolled hook, not a library. - Do not rename
mobileViewvalues ('home','index','capture', etc.) — SUR-247 will replace them with routes. - Do not alter the
useKeyManagementoruseNoteFormhook signatures except to add the capture-animation state in 1f. - Follow repo conventions in
CLAUDE.md: feature branches, Conventional Commits, no hardcoded secrets, offline-first sync rules unchanged, CSS variables only.
PR shape
Four PRs, stacked in this order so each is independently reviewable:
chore/drop-posthog-node— Task 3. Safest, ship first, smallest diff.fix/sur-209-help-links— Task 2. Two-line change, independent.refactor/app-jsx-extractions— Task 1 (all six sub-tasks). The big one. Target < 250 lines inApp.jsxand call out thegit diff --statforsrc/App.jsxin the PR description.refactor/unsynced-changes-modal— Task 4. Depends on Task 1’sAppGates/overlay shape being settled.
Link each PR to Linear [SUR-###] in the commit message once tickets exist. Reference this markdown as the spec.
Verification checklist (every PR)
-
npm run buildpasses with no new warnings -
npm testpasses -
git statusshows no staged.env - Manual smoke: cold load → unlock → capture → edit → sign-out on mobile + desktop breakpoints
- Diff is scoped — no drive-by edits outside the task’s file list
- PR description includes: what changed, why, and for Task 1, the
git diff --statonsrc/App.jsx