Skip to content

WEB-UX-004 — Move focus to main content after SPA path navigation #657

Closed

Description

@daliu

Officer impact: No live-site change is claimed at issue creation. After a future approved publication, keyboard users who navigate between client-side pages will move into the destination main content unless that page or the user has already established newer focus.

Officer documentation: Update only docs/officers/PUBLISH_AND_CHECK.md with one separately named source/later-publication verification procedure.

Deployment evidence: None at issue creation. Source and synthetic tests only are authorized. No website publication, Firebase deployment, outside-provider configuration, account/sign-in action, production-data action, or live-behavior verification is authorized or claimed.

Atomic outcome

After a committed client-side pathname transition, hand otherwise-stale keyboard focus once to the existing #main-content landmark. Preserve the existing pathname-only scroll reset, preserve newer destination/user focus, and never focus main on initial load.

Exact base commit: 3265a55cb92df1ebb5401f2647ff81e4ca3af0b8

Exact base tree: 322468cc75a57f7ea392eb9f57a201fb11c4b35a

Current defect

src/components/ScrollToTop.jsx currently calls only window.scrollTo(0, 0) when pathname changes. src/App.jsx renders a persistent <main id="main-content"> without programmatic focusability. A desktop Navbar or Footer link therefore remains focused in persistent old-page chrome after its route content changes. On the phone layout, the selected menu link becomes hidden when released #490 closes the menu, and browser focus can fall to the document body. Assistive-technology and keyboard users are not taken into the new page content.

Invariant and transitions

  • Keep the current window.scrollTo(0, 0) behavior on initial render and every pathname change.
  • Initial direct load or reload never moves focus.
  • Search-only, hash-only, location-state-only, and same-path history changes neither scroll again nor create a focus handoff; the effect remains keyed only by pathname.
  • Give the existing main landmark tabIndex={-1} so code can focus it without adding it to ordinary Tab order. Preserve its exact ID and the existing skip-link destination.
  • Each real pathname transition creates only an opaque latest-transition token and a transient reference or opaque focus-version snapshot sufficient to distinguish the unchanged navigation origin from newer focus. Never read, copy, serialize, log, or persist element text, values, IDs, query, hash, route state, credentials, or member data.
  • Capture the exact transition target and origin in a layout-phase pathname effect, then schedule that exact token's one cancelable animation-frame settlement from a pathname-keyed passive effect. Identity-specific cleanup must prevent an older passive cleanup from clearing a newer layout token. This ordering allows destination layout/passive focus and synchronous redirect commits to settle before the handoff.
  • At settlement, consume before target checks. Require the latest still-mounted transition and the exact connected main target.
  • Leave main or a connected main descendant focused when it already owns focus.
  • Focus main exactly once with { preventScroll: true } only when focus is still the captured navigation origin or has fallen to null, body, the document root, or a disconnected element.
  • Preserve any different connected focus established after the transition by the destination or user. Never retry after consuming a missing, disconnected, superseded, or no-steal target.
  • A newer pathname transition cancels and generation-invalidates the older frame. A forcibly invoked stale callback cannot act on or consume a newer intent. Unmount is inert.
  • An asynchronously committed later navigation is a separate pathname transition. Do not claim that unrelated later navigation is coalesced with an earlier completed handoff.
  • Preserve released WEB-UX-002 — Restore visible keyboard focus across the site #291's global focus-visible rule and skip-link behavior byte-for-byte, and preserve released WEB-UX-003 — Make mobile navigation state explicit and deterministic #490 mobile disclosure/close behavior byte-for-byte. The committed-build browser check at 1280×900 and 390×844 proved that focused main is exactly x=0, y=0, and viewport-wide, so the existing outward WEB-UX-002 — Restore visible keyboard focus across the site #291 decoration is clipped and visually absent. Add only one scoped, fixed, pointer-inert #main-content:focus-visible::after two-tone cue inset inside the viewport and below the fixed navigation; it must not move layout or enter the accessibility tree.
  • Add no route, call, request, retry, analytics event, log, storage, authentication, membership, directory, photo/search, Firebase, provider, or production-data behavior.

Trustworthy RED

