From 80e05b497af9cf023ada1e23a10b08811dbb68c0 Mon Sep 17 00:00:00 2001 From: Farnam Date: Tue, 27 Oct 2026 22:59:21 +0330 Subject: [PATCH 1/8] test(a11y): cover notice panel focus entry and safe restoration --- .../notices/__tests__/NoticeCenter.test.tsx | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/app/src/components/notices/__tests__/NoticeCenter.test.tsx b/app/src/components/notices/__tests__/NoticeCenter.test.tsx index 92117e941ee..c6ec05ae71b 100644 --- a/app/src/components/notices/__tests__/NoticeCenter.test.tsx +++ b/app/src/components/notices/__tests__/NoticeCenter.test.tsx @@ -1,11 +1,11 @@ import { configureStore } from '@reduxjs/toolkit'; -import { render, screen, within } from '@testing-library/react'; +import { act, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import userErrorsReducer, { reportUserError } from '../../../store/userErrorsSlice'; +import userErrorsReducer, { dismissUserError, reportUserError } from '../../../store/userErrorsSlice'; import type { UserErrorDescriptor } from '../../../types/userError'; import NoticeCenter from '../NoticeCenter'; @@ -58,6 +58,9 @@ function renderCenter(descriptors: UserErrorDescriptor[] = []) { const tree = () => ( +
+ +
@@ -105,6 +108,48 @@ describe('NoticeCenter', () => { expect(within(panel).getByText('API key required')).toBeInTheDocument(); }); + + it('focuses the close control when opened and restores focus to the bell on close', async () => { + renderCenter([keyError]); + const trigger = screen.getByTestId('notice-trigger'); + + await userEvent.click(trigger); + expect(screen.getByTestId('notice-close')).toHaveFocus(); + + await userEvent.click(screen.getByTestId('notice-close')); + expect(screen.queryByTestId('notice-panel')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it('restores the previously focused app control when final dismissal removes the bell', async () => { + renderCenter([keyError]); + const previous = screen.getByRole('button', { name: 'Resume work' }); + previous.focus(); + await userEvent.click(screen.getByTestId('notice-trigger')); + await userEvent.click(screen.getByTestId('notice-dismiss')); + expect(screen.queryByTestId('notice-center')).not.toBeInTheDocument(); + expect(previous).toHaveFocus(); + }); + + it('falls back to persistent app content if the previous control was unmounted', async () => { + renderCenter([keyError]); + const previous = screen.getByRole('button', { name: 'Resume work' }); + previous.focus(); + await userEvent.click(screen.getByTestId('notice-trigger')); + previous.remove(); + await userEvent.click(screen.getByTestId('notice-dismiss')); + expect(screen.getByTestId('notice-fallback')).toHaveFocus(); + }); + + it('does not steal focus when notices disappear while work has focus outside the panel', async () => { + const { store } = renderCenter([keyError]); + await userEvent.click(screen.getByTestId('notice-trigger')); + const previous = screen.getByRole('button', { name: 'Resume work' }); + previous.focus(); + act(() => store.dispatch(dismissUserError({ id: keyError.id }))); + expect(previous).toHaveFocus(); + }); + it('raises the usage limit as a notice that cannot be dismissed at the limit', async () => { usageState.teamUsage = { plan: 'free' }; usageState.isAtLimit = true; From 5e1956780b9f560ab5e5d8222574f83835d02035 Mon Sep 17 00:00:00 2001 From: Farnam Date: Tue, 27 Oct 2026 23:00:18 +0330 Subject: [PATCH 2/8] fix(a11y): restore focus after notice panel dismissal and closing --- app/src/components/notices/NoticeCenter.tsx | 42 ++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/app/src/components/notices/NoticeCenter.tsx b/app/src/components/notices/NoticeCenter.tsx index bbee5aaa64e..b9ec23adf7f 100644 --- a/app/src/components/notices/NoticeCenter.tsx +++ b/app/src/components/notices/NoticeCenter.tsx @@ -95,6 +95,29 @@ export default function NoticeCenter() { const notices = useAppNotices(); const [open, setOpen] = useState(false); const panelRef = useRef(null); + const triggerRef = useRef(null); + const closeRef = useRef(null); + const wasOpenRef = useRef(false); + const previousFocusRef = useRef(null); + const containsFocusRef = useRef(false); + + // Focus enters only when the user opens the non-modal panel. On close, + // return to the bell, or to a stable app control if the bell disappears. + useEffect(() => { + const visible = open && notices.length > 0; + if (visible && !wasOpenRef.current) { + closeRef.current?.focus(); + } else if (!visible && wasOpenRef.current && containsFocusRef.current) { + const previous = previousFocusRef.current; + const target = + triggerRef.current ?? + (previous?.isConnected + ? previous + : document.querySelector('[data-app-focus-fallback]')); + target?.focus(); + } + wasOpenRef.current = visible; + }, [open, notices.length]); // Close on Escape while the panel is open. A fixed overlay with no dismissal // affordance beyond its own toggle is a trap for keyboard users. @@ -121,7 +144,22 @@ export default function NoticeCenter() { return (
+ data-testid="notice-center" + onFocusCapture={event => { + const previous = event.relatedTarget; + if (previous instanceof HTMLElement && !event.currentTarget.contains(previous)) { + previousFocusRef.current = previous; + } + containsFocusRef.current = true; + }} + onBlurCapture={event => { + if ( + event.relatedTarget instanceof Node && + !event.currentTarget.contains(event.relatedTarget) + ) { + containsFocusRef.current = false; + } + }}> {open && (
Date: Wed, 28 Oct 2026 01:00:18 +0330 Subject: [PATCH 3/8] style(notices): remove extra blank line in focus regression tests --- app/src/components/notices/__tests__/NoticeCenter.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/components/notices/__tests__/NoticeCenter.test.tsx b/app/src/components/notices/__tests__/NoticeCenter.test.tsx index c6ec05ae71b..5ac6f6b4def 100644 --- a/app/src/components/notices/__tests__/NoticeCenter.test.tsx +++ b/app/src/components/notices/__tests__/NoticeCenter.test.tsx @@ -108,7 +108,6 @@ describe('NoticeCenter', () => { expect(within(panel).getByText('API key required')).toBeInTheDocument(); }); - it('focuses the close control when opened and restores focus to the bell on close', async () => { renderCenter([keyError]); const trigger = screen.getByTestId('notice-trigger'); From 82571be95bf500db63a6d02e0460e8317efae6af Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 01:13:07 +0330 Subject: [PATCH 4/8] style(notices): use concise test names to satisfy frontend Prettier check --- app/src/components/notices/__tests__/NoticeCenter.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/components/notices/__tests__/NoticeCenter.test.tsx b/app/src/components/notices/__tests__/NoticeCenter.test.tsx index 5ac6f6b4def..ecf12ef554e 100644 --- a/app/src/components/notices/__tests__/NoticeCenter.test.tsx +++ b/app/src/components/notices/__tests__/NoticeCenter.test.tsx @@ -120,7 +120,7 @@ describe('NoticeCenter', () => { expect(trigger).toHaveFocus(); }); - it('restores the previously focused app control when final dismissal removes the bell', async () => { + it('restores app focus after dismissing the last notice', async () => { renderCenter([keyError]); const previous = screen.getByRole('button', { name: 'Resume work' }); previous.focus(); @@ -140,7 +140,7 @@ describe('NoticeCenter', () => { expect(screen.getByTestId('notice-fallback')).toHaveFocus(); }); - it('does not steal focus when notices disappear while work has focus outside the panel', async () => { + it('preserves outside focus when notices disappear', async () => { const { store } = renderCenter([keyError]); await userEvent.click(screen.getByTestId('notice-trigger')); const previous = screen.getByRole('button', { name: 'Resume work' }); From ade5861859c34bb5d29506407a854198f1b17e89 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 01:48:03 +0330 Subject: [PATCH 5/8] style(notices): satisfy Prettier import layout for focus regression tests --- app/src/components/notices/__tests__/NoticeCenter.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/components/notices/__tests__/NoticeCenter.test.tsx b/app/src/components/notices/__tests__/NoticeCenter.test.tsx index ecf12ef554e..7db6926e37b 100644 --- a/app/src/components/notices/__tests__/NoticeCenter.test.tsx +++ b/app/src/components/notices/__tests__/NoticeCenter.test.tsx @@ -5,7 +5,10 @@ import { Provider } from 'react-redux'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import userErrorsReducer, { dismissUserError, reportUserError } from '../../../store/userErrorsSlice'; +import userErrorsReducer, { + dismissUserError, + reportUserError, +} from '../../../store/userErrorsSlice'; import type { UserErrorDescriptor } from '../../../types/userError'; import NoticeCenter from '../NoticeCenter'; From da1e47e9da4668c0011123ab82b907874e8122d1 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 14:49:33 +0330 Subject: [PATCH 6/8] test(a11y): preserve outside focus after a focused notice is removed --- .../notices/__tests__/NoticeCenter.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/components/notices/__tests__/NoticeCenter.test.tsx b/app/src/components/notices/__tests__/NoticeCenter.test.tsx index 7db6926e37b..07e3ad14209 100644 --- a/app/src/components/notices/__tests__/NoticeCenter.test.tsx +++ b/app/src/components/notices/__tests__/NoticeCenter.test.tsx @@ -152,6 +152,21 @@ describe('NoticeCenter', () => { expect(previous).toHaveFocus(); }); + it('does not steal focus after a focused notice disappears but another remains', async () => { + const { store } = renderCenter([keyError, modelWarning]); + await userEvent.click(screen.getByTestId('notice-trigger')); + const dismiss = screen.getAllByTestId('notice-dismiss')[0]; + dismiss.focus(); + act(() => store.dispatch(dismissUserError({ id: keyError.id }))); + + const outside = screen.getByRole('button', { name: 'Resume work' }); + outside.focus(); + act(() => store.dispatch(dismissUserError({ id: modelWarning.id }))); + + expect(screen.queryByTestId('notice-center')).not.toBeInTheDocument(); + expect(outside).toHaveFocus(); + }); + it('raises the usage limit as a notice that cannot be dismissed at the limit', async () => { usageState.teamUsage = { plan: 'free' }; usageState.isAtLimit = true; From 6e45badfe498aadcd1b4fdcb68a438403d6770d6 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 14:50:02 +0330 Subject: [PATCH 7/8] fix(a11y): do not steal focus after focused notice unmounts --- app/src/components/notices/NoticeCenter.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/components/notices/NoticeCenter.tsx b/app/src/components/notices/NoticeCenter.tsx index b9ec23adf7f..1b0eeb2874e 100644 --- a/app/src/components/notices/NoticeCenter.tsx +++ b/app/src/components/notices/NoticeCenter.tsx @@ -108,6 +108,20 @@ export default function NoticeCenter() { if (visible && !wasOpenRef.current) { closeRef.current?.focus(); } else if (!visible && wasOpenRef.current && containsFocusRef.current) { + // A focused notice item may unmount with a null relatedTarget, leaving + // the blur tracker stale. Preserve focus already moved to another app + // control rather than incorrectly restoring the notice trigger. + const active = document.activeElement; + const focusMovedElsewhere = + active instanceof HTMLElement && + active !== document.body && + active !== document.documentElement && + !panelRef.current?.contains(active) && + !triggerRef.current?.contains(active); + if (focusMovedElsewhere) { + wasOpenRef.current = false; + return; + } const previous = previousFocusRef.current; const target = triggerRef.current ?? From abdb5f1d449d04d4ff614b46f961c3b76a922b38 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 14:50:08 +0330 Subject: [PATCH 8/8] fix(a11y): mount stable shell fallback for notice focus restoration --- app/src/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index ba49415d717..b165ff01cf1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -263,7 +263,10 @@ export function AppShellDesktop() { return ( -
+
{chromeless ? (