From f08a6a18db2f15d6ffdede2d282d45cfa5ea2eab Mon Sep 17 00:00:00 2001 From: Chouffe Date: Wed, 12 Aug 2026 15:50:59 +0200 Subject: [PATCH 1/2] feat(localize): halo Submit once every object is accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The localize gate opens silently. Submit sits in the rail footer under every object row, while the last accept usually happens up in the editor — so the moment the alert becomes shippable is exactly the moment the annotator is looking somewhere else, and the button just quietly stops being grey. Pulse the existing `animate-pine-glow` halo on it instead. Same keyframe the add-object flow puts on its solid-pine "Create object" CTA, so "the work is done, move forward" reads as one signal rather than two. Keyed on `!submitBlocked`, not on `allObjectsAccepted`: an alert whose boxes are all drawn but whose sibling is still marked unsure stays blocked, and haloing that would pulse a button that cannot be clicked. Dropped again on `isPending` so the halo and the submit spinner never run at once. --- frontend/src/pages/LocalizeAlertPage.tsx | 14 +++++++++- .../tests/pages/LocalizeAlertPage.test.tsx | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/LocalizeAlertPage.tsx b/frontend/src/pages/LocalizeAlertPage.tsx index de2a5fc8..f8c786a5 100644 --- a/frontend/src/pages/LocalizeAlertPage.tsx +++ b/frontend/src/pages/LocalizeAlertPage.tsx @@ -2184,7 +2184,19 @@ export default function LocalizeAlertPage({ mode }: LocalizeAlertPageProps = {}) handleSubmitClick(); }} disabled={submitBlocked || submitAlert.isPending} - className="flex w-full items-center justify-center rounded-lg bg-pine px-5 py-2.5 text-center font-body text-sm font-semibold text-white hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-char focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + // Haloed once the gate opens, because the last accept + // usually happens up in the editor while Submit sits + // down in the rail footer under every object row — + // enabling it quietly is easy to miss. Keyed on the + // whole gate rather than on the boxes alone, so an + // undecided sibling never pulses an unclickable + // button, and dropped the moment the click lands so + // the halo and the spinner never run together. + className={`flex w-full items-center justify-center rounded-lg bg-pine px-5 py-2.5 text-center font-body text-sm font-semibold text-white hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-char focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50${ + !submitBlocked && !submitAlert.isPending + ? ' animate-pine-glow motion-reduce:animate-none' + : '' + }`} > {submitAlert.isPending ? (
diff --git a/frontend/tests/pages/LocalizeAlertPage.test.tsx b/frontend/tests/pages/LocalizeAlertPage.test.tsx index 0989771f..45cebada 100644 --- a/frontend/tests/pages/LocalizeAlertPage.test.tsx +++ b/frontend/tests/pages/LocalizeAlertPage.test.tsx @@ -1275,6 +1275,28 @@ describe('LocalizeAlertPage', () => { expect(tip).toHaveTextContent('Submits every object still awaiting localization'); }); + // The halo nudges: the gate opening is easy to miss, because Submit sits + // in the rail footer below every object row and the last accept usually + // happens up in the editor. Same `animate-pine-glow` the add-object flow + // puts on its solid-pine CTA, so "done — move forward" reads the same way + // everywhere rather than inventing a second signal. + it('halos the submit button once the gate opens', async () => { + mockAllFramesAccepted(); + await renderAndSettle(, { wrapper }); + + const submit = screen.getByRole('button', { name: /Submit/ }); + await waitFor(() => expect(submit).toBeEnabled()); + expect(submit).toHaveClass('animate-pine-glow'); + }); + + it('leaves submit un-haloed while an object still has a pending frame', async () => { + await renderAndSettle(, { wrapper }); + + const submit = screen.getByRole('button', { name: /Submit/ }); + expect(submit).toBeDisabled(); + expect(submit).not.toHaveClass('animate-pine-glow'); + }); + it('blocks submit and explains why while a sibling object is still undecided', async () => { // The queue hides such an alert; this covers a deep link or a stale // tab, and mirrors the server guard on localize-submit (spec: @@ -1311,6 +1333,11 @@ describe('LocalizeAlertPage', () => { expect(submit).toBeDisabled(); fireEvent.click(submit); expect(apiClient.localizeSubmit).not.toHaveBeenCalled(); + + // And no halo either: every box is drawn, so a nudge keyed on the boxes + // alone would pulse a button that cannot be clicked. The halo follows + // the whole gate, undecided sibling included. + expect(submit).not.toHaveClass('animate-pine-glow'); }); it('enables once every object is accepted, submits exactly the workable annotation ids, and navigates back to the queue', async () => { From bd54fb2d8affb8734448e9cd537d09245b96f5f2 Mon Sep 17 00:00:00 2001 From: Chouffe Date: Wed, 12 Aug 2026 17:27:48 +0200 Subject: [PATCH 2/2] fix(localize): keep the focus ring visible under the Submit halo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the halo was erasing the focus indicator. The focus ring is a box-shadow, `pine-glow` animates box-shadow, and animation declarations outrank normal author ones — so on a haloed button the ring never painted, and `focus:outline-none` had already removed the native fallback. A keyboard user tabbing to the primary action saw nothing. `focus-visible:animate-none` stops the halo on keyboard focus and hands the ring back. Nothing is lost: the halo's job is to get you to the button, and you cannot focus it without having found it. Also add the transition test the other three were missing. They only ever observed a gate that was already open, or already shut, at arrival — an implementation that decided the halo once at mount would have passed all of them. The new one accepts both objects through the CTA bar and watches the halo arrive with the enablement. And correct the comment's claim that the halo drops "the moment the click lands": on the soft-confirm path handleSubmitClick opens the dialog and returns without mutating, so isPending stays false. --- .vite/deps/_metadata.json | 8 +++ .vite/deps/package.json | 3 ++ frontend/src/pages/LocalizeAlertPage.tsx | 16 ++++-- .../tests/pages/LocalizeAlertPage.test.tsx | 51 +++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 .vite/deps/_metadata.json create mode 100644 .vite/deps/package.json diff --git a/.vite/deps/_metadata.json b/.vite/deps/_metadata.json new file mode 100644 index 00000000..50991e1f --- /dev/null +++ b/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "ae227407", + "configHash": "3cb3b211", + "lockfileHash": "e3b0c442", + "browserHash": "614f0b3f", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/.vite/deps/package.json b/.vite/deps/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/src/pages/LocalizeAlertPage.tsx b/frontend/src/pages/LocalizeAlertPage.tsx index f8c786a5..9df0b8ee 100644 --- a/frontend/src/pages/LocalizeAlertPage.tsx +++ b/frontend/src/pages/LocalizeAlertPage.tsx @@ -2190,11 +2190,21 @@ export default function LocalizeAlertPage({ mode }: LocalizeAlertPageProps = {}) // enabling it quietly is easy to miss. Keyed on the // whole gate rather than on the boxes alone, so an // undecided sibling never pulses an unclickable - // button, and dropped the moment the click lands so - // the halo and the spinner never run together. + // button, and off again while the submit is in flight + // so the halo and the spinner never run together. + // + // `focus-visible:animate-none` is not decoration: the + // focus ring below is a box-shadow, the halo animates + // box-shadow, and animations outrank normal author + // declarations — so a running halo would erase the + // ring, and `focus:outline-none` has already removed + // the native fallback. Keyboard focus therefore stops + // the halo and takes the ring back. It has served its + // purpose by then anyway: you cannot focus the button + // without having found it. className={`flex w-full items-center justify-center rounded-lg bg-pine px-5 py-2.5 text-center font-body text-sm font-semibold text-white hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-char focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50${ !submitBlocked && !submitAlert.isPending - ? ' animate-pine-glow motion-reduce:animate-none' + ? ' animate-pine-glow focus-visible:animate-none motion-reduce:animate-none' : '' }`} > diff --git a/frontend/tests/pages/LocalizeAlertPage.test.tsx b/frontend/tests/pages/LocalizeAlertPage.test.tsx index 45cebada..ef2761cd 100644 --- a/frontend/tests/pages/LocalizeAlertPage.test.tsx +++ b/frontend/tests/pages/LocalizeAlertPage.test.tsx @@ -1297,6 +1297,57 @@ describe('LocalizeAlertPage', () => { expect(submit).not.toHaveClass('animate-pine-glow'); }); + // The two above only ever observe a gate that was already open, or already + // shut, at arrival — an implementation that decided the halo once at mount + // would satisfy both. But the whole point of the halo is the moment the + // gate opens under you, so drive that for real: accept both objects from + // the CTA bar and watch the halo arrive with the enablement. + it('starts the halo when the last object is accepted, not only on arrival', async () => { + // Frames read as pending until their lane has actually been accepted, + // so the accept mutation is what flips the gate — as it does in the app. + const detectionIdsByLane: Record = { 101: [1001], 102: [1002, 1003] }; + const acceptedLanes = new Set(); + vi.mocked(apiClient.getDetectionAnnotations).mockImplementation(async filters => { + const laneId = filters?.sequence_id ?? 0; + const items = acceptedLanes.has(laneId) + ? (detectionIdsByLane[laneId] ?? []).map(makeDetectionAnnotation) + : []; + return { ...emptyAnnotationsPage, items, total: items.length }; + }); + vi.mocked(apiClient.bulkUpsertDetectionAnnotations).mockImplementation( + async (sequenceId, items) => { + acceptedLanes.add(sequenceId); + return items.map(item => ({ + annotation_id: 9100 + item.detection_id, + detection_id: item.detection_id, + processing_stage: item.processing_stage, + })); + } + ); + + await renderAndSettle(, { wrapper }); + + // Arrival: genuinely shut, so what follows is a real transition and not + // a gate that was open the whole time. + expect(screen.getByRole('button', { name: /Submit/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Submit/ })).not.toHaveClass('animate-pine-glow'); + + for (const label of ['Object 1', 'Object 2']) { + fireEvent.click(screen.getByRole('button', { name: label })); + fireEvent.click( + within(screen.getByTestId('localize-active-object-actions')).getByRole('button', { + name: `Accept ${label}'s boxes`, + }) + ); + fireEvent.click(await screen.findByTestId('accept-remaining-confirm')); + } + + await waitFor(() => + expect(screen.getByRole('button', { name: /Submit/ })).toBeEnabled() + ); + expect(screen.getByRole('button', { name: /Submit/ })).toHaveClass('animate-pine-glow'); + }); + it('blocks submit and explains why while a sibling object is still undecided', async () => { // The queue hides such an alert; this covers a deep link or a stale // tab, and mirrors the server guard on localize-submit (spec: