Keep navigation badges readable and the skip link first in tab order

The primary and mobile navigation count badges used text-amber-900, which
tailwind.config.js defines at 25% alpha as a dark-surface background token.
Once the navigation became links (08581a2bc9, 5cfa0f26b6) and lost
aria-disabled, the axe scan in the product-trust spec stopped skipping the
Patrol badge and measured it at 1.49:1. The badges now use opaque amber-800
(slate-900 on amber-400 in dark mode) and the update banner's Pre-release
label moves from orange-700 (4.38:1) to orange-800.

The skip-to-content link lived inside AppLayout, but the global banners render
before AppLayout, so whenever the update banner showed the first Tab landed on
its Dismiss button. The link now renders from the app shell ahead of the
banner block, with AppLayout keeping the #main target.

Verified on the pulse-dev Playwright rig against a pulse:test image built
from this change: axe WCAG A/AA scans of /alerts/overview, /patrol,
/settings/infrastructure and /settings/system-general at 1280x720 and of
/actions at 390x844 report no colour-contrast violation on the navigation or
banner, and Tab from the page start focuses the skip link.

Contract-Neutral: colour and DOM-order accessibility fix with no public contract change
This commit is contained in:
rcourtman
2026-09-02 10:53:18 +01:00
parent b5f694b216
commit 908afdf0c5
8 changed files with 114 additions and 49 deletions
+28 -21
View File
@@ -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"
]
}
+5
View File
@@ -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() {
>
<WebSocketContext.Provider value={runtime.enhancedStore()!}>
<DarkModeContext.Provider value={runtime.darkMode}>
{/* 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. */}
<SkipToContentLink />
{/* Global banners deep-link into settings (security
hardening, telemetry preferences, license management),
so they are for sessions that can actually reach those
+4 -21
View File
@@ -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<typeof setTimeout> | undefined;
@@ -726,22 +724,8 @@ export function AppLayout(props: AppLayoutProps) {
<div
class={`pulse-shell ${layoutStore.isFullWidth() || kioskMode() ? 'pulse-shell--full-width' : ''} ${!kioskMode() ? 'pb-safe-or-14 xl:pb-0' : ''}`}
>
{/* 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. */}
<a
href="#main"
onClick={() => 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
</a>
{/* The skip-to-content link renders in App ahead of the global
banners; #main below is its target. */}
<Show when={kioskMode()}>
<div
class="fixed top-0 left-0 right-0 z-40 h-4 bg-transparent"
@@ -944,7 +928,7 @@ export function AppLayout(props: AppLayoutProps) {
</span>
)}
{tab.breakdown && tab.breakdown.warning > 0 && (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-800 dark:text-slate-900 bg-amber-200 dark:bg-amber-400 rounded-full">
{tab.breakdown.warning}
</span>
)}
@@ -952,7 +936,7 @@ export function AppLayout(props: AppLayoutProps) {
);
}
return (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-800 dark:text-slate-900 bg-amber-200 dark:bg-amber-400 rounded-full">
{total}
</span>
);
@@ -982,7 +966,6 @@ export function AppLayout(props: AppLayoutProps) {
</Show>
<main
ref={mainContentEl}
id="main"
tabindex="-1"
class="tab-content mb-1 block rounded-b rounded-tl rounded-tr bg-surface shadow sm:mb-2"
@@ -178,10 +178,6 @@ describe('AppLayout navigation icons', () => {
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');
@@ -170,7 +170,7 @@ export function UpdateBanner() {
{/* Pre-release badge */}
<Show when={updateStore.updateInfo()?.isPrerelease && !isExpanded()}>
<span class="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200 rounded">
<span class="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded">
Pre-release
</span>
</Show>
@@ -55,7 +55,7 @@ function MobileNavDestinationContent(props: {
</span>
</Show>
<Show when={badges().warning > 0}>
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900">
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-800">
{badges().warning}
</span>
</Show>
@@ -66,7 +66,7 @@ function MobileNavDestinationContent(props: {
{(count) => (
<span
aria-hidden="true"
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900"
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-800"
>
{count()}
</span>
@@ -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 (
<a
href={`#${targetId()}`}
onClick={() => 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
</a>
);
};
export default SkipToContentLink;
@@ -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(() => (
<>
<SkipToContentLink />
<main id="main" tabindex="-1">
Content
</main>
</>
));
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('<SkipToContentLink />');
const bannerBlockIndex = appSource.indexOf('<SecurityWarning />');
const layoutIndex = appSource.indexOf('<AppLayout');
expect(skipLinkIndex).toBeGreaterThan(-1);
expect(bannerBlockIndex).toBeGreaterThan(skipLinkIndex);
expect(layoutIndex).toBeGreaterThan(bannerBlockIndex);
});
});