diff --git a/enterprise-dashboard-accessibility-guard/.gitignore b/enterprise-dashboard-accessibility-guard/.gitignore new file mode 100644 index 00000000..75b3a515 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/.gitignore @@ -0,0 +1,3 @@ +frames/ +__pycache__/ +*.tmp diff --git a/enterprise-dashboard-accessibility-guard/README.md b/enterprise-dashboard-accessibility-guard/README.md new file mode 100644 index 00000000..f9072ed0 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/README.md @@ -0,0 +1,38 @@ +# Enterprise Dashboard Accessibility Guard + +Self-contained Enterprise Tooling slice for issue #19. + +This module evaluates institutional admin dashboard releases before they are shown to admins, included in scheduled exports, or summarized through webhook notices. It uses synthetic dashboard records only and does not call external accessibility scanners, SSO providers, webhook endpoints, or private institutional systems. + +## What It Checks + +- Critical metric color contrast and warning-level contrast checks for noncritical content +- Missing, invalid, or unparseable contrast evidence, including unresolved CSS color tokens and noncritical missing contrast evidence +- Missing screen-reader labels +- Keyboard reachability and focus traps +- Missing visible focus indicators for keyboard-reachable dashboard controls +- Malformed dashboard component evidence that would otherwise crash release assessment +- Malformed top-level dashboard packets that would otherwise crash before reviewer evidence is generated +- Malformed reduced-motion evidence that would otherwise crash animated chart assessment +- Private user, project, or direct identifier data embedded in screen-reader labels, table summaries, or export summaries +- Missing table and export summaries +- Heading-order skips +- Missing reduced-motion fallbacks for animated dashboard content + +## Commands + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +`npm run demo` writes JSON, Markdown, and SVG reviewer artifacts under `reports/`. `npm run demo:video` renders a short local MP4 walkthrough. + +## Safety + +- Synthetic sample data only +- No private dashboard data, SSO records, webhook calls, or network access +- No credentials, tokens, payment details, or institutional secrets +- Release decisions are guard outputs, not production enforcement actions diff --git a/enterprise-dashboard-accessibility-guard/acceptance-notes.md b/enterprise-dashboard-accessibility-guard/acceptance-notes.md new file mode 100644 index 00000000..dc044e1e --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/acceptance-notes.md @@ -0,0 +1,37 @@ +# Acceptance Notes + +- Adds `enterprise-dashboard-accessibility-guard/` as an independent module. +- Keeps all records synthetic and local. +- Uses dependency-free Node.js logic for deterministic dashboard release decisions. +- Covers blocked, clean, and warning-only dashboard states with tests. +- Treats noncritical low-contrast content as a remediation warning before public release. +- Blocks release when critical dashboard contrast evidence is missing, invalid, or still expressed as unresolved CSS color tokens. +- Treats missing noncritical dashboard contrast evidence as a remediation warning before public release. +- Blocks release when keyboard-reachable dashboard controls suppress visible focus indicators. +- Blocks malformed dashboard component evidence before it can crash release assessment. +- Blocks malformed top-level dashboard packets before they can crash release assessment. +- Blocks malformed reduced-motion evidence before it can crash animated chart assessment. +- Blocks release when private data or direct identifiers appear in screen-reader labels, table summaries, or export accessibility summaries. +- Generates reviewer artifacts: + - `reports/blocked-packet.json` + - `reports/missing-contrast-packet.json` + - `reports/missing-noncritical-contrast-packet.json` + - `reports/malformed-component-packet.json` + - `reports/malformed-dashboard-packet.json` + - `reports/malformed-motion-packet.json` + - `reports/clean-packet.json` + - `reports/warning-packet.json` + - `reports/accessibility-report.md` + - `reports/summary.svg` + - `reports/demo.mp4` + +## Local Validation + +Run: + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` diff --git a/enterprise-dashboard-accessibility-guard/demo.js b/enterprise-dashboard-accessibility-guard/demo.js new file mode 100644 index 00000000..ff9e9372 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/demo.js @@ -0,0 +1,96 @@ +const fs = require('fs'); +const path = require('path'); + +const { assessDashboardRelease } = require('./index'); +const { + blockedDashboard, + cleanDashboard, + warningDashboard, + missingContrastDashboard, + missingNoncriticalContrastDashboard, + malformedComponentDashboard, + malformedDashboardPacket, + malformedMotionDashboard +} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const packets = [ + ['blocked-packet.json', assessDashboardRelease(blockedDashboard)], + ['missing-contrast-packet.json', assessDashboardRelease(missingContrastDashboard)], + ['missing-noncritical-contrast-packet.json', assessDashboardRelease(missingNoncriticalContrastDashboard)], + ['malformed-component-packet.json', assessDashboardRelease(malformedComponentDashboard)], + ['malformed-dashboard-packet.json', assessDashboardRelease(malformedDashboardPacket)], + ['malformed-motion-packet.json', assessDashboardRelease(malformedMotionDashboard)], + ['clean-packet.json', assessDashboardRelease(cleanDashboard)], + ['warning-packet.json', assessDashboardRelease(warningDashboard)] +]; + +for (const [fileName, packet] of packets) { + fs.writeFileSync(path.join(reportsDir, fileName), `${JSON.stringify(packet, null, 2)}\n`); +} + +fs.writeFileSync(path.join(reportsDir, 'accessibility-report.md'), renderMarkdown(packets)); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), renderSvg(packets)); + +for (const [fileName, packet] of packets) { + console.log(`${fileName}: ${packet.status}; findings=${packet.findings.length}; digest=${packet.auditDigest.slice(0, 12)}`); +} + +function renderMarkdown(packetRows) { + const lines = [ + '# Enterprise Dashboard Accessibility Report', + '', + '| Packet | Status | Dashboard | Export | Webhook | Findings |', + '| --- | --- | --- | --- | --- | --- |' + ]; + + for (const [fileName, packet] of packetRows) { + lines.push([ + fileName, + packet.status, + packet.releaseLanes.adminDashboard, + packet.releaseLanes.scheduledExport, + packet.releaseLanes.webhookNotice, + packet.findings.map((finding) => finding.code).join(', ') || 'none' + ].join(' | ').replace(/^/, '| ').replace(/$/, ' |')); + } + + lines.push(''); + lines.push('All packets use synthetic dashboard records and deterministic SHA-256 audit digests.'); + return `${lines.join('\n')}\n`; +} + +function renderSvg(packetRows) { + const height = 108 + packetRows.length * 72 + 36; + const rows = packetRows.map(([, packet], index) => { + const y = 105 + index * 72; + const color = packet.status === 'hold_accessibility_release' ? '#dc2626' : packet.status === 'remediate_before_public_release' ? '#d97706' : '#16a34a'; + return ` + + + + ${escapeXml(packet.dashboardId)} + ${escapeXml(packet.status)} | findings ${packet.findings.length} | digest ${packet.auditDigest.slice(0, 16)} + `; + }).join(''); + + return [ + ``, + ` `, + ' Enterprise Dashboard Accessibility Guard', + ' Institutional dashboards, exports, and webhook notices are gated before release.', + rows, + '', + '' + ].join('\n'); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/enterprise-dashboard-accessibility-guard/index.js b/enterprise-dashboard-accessibility-guard/index.js new file mode 100644 index 00000000..24cd35a5 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/index.js @@ -0,0 +1,395 @@ +const crypto = require('crypto'); + +function assessDashboardRelease(dashboard) { + const normalized = normalizeDashboardPacket(dashboard); + const findings = [ + ...normalized.findings, + ...assessVisualAndOperableComponents(normalized.dashboard), + ...assessMotion(normalized.dashboard) + ]; + const safeComponentId = createComponentIdentifierSanitizer(); + const safeFindings = findings.map((finding) => ({ + ...finding, + componentId: safeComponentId(finding.componentId) + })); + const safeDashboard = { + ...normalized.dashboard, + dashboardId: safeDashboardId(normalized.dashboard.dashboardId) + }; + const blockerCount = findings.filter((finding) => finding.severity === 'blocker').length; + const warningCount = findings.filter((finding) => finding.severity === 'warning').length; + + const packet = { + dashboardId: safeDashboard.dashboardId, + institutionId: normalized.dashboard.institutionId, + status: chooseStatus(blockerCount, warningCount), + releaseLanes: chooseReleaseLanes(blockerCount, warningCount), + findings: safeFindings, + actions: buildActions(safeDashboard, safeFindings), + wcagSignals: buildWcagSignals(safeFindings), + assessedAt: normalized.dashboard.assessedAt + }; + + packet.auditDigest = digestPacket(packet); + return packet; +} + +function normalizeDashboardPacket(dashboard) { + if (dashboard && typeof dashboard === 'object' && !Array.isArray(dashboard)) { + return { dashboard, findings: [] }; + } + + const normalizedDashboard = { + dashboardId: 'unidentified-dashboard', + institutionId: 'unidentified-institution', + assessedAt: null, + widgets: [], + alerts: [], + exports: [], + motion: {} + }; + + return { + dashboard: normalizedDashboard, + findings: [{ + componentId: normalizedDashboard.dashboardId, + code: 'MALFORMED_DASHBOARD_PACKET', + severity: 'blocker', + message: 'Dashboard release evidence must be an object before accessibility assessment.' + }] + }; +} + +function assessVisualAndOperableComponents(dashboard) { + const findings = []; + const components = collectDashboardComponents(dashboard, findings); + + for (const component of components) { + if (component.critical && (!component.foreground || !component.background)) { + findings.push(finding( + component, + 'INVALID_CONTRAST_EVIDENCE', + 'blocker', + 'Critical component color contrast evidence must include foreground and background hex values before dashboard release.' + )); + } else if (requiresContrastEvidence(component) && (!component.foreground || !component.background)) { + findings.push(finding( + component, + 'INVALID_CONTRAST_EVIDENCE', + 'warning', + 'Noncritical dashboard component contrast evidence should include foreground and background hex values before public release.' + )); + } else if (component.foreground && component.background) { + const contrast = contrastRatio(component.foreground, component.background); + if (contrast === null) { + findings.push(finding( + component, + 'INVALID_CONTRAST_EVIDENCE', + 'blocker', + 'Component color evidence must be parseable hex values before dashboard release.' + )); + } else if (component.critical && contrast < 4.5) { + findings.push(finding( + component, + 'LOW_CONTRAST_CRITICAL_METRIC', + 'blocker', + `Critical component contrast is ${contrast.toFixed(2)}:1, below the 4.5:1 release threshold.` + )); + } else if (contrast < 4.5) { + findings.push(finding( + component, + 'LOW_CONTRAST_NONCRITICAL_METRIC', + 'warning', + `Noncritical component contrast is ${contrast.toFixed(2)}:1, below the 4.5:1 readiness threshold.` + )); + } + } + + if (!component.screenReaderLabel || !component.screenReaderLabel.trim()) { + findings.push(finding(component, 'MISSING_SCREEN_READER_LABEL', 'blocker', 'Component lacks a meaningful screen-reader label.')); + } + + if (component.keyboardReachable === false || component.focusTrap) { + findings.push(finding(component, 'KEYBOARD_TRAP', 'blocker', 'Keyboard users cannot reach or leave this component predictably.')); + } + + if (component.keyboardReachable !== false && component.focusVisible === false) { + findings.push(finding(component, 'MISSING_VISIBLE_FOCUS_INDICATOR', 'blocker', 'Keyboard users need a visible focus indicator on reachable dashboard controls.')); + } + + if (component.ariaTextContainsPrivateData || containsPrivateData(accessibilityText(component))) { + findings.push(finding(component, 'PRIVATE_DATA_IN_ACCESSIBILITY_TEXT', 'blocker', 'Accessibility text exposes private user, lab, or project data.')); + } + + if ((component.type === 'table' || component.format) && !component.tableSummary) { + findings.push(finding(component, 'MISSING_TABLE_SUMMARY', 'blocker', 'Table or export output needs a concise nonvisual summary.')); + } + } + + findings.push(...assessHeadingOrder(components)); + return findings; +} + +function collectDashboardComponents(dashboard, findings) { + const groups = [ + ['widgets', dashboard.widgets], + ['alerts', dashboard.alerts], + ['exports', dashboard.exports] + ]; + const components = []; + + for (const [groupName, group] of groups) { + if (group == null) continue; + if (!Array.isArray(group)) { + findings.push({ + componentId: groupName, + code: 'MALFORMED_DASHBOARD_COMPONENT_ENTRY', + severity: 'blocker', + message: `Dashboard ${groupName} evidence must be an array of component objects before release.` + }); + continue; + } + + group.forEach((component, index) => { + if (!component || typeof component !== 'object' || Array.isArray(component)) { + findings.push({ + componentId: `${groupName}[${index}]`, + code: 'MALFORMED_DASHBOARD_COMPONENT_ENTRY', + severity: 'blocker', + message: 'Dashboard component evidence must be an object before accessibility release.' + }); + return; + } + components.push(component); + }); + } + + return components; +} + +function requiresContrastEvidence(component) { + return Boolean(component.type || component.title || component.foreground || component.background); +} + +function assessHeadingOrder(components) { + const findings = []; + let previousLevel = null; + + for (const component of components.filter((item) => item.headingLevel)) { + if (previousLevel !== null && component.headingLevel > previousLevel + 1) { + findings.push(finding(component, 'HEADING_ORDER_SKIP', 'warning', 'Heading order skips a level and may confuse screen-reader navigation.')); + } + previousLevel = component.headingLevel; + } + + return findings; +} + +function assessMotion(dashboard) { + const animatedCharts = dashboard.motion?.animatedCharts; + if (animatedCharts == null) return []; + if (!Array.isArray(animatedCharts) || animatedCharts.some((componentId) => typeof componentId !== 'string' || !componentId.trim())) { + return [{ + componentId: 'motion.animatedCharts', + code: 'MALFORMED_MOTION_EVIDENCE', + severity: 'blocker', + message: 'Dashboard motion evidence must list animated component IDs before reduced-motion release assessment.' + }]; + } + + if (animatedCharts.length && !dashboard.motion.reducedMotionFallback) { + return animatedCharts.map((componentId) => ({ + componentId, + code: 'MISSING_REDUCED_MOTION_FALLBACK', + severity: 'warning', + message: 'Animated dashboard content needs a reduced-motion fallback before public release.' + })); + } + return []; +} + +function finding(component, code, severity, message) { + return { + componentId: component.id, + code, + severity, + message + }; +} + +function chooseStatus(blockerCount, warningCount) { + if (blockerCount > 0) return 'hold_accessibility_release'; + if (warningCount > 0) return 'remediate_before_public_release'; + return 'release_with_accessibility_monitoring'; +} + +function chooseReleaseLanes(blockerCount, warningCount) { + if (blockerCount > 0) { + return { + adminDashboard: 'blocked', + scheduledExport: 'blocked', + webhookNotice: 'blocked' + }; + } + if (warningCount > 0) { + return { + adminDashboard: 'internal_only', + scheduledExport: 'blocked', + webhookNotice: 'internal_only' + }; + } + return { + adminDashboard: 'allowed', + scheduledExport: 'allowed', + webhookNotice: 'allowed' + }; +} + +function buildActions(dashboard, findings) { + if (!findings.length) return ['release_with_accessibility_monitoring']; + + const actions = new Set(); + const hasBlocker = findings.some((item) => item.severity === 'blocker'); + if (hasBlocker) actions.add(`block_release:${dashboard.dashboardId}`); + + for (const item of findings) { + if (item.code === 'MISSING_REDUCED_MOTION_FALLBACK') { + actions.add(`add_reduced_motion_fallback:${item.componentId}`); + } + if (item.code === 'MISSING_TABLE_SUMMARY') { + actions.add(`add_table_summary:${item.componentId}`); + } + if (item.code === 'MISSING_SCREEN_READER_LABEL') { + actions.add(`add_screen_reader_label:${item.componentId}`); + } + if (item.code === 'MISSING_VISIBLE_FOCUS_INDICATOR') { + actions.add(`add_visible_focus_indicator:${item.componentId}`); + } + if ( + item.code === 'LOW_CONTRAST_CRITICAL_METRIC' || + item.code === 'LOW_CONTRAST_NONCRITICAL_METRIC' + ) { + actions.add(`improve_contrast:${item.componentId}`); + } + if (item.code === 'INVALID_CONTRAST_EVIDENCE') { + actions.add(`provide_valid_contrast_evidence:${item.componentId}`); + } + if (item.code === 'PRIVATE_DATA_IN_ACCESSIBILITY_TEXT') { + actions.add(`redact_accessibility_text:${item.componentId}`); + } + if (item.code === 'MALFORMED_DASHBOARD_COMPONENT_ENTRY') { + actions.add(`repair_dashboard_component_evidence:${item.componentId}`); + } + if (item.code === 'MALFORMED_DASHBOARD_PACKET') { + actions.add(`repair_dashboard_packet:${item.componentId}`); + } + if (item.code === 'MALFORMED_MOTION_EVIDENCE') { + actions.add(`repair_motion_evidence:${item.componentId}`); + } + } + + return [...actions].sort(); +} + +function buildWcagSignals(findings) { + const codes = new Set(findings.map((finding) => finding.code)); + return { + perceivable: + !codes.has('INVALID_CONTRAST_EVIDENCE') && + !codes.has('LOW_CONTRAST_CRITICAL_METRIC') && + !codes.has('LOW_CONTRAST_NONCRITICAL_METRIC') && + !codes.has('MISSING_TABLE_SUMMARY'), + operable: + !codes.has('KEYBOARD_TRAP') && + !codes.has('MISSING_REDUCED_MOTION_FALLBACK') && + !codes.has('MISSING_VISIBLE_FOCUS_INDICATOR') && + !codes.has('MALFORMED_MOTION_EVIDENCE'), + understandable: !codes.has('PRIVATE_DATA_IN_ACCESSIBILITY_TEXT') && !codes.has('HEADING_ORDER_SKIP'), + robust: + !codes.has('MISSING_SCREEN_READER_LABEL') && + !codes.has('MALFORMED_DASHBOARD_COMPONENT_ENTRY') && + !codes.has('MALFORMED_DASHBOARD_PACKET') + }; +} + +function contrastRatio(foreground, background) { + const fg = relativeLuminance(hexToRgb(foreground)); + const bg = relativeLuminance(hexToRgb(background)); + if (fg === null || bg === null) return null; + const lighter = Math.max(fg, bg); + const darker = Math.min(fg, bg); + return (lighter + 0.05) / (darker + 0.05); +} + +function createComponentIdentifierSanitizer() { + const redactions = new Map(); + let redactionCount = 0; + + return (componentId) => { + if (!containsPrivateIdentifier(componentId)) return componentId; + if (!redactions.has(componentId)) { + redactions.set(componentId, `component-redacted-${++redactionCount}`); + } + return redactions.get(componentId); + }; +} + +function safeDashboardId(dashboardId) { + return containsPrivateIdentifier(dashboardId) ? 'dashboard-redacted' : dashboardId; +} + +function containsPrivateIdentifier(value = '') { + return /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|orcid:\d{4}-\d{4}-\d{4}-\d{3}[\dx]|(?:file:\/\/|[A-Z]:[\\/]Users[\\/][^ \s"')]+|\/Users\/[^ \s"')]+|\/home\/[^ \s"')]+|\bprivate[- ]lab\b|\bpatient-export\b)/i.test(value); +} + +function hexToRgb(hex) { + if (typeof hex !== 'string') return null; + const token = hex.trim().replace('#', ''); + const normalized = /^[0-9a-f]{3}$/i.test(token) + ? token.split('').map((char) => char + char).join('') + : token; + if (!/^[0-9a-f]{6}$/i.test(normalized)) return null; + const bigint = parseInt(normalized, 16); + return { + r: (bigint >> 16) & 255, + g: (bigint >> 8) & 255, + b: bigint & 255 + }; +} + +function relativeLuminance(rgb) { + if (!rgb) return null; + const { r, g, b } = rgb; + const channels = [r, g, b].map((channel) => { + const srgb = channel / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function containsPrivateData(value = '') { + return /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|orcid:\d{4}-\d{4}-\d{4}-\d{3}[\dx]|(?:sso|student|user|account)\s+id|(?:file:\/\/|[A-Z]:[\\/]Users[\\/][^ \s"')]+|\/Users\/[^ \s"')]+|\/home\/[^ \s"')]+)|\bprivate[- ]lab\b|\bpatient-export\b|restricted project/i.test(value); +} + +function accessibilityText(component) { + return [component.screenReaderLabel, component.tableSummary].filter(Boolean).join(' '); +} + +function digestPacket(packet) { + return crypto.createHash('sha256').update(stableStringify(packet)).digest('hex'); +} + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +module.exports = { + assessDashboardRelease +}; diff --git a/enterprise-dashboard-accessibility-guard/make-demo-video.py b/enterprise-dashboard-accessibility-guard/make-demo-video.py new file mode 100644 index 00000000..32143ec0 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/make-demo-video.py @@ -0,0 +1,146 @@ +from pathlib import Path +import subprocess +import sys + +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +FRAMES = ROOT / "frames" + + +def load_font(size): + candidates = [ + Path("C:/Windows/Fonts/arial.ttf"), + Path("C:/Windows/Fonts/segoeui.ttf"), + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + ] + for candidate in candidates: + if candidate.exists(): + return ImageFont.truetype(str(candidate), size=size) + return ImageFont.load_default() + + +def draw_frame(path, title, subtitle, accent, bullets): + image = Image.new("RGB", (1280, 720), "#111827") + draw = ImageDraw.Draw(image) + title_font = load_font(48) + subtitle_font = load_font(28) + bullet_font = load_font(25) + + draw.rectangle((0, 0, 1280, 18), fill=accent) + draw.text((70, 82), title, fill="#f9fafb", font=title_font) + draw.text((74, 154), subtitle, fill="#d1d5db", font=subtitle_font) + + y = 242 + for bullet in bullets: + draw.rounded_rectangle((84, y + 4, 106, y + 26), radius=5, fill=accent) + draw.text((130, y), bullet, fill="#e5e7eb", font=bullet_font) + y += 64 + + draw.text((74, 656), "Synthetic dashboard data only - no SSO, webhook, export, or private institution calls", fill="#9ca3af", font=load_font(20)) + image.save(path) + + +def main(): + REPORTS.mkdir(exist_ok=True) + FRAMES.mkdir(exist_ok=True) + + slides = [ + ( + "Enterprise Dashboard Accessibility Guard", + "Issue #19 admin dashboard release slice", + "#60a5fa", + [ + "Gates institutional dashboards before admin release", + "Checks contrast, labels, keyboard reachability, table summaries, and motion fallbacks", + "Keeps export and webhook lanes aligned with accessibility readiness", + ], + ), + ( + "Blocked Release", + "Critical accessibility and privacy issues", + "#ef4444", + [ + "Critical metrics fail contrast threshold", + "Screen-reader labels are missing or expose private data", + "Keyboard traps and missing table summaries block dashboard and export release", + ], + ), + ( + "Malformed Evidence Hold", + "Reviewer packet instead of a crash", + "#f97316", + [ + "Malformed dashboard component entries become explicit blocker findings", + "Release lanes stay blocked until component evidence is repaired", + "Audit packet keeps deterministic remediation actions for maintainers", + ], + ), + ( + "Warning Release", + "Internal-only until remediated", + "#f59e0b", + [ + "Reduced-motion fallback is missing for animated charts", + "Dashboard and webhook notices stay internal-only", + "Scheduled exports remain blocked until the fallback is attached", + ], + ), + ( + "Clean Release", + "Allowed with monitoring", + "#22c55e", + [ + "WCAG-oriented signals are all true", + "Admin dashboard, export, and webhook lanes are allowed", + "Reviewer packet includes stable SHA-256 audit evidence", + ], + ), + ] + + frame_paths = [] + for index, slide in enumerate(slides): + frame_path = FRAMES / f"frame-{index:03d}.png" + draw_frame(frame_path, *slide) + frame_paths.append(frame_path) + + concat_file = FRAMES / "frames.txt" + concat_lines = [] + for frame_path in frame_paths: + concat_lines.append(f"file '{frame_path.as_posix()}'") + concat_lines.append("duration 1.5") + concat_lines.append(f"file '{frame_paths[-1].as_posix()}'") + concat_file.write_text("\n".join(concat_lines) + "\n", encoding="utf-8") + + output = REPORTS / "demo.mp4" + subprocess.run( + [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_file), + "-vf", + "fps=24,format=yuv420p", + "-movflags", + "+faststart", + str(output), + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + print(f"wrote {output}") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"demo video generation failed: {exc}", file=sys.stderr) + raise diff --git a/enterprise-dashboard-accessibility-guard/package.json b/enterprise-dashboard-accessibility-guard/package.json new file mode 100644 index 00000000..fa2ed07d --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "enterprise-dashboard-accessibility-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && python -m py_compile make-demo-video.py", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "python make-demo-video.py" + } +} diff --git a/enterprise-dashboard-accessibility-guard/reports/accessibility-report.md b/enterprise-dashboard-accessibility-guard/reports/accessibility-report.md new file mode 100644 index 00000000..65a02f97 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/accessibility-report.md @@ -0,0 +1,14 @@ +# Enterprise Dashboard Accessibility Report + +| Packet | Status | Dashboard | Export | Webhook | Findings | +| --- | --- | --- | --- | --- | --- | +| blocked-packet.json | hold_accessibility_release | blocked | blocked | blocked | MISSING_VISIBLE_FOCUS_INDICATOR, PRIVATE_DATA_IN_ACCESSIBILITY_TEXT, INVALID_CONTRAST_EVIDENCE, MISSING_SCREEN_READER_LABEL, KEYBOARD_TRAP, MISSING_TABLE_SUMMARY, LOW_CONTRAST_CRITICAL_METRIC, MISSING_TABLE_SUMMARY, HEADING_ORDER_SKIP, MISSING_REDUCED_MOTION_FALLBACK | +| missing-contrast-packet.json | hold_accessibility_release | blocked | blocked | blocked | INVALID_CONTRAST_EVIDENCE | +| missing-noncritical-contrast-packet.json | remediate_before_public_release | internal_only | blocked | internal_only | INVALID_CONTRAST_EVIDENCE | +| malformed-component-packet.json | hold_accessibility_release | blocked | blocked | blocked | MALFORMED_DASHBOARD_COMPONENT_ENTRY | +| malformed-dashboard-packet.json | hold_accessibility_release | blocked | blocked | blocked | MALFORMED_DASHBOARD_PACKET | +| malformed-motion-packet.json | hold_accessibility_release | blocked | blocked | blocked | MALFORMED_MOTION_EVIDENCE | +| clean-packet.json | release_with_accessibility_monitoring | allowed | allowed | allowed | none | +| warning-packet.json | remediate_before_public_release | internal_only | blocked | internal_only | MISSING_REDUCED_MOTION_FALLBACK | + +All packets use synthetic dashboard records and deterministic SHA-256 audit digests. diff --git a/enterprise-dashboard-accessibility-guard/reports/blocked-packet.json b/enterprise-dashboard-accessibility-guard/reports/blocked-packet.json new file mode 100644 index 00000000..f9688c3f --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/blocked-packet.json @@ -0,0 +1,91 @@ +{ + "dashboardId": "enterprise-admin-overview", + "institutionId": "institution-redacted", + "status": "hold_accessibility_release", + "releaseLanes": { + "adminDashboard": "blocked", + "scheduledExport": "blocked", + "webhookNotice": "blocked" + }, + "findings": [ + { + "componentId": "compute-usage-critical", + "code": "MISSING_VISIBLE_FOCUS_INDICATOR", + "severity": "blocker", + "message": "Keyboard users need a visible focus indicator on reachable dashboard controls." + }, + { + "componentId": "compute-usage-critical", + "code": "PRIVATE_DATA_IN_ACCESSIBILITY_TEXT", + "severity": "blocker", + "message": "Accessibility text exposes private user, lab, or project data." + }, + { + "componentId": "contract-risk-score", + "code": "INVALID_CONTRAST_EVIDENCE", + "severity": "blocker", + "message": "Component color evidence must be parseable hex values before dashboard release." + }, + { + "componentId": "private-project-table", + "code": "MISSING_SCREEN_READER_LABEL", + "severity": "blocker", + "message": "Component lacks a meaningful screen-reader label." + }, + { + "componentId": "private-project-table", + "code": "KEYBOARD_TRAP", + "severity": "blocker", + "message": "Keyboard users cannot reach or leave this component predictably." + }, + { + "componentId": "private-project-table", + "code": "MISSING_TABLE_SUMMARY", + "severity": "blocker", + "message": "Table or export output needs a concise nonvisual summary." + }, + { + "componentId": "webhook-failure-alert", + "code": "LOW_CONTRAST_CRITICAL_METRIC", + "severity": "blocker", + "message": "Critical component contrast is 3.08:1, below the 4.5:1 release threshold." + }, + { + "componentId": "weekly-admin-export", + "code": "MISSING_TABLE_SUMMARY", + "severity": "blocker", + "message": "Table or export output needs a concise nonvisual summary." + }, + { + "componentId": "private-project-table", + "code": "HEADING_ORDER_SKIP", + "severity": "warning", + "message": "Heading order skips a level and may confuse screen-reader navigation." + }, + { + "componentId": "compute-usage-critical", + "code": "MISSING_REDUCED_MOTION_FALLBACK", + "severity": "warning", + "message": "Animated dashboard content needs a reduced-motion fallback before public release." + } + ], + "actions": [ + "add_reduced_motion_fallback:compute-usage-critical", + "add_screen_reader_label:private-project-table", + "add_table_summary:private-project-table", + "add_table_summary:weekly-admin-export", + "add_visible_focus_indicator:compute-usage-critical", + "block_release:enterprise-admin-overview", + "improve_contrast:webhook-failure-alert", + "provide_valid_contrast_evidence:contract-risk-score", + "redact_accessibility_text:compute-usage-critical" + ], + "wcagSignals": { + "perceivable": false, + "operable": false, + "understandable": false, + "robust": false + }, + "assessedAt": "2026-05-27T13:00:00Z", + "auditDigest": "54a2b80eea438f90254991c20ca827636fa16789c652fbef6309e56662e55253" +} diff --git a/enterprise-dashboard-accessibility-guard/reports/clean-packet.json b/enterprise-dashboard-accessibility-guard/reports/clean-packet.json new file mode 100644 index 00000000..32bcfb6e --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/clean-packet.json @@ -0,0 +1,22 @@ +{ + "dashboardId": "enterprise-admin-clean", + "institutionId": "institution-redacted", + "status": "release_with_accessibility_monitoring", + "releaseLanes": { + "adminDashboard": "allowed", + "scheduledExport": "allowed", + "webhookNotice": "allowed" + }, + "findings": [], + "actions": [ + "release_with_accessibility_monitoring" + ], + "wcagSignals": { + "perceivable": true, + "operable": true, + "understandable": true, + "robust": true + }, + "assessedAt": "2026-05-27T13:00:00Z", + "auditDigest": "e3abe7a1521f56544cd037dc7fc520af8f8841edf3fbbde2841b57729e2f1e43" +} diff --git a/enterprise-dashboard-accessibility-guard/reports/demo.mp4 b/enterprise-dashboard-accessibility-guard/reports/demo.mp4 new file mode 100644 index 00000000..df87b725 Binary files /dev/null and b/enterprise-dashboard-accessibility-guard/reports/demo.mp4 differ diff --git a/enterprise-dashboard-accessibility-guard/reports/malformed-component-packet.json b/enterprise-dashboard-accessibility-guard/reports/malformed-component-packet.json new file mode 100644 index 00000000..5125ff3f --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/malformed-component-packet.json @@ -0,0 +1,30 @@ +{ + "dashboardId": "enterprise-admin-malformed-component", + "institutionId": "institution-redacted", + "status": "hold_accessibility_release", + "releaseLanes": { + "adminDashboard": "blocked", + "scheduledExport": "blocked", + "webhookNotice": "blocked" + }, + "findings": [ + { + "componentId": "widgets[0]", + "code": "MALFORMED_DASHBOARD_COMPONENT_ENTRY", + "severity": "blocker", + "message": "Dashboard component evidence must be an object before accessibility release." + } + ], + "actions": [ + "block_release:enterprise-admin-malformed-component", + "repair_dashboard_component_evidence:widgets[0]" + ], + "wcagSignals": { + "perceivable": true, + "operable": true, + "understandable": true, + "robust": false + }, + "assessedAt": "2026-05-31T14:30:00Z", + "auditDigest": "5c9dbb472c16ae680eaa0eede8847ba6c793a577c2d73f6b90deed677a973509" +} diff --git a/enterprise-dashboard-accessibility-guard/reports/malformed-dashboard-packet.json b/enterprise-dashboard-accessibility-guard/reports/malformed-dashboard-packet.json new file mode 100644 index 00000000..0d7c7024 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/malformed-dashboard-packet.json @@ -0,0 +1,30 @@ +{ + "dashboardId": "unidentified-dashboard", + "institutionId": "unidentified-institution", + "status": "hold_accessibility_release", + "releaseLanes": { + "adminDashboard": "blocked", + "scheduledExport": "blocked", + "webhookNotice": "blocked" + }, + "findings": [ + { + "componentId": "unidentified-dashboard", + "code": "MALFORMED_DASHBOARD_PACKET", + "severity": "blocker", + "message": "Dashboard release evidence must be an object before accessibility assessment." + } + ], + "actions": [ + "block_release:unidentified-dashboard", + "repair_dashboard_packet:unidentified-dashboard" + ], + "wcagSignals": { + "perceivable": true, + "operable": true, + "understandable": true, + "robust": false + }, + "assessedAt": null, + "auditDigest": "516d9cbe81ea9fa4fdefc56855e88adb197ca89d9365bd483e484e8e387f8fc4" +} diff --git a/enterprise-dashboard-accessibility-guard/reports/malformed-motion-packet.json b/enterprise-dashboard-accessibility-guard/reports/malformed-motion-packet.json new file mode 100644 index 00000000..7811237c --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/malformed-motion-packet.json @@ -0,0 +1,30 @@ +{ + "dashboardId": "enterprise-admin-malformed-motion", + "institutionId": "institution-redacted", + "status": "hold_accessibility_release", + "releaseLanes": { + "adminDashboard": "blocked", + "scheduledExport": "blocked", + "webhookNotice": "blocked" + }, + "findings": [ + { + "componentId": "motion.animatedCharts", + "code": "MALFORMED_MOTION_EVIDENCE", + "severity": "blocker", + "message": "Dashboard motion evidence must list animated component IDs before reduced-motion release assessment." + } + ], + "actions": [ + "block_release:enterprise-admin-malformed-motion", + "repair_motion_evidence:motion.animatedCharts" + ], + "wcagSignals": { + "perceivable": true, + "operable": false, + "understandable": true, + "robust": true + }, + "assessedAt": "2026-06-10T13:10:00Z", + "auditDigest": "1b15a9f22c15eec4f0e4bc19d6a25231fac688e02875894ca3fb7ce2847b2fe7" +} diff --git a/enterprise-dashboard-accessibility-guard/reports/missing-contrast-packet.json b/enterprise-dashboard-accessibility-guard/reports/missing-contrast-packet.json new file mode 100644 index 00000000..13e86dd3 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/missing-contrast-packet.json @@ -0,0 +1,30 @@ +{ + "dashboardId": "enterprise-admin-missing-contrast-evidence", + "institutionId": "institution-redacted", + "status": "hold_accessibility_release", + "releaseLanes": { + "adminDashboard": "blocked", + "scheduledExport": "blocked", + "webhookNotice": "blocked" + }, + "findings": [ + { + "componentId": "contract-risk-without-colors", + "code": "INVALID_CONTRAST_EVIDENCE", + "severity": "blocker", + "message": "Critical component color contrast evidence must include foreground and background hex values before dashboard release." + } + ], + "actions": [ + "block_release:enterprise-admin-missing-contrast-evidence", + "provide_valid_contrast_evidence:contract-risk-without-colors" + ], + "wcagSignals": { + "perceivable": false, + "operable": true, + "understandable": true, + "robust": true + }, + "assessedAt": "2026-05-27T13:17:00Z", + "auditDigest": "1d62d0b424cfe9c0ebbe8c9effdc876399ca66b04e13d2df5f6d6f7f91eff442" +} diff --git a/enterprise-dashboard-accessibility-guard/reports/missing-noncritical-contrast-packet.json b/enterprise-dashboard-accessibility-guard/reports/missing-noncritical-contrast-packet.json new file mode 100644 index 00000000..3027eb55 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/missing-noncritical-contrast-packet.json @@ -0,0 +1,29 @@ +{ + "dashboardId": "enterprise-admin-missing-secondary-contrast", + "institutionId": "institution-redacted", + "status": "remediate_before_public_release", + "releaseLanes": { + "adminDashboard": "internal_only", + "scheduledExport": "blocked", + "webhookNotice": "internal_only" + }, + "findings": [ + { + "componentId": "secondary-usage-trend-without-colors", + "code": "INVALID_CONTRAST_EVIDENCE", + "severity": "warning", + "message": "Noncritical dashboard component contrast evidence should include foreground and background hex values before public release." + } + ], + "actions": [ + "provide_valid_contrast_evidence:secondary-usage-trend-without-colors" + ], + "wcagSignals": { + "perceivable": false, + "operable": true, + "understandable": true, + "robust": true + }, + "assessedAt": "2026-05-30T15:25:00Z", + "auditDigest": "68bfb1e3df68d7733c886d880fc4c014e8f4040b5e47d09f37d2e9cf9589a14d" +} diff --git a/enterprise-dashboard-accessibility-guard/reports/summary.svg b/enterprise-dashboard-accessibility-guard/reports/summary.svg new file mode 100644 index 00000000..94ebf997 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/summary.svg @@ -0,0 +1,54 @@ + + + Enterprise Dashboard Accessibility Guard + Institutional dashboards, exports, and webhook notices are gated before release. + + + + + enterprise-admin-overview + hold_accessibility_release | findings 10 | digest 54a2b80eea438f90 + + + + + enterprise-admin-missing-contrast-evidence + hold_accessibility_release | findings 1 | digest 1d62d0b424cfe9c0 + + + + + enterprise-admin-missing-secondary-contrast + remediate_before_public_release | findings 1 | digest 68bfb1e3df68d773 + + + + + enterprise-admin-malformed-component + hold_accessibility_release | findings 1 | digest 5c9dbb472c16ae68 + + + + + unidentified-dashboard + hold_accessibility_release | findings 1 | digest 516d9cbe81ea9fa4 + + + + + enterprise-admin-malformed-motion + hold_accessibility_release | findings 1 | digest 1b15a9f22c15eec4 + + + + + enterprise-admin-clean + release_with_accessibility_monitoring | findings 0 | digest e3abe7a1521f5654 + + + + + enterprise-admin-motion-warning + remediate_before_public_release | findings 1 | digest bfa5b6aae578307a + + diff --git a/enterprise-dashboard-accessibility-guard/reports/warning-packet.json b/enterprise-dashboard-accessibility-guard/reports/warning-packet.json new file mode 100644 index 00000000..496fb404 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/warning-packet.json @@ -0,0 +1,29 @@ +{ + "dashboardId": "enterprise-admin-motion-warning", + "institutionId": "institution-redacted", + "status": "remediate_before_public_release", + "releaseLanes": { + "adminDashboard": "internal_only", + "scheduledExport": "blocked", + "webhookNotice": "internal_only" + }, + "findings": [ + { + "componentId": "research-output-trend", + "code": "MISSING_REDUCED_MOTION_FALLBACK", + "severity": "warning", + "message": "Animated dashboard content needs a reduced-motion fallback before public release." + } + ], + "actions": [ + "add_reduced_motion_fallback:research-output-trend" + ], + "wcagSignals": { + "perceivable": true, + "operable": false, + "understandable": true, + "robust": true + }, + "assessedAt": "2026-05-27T13:00:00Z", + "auditDigest": "bfa5b6aae578307ad58b740178a32b2a53dac335acea64ac2ee20afa87ca4864" +} diff --git a/enterprise-dashboard-accessibility-guard/requirements-map.md b/enterprise-dashboard-accessibility-guard/requirements-map.md new file mode 100644 index 00000000..c4b2e5b0 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/requirements-map.md @@ -0,0 +1,16 @@ +# Requirements Map + +Issue #19 asks for enterprise tooling around admin dashboards, API and webhook integrations, export pipelines, compliance tracking, usage visibility, and institution-scale governance. + +| Issue Area | This Slice | +| --- | --- | +| Admin dashboards | Gates institutional dashboard widgets before release to admins. | +| Contributor and usage analytics | Checks that critical and noncritical metrics are perceivable, include required contrast evidence, use valid contrast evidence, are labeled, keyboard reachable with visible focus indicators, and safe for nonvisual users. Missing noncritical contrast evidence now keeps public release in remediation instead of passing clean. | +| Compliance tracking | Produces WCAG-oriented readiness signals and deterministic audit evidence for institutional governance. | +| Export pipelines | Blocks scheduled exports when tables lack summaries or dashboard views are not accessible enough for release. | +| Webhook support | Keeps webhook notices internal-only when the dashboard state has nonblocking accessibility warnings. | +| Enterprise governance | Detects private-data and direct identifier leakage in screen-reader labels, table summaries, and export summaries before dashboard or export surfaces are published, and blocks malformed dashboard packets, component evidence, or reduced-motion evidence instead of crashing before audit packet generation. | + +## Non-Overlap + +This is distinct from the existing dashboard/export/webhook replay/compliance/identity/retention/data-residency/SLA/secret-rotation/quota/API-change/connector-certification/incident/funder/AI-model/dashboard-attribution/initiative-tag/policy-exception/IRB/data-export/SCIM/deposit-reconciliation/admin-notification/cost-allocation/LMS/payload-redaction/vendor-DPA/cohort-privacy/API-rate-limit slices. It focuses specifically on accessibility readiness for institutional admin dashboards and their downstream export/webhook release lanes. diff --git a/enterprise-dashboard-accessibility-guard/sample-data.js b/enterprise-dashboard-accessibility-guard/sample-data.js new file mode 100644 index 00000000..36687d6b --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/sample-data.js @@ -0,0 +1,252 @@ +const blockedDashboard = { + dashboardId: 'enterprise-admin-overview', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:00:00Z', + widgets: [ + { + id: 'compute-usage-critical', + type: 'metric', + title: 'Compute usage', + foreground: '#64748b', + background: '#f8fafc', + critical: true, + keyboardReachable: true, + focusVisible: false, + screenReaderLabel: 'Compute usage for private lab alice@example.edu', + ariaTextContainsPrivateData: true, + headingLevel: 2 + }, + { + id: 'contract-risk-score', + type: 'metric', + title: 'Contract risk score', + foreground: 'var(--metric-danger)', + background: '#ffffff', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Contract risk score across departments', + headingLevel: 2 + }, + { + id: 'private-project-table', + type: 'table', + title: 'Private projects', + foreground: '#111827', + background: '#ffffff', + critical: true, + keyboardReachable: false, + focusTrap: true, + screenReaderLabel: '', + tableSummary: '', + headingLevel: 4 + } + ], + alerts: [ + { + id: 'webhook-failure-alert', + title: 'Webhook delivery failed', + foreground: '#ef4444', + background: '#fee2e2', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Webhook delivery failed', + headingLevel: 3 + } + ], + exports: [ + { + id: 'weekly-admin-export', + format: 'csv', + tableSummary: '', + screenReaderLabel: 'Weekly admin export' + } + ], + motion: { + animatedCharts: ['compute-usage-critical'], + reducedMotionFallback: false + } +}; + +const cleanDashboard = { + dashboardId: 'enterprise-admin-clean', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:00:00Z', + widgets: [ + { + id: 'open-access-compliance', + type: 'metric', + title: 'Open access compliance', + foreground: '#0f172a', + background: '#ffffff', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Open access compliance percentage across hosted projects', + headingLevel: 2 + }, + { + id: 'lab-output-table', + type: 'table', + title: 'Lab output', + foreground: '#0f172a', + background: '#f8fafc', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Research output by lab', + tableSummary: 'Rows list labs; columns show projects, reviews, storage, and reproducibility score.', + headingLevel: 3 + } + ], + alerts: [ + { + id: 'repo-sync-alert', + title: 'Repository sync complete', + foreground: '#14532d', + background: '#dcfce7', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Repository sync complete', + headingLevel: 3 + } + ], + exports: [ + { + id: 'quarterly-accessibility-export', + format: 'json', + tableSummary: 'Export includes aggregate accessibility status only.', + screenReaderLabel: 'Quarterly accessibility readiness export' + } + ], + motion: { + animatedCharts: ['open-access-compliance'], + reducedMotionFallback: true + } +}; + +const warningDashboard = { + dashboardId: 'enterprise-admin-motion-warning', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:00:00Z', + widgets: [ + { + id: 'research-output-trend', + type: 'metric', + title: 'Research output trend', + foreground: '#172554', + background: '#dbeafe', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Research output trend for all departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [ + { + id: 'trend-export', + format: 'json', + tableSummary: 'Trend export contains aggregate department counts only.', + screenReaderLabel: 'Research output trend export' + } + ], + motion: { + animatedCharts: ['research-output-trend'], + reducedMotionFallback: false + } +}; + +const missingContrastDashboard = { + dashboardId: 'enterprise-admin-missing-contrast-evidence', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:17:00Z', + widgets: [ + { + id: 'contract-risk-without-colors', + type: 'metric', + title: 'Contract risk score', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Contract risk score across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } +}; + +const missingNoncriticalContrastDashboard = { + dashboardId: 'enterprise-admin-missing-secondary-contrast', + institutionId: 'institution-redacted', + assessedAt: '2026-05-30T15:25:00Z', + widgets: [ + { + id: 'secondary-usage-trend-without-colors', + type: 'metric', + title: 'Storage usage trend', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Storage usage trend across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } +}; + +const malformedComponentDashboard = { + dashboardId: 'enterprise-admin-malformed-component', + institutionId: 'institution-redacted', + assessedAt: '2026-05-31T14:30:00Z', + widgets: [null], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } +}; + +const malformedDashboardPacket = null; + +const malformedMotionDashboard = { + dashboardId: 'enterprise-admin-malformed-motion', + institutionId: 'institution-redacted', + assessedAt: '2026-06-10T13:10:00Z', + widgets: [ + { + id: 'animated-usage-trend', + type: 'metric', + title: 'Usage trend', + foreground: '#111827', + background: '#ffffff', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Usage trend across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: 'animated-usage-trend', + reducedMotionFallback: false + } +}; + +module.exports = { + blockedDashboard, + cleanDashboard, + warningDashboard, + missingContrastDashboard, + missingNoncriticalContrastDashboard, + malformedComponentDashboard, + malformedDashboardPacket, + malformedMotionDashboard +}; diff --git a/enterprise-dashboard-accessibility-guard/test.js b/enterprise-dashboard-accessibility-guard/test.js new file mode 100644 index 00000000..52552fd5 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/test.js @@ -0,0 +1,438 @@ +const assert = require('assert'); + +const { assessDashboardRelease } = require('./index'); +const { blockedDashboard, cleanDashboard, warningDashboard } = require('./sample-data'); + +function codes(packet) { + return packet.findings.map((finding) => finding.code).sort(); +} + +function testCriticalAccessibilityIssuesBlockDashboardRelease() { + const packet = assessDashboardRelease(blockedDashboard); + const findingCodes = codes(packet); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.equal(packet.releaseLanes.scheduledExport, 'blocked'); + assert.equal(packet.releaseLanes.webhookNotice, 'blocked'); + assert.ok(findingCodes.includes('LOW_CONTRAST_CRITICAL_METRIC')); + assert.ok(findingCodes.includes('MISSING_SCREEN_READER_LABEL')); + assert.ok(findingCodes.includes('KEYBOARD_TRAP')); + assert.ok(findingCodes.includes('PRIVATE_DATA_IN_ACCESSIBILITY_TEXT')); + assert.ok(findingCodes.includes('MISSING_TABLE_SUMMARY')); + assert.ok(packet.actions.includes('block_release:enterprise-admin-overview')); + assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); +} + +function testCleanDashboardReleasesWithWcagSignals() { + const packet = assessDashboardRelease(cleanDashboard); + + assert.equal(packet.status, 'release_with_accessibility_monitoring'); + assert.equal(packet.releaseLanes.adminDashboard, 'allowed'); + assert.equal(packet.releaseLanes.scheduledExport, 'allowed'); + assert.equal(packet.releaseLanes.webhookNotice, 'allowed'); + assert.deepEqual(packet.findings, []); + assert.equal(packet.wcagSignals.perceivable, true); + assert.equal(packet.wcagSignals.operable, true); + assert.equal(packet.wcagSignals.understandable, true); + assert.equal(packet.wcagSignals.robust, true); +} + +function testWarningsAllowInternalOnlyPreview() { + const packet = assessDashboardRelease(warningDashboard); + + assert.equal(packet.status, 'remediate_before_public_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'internal_only'); + assert.equal(packet.releaseLanes.scheduledExport, 'blocked'); + assert.equal(packet.releaseLanes.webhookNotice, 'internal_only'); + assert.deepEqual(codes(packet), ['MISSING_REDUCED_MOTION_FALLBACK']); + assert.ok(packet.actions.includes('add_reduced_motion_fallback:research-output-trend')); +} + +function testNonCriticalLowContrastRequiresRemediationBeforeRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-low-contrast-secondary', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:05:00Z', + widgets: [ + { + id: 'secondary-storage-trend', + type: 'metric', + title: 'Storage trend', + foreground: '#94a3b8', + background: '#f8fafc', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Storage trend across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'remediate_before_public_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'internal_only'); + assert.equal(packet.releaseLanes.scheduledExport, 'blocked'); + assert.deepEqual(codes(packet), ['LOW_CONTRAST_NONCRITICAL_METRIC']); + assert.equal(packet.wcagSignals.perceivable, false); + assert.ok(packet.actions.includes('improve_contrast:secondary-storage-trend')); +} + +function testPrivateDataInTableSummaryBlocksRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-private-summary', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:10:00Z', + widgets: [ + { + id: 'private-summary-table', + type: 'table', + title: 'Project review', + foreground: '#111827', + background: '#ffffff', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Project review table', + tableSummary: 'Rows include restricted project alpha and private lab owner alice@example.edu.', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.ok(codes(packet).includes('PRIVATE_DATA_IN_ACCESSIBILITY_TEXT')); + assert.equal(packet.wcagSignals.understandable, false); + assert.ok(packet.actions.includes('redact_accessibility_text:private-summary-table')); +} + +function testDirectIdentifierInScreenReaderLabelBlocksRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-private-label', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:12:00Z', + widgets: [ + { + id: 'private-label-metric', + type: 'metric', + title: 'Sensitive usage metric', + foreground: '#111827', + background: '#ffffff', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Usage for ORCID:0000-0002-1825-0097', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.deepEqual(codes(packet), ['PRIVATE_DATA_IN_ACCESSIBILITY_TEXT']); + assert.equal(packet.wcagSignals.understandable, false); + assert.ok(packet.actions.includes('redact_accessibility_text:private-label-metric')); +} + +function testInvalidContrastEvidenceBlocksRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-invalid-contrast-evidence', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:15:00Z', + widgets: [ + { + id: 'contract-risk-score', + type: 'metric', + title: 'Contract risk score', + foreground: 'var(--metric-danger)', + background: '#ffffff', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Contract risk score across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.deepEqual(codes(packet), ['INVALID_CONTRAST_EVIDENCE']); + assert.equal(packet.wcagSignals.perceivable, false); + assert.ok(packet.actions.includes('provide_valid_contrast_evidence:contract-risk-score')); +} + +function testMissingCriticalContrastEvidenceBlocksRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-missing-contrast-evidence', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:17:00Z', + widgets: [ + { + id: 'contract-risk-without-colors', + type: 'metric', + title: 'Contract risk score', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Contract risk score across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.deepEqual(codes(packet), ['INVALID_CONTRAST_EVIDENCE']); + assert.equal(packet.wcagSignals.perceivable, false); + assert.ok(packet.actions.includes('provide_valid_contrast_evidence:contract-risk-without-colors')); +} + +function testMissingNoncriticalContrastEvidenceRequiresRemediation() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-missing-secondary-contrast', + institutionId: 'institution-redacted', + assessedAt: '2026-05-30T15:25:00Z', + widgets: [ + { + id: 'secondary-usage-trend-without-colors', + type: 'metric', + title: 'Storage usage trend', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Storage usage trend across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'remediate_before_public_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'internal_only'); + assert.equal(packet.releaseLanes.scheduledExport, 'blocked'); + assert.equal(packet.releaseLanes.webhookNotice, 'internal_only'); + assert.deepEqual(codes(packet), ['INVALID_CONTRAST_EVIDENCE']); + assert.equal(packet.wcagSignals.perceivable, false); + assert.ok(packet.actions.includes('provide_valid_contrast_evidence:secondary-usage-trend-without-colors')); +} + +function testMalformedDashboardComponentEntriesBlockRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-malformed-component', + institutionId: 'institution-redacted', + assessedAt: '2026-05-31T14:30:00Z', + widgets: [null], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.deepEqual(codes(packet), ['MALFORMED_DASHBOARD_COMPONENT_ENTRY']); + assert.equal(packet.wcagSignals.robust, false); + assert.ok(packet.actions.includes('repair_dashboard_component_evidence:widgets[0]')); +} + +function testMalformedDashboardPacketBlocksRelease() { + const packet = assessDashboardRelease(null); + + assert.equal(packet.dashboardId, 'unidentified-dashboard'); + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.deepEqual(codes(packet), ['MALFORMED_DASHBOARD_PACKET']); + assert.equal(packet.wcagSignals.robust, false); + assert.ok(packet.actions.includes('repair_dashboard_packet:unidentified-dashboard')); +} + +function testMalformedMotionEvidenceBlocksRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-malformed-motion', + institutionId: 'institution-redacted', + assessedAt: '2026-06-10T13:10:00Z', + widgets: [ + { + id: 'animated-usage-trend', + type: 'metric', + title: 'Usage trend', + foreground: '#111827', + background: '#ffffff', + critical: false, + keyboardReachable: true, + screenReaderLabel: 'Usage trend across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: 'animated-usage-trend', + reducedMotionFallback: false + } + }); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.deepEqual(codes(packet), ['MALFORMED_MOTION_EVIDENCE']); + assert.equal(packet.wcagSignals.operable, false); + assert.ok(packet.actions.includes('repair_motion_evidence:motion.animatedCharts')); +} + +function testShorthandHexContrastEvidenceRemainsValid() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-shorthand-contrast', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:20:00Z', + widgets: [ + { + id: 'repository-sync-status', + type: 'metric', + title: 'Repository sync status', + foreground: '#000', + background: '#fff', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Repository sync status across departments', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'release_with_accessibility_monitoring'); + assert.deepEqual(packet.findings, []); + assert.equal(packet.wcagSignals.perceivable, true); +} + +function testMissingVisibleFocusIndicatorBlocksKeyboardRelease() { + const packet = assessDashboardRelease({ + dashboardId: 'enterprise-admin-hidden-focus', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:25:00Z', + widgets: [ + { + id: 'project-risk-filter', + type: 'filter', + title: 'Project risk filter', + foreground: '#111827', + background: '#ffffff', + critical: true, + keyboardReachable: true, + focusVisible: false, + screenReaderLabel: 'Filter projects by risk status', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.releaseLanes.adminDashboard, 'blocked'); + assert.deepEqual(codes(packet), ['MISSING_VISIBLE_FOCUS_INDICATOR']); + assert.equal(packet.wcagSignals.operable, false); + assert.ok(packet.actions.includes('add_visible_focus_indicator:project-risk-filter')); +} + +function testPrivateComponentIdsAreRedactedFromFindingsAndActions() { + const packet = assessDashboardRelease({ + dashboardId: 'C:\\Users\\Alice\\dashboards\\private-lab', + institutionId: 'institution-redacted', + assessedAt: '2026-05-27T13:25:00Z', + widgets: [ + { + id: 'alice.private@example.edu', + type: 'metric', + title: 'Private cohort summary', + foreground: '#111827', + background: '#ffffff', + critical: true, + keyboardReachable: true, + screenReaderLabel: 'Owner alice.private@example.edu cohort summary', + headingLevel: 2 + } + ], + alerts: [], + exports: [], + motion: { + animatedCharts: [], + reducedMotionFallback: true + } + }); + + const packetJson = JSON.stringify(packet); + + assert.equal(packet.status, 'hold_accessibility_release'); + assert.equal(packet.findings[0].componentId, 'component-redacted-1'); + assert.ok(packet.actions.includes('block_release:dashboard-redacted')); + assert.ok(packet.actions.includes('redact_accessibility_text:component-redacted-1')); + assert.equal(packetJson.includes('alice.private@example.edu'), false); + assert.equal(packetJson.includes('C:\\Users\\Alice'), false); + assert.equal(packetJson.includes('private-lab'), false); +} + +const tests = [ + testCriticalAccessibilityIssuesBlockDashboardRelease, + testCleanDashboardReleasesWithWcagSignals, + testWarningsAllowInternalOnlyPreview, + testNonCriticalLowContrastRequiresRemediationBeforeRelease, + testPrivateDataInTableSummaryBlocksRelease, + testDirectIdentifierInScreenReaderLabelBlocksRelease, + testInvalidContrastEvidenceBlocksRelease, + testMissingCriticalContrastEvidenceBlocksRelease, + testMissingNoncriticalContrastEvidenceRequiresRemediation, + testMalformedDashboardComponentEntriesBlockRelease, + testMalformedDashboardPacketBlocksRelease, + testMalformedMotionEvidenceBlocksRelease, + testShorthandHexContrastEvidenceRemainsValid, + testMissingVisibleFocusIndicatorBlocksKeyboardRelease, + testPrivateComponentIdsAreRedactedFromFindingsAndActions +]; + +for (const test of tests) { + test(); +} + +console.log(`enterprise-dashboard-accessibility-guard tests passed (${tests.length})`);