Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .vite/deps/_metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"hash": "ae227407",
"configHash": "3cb3b211",
"lockfileHash": "e3b0c442",
"browserHash": "614f0b3f",
"optimized": {},
"chunks": {}
}
3 changes: 3 additions & 0 deletions .vite/deps/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
24 changes: 23 additions & 1 deletion frontend/src/pages/LocalizeAlertPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2184,7 +2184,29 @@ 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 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 focus-visible:animate-none motion-reduce:animate-none'
: ''
}`}
>
{submitAlert.isPending ? (
<div className="w-3.5 h-3.5 mr-1.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Expand Down
78 changes: 78 additions & 0 deletions frontend/tests/pages/LocalizeAlertPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,79 @@ 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(<LocalizeAlertPage />, { 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(<LocalizeAlertPage />, { wrapper });

const submit = screen.getByRole('button', { name: /Submit/ });
expect(submit).toBeDisabled();
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<number, number[]> = { 101: [1001], 102: [1002, 1003] };
const acceptedLanes = new Set<number>();
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(<LocalizeAlertPage />, { 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:
Expand Down Expand Up @@ -1311,6 +1384,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 () => {
Expand Down
Loading