Skip to content

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 current function UnlockScreen(...) at the top of the file).
  • Export as default.
  • Replace the inline style={{...}} hits with CSS classes in src/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 / isTabletLayout resize listener in App.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)').matches on line 422 with useMediaQuery('(hover: hover) and (pointer: fine)').
  • Also audit src/hooks/useUI.js:4 — the initial mobileView uses window.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 false when window is undefined and update on mount via matchMedia with addEventListener('change', ...).

1c. src/components/AppGates.jsx

  • Extract the gate ladder (lines 303–366 of App.jsx): policyRoute, session === undefined, session === null, waitlistStatus check, !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 bottomNavItems construction, desktopTabs construction, 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 the goTo* callbacks from useUI.
  • 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/NoteEditForm pair and IdeaActionSheet/IdeaEditForm pair.
  • Each overlay owns its own useState for activeNote/activeAction (or activeIdea/activeIdeaAction). Expose an imperative handle via forwardRef + useImperativeHandle: open(note) / close(). App.jsx then holds a ref and calls noteOverlayRef.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 in App.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) into src/hooks/useNoteForm.js.
  • Expose them on the hook’s return object. Rationale: useNoteForm already owns clearCapture, handleImageBlob, and the capture lifecycle; animation state is part of that lifecycle.

Acceptance:

  • src/App.jsx is under 250 lines.
  • npm run build succeeds with zero warnings.
  • npm test passes with no test file modifications beyond import-path updates.
  • No visible behaviour change when running npm run dev through: cold load, unlock, capture, edit-note, edit-idea, long-press menus, switching between mobile/tablet/desktop breakpoints.

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 — change window.open('/#faq', ...) to window.open('https://surfc.app/#faq', '_blank', 'noreferrer').
  • src/components/SettingsModal.jsx:95 — change href="/#what-is-surfc" to href="https://surfc.app/#what-is-surfc".

Acceptance:

  • Both links open surfc.app in a new tab.
  • Update src/test/ProfileScreen.policyLinks.test.jsx if 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:

  1. grep -r "posthog-node" src/ scripts/ — identify every importer.
  2. If anything under src/ imports it, stop and escalate — that’s a bug, not a cleanup.
  3. If only scripts/ imports it, move it to devDependencies in package.json.
  4. If nothing imports it, remove it entirely.
  5. Run npm run build and compare dist/ bundle size before/after. Record the delta in the PR description.

Acceptance:

  • posthog-node is not in dependencies.
  • npm run build succeeds.
  • npm run check:schema and any other scripts/*.js entrypoints 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/.modal styles from SettingsModal.
  • Lift the confirmation flow one level. useAuth.handleSignOut changes from:
    if (outbox.length > 0 && !window.confirm(...)) return
    // proceed with sign-out
    to a two-phase shape:
    async 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 (or ProfileScreen, wherever onSignOut lives) renders <UnsyncedChangesModal> based on the needsConfirmation return value.
  • Alternative shape: accept a confirm callback injection into useAuth so 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.confirm calls anywhere in src/.
  • 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.js view-state machinery, src/router.js, or the history-bridge tests. All of that moves in SUR-220 / SUR-247.
  • Do not introduce react-router-dom or any new runtime dependency — SUR-220 adds that. useMediaQuery is a hand-rolled hook, not a library.
  • Do not rename mobileView values ('home', 'index', 'capture', etc.) — SUR-247 will replace them with routes.
  • Do not alter the useKeyManagement or useNoteForm hook 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:

  1. chore/drop-posthog-node — Task 3. Safest, ship first, smallest diff.
  2. fix/sur-209-help-links — Task 2. Two-line change, independent.
  3. refactor/app-jsx-extractions — Task 1 (all six sub-tasks). The big one. Target < 250 lines in App.jsx and call out the git diff --stat for src/App.jsx in the PR description.
  4. refactor/unsynced-changes-modal — Task 4. Depends on Task 1’s AppGates/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 build passes with no new warnings
  • npm test passes
  • git status shows 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 --stat on src/App.jsx