openpetswithchatandmcp/website/scripts/check-accessibility-core-workflows.mjs
OpenPets Dev 65ac83849e Add 'website/' from commit '966aea480ffe1c340553aa878def30131d2af827'
git-subtree-dir: website
git-subtree-mainline: 8d3849caea
git-subtree-split: 966aea480f
2026-06-17 20:48:39 +00:00

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
})