diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4d29339e3..f61341654 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,42 +1,49 @@ { "version": 1, - "base_sha": "facee87bb4e7b84a0ce682d6e142d35b55be3ace", - "verified_at": "2026-09-02T08:55:07Z", + "base_sha": "b5f694b216e7a333c8ce26746b4e218e59c65efc", + "verified_at": "2026-09-02T09:53:16Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/patrol.ts", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", - "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx" + "frontend-modern/src/App.tsx", + "frontend-modern/src/AppLayout.tsx", + "frontend-modern/src/components/UpdateBanner.tsx", + "frontend-modern/src/components/shared/MobileNavBar.tsx", + "frontend-modern/src/components/shared/SkipToContentLink.tsx" ], "content_sha256": { - "frontend-modern/src/api/patrol.ts": "c411c12d504b53e64b1435c5b336ba953f5051f6909f2bfef3e46067d5b88f34", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "0d46fd5afbbdf8e1b885fa558be070c07203cc6613dda4458e9790fa8f2e4099", - "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx": "9a9728f618c502a5379f56e93cad29ea939f5c26e73e3c7d22195308a8087b81" + "frontend-modern/src/App.tsx": "a46918db428d7965d72e5e8a296ea813970472dfe8fd04f4dcae18439fa80375", + "frontend-modern/src/AppLayout.tsx": "827b69928bf4b6b2da8b445d0b95bd2a89cc2721c494d624f13d422af2e7ff99", + "frontend-modern/src/components/UpdateBanner.tsx": "14f6125638c81b66bf69a7b40231f2187851edfc5f08093e82c4ba3b4f73005e", + "frontend-modern/src/components/shared/MobileNavBar.tsx": "f0aaa04aab89c0de1794d80448cb478e6cc5cac274519fe37a793b530cd6a00f", + "frontend-modern/src/components/shared/SkipToContentLink.tsx": "3d89ab42df5b342639af1b87012b173f5965b0a1041785a8b53f32675956b1bc" }, "routes": [ - "/patrol" + "/alerts/overview", + "/actions", + "/patrol", + "/settings/infrastructure", + "/settings/system-general" ], "viewports": [ { "width": 1280, - "height": 800 + "height": 720 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "Activity tab with the This week card above Verified outcomes, populated from an isolated mock-mode backend (11 runs, 3 new issues, watch-only mode)", - "card tiles in single column at 375px with no horizontal overflow", - "card refresh in flight and settled", - "Verified outcomes empty state and Review and history below the card", - "watch-only tile copy for Investigated and Fixes run" + "Desktop primary navigation with the Patrol utility link carrying an amber attention-count badge on /alerts/overview", + "Phone-width mobile navigation rail with the Patrol destination carrying an amber count badge on /actions", + "Update banner showing the Pre-release badge beside the update message", + "Skip to main content link revealed at the top-left after the first Tab from the page start, ahead of the update banner controls, at desktop and phone widths" ], "interactions": [ - "clicked the Activity workspace tab", - "scrolled the card into view at desktop and narrow widths", - "clicked Refresh this week's summary and confirmed the tiles reloaded without an error state", - "checked console for card-originated errors (only unrelated dev websocket/update-check noise)" + "ran the axe-core WCAG 2.x A/AA scan on /alerts/overview, /patrol, /settings/infrastructure and /settings/system-general at 1280x720 with the desktop attention badge rendered and confirmed no color-contrast violation remains on the navigation badges", + "ran the axe-core WCAG 2.x A/AA scan on /actions at 390x844 with the mobile rail count badge rendered and confirmed no color-contrast violation remains on the badge or the Pre-release label", + "read the computed badge colours in the page and confirmed the count text now resolves to an opaque amber-800 instead of the theme's 25 percent alpha amber-900", + "reset focus to the document body on /actions at 390x844 with the update banner present, pressed Tab and confirmed the skip link received focus and became visible, then pressed Enter and confirmed focus moved to the main landmark" ] } diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 1050cf660..4b34f0857 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -7,6 +7,7 @@ import { SecurityWarning } from './components/SecurityWarning'; import { Login } from './components/Login'; import { logger } from './utils/logger'; import { UpdateBanner } from './components/UpdateBanner'; +import { SkipToContentLink } from './components/shared/SkipToContentLink'; import { WhatsNewCard } from './components/WhatsNewCard'; import { DemoBanner } from './components/DemoBanner'; import { CommercialMigrationBanner } from './components/CommercialMigrationBanner'; @@ -532,6 +533,10 @@ function App() { > + {/* First focusable element in the document: the skip link + has to precede the banners below, or Tab from the page + start lands on a banner control instead. */} + {/* Global banners deep-link into settings (security hardening, telemetry preferences, license management), so they are for sessions that can actually reach those diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 3d4ea0def..c00ce6a70 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -255,10 +255,8 @@ export function AppLayout(props: AppLayoutProps) { const browserBrandName = createMemo(() => customBrandName() || 'Pulse'); const [headerVisible, setHeaderVisible] = createSignal(true); - const [skipLinkFocused, setSkipLinkFocused] = createSignal(false); const [primaryRouteMemoryVersion, setPrimaryRouteMemoryVersion] = createSignal(0); let headerEl: HTMLDivElement | undefined; - let mainContentEl: HTMLElement | undefined; let assistantLauncherEl: HTMLButtonElement | undefined; let restoreAssistantLauncherFocus = false; let headerHideTimeout: ReturnType | undefined; @@ -726,22 +724,8 @@ export function AppLayout(props: AppLayoutProps) {
- {/* Skip-to-content link: visually hidden until focused, then - appears as a button at the top-left. Lets keyboard users - jump past the chrome straight into the page content. */} - mainContentEl?.focus()} - onFocus={() => setSkipLinkFocused(true)} - onBlur={() => setSkipLinkFocused(false)} - class={ - skipLinkFocused() - ? 'absolute left-2 top-2 z-[100] rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-lg outline outline-2 outline-offset-2 outline-white' - : 'sr-only' - } - > - Skip to main content - + {/* The skip-to-content link renders in App ahead of the global + banners; #main below is its target. */}
)} {tab.breakdown && tab.breakdown.warning > 0 && ( - + {tab.breakdown.warning} )} @@ -952,7 +936,7 @@ export function AppLayout(props: AppLayoutProps) { ); } return ( - + {total} ); @@ -982,7 +966,6 @@ export function AppLayout(props: AppLayoutProps) {
{ expect(main).toHaveClass('mb-1', 'sm:mb-2'); expect(main).toHaveAttribute('id', 'main'); expect(main).toHaveAttribute('tabindex', '-1'); - const skipLink = screen.getByRole('link', { name: 'Skip to main content' }); - expect(skipLink).toHaveAttribute('href', '#main'); - fireEvent.click(skipLink); - expect(main).toHaveFocus(); expect(screen.getByText('Preview')).toHaveClass('bg-orange-700', 'text-white'); expect(screen.getByText('Preview')).not.toHaveClass('bg-orange-500'); expect(container.querySelector('footer')).toHaveClass('pulse-footer', 'px-2', 'sm:px-4'); diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx index 923483e26..6d359db59 100644 --- a/frontend-modern/src/components/UpdateBanner.tsx +++ b/frontend-modern/src/components/UpdateBanner.tsx @@ -170,7 +170,7 @@ export function UpdateBanner() { {/* Pre-release badge */} - + Pre-release diff --git a/frontend-modern/src/components/shared/MobileNavBar.tsx b/frontend-modern/src/components/shared/MobileNavBar.tsx index 923afc8a2..1c139e5bd 100644 --- a/frontend-modern/src/components/shared/MobileNavBar.tsx +++ b/frontend-modern/src/components/shared/MobileNavBar.tsx @@ -55,7 +55,7 @@ function MobileNavDestinationContent(props: { 0}> - + {badges().warning} @@ -66,7 +66,7 @@ function MobileNavDestinationContent(props: { {(count) => ( diff --git a/frontend-modern/src/components/shared/SkipToContentLink.tsx b/frontend-modern/src/components/shared/SkipToContentLink.tsx new file mode 100644 index 000000000..c26099240 --- /dev/null +++ b/frontend-modern/src/components/shared/SkipToContentLink.tsx @@ -0,0 +1,33 @@ +import { Component, createSignal } from 'solid-js'; + +/** + * Skip-to-content link for keyboard and screen-reader users. + * + * It has to be the first focusable element in the document, ahead of every + * global banner (update, security, demo), so a single Tab from the page start + * reaches it. The shell renders it before those banners; AppLayout owns the + * `#main` target it jumps to. Visually hidden until focused, then shown as a + * button at the top-left. + */ +export const SkipToContentLink: Component<{ targetId?: string }> = (props) => { + const targetId = () => props.targetId ?? 'main'; + const [focused, setFocused] = createSignal(false); + + return ( + document.getElementById(targetId())?.focus()} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + class={ + focused() + ? 'absolute left-2 top-2 z-[100] rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-lg outline outline-2 outline-offset-2 outline-white' + : 'sr-only' + } + > + Skip to main content + + ); +}; + +export default SkipToContentLink; diff --git a/frontend-modern/src/components/shared/__tests__/SkipToContentLink.test.tsx b/frontend-modern/src/components/shared/__tests__/SkipToContentLink.test.tsx new file mode 100644 index 000000000..c695cb596 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/SkipToContentLink.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library'; +import { describe, expect, it } from 'vitest'; +import appSource from '@/App.tsx?raw'; +import { SkipToContentLink } from '@/components/shared/SkipToContentLink'; + +describe('SkipToContentLink', () => { + it('moves focus to the main landmark and reveals itself only while focused', () => { + render(() => ( + <> + +
+ Content +
+ + )); + + const skipLink = screen.getByRole('link', { name: 'Skip to main content' }); + expect(skipLink).toHaveAttribute('href', '#main'); + expect(skipLink).toHaveClass('sr-only'); + + fireEvent.focus(skipLink); + expect(skipLink).not.toHaveClass('sr-only'); + fireEvent.blur(skipLink); + expect(skipLink).toHaveClass('sr-only'); + + fireEvent.click(skipLink); + expect(screen.getByRole('main')).toHaveFocus(); + }); + + it('is rendered by the app shell ahead of the global banners', () => { + // Tab order follows DOM order. The update, security and demo banners + // render before AppLayout, so the link has to sit above that banner block + // or the first Tab lands on a banner control instead. + const skipLinkIndex = appSource.indexOf(''); + const bannerBlockIndex = appSource.indexOf(''); + const layoutIndex = appSource.indexOf('