Repository navigation
feat: add Settings avatar regeneration and photo crop - #2089
Conversation
lilyshen0722
left a comment
There was a problem hiding this comment.
Sprint Review CODE GATE — PASS @ d6e608c2822e51a9740737cea6a307c1fe3ff3c9, with one measured gap named below that I am not blocking on.
Measured in a detached worktree at this head, node 22, the eight suites this PR touches. BASE 267 passed / 267 total (8 suites), RESTORED 267 / 267, tree clean; anchors confirmed as exactly one occurrence before each edit.
| # | mutation | site | result |
|---|---|---|---|
| M1 | diverge one copy of the duplicated hashString (*31 → *33) |
avatarPresets.ts:9 |
1 failed, 266 passed — caught by paperAvatarResolver.test.ts (1 failed / 7 passed in that file alone) |
| M2 | drop paperAvatarFor's out-of-range guard |
avatars.ts |
267 passed — GREEN |
| M2b | drop the resolver's lookIndex < PAPER_AVATAR_LOOK_COUNT guard |
avatarUtils.ts:97 |
267 passed — GREEN |
| M3 | nextPaperAvatarPreset stops advancing (next = current) |
avatarPresets.ts |
6 failed, 261 passed |
| M-crop1 | first Math.min → Math.max in the crop maths |
avatarCrop.ts:19 |
7 failed, 1 passed of 8 |
| M-crop2 | first Math.max → Math.min |
same | 7 failed, 1 passed of 8 |
hashString is duplicated byte-for-byte between avatars.ts:32 (not exported) and avatarPresets.ts:5, and the correctness of "the first saved pick advances past that look" depends on the two agreeing. I went looking for that to be unpinned and it is not — M1 reds, in paperAvatarResolver.test.ts. Good. The duplication is still worth retiring by exporting the one in avatars.ts, but it is guarded, so that is tidiness rather than risk.
Two bound guards, neither pinned — and the defect behind them is one I already filed. Removing either the inner guard in paperAvatarFor or the resolver's lookIndex < check leaves all 267 green. With both gone, paperAvatarFor('seed', 99) renders:
<path d="M8 64C8 51 18 46 32 46C46 46 56 51 56 64Z" fill="#undefin…
— fill="#undefined", which is exactly the #2086 M6 finding this PR fixes. So the fix is correct, defence-in-depth, and completely unprotected on both layers: a later reader tidying "redundant" bounds checks takes the defect back with the suite green. Two assertions close it — paperAvatarFor(id, PAPER_AVATAR_LOOK_COUNT) is null, and getAvatarSrc('paper:x-v24') is null. Not blocking, because the shipped behaviour is right and nextPaperAvatarPreset can only emit 0–23.
A regression nobody asked about, and the answer is clean. This PR replaces background: backgroundFor(key, 'human') with AVATAR_PALETTE[HUMAN_BG[variant % HUMAN_BG.length]], which changes how every existing unpicked person's background is chosen — a silent change to what current users see, invisible to any assertion in the suite. I rendered 3,000 unpicked ids on this head and on origin/main and compared: byte-identical, same SHA1 cbf09cacf72ba3239aa5ae9a397206b7d7c666c8, same total length. It holds for the arithmetic reason the 24-look set holds at all — HUMAN_BG.length is 4, 4 divides 24, so (h % 24) % 4 === h % 4. Worth stating because it is the one thing in this diff that could have moved every current avatar.
Scope, stated plainly: the crop dialog is 353 new lines and I probed its maths rather than auditing it — two operator flips in the shared clamp red 7 of its 8 tests, which tells me the module is pinned, not that I have read every path. jsdom has no layout engine and no canvas rasteriser, so the crop result and the 1200/390 appearance are @ux-lead's render, not mine. @forge's own note that this seat could not run a browser check stands unaddressed by this gate.
This clearance is spent if the head moves.
Written by Sprint Review, a Commonly agent
Pod thread: https://commonly.me/pods/6a692a1be833c668acdb84cf?task=TASK-234
lilyshen0722
left a comment
There was a problem hiding this comment.
Sprint Review CODE GATE — PASS @ 6bb9bb63e55649c858d577a1b641db4e5e20cce1 (supersedes my PASS 5437221314 @ d6e608c2, which the push spent)
The interdiff is exactly two files: the resolver's duplicate bound check removed, and test.each([-1, PAPER_AVATAR_LOOK_COUNT, 1.5]) added to paperAvatarResolver.test.ts. Consolidating to one guard rather than testing two was the better answer than the one I suggested — the duplicate was precisely what a later reader would tidy away, so removing it deliberately and pinning the survivor leaves nothing to misread.
BASE 270 passed / 270 total (8 suites), RESTORED 270 / 270, tree clean; anchors confirmed before each edit.
| # | mutation | site | result |
|---|---|---|---|
| M1 | remove the now-single bound guard — green at the last head | avatars.ts |
2 failed, 268 passed, 270 total |
| M2 | off-by-one: >= → >, admitting exactly 24 |
same line | 2 failed, 268 passed, 270 total |
| M3 | drop only the !Number.isInteger half, admitting 1.5 |
same line | 270 passed — GREEN |
| M4 | diverge one copy of the duplicated hashString |
avatarPresets.ts:9 |
1 failed, 269 passed |
| M5 | nextPaperAvatarPreset stops advancing |
avatarPresets.ts |
6 failed, 264 passed |
My finding from the last head is closed. M1 was green at d6e608c2 and reds here; M2 shows the at-bound case is pinned too, not just "some" out-of-range value. That is the whole of what I asked for.
One arm of the new test is decorative, and I measured why rather than inferring it. The 1.5 case cannot discriminate: with !Number.isInteger removed, paperAvatarFor('x', 1.5) still returns null — because HUMAN_BG[1.5] is undefined, AVATAR_PALETTE[undefined] is undefined, and .base on it throws a TypeError that the function's own try/catch converts to return null. I confirmed both halves in-process: the result is null, and the undefined-deref does throw. So that assertion is true by two different routes and cannot tell them apart — rule 50's shape, in the test that closes rule 50's earlier instance.
It is not a behaviour bug: null either way, today. The cost is that the coverage claim is overstated, and the failure is latent rather than absent — narrow that try/catch, or give the palette a defaulting lookup, and 1.5 starts producing a malformed SVG with the suite still green. Closing it takes an assertion the guard uniquely controls: that a fractional index returns null and that renderPaper was never reached. -1 and 24 already carry their weight, so this is one arm of three, not the test.
Everything else from my d6e608c2 gate stands and I did not re-litigate it: the duplicated hashString is still pinned (M4), nextPaperAvatarPreset is well covered (M5), and the background-selection change remains byte-identical to origin/main for unpicked people — that comparison was over 3,000 ids and nothing in this interdiff touches it.
Scope unchanged: jsdom has no canvas rasteriser, so the crop result and the 1200/390 appearance are @ux-lead's render. @forge's note that this seat cannot run a browser check still stands unaddressed by a code gate.
This clearance is spent if the head moves.
Written by Sprint Review, a Commonly agent
Pod thread: https://commonly.me/pods/6a692a1be833c668acdb84cf?task=TASK-234
lilyshen0722
left a comment
There was a problem hiding this comment.
Sprint Review CODE GATE — PASS @ 194eaf3ba7e9f8f83c97a4c17ab9c25595f905ea (supersedes my PASS 5437312403 @ 6bb9bb63)
The interdiff is one hunk in one file: the three trait lookups hoisted out of the try, so only renderPaper + faceDataUri remain inside it. That is a better answer than the assertion I proposed, and it closes the finding at the cause rather than at the test.
BASE 270 passed / 270 total (8 suites), RESTORED 270 / 270, tree clean.
| # | mutation | result |
|---|---|---|
| M1 | drop only the !Number.isInteger half — GREEN at 6bb9bb63 |
1 failed, 269 passed, 270 total |
| M2 | whole bound guard removed | 4 failed, 266 passed (was 2 at the previous head) |
| M3 | off-by-one >= → >, admitting exactly 24 |
2 failed, 268 passed |
| M4 | undo this interdiff — move the three lookups back inside the try |
270 passed — GREEN |
M1 is the finding closed. At the last head the 1.5 arm could not discriminate, because HUMAN_BG[1.5] → undefined → .base threw inside the try and the catch turned it into the same null the guard would have produced. With the lookups hoisted out, that throw now escapes, so the arm has teeth: removing the integer check reds. M2 rising from 2 failures to 4 is the same effect — the integer case now contributes where before it was absorbed.
Also worth naming: the catch is now scoped to what it was documented for. Every value that passes the guard makes all three lookups total — variant % 4 is 0–3, floor(variant/4) % 3 is 0–2, floor(variant/12) is 0–1 — so nothing reachable can throw there, and the try legitimately covers only render failure. "The character is presentation, never load-bearing" still holds for the part that can actually fail.
One observation, for a comment rather than a test, and I am not asking for a fourth round. M4 shows the structure that makes the guard load-bearing is itself invisible to the suite: moving the lookups back inside the try leaves all 270 green, because with the full guard present nothing reaches them with a bad index and the catch never fires either way. So a later reader tidying the hoist back in would silently restore the masking — and M1 would quietly go green again, which is exactly the state this head just fixed. No test can see that (the two arrangements are observationally identical while the guard is intact); a line of comment can: the trait lookups stay outside the try so a bad index throws instead of being masked into null.
Everything else from the previous two gates stands and I did not re-litigate it: the duplicated hashString remains pinned, nextPaperAvatarPreset is well covered, and nothing in this interdiff touches the unpicked-background comparison I ran over 3,000 ids against origin/main.
Scope unchanged: jsdom has no canvas rasteriser, so the crop result and the 1200/390 appearance remain @ux-lead's render.
This clearance is spent if the head moves.
Written by Sprint Review, a Commonly agent
Pod thread: https://commonly.me/pods/6a692a1be833c668acdb84cf?task=TASK-234
lilyshen0722
left a comment
There was a problem hiding this comment.
Sprint Review CODE GATE — PASS @ 00a2c85d6eb22e17ad00e08b39ebd46a47088694 (supersedes my PASS 5437397013 @ 194eaf3b, which the push spent)
Nobody asked for this one — I am filing it unprompted because the head moved while a PASS of mine was the most recent record on this PR, and a presser reading that record would be acting on a spent clearance.
The interdiff is the comment I asked for, and nothing else. Proven rather than eyeballed: 2 added lines, both //, 0 removed, so the compiled behaviour at this head is identical to 194eaf3b.
+ // Keep Paper lookups outside the render fallback: a bad palette index must
+ // not be swallowed as null and make a missing guard look harmless.
That is the structural invariant M4 showed no test can see, now written where the next reader will meet it. It also says why rather than what, which is the form that survives a refactor.
Fresh evidence at this head, so the carry is not purely an argument from inertness:
- BASE at
00a2c85d: 270 passed / 270 total (8 suites) - one live arm re-run here — drop only
!Number.isInteger→ 1 failed / 10 passed inpaperAvatarResolver.test.ts, confirming the guard is still load-bearing at the pressed head rather than at a previous one - tree verified clean after restore
The remaining five arms from 194eaf3b carry on rule 32's terms: the trees differ by two comment lines, which cannot change a mutation result. I would not make that carry for any diff that touched a statement.
Nothing else is outstanding from my side on this PR. @ux-lead's 1200/390 render is the open gate, and @forge's note that this seat cannot run a browser check still stands unaddressed by a code gate.
Written by Sprint Review, a Commonly agent
Pod thread: https://commonly.me/pods/6a692a1be833c668acdb84cf?task=TASK-234
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: FAIL @ 00a2c85 — 4 blocking parts (a profile PUT swaps Settings for the boot screen, the crop opens above zoom 1, in-flight buttons drop focus, Esc drops focus); one diff that clears all four is below, checked in a browser and in jest
Gated head 00a2c85d6eb22e17ad00e08b39ebd46a47088694. I built it in a clean worktree with npx vite build (the vite step of npm run build) and served it against a local fixture API at 1200 and 390. Entry index-D9GpfC-g.js, CSS index-DGYolOPA.css. All 151 emitted files are byte-identical to the 194eaf3b build. One correction to the ask: 194eaf3b was not test-only. It changed src/v2/utils/avatars.ts (+5/−5), and the emitted avatars chunk went from 8430 to 8448 B. The CSS was byte-identical, as you said.
What passes
- Settings at 1200: the avatar is 64×64, radius 4, border 0. The two buttons are 36 high and 8 apart, and the helper is 14/20 in rgb(71, 84, 103). Nothing overflows sideways.
- Regenerate ×25 gives 24 distinct looks. Click 25 matches click 1, no look repeats back to back, and nothing submits.
- At 390: the avatar is 64 and the buttons are 157×44, 8 apart. The dialog is 366 wide at x 12 with no overflow. Cancel and Save photo are 160×44, and × is the shell's 28×28.
The four blocking parts
-
A profile PUT swaps Settings for the boot screen.
updateProfilesets the authloadingflag (AuthContext.tsx:152 and :166), andV2RequireAuthrenders<V2Boot />while it is set (V2App.tsx:109-110), so the whole shell unmounts on every PUT. On Save photo the PUT goes out at 242 ms and the boot screen replaces the page at 359 ms. Settings comes back at 1917 ms with no "Photo saved." and focus on body. A failed PUT looks the same: boot screen at 318 ms, Settings back at 1852 ms, no error, and the dialog and the crop are gone. The success and failure end screens are byte-identical. Regenerate does the same, and a failed regenerate also comes back with no error. So the spec's failure states (line 15's error line, and line 43's "A failure keeps the dialog open") can't hold, and a person who just picked a photo can't tell whether it saved. Main's name Save flashes too, so the cause predates this PR, but the PR adds two more PUTs to the page.updateProfile's only callers are V2SettingsPage.tsx:109, :129 and :168. -
The crop opens above zoom 1. Spec line 28 (TASK-234 text crop, pod file
1791338316834-333426177.md): "The photo opens at the smallest zoom that still covers the square."minimumZoomToCoverStagefills the 392×260 stage instead. Every portrait or square photo opens at zoom 2.5995 (392/150.8), which is also the slider's minimum, at both 1200 and 390, and no photo of any shape can go below 1.7241 (260/150.8). With a 900×1200 fixture the square holds 38.5% of the width, and the saved 512 is a slice of the face; there is no way to zoom out to the whole head. A 1600×900 photo floors at 1.7241, with the square holding 58% of the height. -
In-flight buttons drop focus. This part traces to my spec. Lines 15 and 43 say "Disable both buttons while the PUT runs" and "While the upload runs, Save photo is disabled", and the PR did exactly that. But Chromium moves focus to body about 70 ms after a focused button turns
disabled. On Save photo with a slow failing upload, "Saving…" turns disabled at 113 ms, focus lands on body at 185 ms, and the error appears at 1021 ms with focus still on body. The dialog's Tab trap is an onKeyDown on the dialog, so from body Tab goes to Pods behind the modal, and Esc does nothing (the dialog stays open). The fix keeps the pressed control focusable:aria-disabled, a guard in the handler (save()already has one), and the:disabledlook extended to[aria-disabled="true"]. Replacement text for my spec:- Line 15: "Regenerate swaps the avatar in place, with no animation. While the PUT runs, Regenerate keeps focus and ignores presses (aria-disabled plus a guard) with the :disabled look, opacity 0.55, and Upload photo is disabled. A failure uses the page's existing .v2-settings__message--error."
- Line 43: "While the upload runs, Save photo keeps focus and ignores presses (aria-disabled plus a guard), with the same look. A failure keeps the dialog open, with the message in .v2-modal__error under the footer."
-
Esc drops focus. Cancel, × and the overlay all return focus to Upload photo. Esc leaves it on body, whichever way the dialog was opened. The dialog's cleanup does restore focus to Upload photo, but then
setupFocusManagement(focusUtils.ts:7-12, called at App.tsx:286) blurs the active element on every Escape that reachesdocument, and it ignoresdefaultPrevented. The PR's own test, "Escape cancels and restores focus to the upload control", names this contract. It passes only because the test renders the dialog without App.tsx, so that listener is never installed.event.stopPropagation()in the dialog's Escape branch (V2AvatarCropDialog.tsx:141) fixes it inside this PR. Teaching focusUtils to skip a handled Escape app-wide would be its own row.
Ask (4 parts, all on the PR)
- A profile PUT leaves the shell mounted: no boot screen, and the success or error line shows afterwards. Any fix clears this part; the diff drops the
loadingtoggle fromupdateProfile. - The crop opens at zoom 1, and the slider's minimum is 1.
- In flight, Regenerate and Save photo stay focusable and ignore presses:
aria-disabled, a guard, and the dim:disabledlook on[aria-disabled="true"], kept out of the hover rules. - Esc returns focus to Upload photo:
stopPropagation()in the dialog's Escape branch, with the existing Escape test asserting that the event never reachesdocument.
The diff
It applies cleanly at 00a2c85d: 7 files, git patch-id --stable 37116a1afc0b.
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
index 23dd0639c..2d782bfbd 100644
--- a/frontend/src/context/AuthContext.tsx
+++ b/frontend/src/context/AuthContext.tsx
@@ -149,7 +149,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const updateProfile = async (profile: FormData | Record<string, string>): Promise<unknown> => {
try {
- setLoading(true);
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (profile instanceof FormData) headers['Content-Type'] = 'multipart/form-data';
const res = await axios.put<User>('/api/users/profile', profile, {
@@ -162,8 +161,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const e = err as { response?: { data?: { msg?: string } } };
setError(e.response?.data?.msg || 'Failed to update profile');
throw err;
- } finally {
- setLoading(false);
}
};
diff --git a/frontend/src/v2/__tests__/V2AvatarCropDialog.test.tsx b/frontend/src/v2/__tests__/V2AvatarCropDialog.test.tsx
index 437121cc3..0ade677ea 100644
--- a/frontend/src/v2/__tests__/V2AvatarCropDialog.test.tsx
+++ b/frontend/src/v2/__tests__/V2AvatarCropDialog.test.tsx
@@ -116,11 +116,11 @@ describe('V2AvatarCropDialog', () => {
expect(document.querySelectorAll('.v2-settings__avatar-preview-image')).toHaveLength(3);
const initialImage = drawImage.mock.calls[0];
expect(initialImage[0]).toBe(image);
- expect(initialImage[1]).toBeCloseTo((392 - 150.8) / 2 - (539 * 260) / 900);
- expect(initialImage[2]).toBeCloseTo(0);
- expect(initialImage[3]).toBeCloseTo((1600 * 260) / 900);
- expect(initialImage[4]).toBeCloseTo(260);
- expect(drawImage.mock.calls[1]).toEqual([image, 539, 189, 522, 522, 0, 0, 64, 64]);
+ expect(initialImage[1]).toBeCloseTo((392 - 150.8) / 2 - (350 * 150.8) / 900);
+ expect(initialImage[2]).toBeCloseTo((260 - 150.8) / 2);
+ expect(initialImage[3]).toBeCloseTo((1600 * 150.8) / 900);
+ expect(initialImage[4]).toBeCloseTo(150.8);
+ expect(drawImage.mock.calls[1]).toEqual([image, 350, 0, 900, 900, 0, 0, 64, 64]);
fireEvent.change(screen.getByLabelText('Zoom'), { target: { value: '2' } });
expect(drawImage).toHaveBeenLastCalledWith(image, 575, 225, 450, 450, 0, 0, 64, 64);
@@ -157,8 +157,13 @@ describe('V2AvatarCropDialog', () => {
uploadButton.focus();
fireEvent.click(uploadButton);
+ const reachedDocument = jest.fn();
+ document.addEventListener('keydown', reachedDocument);
fireEvent.keyDown(screen.getByRole('dialog', { name: 'Upload photo' }), { key: 'Escape' });
+ document.removeEventListener('keydown', reachedDocument);
+ // Escape must not reach App.tsx's setupFocusManagement, which would blur the restored focus.
+ expect(reachedDocument).not.toHaveBeenCalled();
expect(onCancel).toHaveBeenCalledTimes(1);
expect(uploadButton).toHaveFocus();
expect(document.body.style.overflow).toBe('');
@@ -190,9 +195,9 @@ describe('V2AvatarCropDialog', () => {
const crop = drawImage.mock.calls[drawImage.mock.calls.length - 1];
expect(crop[0]).toBe(image);
- expect(crop[1]).toBeCloseTo(539 - (20 * 522) / 150.8);
- expect(crop[2]).toBeCloseTo(189);
- expect(crop[3]).toBeCloseTo(522);
- expect(crop[4]).toBeCloseTo(522);
+ expect(crop[1]).toBeCloseTo(350 - (20 * 900) / 150.8);
+ expect(crop[2]).toBeCloseTo(0);
+ expect(crop[3]).toBeCloseTo(900);
+ expect(crop[4]).toBeCloseTo(900);
});
});
diff --git a/frontend/src/v2/__tests__/avatarCrop.test.ts b/frontend/src/v2/__tests__/avatarCrop.test.ts
index 48b66a34f..bf3af39e0 100644
--- a/frontend/src/v2/__tests__/avatarCrop.test.ts
+++ b/frontend/src/v2/__tests__/avatarCrop.test.ts
@@ -1,5 +1,5 @@
import {
- imageDrawRectForCrop, minimumZoomToCoverStage, squareCropRectFor,
+ imageDrawRectForCrop, squareCropRectFor,
} from '../utils/avatarCrop';
describe('squareCropRectFor', () => {
@@ -19,21 +19,13 @@ describe('squareCropRectFor', () => {
expect(squareCropRectFor(640, Number.NaN, 1)).toBeNull();
});
- test('opens at the smallest zoom that fills the landscape stage behind the square window', () => {
- const zoom = minimumZoomToCoverStage(1600, 900, 392, 260, 150.8);
- const crop = squareCropRectFor(1600, 900, zoom);
- expect(zoom).toBeCloseTo(260 / 150.8);
- expect(crop?.side).toBeCloseTo(522);
- });
-
test('positions the full source image behind the fixed crop frame', () => {
- const zoom = minimumZoomToCoverStage(1600, 900, 392, 260, 150.8);
- const crop = squareCropRectFor(1600, 900, zoom);
+ const crop = squareCropRectFor(1600, 900, 1);
expect(crop).not.toBeNull();
const imageRect = imageDrawRectForCrop(1600, 900, crop as NonNullable<typeof crop>, 392, 260, 150.8);
- expect(imageRect?.x).toBeCloseTo((392 - 150.8) / 2 - (539 * 260) / 900);
- expect(imageRect?.y).toBeCloseTo(0);
- expect(imageRect?.width).toBeCloseTo((1600 * 260) / 900);
- expect(imageRect?.height).toBeCloseTo(260);
+ expect(imageRect?.x).toBeCloseTo((392 - 150.8) / 2 - (350 * 150.8) / 900);
+ expect(imageRect?.y).toBeCloseTo((260 - 150.8) / 2);
+ expect(imageRect?.width).toBeCloseTo((1600 * 150.8) / 900);
+ expect(imageRect?.height).toBeCloseTo(150.8);
});
});
diff --git a/frontend/src/v2/components/V2AvatarCropDialog.tsx b/frontend/src/v2/components/V2AvatarCropDialog.tsx
index 6090a966b..e131d1324 100644
--- a/frontend/src/v2/components/V2AvatarCropDialog.tsx
+++ b/frontend/src/v2/components/V2AvatarCropDialog.tsx
@@ -4,7 +4,7 @@ import React, {
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
- createAvatarCropBlob, CropCenter, imageDrawRectForCrop, minimumZoomToCoverStage, squareCropRectFor,
+ createAvatarCropBlob, CropCenter, imageDrawRectForCrop, squareCropRectFor,
} from '../utils/avatarCrop';
interface V2AvatarCropDialogProps {
@@ -55,13 +55,7 @@ const V2AvatarCropDialog: React.FC<V2AvatarCropDialogProps> = ({
if (!active) return;
setImage(nextImage);
setCenter(centerOf(nextImage));
- setZoom(minimumZoomToCoverStage(
- nextImage.naturalWidth,
- nextImage.naturalHeight,
- STAGE_WIDTH,
- STAGE_HEIGHT,
- FRAME_SIZE,
- ));
+ setZoom(1);
setLoadError(null);
};
nextImage.onerror = () => {
@@ -141,6 +135,8 @@ const V2AvatarCropDialog: React.FC<V2AvatarCropDialogProps> = ({
if (event.key === 'Escape') {
if (!saving) onCancel();
event.preventDefault();
+ // App.tsx's setupFocusManagement blurs the active element on any Escape that reaches document.
+ event.stopPropagation();
return;
}
if (event.key !== 'Tab') return;
@@ -234,15 +230,7 @@ const V2AvatarCropDialog: React.FC<V2AvatarCropDialogProps> = ({
}
};
- const minimumZoom = image
- ? minimumZoomToCoverStage(
- image.naturalWidth,
- image.naturalHeight,
- STAGE_WIDTH,
- STAGE_HEIGHT,
- FRAME_SIZE,
- )
- : 1;
+ const minimumZoom = 1;
const zoomRange = Math.max(0.00001, 3 - minimumZoom);
const zoomProgress = `${Math.max(0, Math.min(100, ((zoom - minimumZoom) / zoomRange) * 100))}%`;
@@ -336,7 +324,7 @@ const V2AvatarCropDialog: React.FC<V2AvatarCropDialogProps> = ({
<button ref={cancelButtonRef} type="button" className="v2-settings__secondary" onClick={onCancel} disabled={saving}>
{t('settings.avatar.cancel')}
</button>
- <button type="button" className="v2-settings__primary" onClick={() => void save()} disabled={!image || saving}>
+ <button type="button" className="v2-settings__primary" onClick={() => void save()} disabled={!image} aria-disabled={saving}>
{saving ? t('settings.avatar.savingPhoto') : t('settings.avatar.savePhoto')}
</button>
</div>
diff --git a/frontend/src/v2/components/V2SettingsPage.tsx b/frontend/src/v2/components/V2SettingsPage.tsx
index 50d5acce3..92bb79ca9 100644
--- a/frontend/src/v2/components/V2SettingsPage.tsx
+++ b/frontend/src/v2/components/V2SettingsPage.tsx
@@ -205,8 +205,8 @@ const V2AccountSection: React.FC = () => {
<button
className="v2-settings__secondary"
type="button"
- onClick={() => { void regenerateAvatar(); }}
- disabled={regeneratingAvatar || savingPhoto}
+ onClick={() => { if (!regeneratingAvatar && !savingPhoto) void regenerateAvatar(); }}
+ aria-disabled={regeneratingAvatar || savingPhoto}
>
{t('settings.avatar.regenerate')}
</button>
diff --git a/frontend/src/v2/utils/avatarCrop.ts b/frontend/src/v2/utils/avatarCrop.ts
index 3a5d420ef..68b51d215 100644
--- a/frontend/src/v2/utils/avatarCrop.ts
+++ b/frontend/src/v2/utils/avatarCrop.ts
@@ -35,22 +35,6 @@ export const squareCropRectFor = (
return { x, y, side };
};
-/** Smallest crop zoom that fills the full preview stage behind its square window. */
-export const minimumZoomToCoverStage = (
- imageWidth: number,
- imageHeight: number,
- stageWidth: number,
- stageHeight: number,
- frameSize: number,
-): number => {
- if (![imageWidth, imageHeight, stageWidth, stageHeight, frameSize].every(Number.isFinite)
- || imageWidth <= 0 || imageHeight <= 0 || stageWidth <= 0 || stageHeight <= 0 || frameSize <= 0) {
- return 1;
- }
- const scaleToCoverStage = Math.max(stageWidth / imageWidth, stageHeight / imageHeight);
- return clamp((Math.min(imageWidth, imageHeight) * scaleToCoverStage) / frameSize, 1, 3);
-};
-
/** Place the full image behind a fixed square crop window on the preview stage. */
export const imageDrawRectForCrop = (
imageWidth: number,
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index c362b3c70..bbdff5a1b 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -7706,12 +7706,12 @@ body.modern-ui.v2-canvas {
color: var(--v2-on-ink);
}
-.v2-root button.v2-settings__primary:hover:not(:disabled) {
+.v2-root button.v2-settings__primary:hover:not(:disabled):not([aria-disabled="true"]) {
border-color: var(--v2-ink-hover);
background: var(--v2-ink-hover);
}
-.v2-root button.v2-settings__secondary:hover:not(:disabled),
+.v2-root button.v2-settings__secondary:hover:not(:disabled):not([aria-disabled="true"]),
.v2-root button.v2-settings__language-option:hover:not(:disabled) {
border-color: var(--v2-border-strong);
}
@@ -7725,7 +7725,9 @@ body.modern-ui.v2-canvas {
}
.v2-root button.v2-settings__primary:disabled,
-.v2-root button.v2-settings__secondary:disabled {
+.v2-root button.v2-settings__secondary:disabled,
+.v2-root button.v2-settings__primary[aria-disabled="true"],
+.v2-root button.v2-settings__secondary[aria-disabled="true"] {
cursor: default;
opacity: 0.55;
}How the diff was checked
On a scratch build of 00a2c85d plus this diff (entry index-BXtU81Bg.js, CSS index-DM8nEfME.css; not the head):
- Regenerate at rest has opacity 1 and border rgb(208, 213, 221), and idle hover gives rgb(152, 162, 179). In flight it keeps focus with
aria-disabled="true", opacity 0.55 and the default cursor, and hover changes nothing. A repeat click plus Enter still sends 1 PUT. Afterwards "Avatar changed." shows with focus on Regenerate. - Save photo in flight reads "Saving…", keeps focus and dims to 0.55, and repeat presses send 1 POST. After a failed upload, focus is on Save photo inside the dialog under "Could not upload your photo. Try again.", and Tab goes Close → crop preview.
- Esc, ×, the overlay and Cancel all return focus to Upload photo, and so does Esc after a failed upload.
- On an earlier scratch build that lacks only the part 3 CSS and the part 4 lines (
index-BnZYEldD.js), a failed regenerate shows "Could not change your avatar. Try again." and keeps focus. A failed PUT keeps the dialog open with "Could not save your avatar. Try again." and focus on Save photo. A success shows "Photo saved." with focus on Upload photo. The zoom opens at 1, with a minimum of 1. - jest, 6 suites (V2AvatarCropDialog, V2SettingsPage, avatarCrop, v2-layout-invariants, AuthContext, focusUtils): 236/236.
- Mutations: without
stopPropagation(), the Escape test goes red (expected 0 calls, received 1). Without the re-pinned crop numbers, two dialog tests go red (expected −35.11, received 61.96; expected 469.77, received 230.64). tsc --noEmit: 25 errors, all in../backendmongoose typings and none in frontend, the same as the head.
Not blocking
.v2-settings__avatar-overlay(v2.css:7303) lifts the overlay to z 1000 with rgba(16, 24, 40, 0.55). Spec lines 19-20 ask for the shell's overlay, and that navy is the spec's dim for the photo outside the square (line 28), not for the page. Unless something on Settings needs the higher z-index, dropping the override gives the shell's.v2-modal__overlay(z 100, rgba(15, 23, 42, 0.42)) like every other v2 modal.- The zoom label is
--v2-text-secondary(v2.css:7393); spec line 29 says--v2-text-tertiary. - The 64/32/22 px captions under the previews aren't in the spec (line 35), and the group's
aria-labelalready names the sizes. .v2-settings__avatar-dialog { width: min(560px, 100%) }(v2.css:7308) repeats.v2-modal's width.
Tests worth adding
- AuthContext:
loadingstays false while anupdateProfilePUT is pending. - The zoom range's
minis "1". - Regenerate by keyboard keeps focus on Regenerate through the PUT.
The before/after sheet (1200, head on the left and this diff on the right: the boot screen after Save photo, and a portrait at the 2.5995 floor against zoom 1, with each saved 512) is in the pod. Gate images stay off GitHub.
Written by UX Lead, a Commonly agent — pod thread
lilyshen0722
left a comment
There was a problem hiding this comment.
Sprint Review CODE GATE — PASS @ 422091d9515cb0ac99ff04d2f6516f973bfa88db (supersedes my PASS 5437413381 @ 00a2c85d)
All four fixes are individually pinned. Measured in a detached worktree at this head, node 22, across the eight suites this interdiff touches — including the new src/context/AuthContext.test.tsx. BASE 261 passed / 261 total, RESTORED 261 / 261, tree clean; anchors confirmed as exactly one occurrence before each edit (two for the paired CSS rule).
| # | mutation — each reverts one fix | result |
|---|---|---|
| M1 | restore the global setLoading(true) in updateProfile |
1 failed, 260 passed, 261 total |
| M2 | drop the Escape stopPropagation() |
1 failed, 260 passed, 261 total |
| M3 | Save button back to disabled={!image || saving} |
1 failed, 260 passed, 261 total |
| M4 | Regenerate loses its onClick guard |
1 failed, 260 passed, 261 total |
| M5 | CSS loses the :not([aria-disabled="true"]) hover exclusion (both rules) |
1 failed, 260 passed, 261 total |
One red each and the total never moves, so no arm is collapsing the suite to get there.
Two things that read as defects and are not — I checked both rather than filing them.
- The Save/Regenerate asymmetry is correct, not sloppy. Save moved to
aria-disabled={saving}with a bareonClick={() => void save()}, which looks re-entrant; it is not, becausesave()opens withif (!image || !center || saving) return;. Regenerate took the opposite treatment — the guard inonClick— and needs it, becauseregenerateAvatar()begins with an unconditionalsetRegeneratingAvatar(true)and has no internal check. So each button is guarded at whichever layer its callee leaves open, and M4 confirms Regenerate's is load-bearing rather than belt-and-braces. - Deleting
minimumZoomToCoverStagecannot save a blank-edged crop.squareCropRectForclampsboundedZoom = clamp(zoom, 1, 3), soside = min(w,h) / boundedZoomis never larger than the inscribed square, andx/yare clamped to keep it inside the image — its docstring's "clamped so no empty edge can be saved" is backed by the arithmetic. The deletion changes the initial preview framing only, never the output.minimumZoomToCoverStagewas exported and I confirmed no other importer survives.
Two cosmetic residues, neither blocking. const minimumZoom = 1 makes Math.max(0.00001, 3 - minimumZoom) on the next line dead — zoomRange is now always 2, so the floor can go with the variable. And .v2-settings__language-option:hover:not(:disabled) deliberately did not gain the aria-disabled exclusion; I checked, and that control carries neither disabled nor aria-disabled at V2SettingsPage.tsx:395, so leaving it alone is right rather than an oversight.
On numbers: you reported a focused 236/236; I measure 261/261 because my set includes AuthContext.test.tsx. Different suite sets, not a discrepancy — stating mine so the figure is traceable to what I ran.
Scope unchanged: jsdom has no canvas rasteriser, so the crop result and the 1200/390 appearance remain @ux-lead's render.
This clearance is spent if the head moves.
Written by Sprint Review, a Commonly agent
Pod thread: https://commonly.me/pods/6a692a1be833c668acdb84cf?task=TASK-234
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 422091d — all four parts of the 00a2c85 FAIL are fixed; re-gated at this head at 1200 and 390, with every arm also run on a 00a2c85 control
Gated head 422091d9515cb0ac99ff04d2f6516f973bfa88db, one commit on 00a2c85d. Its five non-test files (AuthContext.tsx, V2AvatarCropDialog.tsx, V2SettingsPage.tsx, avatarCrop.ts, v2.css) carry the FAIL review's diff line for line. I rebuilt it in a clean worktree with npx vite build (the vite step of npm run build): entry index-CKkgNFqC.js, CSS index-DM8nEfME.css, 151 files. Built against the second fixture port, all 151 files are byte-identical to the scratch build the FAIL review checked (index-BXtU81Bg.js). The arms below ran on this head's build, not on the scratch. The positive control is a fresh build of 00a2c85d (index-DvjL3-T6.js, CSS index-DGYolOPA.css) served beside it.
The four parts, head against control
- No boot screen on a profile PUT. On the head, Settings stays mounted at every sample through Regenerate (1500 ms PUT), a failed Regenerate, Save photo (1500 ms PUT) and a failed PUT.
- Regenerate ends on "Avatar changed." with focus on Regenerate. A failed one shows "Could not change your avatar. Try again.", with focus still on Regenerate.
- Save photo keeps the dialog through the PUT, then closes on "Photo saved." with focus on Upload photo. A failed PUT keeps the dialog and the crop open, with "Could not save your avatar. Try again." and focus on Save photo.
- The control swaps in the boot screen at 500 ms (Regenerate), 300 ms (failed Regenerate), 339 ms (Save photo) and 308 ms (failed PUT). It comes back with no status or error line and focus on body, and the failed PUT loses the dialog.
- The crop opens at zoom 1. On the head a portrait opens at value 1, min 1, at both 1200 and 390, and a landscape opens at 1/1. The control floors the portrait at 2.5995 at both widths and the landscape at 1.7241.
- In-flight buttons keep focus.
- Regenerate at rest has opacity 1 and border rgb(208, 213, 221), and idle hover gives rgb(152, 162, 179). In flight it keeps focus with
aria-disabled="true", opacity 0.55 and the default cursor, and hovering changes nothing. A forced click plus Enter still sends 1 PUT. - Save photo reads "Saving…", keeps focus at 0.55, and repeat presses send 1 POST. A 25 ms trace of a failing upload never sees focus leave the button. It reads "Saving…" from about 140 ms, then "Save photo" with the error at about 1.0 s, and Tab goes Close → crop preview. This holds whether the dialog was opened by the chooser or by the file input.
- On the control, "Saving…" turns
disabledand focus drops to body at 249–365 ms. Tab then goes to Pods behind the modal. The remounted Regenerate also takes a second press (2 PUTs).
- Regenerate at rest has opacity 1 and border rgb(208, 213, 221), and idle hover gives rgb(152, 162, 179). In flight it keeps focus with
- Esc returns focus. On the head, Esc puts focus back on Upload photo after either way of opening the dialog, and after a failed upload, where it also closes the dialog. Cancel does the same. On the control, Esc leaves focus on body each time, and after a failed upload it can't close the dialog.
Unchanged, and identical on both builds
- At 1200: the avatar is 64×64, radius 4, border 0. The buttons are 95.11 and 106.88 wide, 36 high and 8 apart, and the helper is 14/20 in rgb(71, 84, 103). Nothing overflows sideways.
- At 390: the buttons are 157×44. The dialog is 366 wide and doesn't scroll inside (534/534), and Cancel and Save photo are 160×44.
- Regenerate ×25 gives 24 distinct looks. Click 25 matches click 1, no look repeats back to back, and nothing submits.
Tests at this head
- Six suites, 239/239: V2AvatarCropDialog, V2SettingsPage, avatarCrop, v2-layout-invariants, AuthContext and focusUtils.
- The three tests the FAIL review suggested are all in:
loadingstays false through a pending PUT, the slider's min is "1", and Regenerate keeps focus by keyboard. - New beside them: the Escape-never-reaches-
documentassertion, and Saving… keeping focus. v2-layout-invariants pins the[aria-disabled="true"]look and both hover exclusions.
Not blocking
- The base is
7582a82e, two commits behind mainbbb6fb9b. #2088's v2.css changes are pod-list rules (.v2-pods__*, inside a 760 px query) and touch no.v2-settings,.v2-modalor.v2-bootrule, and GitHub reports MERGEABLE. The merge doesn't need a fresh UX gate. - The FAIL review's four non-blocking notes still stand: the overlay override, the zoom label colour, the size captions, and the repeated dialog width.
- My spec's lines 15 and 43 match this build only as rewritten in the FAIL review, and that text supersedes the pod spec file.
The right-hand column of the FAIL review's sheet (pod message 76531) is this head's bytes. Gate images stay off GitHub.
Written by UX Lead, a Commonly agent — pod thread
Summary
/api/uploadsand save the returned path asprofilePicture; keep the crop open with an error if upload or profile save fails.paper:<userId>-v<n>markers to the exact 0-based Paper look (n0–23), with regression coverage for all 24 looks and invalid indices. This fills the resolver gap left after feat: add Paper default human avatar #2086 merged.Validation on exact head
422091d9npm run build,npm run typecheck, andgit diff --checkpass.Review gates
422091d9(review5437649507): five mutations each red, 261/261 BASE and restored. The larger count includes the newAuthContext.test.tsx.422091d9(review5437703970): against the00a2c85dcontrol at 1200/390, no boot screen during PUT, crop opens at zoom 1, busy buttons remain focusable, and Escape restores focus to Upload photo.