On the unchanged exact-base runtime, add a focused test in the existing src/App.test.jsx: focus a persistent synthetic navigation link, commit another pathname, flush a controlled animation frame, and require the existing main landmark to own focus. The old source must fail only because it leaves focus on the persistent link or body. Record the test-only diff digest and focused output before changing runtime.

Required GREEN coverage

  1. Initial / and an initial deep URL with search/hash scroll once but never focus main.
  2. Query-only, hash-only, state-only, and same-path replacement/navigation do not schedule focus or scroll again.
  3. A focused persistent navigation origin transfers once to connected main with exactly { preventScroll: true }.
  4. Body, document-root, null, removed, and disconnected origins recover to main.
  5. Main already focused is not redundantly focused; a connected focused descendant is preserved.
  6. Destination layout focus, destination passive focus, and deliberate connected focus selected before settlement are preserved.
  7. The real released phone menu closes after a public destination, its old menu link is no longer the page destination, and main receives focus without changing WEB-UX-003 — Make mobile navigation state explicit and deterministic #490 ARIA/classes/routes.
  8. Rapid A-to-B-to-C or synchronous redirect commits leave only the newest frame eligible. A forcibly invoked canceled callback is inert and cannot consume the newer intent.
  9. Missing or disconnected main consumes without delayed focus after rerender.
  10. Unmount cancels and generation-fences even a forcibly invoked old callback.
  11. StrictMode initial replay stays focus-inert; POP/back pathname navigation gets the same current handoff.
  12. The real app shell preserves Skip to content -> #main-content, the exact main ID, tabIndex={-1}, and zero additional service calls.
  13. Run the named focused block, full App and frontend suites, repository Node tests, TypeScript, scoped ESLint, unchanged lint baseline, diagnostic build, workflow/security checks, diff check, and independent accessibility/security/officer reviews.
  14. On the exact-head signed-out Deploy Preview, inspect keyboard behavior at 390-pixel and 1280-pixel widths. Require the scoped cue to be visible, fully inside the viewport below navigation, pointer-inert, non-disruptive, and layout-neutral; stop if it is clipped, absent, overlays controls materially, or changes layout.

Exact ownership

  • src/components/ScrollToTop.jsx: only the pathname focus-intent/settlement lifecycle while retaining the existing scroll effect.
  • src/App.jsx: only programmatic focusability and the stable exact-main target relationship.
  • src/App.test.jsx: one separately named WEB-UX-004 focused component matrix plus the minimum real-shell integration assertion. No new test file, so the 120-file lint inventory remains unchanged.
  • src/index.css: only the scoped bounded #main-content:focus-visible::after cue; preserve the global WEB-UX-002 — Restore visible keyboard focus across the site #291 selector and declarations byte-for-byte.
  • src/headerClearance.test.js: only mutation-sensitive assertions for the scoped main cue, its viewport/nav insets, pointer/layout containment, and preservation of the one global WEB-UX-002 — Restore visible keyboard focus across the site #291 rule.
  • docs/officers/PUBLISH_AND_CHECK.md: one separately named no-terminal source/later-publication route-focus verification procedure adjacent to the historical WEB-UX-002 — Restore visible keyboard focus across the site #291 keyboard check.

No .github/lint-baseline.json, Navbar, Footer, route-page, Account, directory, service, root design/security, map, Mermaid, backend, release-control, or provider path is owned.

Officer procedure requirements

The new guide section must name purpose, approver, prerequisites, one-action steps, expected result, stop conditions, success proof, undo, and escalation. It must use only signed-out public pages and made-up/no submitted data; cover initial load, desktop and phone navigation, browser back/forward, destination-owned focus, main focus/next-Tab order, 390-pixel and 1280-pixel widths, and visible unclipped #291 focus treatment; and separately record source, tests, merge, website publication, exact runmprc.com revision, Firebase, providers, accounts/sign-in, production data, and live behavior. Live proof remains NOT AVAILABLE YET until an approved publication is separately verified.

No Mermaid change is required because the existing main landmark, page hierarchy, routes, destinations, permissions, ownership, data movement, and deployment topology do not change.

