From 8deb26c07ebdf1490eb245c531b7c423e5a269b4 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Thu, 24 Sep 2026 17:16:34 +0300 Subject: [PATCH 1/3] feat(check-in): a portrait for the desk, and both pictures save and adjust with their forms The avatar moves into Profile Information and the portrait into Check-in details and About you; each is a pending edit sent after PATCH /user. The picked file goes as picked with its crop, Adjust re-crops from the stored original, and the check-in page shows the portrait in the picture column instead of the avatar. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 78 +-- README.md | 15 +- src/app/privacy/page.tsx | 32 +- src/app/settings/page.tsx | 3 - .../auth/profile-completion-form.tsx | 6 +- .../checkin-page-frame.render.test.tsx | 69 ++- src/components/checkin/checkin-page-frame.tsx | 51 +- .../settings/avatar-card.render.test.tsx | 274 --------- src/components/settings/avatar-card.tsx | 236 -------- .../settings/check-in-details-card.tsx | 10 +- .../settings/data-export-card.render.test.tsx | 9 +- src/components/settings/data-export-card.tsx | 7 +- .../delete-account-card.render.test.tsx | 4 +- .../settings/delete-account-card.tsx | 7 +- src/components/settings/profile-card.tsx | 8 +- src/components/ui/image-crop-dialog.tsx | 26 +- src/components/ui/user-avatar.render.test.tsx | 24 +- src/components/ui/user-avatar.tsx | 2 +- src/components/user/picture-field.tsx | 274 +++++++++ src/components/user/picture-slot.tsx | 271 +++++++++ src/components/user/portrait-image.tsx | 75 +++ src/components/user/user-fields-dialog.tsx | 5 + .../user/user-fields-form.render.test.tsx | 547 ++++++++++++++++++ src/components/user/user-fields-form.tsx | 56 +- src/hooks/usePictureEdit.ts | 47 ++ src/lib/api/auth.ts | 170 +++--- src/lib/certification.ts | 6 +- src/lib/image-crop.test.ts | 22 +- src/lib/image-crop.ts | 2 +- src/lib/picture-edits.test.ts | 119 ++++ src/lib/picture-edits.ts | 91 +++ src/lib/picture.test.ts | 132 +++++ src/lib/picture.ts | 157 +++++ src/proxy.ts | 14 +- 34 files changed, 2121 insertions(+), 728 deletions(-) delete mode 100644 src/components/settings/avatar-card.render.test.tsx delete mode 100644 src/components/settings/avatar-card.tsx create mode 100644 src/components/user/picture-field.tsx create mode 100644 src/components/user/picture-slot.tsx create mode 100644 src/components/user/portrait-image.tsx create mode 100644 src/components/user/user-fields-form.render.test.tsx create mode 100644 src/hooks/usePictureEdit.ts create mode 100644 src/lib/picture-edits.test.ts create mode 100644 src/lib/picture-edits.ts create mode 100644 src/lib/picture.test.ts create mode 100644 src/lib/picture.ts diff --git a/DECISIONS.md b/DECISIONS.md index 3344fd2f..d83f67c6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -865,20 +865,20 @@ would bury the rows that need attention, the same reasoning as `worstServiceStat for untracked gear. The window is 90 days, not gear's 30: renewing a rescue or first-aid card means booking a course with an instructor, not dropping a regulator at a shop. -## Card images ride on the certification form's own save +## Card images and both pictures ride on their form's own save -The API takes card images on `PUT /certification/{uuid}/file/{side}`, not as multipart on create, so -a card being created has no uuid to upload against until `createCertification` resolves. That is an -ordering constraint, not a reason for a second dialog: `CertificationCardFiles` collects -add/replace/delete per side into `CertificationCardEdits`, and `applyCertificationCardEdits` sends -them after the details save — serially, in `CERTIFICATION_SIDES` order, `PUT` alone for a replace -since it overwrites. Cancel therefore leaves the stored cards untouched, which the upload-on-pick -dialog could not. +Each is held as a pending edit and sent after the form's fields save, so Cancel leaves every stored +image untouched. For cards the order is forced: `PUT /certification/{uuid}/file/{side}` needs a uuid +a new card has only once `createCertification` resolves. `CertificationCardFiles` collects +add/replace/delete per side and `applyCertificationCardEdits` sends them serially, in +`CERTIFICATION_SIDES` order. The avatar (Profile Information) and the portrait (Check-in details, +About you) each hold one `PictureEdit` — replace, adjust, remove, or the portrait's copy — which +`applyPictureEdit` sends after `PATCH /user`. Rejected: a picture card saving on pick beside forms +that wait for Save. -A failed image does not fail the save: the details are written, and refilling the form to retry one -picture costs more than the picture. One toast per side, and the certification is re-read whenever -anything was sent — the embedded `files` the list and the check-in sheet draw from is stale either -way, and only the API knows which sides landed. +A failed image does not fail the save: the fields are written, and refilling the form to retry one +picture costs more than the picture. One toast names the side or the picture. A certification is +re-read whenever anything was sent, since only the API knows which sides landed. ## Every c-card is drawn in one shape, and cropped to it on the way in @@ -4021,24 +4021,24 @@ variable a released artifact must document. Initials (`getUserInitials`) are the `flag()` stays for `WEB_HSTS` and `WEB_NOINDEX`; its doc comment and `runtime-config.test.ts` demonstrate the unrecognized-value warning on `WEB_NOINDEX`. -## Avatars: The digest is the whole client contract +## Pictures: The digest is the whole client contract -`UserRead` carries `avatar_sha256`, one nullable string answering three questions: whether there is -a picture, which version, and what to append as `?v=`. There is deliberately no URL. The bytes are -owner-only behind an `Authorization` header and the access token lives in memory -(`lib/api/client.ts`), so an `` at the API could never load them; `UserAvatar` fetches -through the API client via `hooks/useAuthedBlobUrl.ts` and renders from an object URL. Its props are -`{ name, avatarSha, size, className }` — no `email`. Radix's `AvatarFallback` renders until -`AvatarImage` has loaded, so in-flight, failed and no-picture are one state drawn as initials, with -no probe and no broken-image glyph. Staleness is handled by the URL: `?v={sha}` changes with the -picture, the old entry ages out of the five-minute `max-age`, and after upload or remove the card -calls `refreshUser()`, which re-reads `avatar_sha256` for every mounted `UserAvatar` in the same -paint. +`UserRead` carries `avatar_sha256` and `portrait_sha256`, each one nullable string answering three +questions: whether there is a picture, which version, and what to append as `?v=`. Each +`*_original_sha256` does the same for the original, and is what offers "Adjust". There is +deliberately no URL: the bytes are owner-only and the access token lives in memory +(`lib/api/client.ts`), so an `` could never load them. `UserAvatar` and `PortraitImage` +fetch through `hooks/useAuthedBlobUrl.ts` and render from an object URL. Radix's `AvatarFallback` +renders until `AvatarImage` has loaded, so in-flight, failed and no-picture are one state drawn as +initials, with no broken-image glyph. Staleness is handled by the URL: `?v={sha}` changes with the +picture, and after a save the form calls `refreshUser()`, which repaints every mounted picture in +the same paint. ## The crop dialog's three traps -Never JPEG: `canvas.toBlob("image/jpeg")` composites transparency onto black. The avatar exports PNG -because `PUT /user/avatar` re-encodes anyway; a card exports WebP because its endpoint does not. +Never JPEG: `canvas.toBlob("image/jpeg")` composites transparency onto black. A card exports WebP +because its endpoint stores what it is given. The two pictures export nothing: the picked file goes +as it is, with the crop beside it as numbers. `react-easy-crop` injects its own `