diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 001db34..8b4a246 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -177,6 +177,8 @@ Exit gate: **MEMBERS-DIRECTORY-001G current source boundary:** [#631](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/631) changes only the preserved connected officer People finder. A `null` photo still means **No photo**; a supplied non-null thumbnail that fails browser rendering is removed from the page and becomes the distinct **Photo unavailable** fallback, while a different later photo version gets a fresh render attempt. After completed validation, empty results, fixed failure, or result cards, **Clear search and result cards** clears the query and prior displayed state, announces **Search field and displayed result cards cleared.**, and focuses the persistent name input without creating a request ID or calling the directory service. Clear is not offered while idle or pending and does not cancel work, erase browser memory/cache, reverse an audit, or recall a returned or captured result. Name-only explicit search, visual comparison of voluntary thumbnails, result bounds, guard and stale-result fences remain unchanged; there is no photo query, facial recognition, biometric processing, total, export, or roster authority. The availability value remains `false`, so the default source branch and live #623 preview stay inert. #631 changes no service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in, or production data. #507 still owns privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. +**MEMBERS-DIRECTORY-001H current source boundary:** [#633](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/633) changes only the preserved connected Account photo controls. **Remove current saved photo** distinguishes the authoritative saved thumbnail from a separate local replacement. A confirmed remove and authoritative readback preserves the exact current reading or ready draft identity while updating only the saved-photo state and revision; the replacement is not uploaded until a later explicit Save creates a new request ID and sends the same content type and bytes once against that refreshed revision. Definitive rejection plus successful readback keeps the draft and binds the fixed error to Remove. An unknown outcome or failed post-mutation readback discards the draft, bytes, and data URL, hides the photo and finder mutation controls, and retains only the existing **Reload settings** recovery with no Save or duplicate-mutation retry. Confirmed readback focuses a still-current Remove action first, an eligible Save action second, or the persistent Add/Replace input otherwise. Finder visibility and the existing context, read, render, mutation, and readback fences remain unchanged. No photo query, face recognition, matching, embedding, similarity, biometric processing, roster authority, or membership proof is added. Availability stays `false`, so the default source branch and live #623 preview remain inert. #633 changes no service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in, production data, 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 e852b99..07d325f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -103,6 +103,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Source-only privacy and accessibility containment for RISK-042 | MEMBERS-DIRECTORY-001E [#627](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/627) makes the preserved connected Account branch use the projection's bounded Unicode display-name eligibility before a new opt-in while preserving turn-off for an existing opt-in whose name becomes ineligible. It gives no-photo placeholders image semantics, associates generic photo/setting errors with the affected control, exposes name-search validation state and descriptions, and announces a successful non-empty result without a total. Native controls retain keyboard use, explicit readable foreground/background colors, at least 44-pixel interaction height, and 320-pixel containment. A deferred file read cannot submit or render after the application/user context changes. Generated-only tests cover eligible/ineligible current-name updates, accessibility states, narrow layout/contrast classes, and stale file-read races. | This is source-only frontend hardening behind the unchanged `false` availability value. The live #623 preview remains inert; no backend, Rules, index, service contract, package, workflow, provider, release, account, sign-in, or production-data behavior changes. Accessibility state is not authorization, and a browser eligibility check is not server enforcement. #507 still owns privacy approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Do not use a real name or photo. | | Source-only explicit photo-review containment for RISK-042 | MEMBERS-DIRECTORY-001F [#629](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/629) makes the preserved connected Account branch validate and read a selected JPG, PNG, or WebP locally into a centered-square component-memory draft labeled **Selected photo — not uploaded yet**. Selection, validation, reading, preview, and Cancel create no request ID and call no directory service. Only explicit **Save profile photo** creates the request ID and sends the existing revisioned upload request. Confirmed Save clears the draft after authoritative refetch. Request-ID failure or definitive rejection plus successful refetch retains a retryable draft and adopts the refreshed revision; unknown or failed readback discards the draft and hides controls. A visibility save preserves the draft. The application never reads, retains, renders, logs, sends, or otherwise exposes `file.name`; tests use only opaque generated fixture names. Generation and application/account-context fences prevent an older read or render event from changing or uploading a newer draft. Invalid and unreadable selections retain no draft. Unrenderable bytes and their draft UI are discarded, the fixed error remains associated with the persistent file control, and no Save action is exposed. The authoritative saved thumbnail has a version-reset, byte-free **Photo unavailable** fallback while Remove remains available. | A local preview still places private image bytes in browser memory, and explicit Save does not itself prove an approved notice, scoped authority, backend deployment, or successful processing. Retryable bytes intentionally remain in component memory only for request-ID failure or definitive rejection with successful readback; an unknown outcome clears them. This source-only UI control stays behind the unchanged `false` availability value; the live #623 preview remains inert. It changes no visibility preference or service/server contract. #507 still owns privacy and 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; do not inspect or upload a real photo. | | Source-only rendered-result containment for RISK-042 | MEMBERS-DIRECTORY-001G [#631](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/631) makes the preserved connected People finder distinguish an absent photo from a supplied thumbnail that the browser cannot display. `null` renders **No photo**; a failed non-null render removes the image/data URL from the page and renders **Photo unavailable**; a different later version receives a fresh render attempt. After completed validation, empty results, fixed failure, or result cards, **Clear search and result cards** empties the query, removes the prior message, headings, cards, names, and images from component state and the rendered page, announces **Search field and displayed result cards cleared.**, and focuses the name input. It creates no request ID and makes no directory-service call. Generated-only tests use made-up names and non-face thumbnails to cover the fallbacks, version reset, four clearable outcomes, focus, zero-call behavior, keyboard/44-pixel/contrast/320-pixel layout, and the unchanged unavailable default. | This local rendered-page disposal is not cancellation, browser-memory erasure, cache removal, audit rollback, or recall of a response already returned, seen, or captured. Clear is absent while idle or pending; existing pending, stale-context, guard, projection, cap, and fixed-error behavior remains. The source-controlled availability value stays `false`, and live #623 remains inert. No service/server contract, Firebase, provider, account, sign-in, production data, or 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. Never add a photo query, face recognition, embedding, similarity, biometrics, total, export, or roster authority; use no real name or photo. | +| Source-only reviewed-replacement containment for RISK-042 | MEMBERS-DIRECTORY-001H [#633](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/633) renames the destructive control **Remove current saved photo** and makes a confirmed remove plus authoritative readback preserve the exact current reading or ready component-memory draft identity while updating only the current saved-photo state and revision. The replacement creates no upload request or service call until a later explicit Save creates a new request ID, sends the same content type and bytes once, and uses the refreshed revision. Definitive rejection plus successful readback preserves the draft and associates the fixed error with Remove. Unknown outcome or failed post-mutation readback clears the draft identity, bytes, and data URL, hides photo and finder mutation controls, retains only the existing **Reload settings** recovery, and exposes no Save or duplicate-mutation retry. Confirmed readback moves focus to a still-current Remove action, otherwise an eligible Save action, otherwise the persistent Add/Replace input. Generated-only tests cover exact-byte/revision reuse, reading and ready drafts, no-draft and concurrent-photo focus, rejection, unknown/readback failure, unchanged visibility, stale contexts, reselection, unmount, and the unchanged unavailable default. | Intentionally preserving an unsaved replacement keeps private image bytes in component memory after the current saved photo is removed; it is not an upload, durable draft, server state, cancellation, or deletion proof outside the authoritative saved-photo readback. Context and generation fences limit stale restoration, while unknown state fails closed by discarding the draft. The source-controlled availability value stays `false`, and live #623 remains inert. No service/server contract, photo query, face recognition, matching, embedding, similarity, biometric processing, roster authority, membership proof, Firebase, provider, account, sign-in, production data, 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; do not inspect or upload a real 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 8208c3a..c741461 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -689,6 +689,8 @@ The preserved connected branch sends nothing while the person types. Search happ **MEMBERS-DIRECTORY-001G local People-finder disposal — SOURCE ONLY:** [#631](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/631) changes only the preserved connected officer People finder. A result whose photo value is `null` renders **No photo** with the accessible label **No profile photo for <display name>**. A supplied non-null thumbnail that the browser cannot render instead removes that image and its data URL from the rendered page and shows **Photo unavailable** with the accessible label **Profile photo unavailable for <display name>**; a later result with a different photo version receives a fresh render attempt. After completed local validation, an empty result, a fixed search failure, or displayed result cards, **Clear search and result cards** empties the query, clears the prior validation/failure/completion or empty-result display and every heading, card, name, and image, announces **Search field and displayed result cards cleared.**, and focuses the persistent name input. Clear creates no request ID and makes no directory-service call. It is unavailable during an idle or pending search and does not claim to cancel work, erase browser memory or cache, roll back an audit, or recall a response already returned, seen, or captured. Explicit-submit normalization, pending behavior, stale context/unmount fencing, fixed errors, the bounded projection/result cap, and `AdminGuard` remain unchanged. This is still name search plus visual comparison of voluntary thumbnails: there is no photo query, face recognition, embedding, similarity matching, biometric processing, total, export, or roster authority. Availability remains byte-for-byte `false`; the default source branch and live #623 preview remain inert. #631 changes no service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in state, or production data, and #507 retains every privacy, authorization, staging, deployment, connection, publication, and live-proof gate. +**MEMBERS-DIRECTORY-001H reviewed-replacement removal — SOURCE ONLY:** [#633](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/633) changes only the preserved connected Account photo controls. The destructive action is named **Remove current saved photo** so it cannot be mistaken for the separate local replacement. After a confirmed remove and authoritative profile readback, the component updates the current saved-photo state and revision while preserving the exact current reading or ready local draft and its identity; the replacement creates no upload request or service call until a later explicit **Save profile photo**. That later Save creates one new request ID, sends the same content type and bytes once, and uses the revision returned by the remove readback. A definitive rejected remove with successful readback keeps the draft and associates the fixed error with the remove action. An unknown remove outcome or any failed post-mutation readback clears the draft identity, bytes, and data URL, hides the photo and finder mutation controls, retains only the existing **Reload settings** recovery, and offers no Save or duplicate-mutation retry. After a confirmed readback, focus moves first to a still-valid **Remove current saved photo** action if a current saved photo remains, otherwise to **Save profile photo** for a current ready draft, and otherwise to the persistent Add/Replace file input, including while the preserved draft is still reading. Visibility remains unchanged. Existing application, account, unmount, reselection, read, render, mutation, and readback fences keep stale completions from restoring bytes, focus, state, or a service call in another context. This adds no photo query, recognition, matching, embedding, similarity, biometric processing, roster authority, or membership proof. Availability remains byte-for-byte `false`; the default source branch and live #623 preview remain inert. #633 changes no service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in state, production data, or connected/live behavior, and #507 retains every privacy, authorization, staging, deployment, connection, publication, and live-proof gate. + The backend source still has no production index, Rules, Function, privacy-notice, retention/backup approval, or live proof. Synthetic local artifacts and tests prove that the protected #621 layouts are disabled and their default branches make zero directory calls. Publishing and anonymously reading back the exact frontend may prove only its revision, normal sign-in and administrator guards, and absence of a public member-directory request. [#507](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/507) must complete the #110 policy decision, scoped authorization, #133 protected short-lived authority, any required index and backend-first deployment/readback (including the trigger retry policy), isolated synthetic staging checks, and backup-officer procedure before a separately reviewed source change turns availability on. Only then may the connected website be published and verified on `runmprc.com`. #507 must also own an idempotent, dry-runnable projection repair/backfill for any later schema/normalization upgrade, or prove no older preference data exists for the initial target. A green source build, disabled preview, merge, or frontend publication is not authorization to search a real name or inspect a real photo. WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) published only that inert interface as deploy `6a7e072f8f346b0008510d29`, from source `c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec`, tree `411aa6ec9a9459f5d923030533ffc7c007fe6908`, and a 62-file artifact with digest `d837272a1e5efc1575809e87f532276b38d1a63f1dd79ec1aef0533f6da8afb1`. Protected-layout proof remains synthetic. Completed signed-out public proof is limited to the exact revision, normal sign-in and administrator guards, and absence of a member-directory request. The manifest is inactive; the release source is absent; and the rollback ref remains. #623 deployed no Firebase, Rules, Functions, or indexes and changed no provider configuration, account, sign-in, or production data. #507 remains **NOT AVAILABLE YET**. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 6ee0b22..c93fbe0 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -2440,6 +2440,20 @@ flowchart TD Send --> Readback["Refetch authoritative processed thumbnail or no-photo fallback"] Discard --> Preserved Readback --> Preserved + RemovalReview["#633 reviewed-replacement removal — SOURCE ONLY"] --> RemoveCurrent["Remove current saved photo"] + RemoveCurrent --> RemovalReadback{"Remove outcome and authoritative readback?"} + RemovalReadback -- "confirmed" --> KeepDraft["Update saved-photo state and revision; preserve same reading or ready local draft identity; no upload"] + RemovalReadback -- "definitive rejection and successful readback" --> KeepRetry["Keep draft; fixed error describes Remove"] + RemovalReadback -- "unknown or readback fails" --> DropDraft["Discard draft bytes and data URL; hide photo and finder mutation controls"] + KeepDraft --> FocusChoice{"Useful focus target?"} + FocusChoice -- "saved photo remains" --> FocusRemove["Remove current saved photo"] + FocusChoice -- "current draft ready" --> FocusSave["Save profile photo"] + FocusChoice -- "reading or no ready draft" --> FocusInput["Persistent Add or Replace input"] + FocusSave -. "later explicit Save sends same bytes once with readback revision" .-> Send + FocusRemove --> Preserved + FocusInput --> Preserved + KeepRetry --> Preserved + DropDraft --> Recovery["Existing unknown or unavailable reload state; no Save retry"] FinderDisposal["#631 connected People-finder disposal — SOURCE ONLY"] --> Completed["Completed validation, empty, fixed failure, or result-card state"] Completed --> Clear["Clear query, prior messages/headings/cards/names/images; announce local clear; focus input; zero new request or call"] FinderDisposal --> PhotoValue{"Returned photo value?"} @@ -2458,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; 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; 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: @@ -2772,6 +2786,91 @@ Officer source-review procedure for MEMBERS-DIRECTORY-001G [#631] local People-f **Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real name or photo appeared, a supplied image remained exposed after failure or Clear, Clear made a request, connected behavior became available, or the local result could not be removed from the rendered page. +Officer source-review procedure for MEMBERS-DIRECTORY-001H [#633] reviewed-replacement removal — connected source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify that removing the current saved photo does not silently discard a separate local replacement and that keyboard focus returns to a useful photo action. This review does not connect the feature, upload a real photo, or change production. + +**Approvers:** membership lead, privacy owner, and platform/security owner. + +**Prerequisites:** #631 is reviewed and merged. Ask the platform owner or testing specialist for the exact #633 source candidate, the named generated-only test output, and a redacted written synthetic-behavior report. That specialist runs the tests and records the evidence. The backup officer reviews the written evidence without a terminal or test harness. Keep the source-controlled availability value `false`. Use only a made-up account and generated non-face images. 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 #633 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-001H test output. +6. Ask the testing specialist for the redacted written synthetic-behavior report. +7. Confirm the report names the specialist who ran the tests. +8. Confirm the evidence uses only a made-up account. +9. Confirm the evidence uses only generated non-face images. +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 the destructive action is named **Remove current saved photo**. +13. Confirm that label distinguishes the saved thumbnail from the local replacement. +14. Confirm selecting a replacement creates no upload request number. +15. Confirm reading a replacement calls no upload service. +16. Confirm reviewing a ready replacement calls no upload service. +17. Confirm a successful remove refetches the authoritative profile. +18. Confirm that readback updates the current saved-photo state. +19. Confirm that readback updates the revision used by a later Save. +20. Confirm a current ready replacement keeps the same local identity after confirmed removal. +21. Confirm a current ready replacement keeps the same generated bytes after confirmed removal. +22. Confirm confirmed removal does not upload the preserved replacement. +23. Confirm a later explicit Save creates one new request number. +24. Confirm that Save sends the preserved content type once. +25. Confirm that Save sends the preserved bytes once. +26. Confirm that Save uses the revision returned by the remove readback. +27. Confirm a current reading replacement keeps its identity after confirmed removal. +28. Confirm the current reader may finish locally after confirmed removal. +29. Confirm the finished reader still requires explicit Save before upload. +30. Confirm a successful remove does not change the officer-finder choice. +31. Confirm readback with a current saved photo focuses **Remove current saved photo**. +32. Confirm readback with no saved photo and a ready current draft focuses **Save profile photo**. +33. Confirm readback with a reading draft focuses the persistent Add/Replace file input. +34. Confirm readback with no draft focuses the persistent Add/Replace file input. +35. Confirm definitive remove rejection plus successful readback keeps the local draft. +36. Confirm that rejection exposes one fixed error for **Remove current saved photo**. +37. Confirm an unknown remove outcome discards the local draft. +38. Confirm a failed post-remove readback discards the local draft. +39. Confirm each discarded draft leaves no generated data URL in the rendered page. +40. Confirm each unknown or failed-readback state exposes no Save retry. +41. Confirm each unknown or failed-readback state hides the photo and finder mutation controls. +42. Confirm each unknown or failed-readback state retains only the existing **Reload settings** recovery. +43. Confirm an older reader cannot replace a current reselection. +44. Confirm an old remove completion cannot restore a draft after the application changes. +45. Confirm an old remove completion cannot restore a draft after the made-up account changes. +46. Confirm an old remove completion cannot restore state after unmount. +47. Confirm no stale completion moves focus in a later context. +48. Confirm no stale completion sends a photo in a later context. +49. Confirm the default Account branch obtains no directory context. +50. Confirm the default Account branch creates no request number. +51. Confirm the default Account branch calls no directory service. +52. Confirm the source diff changes no Account wiring or People-finder page. +53. Confirm the source diff changes no client service contract. +54. Confirm the source diff adds no photo query, face recognition, or biometric processing. +55. Confirm the source diff changes no Function, Rule, index, schema, package, workflow, or release control. +56. Record the source change as its own state. +57. Record the named test results as their own state. +58. Record whether the change merged as its own state. +59. Record whether any website artifact was published as its own state. +60. Record the exact `runmprc.com` revision as its own state. +61. Record whether Firebase was deployed as its own state. +62. Record whether an outside provider was configured as its own state. +63. Record whether an account or sign-in state changed as its own state. +64. Record whether production data changed as its own state. +65. Record whether connected behavior became available as its own state. +66. Stop before changing availability, Firebase, a provider, an account, production data, or the live website. + +**Expected result:** the reviewed connected source clearly separates **Remove current saved photo** from the local replacement. Confirmed removal and authoritative readback preserve the same current reading or ready draft while updating the saved-photo state and revision. A later explicit Save sends the preserved content type and bytes once against that refreshed revision. Definitive rejection plus successful readback keeps the draft and associates the fixed error with Remove. Unknown outcome or failed readback discards the draft and data URL, hides the photo and finder mutation controls, retains only the existing **Reload settings** recovery, and offers no Save or duplicate-mutation retry. Focus moves to a still-current Remove action first, an eligible Save action second, or the persistent Add/Replace input otherwise. Finder visibility stays unchanged. Stale completions remain inert. Availability remains `false`; the default branch and live #623 preview remain inert. The backend and connected behavior remain **NOT AVAILABLE YET**. + +**Stop conditions:** a real account or photo; production sign-in; a replacement upload before explicit Save; a local draft lost after confirmed removal; a draft retained after an unknown outcome or failed readback; a discarded data URL left in the page; Save using the pre-remove revision; duplicate upload; focus sent to a missing or stale control; an older reader, mutation, or readback restoring bytes, state, focus, or a service call in another context; a photo change that alters finder visibility; a Firebase, Rule, index, Function, service-contract, 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 #633 issue, reviewed pull request and commit; trustworthy old-source failure; green separately named MEMBERS-DIRECTORY-001H focused tests; green full frontend tests; type-checking; diagnostic production build; unchanged lint baseline; workflow checks; diff-check; independent privacy/security, frontend/accessibility, 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 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 #633 changes source only. Undo must not retain or restore a stale local draft. + +**Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real photo appeared, a replacement was uploaded without explicit Save, draft bytes survived an unknown state, finder visibility changed, or connected behavior became available. + **Expected result:** production deploy `6a7e072f8f346b0008510d29` defaults to a visibly disabled preview that makes zero directory calls, accepts no file or name, and shows no person. Separate synthetic tests prove the protected disabled layouts and preserved connected source. Completed signed-out public readback proves only the exact revision, normal guards, and absence of a member-directory request. The backend and connected behavior remain unavailable. There is no public directory, official roster, public photo URL, Firebase Storage object, photo-as-query path, face recognition, similarity score, embedding, biometric template, export, result total, or pagination. **Stop conditions:** an enabled preview control; a preview that reads saved directory state, accepts a file or finder name, creates a directory request number, calls a directory service, or shows a sample or result card; a real person, name, photo, member record, production sign-in, direct production Firebase access, production data change, or member-directory callable request; a public/permanent photo URL; an upload that silently opts in; a result from a search transaction ordered after completed opt-out; a notice that fails to explain that an earlier-committed response may still arrive; raw image, name, query, result identity, or provider detail in a log, issue, screenshot, message, email, or AI tool; a request for photo search, face recognition, similarity matching, or biometric processing; missing privacy approval for connected publication; an unreviewed availability flip; or a claim that source, tests, merge, frontend publication, or preview means the backend feature is live. diff --git a/src/pages/account/MemberDirectoryProfile.test.tsx b/src/pages/account/MemberDirectoryProfile.test.tsx index e7601d0..882a8ea 100644 --- a/src/pages/account/MemberDirectoryProfile.test.tsx +++ b/src/pages/account/MemberDirectoryProfile.test.tsx @@ -284,7 +284,7 @@ describe('My Account member directory profile', () => { 'accept', 'image/jpeg,image/png,image/webp', ); - expect(screen.queryByRole('button', { name: 'Remove profile photo' })) + expect(screen.queryByRole('button', { name: 'Remove current saved photo' })) .not.toBeInTheDocument(); expect(screen.getByText(/search result does not prove current club membership/i)) .toBeInTheDocument(); @@ -958,55 +958,365 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('status')).toHaveTextContent('Officer finder is off.'); }); - test('removes a photo without changing an enabled finder setting', async () => { - const refreshed = { - ...PROFILE_WITH_PHOTO, - revision: 5, - hasPhoto: false, - photo: null, - }; - (getMyMemberDirectoryProfile as jest.Mock) - .mockResolvedValueOnce(PROFILE_WITH_PHOTO) - .mockResolvedValueOnce(refreshed); - renderProfile(); - const remove = await screen.findByRole('button', { name: 'Remove profile photo' }); + describe('MEMBERS-DIRECTORY-001H saved-photo removal with a local draft', () => { + async function selectReadyReplacement(bytes: string, filename: string) { + const input = await screen.findByLabelText('Replace profile photo'); + fireEvent.change(input, { + target: { + files: [new File([bytes], filename, { type: 'image/png' })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + return { input, preview }; + } - fireEvent.click(remove); + test('preserves a ready replacement after confirmed removal and focuses its Save action', async () => { + const refreshed = { + ...PROFILE_WITH_PHOTO, + revision: 5, + hasPhoto: false, + photo: null, + }; + const saved = { + ...refreshed, + revision: 6, + hasPhoto: true, + photo: { + ...PHOTO, + version: '22222222-2222-4222-8222-222222222222', + }, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(refreshed) + .mockResolvedValueOnce(saved); + renderProfile(); + await selectReadyReplacement('preserved replacement', 'fixture-001h-001.png'); - await waitFor(() => expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledWith(app, { - requestId: REQUEST_ID, - expectedRevision: 4, - })); - expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); - expect(await screen.findByRole('img', { name: 'No profile photo' })) - .toBeInTheDocument(); - expect(screen.getByRole('checkbox', { - name: 'Let verified website administrators find me by name', - })).toBeChecked(); - expect(screen.getByRole('status')).toHaveTextContent( - 'Profile photo removed. Your officer finder setting did not change.', - ); - }); + fireEvent.click(screen.getByRole('button', { name: 'Remove current saved photo' })); - test('announces a current photo restored elsewhere after removal', async () => { - const changedAgain = { - ...PROFILE_WITH_PHOTO, - revision: 6, - photo: { ...PHOTO, version: '22222222-2222-4222-8222-222222222222' }, - }; - (getMyMemberDirectoryProfile as jest.Mock) - .mockResolvedValueOnce(PROFILE_WITH_PHOTO) - .mockResolvedValueOnce(changedAgain); - renderProfile(); + expect(await screen.findByRole('img', { name: 'No profile photo' })) + .toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toHaveAttribute( + 'src', + `data:image/png;base64,${btoa('preserved replacement')}`, + ); + expect(screen.getByRole('button', { name: 'Save profile photo' })) + .toHaveFocus(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); - fireEvent.click(await screen.findByRole('button', { name: 'Remove profile photo' })); + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); - expect(await screen.findByText( - 'Profile photo changed again elsewhere. The preview shows the current photo.', - )).toHaveAttribute('role', 'status'); - expect(screen.getByRole('img', { name: 'Your current profile thumbnail' })) - .toBeInTheDocument(); - expect(screen.getByRole('status')).not.toHaveTextContent('Profile photo removed.'); + await waitFor(() => expect(setMyMemberDirectoryPhoto).toHaveBeenCalledWith(app, { + requestId: REQUEST_ID, + expectedRevision: 5, + contentType: 'image/png', + base64Data: btoa('preserved replacement'), + })); + expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(2); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + expect(await screen.findByRole('img', { name: 'Your current profile thumbnail' })) + .toBeInTheDocument(); + }); + + test('lets the current FileReader finish locally after confirmed removal and focuses the input while it is reading', async () => { + const deferredReader = installDeferredFileReader(); + try { + const refreshed = { + ...PROFILE_WITH_PHOTO, + revision: 5, + hasPhoto: false, + photo: null, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(refreshed); + renderProfile(); + const input = await screen.findByLabelText('Replace profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['reading replacement'], 'fixture-001h-002.png', { + type: 'image/png', + })], + }, + }); + expect(screen.getByText('Preparing selected photo preview...')) + .toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { + name: 'Remove current saved photo', + })); + + expect(await screen.findByRole('img', { name: 'No profile photo' })) + .toBeInTheDocument(); + expect(screen.getByLabelText('Add profile photo')).toHaveFocus(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + + await act(async () => deferredReader.readers[0].complete('reading replacement')); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + expect(preview).toHaveAttribute( + 'src', + `data:image/png;base64,${btoa('reading replacement')}`, + ); + fireEvent.load(preview); + expect(screen.getByRole('button', { name: 'Save profile photo' })) + .toBeEnabled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + deferredReader.restore(); + } + }); + + test('keeps only the current reselection when deferred reads finish after confirmed removal', async () => { + const deferredReader = installDeferredFileReader(); + try { + const refreshed = { + ...PROFILE_WITH_PHOTO, + revision: 5, + hasPhoto: false, + photo: null, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(refreshed); + renderProfile(); + const input = await screen.findByLabelText('Replace profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['stale replacement'], 'fixture-001h-003.png', { + type: 'image/png', + })], + }, + }); + fireEvent.change(input, { + target: { + files: [new File(['current replacement'], 'fixture-001h-004.png', { + type: 'image/png', + })], + }, + }); + + fireEvent.click(screen.getByRole('button', { + name: 'Remove current saved photo', + })); + expect(await screen.findByRole('img', { name: 'No profile photo' })) + .toBeInTheDocument(); + + await act(async () => deferredReader.readers[0].complete('stale replacement')); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + await act(async () => deferredReader.readers[1].complete('current replacement')); + + expect(await screen.findByRole('img', { + name: 'Selected profile photo preview', + })).toHaveAttribute( + 'src', + `data:image/png;base64,${btoa('current replacement')}`, + ); + expect(document.body.innerHTML).not.toContain(btoa('stale replacement')); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + deferredReader.restore(); + } + }); + + test('preserves the ready draft and associates a fixed error after a definitive rejected remove readback', async () => { + const rejected = { code: 'functions/failed-precondition' }; + const current = { ...PROFILE_WITH_PHOTO, revision: 7 }; + (removeMyMemberDirectoryPhoto as jest.Mock).mockRejectedValueOnce(rejected); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(current); + renderProfile(); + await selectReadyReplacement('rejected replacement', 'fixture-001h-005.png'); + + fireEvent.click(screen.getByRole('button', { + name: 'Remove current saved photo', + })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'That change was rejected before it was saved. Review the requirements and try again.', + ); + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toHaveAttribute( + 'src', + `data:image/png;base64,${btoa('rejected replacement')}`, + ); + expect(screen.getByRole('button', { name: 'Save profile photo' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Remove current saved photo' }) + .getAttribute('aria-describedby')).toContain('member-directory-action-error'); + expect(screen.getByRole('checkbox')).toBeChecked(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + }); + + test.each([ + ['outcome is unknown', false], + ['successful mutation readback fails', true], + ])('discards draft bytes and hides connected controls when the remove %s', async (_label, resolves) => { + if (resolves) { + (removeMyMemberDirectoryPhoto as jest.Mock).mockResolvedValueOnce({}); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockRejectedValueOnce(new Error('synthetic private readback detail')); + } else { + (removeMyMemberDirectoryPhoto as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private outcome detail')); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO); + } + renderProfile(); + await selectReadyReplacement('discarded replacement', 'fixture-001h-006.png'); + + fireEvent.click(screen.getByRole('button', { + name: 'Remove current saved photo', + })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not confirm that change/i, + ); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(screen.queryByLabelText('Replace profile photo')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add profile photo')).not.toBeInTheDocument(); + expect(document.body.innerHTML).not.toContain(btoa('discarded replacement')); + expect(document.body).not.toHaveTextContent('synthetic private'); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); + + test('focuses the file input after confirmed removal with no local draft and leaves visibility on', async () => { + const refreshed = { + ...PROFILE_WITH_PHOTO, + revision: 5, + hasPhoto: false, + photo: null, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(refreshed); + renderProfile(); + const remove = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + + fireEvent.click(remove); + + await waitFor(() => expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledWith(app, { + requestId: REQUEST_ID, + expectedRevision: 4, + })); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + expect(await screen.findByRole('img', { name: 'No profile photo' })) + .toBeInTheDocument(); + expect(screen.getByRole('checkbox', { + name: 'Let verified website administrators find me by name', + })).toBeChecked(); + expect(screen.getByRole('status')).toHaveTextContent( + 'Profile photo removed. Your officer finder setting did not change.', + ); + expect(screen.getByLabelText('Add profile photo')).toHaveFocus(); + }); + + test('focuses the still-current saved-photo remove action after concurrent restoration', async () => { + const changedAgain = { + ...PROFILE_WITH_PHOTO, + revision: 6, + photo: { ...PHOTO, version: '22222222-2222-4222-8222-222222222222' }, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(changedAgain); + renderProfile(); + await selectReadyReplacement('concurrent replacement', 'fixture-001h-007.png'); + + fireEvent.click(await screen.findByRole('button', { + name: 'Remove current saved photo', + })); + + expect(await screen.findByText( + 'Profile photo changed again elsewhere. The preview shows the current photo.', + )).toHaveAttribute('role', 'status'); + expect(screen.getByRole('img', { name: 'Your current profile thumbnail' })) + .toBeInTheDocument(); + expect(screen.queryByText( + 'Profile photo removed. Your officer finder setting did not change.', + )).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toHaveAttribute( + 'src', + `data:image/png;base64,${btoa('concurrent replacement')}`, + ); + expect(screen.getByRole('button', { name: 'Remove current saved photo' })) + .toHaveFocus(); + }); + + test.each([ + ['application', otherApp, 'synthetic-user'], + ['account', app, 'other-synthetic-user'], + ])('makes an old remove completion inert after the %s changes', async (_label, nextApp, nextUid) => { + const oldRemove = deferred(); + (removeMyMemberDirectoryPhoto as jest.Mock).mockReturnValueOnce(oldRemove.promise); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(DEFAULT_PROFILE); + const view = renderProfile(); + await selectReadyReplacement('old-context replacement', 'fixture-001h-008.png'); + fireEvent.click(screen.getByRole('button', { + name: 'Remove current saved photo', + })); + + view.rerender( + , + ); + expect(await screen.findByRole('img', { name: 'No profile photo' })) + .toBeInTheDocument(); + await act(async () => oldRemove.resolve({})); + + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(document.body.innerHTML).not.toContain(btoa('old-context replacement')); + expect(screen.getByLabelText('Add profile photo')).not.toHaveFocus(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); + + test('makes an old remove completion inert after unmount', async () => { + const oldRemove = deferred(); + (removeMyMemberDirectoryPhoto as jest.Mock).mockReturnValueOnce(oldRemove.promise); + (getMyMemberDirectoryProfile as jest.Mock).mockResolvedValueOnce(PROFILE_WITH_PHOTO); + const view = renderProfile(); + await selectReadyReplacement('unmounted replacement', 'fixture-001h-009.png'); + fireEvent.click(screen.getByRole('button', { + name: 'Remove current saved photo', + })); + + view.unmount(); + await act(async () => oldRemove.resolve({})); + + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); }); test('MEMBERS-DIRECTORY-001F falls back when a saved thumbnail cannot decode and resets for a new version', async () => { @@ -1034,7 +1344,7 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('img', { name: 'Saved profile photo could not be displayed', })).toHaveTextContent('Photo unavailable'); - expect(screen.getByRole('button', { name: 'Remove profile photo' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Remove current saved photo' })).toBeEnabled(); fireEvent.click(screen.getByRole('checkbox')); @@ -1045,7 +1355,7 @@ describe('My Account member directory profile', () => { 'src', `data:image/webp;base64,${newPhoto.base64Data}`, ); - expect(screen.getByRole('button', { name: 'Remove profile photo' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Remove current saved photo' })).toBeEnabled(); }); test('blocks parallel mutations, then hides all controls after an unknown mutation outcome', async () => { @@ -1540,7 +1850,7 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('checkbox').getAttribute('aria-describedby')).toContain( 'member-directory-privacy-description', ); - expect(screen.getByRole('button', { name: 'Remove profile photo' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Remove current saved photo' })).toBeEnabled(); }); test('MEMBERS-DIRECTORY-001F keeps the photo review contained at 320px', () => { diff --git a/src/pages/account/MemberDirectoryProfile.tsx b/src/pages/account/MemberDirectoryProfile.tsx index b09623d..29650e4 100644 --- a/src/pages/account/MemberDirectoryProfile.tsx +++ b/src/pages/account/MemberDirectoryProfile.tsx @@ -321,7 +321,9 @@ function MemberDirectoryProfileAttempt({ const mutationRef = useRef(null); const photoReadRef = useRef(null); const photoInputRef = useRef(null); - const focusPhotoInputAfterSaveRef = useRef(false); + const removePhotoButtonRef = useRef(null); + const savePhotoButtonRef = useRef(null); + const postMutationFocusRef = useRef<'photo-input' | 'remove-result' | null>(null); useEffect(() => { const lifetime = Symbol('member-directory-lifetime'); @@ -331,15 +333,32 @@ function MemberDirectoryProfileAttempt({ loadRef.current = null; mutationRef.current = null; photoReadRef.current = null; - focusPhotoInputAfterSaveRef.current = false; + postMutationFocusRef.current = null; }; }, []); useEffect(() => { - if (state.phase !== 'ready' || !focusPhotoInputAfterSaveRef.current) return; - focusPhotoInputAfterSaveRef.current = false; + if (state.phase !== 'ready' || postMutationFocusRef.current === null) return; + const focusIntent = postMutationFocusRef.current; + postMutationFocusRef.current = null; + if (focusIntent === 'photo-input') { + photoInputRef.current?.focus(); + return; + } + if (state.profile.hasPhoto) { + removePhotoButtonRef.current?.focus(); + return; + } + if ( + photoDraft?.phase === 'preview' + && photoDraft.renderState === 'ready' + && photoReadRef.current === photoDraft.identity + ) { + savePhotoButtonRef.current?.focus(); + return; + } photoInputRef.current?.focus(); - }, [state.phase]); + }, [photoDraft, state]); useEffect(() => { const lifetime = lifetimeRef.current; @@ -348,7 +367,7 @@ function MemberDirectoryProfileAttempt({ loadRef.current = load; mutationRef.current = null; photoReadRef.current = null; - focusPhotoInputAfterSaveRef.current = false; + postMutationFocusRef.current = null; setActionError(null); setPhotoDraft(null); setState({ phase: 'loading' }); @@ -450,11 +469,13 @@ function MemberDirectoryProfileAttempt({ const profile = await getMyMemberDirectoryProfile(app); if (!mutationIsCurrent(start)) return; mutationRef.current = null; - if (start.action !== 'visibility') { + if (start.action === 'upload') { photoReadRef.current = null; setPhotoDraft(null); + postMutationFocusRef.current = 'photo-input'; + } else if (start.action === 'remove') { + postMutationFocusRef.current = 'remove-result'; } - if (start.action === 'upload') focusPhotoInputAfterSaveRef.current = true; setState({ phase: 'ready', profile, confirmation: confirmation(profile) }); } catch { if (!mutationIsCurrent(start)) return; @@ -759,6 +780,7 @@ function MemberDirectoryProfileAttempt({ {profile.hasPhoto && ( )} @@ -822,6 +844,7 @@ function MemberDirectoryProfileAttempt({
{photoDraft.phase !== 'reading' && (