From 234d91867de14fa9503fedb60586edbffdf9ecae Mon Sep 17 00:00:00 2001 From: Chouffe Date: Thu, 13 Aug 2026 17:45:36 +0200 Subject: [PATCH 1/2] fix(ui): drop the toast below a full-width top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast sat at top-4, 16px into a viewport whose top 48px the localize object editor fills with its own bar — including the close button on the same right edge the toast anchors to. Accepting a box covered the control the reader was reaching for. top-16 clears that bar with the same 16px breathing room the toast had against the page edge before. --- frontend/src/components/ui/NotificationSystem.tsx | 6 +++++- .../tests/components/ui/NotificationSystem.test.tsx | 13 +++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ui/NotificationSystem.tsx b/frontend/src/components/ui/NotificationSystem.tsx index 106d2679..f316f65f 100644 --- a/frontend/src/components/ui/NotificationSystem.tsx +++ b/frontend/src/components/ui/NotificationSystem.tsx @@ -86,7 +86,11 @@ export const NotificationSystem: React.FC = ({ return (
diff --git a/frontend/tests/components/ui/NotificationSystem.test.tsx b/frontend/tests/components/ui/NotificationSystem.test.tsx index c55d1f3d..7dbe71da 100644 --- a/frontend/tests/components/ui/NotificationSystem.test.tsx +++ b/frontend/tests/components/ui/NotificationSystem.test.tsx @@ -191,10 +191,19 @@ describe('NotificationSystem', () => { expect(screen.getByText(longMessage)).toBeInTheDocument(); }); + it('should sit below a full-width top bar', () => { + // top-16 (64px) clears the localize editor's 48px 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'); + + const notificationDiv = container.querySelector('.fixed.top-16.right-4'); expect(notificationDiv).toHaveClass( 'transition-all', 'duration-300', From 10e97aefdbfcc0926fd281d6f7807972c56e38e0 Mon Sep 17 00:00:00 2001 From: Chouffe Date: Thu, 13 Aug 2026 18:00:32 +0200 Subject: [PATCH 2/2] fix(ui): only drop the toast when a bar owns the top of the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the toast to top-16 app-wide traded one covered control for another: with no overlay up, 64px is where the localize rail's own header starts, and the toast landed on its FP toggle — a button, where before it covered only the page header's progress badge. So the drop is now conditional. The localize object editor and the add-object overlay are the two things that pin a 48px bar across the top, and the toast clears it exactly while one of them is mounted. --- .../src/components/ui/NotificationSystem.tsx | 18 +++++++--- frontend/src/pages/LocalizeAlertPage.tsx | 5 +++ .../components/ui/NotificationSystem.test.tsx | 16 ++++++--- .../tests/pages/LocalizeAlertPage.test.tsx | 34 +++++++++++++++++++ 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ui/NotificationSystem.tsx b/frontend/src/components/ui/NotificationSystem.tsx index f316f65f..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,11 +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 7dbe71da..ce7d001d 100644 --- a/frontend/tests/components/ui/NotificationSystem.test.tsx +++ b/frontend/tests/components/ui/NotificationSystem.test.tsx @@ -191,11 +191,19 @@ describe('NotificationSystem', () => { expect(screen.getByText(longMessage)).toBeInTheDocument(); }); - it('should sit below a full-width top bar', () => { - // top-16 (64px) clears the localize editor's 48px bar, whose close - // button sits at the same right edge the toast does. + 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(); }); @@ -203,7 +211,7 @@ describe('NotificationSystem', () => { it('should apply correct transition classes', () => { const { container } = render(); - const notificationDiv = container.querySelector('.fixed.top-16.right-4'); + const notificationDiv = container.querySelector('.fixed.top-4.right-4'); expect(notificationDiv).toHaveClass( 'transition-all', 'duration-300', 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`),