From 5ac46b43b7d2e18e338bb75d23106a851d6c7942 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 13 Aug 2026 18:04:24 -0700 Subject: [PATCH] Restore focus after rejected photo removal (#643) --- IMPLEMENTATION_PLAN.md | 2 + SECURITY.md | 1 + SYSTEM_DESIGN.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 101 ++++- .../account/MemberDirectoryProfile.test.tsx | 426 ++++++++++++++++++ src/pages/account/MemberDirectoryProfile.tsx | 60 +++ 6 files changed, 591 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 731a4fd..e2893e2 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -187,6 +187,8 @@ Exit gate: **MEMBERS-DIRECTORY-001L current source boundary:** [#641](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/641) changes only search-settlement keyboard focus in the preserved connected officer People finder. A valid explicit search records an intent only after request-ID creation admits `pending`, only when the persistent name input or Search button owns focus, and only as the exact operation symbol plus that origin. On that current operation's result-card, empty-result, or fixed-failure render, the intent is consumed: the already-focused origin is left alone; body, root, absent, or disconnected focus returns to the same now-enabled origin; and any other connected focus chosen during the request is preserved. Programmatic or outside-focused submit creates no intent. Editing and Clear remove obsolete intent; validation and request-ID failure never enter `pending`; and application change, administrator change, unmount, or stale resolution or rejection cannot move focus. Clear retains its existing zero-ID, zero-call disposal and input focus. The handoff creates no request ID, search, retry, Clear action, result, audit, service call, or data URL. Existing normalization, native pending controls, response and result behavior, privacy bounds, `AdminGuard`, and stale fences remain unchanged. Availability stays `false`, so the default branch and live #623 preview remain inert. #641 changes no data movement, page structure, Account path, service contract, Function, Rule, index, 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-001M current source boundary:** [#643](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/643) changes only rejected-removal keyboard focus in the preserved connected Account branch. A pending focus intent is created only when request-ID creation and `startMutation('remove')` admit the exact current operation into `pending` while **Remove current saved photo** owns focus, and it contains only the mounted application-and-account lifetime and exact operation symbol. After a definitive rejection and successful current authoritative readback, that matching pending intent transfers to the result ref and the matching ready render chooses a surviving Remove action, otherwise an enabled **Save profile photo** for the exact current ready draft, otherwise the persistent Add/Replace file input for no, reading, or not-yet-ready draft. The already-focused target is left alone; body, document-root, absent, or disconnected focus returns to that target; and another connected element deliberately focused during pending keeps focus. A surviving Remove alone describes the fixed rejection. If Remove disappears, the alert remains standalone and does not become an error for Save or the file input; destination focus does not claim success or deletion proof. Programmatic or outside-focused invocation, request-ID failure, failed mutation admission, confirmed success, unknown outcome, failed confirming read, application or account change, unmount, and stale completion create no intent or clear or fail the guards. Existing confirmed-success focus, #637 Reload recovery, draft identity/bytes/render/revision behavior, mutation/read/render/context fences, and error truth 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. #643 changes no data movement, page structure, Account wiring, service contract, Function, Rule, index, 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. + ### 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 e9f4f04..0e12690 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -108,6 +108,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Source-only recovery-focus containment for RISK-042 | MEMBERS-DIRECTORY-001J [#637](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/637) gives the preserved connected Account branch one explicit recovery-focus intent for a user-initiated uncertain mutation, failed authoritative post-mutation readback, or failed confirming read after definitive rejection. The intent is tied to the current mounted application-and-account lifetime. A Reload-created intent is additionally bound by the next load effect to that exact load identity. Only the matching current unavailable or unknown transition focuses the rendered **Reload settings** action. Repeated failed reloads focus each replacement action. Initial or background load failure has no intent and does not steal focus; successful authoritative reload clears the intent without redirecting focus to an unrelated ready control. Application change, account change, unmount, and stale completion remain focus-inert. Generated-only tests cover all three mutations, both readback-failure classes, repeated uncertain and generic reload failures, successful reload, outside focus, application/account changes, unmount, zero retry or request-ID creation, and the unchanged unavailable default. | Programmatic focus is accessibility state, not provider acknowledgement, authorization, durable reconciliation, deletion proof, or evidence that a stale load became current. It creates no service call, mutation, automatic retry, draft restoration, data URL, or new data flow. The source-controlled availability value stays `false`, and live #623 remains inert. No 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 saved-photo response containment for RISK-042 | MEMBERS-DIRECTORY-001K [#639](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/639) makes the preserved connected Account service admit an inbound returned saved photo only when the existing exact object, `image/webp`, 256×256 dimensions, and UUID version contract contains canonical base64 that decodes to 12 through 65,536 bytes with `RIFF` at bytes 0–3 and `WEBP` at bytes 8–11. Any failure returns only **Invalid member directory response.** without rendering or logging raw bytes, a provider value, or caught detail. A real Account component-to-real-service test with only `firebase/functions` mocked proves mislabeled bytes stop before a saved-thumbnail image or data URL. Structurally admitted but browser-undecodable bytes retain the version-scoped, byte-free **Photo unavailable** fallback and enabled **Remove current saved photo** action without another callable. Outbound JPG, PNG, and WebP admission and exact bytes remain unchanged. | RIFF/WEBP markers are structural admission, not full decoding, safe image proof, authenticity, server-state proof, authorization, or provider acknowledgement; header-shaped hostile bytes can still reach the browser decoder, so the byte-free render fallback remains required. The source-controlled availability value stays `false`, and live #623 remains inert. No Function, Rule, index, schema, package, workflow, photo query, facial recognition, matching, embedding, similarity, biometric processing, 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 only generated non-face test bytes; do not inspect or upload a real photo. | | 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. | | 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 053f227..9f18618 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -703,6 +703,8 @@ WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) publi **MEMBERS-DIRECTORY-001L People-finder search focus — SOURCE ONLY:** [#641](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/641) changes only keyboard-focus settlement in the preserved connected officer People finder. After a valid query and request ID admit an explicit search into `pending`, the component records only that exact operation symbol and whether the persistent name input or Search button owned focus; it stores no query, name, result, photo, account ID, request ID, or service value in the focus intent. When that current operation renders result cards, an empty result, or the fixed unavailable state, the intent is consumed. An origin that remains focused is left alone; focus evicted to the document body, root element, no active element, or a disconnected element returns to the same now-enabled origin; any other connected element that the user focused during the request keeps focus. Editing, Clear, local validation failure, request-ID failure, application or administrator change, unmount, and an obsolete resolution or rejection clear or fail the intent guards. Clear retains its existing local disposal and input focus. The handoff creates no request ID, search, retry, Clear action, result, audit, service call, or data URL. Existing normalization, native pending disablement, response bounds, fixed errors, result and photo behavior, `AdminGuard`, and stale-result fences remain unchanged. This adds no data movement, page structure, service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in state, production-data action, deployment, photo query, face recognition, matching, embedding, similarity, biometric processing, 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-001M rejected-removal focus — SOURCE ONLY:** [#643](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/643) changes only keyboard-focus recovery in the preserved connected Account photo controls. After request-ID creation and `startMutation('remove')` admit an exact current removal into `pending`, the component records a focus intent only if **Remove current saved photo** owns focus. The pending intent stores only the mounted application-and-account lifetime and exact mutation-operation symbol; it stores no photo bytes, profile value, request ID, revision, provider value, or error. A definitive rejection transfers the matching pending intent to the result ref only after a current authoritative profile read succeeds, then the ready render consumes it. The current destination is a surviving Remove action, otherwise the enabled **Save profile photo** action for the exact current ready draft, otherwise the persistent Add/Replace file input for no draft or a reading or not-yet-ready draft. 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 chosen during the mutation is preserved. A surviving Remove alone keeps the fixed rejection description. If Remove disappears, the fixed `role="alert"` remains standalone and is not associated with Save or the file input; moving focus identifies only the current available control and does not imply that removal succeeded or that the rejection belongs to that destination. Programmatic or outside-focused invocation, request-ID failure, rejected mutation admission, confirmed success, unknown outcome, failed confirming read, application or account change, unmount, and obsolete completion create no intent or clear or fail its guards. Existing confirmed-removal focus, #637 Reload recovery, draft identity/bytes/render/revision rules, mutation/read/render/context fences, and fixed errors 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, package, workflow, backend, provider, account, sign-in state, production-data action, deployment, photo query, face recognition, matching, embedding, similarity, biometric processing, 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 6767027..d145260 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; 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; 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: @@ -3247,6 +3247,105 @@ Officer source-review procedure for MEMBERS-DIRECTORY-001L [#641] People-finder **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 administrator accounts, settlement stole deliberately moved focus, a focus handoff created a request or service call, or connected behavior became available. +Officer source-review procedure for MEMBERS-DIRECTORY-001M [#643] rejected-removal focus — connected source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify from specialist-prepared evidence that the preserved connected Account interface returns otherwise-lost keyboard focus to the current surviving photo control after one focused removal is definitively rejected and the current profile is read successfully, without mislabeling the rejection, stealing deliberately moved focus, connecting the feature, using a real person, or changing production. + +**Approvers:** membership lead, privacy owner, and platform/security owner. + +**Prerequisites:** #641 is reviewed and merged. Ask the platform owner or testing specialist for the exact #643 source candidate, the named synthetic-only test output, the trustworthy old-source 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. Keep the source-controlled availability value `false`. Do not sign in to production, choose a real 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 #643 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-001M test output. +6. Ask the testing specialist for the trustworthy old-source 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 image fixture is generated non-face data. +10. Confirm the source-controlled availability value remains byte-for-byte `false`. +11. Confirm the last verified production deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +12. Confirm request-number creation must succeed before a rejected-removal focus intent can exist. +13. Confirm the exact current Remove operation must enter `pending` before the intent can exist. +14. Confirm **Remove current saved photo** must own focus when that operation enters `pending`. +15. Confirm the intent records only the current mounted application-and-account lifetime. +16. Confirm the intent records only the exact mutation-operation identity. +17. Confirm a definitive rejection and current successful readback transfer the same lifetime-and-operation intent from pending ownership to result ownership. +18. Confirm the intent records no photo bytes or data URL. +19. Confirm the intent records no profile value. +20. Confirm the intent records no request number or revision. +21. Confirm the intent records no provider value or caught error. +22. Confirm pending still disables the focused Remove action. +23. Confirm the trustworthy old-source failure explicitly models browser focus eviction from that disabled action. +24. Confirm only a definitive removal rejection can advance the intent toward a ready result. +25. Confirm a successful current authoritative profile read is required before focus can move. +26. Confirm a current saved photo makes the surviving Remove action the first destination. +27. Confirm no saved photo and an exact current ready draft make the enabled **Save profile photo** action the destination. +28. Confirm no saved photo and no draft make the persistent Add profile photo input the destination. +29. Confirm no saved photo and a reading draft make the persistent Add profile photo input the destination. +30. Confirm no saved photo and a not-yet-ready preview make the persistent Add profile photo input the destination. +31. Confirm a destination that already owns focus is not focused again. +32. Confirm document-body focus returns to the current destination. +33. Confirm document-root, absent, or disconnected focus returns to the current destination. +34. Confirm another connected control deliberately focused during pending keeps focus. +35. Confirm an outside-focused or programmatic Remove invocation creates no focus intent. +36. Confirm request-number failure leaves the enabled Remove action focused and sends no removal call. +37. Confirm a failed mutation admission creates no focus intent. +38. Confirm a surviving Remove action alone describes the fixed rejection alert. +39. Confirm a vanished Remove action leaves the fixed rejection as a standalone alert. +40. Confirm the standalone rejection is not attached to **Save profile photo**. +41. Confirm the standalone rejection is not attached to the Add/Replace file input. +42. Confirm the standalone alert does not claim that removal succeeded. +43. Confirm destination focus identifies only the current available control and does not prove deletion. +44. Confirm the existing confirmed-removal destination order remains unchanged. +45. Confirm a failed definitive-rejection readback retains the existing **Reload settings** focus recovery. +46. Confirm an ordinary unknown outcome retains the existing **Reload settings** focus recovery. +47. Confirm an application change makes an older rejected-removal intent focus-inert. +48. Confirm an account change makes an older rejected-removal intent focus-inert. +49. Confirm unmount makes an older rejected-removal intent focus-inert. +50. Confirm an obsolete operation cannot move focus. +51. Confirm the focus handoff creates no second request number. +52. Confirm the focus handoff creates no second removal call. +53. Confirm the focus handoff creates no upload or visibility call. +54. Confirm the focus handoff starts no retry or automatic mutation. +55. Confirm the focus handoff creates no draft or data URL. +56. Confirm the focus handoff creates no confirmation or audit. +57. Confirm the focus handoff performs no provider or production-data action. +58. Confirm the exact current draft identity, bytes, render state, and revision rules remain unchanged. +59. Confirm #637 Reload focus recovery remains unchanged. +60. Confirm the source diff changes no Account wiring or service contract. +61. Confirm the source diff changes no People-finder page or administrator guard. +62. Confirm the source diff changes no Function, Rule, index, or schema. +63. Confirm the source diff changes no package, workflow, or release control. +64. Confirm the source diff adds no photo query or facial recognition. +65. Confirm the source diff adds no image matching, embedding, or similarity score. +66. Confirm the source diff adds no biometric processing, roster authority, or membership proof. +67. Confirm the default Account branch obtains no directory-service context. +68. Confirm the default Account branch creates no directory request number. +69. Confirm the default Account branch calls no directory service. +70. Record the source change as its own state. +71. Record the named test results as their own state. +72. Record whether the change merged as its own state. +73. Record whether any website artifact was published as its own state. +74. Record the exact `runmprc.com` revision as its own state. +75. Record whether Firebase was deployed as its own state. +76. Record whether an outside provider was configured as its own state. +77. Record whether an account or sign-in state changed as its own state. +78. Record whether production data changed as its own state. +79. Record whether connected or live profile-photo behavior became available as its own state. +80. Stop before changing availability, Firebase, a provider, an account, production data, or the live website. + +**Expected result:** the reviewed connected source creates a pending rejected-removal focus intent only after request-number creation and admitted pending removal while **Remove current saved photo** owns focus. That intent contains only the current mounted application-and-account lifetime and exact operation identity. Definitive rejection plus a current successful authoritative readback transfers the matching intent to result ownership, and the ready render consumes it. A current saved photo selects surviving Remove; otherwise an exact current ready draft selects enabled Save; otherwise the persistent Add/Replace input is selected for no, reading, or not-yet-ready draft. Focus returns from the document body, document root, no active element, or a disconnected element, but an already-focused target is left alone and any other connected focus deliberately chosen during pending is preserved. Surviving Remove alone describes the fixed rejection. If Remove disappears, the rejection remains a standalone alert and is not associated with Save or the file input; destination focus does not claim success or deletion proof. Programmatic or outside-focused invocation, request-number failure, failed mutation admission, confirmed success, unknown outcome, failed confirming read, application or account change, unmount, and obsolete completion create no intent or clear or fail its guards. Existing confirmed-removal and #637 Reload focus behavior remains unchanged. Focus creates no request number, callable, retry, mutation, draft, data URL, confirmation, audit, provider action, or data action. 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 Remove action that disappears after definitive rejection and current successful readback while focus remains on the document body; a ready current replacement that does not select Save; a no, reading, or not-yet-ready draft that does not select the persistent file input; a settlement that steals another connected focus; an outside-focused invocation or request-number failure that creates an intent; a standalone rejection attached to Save or the file input; focus represented as removal success or deletion proof; a failed confirming read or unknown outcome that bypasses Reload recovery; an application, account, unmounted, or obsolete completion that moves focus; a second request number, callable, retry, mutation, draft, data URL, confirmation, audit, provider action, or data action caused by focus; changed draft, confirmed-removal, or #637 recovery behavior; a photo query; face recognition, matching, embedding, similarity scoring, biometric processing, roster authority, or membership proof; a 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, or merge means the feature is live. + +**Success proof:** record the exact #643 issue, reviewed pull request and commit; trustworthy old-source failure; green separately named MEMBERS-DIRECTORY-001M focused tests; green full frontend tests; type-checking; scoped lint; diagnostic production build; unchanged lint baseline; 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 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 #643 changes source only. Undo must not leave otherwise-lost focus on the document body after the current rejected-removal readback or misassociate the standalone rejection with a replacement action. + +**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 standalone rejection was attached to the wrong control, 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 7056f02..3f7270c 100644 --- a/src/pages/account/MemberDirectoryProfile.test.tsx +++ b/src/pages/account/MemberDirectoryProfile.test.tsx @@ -10,6 +10,7 @@ import { createMemberDirectoryRequestId, getMyMemberDirectoryProfile, isDefinitiveMemberDirectoryRejection, + MemberDirectoryProfile as MemberDirectoryProfileData, removeMyMemberDirectoryPhoto, setMyMemberDirectoryPhoto, setMyMemberDirectoryVisibility, @@ -1319,6 +1320,431 @@ describe('My Account member directory profile', () => { }); }); + describe('MEMBERS-DIRECTORY-001M rejected-removal focus recovery', () => { + const rejected = { code: 'functions/failed-precondition' }; + const WITHOUT_PHOTO: MemberDirectoryProfileData = { + ...PROFILE_WITH_PHOTO, + revision: 7, + hasPhoto: false, + photo: null, + }; + + function arrangeRejectedRemoval( + current: MemberDirectoryProfileData = WITHOUT_PHOTO, + ) { + const removal = deferred(); + const readback = deferred(); + (removeMyMemberDirectoryPhoto as jest.Mock).mockReturnValueOnce(removal.promise); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockReturnValueOnce(readback.promise); + return { + removal, + readback, + settle: async () => { + await act(async () => { + removal.reject(rejected); + await Promise.resolve(); + }); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + await act(async () => readback.resolve(current)); + }, + }; + } + + function modelDisabledControlFocusEviction(control: HTMLElement) { + control.blur(); + const disposable = document.createElement('button'); + document.body.appendChild(disposable); + disposable.focus(); + expect(disposable).toHaveFocus(); + disposable.remove(); + expect(document.body).toHaveFocus(); + } + + async function selectReplacement({ + previewState = 'ready', + }: { + previewState?: 'ready' | 'loading'; + } = {}) { + const input = await screen.findByLabelText('Replace profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['001m replacement'], 'fixture-001m.png', { + type: 'image/png', + })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + if (previewState === 'ready') fireEvent.load(preview); + return { input, preview }; + } + + test('restores the persistent file input after focused Remove disappears on a definitive rejection readback', async () => { + const attempt = arrangeRejectedRemoval(); + renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + + fireEvent.click(remove); + + expect(remove).toBeDisabled(); + modelDisabledControlFocusEviction(remove); + await attempt.settle(); + + const input = screen.getByLabelText('Add profile photo'); + expect(input).toHaveFocus(); + expect(screen.queryByRole('button', { name: 'Remove current saved photo' })) + .not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'That change was rejected before it was saved. Review the requirements and try again.', + ); + expect(input.getAttribute('aria-describedby')) + .not.toContain('member-directory-action-error'); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + }); + + test('restores the ready replacement Save action without attaching the Remove rejection to it', async () => { + const attempt = arrangeRejectedRemoval(); + renderProfile(); + await selectReplacement(); + const remove = screen.getByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + + fireEvent.click(remove); + + expect(remove).toBeDisabled(); + modelDisabledControlFocusEviction(remove); + await attempt.settle(); + + const save = screen.getByRole('button', { name: 'Save profile photo' }); + const input = screen.getByLabelText('Add profile photo'); + expect(save).toHaveFocus(); + expect(save.getAttribute('aria-describedby')) + .not.toContain('member-directory-action-error'); + expect(input.getAttribute('aria-describedby')) + .not.toContain('member-directory-action-error'); + expect(screen.getByRole('alert')).toHaveTextContent(/rejected before it was saved/i); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); + + test('falls back to the file input while the replacement preview is not ready', async () => { + const attempt = arrangeRejectedRemoval(); + renderProfile(); + await selectReplacement({ previewState: 'loading' }); + const remove = screen.getByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + fireEvent.click(remove); + modelDisabledControlFocusEviction(remove); + + await attempt.settle(); + + expect(screen.getByLabelText('Add profile photo')).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Save profile photo' })).toBeDisabled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); + + test('falls back to the file input while a replacement is still being read', async () => { + const reader = installDeferredFileReader(); + try { + const attempt = arrangeRejectedRemoval(); + renderProfile(); + const input = await screen.findByLabelText('Replace profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['001m reading'], 'fixture-001m-reading.png', { + type: 'image/png', + })], + }, + }); + expect(screen.getByText('Preparing selected photo preview...')).toBeInTheDocument(); + const remove = screen.getByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + fireEvent.click(remove); + modelDisabledControlFocusEviction(remove); + + await attempt.settle(); + + expect(screen.getByLabelText('Add profile photo')).toHaveFocus(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + reader.restore(); + } + }); + + test('restores a surviving Remove action and keeps its fixed error association', async () => { + const current = { + ...PROFILE_WITH_PHOTO, + revision: 7, + photo: { ...PHOTO, version: '22222222-2222-4222-8222-222222222222' }, + }; + const attempt = arrangeRejectedRemoval(current); + renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + fireEvent.click(remove); + modelDisabledControlFocusEviction(remove); + + await attempt.settle(); + + const survivingRemove = screen.getByRole('button', { + name: 'Remove current saved photo', + }); + expect(survivingRemove).toHaveFocus(); + expect(survivingRemove.getAttribute('aria-describedby')) + .toContain('member-directory-action-error'); + }); + + test('does not redundantly focus a surviving Remove action that retained focus', async () => { + const current = { ...PROFILE_WITH_PHOTO, revision: 7 }; + const attempt = arrangeRejectedRemoval(current); + renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + const focus = jest.spyOn(remove, 'focus'); + + fireEvent.click(remove); + expect(remove).toHaveFocus(); + await attempt.settle(); + + expect(remove).toHaveFocus(); + expect(focus).not.toHaveBeenCalled(); + focus.mockRestore(); + }); + + test('creates no focus intent for an outside-focused programmatic Remove invocation', async () => { + const attempt = arrangeRejectedRemoval(); + render( + <> + + + , + ); + const outside = screen.getByRole('button', { name: 'Outside control' }); + outside.focus(); + + fireEvent.click(await screen.findByRole('button', { + name: 'Remove current saved photo', + })); + await attempt.settle(); + + expect(outside).toHaveFocus(); + expect(screen.getByLabelText('Add profile photo')).not.toHaveFocus(); + }); + + test('preserves a deliberately selected connected control during the pending removal', async () => { + const attempt = arrangeRejectedRemoval(); + render( + <> + + + , + ); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + const outside = screen.getByRole('button', { name: 'Outside control' }); + remove.focus(); + fireEvent.click(remove); + outside.focus(); + + await attempt.settle(); + + expect(outside).toHaveFocus(); + expect(screen.getByLabelText('Add profile photo')).not.toHaveFocus(); + }); + + test('preserves a deliberately selected connected in-profile control during the pending removal', async () => { + const attempt = arrangeRejectedRemoval(); + renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + fireEvent.click(remove); + const inProfile = screen.getByRole('heading', { + name: 'Profile photo and officer finder', + }); + inProfile.tabIndex = -1; + inProfile.focus(); + expect(inProfile).toHaveFocus(); + + await attempt.settle(); + + expect(inProfile).toHaveFocus(); + expect(screen.getByLabelText('Add profile photo')).not.toHaveFocus(); + }); + + test('keeps a request-ID failure on the enabled focused Remove with zero removal call', async () => { + (getMyMemberDirectoryProfile as jest.Mock).mockResolvedValueOnce(PROFILE_WITH_PHOTO); + (createMemberDirectoryRequestId as jest.Mock).mockImplementationOnce(() => { + throw new Error('synthetic private request-id detail'); + }); + renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + + fireEvent.click(remove); + + expect(remove).toBeEnabled(); + expect(remove).toHaveFocus(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'This browser could not safely start that change. No setting was changed.', + ); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['definitive confirming-read failure', true], + ['ordinary unknown outcome', false], + ])('leaves rejected-removal focus recovery to Reload after a %s', async ( + _label, + definitive, + ) => { + const failure = definitive ? rejected : new Error('synthetic private outcome detail'); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockRejectedValueOnce(new Error('synthetic private confirming-read detail')); + (removeMyMemberDirectoryPhoto as jest.Mock).mockRejectedValueOnce(failure); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => definitive && error === rejected, + ); + renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + fireEvent.click(remove); + modelDisabledControlFocusEviction(remove); + + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(reload).toHaveFocus(); + expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(definitive ? 2 : 1); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test.each([ + ['application', otherApp, 'synthetic-user'], + ['account', app, 'other-synthetic-user'], + ])('makes an old rejected-removal focus intent inert after the %s changes', async ( + _label, + nextApp, + nextUid, + ) => { + const removal = deferred(); + const oldReadback = deferred(); + (removeMyMemberDirectoryPhoto as jest.Mock).mockReturnValueOnce(removal.promise); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockReturnValueOnce(oldReadback.promise) + .mockResolvedValueOnce(DEFAULT_PROFILE); + const view = renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + fireEvent.click(remove); + modelDisabledControlFocusEviction(remove); + await act(async () => { + removal.reject(rejected); + await Promise.resolve(); + }); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + + view.rerender( + , + ); + const currentInput = await screen.findByLabelText('Add profile photo'); + currentInput.focus(); + await act(async () => oldReadback.resolve(WITHOUT_PHOTO)); + + expect(currentInput).toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(3); + expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + }); + + test('makes an old rejected-removal focus intent inert after unmount', async () => { + const removal = deferred(); + const oldReadback = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockReturnValueOnce(oldReadback.promise); + (removeMyMemberDirectoryPhoto as jest.Mock).mockReturnValueOnce(removal.promise); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + const view = renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + remove.focus(); + fireEvent.click(remove); + modelDisabledControlFocusEviction(remove); + await act(async () => { + removal.reject(rejected); + await Promise.resolve(); + }); + 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(WITHOUT_PHOTO)); + expect(outside).toHaveFocus(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + } finally { + outside.remove(); + } + }); + }); + 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 2d3f139..737ef33 100644 --- a/src/pages/account/MemberDirectoryProfile.tsx +++ b/src/pages/account/MemberDirectoryProfile.tsx @@ -227,6 +227,11 @@ type RecoveryFocusIntent = { load: symbol | null; }; +type RemoveFocusIntent = { + lifetime: symbol; + operation: symbol; +}; + function visibilityConfirmation( requested: boolean, ): MutationConfirmation { @@ -327,6 +332,8 @@ function MemberDirectoryProfileAttempt({ const mutationRef = useRef(null); const uncertainChangeRef = useRef(false); const recoveryFocusIntentRef = useRef(null); + const pendingRemoveFocusIntentRef = useRef(null); + const rejectedRemoveResultFocusIntentRef = useRef(null); const photoReadRef = useRef(null); const photoInputRef = useRef(null); const removePhotoButtonRef = useRef(null); @@ -343,6 +350,8 @@ function MemberDirectoryProfileAttempt({ mutationRef.current = null; uncertainChangeRef.current = false; recoveryFocusIntentRef.current = null; + pendingRemoveFocusIntentRef.current = null; + rejectedRemoveResultFocusIntentRef.current = null; photoReadRef.current = null; postMutationFocusRef.current = null; }; @@ -363,6 +372,36 @@ function MemberDirectoryProfileAttempt({ target.focus(); }, [state.phase]); + useEffect(() => { + const intent = rejectedRemoveResultFocusIntentRef.current; + if (state.phase !== 'ready' || intent === null) return; + rejectedRemoveResultFocusIntentRef.current = null; + if (intent.lifetime !== lifetimeRef.current) return; + + let target: HTMLElement | null = null; + if (state.profile.hasPhoto) { + target = removePhotoButtonRef.current; + } else if ( + photoDraft?.phase === 'preview' + && photoDraft.renderState === 'ready' + && photoReadRef.current === photoDraft.identity + ) { + target = savePhotoButtonRef.current; + } else { + target = photoInputRef.current; + } + if (target === null) return; + + const active = document.activeElement; + if (active === target) return; + if ( + active === null + || active === document.body + || active === document.documentElement + || !active.isConnected + ) target.focus(); + }, [photoDraft, state]); + useEffect(() => { if (state.phase !== 'ready' || postMutationFocusRef.current === null) return; const focusIntent = postMutationFocusRef.current; @@ -402,6 +441,8 @@ function MemberDirectoryProfileAttempt({ recoveryFocusIntentRef.current = { ...focusIntent, load }; } mutationRef.current = null; + pendingRemoveFocusIntentRef.current = null; + rejectedRemoveResultFocusIntentRef.current = null; photoReadRef.current = null; postMutationFocusRef.current = null; setActionError(null); @@ -479,6 +520,13 @@ function MemberDirectoryProfileAttempt({ try { const profile = await getMyMemberDirectoryProfile(app); if (!mutationIsCurrent(start)) return; + const focusIntent = pendingRemoveFocusIntentRef.current; + pendingRemoveFocusIntentRef.current = null; + rejectedRemoveResultFocusIntentRef.current = focusIntent !== null + && focusIntent.lifetime === start.lifetime + && focusIntent.operation === start.operation + ? focusIntent + : null; mutationRef.current = null; uncertainChangeRef.current = false; recoveryFocusIntentRef.current = null; @@ -491,6 +539,8 @@ function MemberDirectoryProfileAttempt({ } catch { if (!mutationIsCurrent(start)) return; mutationRef.current = null; + pendingRemoveFocusIntentRef.current = null; + rejectedRemoveResultFocusIntentRef.current = null; photoReadRef.current = null; setPhotoDraft(null); setActionError(null); @@ -504,6 +554,8 @@ function MemberDirectoryProfileAttempt({ return; } mutationRef.current = null; + pendingRemoveFocusIntentRef.current = null; + rejectedRemoveResultFocusIntentRef.current = null; photoReadRef.current = null; setPhotoDraft(null); setActionError(null); @@ -518,6 +570,8 @@ function MemberDirectoryProfileAttempt({ } if (!mutationIsCurrent(start)) return; + pendingRemoveFocusIntentRef.current = null; + rejectedRemoveResultFocusIntentRef.current = null; try { const profile = await getMyMemberDirectoryProfile(app); if (!mutationIsCurrent(start)) return; @@ -588,6 +642,12 @@ function MemberDirectoryProfileAttempt({ } const start = startMutation('remove'); if (start === null) return; + if (document.activeElement === removePhotoButtonRef.current) { + pendingRemoveFocusIntentRef.current = { + lifetime: start.lifetime, + operation: start.operation, + }; + } finishMutation( start, () => removeMyMemberDirectoryPhoto(app, {