diff --git a/CHANGELOG.md b/CHANGELOG.md index f252c82..07eb6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ 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 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 ### 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..6d7f00a 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -4,9 +4,14 @@ 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 connector line colour (teal). */ 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). 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; @@ -31,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']; } @@ -340,7 +357,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}` : ''; @@ -402,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 cf75285..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; @@ -173,10 +184,36 @@ 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 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([ + ['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; + expect(badge.style.color).toBe('rgb(255, 255, 255)'); + expect(contrastWithWhite(badge.style.background)).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 });