From f09c57d168058a8ecbecace265f03672f54a1871 Mon Sep 17 00:00:00 2001 From: Braeden Singleton Date: Tue, 21 Apr 2026 17:51:41 -0400 Subject: [PATCH 1/4] add javascript contrast watcher --- apps/dashboard/test/accessibility_helper.rb | 118 ++++++++++++++++++ .../test/application_system_test_case.rb | 6 + 2 files changed, 124 insertions(+) create mode 100644 apps/dashboard/test/accessibility_helper.rb diff --git a/apps/dashboard/test/accessibility_helper.rb b/apps/dashboard/test/accessibility_helper.rb new file mode 100644 index 0000000000..bf24000dcd --- /dev/null +++ b/apps/dashboard/test/accessibility_helper.rb @@ -0,0 +1,118 @@ +# Accessibility checks to be performed on every page visit. +class ActiveSupport::TestCase + CONTRAST_WATCH_SCRIPT = <<~HEREDOC + if (!window.__contrastViolations) { + window.__contrastViolations = []; + + function getLuminance(r, g, b) { + const [rs, gs, bs] = [r, g, b].map(c => { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; + } + + function parse(color) { + const inner = color.replace('rgb(', '').replace(')', ''); + values = inner.split(', '); + return values; + } + + function getContrastRatio(color1, color2) { + try { + const [r1,g1,b1] = parse(color1); + const [r2,g2,b2] = parse(color2); + const l1 = getLuminance(r1,g1,b1); + const l2 = getLuminance(r2,g2,b2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); + } catch (error) { + throw `color1: ${color1}, color2: ${color2}, error: ${error}`; + } + } + + function isVisible(el) { + const style = window.getComputedStyle(el); + return style.display !== 'none' + && style.visibility !== 'hidden' + && style.opacity !== '0' + && el.offsetWidth > 0; + } + + function hasText(el){ + var textFound = false; + el.childNodes.forEach((ch) => { + if (ch.nodeType === 3) { + if (ch.textContent.trim() !== '') { + textFound = true; + } + } + }); + return textFound; + } + + function checkElement(el) { + if (!isVisible(el) || !hasText(el) || el.nodeType !== Node.ELEMENT_NODE) return; + const style = window.getComputedStyle(el); + const fg = style.color; + var bg = style.backgroundColor; + if (!fg || !bg) return; + + // ascend tree to get first defined background + var current = el + while (bg === 'rgba(0, 0, 0, 0)') { + let parent = current.parentElement + current = parent + bg = window.getComputedStyle(parent).backgroundColor + } + const ratio = getContrastRatio(fg, bg); + const fontSize = parseFloat(style.fontSize); + const isBold = parseInt(style.fontWeight) >= 700; + // WCAG AA: 4.5:1 normal, 3:1 large (18pt / 14pt bold) + const isLargeText = fontSize >= 24 || (isBold && fontSize >= 18.67); + const required = isLargeText ? 3.0 : 4.5; + + if (ratio < required) { + window.__contrastViolations.push({ + tag: el.tagName, + text: el.innerText?.slice(0, 50), + fg, bg, + ratio: Math.round(ratio * 100) / 100, + required, + path: el.closest('[id]')?.id || el.className + }); + throw `Contrast check failed. Failing elements are ${JSON.stringify(window.__contrastViolations)}`; + } + } + + function checkTree(root) { + root.querySelectorAll('*').forEach(checkElement); + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach(node => checkTree(node)); + if (mutation.type === 'attributes' && isVisible(mutation.target)) { + checkElement(mutation.target); + } + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class', 'hidden', 'aria-hidden'] + }); + + // Scan whatever's already on the page + checkTree(document.body); + } + HEREDOC + + def inject_contrast_observer + page.execute_script(CONTRAST_WATCH_SCRIPT) + end +end + diff --git a/apps/dashboard/test/application_system_test_case.rb b/apps/dashboard/test/application_system_test_case.rb index 689d514de7..e323d00c87 100644 --- a/apps/dashboard/test/application_system_test_case.rb +++ b/apps/dashboard/test/application_system_test_case.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'accessibility_helper' class ApplicationSystemTestCase < ActionDispatch::SystemTestCase DOWNLOAD_DIRECTORY = Rails.root.join('tmp', 'downloads') @@ -19,6 +20,11 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase Selenium::WebDriver.logger.level = :debug unless ENV['DEBUG'].nil? + def visit(path) + super(path) + inject_contrast_observer + end + def find_option_style(ele, opt) find("##{bc_ele_id(ele)} option[value='#{opt}']")['style'].to_s end From cfb6df643eb6d1161a5323250fc286b5793c84c2 Mon Sep 17 00:00:00 2001 From: Braeden Singleton Date: Fri, 24 Apr 2026 17:30:45 -0400 Subject: [PATCH 2/4] add exceptions and handle parents --- apps/dashboard/test/accessibility_helper.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/test/accessibility_helper.rb b/apps/dashboard/test/accessibility_helper.rb index bf24000dcd..cd29785e01 100644 --- a/apps/dashboard/test/accessibility_helper.rb +++ b/apps/dashboard/test/accessibility_helper.rb @@ -54,6 +54,9 @@ class ActiveSupport::TestCase function checkElement(el) { if (!isVisible(el) || !hasText(el) || el.nodeType !== Node.ELEMENT_NODE) return; + + if (el.classList.contains('sr-only') || el.classList.contains('visually-hidden')) return; + const style = window.getComputedStyle(el); const fg = style.color; var bg = style.backgroundColor; @@ -62,9 +65,15 @@ class ActiveSupport::TestCase // ascend tree to get first defined background var current = el while (bg === 'rgba(0, 0, 0, 0)') { - let parent = current.parentElement - current = parent - bg = window.getComputedStyle(parent).backgroundColor + let parent = current.parentElement; + if (!parent) { // raise error if no background found + el.style = 'background-color: red;'; + throw `${el.tagName} element has no defined background color. (look for red highlight in screenshot)`; + } + if (parent.hasAttribute('disabled')) return; + + current = parent; + bg = window.getComputedStyle(parent).backgroundColor; } const ratio = getContrastRatio(fg, bg); const fontSize = parseFloat(style.fontSize); From 13e7270d9dbdbeea3edd04016bfffb868fda8252 Mon Sep 17 00:00:00 2001 From: Braeden Singleton Date: Fri, 24 Apr 2026 17:36:52 -0400 Subject: [PATCH 3/4] remove debugging --- apps/dashboard/test/accessibility_helper.rb | 22 +++++++++------------ 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/test/accessibility_helper.rb b/apps/dashboard/test/accessibility_helper.rb index cd29785e01..79f87b8f53 100644 --- a/apps/dashboard/test/accessibility_helper.rb +++ b/apps/dashboard/test/accessibility_helper.rb @@ -19,17 +19,13 @@ class ActiveSupport::TestCase } function getContrastRatio(color1, color2) { - try { - const [r1,g1,b1] = parse(color1); - const [r2,g2,b2] = parse(color2); - const l1 = getLuminance(r1,g1,b1); - const l2 = getLuminance(r2,g2,b2); - const lighter = Math.max(l1, l2); - const darker = Math.min(l1, l2); - return (lighter + 0.05) / (darker + 0.05); - } catch (error) { - throw `color1: ${color1}, color2: ${color2}, error: ${error}`; - } + const [r1,g1,b1] = parse(color1); + const [r2,g2,b2] = parse(color2); + const l1 = getLuminance(r1,g1,b1); + const l2 = getLuminance(r2,g2,b2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); } function isVisible(el) { @@ -83,7 +79,7 @@ class ActiveSupport::TestCase const required = isLargeText ? 3.0 : 4.5; if (ratio < required) { - window.__contrastViolations.push({ + const contrastViolation = { tag: el.tagName, text: el.innerText?.slice(0, 50), fg, bg, @@ -91,7 +87,7 @@ class ActiveSupport::TestCase required, path: el.closest('[id]')?.id || el.className }); - throw `Contrast check failed. Failing elements are ${JSON.stringify(window.__contrastViolations)}`; + throw `Contrast check failed. Failing element: ${JSON.stringify(contrastViolation)}`; } } From 4bb3396f8528499c47c060e77c352f9d541af8bd Mon Sep 17 00:00:00 2001 From: Braeden Singleton Date: Fri, 24 Apr 2026 17:51:26 -0400 Subject: [PATCH 4/4] account for raw files --- apps/dashboard/test/accessibility_helper.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/test/accessibility_helper.rb b/apps/dashboard/test/accessibility_helper.rb index 79f87b8f53..a3d6010675 100644 --- a/apps/dashboard/test/accessibility_helper.rb +++ b/apps/dashboard/test/accessibility_helper.rb @@ -62,14 +62,14 @@ class ActiveSupport::TestCase var current = el while (bg === 'rgba(0, 0, 0, 0)') { let parent = current.parentElement; - if (!parent) { // raise error if no background found - el.style = 'background-color: red;'; - throw `${el.tagName} element has no defined background color. (look for red highlight in screenshot)`; - } - if (parent.hasAttribute('disabled')) return; + if (!parent) { // assume white if no background found + bg = 'rgb(255, 255, 255)'; + } else { + if (parent.hasAttribute('disabled')) return; - current = parent; - bg = window.getComputedStyle(parent).backgroundColor; + current = parent; + bg = window.getComputedStyle(parent).backgroundColor; + } } const ratio = getContrastRatio(fg, bg); const fontSize = parseFloat(style.fontSize); @@ -86,7 +86,7 @@ class ActiveSupport::TestCase ratio: Math.round(ratio * 100) / 100, required, path: el.closest('[id]')?.id || el.className - }); + }; throw `Contrast check failed. Failing element: ${JSON.stringify(contrastViolation)}`; } }