diff --git a/tests/test_tenants_endpoint.py b/tests/test_tenants_endpoint.py index 544e7added87..8ccba2523d07 100644 --- a/tests/test_tenants_endpoint.py +++ b/tests/test_tenants_endpoint.py @@ -352,3 +352,66 @@ def test_fe_a11y_and_recent_tenants_pins(): # the actual completeWizard call lives behind confirmSkip. assert "requestSkip" in wizard_src assert "confirmSkip" in wizard_src + + +def test_fe_forced_colors_and_axe_core_pins(): + """KR-FE-A11Y-COMPLETION-FORCED-COLORS-AND-AXE-CORE-CI — pin the + forced-colors media query block + the data-attribute hooks the + block targets + the @axe-core/react dev integration. Renaming + a data attribute on a component without updating the matching + CSS rule silently breaks the high-contrast experience. + """ + repo = Path(__file__).parent.parent + + # A — forced-colors media-query block in index.css with the + # rules that target the multi-tenant chrome. + css_src = (repo / "web" / "src" / "index.css").read_text(encoding="utf-8") + assert "@media (forced-colors: active)" in css_src, ( + "index.css must include a forced-colors media-query block — " + "Windows High Contrast users lose every author background " + "without it" + ) + # Each rule must reference its data-attribute hook so a future + # rename of the attribute on the component side fails this test. + for hook in ( + '[role="option"][data-highlighted="true"]', + "[data-tenant-chip]", + "[data-rung-bar]", + ':focus-visible', + 'a[aria-current="page"]', + ): + assert hook in css_src, ( + f"forced-colors block must include the {hook!r} rule" + ) + + # The components emit the data attributes the CSS targets. + picker_src = ( + repo / "web" / "src" / "components" / "TenantPicker.tsx" + ).read_text(encoding="utf-8") + assert 'data-highlighted={highlighted ? "true" : undefined}' in picker_src, ( + "TenantPicker option must emit data-highlighted — paired " + "with the forced-colors CSS rule" + ) + + badge_src = ( + repo / "web" / "src" / "components" / "ActiveTenantBadge.tsx" + ).read_text(encoding="utf-8") + assert "data-tenant-chip" in badge_src + + agg_src = ( + repo / "web" / "src" / "components" / "AggregateCostCards.tsx" + ).read_text(encoding="utf-8") + assert "data-rung-bar" in agg_src + + # B — @axe-core/react dev dep + main.tsx dev-only mount. + pkg_src = (repo / "web" / "package.json").read_text(encoding="utf-8") + assert "@axe-core/react" in pkg_src, ( + "@axe-core/react must be present in web/package.json devDependencies " + "for the dev-mode a11y console integration" + ) + main_src = (repo / "web" / "src" / "main.tsx").read_text(encoding="utf-8") + # Dev-only gate — the entire branch must be tree-shaken out of + # production builds. import.meta.env.DEV is the vite-recognized + # gate. + assert "import.meta.env.DEV" in main_src + assert '"@axe-core/react"' in main_src diff --git a/web/package-lock.json b/web/package-lock.json index 171ccf9e0bc0..fef35aa717ee 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,6 +32,7 @@ "unicode-animations": "^1.0.3" }, "devDependencies": { + "@axe-core/react": "^4.10.0", "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", "@types/react": "^19.2.14", @@ -47,6 +48,17 @@ "vite": "^7.3.1" } }, + "node_modules/@axe-core/react": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/react/-/react-4.11.3.tgz", + "integrity": "sha512-G7TrxptKNFfrQZ+Iygb8nGx5w8Su0jIjwmtVN/9Jc4G6RumCh1rD3pZRT2NzZKjT70q4BReuWVwEazS3HxFfjg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4", + "requestidlecallback": "^0.3.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -78,7 +90,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1128,7 +1139,6 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", - "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1781,7 +1791,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2487,7 +2496,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2497,7 +2505,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2508,7 +2515,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2573,7 +2579,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -2902,7 +2907,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2978,6 +2982,16 @@ "node": ">=4" } }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3055,7 +3069,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3563,7 +3576,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3648,6 +3660,12 @@ "node": ">=12" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3877,7 +3895,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4193,6 +4210,33 @@ "node": ">=0.10.0" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4262,8 +4306,7 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/has-flag": { "version": "4.0.0", @@ -4564,12 +4607,20 @@ "json-buffer": "3.0.1" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/leva": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -4972,6 +5023,47 @@ "node": ">=0.10.0" } }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5008,7 +5100,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5136,7 +5227,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5208,7 +5298,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5228,7 +5317,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5322,6 +5410,13 @@ } } }, + "node_modules/requestidlecallback": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/requestidlecallback/-/requestidlecallback-0.3.0.tgz", + "integrity": "sha512-TWHFkT7S9p7IxLC5A1hYmAYQx2Eb9w1skrXmQ+dS1URyvR8tenMLl4lHbqEOUnpEYxNKpkVMXUgknVpBZWXXfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5589,8 +5684,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -5655,7 +5749,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5754,7 +5847,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5770,7 +5862,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5892,7 +5983,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5938,90 +6028,6 @@ "optional": true } } - }, - "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" - }, - "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/launder": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", - "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", - "license": "MIT", - "dependencies": { - "dayjs": "^1.11.7" - } - }, - "node_modules/motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", - "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", - "license": "MIT", - "peer": true, - "dependencies": { - "framer-motion": "^12.38.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.36.0" - } - }, - "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", - "license": "MIT" } } } diff --git a/web/package.json b/web/package.json index b8608499f8c9..c70dfbbf28b8 100644 --- a/web/package.json +++ b/web/package.json @@ -34,6 +34,7 @@ "unicode-animations": "^1.0.3" }, "devDependencies": { + "@axe-core/react": "^4.10.0", "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", "@types/react": "^19.2.14", diff --git a/web/src/components/ActiveTenantBadge.tsx b/web/src/components/ActiveTenantBadge.tsx index 29b6f92c91c8..d399c01ab1b9 100644 --- a/web/src/components/ActiveTenantBadge.tsx +++ b/web/src/components/ActiveTenantBadge.tsx @@ -78,6 +78,12 @@ export function ActiveTenantBadge() { onClick={requestOpenTenantPicker} aria-label={`Active tenant: ${label}. Click to change.`} title="Click to open the tenant picker" + // KR-FE-A11Y-COMPLETION-FORCED-COLORS-AND-AXE-CORE-CI — + // data attribute hook for forced-colors. The border- + // current/20 + bg-card/60 chip styling vanishes under + // high-contrast; the CSS rule promotes data-tenant-chip + // to a CanvasText border so the affordance survives. + data-tenant-chip className={cn( "inline-flex items-center gap-1.5", "rounded border border-current/20 bg-card/60 px-2 py-0.5", @@ -102,6 +108,7 @@ export function ActiveTenantBadge() { onClick={() => void onShare()} aria-label="Copy a shareable URL with the current tenant filter" title="Copy share URL" + data-tenant-chip className={cn( "inline-flex items-center justify-center", "rounded border border-current/20 bg-card/60 p-1", diff --git a/web/src/components/AggregateCostCards.tsx b/web/src/components/AggregateCostCards.tsx index 79f931351821..b022f036aae2 100644 --- a/web/src/components/AggregateCostCards.tsx +++ b/web/src/components/AggregateCostCards.tsx @@ -282,7 +282,11 @@ function TenantCostCard({ tenantId, block }: TenantCostCardProps) { {/* Simple progress bar — pct-used over budget. Tone tracks - the rung so a downshifted tenant glows warning-yellow. */} + the rung so a downshifted tenant glows warning-yellow. + data-rung-bar opts this element into the + forced-color-adjust: none rule in index.css so the + semantic fill color (red = hard stop, etc.) is preserved + under high-contrast — the COLOR is the message here. */}
onPick(tenantId)} onMouseEnter={onHover} diff --git a/web/src/index.css b/web/src/index.css index e9818174e02e..fdad1ae3178a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -227,3 +227,88 @@ code { font-size: 0.875rem; } display: none; } +/* ------------------------------------------------------------------ */ +/* KR-FE-A11Y-COMPLETION-FORCED-COLORS-AND-AXE-CORE-CI */ +/* */ +/* Forced-colors mode (Windows High Contrast, "Increase contrast" */ +/* themes) replaces author colors with system tokens (Canvas, */ +/* CanvasText, Highlight, ButtonFace, etc.). The cockpit relies on */ +/* author backgrounds to convey hierarchy + state (badge tones, */ +/* picker option highlight, alert chips), so without explicit */ +/* overrides those visual cues disappear under forced-colors. We */ +/* surface borders + outlines using system colors instead — they */ +/* remain visible because forced-colors does NOT replace border- */ +/* color when set to CanvasText / Highlight. */ +/* */ +/* Discipline: prefer ``border: 1px solid CanvasText`` over */ +/* ``forced-color-adjust: none`` — the latter fights the user's */ +/* setting and is a last resort for elements whose colored state IS */ +/* the semantic (destructive button background). */ +/* */ +/* Drift-guard: tests/test_tenants_endpoint.py asserts this block */ +/* exists. Renaming the rules or removing them surfaces in CI. */ +/* ------------------------------------------------------------------ */ +@media (forced-colors: active) { + /* Focus-visible across the cockpit. Tailwind's focus-visible:ring + uses box-shadow, which forced-colors strips. Replace with a + system-color outline so keyboard focus stays visible on every + focusable element. This is the highest-impact single rule — + it restores the keyboard-nav cue for the entire app. */ + :focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + /* TenantPicker listbox highlight + selection. The keyboard- + highlight (bg-accent/60) and selected-active (font-semibold + + accent text color) cues both depend on author backgrounds / + text colors that forced-colors strips. Tag the highlighted + + selected options with explicit system-color outlines so the + "where would Enter land" + "currently active" cues survive. + ``data-highlighted`` is set by TenantPicker.tsx; aria-selected + is the standard pattern. */ + [role="option"][data-highlighted="true"], + [role="option"][aria-selected="true"] { + outline: 2px solid Highlight; + outline-offset: -2px; + forced-color-adjust: none; + } + + /* ActiveTenantBadge chip in audit-panel headers — translucent + bg + soft current/20 border. Border becomes invisible under + forced-colors unless it's set with a system color; force a + visible CanvasText border to keep the chip affordance. */ + [data-tenant-chip] { + border: 1px solid CanvasText; + } + + /* AggregateCostCards rung progress bar. The fill color is the + semantic — green (normal) → yellow (warn) → red (hard stop). + ``forced-color-adjust: none`` is the explicit opt-in here: + the COLOR is the message, so we want author colors preserved + even under high-contrast. Track + thumb still get a system + outline to stay readable. */ + [data-rung-bar] { + forced-color-adjust: none; + outline: 1px solid CanvasText; + } + + /* Sidebar selected nav-link gets a bg-accent in normal mode — + under forced-colors the active vs inactive distinction + vanishes. NavLink emits ``aria-current="page"`` for the + active route; use that for the system-color outline. */ + a[aria-current="page"] { + outline: 2px solid Highlight; + outline-offset: -2px; + } + + /* @nous-research/ui Badge tones already use Tailwind ``border`` + classes (border-destructive/30 etc.), so the border itself + remains visible under forced-colors thanks to system color + substitution. No additional rule needed. + ConfirmDialog backdrop is rgba(0,0,0,0.6); panel bg becomes + Canvas under forced-colors; existing border + backdrop give + enough separation. No override needed. */ +} + + diff --git a/web/src/main.tsx b/web/src/main.tsx index c727f0e3f727..53a1d0e2f4ac 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import "flag-icons/css/flag-icons.min.css"; @@ -13,6 +14,33 @@ import { HERMES_BASE_PATH } from "./lib/api"; // can access React, components, etc. immediately. exposePluginSDK(); +// KR-FE-A11Y-COMPLETION-FORCED-COLORS-AND-AXE-CORE-CI — dev-mode +// only @axe-core/react integration. Logs WCAG 2.1 AA violations +// to the browser console as the developer works; no production +// cost (the dynamic import is tree-shaken in `vite build`, which +// sets import.meta.env.PROD = true and import.meta.env.DEV = false, +// so the entire branch is dropped). +// +// CI guard via a standalone axe runner is deferred per §4 STOP-ASK +// (B.1): adding Playwright + chromium-download in GH Actions is +// enough new infra to warrant its own bucket. Dev-mode catches +// the majority of regressions during development; the static +// a11y grep pins in tests/test_tenants_endpoint.py catch the +// drift-pinned ones at CI time without any browser infra. +if (import.meta.env.DEV) { + void import("@axe-core/react").then(({ default: axe }) => { + void import("react-dom").then((ReactDOM) => { + // 1000ms debounce — axe is heavy; this collapses rapid + // re-render storms during dev (typing in forms, etc.). + void axe(React, ReactDOM, 1000); + // eslint-disable-next-line no-console + console.info( + "[a11y] @axe-core/react active — WCAG 2.1 AA violations will log to console", + ); + }); + }); +} + createRoot(document.getElementById("root")!).render(