wick-a11y
Version:
A Cypress plugin for configurable accessibility analysis supporting WCAG 2.2 (A-AAA). It provides a detailed list of violations in the Cypress log, and generates an HTML report with per-violation details, fix guidance, and screenshots of the issues. The
1,170 lines (1,021 loc) • 56.5 kB
JavaScript
//*******************************************************************************
// FOR MULTIPLE ATTEMPTS CASES
//*******************************************************************************
let reportIdGlobal = ''
//*******************************************************************************
// PUBLIC FUNCTIONS AND CONSTANTS
//*******************************************************************************
/**
* Defines the scope considered in the accessibility analysis.
* @type {string}
*/
const contextHelp = 'Context defines the scope considered in the accessibility analysis, specifying which elements have been tested and which have not been tested.'
/**
* Tags define the severity of violations that have been considered in the accessibility analysis.
* @type {string}
*/
const runOnlyHelp = 'Tags define the severity of violations that have been considered in the accessibility analysis.'
/**
* Defines what specific accessibility rules should be enabled or disabled for the analysis.
* @type {string}
*/
const rulesHelp = 'Rules define what specific accessibility rules should be enable or disabled for the analysis, like "color-contrast" or "valid-lang".'
/**
* @typedef {Object} ImpactSeverityDescription
* @property {string} critical - <Description of critical violations>
* @property {string} serious - <Description of serious violations>
* @property {string} moderate - <Description of moderate violations>
* @property {string} minor - <Description of minor violations>
*/
const impactSeverityDescription = {
critical: `A 'critical' accessibility violation represents a significant barrier that prevents users with disabilities
from accessing core functionality or content.<br>For example, images must have alternate text (alt text) to
ensure that visually impaired users can understand the content of the images through screen readers.
Missing alt text on critical images can be a substantial obstacle to accessibility.`,
serious: `A 'serious' accessibility violation significantly degrades the user experience for individuals with disabilities
but does not completely block access.<br>For instance, elements must meet minimum color contrast ratio thresholds.
If text and background colors do not have sufficient contrast, users with visual impairments or color blindness may
find it difficult to read the content.`,
moderate: `A 'moderate' accessibility violation impacts the user experience but with less severe consequences.
These issues can cause some confusion or inconvenience.<br>For example, all page content should be contained by landmarks.
Properly defining landmarks (like header, main, nav) helps screen reader users to navigate and understand the structure
of the page better.`,
minor: `A 'minor' accessibility violation has a minimal impact on accessibility. These issues are typically more related to best
practices and can slightly inconvenience users.<br>For instance, the ARIA role should be appropriate for the element means
that ARIA roles assigned to elements should match their purpose to avoid confusion for screen reader users, though it does
not significantly hinder access if not perfectly used.`
}
/**
* @public
* Records the accessibility violations and generates an HTML report with the violations detected.
* The report includes a screenshot of the page with the elements that have violations highlighted in different colors based on severity.
*
* @param {Object} reportInfo - The information to be used to create the report.
* @param {Object} reportInfo.testResults - The test results object.
* @param {Array} reportInfo.violations - The array of accessibility violations.
* @param {Object} reportInfo.accessibilityContext - The context parameter of the accessibility analysis.
* @param {Object} reportInfo.accessibilityOptions - The options for the accessibility analysis.
* @param {Array} reportInfo.impactPriority - List of accessibility impact priorities supported
* @param {Object} reportInfo.impactStyling - The impact styling object.
*/
export const recordReportViolations = (reportInfo) => {
cy.url({ log: false }).then(url => {
const day = new Date()
const reportGeneratedOn = day.toString()
const fileDate = day.toLocaleString({ timezone: "short" }).replace(/\//g, '-').replace(/:/g, '_').replace(/,/g, '')
const testSpec = Cypress.spec.name
const testName = Cypress._.last(Cypress.currentTest.titlePath)
// Accessibility folder
const accessibilityFolder = Cypress.config('accessibilityFolder') || defaultAccessibilityFolder
// const reportId = normalizeFileName(`Accessibility Report --- ${testSpec} --- ${testName} (${fileDate})`)
// Report Id folder
const attempt = Cypress.currentRetry
let reportId
if (attempt === 0) {
reportIdGlobal = normalizeFileName(`Accessibility Report --- ${testSpec} --- ${testName} (${fileDate})`)
reportId = reportIdGlobal
} else {
reportId = `${reportIdGlobal} (attempt ${attempt + 1})`
}
const reportFolder = `${accessibilityFolder}/${reportId}`
// Generate the HTML report with the violations detected, including screenshot of the page with the elements vith violations highlighted in different colors based on severity
cy.task('createFolderIfNotExist', `${reportFolder}`).then(() => {
// Generate screenshot of the page
const issuesScreenshotFilePath = takeScreenshotsViolations(reportId, reportFolder)
// Build the content of the HTML report
const fileBody = buildHtmlReportBody(reportInfo, { testSpec, testName, url, reportGeneratedOn, issuesScreenshotFilePath })
// Generate the HTML report
const file = { folderPath: reportFolder, fileName: 'Accessibility Report.html', fileBody }
cy.task('generateViolationsReport', file).then((result) => {
console.log(result)
cy.log(result)
})
})
})
}
//*******************************************************************************
// PRIVATE FUNCTIONS AND CONSTANTS
//*******************************************************************************
/**
* The default folder path for storing accessibility reports.
* @type {string}
*/
const defaultAccessibilityFolder = 'cypress/accessibility'
/**
* Takes screenshot of accessibility violations and moves them to the accessibility results folder.
*
* @param {string} reportId - The ID of the accessibility report.
* @param {string} reportFolder - The folder where the screenshots will be moved to.
* @returns {string} - The filename of the moved screenshot.
*/
const takeScreenshotsViolations = (reportId, reportFolder) => {
// reportId - E.g. 'Accessibility Report --- accessibility-tests-samples.js --- Test Sample Page Accessibility (6-23-2024 3_13_03 PM)'
// reportFolder - E.g. 'cypress/accessibility/Accessibility Report --- accessibility-tests-samples.js --- Test Sample Page Accessibility (6-23-2024 3_13_03 PM)'
const attempt = Cypress.currentRetry
const attemptSuffix = attempt > 0 ? ` (attempt ${attempt + 1})` : ''
const issuesFileNameOrigin = `${reportId} Accessibility Issues Image`
const issuesFileNameTarget = `Accessibility Issues Image`
const targetFileName = `${issuesFileNameTarget}${attemptSuffix}.png`
const targetFilePath = `${reportFolder}/${targetFileName}`
let originFilePath = ''
setViolationsHover('disabled')
cy.screenshot(`${issuesFileNameOrigin}`, { capture: 'fullPage' })
setViolationsHover('enabled')
// To get the *exact path* of the screenshot file, we temporarily override Cypress's global screenshot default configuration.
// `onAfterScreenshot` is a callback function that Cypress ran immediately after the screenshot
// It provides a `details` object containing metadata, including the actual screenshot `path` which we need to access.
const restoreScreenshotDefaults = Cypress.Screenshot.defaults({
onAfterScreenshot: (_el, details) => { originFilePath = details.path }
})
cy.then(() => {
// It's critical to restore the original Cypress screenshot defaults. If we don't, our
// custom `onAfterScreenshot` function would affect every other screenshot path in the entire test suite.
if (typeof restoreScreenshotDefaults === 'function') restoreScreenshotDefaults()
// In case of path error, throw error messages without interrupting users tests
if (originFilePath === '') {
Cypress.log({
name: 'wick-a11y', // Package name for the command
displayName: 'Wick-A11y Screenshot', // What the user sees
message: '⚠️ Warning: Could not save screenshot to report folder.',
consoleProps: () => ({ // Debugging info in the console
'Plugin': 'wick-a11y',
'Warning': 'Could not capture the screenshot path from onAfterScreenshot.',
'Effect': 'The test will pass, but the screenshot file was not moved to the report folder. It may exist in the default Cypress screenshots directory.',
}),
});
console.warn('Wick-A11y Screenshot Warning: Could not capture screenshot path from onAfterScreenshot.');
return; // Exit and allow users test to continue.
}
return cy.task('moveScreenshotToFolder', { originFilePath, targetFilePath }).then(console.log)
})
return targetFileName
}
/**
* Sets the hover class for the severity rectangles.
*
* @param {string} className - The class name to be set: 'enabled', 'disabled'.
*/
const setViolationsHover = (className) => {
cy.then(() => {
Cypress.$('.severity rect').attr('class', className)
})
}
/**
* Builds the HTML report body for accessibility violations.
*
* @param {Object} reportInfo - The information to be used to create the report.
* @param {Object} reportInfo.testResults - The test results object.
* @param {Array} reportInfo.violations - The array of accessibility violations.
* @param {Object} reportInfo.accessibilityContext - The context parameter of the accessibility analysis.
* @param {Object} reportInfo.accessibilityOptions - The options for the accessibility analysis.
* @param {Object} reportInfo.impactStyling - The impact styling object.
* @param {Array} reportInfo.impactPriority - List of accessibility impact priorities supported
* @param {Object} options - The options for building the report body.
* @param {string} options.testSpec - The test spec (test file).
* @param {string} options.testName - The name of the test with the accessibility analysis is performed
* @param {string} options.url - The URL of the page being tested.
* @param {string} options.reportGeneratedOn - The date and time when the report was generated.
* @param {string} options.issuesScreenshotFilePath - The file path of the screenshot showing the accessibility violations.
* @returns {string} The HTML report body.
*/
const buildHtmlReportBody = (reportInfo, options) => {
const { testResults, violations, accessibilityContext, accessibilityOptions, impactStyling, impactPriority } = reportInfo
const { testSpec, testName, url, reportGeneratedOn, issuesScreenshotFilePath } = options
const fileBody = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessibility Report (Axe-core®)</title>
<style>
/* CSS Custom Properties for consistent theming */
:root {
--color-white: #ffffff;
--color-primary-blue: #f0f8ff;
--color-secondary-blue: #e6f3ff;
--color-primary-yellow: #fffbe0;
--color-border-light: #e1e8ed;
--color-border-medium: #c1c9d0;
--color-text-primary: #1a1a1a;
--color-text-secondary: #4a4a4a;
--color-text-link: #0056b3;
--color-text-link-hover: #003d82;
--color-focus: #005fcc;
--color-shadow: rgba(0, 0, 0, 0.1);
--color-shadow-hover: rgba(0, 0, 0, 0.25);
--font-family-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--font-size-base: 16px;
--font-size-small: 14px;
--font-size-large: 18px;
--font-size-xlarge: 24px;
--font-size-xxlarge: 32px;
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--border-radius: 8px;
--border-radius-lg: 12px;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
}
/* Reset and base styles */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
line-height: 1.6;
color: var(--color-text-primary);
background-color: var(--color-white);
scroll-behavior: smooth;
}
/* Skip link for keyboard navigation */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: var(--color-focus);
color: var(--color-white);
padding: var(--spacing-sm) var(--spacing-md);
text-decoration: none;
border-radius: var(--border-radius);
z-index: 1000;
font-weight: 600;
}
.skip-link:focus {
top: 6px;
}
/* Main container */
.container {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-lg);
background-color: var(--color-white);
}
@media (max-width: 768px) {
.container {
padding: var(--spacing-md);
}
}
/* Header styles */
.header {
text-align: center;
color: var(--color-text-primary);
font-size: var(--font-size-xxlarge);
font-weight: 700;
margin: 0 0 var(--spacing-xl) 0;
letter-spacing: -0.5px;
}
/* Horizontal summary grid layout */
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
@media (max-width: 1024px) {
.summary-grid {
grid-template-columns: 1fr;
gap: var(--spacing-xs);
}
}
/* Collapsible summary sections */
.summary-section {
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background: var(--color-white);
box-shadow: 0 2px 8px var(--color-shadow);
transition: box-shadow var(--transition-normal);
}
.summary-section:hover {
box-shadow: 0 4px 12px var(--color-shadow-hover);
}
.summary-section[open] {
box-shadow: 0 4px 12px var(--color-shadow-hover);
}
.summary-section__toggle {
width: 100%;
padding: var(--spacing-sm);
background: linear-gradient(135deg, var(--color-primary-blue) 0%, var(--color-secondary-blue) 100%);
border: none;
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-size-large);
font-weight: 600;
color: var(--color-text-primary);
transition: background-color var(--transition-fast);
list-style: none;
}
.summary-section__toggle::-webkit-details-marker {
display: none;
}
.summary-section__toggle:hover,
.summary-section__toggle:focus {
background: linear-gradient(135deg, var(--color-secondary-blue) 0%, var(--color-primary-blue) 100%);
outline: 2px solid var(--color-focus);
outline-offset: -2px;
}
.summary-section__icon {
transition: transform var(--transition-fast);
font-size: 0.8em;
}
.summary-section[open] .summary-section__icon {
transform: rotate(90deg);
}
.summary-section__content {
padding: var(--spacing-sm);
}
/* Legacy card layout - keep for backward compatibility */
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
}
.card {
background: var(--color-white);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
padding: var(--spacing-sm);
box-shadow: 0 2px 8px var(--color-shadow);
transition: box-shadow var(--transition-normal), transform var(--transition-fast);
}
.card:hover {
box-shadow: 0 6px 20px var(--color-shadow-hover);
transform: translateY(-2px);
}
.card--blue {
background: linear-gradient(135deg, var(--color-primary-blue) 0%, var(--color-secondary-blue) 100%);
}
.card--yellow {
background: linear-gradient(135deg, var(--color-primary-yellow) 0%, #fef8e0 100%);
}
.card--full-width {
grid-column: 1 / -1;
}
/* Card headers */
.card__header {
font-size: var(--font-size-large);
font-weight: 600;
margin: 0 0 var(--spacing-md) 0;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border-light);
padding-bottom: var(--spacing-sm);
}
/* Summary items */
.summary-item {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-base);
line-height: 1.5;
}
.summary-item:last-child {
margin-bottom: 0;
}
.summary-item__label {
font-weight: 600;
margin-right: var(--spacing-xs);
color: var(--color-text-primary);
min-width: 80px;
}
.summary-item__value {
color: var(--color-text-secondary);
word-break: break-word;
}
/* Impact severity indicators */
.impact-indicator {
display: flex;
align-items: center;
margin-bottom: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius);
background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--color-border-light);
transition: background-color var(--transition-fast);
}
.impact-indicator:hover {
background: rgba(255, 255, 255, 0.9);
}
.impact-indicator__icon {
margin-right: var(--spacing-sm);
font-size: var(--font-size-large);
}
.impact-indicator__label {
font-weight: 600;
margin-right: var(--spacing-xs);
text-transform: uppercase;
font-size: var(--font-size-small);
letter-spacing: 0.5px;
}
.impact-indicator__count {
color: var(--color-text-secondary);
font-weight: 500;
}
/* Links */
a {
color: var(--color-text-link);
text-decoration: underline;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-text-link-hover);
}
a:focus {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
border-radius: 2px;
}
/* Tooltip improvements with smart positioning */
.tooltip {
position: relative;
display: inline;
cursor: help;
border-bottom: 1px dotted var(--color-text-link);
color: var(--color-text-link);
transition: color var(--transition-fast);
}
.tooltip:hover,
.tooltip:focus {
color: var(--color-text-link-hover);
border-bottom-color: var(--color-text-link-hover);
}
.tooltip:focus {
outline: 2px solid var(--color-focus);
outline-offset: 1px;
border-radius: 2px;
}
.tooltip__content {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 500px;
background: var(--color-text-primary);
color: var(--color-white);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius);
font-size: var(--font-size-small);
line-height: 1.4;
text-align: left;
z-index: 1000;
transition: opacity var(--transition-normal), visibility var(--transition-normal);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Analysis Configuration tooltips - narrower and positioned to the left */
.card--yellow .tooltip__content {
max-width: 300px;
left: 0;
transform: translateX(0);
}
.card--yellow .tooltip__content::after {
left: 20px;
transform: translateX(0);
}
/* Smart positioning for tooltips near viewport edges */
.tooltip--left .tooltip__content {
left: 0;
transform: translateX(0);
}
.tooltip--right .tooltip__content {
left: auto;
right: 0;
transform: translateX(0);
}
.tooltip--bottom .tooltip__content {
bottom: auto;
top: 125%;
}
.tooltip__content::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--color-text-primary);
}
.tooltip--left .tooltip__content::after {
left: 20px;
transform: translateX(0);
}
.tooltip--right .tooltip__content::after {
left: auto;
right: 20px;
transform: translateX(0);
}
.tooltip--bottom .tooltip__content::after {
top: -6px;
border-top-color: transparent;
border-bottom-color: var(--color-text-primary);
}
.tooltip:hover .tooltip__content,
.tooltip:focus .tooltip__content {
visibility: visible;
opacity: 1;
}
/* Violations section */
.violations-section {
margin-top: var(--spacing-lg);
}
/* Severity-based violation sections */
.severity-section {
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background: var(--color-white);
margin-bottom: var(--spacing-sm);
box-shadow: 0 2px 8px var(--color-shadow);
transition: box-shadow var(--transition-normal);
}
.severity-section:hover {
box-shadow: 0 4px 12px var(--color-shadow-hover);
}
.severity-section[open] {
box-shadow: 0 4px 12px var(--color-shadow-hover);
}
.severity-section__toggle {
width: 100%;
padding: var(--spacing-sm);
border: none;
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-size-large);
font-weight: 600;
color: var(--color-text-primary);
transition: background-color var(--transition-fast);
list-style: none;
}
.severity-section__toggle::-webkit-details-marker {
display: none;
}
.severity-section__toggle:hover,
.severity-section__toggle:focus {
outline: 2px solid var(--color-focus);
outline-offset: -2px;
}
.severity-section--critical .severity-section__toggle {
background: linear-gradient(135deg, #fee, #fdd);
}
.severity-section--serious .severity-section__toggle {
background: linear-gradient(135deg, #fff4e6, #ffe6cc);
}
.severity-section--moderate .severity-section__toggle {
background: linear-gradient(135deg, #fffef0, #fefcf0);
}
.severity-section--minor .severity-section__toggle {
background: linear-gradient(135deg, var(--color-primary-blue), var(--color-secondary-blue));
}
.severity-section__icon {
transition: transform var(--transition-fast);
font-size: 0.8em;
margin-left: var(--spacing-xs);
}
.severity-section[open] .severity-section__icon {
transform: rotate(90deg);
}
.severity-section__content {
padding: var(--spacing-sm);
}
.violations-title {
font-size: var(--font-size-xlarge);
font-weight: 700;
margin: 0 0 var(--spacing-lg) 0;
color: var(--color-text-primary);
text-align: center;
}
.violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.violation-card {
background: var(--color-white);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
margin-bottom: var(--spacing-lg);
box-shadow: 0 2px 8px var(--color-shadow);
overflow: hidden;
transition: box-shadow var(--transition-normal), transform var(--transition-fast);
}
.violation-card:hover {
box-shadow: 0 6px 24px var(--color-shadow-hover);
transform: translateY(-2px);
}
.violation-card__header {
padding: var(--spacing-sm);
background: linear-gradient(135deg, var(--color-primary-blue) 0%, var(--color-secondary-blue) 100%);
border-bottom: 1px solid var(--color-border-light);
}
.violation-card__title {
font-size: var(--font-size-large);
font-weight: 600;
margin: 0 0 var(--spacing-sm) 0;
color: var(--color-text-primary);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.violation-card__impact {
background: rgba(255, 255, 255, 0.9);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius);
font-size: var(--font-size-small);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.violation-card__rule-id {
font-style: italic;
color: var(--color-text-secondary);
font-weight: 400;
}
.violation-card__meta {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
align-items: center;
margin-top: var(--spacing-sm);
}
.violation-card__link {
display: inline-flex;
align-items: center;
padding: var(--spacing-xs) var(--spacing-md);
background: var(--color-white);
border: 1px solid var(--color-text-link);
border-radius: var(--border-radius);
text-decoration: none;
font-size: var(--font-size-small);
font-weight: 500;
transition: all var(--transition-fast);
}
.violation-card__link:hover {
background: var(--color-text-link);
color: var(--color-white);
transform: translateY(-1px);
}
.violation-card__tags {
font-size: var(--font-size-small);
color: var(--color-text-secondary);
font-weight: 500;
}
.violation-card__content {
padding: var(--spacing-sm);
}
.affected-elements {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-sm);
}
@media (min-width: 1200px) {
.affected-elements {
grid-template-columns: 1fr 1fr 1fr;
}
}
@media (max-width: 768px) {
.affected-elements {
grid-template-columns: 1fr;
}
}
.affected-element {
background: var(--color-primary-yellow);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius);
padding: var(--spacing-sm);
transition: background-color var(--transition-fast);
}
.affected-element:hover {
background: #fef6d0;
}
.affected-element__header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
font-weight: 600;
font-size: var(--font-size-small);
color: var(--color-text-primary);
}
.affected-element__selector {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: rgba(255, 255, 255, 0.8);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius);
border: 1px solid var(--color-border-light);
font-size: var(--font-size-small);
word-break: break-all;
margin-bottom: var(--spacing-sm);
}
/* Expandable controls for failure summaries using HTML5 details/summary */
.expandable {
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius);
background: rgba(255, 255, 255, 0.9);
margin-top: var(--spacing-sm);
}
.expandable__toggle {
width: 100%;
padding: var(--spacing-sm);
background: none;
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-small);
font-weight: 500;
color: var(--color-text-primary);
transition: background-color var(--transition-fast);
list-style: none;
}
.expandable__toggle::-webkit-details-marker {
display: none;
}
.expandable__toggle::marker {
display: none;
}
.expandable__toggle:hover {
background: rgba(0, 0, 0, 0.05);
}
.expandable__toggle:focus {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
background: rgba(0, 86, 179, 0.1);
}
.expandable__icon {
font-size: 12px;
transition: transform var(--transition-fast);
color: var(--color-text-link);
display: inline-block;
}
.expandable[open] .expandable__icon {
transform: rotate(90deg);
}
.expandable__content {
padding: 0 var(--spacing-sm) var(--spacing-sm) var(--spacing-sm);
border-top: 1px solid var(--color-border-light);
background: rgba(255, 255, 255, 0.5);
font-size: var(--font-size-small);
line-height: 1.5;
color: var(--color-text-secondary);
}
/* Screenshot section */
.screenshot-section {
margin-top: var(--spacing-xl);
text-align: center;
}
.screenshot-title {
font-size: var(--font-size-xlarge);
font-weight: 700;
margin: 0 0 var(--spacing-lg) 0;
color: var(--color-text-primary);
}
.screenshot-container {
display: inline-block;
max-width: 100%;
border-radius: var(--border-radius-lg);
overflow: hidden;
box-shadow: 0 8px 32px var(--color-shadow);
transition: transform var(--transition-normal);
}
.screenshot-container:hover {
transform: scale(1.02);
}
.screenshot-image {
width: 100%;
height: auto;
display: block;
border: 2px solid var(--color-border-medium);
}
/* Footer */
.footer {
margin-top: var(--spacing-xl);
padding: var(--spacing-xs) var(--spacing-lg);
background: linear-gradient(135deg, var(--color-primary-blue) 0%, var(--color-secondary-blue) 100%);
border-radius: var(--border-radius-lg);
text-align: center;
color: var(--color-text-secondary);
font-size: var(--font-size-small);
line-height: 1.2;
}
/* Focus management for better accessibility */
.focusable {
transition: outline var(--transition-fast);
}
.focusable:focus {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* Print styles */
@media print {
.container {
max-width: none;
margin: 0;
padding: 20px;
}
.card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #ccc;
}
.violation-card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #ccc;
}
}
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="container">
<header role="banner">
<h1 class="header">Accessibility Report (Axe-core®)</h1>
</header>
<main id="main-content" role="main">
<!-- Horizontal Summary Sections -->
<section aria-labelledby="summary-heading" class="summary-grid">
<details class="summary-section" open>
<summary class="summary-section__toggle" id="summary-heading">
<span>📊 Test Summary</span>
<span class="summary-section__icon">▶</span>
</summary>
<div class="summary-section__content">
<div class="summary-item">
<span class="summary-item__label">Spec:</span>
<span class="summary-item__value">${escapeHTML(testSpec)}</span>
</div>
<div class="summary-item">
<span class="summary-item__label">Test:</span>
<span class="summary-item__value">${escapeHTML(testName)}</span>
</div>
<div class="summary-item">
<span class="summary-item__label">Page URL:</span>
<span class="summary-item__value">
<a href="${url}" target="_blank" rel="noopener noreferrer" aria-label="Open tested page in new tab">${escapeHTML(url)}</a>
</span>
</div>
<div class="summary-item">
<span class="summary-item__label">Generated:</span>
<span class="summary-item__value">${reportGeneratedOn}</span>
</div>
</div>
</details>
<details class="summary-section" open>
<summary class="summary-section__toggle">
<span>⚠️ Violations Summary</span>
<span class="summary-section__icon">▶</span>
</summary>
<div class="summary-section__content">
${impactPriority.map((impact) => {
const totalIssues = testResults.testSummary[impact] !== undefined ? testResults.testSummary[impact] : 'n/a'
return `
<div class="impact-indicator">
<span class="impact-indicator__icon" aria-hidden="true">${impactStyling[impact].icon}</span>
<span class="impact-indicator__label">
<span tabindex="0" class="tooltip focusable" aria-describedby="tooltip-${impact}">
${impact}
<span id="tooltip-${impact}" class="tooltip__content" role="tooltip">
${impactSeverityDescription[impact]}
</span>
</span>:
</span>
<span class="impact-indicator__count">${totalIssues}</span>
</div>`
}).join('')}
</div>
</details>
<details class="summary-section" open>
<summary class="summary-section__toggle">
<span>⚙️ Analysis Configuration</span>
<span class="summary-section__icon">▶</span>
</summary>
<div class="summary-section__content">
<div class="summary-item">
<span class="summary-item__label">
<span tabindex="0" class="tooltip focusable" aria-describedby="tooltip-context">
Context
<span id="tooltip-context" class="tooltip__content" role="tooltip">
${contextHelp}
</span>
</span>:
</span>
<span class="summary-item__value">${getHumanReadableFormat(accessibilityContext)}</span>
</div>
<div class="summary-item">
<span class="summary-item__label">
<span tabindex="0" class="tooltip focusable" aria-describedby="tooltip-tags">
Tags
<span id="tooltip-tags" class="tooltip__content" role="tooltip">
${runOnlyHelp}
</span>
</span>:
</span>
<span class="summary-item__value">${accessibilityOptions.runOnly.join(', ')}</span>
</div>
${accessibilityOptions.rules ? `
<div class="summary-item">
<span class="summary-item__label">
<span tabindex="0" class="tooltip focusable" aria-describedby="tooltip-rules">
Rules
<span id="tooltip-rules" class="tooltip__content" role="tooltip">
${rulesHelp}
</span>
</span>:
</span>
<span class="summary-item__value">${getHumanReadableFormat(accessibilityOptions.rules)}</span>
</div>` : ''
}
</div>
</details>
</section>
<!-- Violations Details by Severity -->
<section aria-labelledby="violations-heading" class="violations-section">
<h2 id="violations-heading" class="violations-title">Accessibility Violations Details</h2>
${impactPriority.map((impact) => {
const violationsForImpact = violations.filter(violation => violation.impact === impact);
const violationCount = violationsForImpact.length;
if (violationCount === 0) {
return `
<details class="severity-section severity-section--${impact}" open>
<summary class="severity-section__toggle">
<span>
<span aria-hidden="true">${impactStyling[impact].icon}</span>
${impact.toUpperCase()} VIOLATIONS (${violationCount})
</span>
<span class="severity-section__icon">▶</span>
</summary>
<div class="severity-section__content">
<p style="text-align: center; color: var(--color-text-secondary); font-style: italic; padding: var(--spacing-md);">
No ${impact} violations found.
</p>
</div>
</details>
`;
}
return `
<details class="severity-section severity-section--${impact}" open>
<summary class="severity-section__toggle">
<span>
<span aria-hidden="true">${impactStyling[impact].icon}</span>
${impact.toUpperCase()} VIOLATIONS (${violationCount})
</span>
<span class="severity-section__icon">▶</span>
</summary>
<div class="severity-section__content">
<ul class="violations-list" style="list-style: none; padding: 0; margin: 0;">
${violationsForImpact.map((violation, violationIndex) => `
<li class="violation-card">
<div class="violation-card__header">
<h3 class="violation-card__title" id="violation-${impact}-${violationIndex}">
<span class="violation-card__impact">
<span aria-hidden="true">${impactStyling[violation.impact].icon}</span>
${violation.impact}
</span>
<span>${escapeHTML(violation.help)}</span>
<span class="violation-card__rule-id">(Rule: ${escapeHTML(violation.id)})</span>
</h3>
<div class="violation-card__meta">
<a href="${violation.helpUrl}" target="_blank" rel="noopener noreferrer" class="violation-card__link" aria-label="Learn more about ${escapeHTML(violation.help)} rule">
Learn More
</a>
<span class="violation-card__tags">
<strong>Tags:</strong> ${violation.tags.join(", ")}
</span>
</div>
</div>
<div class="violation-card__content">
<h4>Affected Elements (${violation.nodes.length})</h4>
<ul class="affected-elements">
${violation.nodes.map((node, nodeIndex) => `
<li class="affected-element">
<div class="affected-element__header">
<span aria-hidden="true">${impactStyling.fixme.icon}</span>
<span>Element ${nodeIndex + 1}</span>
</div>