From c930dcd88aa46d654305cc9aec1ad743fe4fa95f Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 13 Aug 2026 19:21:27 -0700 Subject: [PATCH] Preserve visibility toggle focus (#647) --- IMPLEMENTATION_PLAN.md | 2 + SECURITY.md | 1 + SYSTEM_DESIGN.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 117 ++++- .../account/MemberDirectoryProfile.test.tsx | 491 ++++++++++++++++++ src/pages/account/MemberDirectoryProfile.tsx | 63 +++ 6 files changed, 675 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index c54f632..25eb9b2 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -191,6 +191,8 @@ Exit gate: **MEMBERS-DIRECTORY-001N current source boundary:** [#645](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/645) changes only confirmed upload and removal keyboard focus in the preserved connected Account branch. A pending confirmed-photo intent is created only after request-ID creation and `startMutation('upload'|'remove')` admit the exact current operation into `pending`, and only while the exact **Save profile photo** or **Remove current saved photo** initiating action owns focus. It contains only the mounted application-and-account lifetime, exact operation symbol, and upload-or-remove action. A successful mutation plus current successful authoritative readback transfers only the matching lifetime, operation, and action to the result ref for one ready render. Upload keeps the persistent Add/Replace file input as its destination; removal keeps the existing surviving Remove, exact current ready Save, then persistent file-input priority. The already-focused destination is left alone; body, document-root, absent, or disconnected focus returns to that destination; and any other connected focus deliberately chosen during mutation or readback keeps focus. Programmatic or outside-focused invocation, request-ID failure, failed mutation admission, definitive rejection, unknown outcome, failed readback, application or account change, unmount, and stale completion create no intent or clear or fail the guards. #643 rejected-removal behavior, #637 Reload recovery, confirmations, exact calls, bytes, revisions, draft behavior, and existing fences remain unchanged. Focus creates no request ID, callable, retry, mutation, draft, data URL, confirmation, audit, provider action, or data action. Availability stays `false`, so the default branch and live #623 preview remain inert. #645 changes no data movement, page structure, Account wiring, People finder, visibility setting, service contract, Function, Rule, index, schema, package, workflow, backend, provider, account, sign-in, production data, deployment, 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-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. + ### 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 bed357b..d0accc3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -110,6 +110,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Source-only search-focus containment for RISK-042 | MEMBERS-DIRECTORY-001L [#641](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/641) gives the preserved connected People finder one exact-operation focus intent after a valid query and request ID admit `pending`, and only when the persistent name input or Search button owns focus. Current result-card, empty-result, and fixed-failure settlement consumes the intent after render. The already-focused origin is left alone; body, root, absent, or disconnected focus returns to the same now-enabled origin; and another connected element focused during the request retains focus. Programmatic or outside-focused submit creates no intent. Editing, Clear, local validation failure, request-ID failure, application or administrator change, unmount, and stale resolution or rejection clear or fail the guards. Generated-only tests cover both origins and all three outcomes, deliberate outside focus, retained-origin focus, local failures, Clear, context changes, unmount, one exact request, and the unavailable default. | Programmatic focus is accessibility state, not authorization, search correctness, provider acknowledgement, audit proof, membership evidence, or live behavior. It stores no query, name, result, photo, account ID, request ID, or service value and creates no request ID, search, retry, Clear action, result, audit, service call, or data URL. Existing response/privacy bounds and name-only search plus human comparison of voluntary thumbnails remain unchanged; never add a photo query, face recognition, matching, embedding, similarity, biometric processing, totals, export, or roster authority. 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 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. | | 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 630afe8..204d6c5 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -707,6 +707,8 @@ WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) publi **MEMBERS-DIRECTORY-001N confirmed-photo focus containment — SOURCE ONLY:** [#645](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/645) changes only confirmed upload and removal keyboard-focus settlement in the preserved connected Account photo controls. After request-ID creation and `startMutation('upload'|'remove')` admit the exact current operation into `pending`, the component records a pending confirmed-photo intent only if **Save profile photo** owns focus for upload or **Remove current saved photo** owns focus for removal. The intent stores only the mounted application-and-account lifetime, exact operation symbol, and `upload` or `remove` action; it stores no name, profile, revision, request ID, photo bytes, data URL, provider value, response, or error. A successful mutation transfers only a matching current lifetime, operation, and action to the result ref after a current authoritative profile read also succeeds, then the matching ready render consumes the result once. Confirmed upload retains the persistent Add/Replace file input as its destination. Confirmed removal retains the existing priority: a surviving Remove action, otherwise enabled Save for the exact current render-ready draft, otherwise the persistent file input. An already-focused destination is left alone; focus at the document body, document root element, no active element, or a disconnected element returns to the current destination; any other connected focus deliberately chosen during the mutation or readback is preserved. Programmatic or outside-focused invocation, request-ID failure, rejected mutation admission, definitive rejection, unknown outcome, failed readback, application or account change, unmount, and obsolete completion create no intent or clear or fail its guards. #643's separate rejected-removal focus and error ownership, #637 Reload recovery, confirmation copy, draft identity/bytes/render/revision rules, and mutation/read/render/context fences remain unchanged. The handoff creates no request ID, callable, retry, mutation, draft, data URL, confirmation, audit, provider action, or data action. This adds no data movement, page structure, service contract, Function, Rule, index, schema, package, workflow, backend, provider, account, sign-in state, production-data action, deployment, photo query, facial recognition, matching, embedding, similarity, biometric processing, 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-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. + ### 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 f09bcd9..0e6b047 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -2472,7 +2472,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; 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; 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: @@ -3451,6 +3451,121 @@ Officer source-review procedure for MEMBERS-DIRECTORY-001N [#645] confirmed-phot **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 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-001O [#647] visibility focus containment — connected source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify from specialist-prepared evidence that the preserved connected Account interface restores keyboard focus displaced from the exact focused officer-finder checkbox after confirmed success or definitive rejection and authoritative readback, while preserving any other connected focus deliberately chosen during that work, without connecting the feature, using a real person, or changing production. + +**Approvers:** membership lead, privacy owner, and platform/security owner. + +**Prerequisites:** #645 is reviewed and merged. Ask the platform owner or testing specialist for the exact #647 source candidate, the named synthetic-only test output, the trustworthy unchanged-runtime failure, 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. An operation identity in this procedure means only the component's one-time internal marker for the admitted visibility change. 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 #647 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 named MEMBERS-DIRECTORY-001O test output. +6. Ask the testing specialist for the trustworthy unchanged-runtime failure. +7. Ask the testing specialist for the redacted written synthetic-behavior report. +8. Confirm the report names the specialist who ran the tests. +9. Confirm every account and name in the evidence is made up. +10. Confirm no real photo appears in the evidence. +11. Confirm the source-controlled availability value remains byte-for-byte `false`. +12. Confirm the last verified production deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +13. Confirm request-number creation must succeed before a visibility focus intent can exist. +14. Confirm the visibility operation must enter `pending` before the intent can exist. +15. Confirm the exact officer-finder checkbox must own focus when the operation enters `pending`. +16. Confirm an outside-focused invocation creates no visibility focus intent. +17. Confirm a programmatic invocation creates no visibility focus intent. +18. Confirm the intent records only the current mounted application-and-account lifetime. +19. Confirm the intent records only the exact visibility-operation identity. +20. Confirm the intent records no query or name. +21. Confirm the intent records no profile or revision. +22. Confirm the intent records no request number or result. +23. Confirm the intent records no error or provider value. +24. Confirm the intent records no photo byte or data URL. +25. Confirm the checkbox keeps its native disabled state while the visibility operation is pending. +26. Confirm the existing duplicate-mutation gate remains unchanged. +27. Confirm a visibility mutation must report success before the confirmed-success intent can advance. +28. Confirm a current successful authoritative profile read is required after confirmed success. +29. Confirm only a definitive rejection may use the rejection settlement branch. +30. Confirm a current successful confirming profile read is required after definitive rejection. +31. Confirm the current mounted lifetime must match before transfer. +32. Confirm the exact operation identity must match before transfer. +33. Confirm only the matching pending intent transfers to result ownership. +34. Confirm one matching ready render consumes the result exactly once. +35. Confirm the persistent officer-finder checkbox is the only focus destination. +36. Confirm the destination checkbox must still be connected. +37. Confirm the destination checkbox must be enabled. +38. Confirm a checkbox that retained focus is not focused again. +39. Confirm document-body focus returns to the checkbox. +40. Confirm document-root focus returns to the checkbox. +41. Confirm absent focus returns to the checkbox. +42. Confirm disconnected focus returns to the checkbox. +43. Confirm connected outside focus selected during confirmed work remains focused. +44. Confirm connected in-profile focus selected during confirmed work remains focused. +45. Confirm connected outside focus selected during definitive-rejection work remains focused. +46. Confirm connected in-profile focus selected during definitive-rejection work remains focused. +47. Confirm requested-on success restores the enabled checked checkbox after modeled native focus loss. +48. Confirm requested-off success restores the enabled unchecked checkbox after modeled native focus loss. +49. Confirm a changed-again authoritative result restores the enabled checkbox in its returned state. +50. Confirm all existing visibility confirmation wording remains unchanged. +51. Confirm definitive rejection retains the fixed alert association with the checkbox. +52. Confirm a returned off checkbox disabled by current name eligibility is not focused. +53. Confirm the disabled-checkbox result is still consumed permanently. +54. Confirm the request-number-failure evidence shows the existing enabled, generic, zero-call, focus-inert state. +55. Confirm request-number failure sends no visibility mutation. +56. Confirm rejected mutation admission creates no visibility focus intent. +57. Confirm an ordinary unknown outcome uses only the existing **Reload settings** focus recovery. +58. Confirm failed readback after mutation success uses only the existing **Reload settings** focus recovery. +59. Confirm failed confirming read after definitive rejection uses only the existing **Reload settings** focus recovery. +60. Confirm none of those failure paths restores focus to the checkbox. +61. Confirm an application change makes an older visibility mutation focus-inert. +62. Confirm an account change makes an older visibility mutation focus-inert. +63. Confirm an application change makes an older authoritative readback focus-inert. +64. Confirm an account change makes an older authoritative readback focus-inert. +65. Confirm unmount makes an older authoritative readback focus-inert. +66. Confirm a later ready-state rerender cannot repeat a consumed focus handoff. +67. Confirm request-number counts remain unchanged. +68. Confirm visibility-mutation call counts remain unchanged. +69. Confirm authoritative profile-read counts remain unchanged. +70. Confirm the focus handoff creates no photo-upload call. +71. Confirm the focus handoff creates no photo-removal call. +72. Confirm the focus handoff starts no retry. +73. Confirm the focus handoff creates no draft or data URL. +74. Confirm #643 rejected-removal focus remains unchanged. +75. Confirm #645 confirmed-photo focus remains unchanged. +76. Confirm #637 **Reload settings** recovery remains unchanged. +77. Confirm the source diff changes no People-finder behavior or administrator guard. +78. Confirm the source diff changes no service contract, Function, Rule, index, or schema. +79. Confirm the source diff changes no package, workflow, or release control. +80. Confirm the source diff adds no photo query or facial recognition. +81. Confirm the source diff adds no matching, embedding, or similarity score. +82. Confirm the source diff adds no biometric processing, total, export, roster authority, or membership proof. +83. Confirm the default Account branch obtains no directory-service context. +84. Confirm the default Account branch creates no directory request number. +85. Confirm the default Account branch calls no directory service. +86. Record the source change as its own state. +87. Record the named test results as their own state. +88. Record whether the change merged as its own state. +89. Record whether any website artifact was published as its own state. +90. Record the exact `runmprc.com` revision as its own state. +91. Record whether Firebase was deployed as its own state. +92. Record whether an outside provider was configured as its own state. +93. Record whether an account or sign-in state changed as its own state. +94. Record whether production data changed as its own state. +95. Record whether connected or live profile-photo or officer-finder behavior became available as its own state. +96. Stop before changing availability, Firebase, a provider, an account, production data, or the live website. + +**Expected result:** the reviewed connected source creates one pending visibility focus intent only after request-number creation and admitted pending visibility while the exact officer-finder checkbox owns focus. The intent contains only the current mounted application-and-account lifetime and exact operation identity. Confirmed success plus a current successful authoritative read, or definitive rejection plus a current successful confirming read, transfers only the matching intent to one ready render. That render consumes the result once. Body, document-root, absent, or disconnected focus returns only to the same connected and enabled checkbox. A checkbox that retained focus is left alone. Any other connected focus deliberately chosen during mutation or readback is preserved. A returned off checkbox disabled by current name eligibility consumes the result without focus. Programmatic or outside-focused invocation, request-number failure, failed mutation admission, unknown outcome, failed readback, application or account change, unmount, and obsolete mutation or readback completion create no result or clear or fail its guards. #637 Reload recovery, #643 rejected-removal focus, and #645 confirmed-photo focus remain separate. Focus creates no request number, read, mutation, retry, audit, result, draft, photo byte, data URL, provider action, or data action beyond the already admitted operation. Availability remains `false`; the default branch obtains no directory context, creates no request number, and calls no directory service; and live #623 remains inert. The backend and connected behavior remain **NOT AVAILABLE YET**. + +**Stop conditions:** a real account, name, or photo; production sign-in; direct production Firebase access; a focused current checkbox that loses focus during confirmed success or definitive rejection plus current successful readback and never regains it while enabled; focus placed on a returned disabled checkbox; settlement that moves focus away from another connected control; an outside-focused or programmatic invocation that creates an intent; an intent that records a query, name, profile, revision, request number, result, error, provider value, photo byte, or data URL; a transfer before successful current authoritative readback; delayed focus during a later unrelated render; an unknown outcome or failed readback that bypasses Reload recovery; an application, account, unmounted, or obsolete completion that moves focus; an extra request number, read, mutation, retry, audit, result, draft, photo byte, data URL, provider action, or data action caused by focus; changed errors, confirmations, revisions, native pending disablement, #637, #643, or #645 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, 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 #647 issue, reviewed pull request and commit; trustworthy unchanged-runtime named failure; green 21-test MEMBERS-DIRECTORY-001O 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 #647 changes source only. Undo must not restore lost visibility-checkbox focus after authoritative settlement or weaken #637, #643, or #645 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, 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. + ## 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/MemberDirectoryProfile.test.tsx b/src/pages/account/MemberDirectoryProfile.test.tsx index 8ffdf01..bf0e925 100644 --- a/src/pages/account/MemberDirectoryProfile.test.tsx +++ b/src/pages/account/MemberDirectoryProfile.test.tsx @@ -2332,6 +2332,497 @@ describe('My Account member directory profile', () => { }); }); + describe('MEMBERS-DIRECTORY-001O visibility focus containment', () => { + type VisibilityAttemptOptions = { + current: MemberDirectoryProfileData; + definitiveRejection?: boolean; + initial?: MemberDirectoryProfileData; + }; + + function modelDisabledVisibilityFocusEviction(control: HTMLElement) { + control.blur(); + const disposable = document.createElement('button'); + document.body.appendChild(disposable); + disposable.focus(); + expect(disposable).toHaveFocus(); + disposable.remove(); + expect(document.body).toHaveFocus(); + } + + function arrangeVisibilityAttempt({ + current, + definitiveRejection = false, + initial = DEFAULT_PROFILE, + }: VisibilityAttemptOptions) { + const mutation = deferred(); + const readback = deferred(); + (setMyMemberDirectoryVisibility as jest.Mock).mockReturnValueOnce(mutation.promise); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(initial) + .mockReturnValueOnce(readback.promise); + const rejected = { code: 'functions/failed-precondition' }; + if (definitiveRejection) { + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + } + return { + settle: async () => { + const observedMutation = mutation.promise.catch(() => undefined); + await act(async () => { + if (definitiveRejection) mutation.reject(rejected); + else mutation.resolve({}); + await observedMutation; + }); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + await act(async () => readback.resolve(current)); + }, + }; + } + + test.each([ + [ + 'requested on', + DEFAULT_PROFILE, + { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }, + true, + 'Officer finder is on.', + ], + [ + 'requested off', + PROFILE_WITH_PHOTO, + { ...PROFILE_WITH_PHOTO, revision: 5, searchableByOfficers: false }, + false, + 'Officer finder is off.', + ], + [ + 'changed-again result', + DEFAULT_PROFILE, + { ...DEFAULT_PROFILE, revision: 2, searchableByOfficers: false }, + false, + 'Officer finder changed again elsewhere. It is currently off.', + ], + ] as const)( + 'restores the checkbox after browser focus eviction for a confirmed %s', + async (_label, initial, current, expectedChecked, confirmation) => { + const attempt = arrangeVisibilityAttempt({ initial, current }); + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + + fireEvent.click(checkbox); + expect(checkbox).toBeDisabled(); + modelDisabledVisibilityFocusEviction(checkbox); + await attempt.settle(); + + const currentCheckbox = screen.getByRole('checkbox'); + expect(currentCheckbox).toBeEnabled(); + expect(currentCheckbox).toHaveFocus(); + expect(currentCheckbox).toHaveProperty('checked', expectedChecked); + expect(screen.getByRole('status')).toHaveTextContent(confirmation); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }, + ); + + test('restores the checkbox after definitive rejection readback and keeps its error association', async () => { + const attempt = arrangeVisibilityAttempt({ + current: DEFAULT_PROFILE, + definitiveRejection: true, + }); + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + + fireEvent.click(checkbox); + expect(checkbox).toBeDisabled(); + modelDisabledVisibilityFocusEviction(checkbox); + await attempt.settle(); + + const currentCheckbox = screen.getByRole('checkbox'); + expect(currentCheckbox).toHaveFocus(); + expect(currentCheckbox).toBeEnabled(); + expect(currentCheckbox).not.toBeChecked(); + expect(currentCheckbox.getAttribute('aria-describedby')) + .toContain('member-directory-action-error'); + expect(screen.getByRole('alert')).toHaveTextContent( + 'That change was rejected before it was saved.', + ); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); + + test('does not redundantly focus a checkbox that retained focus', async () => { + const current = { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; + const attempt = arrangeVisibilityAttempt({ current }); + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + const focus = jest.spyOn(checkbox, 'focus'); + + fireEvent.click(checkbox); + expect(checkbox).toHaveFocus(); + await attempt.settle(); + + expect(checkbox).toHaveFocus(); + expect(focus).not.toHaveBeenCalled(); + focus.mockRestore(); + }); + + test.each([ + ['confirmed success', false], + ['definitive rejection', true], + ] as const)( + 'preserves deliberate connected outside focus after %s', + async (_label, definitiveRejection) => { + const current = definitiveRejection + ? DEFAULT_PROFILE + : { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; + const attempt = arrangeVisibilityAttempt({ current, definitiveRejection }); + render( + <> + + + , + ); + const checkbox = await screen.findByRole('checkbox'); + const outside = screen.getByRole('button', { name: 'Outside control' }); + checkbox.focus(); + fireEvent.click(checkbox); + outside.focus(); + + await attempt.settle(); + + expect(outside).toHaveFocus(); + expect(screen.getByRole('checkbox')).not.toHaveFocus(); + }, + ); + + test.each([ + ['confirmed success', false], + ['definitive rejection', true], + ] as const)( + 'preserves deliberate connected in-profile focus after %s', + async (_label, definitiveRejection) => { + const current = definitiveRejection + ? DEFAULT_PROFILE + : { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; + const attempt = arrangeVisibilityAttempt({ current, definitiveRejection }); + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + const heading = screen.getByRole('heading', { + name: 'Profile photo and officer finder', + }); + heading.tabIndex = -1; + heading.focus(); + + await attempt.settle(); + + expect(heading).toHaveFocus(); + expect(screen.getByRole('checkbox')).not.toHaveFocus(); + }, + ); + + test('creates no handoff for an outside-focused programmatic invocation', async () => { + const current = { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; + const attempt = arrangeVisibilityAttempt({ current }); + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + + fireEvent.click(checkbox); + outside.remove(); + expect(document.body).toHaveFocus(); + await attempt.settle(); + + expect(document.body).toHaveFocus(); + expect(screen.getByRole('checkbox')).not.toHaveFocus(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + }); + + test('keeps request-ID failure enabled, generic, zero-call, and focus-inert', async () => { + (createMemberDirectoryRequestId as jest.Mock).mockImplementationOnce(() => { + throw new Error('synthetic private request-id detail'); + }); + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + + fireEvent.click(checkbox); + + expect(checkbox).toHaveFocus(); + expect(checkbox).toBeEnabled(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'This browser could not safely start that change. No setting was changed.', + ); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test.each([ + ['ordinary unknown outcome', 'unknown'], + ['post-mutation readback failure', 'readback'], + ['definitive confirming-read failure', 'definitive'], + ] as const)( + 'leaves %s focus recovery exclusively to Reload', + async (_label, outcome) => { + const mutation = deferred(); + const readback = deferred(); + const rejected = { code: 'functions/failed-precondition' }; + (setMyMemberDirectoryVisibility as jest.Mock).mockReturnValueOnce(mutation.promise); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockReturnValueOnce(readback.promise); + if (outcome === 'definitive') { + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + } + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + modelDisabledVisibilityFocusEviction(checkbox); + + const observedMutation = mutation.promise.catch(() => undefined); + await act(async () => { + if (outcome === 'unknown') mutation.reject(new Error('synthetic private detail')); + else if (outcome === 'definitive') mutation.reject(rejected); + else mutation.resolve({}); + await observedMutation; + }); + if (outcome !== 'unknown') { + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + await act(async () => readback.reject(new Error('synthetic private readback detail'))); + } + + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(reload).toHaveFocus(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes( + outcome === 'unknown' ? 1 : 2, + ); + expect(document.body).not.toHaveTextContent('synthetic private'); + }, + ); + + test('consumes the intent without focusing a returned checkbox disabled by name eligibility', async () => { + const current = { ...PROFILE_WITH_PHOTO, revision: 5, searchableByOfficers: false }; + const attempt = arrangeVisibilityAttempt({ + initial: PROFILE_WITH_PHOTO, + current, + }); + const view = renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + modelDisabledVisibilityFocusEviction(checkbox); + + view.rerender( + , + ); + await attempt.settle(); + + const returned = screen.getByRole('checkbox'); + expect(returned).toBeDisabled(); + expect(returned).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + expect(screen.getByText(/current Profile name is not eligible/i)) + .toBeInTheDocument(); + + view.rerender( + , + ); + expect(screen.getByRole('checkbox')).toBeEnabled(); + expect(screen.getByRole('checkbox')).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + }); + + test.each([ + ['application', otherApp, 'synthetic-user'], + ['account', app, 'other-synthetic-user'], + ] as const)( + 'makes old mutation focus inert after the %s changes', + async (_label, nextApp, nextUid) => { + const mutation = deferred(); + (setMyMemberDirectoryVisibility as jest.Mock).mockReturnValueOnce(mutation.promise); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockResolvedValueOnce(DEFAULT_PROFILE); + const view = renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + modelDisabledVisibilityFocusEviction(checkbox); + + view.rerender( + , + ); + const currentCheckbox = await screen.findByRole('checkbox'); + currentCheckbox.focus(); + await act(async () => mutation.resolve({})); + + expect(currentCheckbox).toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + ['application', otherApp, 'synthetic-user'], + ['account', app, 'other-synthetic-user'], + ] as const)( + 'makes old authoritative readback focus inert after the %s changes', + async (_label, nextApp, nextUid) => { + const oldReadback = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockReturnValueOnce(oldReadback.promise) + .mockResolvedValueOnce(DEFAULT_PROFILE); + const view = renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + modelDisabledVisibilityFocusEviction(checkbox); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + + view.rerender( + , + ); + const currentCheckbox = await screen.findByRole('checkbox'); + const outside = document.createElement('button'); + document.body.appendChild(outside); + try { + outside.focus(); + await act(async () => oldReadback.resolve({ + ...DEFAULT_PROFILE, + revision: 1, + searchableByOfficers: true, + })); + + expect(outside).toHaveFocus(); + expect(currentCheckbox).not.toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(3); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + } finally { + outside.remove(); + } + }, + ); + + test('makes an old authoritative readback focus inert after unmount', async () => { + const oldReadback = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockReturnValueOnce(oldReadback.promise); + const view = renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + modelDisabledVisibilityFocusEviction(checkbox); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + view.unmount(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + try { + outside.focus(); + await act(async () => oldReadback.resolve({ + ...DEFAULT_PROFILE, + revision: 1, + searchableByOfficers: true, + })); + + expect(outside).toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + } finally { + outside.remove(); + } + }); + + test('consumes a visibility focus handoff exactly once', async () => { + const current = { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; + const attempt = arrangeVisibilityAttempt({ current }); + render( + <> + + + , + ); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + modelDisabledVisibilityFocusEviction(checkbox); + await attempt.settle(); + expect(checkbox).toHaveFocus(); + const outside = screen.getByRole('button', { name: 'Outside control' }); + outside.focus(); + + const input = screen.getByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['next local bytes'], 'next-local.png', { + type: 'image/png', + })], + }, + }); + await screen.findByRole('img', { name: 'Selected profile photo preview' }); + + expect(outside).toHaveFocus(); + expect(checkbox).not.toHaveFocus(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(document.body).not.toHaveTextContent('next-local.png'); + }); + }); + test('MEMBERS-DIRECTORY-001F falls back when a saved thumbnail cannot decode and resets for a new version', async () => { const newPhoto = { ...PHOTO, diff --git a/src/pages/account/MemberDirectoryProfile.tsx b/src/pages/account/MemberDirectoryProfile.tsx index fc6e6a4..c0baf54 100644 --- a/src/pages/account/MemberDirectoryProfile.tsx +++ b/src/pages/account/MemberDirectoryProfile.tsx @@ -238,6 +238,11 @@ type ConfirmedPhotoFocusIntent = { action: 'upload' | 'remove'; }; +type VisibilityFocusIntent = { + lifetime: symbol; + operation: symbol; +}; + function visibilityConfirmation( requested: boolean, ): MutationConfirmation { @@ -342,11 +347,14 @@ function MemberDirectoryProfileAttempt({ const rejectedRemoveResultFocusIntentRef = useRef(null); const pendingConfirmedPhotoFocusIntentRef = useRef(null); const confirmedPhotoResultFocusIntentRef = useRef(null); + const pendingVisibilityFocusIntentRef = useRef(null); + const visibilityResultFocusIntentRef = useRef(null); const photoReadRef = useRef(null); const photoInputRef = useRef(null); const removePhotoButtonRef = useRef(null); const reloadButtonRef = useRef(null); const savePhotoButtonRef = useRef(null); + const visibilityCheckboxRef = useRef(null); useEffect(() => { const lifetime = Symbol('member-directory-lifetime'); @@ -361,6 +369,8 @@ function MemberDirectoryProfileAttempt({ rejectedRemoveResultFocusIntentRef.current = null; pendingConfirmedPhotoFocusIntentRef.current = null; confirmedPhotoResultFocusIntentRef.current = null; + pendingVisibilityFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = null; photoReadRef.current = null; }; }, []); @@ -442,6 +452,24 @@ function MemberDirectoryProfileAttempt({ ) target.focus(); }, [photoDraft, state]); + useEffect(() => { + const intent = visibilityResultFocusIntentRef.current; + if (state.phase !== 'ready' || intent === null) return; + visibilityResultFocusIntentRef.current = null; + if (intent.lifetime !== lifetimeRef.current) return; + + const target = visibilityCheckboxRef.current; + if (target === null || !target.isConnected || target.disabled) return; + const active = document.activeElement; + if (active === target) return; + if ( + active === null + || active === document.body + || active === document.documentElement + || !active.isConnected + ) target.focus(); + }, [displayNameEligible, state]); + useEffect(() => { const lifetime = lifetimeRef.current; const load = Symbol('member-directory-load'); @@ -462,6 +490,8 @@ function MemberDirectoryProfileAttempt({ rejectedRemoveResultFocusIntentRef.current = null; pendingConfirmedPhotoFocusIntentRef.current = null; confirmedPhotoResultFocusIntentRef.current = null; + pendingVisibilityFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = null; photoReadRef.current = null; setActionError(null); setPhotoDraft(null); @@ -537,6 +567,7 @@ function MemberDirectoryProfileAttempt({ if (isDefinitiveMemberDirectoryRejection(error)) { pendingConfirmedPhotoFocusIntentRef.current = null; confirmedPhotoResultFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = null; try { const profile = await getMyMemberDirectoryProfile(app); if (!mutationIsCurrent(start)) return; @@ -547,6 +578,14 @@ function MemberDirectoryProfileAttempt({ && focusIntent.operation === start.operation ? focusIntent : null; + const visibilityFocusIntent = pendingVisibilityFocusIntentRef.current; + pendingVisibilityFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = visibilityFocusIntent !== null + && visibilityFocusIntent.lifetime === start.lifetime + && visibilityFocusIntent.operation === start.operation + && start.action === 'visibility' + ? visibilityFocusIntent + : null; mutationRef.current = null; uncertainChangeRef.current = false; recoveryFocusIntentRef.current = null; @@ -563,6 +602,8 @@ function MemberDirectoryProfileAttempt({ rejectedRemoveResultFocusIntentRef.current = null; pendingConfirmedPhotoFocusIntentRef.current = null; confirmedPhotoResultFocusIntentRef.current = null; + pendingVisibilityFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = null; photoReadRef.current = null; setPhotoDraft(null); setActionError(null); @@ -580,6 +621,8 @@ function MemberDirectoryProfileAttempt({ rejectedRemoveResultFocusIntentRef.current = null; pendingConfirmedPhotoFocusIntentRef.current = null; confirmedPhotoResultFocusIntentRef.current = null; + pendingVisibilityFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = null; photoReadRef.current = null; setPhotoDraft(null); setActionError(null); @@ -597,6 +640,7 @@ function MemberDirectoryProfileAttempt({ pendingRemoveFocusIntentRef.current = null; rejectedRemoveResultFocusIntentRef.current = null; confirmedPhotoResultFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = null; try { const profile = await getMyMemberDirectoryProfile(app); if (!mutationIsCurrent(start)) return; @@ -609,6 +653,14 @@ function MemberDirectoryProfileAttempt({ && (start.action === 'upload' || start.action === 'remove') ? focusIntent : null; + const visibilityFocusIntent = pendingVisibilityFocusIntentRef.current; + pendingVisibilityFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = visibilityFocusIntent !== null + && visibilityFocusIntent.lifetime === start.lifetime + && visibilityFocusIntent.operation === start.operation + && start.action === 'visibility' + ? visibilityFocusIntent + : null; mutationRef.current = null; if (start.action === 'upload') { photoReadRef.current = null; @@ -622,6 +674,8 @@ function MemberDirectoryProfileAttempt({ mutationRef.current = null; pendingConfirmedPhotoFocusIntentRef.current = null; confirmedPhotoResultFocusIntentRef.current = null; + pendingVisibilityFocusIntentRef.current = null; + visibilityResultFocusIntentRef.current = null; photoReadRef.current = null; setPhotoDraft(null); setActionError(null); @@ -650,6 +704,14 @@ function MemberDirectoryProfileAttempt({ } const start = startMutation('visibility'); if (start === null) return; + visibilityResultFocusIntentRef.current = null; + pendingVisibilityFocusIntentRef.current = document.activeElement + === visibilityCheckboxRef.current + ? { + lifetime: start.lifetime, + operation: start.operation, + } + : null; finishMutation( start, () => setMyMemberDirectoryVisibility(app, { @@ -1050,6 +1112,7 @@ function MemberDirectoryProfileAttempt({