460 lines
16 KiB
JavaScript
Executable file
460 lines
16 KiB
JavaScript
Executable file
import { promises as fs } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = path.dirname(__filename)
|
|
const uiRoot = path.resolve(__dirname, '..')
|
|
const repoRoot = path.resolve(uiRoot, '..')
|
|
|
|
const configPath = path.join(uiRoot, 'config', 'accessibility-core-workflows.json')
|
|
const tokenCssPath = path.join(uiRoot, 'src', 'styles', 'global.css')
|
|
const docsSpecPath = path.join(
|
|
repoRoot,
|
|
'docs',
|
|
'archive',
|
|
'cross-corpus',
|
|
'CROSS_CORPUS_ACCESSIBILITY_CORE_WORKFLOWS.md'
|
|
)
|
|
const evidenceDir = path.join(repoRoot, 'docs', 'evidence', 'cross-corpus')
|
|
const evidenceJsonPath = path.join(
|
|
evidenceDir,
|
|
'corpus-accessibility-core-workflows.json'
|
|
)
|
|
const evidenceMdPath = path.join(
|
|
evidenceDir,
|
|
'corpus-accessibility-core-workflows.md'
|
|
)
|
|
|
|
function relativeRepoPath(filePath) {
|
|
return path.relative(repoRoot, filePath).replaceAll('\\', '/')
|
|
}
|
|
|
|
function assertNonEmptyString(value, fieldName, scopeId) {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
throw new Error(`Invalid ${fieldName} for "${scopeId}": expected non-empty string.`)
|
|
}
|
|
}
|
|
|
|
function assertNonEmptyArray(value, fieldName, scopeId) {
|
|
if (!Array.isArray(value) || value.length === 0) {
|
|
throw new Error(`Invalid ${fieldName} for "${scopeId}": expected non-empty array.`)
|
|
}
|
|
}
|
|
|
|
function markdownList(items) {
|
|
return items.map((item) => `- ${item}`).join('\n')
|
|
}
|
|
|
|
function parseCssVariables(cssSource) {
|
|
const variables = {}
|
|
const variablePattern = /(--[A-Za-z0-9-_]+)\s*:\s*([^;]+);/g
|
|
for (const match of cssSource.matchAll(variablePattern)) {
|
|
variables[match[1]] = match[2].trim()
|
|
}
|
|
return variables
|
|
}
|
|
|
|
function parseRgbColor(value, label) {
|
|
const normalized = String(value).trim()
|
|
const match = normalized.match(
|
|
/^rgba?\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i
|
|
)
|
|
if (!match) {
|
|
throw new Error(`Unsupported color format for ${label}: ${normalized}`)
|
|
}
|
|
|
|
const r = Number(match[1])
|
|
const g = Number(match[2])
|
|
const b = Number(match[3])
|
|
const a = match[4] === undefined ? 1 : Number(match[4])
|
|
if ([r, g, b].some((channel) => channel < 0 || channel > 255) || a < 0 || a > 1) {
|
|
throw new Error(`Color channel out of range for ${label}: ${normalized}`)
|
|
}
|
|
return { r, g, b, a }
|
|
}
|
|
|
|
function compositeColors(fg, bg) {
|
|
const alpha = fg.a + bg.a * (1 - fg.a)
|
|
if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 }
|
|
return {
|
|
r: (fg.r * fg.a + bg.r * bg.a * (1 - fg.a)) / alpha,
|
|
g: (fg.g * fg.a + bg.g * bg.a * (1 - fg.a)) / alpha,
|
|
b: (fg.b * fg.a + bg.b * bg.a * (1 - fg.a)) / alpha,
|
|
a: alpha,
|
|
}
|
|
}
|
|
|
|
function srgbToLinear(channel) {
|
|
const c = channel / 255
|
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
}
|
|
|
|
function relativeLuminance(color) {
|
|
const r = srgbToLinear(color.r)
|
|
const g = srgbToLinear(color.g)
|
|
const b = srgbToLinear(color.b)
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
}
|
|
|
|
function contrastRatio(foreground, background) {
|
|
const l1 = relativeLuminance(foreground)
|
|
const l2 = relativeLuminance(background)
|
|
const lighter = Math.max(l1, l2)
|
|
const darker = Math.min(l1, l2)
|
|
return (lighter + 0.05) / (darker + 0.05)
|
|
}
|
|
|
|
function colorToString(color) {
|
|
if (Number.isFinite(color.a) && color.a < 1) {
|
|
return `rgba(${color.r}, ${color.g}, ${color.b}, ${Number(color.a.toFixed(3))})`
|
|
}
|
|
return `rgb(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)})`
|
|
}
|
|
|
|
function formatRatio(value) {
|
|
return Number(value.toFixed(2))
|
|
}
|
|
|
|
function formatSpecsMarkdown(config, validatedWorkflows, contrastResults) {
|
|
const workflowSections = validatedWorkflows
|
|
.map((workflow) => {
|
|
const checks = workflow.checks
|
|
.map(
|
|
(check) =>
|
|
`- [${check.dimension}] [${check.type}] \`${check.path}\`: ${check.focus}`
|
|
)
|
|
.join('\n')
|
|
|
|
const requiredDimensions = workflow.required_dimensions
|
|
.map((dimension) => `\`${dimension}\``)
|
|
.join(', ')
|
|
|
|
return [
|
|
`## ${workflow.title}`,
|
|
'',
|
|
`- Workflow ID: \`${workflow.id}\``,
|
|
`- Primary Surface: ${workflow.primary_surface}`,
|
|
`- Goal: ${workflow.goal}`,
|
|
`- Required Dimensions: ${requiredDimensions}`,
|
|
'',
|
|
'### Coverage Checks',
|
|
checks,
|
|
'',
|
|
].join('\n')
|
|
})
|
|
.join('\n')
|
|
|
|
const contrastRows = contrastResults
|
|
.map(
|
|
(row) =>
|
|
`| ${row.id} | \`${row.foreground_var}\` on \`${row.background_var}\`${row.backdrop_var ? ` over \`${row.backdrop_var}\`` : ''} | ${row.ratio.toFixed(2)} | ${row.min_ratio.toFixed(2)} | ${row.status.toUpperCase()} |`
|
|
)
|
|
.join('\n')
|
|
|
|
return [
|
|
'# Cross-Corpus Accessibility Core Workflows',
|
|
'',
|
|
`Updated: ${config.updated_at}`,
|
|
`Source of truth: \`${relativeRepoPath(configPath)}\``,
|
|
`Token source: \`${relativeRepoPath(tokenCssPath)}\``,
|
|
'',
|
|
'This document defines the explicit accessibility pass contract for core cross-corpus workflows. It maps workflow accessibility dimensions (focus order, labels, keyboard trap handling, focus restore) to automated tests and records token-level contrast checks used in the 8.0 polish track.',
|
|
'',
|
|
workflowSections,
|
|
'## Token Contrast Requirements',
|
|
'',
|
|
'| Check | Pair | Measured Ratio | Minimum | Status |',
|
|
'|---|---|---:|---:|---|',
|
|
contrastRows || '| - | - | - | - | - |',
|
|
'',
|
|
].join('\n')
|
|
}
|
|
|
|
function formatEvidenceMarkdown(report) {
|
|
const workflowRows = report.workflows
|
|
.map(
|
|
(workflow) =>
|
|
`| ${workflow.id} | ${workflow.title} | ${workflow.checks.length} | ${workflow.required_dimensions.length} | ${workflow.validation.status.toUpperCase()} |`
|
|
)
|
|
.join('\n')
|
|
|
|
const dimensionRows = Object.entries(report.summary.dimension_coverage)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([dimension, count]) => `- ${dimension}: ${count}`)
|
|
.join('\n')
|
|
|
|
const contrastRows = report.contrast_checks
|
|
.map(
|
|
(row) =>
|
|
`| ${row.id} | ${row.ratio.toFixed(2)} | ${row.min_ratio.toFixed(2)} | ${row.status.toUpperCase()} |`
|
|
)
|
|
.join('\n')
|
|
|
|
const missingFiles = report.summary.missing_test_files.length
|
|
? report.summary.missing_test_files.map((file) => `- ${file}`).join('\n')
|
|
: '- none'
|
|
|
|
const workflowGaps = report.summary.workflow_dimension_gaps.length
|
|
? report.summary.workflow_dimension_gaps
|
|
.map(
|
|
(gap) =>
|
|
`- ${gap.workflow_id}: ${gap.missing_dimensions.map((d) => `\`${d}\``).join(', ')}`
|
|
)
|
|
.join('\n')
|
|
: '- none'
|
|
|
|
return [
|
|
'# Corpus Accessibility Core Workflow Coverage',
|
|
'',
|
|
`Generated: ${report.generated_at}`,
|
|
`Status: **${report.summary.status.toUpperCase()}**`,
|
|
'',
|
|
'## Summary',
|
|
'',
|
|
`- Workflows: ${report.summary.total_workflows}`,
|
|
`- Workflow checks: ${report.summary.total_workflow_checks}`,
|
|
`- Workflows passing validation: ${report.summary.workflows_passing}`,
|
|
`- Contrast checks: ${report.summary.total_contrast_checks}`,
|
|
`- Contrast checks passing: ${report.summary.contrast_checks_passing}`,
|
|
`- Lowest measured contrast ratio: ${report.summary.min_contrast_ratio?.toFixed(2) ?? '-'}`,
|
|
'',
|
|
'## Dimension Coverage',
|
|
'',
|
|
dimensionRows || '- none',
|
|
'',
|
|
'## Missing Test References',
|
|
'',
|
|
missingFiles,
|
|
'',
|
|
'## Workflow Dimension Gaps',
|
|
'',
|
|
workflowGaps,
|
|
'',
|
|
'## Workflow Validation',
|
|
'',
|
|
'| Workflow ID | Title | Checks | Required Dimensions | Status |',
|
|
'|---|---|---:|---:|---|',
|
|
workflowRows,
|
|
'',
|
|
'## Contrast Validation',
|
|
'',
|
|
'| Check | Ratio | Minimum | Status |',
|
|
'|---|---:|---:|---|',
|
|
contrastRows,
|
|
'',
|
|
'## Evidence Files',
|
|
'',
|
|
`- Specs doc: \`${relativeRepoPath(docsSpecPath)}\``,
|
|
`- Coverage JSON: \`${relativeRepoPath(evidenceJsonPath)}\``,
|
|
`- Coverage Markdown: \`${relativeRepoPath(evidenceMdPath)}\``,
|
|
'',
|
|
].join('\n')
|
|
}
|
|
|
|
async function main() {
|
|
const config = JSON.parse(await fs.readFile(configPath, 'utf8'))
|
|
const cssSource = await fs.readFile(tokenCssPath, 'utf8')
|
|
const cssVariables = parseCssVariables(cssSource)
|
|
|
|
assertNonEmptyArray(config.workflows, 'workflows', 'accessibility-core-workflows')
|
|
assertNonEmptyArray(config.contrast_checks, 'contrast_checks', 'accessibility-core-workflows')
|
|
|
|
await fs.mkdir(evidenceDir, { recursive: true })
|
|
await fs.mkdir(path.dirname(docsSpecPath), { recursive: true })
|
|
|
|
const seenWorkflowIds = new Set()
|
|
const missingTestFiles = new Set()
|
|
const workflowDimensionGaps = []
|
|
const dimensionCoverage = {}
|
|
|
|
const validatedWorkflows = []
|
|
for (const workflow of config.workflows) {
|
|
assertNonEmptyString(workflow.id, 'workflow.id', 'workflow')
|
|
if (seenWorkflowIds.has(workflow.id)) {
|
|
throw new Error(`Duplicate workflow id: ${workflow.id}`)
|
|
}
|
|
seenWorkflowIds.add(workflow.id)
|
|
|
|
assertNonEmptyString(workflow.title, 'workflow.title', workflow.id)
|
|
assertNonEmptyString(workflow.primary_surface, 'workflow.primary_surface', workflow.id)
|
|
assertNonEmptyString(workflow.goal, 'workflow.goal', workflow.id)
|
|
assertNonEmptyArray(workflow.required_dimensions, 'workflow.required_dimensions', workflow.id)
|
|
assertNonEmptyArray(workflow.checks, 'workflow.checks', workflow.id)
|
|
|
|
const workflowCheckDimensions = new Set()
|
|
const validatedChecks = []
|
|
|
|
for (const [index, check] of workflow.checks.entries()) {
|
|
if (!check || typeof check !== 'object') {
|
|
throw new Error(`Workflow "${workflow.id}" check[${index}] is invalid.`)
|
|
}
|
|
assertNonEmptyString(check.dimension, `workflow.checks[${index}].dimension`, workflow.id)
|
|
assertNonEmptyString(check.type, `workflow.checks[${index}].type`, workflow.id)
|
|
assertNonEmptyString(check.path, `workflow.checks[${index}].path`, workflow.id)
|
|
assertNonEmptyString(check.focus, `workflow.checks[${index}].focus`, workflow.id)
|
|
|
|
workflowCheckDimensions.add(check.dimension)
|
|
dimensionCoverage[check.dimension] = (dimensionCoverage[check.dimension] || 0) + 1
|
|
|
|
const absolutePath = path.join(repoRoot, check.path)
|
|
let exists = false
|
|
try {
|
|
const stat = await fs.stat(absolutePath)
|
|
exists = stat.isFile()
|
|
} catch {
|
|
exists = false
|
|
}
|
|
if (!exists) missingTestFiles.add(check.path)
|
|
|
|
validatedChecks.push({
|
|
...check,
|
|
validation: { status: exists ? 'pass' : 'fail' },
|
|
})
|
|
}
|
|
|
|
const missingDimensions = workflow.required_dimensions.filter(
|
|
(dimension) => !workflowCheckDimensions.has(dimension)
|
|
)
|
|
if (missingDimensions.length > 0) {
|
|
workflowDimensionGaps.push({
|
|
workflow_id: workflow.id,
|
|
missing_dimensions: missingDimensions,
|
|
})
|
|
}
|
|
|
|
const workflowHasMissingFiles = validatedChecks.some((check) => check.validation.status !== 'pass')
|
|
const workflowStatus =
|
|
workflowHasMissingFiles || missingDimensions.length > 0 ? 'fail' : 'pass'
|
|
|
|
validatedWorkflows.push({
|
|
...workflow,
|
|
checks: validatedChecks,
|
|
validation: {
|
|
status: workflowStatus,
|
|
missing_dimensions: missingDimensions,
|
|
},
|
|
})
|
|
}
|
|
|
|
const contrastResults = []
|
|
for (const check of config.contrast_checks) {
|
|
assertNonEmptyString(check.id, 'contrast_checks[].id', 'contrast')
|
|
assertNonEmptyString(check.foreground_var, 'contrast_checks[].foreground_var', check.id)
|
|
assertNonEmptyString(check.background_var, 'contrast_checks[].background_var', check.id)
|
|
if (check.backdrop_var !== undefined) {
|
|
assertNonEmptyString(check.backdrop_var, 'contrast_checks[].backdrop_var', check.id)
|
|
}
|
|
|
|
const foregroundRaw = cssVariables[check.foreground_var]
|
|
const backgroundRaw = cssVariables[check.background_var]
|
|
const backdropRaw = check.backdrop_var ? cssVariables[check.backdrop_var] : null
|
|
|
|
if (!foregroundRaw) {
|
|
throw new Error(`Missing CSS variable ${check.foreground_var} for contrast check ${check.id}`)
|
|
}
|
|
if (!backgroundRaw) {
|
|
throw new Error(`Missing CSS variable ${check.background_var} for contrast check ${check.id}`)
|
|
}
|
|
if (check.backdrop_var && !backdropRaw) {
|
|
throw new Error(`Missing CSS variable ${check.backdrop_var} for contrast check ${check.id}`)
|
|
}
|
|
|
|
const foreground = parseRgbColor(foregroundRaw, `${check.id}:${check.foreground_var}`)
|
|
const background = parseRgbColor(backgroundRaw, `${check.id}:${check.background_var}`)
|
|
const backdrop = backdropRaw
|
|
? parseRgbColor(backdropRaw, `${check.id}:${check.backdrop_var}`)
|
|
: null
|
|
|
|
let effectiveBackground = background
|
|
if (background.a < 1) {
|
|
if (!backdrop) {
|
|
throw new Error(
|
|
`Contrast check ${check.id} uses translucent background without backdrop_var.`
|
|
)
|
|
}
|
|
effectiveBackground = compositeColors(background, backdrop)
|
|
}
|
|
|
|
const effectiveForeground =
|
|
foreground.a < 1 ? compositeColors(foreground, effectiveBackground) : foreground
|
|
|
|
const ratio = contrastRatio(effectiveForeground, effectiveBackground)
|
|
const minRatio = Number(check.min_ratio ?? 4.5)
|
|
const status = ratio >= minRatio ? 'pass' : 'fail'
|
|
|
|
contrastResults.push({
|
|
...check,
|
|
foreground_raw: foregroundRaw,
|
|
background_raw: backgroundRaw,
|
|
backdrop_raw: backdropRaw,
|
|
effective_foreground: colorToString(effectiveForeground),
|
|
effective_background: colorToString(effectiveBackground),
|
|
ratio: formatRatio(ratio),
|
|
status,
|
|
})
|
|
}
|
|
|
|
const workflowsPassing = validatedWorkflows.filter(
|
|
(workflow) => workflow.validation.status === 'pass'
|
|
).length
|
|
const contrastPassing = contrastResults.filter((row) => row.status === 'pass').length
|
|
const minContrastRatio =
|
|
contrastResults.length > 0
|
|
? Math.min(...contrastResults.map((row) => row.ratio))
|
|
: null
|
|
|
|
const status =
|
|
missingTestFiles.size === 0 &&
|
|
workflowDimensionGaps.length === 0 &&
|
|
contrastPassing === contrastResults.length
|
|
? 'pass'
|
|
: 'fail'
|
|
|
|
const report = {
|
|
schema_version: 1,
|
|
generated_at: new Date().toISOString(),
|
|
source: relativeRepoPath(configPath),
|
|
token_source: relativeRepoPath(tokenCssPath),
|
|
workflows: validatedWorkflows,
|
|
contrast_checks: contrastResults,
|
|
summary: {
|
|
status,
|
|
total_workflows: validatedWorkflows.length,
|
|
total_workflow_checks: validatedWorkflows.reduce(
|
|
(sum, workflow) => sum + workflow.checks.length,
|
|
0
|
|
),
|
|
workflows_passing: workflowsPassing,
|
|
dimension_coverage: dimensionCoverage,
|
|
missing_test_files: [...missingTestFiles].sort(),
|
|
workflow_dimension_gaps: workflowDimensionGaps,
|
|
total_contrast_checks: contrastResults.length,
|
|
contrast_checks_passing: contrastPassing,
|
|
min_contrast_ratio: minContrastRatio === null ? null : formatRatio(minContrastRatio),
|
|
},
|
|
}
|
|
|
|
const specsMarkdown = formatSpecsMarkdown(config, validatedWorkflows, contrastResults)
|
|
const evidenceMarkdown = formatEvidenceMarkdown(report)
|
|
|
|
await fs.writeFile(docsSpecPath, `${specsMarkdown}\n`, 'utf8')
|
|
await fs.writeFile(evidenceJsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
await fs.writeFile(evidenceMdPath, `${evidenceMarkdown}\n`, 'utf8')
|
|
|
|
console.log(
|
|
`[a11y-core-workflows] ${status.toUpperCase()} (${report.summary.total_workflows} workflows, ${report.summary.total_contrast_checks} contrast checks)`
|
|
)
|
|
console.log(`[a11y-core-workflows] Specs: ${relativeRepoPath(docsSpecPath)}`)
|
|
console.log(`[a11y-core-workflows] Coverage JSON: ${relativeRepoPath(evidenceJsonPath)}`)
|
|
console.log(`[a11y-core-workflows] Coverage MD: ${relativeRepoPath(evidenceMdPath)}`)
|
|
|
|
if (status !== 'pass') {
|
|
process.exitCode = 1
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[a11y-core-workflows] ${error.message}`)
|
|
process.exitCode = 1
|
|
})
|