diff --git a/frontend/src/components/ui/NotificationSystem.tsx b/frontend/src/components/ui/NotificationSystem.tsx index 106d2679..fbc42e0e 100644 --- a/frontend/src/components/ui/NotificationSystem.tsx +++ b/frontend/src/components/ui/NotificationSystem.tsx @@ -33,6 +33,13 @@ interface NotificationSystemProps { onDismiss: () => void; /** Auto-dismiss timeout in milliseconds (default: 3000ms, 0 to disable) */ autoDismissMs?: number; + /** + * Drop the toast clear of a full-width 48px bar pinned to the top of the + * viewport. Set it while such a bar is up — the localize object editor and + * the add-object overlay both raise one, and both end it with a close + * button on the same right edge the toast anchors to. + */ + belowTopBar?: boolean; } /** @@ -68,6 +75,7 @@ export const NotificationSystem: React.FC = ({ toastType, onDismiss, autoDismissMs = 3000, + belowTopBar = false, }) => { // Auto-dismiss logic useEffect(() => { @@ -86,7 +94,11 @@ export const NotificationSystem: React.FC = ({ return (
diff --git a/frontend/src/pages/LocalizeAlertPage.tsx b/frontend/src/pages/LocalizeAlertPage.tsx index 9df0b8ee..8afbf3f9 100644 --- a/frontend/src/pages/LocalizeAlertPage.tsx +++ b/frontend/src/pages/LocalizeAlertPage.tsx @@ -2498,6 +2498,11 @@ export default function LocalizeAlertPage({ mode }: LocalizeAlertPageProps = {}) toastMessage={toastMessage} toastType={toastType} onDismiss={dismissToast} + // Both full-screen overlays fill the top 48px of the viewport with + // their own bar, close button included; the toast has to clear it. + // With neither up it stays high, where it covers only the page + // header's progress badge rather than the rail's FP toggle. + belowTopBar={modalContext !== null || addObjectOpen} /> {showShortcutsModal && ( diff --git a/frontend/tests/components/ui/NotificationSystem.test.tsx b/frontend/tests/components/ui/NotificationSystem.test.tsx index c55d1f3d..ce7d001d 100644 --- a/frontend/tests/components/ui/NotificationSystem.test.tsx +++ b/frontend/tests/components/ui/NotificationSystem.test.tsx @@ -191,9 +191,26 @@ describe('NotificationSystem', () => { expect(screen.getByText(longMessage)).toBeInTheDocument(); }); + it('should sit high by default', () => { + const { container } = render(); + + expect(container.querySelector('.fixed.right-4')).toHaveClass('top-4'); + expect(container.querySelector('.top-16')).toBeNull(); + }); + + it('should clear a full-width top bar when asked', () => { + // top-16 (64px) clears the 48px bar the localize object editor and the + // add-object overlay each pin to the top of the viewport — a bar whose + // close button sits at the same right edge the toast does. + const { container } = render(); + + expect(container.querySelector('.fixed.right-4')).toHaveClass('top-16'); + expect(container.querySelector('.top-4')).toBeNull(); + }); + it('should apply correct transition classes', () => { const { container } = render(); - + const notificationDiv = container.querySelector('.fixed.top-4.right-4'); expect(notificationDiv).toHaveClass( 'transition-all', diff --git a/frontend/tests/pages/LocalizeAlertPage.test.tsx b/frontend/tests/pages/LocalizeAlertPage.test.tsx index ef2761cd..acae139e 100644 --- a/frontend/tests/pages/LocalizeAlertPage.test.tsx +++ b/frontend/tests/pages/LocalizeAlertPage.test.tsx @@ -860,6 +860,40 @@ describe('LocalizeAlertPage', () => { expect(screen.queryByText('Frame saved')).not.toBeInTheDocument(); }); + it('drops the toast below the editor bar while the editor is open, and only then', async () => { + // The editor's own 48px bar ends with its close button on the right edge + // the toast anchors to; with no editor up, that height belongs to the + // rail's header button instead. + vi.mocked(apiClient.createDetectionAnnotation).mockRejectedValue(new Error('boom')); + await renderAndSettle(, { + wrapper: makeWrapper(`${ROUTES.LOCALIZE}/101/object/101/1001`), + }); + await waitFor(() => expect(screen.getByTestId('image-modal')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Mock Submit')); + + const toast = (await screen.findByText(/failed to save frame/i)).closest('.fixed')!; + expect(toast).toHaveClass('top-16'); + expect(toast).not.toHaveClass('top-4'); + }); + + it('keeps the toast high when no overlay owns the top of the viewport', async () => { + vi.mocked(apiClient.bulkUpsertDetectionAnnotations).mockRejectedValue(new Error('boom')); + await renderAndSettle(, { wrapper }); + + fireEvent.click(screen.getByRole('button', { name: 'Object 2' })); + fireEvent.click( + within(screen.getByTestId('localize-active-object-actions')).getByRole('button', { + name: "Accept Object 2's boxes", + }) + ); + fireEvent.click(await screen.findByTestId('accept-remaining-confirm')); + + const toast = (await screen.findByText(/failed to accept boxes/i)).closest('.fixed')!; + expect(toast).toHaveClass('top-4'); + expect(toast).not.toHaveClass('top-16'); + }); + it('hands the editor the object named in the URL, not just the frame', async () => { await renderAndSettle(, { wrapper: makeWrapper(`${ROUTES.LOCALIZE}/101/object/101/1001`),