From c111c372fda21f8814c7d67cb8b3503ad1c543d9 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 13 Aug 2026 20:06:05 -0700 Subject: [PATCH] Announce successful profile reload result (#649) --- IMPLEMENTATION_PLAN.md | 2 + SECURITY.md | 1 + SYSTEM_DESIGN.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 122 ++++++- src/pages/account/Account.css | 5 + .../account/MemberDirectoryProfile.test.tsx | 337 +++++++++++++++++- src/pages/account/MemberDirectoryProfile.tsx | 90 ++++- 7 files changed, 554 insertions(+), 5 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 25eb9b2..387461d 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -193,6 +193,8 @@ Exit gate: **MEMBERS-DIRECTORY-001O current source boundary:** [#647](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/647) changes only visibility-mutation keyboard focus in the preserved connected Account branch. After request-ID creation and admitted `startMutation('visibility')`, the component records one pending intent only when the exact officer-finder checkbox owns focus, and the intent contains only the mounted application-and-account lifetime and exact operation symbol. Confirmed success plus a current successful authoritative profile read, or definitive rejection plus a current successful confirming read, transfers only the matching intent, and one matching ready render consumes it. Body, document-root, absent, or disconnected focus returns only to the same connected and enabled checkbox; an already-focused checkbox is left alone; any other connected focus deliberately chosen during mutation or readback keeps focus; and an ineligible returned off checkbox consumes the result without focus because it is disabled. Programmatic or outside-focused invocation, request-ID failure, failed mutation admission, unknown outcome, failed readback, application or account change, unmount, and stale mutation or readback completion create no result or clear or fail the guards. #637 Reload recovery, #643 rejected-removal focus, #645 confirmed-photo focus, native pending disablement, errors, confirmations, exact calls and revisions, and existing fences remain unchanged. Focus creates no request ID, read, mutation, retry, audit, result, draft, photo byte, data URL, provider action, or data action beyond the already admitted operation. Availability stays `false`, so the default branch and live #623 preview remain inert. #647 changes no data movement, element structure, page topology, Account wiring, People finder, service contract, Function, Rule, index, schema, package, workflow, backend, provider, account, sign-in, production data, deployment, publication, biometric processing, or connected/live behavior. #507 still owns privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. +**MEMBERS-DIRECTORY-001P current source boundary:** [#649](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/649) changes only explicit successful-Reload feedback and keyboard focus in the preserved connected Account branch. Every valid explicit current **Reload settings** followed by a guarded successful authoritative profile read renders exactly **Profile photo and officer finder settings reloaded.** as the first child of the recovered ready-controls region, with status, polite live, atomic, programmatic-focus, and scoped visible-outline semantics. That result proves only the current read completed and is kept separate from bottom mutation confirmations. A pending focus intent is created only when the exact rendered Reload button owns focus at a valid unavailable or uncertain-state invocation, stores only the mounted application-and-account lifetime and exact load symbol, is bound by the next load effect, and transfers only on the matching current guarded success. One ready effect consumes it before target checks: an already-focused connected result is left alone; body, document-root, absent, or disconnected focus returns to the result; and any other connected focus deliberately chosen during the read remains focused. A programmatic or unfocused valid Reload shows the same truthful result without moving focus. Initial or background success shows no Reload result, while Reload failure shows no success result and retains #637 replacement-Reload focus. Application change, account change, unmount, a new load, obsolete completion, and later same-context rerender cannot reuse the intent. The result and handoff create no request ID, extra read, mutation, retry, prior-change claim, audit, draft, photo byte, data URL, provider action, or data action. Availability stays `false`, so the default branch and live #623 preview remain inert. #649 adds one visible page-structure node but changes no data movement, permission, ownership, Account wiring, People finder, service contract, Function, Rule, index, schema, package, workflow, backend, provider, account, sign-in, production data, deployment, publication, biometric processing, or connected/live behavior; #507 still owns privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. + ### Phase 5 — End-to-end qualification **Issue:** TEST-001 plus final closure evidence from all prior phases diff --git a/SECURITY.md b/SECURITY.md index d0accc3..ca66ea2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -111,6 +111,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Source-only rejected-removal focus containment for RISK-042 | MEMBERS-DIRECTORY-001M [#643](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/643) gives the preserved connected Account branch one exact-operation pending focus intent only after request-ID creation and admitted `pending` removal, and only when **Remove current saved photo** owns focus. A definitive rejection plus current successful authoritative readback transfers the matching pending intent to the result ref for one ready render. The destination is a surviving Remove action, otherwise the enabled Save action for the exact current ready draft, otherwise the persistent file input for no, reading, or not-yet-ready draft. Body, document-root, absent, or disconnected focus returns to that current destination; an already-focused destination is left alone; any other connected focus selected during pending is preserved. A surviving Remove alone describes the fixed rejection. If Remove disappears, the fixed alert stays standalone and is not attached to Save or the input. Generated-only tests cover all destinations, native focus eviction, retained and deliberate outside focus, request-ID failure, both readback-failure classes, application/account changes, unmount, zero extra calls, and the unavailable default. | Programmatic focus is current-interface accessibility state, not provider acknowledgement, deletion proof, reconciliation, authorization, audit evidence, or proof that the rejected removal succeeded. The focus intent contains only the mounted application-and-account lifetime and exact mutation-operation symbol, with no photo bytes, profile, request ID, revision, provider value, or error, and creates no request ID, callable, retry, mutation, draft, data URL, confirmation, audit, provider action, or data action. Existing confirmed-success and #637 Reload focus behavior remains. The source-controlled availability value stays `false`, and live #623 remains inert. No data movement, page structure, service/server contract, Function, Rule, index, Firebase or provider configuration, account, sign-in, production data, deployment, or connected/live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use no real name or photo. | | Source-only confirmed-photo focus containment for RISK-042 | MEMBERS-DIRECTORY-001N [#645](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/645) gives the preserved connected Account branch separate pending and result confirmed-photo focus refs. Only a successful request-ID creation followed by admitted `pending` upload or removal may record an intent, and only while the exact **Save profile photo** or **Remove current saved photo** initiating control owns focus. The intent contains only the mounted application-and-account lifetime, exact operation symbol, and upload-or-remove action. A successful mutation and current successful authoritative readback transfer only a matching lifetime, operation, and action for one ready render. Confirmed upload targets the persistent file input. Confirmed removal retains the existing surviving Remove, exact current render-ready Save, then persistent file-input priority. Body, document-root, absent, or disconnected focus returns to the current destination; an already-focused destination is left alone; every other connected focus selected during mutation or readback is preserved. Generated-only tests cover both actions, all removal destinations, native focus eviction, redundant-focus avoidance, deliberate outside and in-profile focus, programmatic invocation, request-ID failure, definitive rejection, unknown and readback failure, application/account changes, unmount, one-shot consumption, exact call/byte/revision behavior, and the unavailable default. | Programmatic focus is current-interface accessibility state, not provider acknowledgement, upload or deletion proof, reconciliation, authorization, audit evidence, or connected-live proof. The focus intent stores no name, profile, revision, request ID, photo bytes, data URL, provider value, response, or error and creates no request ID, callable, retry, mutation, draft, data URL, confirmation, audit, provider action, or data action. #643 rejected-removal focus/error ownership and #637 Reload recovery remain separate. The source-controlled availability value stays `false`, and live #623 remains inert. No data movement, page structure, People finder, visibility behavior, service/server contract, Function, Rule, index, schema, Firebase or provider configuration, account, sign-in, production data, deployment, biometric processing, or connected/live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use only generated non-face test images; never add a photo query, facial recognition, matching, embedding, similarity, biometric processing, roster authority, or membership proof. | | Source-only visibility-focus containment for RISK-042 | MEMBERS-DIRECTORY-001O [#647](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/647) gives the preserved connected Account branch separate pending and result visibility focus refs plus the exact persistent officer-finder checkbox ref. Only successful request-ID creation followed by admitted `pending` visibility may record an intent, and only while that checkbox owns focus. The intent contains only the mounted application-and-account lifetime and exact operation symbol. Confirmed success plus a current successful authoritative profile read, or definitive rejection plus a current successful confirming read, transfers only a matching intent, and one ready render consumes it before checking the destination. Body, document-root, absent, or disconnected focus returns only to the same connected and enabled checkbox; retained checkbox focus is left alone; another connected outside or in-profile focus is preserved; and an ineligible returned off checkbox consumes the result without focus because it is disabled. Generated-only tests cover requested on, requested off, changed-again state, definitive rejection and error association, native focus eviction, redundant-focus avoidance, deliberate connected focus, programmatic invocation, request-ID failure, unknown and both readback-failure paths, name-ineligible disablement, application/account changes, unmount, stale mutation/readback completion, one-shot consumption, exact call counts, and the unavailable default. | Programmatic focus is current-interface accessibility state, not provider acknowledgement, saved-setting proof, mutation correctness, reconciliation, authorization, audit evidence, membership proof, or connected-live proof. The focus intent stores no query, name, profile, revision, request ID, result, error, provider value, photo byte, or data URL and creates no request ID, read, mutation, retry, audit, result, draft, photo byte, data URL, provider action, or data action beyond the already admitted operation. #637 Reload recovery, #643 rejected-removal focus, and #645 confirmed-photo focus remain separate. The source-controlled availability value stays `false`, and live #623 remains inert. No data movement, element structure, page topology, People finder, service/server contract, Function, Rule, index, schema, Firebase or provider configuration, account, sign-in, production data, deployment, publication, biometric processing, or connected/live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use no real name or photo; never add a photo query, facial recognition, matching, embedding, similarity, biometric processing, total, export, roster authority, or membership proof. | +| Source-only successful-Reload result and focus containment for RISK-042 | MEMBERS-DIRECTORY-001P [#649](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/649) gives every valid explicit current Reload whose guarded authoritative profile read succeeds one fixed first-in-ready-controls result, **Profile photo and officer finder settings reloaded.**, with status, polite live, atomic, programmatic-focus, and scoped visible-outline semantics. It is separate from mutation confirmations and proves only that the current read completed. Separate pending and result `ReloadFocusIntent` refs contain only the mounted application-and-account lifetime and exact load symbol. The pending intent is armed only if the exact rendered Reload button owns focus at invocation, is bound to the exact next load, and transfers only on its matching current guarded success. One ready effect consumes the result before checking the exact lifetime, current load, and connected result target. It leaves retained result focus alone, restores body, document-root, absent, or disconnected focus, and preserves any other connected outside or in-profile focus. A valid programmatic or unfocused Reload shows the result without moving focus. Initial/background success shows no Reload result; failure clears the new refs and retains #637 replacement-Reload focus; application/account change, unmount, new load, obsolete completion, and later rerender are inert. Generated-only tests cover generic and uncertain recovery, fixed copy and placement, live semantics, visible focus style, lost-focus restoration, redundant-focus avoidance, deliberate outside and in-profile focus, unfocused invocation after its origin disappears, initial load, failure and retry, one-shot consumption, context change, unmount, exact read counts, zero mutations, and the unavailable default. | The result and programmatic focus are current-interface accessibility feedback, not proof that an earlier mutation succeeded or failed, provider acknowledgement, saved-setting correctness, reconciliation, authorization, audit evidence, membership proof, or connected-live proof. The intent stores no UID, query, name, profile, revision, request ID, result, error, provider value, photo byte, or data URL and creates no request ID, extra read, mutation, retry, audit, draft, photo byte, data URL, provider action, or data action. Existing upload, removal, visibility, People-finder, and #637 failure-focus contracts remain separate. The source-controlled availability value stays `false`, and live #623 remains inert. This adds one visible page-structure node but changes no data movement, permission, ownership, service/server contract, Function, Rule, index, schema, Firebase or provider configuration, account, sign-in, production data, deployment, publication, or connected/live behavior. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use no real name or photo; never add a photo query, facial recognition, matching, embedding, similarity, biometric processing, total, export, roster authority, or membership proof. | | Immediate Product-binding containment part of RISK-031 | PAY-PRODUCT-001A is tracked in live [#353](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/353). One dependency-free projection is used by the paid race and Shop checkout paths. A present stored Product link must be an own primitive non-empty string from 1 through 255 JavaScript code units and is copied without trimming, conversion, character restrictions, or `prod_` inference. Only a genuinely missing own field retains the compatibility Product-create path. Before any mapping write, a resolved Product must provide a bounded custom ID, exact Product kind, expected declared mode, and an installed-SDK 2xx response marker. Malformed stored links stop before token, registration/order identifier, Product-link or business-record write, or Stripe work; earlier access and request-count checks and their safety-counter writes may already have run. Malformed created results stop after at most one Product attempt but before mapping, Checkout Session, or business-record writes. | This structural containment does not prove provider origin, Stripe account ownership, intended catalog identity, metadata binding, Product status, price, dispatch, delivery, or reconciliation. A rejected create result may leave an orphaned Product; do not retry automatically. Anonymous clean-missing creation remains concurrency-prone and reachable from public traffic. Complete #113 inventory/disposition, authenticated idempotent catalog synchronization, Product-specific plan/pre-send/result/lost-acknowledgement/reconciliation controls, isolated staging, protected Firebase deployment, and provider proof before live commerce. | | Immediate current-handler containment part of RISK-003 | PAY-SESSION-001A is tracked in live [#357](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/357). The current paid race and Shop handlers build one immutable expectation before the Session call, catch rejection without opening it, and immediately project only a closed installed-SDK result. A result must match declared test/live mode, payment/open/unpaid state, exact cents, USD, buyer email, exact closed metadata, exact callbacks, a mode-compatible bounded Session ID, one canonical HTTPS capability at the hard-coded default `checkout.stripe.com` origin, and an installed-SDK 200 marker. Only copied ID/URL values continue; records store the ID but never the URL. Invalid results create no registration/order record and return one fixed unknown-result message. The website makes rejection or a missing paid URL terminal for that page visit, retains the form, and blocks a second direct handler call. | This is a narrow legacy compatibility stop, not trusted provider/account origin, deterministic business idempotency, dispatch/delivery proof, durable result evidence, payment proof, or adoption of the unused C4 chain. The Session call occurs first, so a rejected result may leave a payable orphan; earlier rate-counter, token/identifier, and lazy Product-mapping effects may remain. Reload, another tab/device, or a scripted caller bypasses the page lock. A configured Stripe custom checkout domain is blocked until #113 and a protected configuration boundary approve it. Complete PAY-002C/D persistence-first sagas, trusted C4 controller/result persistence, reconciliation, protected staging/deployment, provider readback, and exact website/Firebase/live proof. | | Immediate pre-render browser containment part of RISK-003 | PAY-SESSION-001B is tracked in live [#503](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/503). Each current public race and Shop page now keeps one component-local in-memory marker. After existing local checks admit a request, the handler sets that marker synchronously before analytics or its first Checkout promise can settle. A same-action or later submission on that mounted page is inert, including the gap before React renders the existing pending disabled button. Focused actual-route tests prove one service/analytics attempt across same-action and post-render repeats; preserve free-participant, volunteer-without-price-tier, and both paid-link navigation branches; and prove local waiver/native-disabled paths do not consume an attempt. | This browser marker is not a server boundary, durable idempotency key, provider dispatch/result record, business-state lock, payment proof, or reconciliation. It never releases during the mounted page visit because the admitted result must navigate or enter #357's terminal unknown-result state. Existing form controls other than the submit button remain editable. Reload, a remount, another tab/device, a script, or a direct service caller can bypass it. A same-mounted route change instead stays locked, and this slice does not fence an older pending result from that changed route. Complete PAY-002C/D persistence-first deterministic commands, durable replay/reconciliation, protected deployment, and exact website/Firebase/Stripe/live proof. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 204d6c5..43ec822 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -709,6 +709,8 @@ WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) publi **MEMBERS-DIRECTORY-001O visibility focus containment — SOURCE ONLY:** [#647](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/647) changes only visibility-mutation keyboard-focus settlement in the preserved connected Account controls. After request-ID creation and `startMutation('visibility')` admit the exact current operation into `pending`, the component records a pending visibility intent only if the exact **Let verified website administrators find me by name** checkbox owns focus. The intent stores only the mounted application-and-account lifetime and exact operation symbol; it stores no query, name, profile, revision, request ID, result, error, provider value, photo byte, or data URL. Confirmed mutation success plus a current successful authoritative profile read, or a definitive rejection plus a current successful confirming read, transfers only a matching current lifetime and operation to the result ref; the matching ready render then consumes that result once before inspecting its destination. The same persistent checkbox is the only destination. It is left alone if already focused; focus at the document body, document root element, no active element, or a disconnected element returns only to that connected and enabled checkbox; and any other connected outside or in-profile focus deliberately chosen during the mutation or readback is preserved. If current name eligibility makes the returned off checkbox disabled, the result is consumed without focusing it. Programmatic or outside-focused invocation, request-ID failure, rejected mutation admission, ordinary unknown outcome, failed post-mutation or confirming read, application or account change, unmount, and obsolete mutation or readback completion create no result or clear or fail its guards. Unknown and failed-readback paths retain #637 Reload recovery. #643 rejected-removal focus, #645 confirmed-photo focus, native pending disablement, error ownership, confirmation copy, exact revisions, and existing mutation/read/render/context fences remain unchanged. The handoff creates no request ID, read, mutation, retry, audit, result, draft, photo byte, data URL, provider action, or data action beyond the already admitted operation. This adds no data movement, element structure, page topology, service contract, Function, Rule, index, schema, package, workflow, backend, provider, account, sign-in state, production-data action, deployment, publication, photo query, facial recognition, matching, embedding, similarity, biometric processing, total, export, roster authority, membership proof, or connected/live behavior. Availability remains byte-for-byte `false`; the default branch and live #623 preview remain inert, and #507 retains every privacy, authorization, staging, backend-first deployment/readback, availability-flip, publication, and live-proof gate. +**MEMBERS-DIRECTORY-001P successful-Reload result focus — SOURCE ONLY:** [#649](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/649) changes only explicit successful-Reload feedback and keyboard-focus settlement in the preserved connected Account controls. Every valid explicit current **Reload settings** whose guarded authoritative profile read succeeds renders exactly **Profile photo and officer finder settings reloaded.** as the first child of the recovered ready-controls region. The result has `role="status"`, polite live and atomic semantics, `tabIndex={-1}`, and a scoped 3-pixel `#005bd8` focus outline with a 3-pixel offset; it is separate from the bottom mutation confirmations and proves only that the current authoritative read completed. `reloadSettings` records a pending result-focus intent only if the exact rendered Reload button owns focus when that valid unavailable or uncertain-state Reload begins. The intent stores only the mounted application-and-account lifetime and exact load symbol, contains no UID, name, profile, revision, request ID, result, error, provider value, photo byte, query, or data URL, and is bound by the next load effect to that exact load. Only the matching current guarded success transfers it. The ready effect consumes the result intent before target checks, requires the exact current lifetime and load plus the connected result node, leaves an already-focused result alone, returns body, document-root, absent, or disconnected focus to that result, and preserves any other connected outside or in-profile focus deliberately chosen while the read was pending. A programmatic or unfocused valid Reload still renders the truthful result but creates no focus intent, even if its former outside focus later disappears. Initial or background success renders no Reload result and moves no focus. Reload failure renders no success result, clears the new intents, and retains #637 replacement-Reload focus; application change, account change, unmount, a new load, obsolete completion, and later same-context rerender cannot reuse an old intent. The result and handoff create no request ID, extra profile read, mutation, retry, confirmation about an earlier change, audit, draft, photo byte, data URL, provider action, or data action. This adds one visible page-structure node and no data movement, permission, ownership, deployment, service contract, Function, Rule, index, schema, package, workflow, backend, provider, account, sign-in state, production-data action, photo query, facial recognition, matching, embedding, similarity, biometric processing, total, export, roster authority, membership proof, or connected/live behavior. Existing upload, removal, visibility, and People-finder focus contracts remain unchanged. Availability remains byte-for-byte `false`; the default branch and live #623 preview remain inert, and #507 retains every privacy, authorization, staging, backend-first deployment/readback, availability-flip, publication, and live-proof gate. + ### 8.0a Provider-neutral membership authority and entitlement — SOURCE ONLY, UNUSED MEMBERS-IDENTITY-001A [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208), with its command-order correction in MEMBERS-IDENTITY-001H [#451](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/451), defines one unused pure contract that keeps a stable MPRC membership separate from the Firebase account used to sign in. A membership record can exist and receive monotonic term decisions without a UID. Such a record grants no website entitlement. Google, WhatsApp, Strava, email equality, a profile role, and any browser field remain projections or inputs to future reviewed workflows; none is membership authority. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 0e6b047..5cd865d 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -2454,6 +2454,15 @@ flowchart TD FocusInput --> Preserved KeepRetry --> Preserved DropDraft --> Recovery["Existing unknown or unavailable reload state; no Save retry"] + ReloadReview["#649 successful Reload result — SOURCE ONLY"] --> ExplicitReload["Valid explicit current Reload settings"] + ExplicitReload --> ReloadOutcome{"Guarded current authoritative read?"} + ReloadOutcome -- "success" --> ReloadResult["First recovered ready-controls child: Profile photo and officer finder settings reloaded."] + ReloadResult --> ReloadFocus{"Exact Reload owned focus and focus otherwise lost?"} + ReloadFocus -- "yes" --> FocusResult["Focus connected status with visible outline"] + ReloadFocus -- "no intent or connected focus selected" --> PreserveReloadFocus["Show result without moving focus"] + FocusResult --> Preserved + PreserveReloadFocus --> Preserved + ReloadOutcome -- "failure" --> Recovery FinderDisposal["#631 connected People-finder disposal — SOURCE ONLY"] --> Completed["Completed validation, empty, fixed failure, or result-card state"] Completed --> Clear["Clear query, prior messages/headings/cards/names/images; announce local clear; focus input; zero new request or call"] FinderDisposal --> PhotoValue{"Returned photo value?"} @@ -2472,7 +2481,7 @@ flowchart TD Connected -. "never photo search or proof" .-> Official["Membership, role, payment, or official records"] ``` -Text alternative: the published #623 artifact keeps the Account and administrator-guarded People-finder controls disabled; source-only #627 preserves the accessible connected layouts behind the unchanged false availability value; source-only #629 adds local photo review where Cancel sends nothing, Save alone sends the existing upload command, and the current saved photo remains authoritative with a version-reset unavailable fallback; source-only #631 distinguishes a `null` **No photo** result from an unrenderable supplied **Photo unavailable** result, retries a different later photo version, and lets completed local states clear the query, messages, cards, names, and images with an announcement, input focus, and no new request or service call; that Clear action does not cancel work, erase memory or cache, roll back an audit, or recall a seen result; source-only #633 makes **Remove current saved photo** preserve the same local reading or ready replacement through a confirmed authoritative remove, use the refreshed revision only when the person later chooses Save, focus a remaining Remove action before a ready Save action before the persistent file input, keep the draft after a definitive rejection with successful readback, and after an unknown outcome or failed readback discard its bytes, hide photo and finder mutation controls, and retain only the existing Reload settings recovery with no Save retry; source-only #635 keeps that uncertain-change warning through failed Reload settings attempts until one authoritative profile read succeeds, while generic load failures make no global no-change promise and reload sends no mutation; source-only #637 focuses the recovery action after user-initiated mutation or readback failure, binds a Reload focus intent to the exact current load before focusing a replacement after failure, never steals focus on an initial load failure, and keeps stale application, account, and unmounted completions focus-inert; source-only #639 admits a returned saved photo only when its canonical decoded bytes total 12 through 65,536 with `RIFF` at bytes 0–3 and `WEBP` at bytes 8–11, maps every other returned byte shape to one fixed byte-free failure before the Account image path, preserves the version-scoped **Photo unavailable** fallback and Remove action for structurally admitted bytes the browser cannot display, and leaves outbound uploads unchanged; source-only #641 records only the exact current input-or-Search focus origin after a valid search enters pending, restores that same now-enabled origin after cards, empty, or fixed failure only when native disablement left no meaningful focus, and preserves any other connected focus the user chose during the request; source-only #643 records only the exact current lifetime and removal operation when focused Remove enters pending, and after definitive rejection plus successful authoritative readback restores otherwise-lost focus to surviving Remove, a current ready Save, or the persistent file input without stealing deliberately moved connected focus or attaching the standalone rejection alert to a replacement target; source-only #645 records only the exact current lifetime, operation, and upload-or-remove action when focused Save or Remove enters pending, transfers it only after confirmed success plus current authoritative readback, restores otherwise-lost upload focus to the persistent file input or removal focus by the existing Remove, ready Save, then file-input priority, and preserves any other connected focus deliberately chosen during the mutation or readback; source-only #647 records only the exact current lifetime and visibility operation when the focused officer-finder checkbox enters pending, transfers that intent after confirmed success or definitive rejection only after current authoritative readback, restores otherwise-lost focus to the same connected and enabled checkbox without stealing another deliberately focused control, and consumes without focus when current name eligibility leaves the returned off checkbox disabled; and only #507 may later connect name search plus voluntary thumbnails after privacy, authorization, staging, and backend-first readback, without photo search, face recognition, or official-record authority. +Text alternative: the published #623 artifact keeps the Account and administrator-guarded People-finder controls disabled; source-only #627 preserves the accessible connected layouts behind the unchanged false availability value; source-only #629 adds local photo review where Cancel sends nothing, Save alone sends the existing upload command, and the current saved photo remains authoritative with a version-reset unavailable fallback; source-only #631 distinguishes a `null` **No photo** result from an unrenderable supplied **Photo unavailable** result, retries a different later photo version, and lets completed local states clear the query, messages, cards, names, and images with an announcement, input focus, and no new request or service call; that Clear action does not cancel work, erase memory or cache, roll back an audit, or recall a seen result; source-only #633 makes **Remove current saved photo** preserve the same local reading or ready replacement through a confirmed authoritative remove, use the refreshed revision only when the person later chooses Save, focus a remaining Remove action before a ready Save action before the persistent file input, keep the draft after a definitive rejection with successful readback, and after an unknown outcome or failed readback discard its bytes, hide photo and finder mutation controls, and retain only the existing Reload settings recovery with no Save retry; source-only #635 keeps that uncertain-change warning through failed Reload settings attempts until one authoritative profile read succeeds, while generic load failures make no global no-change promise and reload sends no mutation; source-only #637 focuses the recovery action after user-initiated mutation or readback failure, binds a Reload focus intent to the exact current load before focusing a replacement after failure, never steals focus on an initial load failure, and keeps stale application, account, and unmounted completions focus-inert; source-only #639 admits a returned saved photo only when its canonical decoded bytes total 12 through 65,536 with `RIFF` at bytes 0–3 and `WEBP` at bytes 8–11, maps every other returned byte shape to one fixed byte-free failure before the Account image path, preserves the version-scoped **Photo unavailable** fallback and Remove action for structurally admitted bytes the browser cannot display, and leaves outbound uploads unchanged; source-only #641 records only the exact current input-or-Search focus origin after a valid search enters pending, restores that same now-enabled origin after cards, empty, or fixed failure only when native disablement left no meaningful focus, and preserves any other connected focus the user chose during the request; source-only #643 records only the exact current lifetime and removal operation when focused Remove enters pending, and after definitive rejection plus successful authoritative readback restores otherwise-lost focus to surviving Remove, a current ready Save, or the persistent file input without stealing deliberately moved connected focus or attaching the standalone rejection alert to a replacement target; source-only #645 records only the exact current lifetime, operation, and upload-or-remove action when focused Save or Remove enters pending, transfers it only after confirmed success plus current authoritative readback, restores otherwise-lost upload focus to the persistent file input or removal focus by the existing Remove, ready Save, then file-input priority, and preserves any other connected focus deliberately chosen during the mutation or readback; source-only #647 records only the exact current lifetime and visibility operation when the focused officer-finder checkbox enters pending, transfers that intent after confirmed success or definitive rejection only after current authoritative readback, restores otherwise-lost focus to the same connected and enabled checkbox without stealing another deliberately focused control, and consumes without focus when current name eligibility leaves the returned off checkbox disabled; source-only #649 puts the fixed **Profile photo and officer finder settings reloaded** polite atomic status first in recovered ready controls after every valid explicit current Reload success, restores otherwise-lost focus to its connected visibly outlined node only when that exact Reload owned focus and its exact current authoritative read succeeded, preserves any deliberately selected connected focus, shows the result without moving focus for a programmatic or unfocused Reload, and leaves initial/background success and failed Reloads on their existing paths; and only #507 may later connect name search plus voluntary thumbnails after privacy, authorization, staging, and backend-first readback, without photo search, face recognition, or official-record authority. Officer review steps for the #621 frontend preview: @@ -3566,6 +3575,117 @@ Officer source-review procedure for MEMBERS-DIRECTORY-001O [#647] visibility foc **Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real name or photo appeared, focus moved across applications or accounts, settlement stole deliberately moved focus, a disabled checkbox received focus, a focus intent retained private or service data, a focus handoff created a request or service call, or connected behavior became available. +Officer source-review procedure for MEMBERS-DIRECTORY-001P [#649] successful Reload result focus — connected source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify from specialist-prepared evidence that every valid explicit current **Reload settings** success in the preserved connected Account interface shows one exact completion result before the recovered controls, and that only a Reload which owned focus may restore otherwise-lost focus to that result, without connecting the feature, using a real person, or changing production. + +**Approvers:** membership lead, privacy owner, and platform/security owner. + +**Prerequisites:** #647 is reviewed and merged. Ask the platform owner or testing specialist for the exact #649 source candidate, the trustworthy old-source named result, the current named synthetic-only test output, the scoped focus-style evidence, and a redacted written behavior report. The specialist runs the tests and records the evidence. The backup officer reviews that written evidence without a terminal or test harness. A load identity in this procedure means only the component's one-time internal marker for the exact profile read. Keep the source-controlled availability value `false`. Do not sign in to production, use a real name or photo, call production Firebase, or change production data. + +1. Keep the complete profile-photo and People-finder feature marked **NOT AVAILABLE YET**. +2. Ask the platform owner for the exact #649 issue. +3. Ask the platform owner for the reviewed pull request. +4. Ask the platform owner for the candidate or merge commit. +5. Ask the testing specialist for the trustworthy old-source 8-failed and 5-passed named result. +6. Ask the testing specialist for the current 15-passed MEMBERS-DIRECTORY-001P result. +7. Ask the testing specialist for the scoped focus-style evidence. +8. Ask the testing specialist for the redacted written synthetic-behavior report. +9. Confirm the report names the specialist who ran the tests. +10. Confirm every account and name in the evidence is made up. +11. Confirm no real photo appears in the evidence. +12. Confirm the source-controlled availability value remains byte-for-byte `false`. +13. Confirm the last verified production deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +14. Confirm the successful result says exactly **Profile photo and officer finder settings reloaded.**. +15. Confirm every valid explicit current Reload with a successful guarded authoritative read shows that result. +16. Confirm the result is the first child of the recovered ready-controls region. +17. Confirm the result has status semantics. +18. Confirm the result has polite live semantics. +19. Confirm the result has atomic semantics. +20. Confirm the result has a programmatic-only tab position of `-1`. +21. Confirm the result has the scoped `member-directory-profile__reload-result` class. +22. Confirm focused result evidence shows a 3-pixel `#005bd8` outline. +23. Confirm focused result evidence shows a 3-pixel outline offset. +24. Confirm the result is separate from the bottom mutation confirmations. +25. Confirm the result proves only that the current authoritative profile read completed. +26. Confirm the result does not say whether an earlier uncertain change succeeded. +27. Confirm generic unavailable-state recovery can show the exact result after success. +28. Confirm uncertain-change recovery can show the same exact result after success. +29. Confirm initial or background success shows no Reload result. +30. Confirm initial or background success moves no focus. +31. Confirm failed Reload shows no successful result. +32. Confirm failed Reload retains #637 replacement-Reload focus. +33. Confirm only a valid unavailable or uncertain-state Reload invocation can begin this handoff. +34. Confirm the exact rendered Reload button must own focus to create a result-focus intent. +35. Confirm an outside-focused Reload invocation creates no result-focus intent. +36. Confirm a programmatic Reload invocation creates no result-focus intent. +37. Confirm the pending intent records only the current mounted application-and-account lifetime. +38. Confirm the pending intent records only the exact load identity after binding. +39. Confirm the pending intent records no UID, query, or name. +40. Confirm the pending intent records no profile, revision, or request number. +41. Confirm the pending intent records no result, error, or provider value. +42. Confirm the pending intent records no photo byte or data URL. +43. Confirm the next load effect binds the pending intent to that exact load identity. +44. Confirm only the matching current guarded success transfers the intent to the result. +45. Confirm a failed Reload clears the new pending and result intents. +46. Confirm one matching ready effect consumes the result intent before checking its target. +47. Confirm the current mounted lifetime must still match before focus. +48. Confirm the current exact load identity must still match before focus. +49. Confirm the successful result node must still be connected before focus. +50. Confirm a result that already retained focus is not focused again. +51. Confirm document-body focus returns to the successful result. +52. Confirm document-root focus returns to the successful result. +53. Confirm absent focus returns to the successful result. +54. Confirm disconnected focus returns to the successful result. +55. Confirm connected outside focus selected while Reload is pending remains focused. +56. Confirm connected in-profile focus selected while Reload is pending remains focused. +57. Confirm an unfocused Reload whose outside origin disappears does not focus the result. +58. Confirm a later same-context rerender cannot repeat a consumed focus handoff. +59. Confirm an application change makes an older successful Reload result- and focus-inert. +60. Confirm an account change makes an older successful Reload result- and focus-inert. +61. Confirm unmount makes an older successful Reload result- and focus-inert. +62. Confirm a failed Reload followed by exact successful retry focuses only the successful result. +63. Confirm profile-read counts include only the existing initial read and each explicit Reload. +64. Confirm the result and focus handoff create no request number. +65. Confirm the result and focus handoff create no visibility mutation. +66. Confirm the result and focus handoff create no photo-upload call. +67. Confirm the result and focus handoff create no photo-removal call. +68. Confirm the result and focus handoff start no retry. +69. Confirm the result and focus handoff create no draft, photo byte, or data URL. +70. Confirm #643 rejected-removal focus remains unchanged. +71. Confirm #645 confirmed-photo focus remains unchanged. +72. Confirm #647 visibility focus remains unchanged. +73. Confirm the source diff changes no People-finder behavior or administrator guard. +74. Confirm the source diff changes no service contract, Function, Rule, index, or schema. +75. Confirm the source diff changes no package, workflow, permission, ownership, or release control. +76. Confirm the source diff adds no photo query or facial recognition. +77. Confirm the source diff adds no matching, embedding, or similarity score. +78. Confirm the source diff adds no biometric processing, total, export, roster authority, or membership proof. +79. Confirm the default Account branch obtains no directory-service context. +80. Confirm the default Account branch creates no directory request number. +81. Confirm the default Account branch calls no directory service. +82. Record the source change as its own state. +83. Record the named test results as their own state. +84. Record whether the change merged as its own state. +85. Record whether any website artifact was published as its own state. +86. Record the exact `runmprc.com` revision as its own state. +87. Record whether Firebase was deployed as its own state. +88. Record whether an outside provider was configured as its own state. +89. Record whether an account or sign-in state changed as its own state. +90. Record whether production data changed as its own state. +91. Record whether connected or live profile-photo or officer-finder behavior became available as its own state. +92. Stop before changing availability, Firebase, a provider, an account, production data, or the live website. + +**Expected result:** every valid explicit current Reload followed by a guarded successful authoritative profile read renders exactly **Profile photo and officer finder settings reloaded.** as the first child of the recovered ready controls, with status, polite live, atomic, programmatic-focus, and visible-outline semantics. The result proves only that the read completed. An exact focused Reload records only the current lifetime and load identity, and only its matching current success may transfer that intent. One ready effect consumes the intent before target checks. Body, document-root, absent, or disconnected focus returns to the connected result; retained result focus is left alone; and any other connected focus deliberately chosen during the read is preserved. A programmatic or unfocused valid Reload shows the result without moving focus. Initial/background success shows no result. Failure shows no success result and retains #637 replacement-Reload focus. Application change, account change, unmount, a new load, obsolete completion, and later rerender cannot reuse the handoff. The result creates no request number, extra read, mutation, retry, audit, draft, photo byte, data URL, provider action, or data action. Availability remains `false`; the default branch obtains no directory-service context, creates no request number, and calls no directory service; live #623 remains inert; and the backend and connected behavior remain **NOT AVAILABLE YET**. + +**Stop conditions:** a real account, name, or photo; production sign-in; direct production Firebase access; missing or changed successful-Reload copy; a result outside the first recovered ready-controls position; missing status, live, atomic, programmatic-focus, or visible-outline semantics; a result that claims an earlier change succeeded or failed; a result shown for initial/background success or failed Reload; a focused exact current Reload whose successful result receives no otherwise-lost focus; an outside-focused or programmatic invocation that creates an intent; a connected outside or in-profile control that loses deliberately selected focus; an intent that records a UID, query, name, profile, revision, request number, result, error, provider value, photo byte, or data URL; transfer before matching current authoritative success; delayed focus during a later render; an application, account, unmounted, new-load, or obsolete completion that moves focus or renders an old result; an extra request number, read, mutation, retry, audit, draft, photo byte, data URL, provider action, or data action; changed #637, #643, #645, or #647 behavior; a photo query; facial recognition, matching, embedding, similarity scoring, biometric processing, total, export, roster authority, or membership proof; a People-finder, service-contract, Function, Rule, index, schema, package, workflow, permission, ownership, provider, account, sign-in, production-data, or website change; an availability flip; use of a terminal or test harness by the backup officer; or a claim that source, tests, merge, or a preview means the feature is live. + +**Success proof:** record the exact #649 issue, reviewed pull request and commit; trustworthy old-source named result with 8 failures and 5 passes; green 15-of-15 MEMBERS-DIRECTORY-001P block; green full component and frontend tests; type-checking; scoped lint; diagnostic production build; unchanged lint baseline; repository Node tests; workflow checks; diff-check; independent privacy/security, frontend/accessibility, focus/race, and backup-officer reviews; and exact-main CI if merged. Record source, tests, merge, website publication, exact `runmprc.com` revision, Firebase deployment, provider configuration, account/sign-in change, production-data action, and connected/live behavior separately. Record the unchanged `false` availability value and unchanged #623 deploy separately. Source and tests do not prove merge; merge does not prove publication; publication does not prove `runmprc.com`, Firebase, provider, account, data, or connected-live behavior. Final connection and live proof remain #507 work. + +**Undo:** use one reviewed frontend-and-documentation revert or safe roll-forward. Confirm the default disabled branch still makes zero directory calls. No Firebase, provider, account, or production-data undo is needed because #649 changes source only. Undo must not restore the successful-Reload body-focus defect or weaken #637, #643, #645, or #647 focus containment. + +**Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real name or photo appeared, focus moved across applications or accounts, a successful result overstated an earlier change, settlement stole deliberately moved focus, a focus intent retained private or service data, a result or focus handoff created a request or service call, or connected behavior became available. + ## Admin screens — NOT AVAILABLE YET Admin event and product editors exist in source, but their live permissions, backup, preview, and rollback behavior have not been approved. Saving can write directly to production Firestore. Officers must not use these screens as a continuity procedure yet. diff --git a/src/pages/account/Account.css b/src/pages/account/Account.css index 5c95aad..9f57c72 100644 --- a/src/pages/account/Account.css +++ b/src/pages/account/Account.css @@ -206,6 +206,11 @@ align-items: stretch; } +.member-directory-profile__reload-result:focus { + outline: 3px solid #005bd8; + outline-offset: 3px; +} + .member-directory-profile__controls, .member-directory-profile__warning { margin-top: 1rem; diff --git a/src/pages/account/MemberDirectoryProfile.test.tsx b/src/pages/account/MemberDirectoryProfile.test.tsx index bf0e925..7676290 100644 --- a/src/pages/account/MemberDirectoryProfile.test.tsx +++ b/src/pages/account/MemberDirectoryProfile.test.tsx @@ -3476,7 +3476,7 @@ describe('My Account member directory profile', () => { view.unmount(); }); - test('clears recovery focus intent after a successful authoritative reload', async () => { + test('announces and focuses the result after a successful authoritative reload', async () => { (getMyMemberDirectoryProfile as jest.Mock) .mockRejectedValueOnce(new Error('synthetic private initial detail')) .mockResolvedValueOnce(PROFILE_WITH_PHOTO); @@ -3490,7 +3490,8 @@ describe('My Account member directory profile', () => { .toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Reload settings' })) .not.toBeInTheDocument(); - expect(document.activeElement).toBe(document.body); + expect(screen.getByText('Profile photo and officer finder settings reloaded.')) + .toHaveFocus(); expect(screen.getByRole('checkbox')).not.toHaveFocus(); expect(screen.getByLabelText('Replace profile photo')).not.toHaveFocus(); expect(screen.getByRole('button', { name: 'Remove current saved photo' })) @@ -3573,6 +3574,338 @@ describe('My Account member directory profile', () => { }); }); + describe('MEMBERS-DIRECTORY-001P successful Reload result focus', () => { + const reloadSuccessMessage = 'Profile photo and officer finder settings reloaded.'; + + function expectNoDirectoryMutation() { + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } + + async function arrangeDeferredReload( + current: MemberDirectoryProfileData = DEFAULT_PROFILE, + ) { + const reloadRead = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private initial detail')) + .mockReturnValueOnce(reloadRead.promise); + const view = renderProfile(); + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + return { + current, reload, reloadRead, view, + }; + } + + test('renders and focuses the exact polite result before controls after a focused successful Reload', async () => { + const { + current, reload, reloadRead, + } = await arrangeDeferredReload(PROFILE_WITH_PHOTO); + reload.focus(); + expect(reload).toHaveFocus(); + + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + await act(async () => reloadRead.resolve(current)); + + const result = await screen.findByText(reloadSuccessMessage); + expect(result).toHaveAttribute('role', 'status'); + expect(result).toHaveAttribute('aria-live', 'polite'); + expect(result).toHaveAttribute('aria-atomic', 'true'); + expect(result).toHaveAttribute('tabindex', '-1'); + expect(result).toHaveClass('member-directory-profile__reload-result'); + expect(result.parentElement).toHaveClass('member-directory-profile__controls'); + expect(result.parentElement?.firstElementChild).toBe(result); + expect(result).toHaveFocus(); + expect(screen.getByLabelText('Replace profile photo')).not.toHaveFocus(); + expect(screen.getByRole('button', { name: 'Remove current saved photo' })) + .not.toHaveFocus(); + expect(screen.getByRole('checkbox')).not.toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expectNoDirectoryMutation(); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test('shows the same truthful result after uncertain-change recovery without claiming the prior mutation outcome', async () => { + const reloadRead = deferred(); + const current = { + ...DEFAULT_PROFILE, + revision: 1, + searchableByOfficers: true, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockReturnValueOnce(reloadRead.promise); + (setMyMemberDirectoryVisibility as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private outcome detail')); + renderProfile(); + fireEvent.click(await screen.findByRole('checkbox')); + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + reload.focus(); + + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + await act(async () => reloadRead.resolve(current)); + + const result = await screen.findByText(reloadSuccessMessage); + expect(result).toHaveFocus(); + expect(screen.getByRole('checkbox')).toBeChecked(); + expect(result).not.toHaveTextContent(/change (succeeded|failed)|finder is (on|off)/i); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test('does not show or focus a Reload result after an initial background success', async () => { + const initialRead = deferred(); + (getMyMemberDirectoryProfile as jest.Mock).mockReturnValueOnce(initialRead.promise); + render( + <> + + + , + ); + const outside = screen.getByRole('button', { name: 'Outside control' }); + outside.focus(); + + await act(async () => initialRead.resolve(DEFAULT_PROFILE)); + + expect(await screen.findByLabelText('Add profile photo')).toBeInTheDocument(); + expect(screen.queryByText(reloadSuccessMessage)).not.toBeInTheDocument(); + expect(outside).toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); + expectNoDirectoryMutation(); + }); + + test('shows the result but does not move focus for an unfocused programmatic Reload', async () => { + const { + current, reload, reloadRead, + } = await arrangeDeferredReload(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + expect(outside).toHaveFocus(); + + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + outside.remove(); + expect(document.body).toHaveFocus(); + await act(async () => reloadRead.resolve(current)); + + const result = await screen.findByText(reloadSuccessMessage); + expect(result).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + expectNoDirectoryMutation(); + }); + + test.each([ + ['outside', false], + ['inside the profile region', true], + ])('preserves connected focus moved %s while a focused Reload is pending', async ( + _label, + inside, + ) => { + const { + current, reload, reloadRead, + } = await arrangeDeferredReload(); + const destination = inside + ? screen.getByRole('heading', { name: 'Profile photo and officer finder' }) + : document.createElement('button'); + if (inside) destination.setAttribute('tabindex', '-1'); + else document.body.appendChild(destination); + reload.focus(); + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + destination.focus(); + expect(destination).toHaveFocus(); + + await act(async () => reloadRead.resolve(current)); + + expect(await screen.findByText(reloadSuccessMessage)).not.toHaveFocus(); + expect(destination).toHaveFocus(); + expectNoDirectoryMutation(); + if (inside) destination.removeAttribute('tabindex'); + else destination.remove(); + }); + + test.each([ + ['document root', () => document.documentElement], + ['disconnected element', () => document.createElement('button')], + ])('restores the focused Reload result from the %s sentinel', async ( + _label, + activeElement, + ) => { + const { + current, reload, reloadRead, + } = await arrangeDeferredReload(); + reload.focus(); + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + const activeElementSpy = jest.spyOn(document, 'activeElement', 'get') + .mockReturnValue(activeElement()); + try { + await act(async () => reloadRead.resolve(current)); + } finally { + activeElementSpy.mockRestore(); + } + + expect(screen.getByText(reloadSuccessMessage)).toHaveFocus(); + expectNoDirectoryMutation(); + }); + + test('keeps released replacement-Reload focus and omits success copy after failure', async () => { + const reloadRead = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private initial detail')) + .mockReturnValueOnce(reloadRead.promise); + renderProfile(); + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + reload.focus(); + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + + await act(async () => reloadRead.reject(new Error('synthetic private reload detail'))); + + const replacement = await screen.findByRole('button', { name: 'Reload settings' }); + expect(replacement).not.toBe(reload); + expect(replacement).toHaveFocus(); + expect(screen.queryByText(reloadSuccessMessage)).not.toBeInTheDocument(); + expectNoDirectoryMutation(); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test('focuses only the exact success result after a failed Reload is retried', async () => { + (getMyMemberDirectoryProfile as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private initial detail')) + .mockRejectedValueOnce(new Error('synthetic private first reload detail')) + .mockResolvedValueOnce(DEFAULT_PROFILE); + renderProfile(); + const first = await screen.findByRole('button', { name: 'Reload settings' }); + first.focus(); + fireEvent.click(first); + const second = await screen.findByRole('button', { name: 'Reload settings' }); + expect(second).not.toBe(first); + expect(second).toHaveFocus(); + + fireEvent.click(second); + + const result = await screen.findByText(reloadSuccessMessage); + expect(result).toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(3); + expectNoDirectoryMutation(); + }); + + test('consumes successful Reload focus once before a later same-context rerender', async () => { + const { + current, reload, reloadRead, view, + } = await arrangeDeferredReload(); + reload.focus(); + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + await act(async () => reloadRead.resolve(current)); + const result = await screen.findByText(reloadSuccessMessage); + expect(result).toHaveFocus(); + const disposable = document.createElement('button'); + document.body.appendChild(disposable); + disposable.focus(); + disposable.remove(); + expect(document.body).toHaveFocus(); + + view.rerender( + , + ); + + expect(screen.getByText(reloadSuccessMessage)).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expectNoDirectoryMutation(); + }); + + test.each([ + ['application', otherApp, 'synthetic-user'], + ['account', app, 'other-synthetic-user'], + ])('makes an old successful Reload focus- and result-inert after the %s changes', async ( + _label, + nextApp, + nextUid, + ) => { + const oldReload = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private initial detail')) + .mockReturnValueOnce(oldReload.promise) + .mockResolvedValueOnce(DEFAULT_PROFILE); + const view = renderProfile(); + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + reload.focus(); + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + + view.rerender( + , + ); + const currentInput = await screen.findByLabelText('Add profile photo'); + currentInput.focus(); + await act(async () => oldReload.resolve(DEFAULT_PROFILE)); + + expect(currentInput).toHaveFocus(); + expect(screen.queryByText(reloadSuccessMessage)).not.toBeInTheDocument(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(3); + expectNoDirectoryMutation(); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test('makes an old successful Reload focus-inert after unmount', async () => { + const oldReload = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private initial detail')) + .mockReturnValueOnce(oldReload.promise); + const view = renderProfile(); + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + reload.focus(); + fireEvent.click(reload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + view.unmount(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + try { + outside.focus(); + await act(async () => oldReload.resolve(DEFAULT_PROFILE)); + + expect(outside).toHaveFocus(); + expectNoDirectoryMutation(); + } finally { + outside.remove(); + } + }); + + test('keeps the successful Reload result focus outline scoped and visible', () => { + const css = readFileSync(join(__dirname, 'Account.css'), 'utf8'); + + expect(css).toMatch( + /\.member-directory-profile__reload-result:focus\s*\{[^}]*outline:\s*3px solid #005bd8;[^}]*outline-offset:\s*3px;/, + ); + }); + }); + test('reloads settings before allowing a retry after an unknown outcome', async () => { const current = { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; (getMyMemberDirectoryProfile as jest.Mock) diff --git a/src/pages/account/MemberDirectoryProfile.tsx b/src/pages/account/MemberDirectoryProfile.tsx index c0baf54..b03d694 100644 --- a/src/pages/account/MemberDirectoryProfile.tsx +++ b/src/pages/account/MemberDirectoryProfile.tsx @@ -19,6 +19,7 @@ const MIN_CANONICAL_DISPLAY_NAME_CODE_UNITS = 2; const CONTROL_OR_FORMAT_PATTERN = /[\p{Cc}\p{Cf}]/u; const DIRECTORY_TOKEN_PATTERN = /[\p{L}\p{N}][\p{L}\p{M}\p{N}]*/gu; const LOAD_FAILURE_MESSAGE = 'We could not load your profile photo and officer finder settings. Reload settings to try again.'; +const RELOAD_SUCCESS_MESSAGE = 'Profile photo and officer finder settings reloaded.'; const UNKNOWN_CHANGE_MESSAGE = 'We could not confirm that change. Do not make another change yet. Reload settings to check what is currently saved.'; const REJECTED_CHANGE_MESSAGE = 'That change was rejected before it was saved. Review the requirements and try again.'; const REQUEST_UNAVAILABLE_MESSAGE = 'This browser could not safely start that change. No setting was changed. Reload the page and try again.'; @@ -227,6 +228,11 @@ type RecoveryFocusIntent = { load: symbol | null; }; +type ReloadFocusIntent = { + lifetime: symbol; + load: symbol | null; +}; + type RemoveFocusIntent = { lifetime: symbol; operation: symbol; @@ -343,6 +349,8 @@ function MemberDirectoryProfileAttempt({ const mutationRef = useRef(null); const uncertainChangeRef = useRef(false); const recoveryFocusIntentRef = useRef(null); + const pendingReloadFocusIntentRef = useRef(null); + const reloadResultFocusIntentRef = useRef(null); const pendingRemoveFocusIntentRef = useRef(null); const rejectedRemoveResultFocusIntentRef = useRef(null); const pendingConfirmedPhotoFocusIntentRef = useRef(null); @@ -353,6 +361,7 @@ function MemberDirectoryProfileAttempt({ const photoInputRef = useRef(null); const removePhotoButtonRef = useRef(null); const reloadButtonRef = useRef(null); + const reloadSuccessStatusRef = useRef(null); const savePhotoButtonRef = useRef(null); const visibilityCheckboxRef = useRef(null); @@ -365,6 +374,8 @@ function MemberDirectoryProfileAttempt({ mutationRef.current = null; uncertainChangeRef.current = false; recoveryFocusIntentRef.current = null; + pendingReloadFocusIntentRef.current = null; + reloadResultFocusIntentRef.current = null; pendingRemoveFocusIntentRef.current = null; rejectedRemoveResultFocusIntentRef.current = null; pendingConfirmedPhotoFocusIntentRef.current = null; @@ -375,6 +386,28 @@ function MemberDirectoryProfileAttempt({ }; }, []); + useEffect(() => { + if (state.phase !== 'ready' || state.confirmation !== RELOAD_SUCCESS_MESSAGE) return; + const intent = reloadResultFocusIntentRef.current; + if (intent === null) return; + reloadResultFocusIntentRef.current = null; + if ( + intent.lifetime !== lifetimeRef.current + || intent.load === null + || intent.load !== loadRef.current + ) return; + const target = reloadSuccessStatusRef.current; + if (target === null || !target.isConnected) return; + const active = document.activeElement; + if (active === target) return; + if ( + active === null + || active === document.body + || active === document.documentElement + || !active.isConnected + ) target.focus(); + }, [state]); + useEffect(() => { if (state.phase !== 'unknown' && state.phase !== 'unavailable') return; const intent = recoveryFocusIntentRef.current; @@ -485,6 +518,20 @@ function MemberDirectoryProfileAttempt({ ) { recoveryFocusIntentRef.current = { ...focusIntent, load }; } + const pendingReloadFocusIntent = pendingReloadFocusIntentRef.current; + reloadResultFocusIntentRef.current = null; + if ( + pendingReloadFocusIntent !== null + && pendingReloadFocusIntent.lifetime === lifetime + && pendingReloadFocusIntent.load === null + ) { + pendingReloadFocusIntentRef.current = { + ...pendingReloadFocusIntent, + load, + }; + } else { + pendingReloadFocusIntentRef.current = null; + } mutationRef.current = null; pendingRemoveFocusIntentRef.current = null; rejectedRemoveResultFocusIntentRef.current = null; @@ -506,9 +553,26 @@ function MemberDirectoryProfileAttempt({ || lifetimeRef.current !== lifetime || loadRef.current !== load ) return; + const reloadIntent = recoveryFocusIntentRef.current; + const reloaded = reloadIntent !== null + && reloadIntent.lifetime === lifetime + && reloadIntent.source === 'reload' + && reloadIntent.load === load; + const boundReloadFocusIntent = pendingReloadFocusIntentRef.current; + reloadResultFocusIntentRef.current = reloaded + && boundReloadFocusIntent !== null + && boundReloadFocusIntent.lifetime === lifetime + && boundReloadFocusIntent.load === load + ? boundReloadFocusIntent + : null; + pendingReloadFocusIntentRef.current = null; uncertainChangeRef.current = false; recoveryFocusIntentRef.current = null; - setState({ phase: 'ready', profile, confirmation: null }); + setState({ + phase: 'ready', + profile, + confirmation: reloaded ? RELOAD_SUCCESS_MESSAGE : null, + }); } catch { if ( !active @@ -516,6 +580,8 @@ function MemberDirectoryProfileAttempt({ || lifetimeRef.current !== lifetime || loadRef.current !== load ) return; + pendingReloadFocusIntentRef.current = null; + reloadResultFocusIntentRef.current = null; setState({ phase: preserveUncertainChange ? 'unknown' : 'unavailable', }); @@ -914,6 +980,11 @@ function MemberDirectoryProfileAttempt({ (state.phase !== 'unknown' && state.phase !== 'unavailable') || lifetime === null ) return; + reloadResultFocusIntentRef.current = null; + pendingReloadFocusIntentRef.current = reloadButtonRef.current !== null + && document.activeElement === reloadButtonRef.current + ? { lifetime, load: null } + : null; recoveryFocusIntentRef.current = { lifetime, source: 'reload', @@ -972,6 +1043,19 @@ function MemberDirectoryProfileAttempt({ } return (
+ {state.phase === 'ready' + && state.confirmation === RELOAD_SUCCESS_MESSAGE && ( +

+ {RELOAD_SUCCESS_MESSAGE} +

+ )}
Current saved photo @@ -1165,7 +1249,9 @@ function MemberDirectoryProfileAttempt({ {pendingMessage}

)} - {state.phase === 'ready' && state.confirmation && ( + {state.phase === 'ready' + && state.confirmation + && state.confirmation !== RELOAD_SUCCESS_MESSAGE && (

{state.confirmation}