Fix shell and settings accessibility regressions

Keyboard and screen-reader users could not reliably bypass the shell or identify two General Settings toggles, while the RC badge failed the maintained contrast contract. Restoring explicit focus transfer, accessible control names, and compliant badge contrast keeps core navigation and settings usable without changing product scope.

Change-source: pulse-maintainer
Contract-Neutral: Accessibility regression fix only; no public contract or subsystem boundary changes.
This commit is contained in:
pulse-triage[bot]
2026-09-02 03:54:21 +01:00
parent 44e274e5b3
commit 9bda0b30db
6 changed files with 60 additions and 44 deletions
+23 -41
View File
@@ -1,42 +1,23 @@
{
"version": 1,
"base_sha": "2f8a4ec629b75c7ecefd38a9abc4b1511bc9a891",
"verified_at": "2026-09-01T19:54:19Z",
"base_sha": "44e274e5b386e5e990e9bf09b85b6258043487ba",
"verified_at": "2026-09-02T02:59:02Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/App.tsx",
"frontend-modern/src/AppLayout.tsx",
"frontend-modern/src/components/shared/MobileNavBar.tsx",
"frontend-modern/src/components/shared/mobileNavBarModel.ts",
"frontend-modern/src/features/home/HomePageSurface.tsx",
"frontend-modern/src/features/home/homePageModel.ts",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/routing/navigation.ts",
"frontend-modern/src/routing/resourceLinks.ts",
"frontend-modern/src/routing/routePreload.ts",
"frontend-modern/src/utils/assistantPageContext.ts"
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx"
],
"content_sha256": {
"frontend-modern/src/App.tsx": "d5473f7838148edeaaf5564f01c166d97b6086232dcba70f6296b5c58e563a4b",
"frontend-modern/src/AppLayout.tsx": "db685903fef2a1509edd812acb3904f7ff3ffab0bbd1af2bff183c1b3ae8e373",
"frontend-modern/src/components/shared/MobileNavBar.tsx": "27358f3e77267cc111ce97b32d715b0069e2d1370ddd2b3ecc4f2d34a3e6dac5",
"frontend-modern/src/components/shared/mobileNavBarModel.ts": "ab9e69d379579d02e33a2abd1224d8c13e6543dd6aa0ad40cc1f65697a289d04",
"frontend-modern/src/features/home/HomePageSurface.tsx": "deleted",
"frontend-modern/src/features/home/homePageModel.ts": "deleted",
"frontend-modern/src/i18n/messages.de.ts": "602246d3d4ce11a1a8a914027d950a30ba1f2f25850c385836013853957c2d0c",
"frontend-modern/src/i18n/messages.es.ts": "ed5a749603efad29293cd6964215f6edc21a4ce14bca99a48429ddd120770263",
"frontend-modern/src/i18n/messages.ts": "43a757e00eaa7879c400895c9c59a930e31513dbf03e0ae6d60f7e72a6ad9962",
"frontend-modern/src/routing/navigation.ts": "8ae1ad012e60ef345ffb3d18bb66f5d8af056758109655a8ec362ebf4c7b9556",
"frontend-modern/src/routing/resourceLinks.ts": "dee9a426de785e23390ba49c9067f55c8f923cecfce1247ff18f9b1004e0cc90",
"frontend-modern/src/routing/routePreload.ts": "ee79d423db0afcf8d76d1d39da59a13cb2c98e908e516a016cbb425982b25873",
"frontend-modern/src/utils/assistantPageContext.ts": "bfca19b4e183777ee1535073d48cce92ce31980f4105bab337f33a28538a6a35"
"frontend-modern/src/AppLayout.tsx": "be386bcaa58656a9397fed71a9d70147e2540ff6cb4aff53261d9c03e6ca3834",
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "f1e1c0630cc841ae2820b15130a887aeef0ec7838cb93f652213ebb8fb278199"
},
"routes": [
"/",
"/home",
"/proxmox/overview"
"/actions",
"/alerts/overview",
"/settings/infrastructure",
"/settings/system-general",
"/patrol",
"/"
],
"viewports": [
{
@@ -44,21 +25,22 @@
"height": 720
},
{
"width": 375,
"height": 812
"width": 390,
"height": 844
}
],
"states": [
"signed-in desktop shell with primary tabs Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines and no Home tab",
"/home renders the Page Not Found surface with 'No route matched /home' and a Go to workspace button",
"signed-in narrow shell on /proxmox/overview with the bottom navigation bar showing Proxmox, Alerts, Patrol, Actions, More and no Home entry",
"More navigation sheet open at narrow width listing Settings only",
"no horizontal page overflow at 375px; no console errors at either width"
"authenticated empty Actions surface at phone width with the RC Preview badge visible, no horizontal overflow, and the skip link focused",
"authenticated General Settings surface with Full-width mode off and Outbound usage telemetry on, each exposed as a named pressed-state button",
"authenticated Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion enabled and no automatically detectable WCAG A/AA violations",
"Add infrastructure dialog open with its accessible description, close control focused, and underlying shell retained",
"logged-out welcome surface with reduced motion enabled and no automatically detectable WCAG A/AA violations"
],
"interactions": [
"navigated to / and /home at desktop width and read the rendered nav and main headings",
"pressed Go to workspace on the /home not-found page and landed on /proxmox/overview",
"resized to 375x812, loaded /proxmox/overview, opened the More navigation sheet, closed it with Escape",
"confirmed the default landing route and nav order are unchanged from the parent revision apart from the removed Home entry"
"tabbed from the document start to Skip to main content at phone width, activated it with Enter, and confirmed focus moved to the main landmark",
"inspected the rendered 390x844 Actions screenshot for badge contrast, placement, clipping, scrolling, and bottom-navigation coherence",
"navigated the authenticated desktop routes and scanned their rendered states for WCAG A/AA violations and unexpected reduced-motion effects",
"opened Add infrastructure, verified initial close-control focus and dialog description, dismissed it with Escape, and confirmed focus returned to Add infrastructure",
"opened the logged-out entry surface and verified its heading, disabled welcome/form motion, and accessibility scan"
]
}
+5 -1
View File
@@ -258,6 +258,7 @@ export function AppLayout(props: AppLayoutProps) {
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;
@@ -730,6 +731,7 @@ export function AppLayout(props: AppLayoutProps) {
jump past the chrome straight into the page content. */}
<a
href="#main"
onClick={() => mainContentEl?.focus()}
onFocus={() => setSkipLinkFocused(true)}
onBlur={() => setSkipLinkFocused(false)}
class={
@@ -814,7 +816,7 @@ export function AppLayout(props: AppLayoutProps) {
</span>
</Show>
<Show when={props.versionInfo()?.channel === 'rc'}>
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
<span class="text-xs px-1.5 py-0.5 bg-orange-700 text-white rounded font-bold">
Preview
</span>
</Show>
@@ -980,7 +982,9 @@ 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"
>
<div class="pulse-panel">
@@ -174,7 +174,16 @@ describe('AppLayout navigation icons', () => {
expect(container.querySelector('.pulse-shell')).toHaveClass('pb-safe-or-14');
expect(container.querySelector('.pulse-shell')).not.toHaveClass('pb-safe-or-16');
expect(container.querySelector('.header')).toHaveClass('mb-1', 'sm:mb-3');
expect(container.querySelector('main')).toHaveClass('mb-1', 'sm:mb-2');
const main = container.querySelector('main');
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');
const desktopNav = screen.getByRole('navigation', { name: 'Primary navigation' });
@@ -223,6 +223,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<Toggle
checked={layoutStore.isFullWidth()}
class="shrink-0"
ariaLabel={t('settings.general.fullWidth.title')}
onChange={() => layoutStore.toggle()}
/>
</div>
@@ -276,6 +277,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<Toggle
checked={props.telemetryEnabled()}
class="shrink-0"
ariaLabel={t('settings.general.telemetry.title')}
disabled={props.telemetryEnabledLocked() || props.savingTelemetry()}
onChange={() => props.handleTelemetryEnabledChange(!props.telemetryEnabled())}
/>
@@ -86,6 +86,14 @@ describe('GeneralSettingsPanel localization', () => {
).toBeInTheDocument();
expect(screen.getByText('Usage data and privacy')).toBeInTheDocument();
expect(screen.getByText('Outbound usage telemetry')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Full-width mode' })).toHaveAttribute(
'aria-pressed',
'false',
);
expect(screen.getByRole('button', { name: 'Outbound usage telemetry' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByRole('button', { name: 'Preview payload' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset ID' })).toBeInTheDocument();
expect(screen.getByText('Monitoring cadence')).toBeInTheDocument();
@@ -137,8 +137,19 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o
document.documentElement.clientWidth,
);
expect(overflow).toBeFalsy();
const skipLink = page.getByRole("link", { name: "Skip to main content" });
await page.evaluate(() => {
document.body.tabIndex = -1;
document.body.focus();
document.body.removeAttribute("tabindex");
});
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
await expect(skipLink).toBeFocused();
await expect(skipLink).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.locator("#main")).toBeFocused();
await testInfo.attach("actions-phone-width", {
body: await page.screenshot(),
contentType: "image/png",