From e01dafdf0286ed56ec7c62d8db1b2bdc8ca2b22b Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Sat, 3 Oct 2026 19:07:49 +0100 Subject: [PATCH 1/2] fix(overlay): tab-order badges pass contrast (0.15.7) White 10px digits sat on #0b8f8f at 3.93:1; axe flagged them on a dark-mode page (admin example). The warning badge (#e8710a) was 3.09:1. Fills are now #0a7a7a (5.15:1) and #b45309 (5.02:1); the connector lines keep the brighter teal. New specs assert >= 4.5:1 for both and fail on the old colours. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/overlay.ts | 12 +++++++++--- src/testing/overlay.spec.ts | 21 ++++++++++++++++++++- 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f252c82..e9e5592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to `@ngbracket/a11y-devtools` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## 0.15.7 + +### Fixed + +- The keyboard tab-order badges now pass a colour contrast check. Their white + digits sat on a teal fill at 3.93:1, below the 4.5:1 that text this size + needs, and axe flagged them on a dark-mode page. The fill is now `#0a7a7a` + (5.15:1). The orange badge for a positive `tabindex` had the same problem + (3.09:1) and is now `#b45309` (5.02:1). The lines joining the badges keep + their brighter teal, so they still show on dark pages. + ## 0.15.6 ### Fixed diff --git a/package-lock.json b/package-lock.json index 23ec5d8..94b9af8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.15.6", + "version": "0.15.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngbracket/a11y-devtools", - "version": "0.15.6", + "version": "0.15.7", "license": "MIT", "dependencies": { "axe-core": "^4.13.0" diff --git a/package.json b/package.json index 1d9549d..e4d462e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.15.6", + "version": "0.15.7", "description": "Finds accessibility problems in Angular apps and names the component behind each one. A dev overlay, or headless reports for CI.", "license": "MIT", "author": "Duncan Faulkner", diff --git a/src/overlay.ts b/src/overlay.ts index 67bb8f9..7118f3b 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -4,9 +4,15 @@ import type { AxDescription } from './keyboard/accname.js'; import { keepInTopLayer } from './top-layer.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; -/** Tab-order path + badge colours: normal teal, warning orange for positive tabindex. */ +/** Tab-order path colour (teal); bright enough to show on dark pages. */ const TAB_ORDER_COLOR = '#0b8f8f'; -const TAB_ORDER_WARN_COLOR = '#e8710a'; +/** + * Badge fills behind white 10px digits: at least 4.5:1 with white (teal 5.15, + * orange 5.02), so the overlay passes its own contrast check in any theme. The + * orange marks a positive-tabindex stop. + */ +const TAB_BADGE_FILL = '#0a7a7a'; +const TAB_BADGE_WARN_FILL = '#b45309'; /** How far outside a control's left edge the tab-order badge sits, and its min viewport x. */ const TAB_BADGE_GUTTER = 10; const TAB_BADGE_MIN_X = 9; @@ -340,7 +346,7 @@ function buildAxPanelContent(doc: Document, data: AxPanelData): HTMLElement[] { * positive tabindex hijacks the natural order. */ function buildBadge(doc: Document, stop: TabStop): HTMLElement { - const color = stop.positive ? TAB_ORDER_WARN_COLOR : TAB_ORDER_COLOR; + const color = stop.positive ? TAB_BADGE_WARN_FILL : TAB_BADGE_FILL; const badge = doc.createElement('div'); badge.setAttribute('data-ngb-tab-order', String(stop.order)); const owner = stop.component ? ` ยท ${stop.component}` : ''; diff --git a/src/testing/overlay.spec.ts b/src/testing/overlay.spec.ts index cf75285..44a02f5 100644 --- a/src/testing/overlay.spec.ts +++ b/src/testing/overlay.spec.ts @@ -173,10 +173,29 @@ describe('createOverlay', () => { const a = targetEl('a'); overlay.renderTabOrder([stop(a, { order: 1, positive: true, tabindex: 3 })]); const badge = overlayRoot()!.querySelector('[data-ngb-tab-order]') as HTMLElement; - expect(badge.style.background).toContain('rgb(232, 113, 10)'); // #e8710a warn + expect(badge.style.background).toContain('rgb(180, 83, 9)'); // #b45309 warn expect(badge.title).toContain('hijacks order'); }); + // White 10px digits need 4.5:1 on the fill, in light and dark pages alike + // (axe flagged 3.93:1 on the old #0b8f8f teal). + it.each([ + ['a normal stop', {}], + ['a positive-tabindex stop', { positive: true, tabindex: 3 }], + ])('gives %s badge at least 4.5:1 contrast with its white digits', (_, extra) => { + const a = targetEl('a'); + overlay.renderTabOrder([stop(a, { order: 1, ...extra })]); + const badge = overlayRoot()!.querySelector('[data-ngb-tab-order]') as HTMLElement; + const rgb = badge.style.background.match(/\d+/g)!.slice(0, 3).map(Number); + const lum = (c: number[]) => + c + .map((v) => v / 255) + .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) + .reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); + expect(badge.style.color).toBe('rgb(255, 255, 255)'); + expect(1.05 / (lum(rgb) + 0.05)).toBeGreaterThanOrEqual(4.5); + }); + it('threads a connector polyline through the badge anchor points', () => { const a = targetEl('a', { top: 100, left: 50, width: 20, height: 20 }); const b = targetEl('b', { top: 200, left: 60, width: 20, height: 20 }); From 99fe538a604878b7eb7652e4f170e7721114ee2e Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Sat, 3 Oct 2026 19:11:25 +0100 Subject: [PATCH 2/2] fix(overlay): finding labels pass contrast too (review) White 11px label text sat on the impact colour: serious 3.09, moderate 2.42, minor 4.11, none 3.45. Labels get their own fills in the same hue (#b45309, #8a6d00, #2563c4, #666666; critical keeps #d32029), all >= 4.9:1; box outlines keep the impact colours. Specs cover every impact (4 fail on the old fills). Connector comment no longer claims a ratio. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++++- src/overlay.ts | 19 +++++++++++++++---- src/testing/overlay.spec.ts | 32 +++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e5592..07eb6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,12 @@ This project adheres to [Semantic Versioning](https://semver.org/). needs, and axe flagged them on a dark-mode page. The fill is now `#0a7a7a` (5.15:1). The orange badge for a positive `tabindex` had the same problem (3.09:1) and is now `#b45309` (5.02:1). The lines joining the badges keep - their brighter teal, so they still show on dark pages. + their teal. +- The finding labels now pass a colour contrast check too. Their white text + sat on the impact colour, which gave 2.42:1 to 4.11:1 for every impact except + critical. Labels now use a darker fill in the same hue: serious `#b45309`, + moderate `#8a6d00`, minor `#2563c4` and no impact `#666666`, all at least + 4.9:1. The box outlines keep the original impact colours. ## 0.15.6 diff --git a/src/overlay.ts b/src/overlay.ts index 7118f3b..6d7f00a 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -4,12 +4,11 @@ import type { AxDescription } from './keyboard/accname.js'; import { keepInTopLayer } from './top-layer.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; -/** Tab-order path colour (teal); bright enough to show on dark pages. */ +/** Tab-order connector line colour (teal). */ const TAB_ORDER_COLOR = '#0b8f8f'; /** * Badge fills behind white 10px digits: at least 4.5:1 with white (teal 5.15, - * orange 5.02), so the overlay passes its own contrast check in any theme. The - * orange marks a positive-tabindex stop. + * orange 5.02). The orange marks a positive-tabindex stop. */ const TAB_BADGE_FILL = '#0a7a7a'; const TAB_BADGE_WARN_FILL = '#b45309'; @@ -37,6 +36,18 @@ const IMPACT_COLOR: Record | 'none', string> = { none: '#8a8a8a', }; +/** + * Label fill per impact, behind white 11px text: at least 4.5:1 with white, in + * the same hue as the border, so the labels pass a contrast check themselves. + */ +const IMPACT_LABEL_FILL: Record | 'none', string> = { + critical: '#d32029', + serious: '#b45309', + moderate: '#8a6d00', + minor: '#2563c4', + none: '#666666', +}; + function colorFor(impact: Impact): string { return IMPACT_COLOR[impact ?? 'none']; } @@ -408,7 +419,7 @@ function buildBox(doc: Document, finding: A11yFinding): HTMLElement { padding: '1px 4px', font: '11px/1.4 ui-monospace, monospace', color: '#fff', - background: color, + background: IMPACT_LABEL_FILL[finding.impact ?? 'none'], whiteSpace: 'nowrap', }); box.appendChild(label); diff --git a/src/testing/overlay.spec.ts b/src/testing/overlay.spec.ts index 44a02f5..a619959 100644 --- a/src/testing/overlay.spec.ts +++ b/src/testing/overlay.spec.ts @@ -40,6 +40,17 @@ function overlayRoot(): HTMLElement | null { return document.querySelector(`[${OVERLAY_ATTR}]`); } +/** WCAG contrast of an `rgb(r, g, b)` fill against white text. */ +function contrastWithWhite(fill: string): number { + const lum = fill + .match(/\d+/g)! + .slice(0, 3) + .map((v) => Number(v) / 255) + .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) + .reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); + return 1.05 / (lum + 0.05); +} + describe('createOverlay', () => { let overlay: A11yOverlay; @@ -177,6 +188,19 @@ describe('createOverlay', () => { expect(badge.title).toContain('hijacks order'); }); + // White 11px label text needs 4.5:1 on its fill for every impact (axe + // flagged the overlay; serious, moderate, minor and none were 2.42 to 4.11). + it.each(['critical', 'serious', 'moderate', 'minor', null] as const)( + 'gives a %s finding label at least 4.5:1 contrast with its white text', + (impact) => { + targetEl('target'); + overlay.render([finding({ impact })]); + const label = overlayRoot()!.querySelector('[data-impact] span') as HTMLElement; + expect(label.style.color).toBe('rgb(255, 255, 255)'); + expect(contrastWithWhite(label.style.background)).toBeGreaterThanOrEqual(4.5); + }, + ); + // White 10px digits need 4.5:1 on the fill, in light and dark pages alike // (axe flagged 3.93:1 on the old #0b8f8f teal). it.each([ @@ -186,14 +210,8 @@ describe('createOverlay', () => { const a = targetEl('a'); overlay.renderTabOrder([stop(a, { order: 1, ...extra })]); const badge = overlayRoot()!.querySelector('[data-ngb-tab-order]') as HTMLElement; - const rgb = badge.style.background.match(/\d+/g)!.slice(0, 3).map(Number); - const lum = (c: number[]) => - c - .map((v) => v / 255) - .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) - .reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); expect(badge.style.color).toBe('rgb(255, 255, 255)'); - expect(1.05 / (lum(rgb) + 0.05)).toBeGreaterThanOrEqual(4.5); + expect(contrastWithWhite(badge.style.background)).toBeGreaterThanOrEqual(4.5); }); it('threads a connector polyline through the badge anchor points', () => {