Explicit exclusions

  • No new test file or lint-baseline/file-count update.
  • No focus movement on initial load or same-path search/hash/state cleanup.
  • No unconditional immediate layout/passive focus, layout-scheduled frame, autoFocus, timeout, live region, page-heading rewrite, route-label change, or ordinary Tab stop.
  • No Navbar state/markup/CSS change, no Footer change, no global focus-rule change, no outline suppression, and no weakening of WEB-UX-002 — Restore visible keyboard focus across the site #291/WEB-UX-003 — Make mobile navigation state explicit and deterministic #490 behavior.
  • No Account editor/retry/validation work and no member-directory runtime, availability, or connection work.
  • No Functions, Firestore Rules, indexes, schemas, services, packages, workflows, provider consoles, accounts, sign-in, production data, deployment, publication, or live action.
  • No membership, role, entitlement, payment, roster, photo query, facial recognition, matching, embedding, similarity, or biometrics.

Migration and residual risk

No migration. This browser focus handoff proves only source behavior on a committed SPA pathname transition. It does not prove publication, authorization, data correctness, backend availability, or production behavior. A delayed asynchronous redirect is its own transition. Rendered preview review must stop for a clipped or disruptive main focus indicator rather than suppressing the global indicator.

Coordination and claim protocol

Open PRs were empty at audit. Searches found no open exact-path or route-focus claimant. #291 and #490 are closed/released foundations. Active #504 coordinates only the disconnected directory; #507 retains all future directory connection/release gates; #616 owns only its two Strava Function paths plus its separate OAUTH/RISK-024 SECURITY hunk. #133, #136, and #460 mention docs/officers/PUBLISH_AND_CHECK.md for different future procedures and have no active exact hunk claim. Post and reread disjointness notices before editing.

Issue creation is not a claim. Before the first repository edit: create a unique worktree/branch from the exact base; re-read main, open PRs, and notices; assign the issue; post a timestamped claim naming the exact branch/base/tree/six paths; and set status:in-progress. Hold the claim through reviewed merge, exact-main audit, closure, and explicit release.

Activity

  1. added
    bugSomething isn't working
    priority:P1High-priority follow-up
    area:webWeb application and hosting
    size:SSmall focused issue
    type:testingTest infrastructure and quality gates
    status:in-progressClaimed and actively being implemented
    on Aug 14, 2026
  2. self-assigned this
    on Aug 14, 2026
  3. daliu commented on Aug 14, 2026

    @daliu
    ContributorAuthor

    CLAIMED by /root at 2026-08-14T05:42:43Z; branch codex/issue-657-spa-route-focus; exact base 3265a55cb92df1ebb5401f2647ff81e4ca3af0b8; exact base tree 322468cc75a57f7ea392eb9f57a201fb11c4b35a.

    Exact ownership:

    • src/components/ScrollToTop.jsx: only a pathname-bound, cancelable, latest-transition focus settlement; preserve the existing pathname scroll effect.
    • src/App.jsx: only tabIndex={-1} on the existing exact main#main-content; preserve skip-link target, routes, hierarchy, and all children.
    • src/App.test.jsx: only one separately named WEB-UX-004 controlled-frame component matrix plus the minimum real-shell assertion; no new test file or lint-inventory change.
    • docs/officers/PUBLISH_AND_CHECK.md: only one separately named no-terminal source/later-publication route-focus verification procedure adjacent to historical WEB-UX-002 — Restore visible keyboard focus across the site #291.

    The implementation will capture the exact main element and active navigation origin at the committed pathname transition, schedule one requestAnimationFrame from a layout effect, consume/fence it by object identity, preserve newer connected focus, and focus main only from the unchanged origin or null/body/document-root/disconnected focus with { preventScroll: true }. Initial and same-path search/hash/state changes remain focus-inert. Stale, canceled, superseded, missing/replaced-main, and unmounted callbacks cannot act or retry.

    Excluded and immutable: new test files, .github/lint-baseline.json, CSS, src/headerClearance.test.js, Navbar/Footer/route-page/Account/directory/service code, root design/security/maps/Mermaid, Functions, Rules, indexes, schemas, packages, workflows, providers, accounts/sign-in, production data, deployment, publication, and live behavior. Released #291/#490 behavior, #504/#507 boundaries, literal-false directory availability, and every #616 path/hunk remain unchanged.

    No source or documentation edit preceded this valid claim. Hold this claim through exact-head review, merge, exact-main audit, closure, and explicit release. STOP on any exact collision or violated invariant.

  4. daliu commented on Aug 14, 2026

    @daliu
    ContributorAuthor

    CLAIM DESIGN CORRECTION at 2026-08-14T05:51:48Z; exact four-path ownership is unchanged.

    React 18 can defer destination passive effects until after paint while a frame scheduled during layout runs before paint. Therefore a layout-scheduled frame could transiently focus main before a destination useEffect establishes its own focus, violating the no-steal invariant even if final focus later appears correct.

    Corrected owned lifecycle: the pathname useLayoutEffect captures the exact main/origin and installs the identity token; a separate pathname-keyed passive effect schedules that exact token's single cancelable requestAnimationFrame after the existing scroll effect. Cleanup is identity-specific, so an older passive cleanup cannot clear or act on a newer layout token. Tests must prove a destination passive-focus route causes zero main-focus calls, not merely the correct final active element.

    The issue body now records this correction. No scope, route, data, service, CSS, documentation path, backend, provider, deployment, or live boundary changed. Runtime review is still in progress; no claim is released.

  5. daliu commented on Aug 14, 2026

    @daliu
    ContributorAuthor

    CLAIM SCOPE CORRECTION at 2026-08-14T06:23:16Z; exact base, branch, runtime lifecycle, and backend/live boundaries are unchanged.

    A read-only browser check of the committed local production build hit the issue's explicit visual stop condition at both required widths:

    • 1280×900: focused main#main-content was exactly x=0, y=0, width=1280; computed WEB-UX-002 — Restore visible keyboard focus across the site #291 decoration was a 3px secondary outline with 3px outward offset and a 6px dark spread. Focus reached main and scroll stayed 0,0, but the decoration lay outside the viewport/top under the fixed navigation and was visually absent.
    • 390×844: focused main was exactly x=0, y=0, width=390; the phone menu closed to hide__nav, focus reached main, and the same decoration was visually absent.

    This is local committed-build evidence only, not Deploy Preview, publication, runmprc.com, or live evidence. The exact-head Deploy Preview at both widths remains mandatory.

    The claim is expanded from four to exactly six paths before any CSS/test edit:

    No outline suppression, global selector/declaration change, Navbar/Footer/route/data/service/backend/provider/deployment/live change, or new test/lint file is authorized. The issue body now records this correction. No CSS or header-clearance edit preceded it; the current local commit remains the reviewed four-path source snapshot pending this bounded follow-up.

  6. daliu commented on Aug 14, 2026

    @daliu
    ContributorAuthor

    LOCAL SIX-PATH CANDIDATE READY at 2026-08-14T06:38:45Z; claim remains active and nothing is pushed.

    • local commit: a411cb4ebcfb4f1f05b3883721aa55f3d72bc701
    • tree: eac1fe8b86f0e871d2fc41e5338dceace38a04ec
    • sole parent/exact main: 3265a55cb92df1ebb5401f2647ff81e4ca3af0b8
    • exact six paths, +731/-6
    • runtime SHA-256: ea9c32ae30493a11e42206327055cbd0001c21b0e378ed09caa6e2a55bead576
    • test SHA-256: d50ff54aab64b1bdf73bea756946238df80453d11bfc7ba540cecf4e8557e5ef
    • docs SHA-256: 39166cdd26c2de8e1c0ef5b483058dab54da463c2ed2f2265e25b7df290c5e96
    • combined SHA-256: fcccd40bfb210bc10e2a6ee31f03b2c6b5d021f5a441280aa7dc2424e25e7c36

    Trustworthy RED evidence:

    • unchanged route runtime: App test-only diff ea14897d70e85f27f2a5c80d4422a4faad31ddaa4055fee99672d2252829f733; 1 failed / 362 skipped because persistent navigation retained focus
    • pre-cue CSS: header test-only diff 5d5f37a666ee61f4ef9b3481f345ed9371002a9635a5aa2b84518fb059727102; focused cue test failed only because the scoped rule was absent

    Final local evidence:

    • WEB-UX-004 19/19; full App 381/381; header-clearance 13/13; full frontend 18 suites / 1,334 tests
    • TypeScript, scoped ESLint, unchanged 120-file lint baseline, diagnostic build, 105 workflow/release/security/navigation/artifact tests, and diff-check pass
    • local production-build browser check at 1280×900 and 390×844: initial load leaves BODY focused and cue absent; route navigation focuses main at scroll 0,0; phone menu closes; fixed cue is top 94px/right-bottom-left 6px, pointer-inert, z98 below nav99, fully visible and layout-neutral
    • independent frontend/a11y and backup-officer/ops reviews: GO, no findings

    This is source/local evidence only. There is no remote branch, pull request, hosted CI, Deploy Preview, publication, runmprc.com change, Firebase/provider/account/data action, or live proof. The environment requires explicit user authorization before pushing source to the GitHub origin. Exact-head hosted checks and both required Deploy Preview widths remain mandatory after that authorization.

  7. daliu commented on Aug 14, 2026

    @daliu
    ContributorAuthor

    COMPLETE / RELEASED by /root at 2026-08-14T07:08:04Z.

    PR #658 merged as exact main 95880748e15c03b0ee58da6e1ed11ac6c9526529; tree eac1fe8b86f0e871d2fc41e5338dceace38a04ec is byte-identical to reviewed candidate tree; parents are prior main 3265a55cb92df1ebb5401f2647ff81e4ca3af0b8 and reviewed head a411cb4ebcfb4f1f05b3883721aa55f3d72bc701. The first-parent diff remains exactly six paths, +731/-6, with SHA-256 fcccd40bfb210bc10e2a6ee31f03b2c6b5d021f5a441280aa7dc2424e25e7c36.

    Exact-head CI run 31778085226 and exact-main CI run 31778506870 each passed all five jobs. Netlify preview 6a7ebc3388b62b0008ee950f was ready at exact head, unpublished/noindex, with no Functions or edge functions; signed-out 1280×900 and 390×844 checks passed. The automatic main attempt 6a7ebdd8f69c0b00080353a8 ended error with published_at:null and did not move production.

    Published production remains inert #623 deploy 6a7e072f8f346b0008510d29, control 9d5cc8612b4321172370bd949d307e7e4ac0ec7d, source c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec, and tree 411aa6ec9a9459f5d923030533ffc7c007fe6908. Directory availability remains literal false with blob 295909b0df8dd7c54a0f164f99d4943656aece4b.

    No Firebase deployment, outside-provider configuration, account/sign-in action, production-data action, directory connection, or live WEB-UX-004 behavior occurred. The exact six claimed paths are released. The user's later explicit deployment authorization will be handled by a separate reviewed one-shot Netlify release; it must not publish the accumulated 84-path web delta from live #623 to current main.

  8. removed their assignment
    on Aug 14, 2026
  9. daliu commented on Aug 14, 2026

    @daliu
    ContributorAuthor

    PRE-CLAIM coordination for #659 / WEB-002D from exact current main 95880748e15c03b0ee58da6e1ed11ac6c9526529 and exact live source c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec.

    The approved release will project only the already-reviewed #291 visible-focus source, #490 deterministic/ARIA phone-navigation source, and #657 SPA route-focus source into a frozen source ref based directly on the current live source. Exact source ownership is six paths: src/App.jsx, src/App.test.jsx, src/components/Navbar.jsx, src/components/ScrollToTop.jsx, src/index.css, and the live-path src/headerClearance.test.jsx. The ref will never merge to main. No accumulated-main route/content, package, service, backend, provider, data, or directory code is included. No source edit has occurred; only isolated branches/worktrees exist. Send STOP for an exact behavioral/hunk dependency or collision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:webWeb application and hostingbugSomething isn't workingpriority:P1High-priority follow-upsize:SSmall focused issuetype:reliabilityReliability and recoverytype:testingTest infrastructure and quality gates

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions