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..e28c5002 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/README.md @@ -0,0 +1,33 @@ +# 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 screen-reader labels +- Keyboard reachability and focus traps +- Private user or project data embedded in accessibility text +- 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..0f2e6fdd --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/acceptance-notes.md @@ -0,0 +1,25 @@ +# 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. +- Generates reviewer artifacts: + - `reports/blocked-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..36656709 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/demo.js @@ -0,0 +1,81 @@ +const fs = require('fs'); +const path = require('path'); + +const { assessDashboardRelease } = require('./index'); +const { blockedDashboard, cleanDashboard, warningDashboard } = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const packets = [ + ['blocked-packet.json', assessDashboardRelease(blockedDashboard)], + ['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 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..5583f81c --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/index.js @@ -0,0 +1,226 @@ +const crypto = require('crypto'); + +function assessDashboardRelease(dashboard) { + const findings = [ + ...assessVisualAndOperableComponents(dashboard), + ...assessMotion(dashboard) + ]; + const blockerCount = findings.filter((finding) => finding.severity === 'blocker').length; + const warningCount = findings.filter((finding) => finding.severity === 'warning').length; + + const packet = { + dashboardId: dashboard.dashboardId, + institutionId: dashboard.institutionId, + status: chooseStatus(blockerCount, warningCount), + releaseLanes: chooseReleaseLanes(blockerCount, warningCount), + findings, + actions: buildActions(dashboard, findings), + wcagSignals: buildWcagSignals(findings), + assessedAt: dashboard.assessedAt + }; + + packet.auditDigest = digestPacket(packet); + return packet; +} + +function assessVisualAndOperableComponents(dashboard) { + const components = [ + ...(dashboard.widgets || []), + ...(dashboard.alerts || []), + ...(dashboard.exports || []) + ]; + const findings = []; + + for (const component of components) { + if (component.foreground && component.background) { + const contrast = contrastRatio(component.foreground, component.background); + 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.ariaTextContainsPrivateData || containsPrivateData(component.screenReaderLabel)) { + 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 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) { + if (dashboard.motion?.animatedCharts?.length && !dashboard.motion.reducedMotionFallback) { + return dashboard.motion.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 === 'LOW_CONTRAST_CRITICAL_METRIC' || + item.code === 'LOW_CONTRAST_NONCRITICAL_METRIC' + ) { + actions.add(`improve_contrast:${item.componentId}`); + } + } + + return [...actions].sort(); +} + +function buildWcagSignals(findings) { + const codes = new Set(findings.map((finding) => finding.code)); + return { + perceivable: + !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'), + understandable: !codes.has('PRIVATE_DATA_IN_ACCESSIBILITY_TEXT') && !codes.has('HEADING_ORDER_SKIP'), + robust: !codes.has('MISSING_SCREEN_READER_LABEL') + }; +} + +function contrastRatio(foreground, background) { + const fg = relativeLuminance(hexToRgb(foreground)); + const bg = relativeLuminance(hexToRgb(background)); + const lighter = Math.max(fg, bg); + const darker = Math.min(fg, bg); + return (lighter + 0.05) / (darker + 0.05); +} + +function hexToRgb(hex) { + const normalized = hex.replace('#', ''); + const bigint = parseInt(normalized, 16); + return { + r: (bigint >> 16) & 255, + g: (bigint >> 8) & 255, + b: bigint & 255 + }; +} + +function relativeLuminance({ r, g, b }) { + 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,}|private lab|restricted project/i.test(value); +} + +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..8f779777 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/make-demo-video.py @@ -0,0 +1,136 @@ +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", + ], + ), + ( + "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..0af26adf --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/accessibility-report.md @@ -0,0 +1,9 @@ +# Enterprise Dashboard Accessibility Report + +| Packet | Status | Dashboard | Export | Webhook | Findings | +| --- | --- | --- | --- | --- | --- | +| blocked-packet.json | hold_accessibility_release | blocked | blocked | blocked | PRIVATE_DATA_IN_ACCESSIBILITY_TEXT, MISSING_SCREEN_READER_LABEL, KEYBOARD_TRAP, MISSING_TABLE_SUMMARY, LOW_CONTRAST_CRITICAL_METRIC, MISSING_TABLE_SUMMARY, HEADING_ORDER_SKIP, MISSING_REDUCED_MOTION_FALLBACK | +| 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..ecfb7205 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/blocked-packet.json @@ -0,0 +1,76 @@ +{ + "dashboardId": "enterprise-admin-overview", + "institutionId": "institution-redacted", + "status": "hold_accessibility_release", + "releaseLanes": { + "adminDashboard": "blocked", + "scheduledExport": "blocked", + "webhookNotice": "blocked" + }, + "findings": [ + { + "componentId": "compute-usage-critical", + "code": "PRIVATE_DATA_IN_ACCESSIBILITY_TEXT", + "severity": "blocker", + "message": "Accessibility text exposes private user, lab, or project data." + }, + { + "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", + "block_release:enterprise-admin-overview", + "improve_contrast:webhook-failure-alert" + ], + "wcagSignals": { + "perceivable": false, + "operable": false, + "understandable": false, + "robust": false + }, + "assessedAt": "2026-05-27T13:00:00Z", + "auditDigest": "08fe3d5920f0e34baf7e2c3d0919ac2a322e0b3bd2bc92602de6e6b201e8dfc5" +} 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..9f8ca463 Binary files /dev/null and b/enterprise-dashboard-accessibility-guard/reports/demo.mp4 differ diff --git a/enterprise-dashboard-accessibility-guard/reports/summary.svg b/enterprise-dashboard-accessibility-guard/reports/summary.svg new file mode 100644 index 00000000..4d4e7ab7 --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/reports/summary.svg @@ -0,0 +1,24 @@ + + + Enterprise Dashboard Accessibility Guard + Institutional dashboards, exports, and webhook notices are gated before release. + + + + + enterprise-admin-overview + hold_accessibility_release | findings 8 | digest 08fe3d5920f0e34b + + + + + 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..c1e6cc2f --- /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, labeled, keyboard reachable, and safe for nonvisual users. | +| 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 leakage in screen-reader text before dashboard or export surfaces are published. | + +## 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..3c9e103e --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/sample-data.js @@ -0,0 +1,149 @@ +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, + screenReaderLabel: 'Compute usage for private lab alice@example.edu', + ariaTextContainsPrivateData: true, + 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 + } +}; + +module.exports = { + blockedDashboard, + cleanDashboard, + warningDashboard +}; diff --git a/enterprise-dashboard-accessibility-guard/test.js b/enterprise-dashboard-accessibility-guard/test.js new file mode 100644 index 00000000..541bb40f --- /dev/null +++ b/enterprise-dashboard-accessibility-guard/test.js @@ -0,0 +1,97 @@ +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')); +} + +const tests = [ + testCriticalAccessibilityIssuesBlockDashboardRelease, + testCleanDashboardReleasesWithWcagSignals, + testWarningsAllowInternalOnlyPreview, + testNonCriticalLowContrastRequiresRemediationBeforeRelease +]; + +for (const test of tests) { + test(); +} + +console.log(`enterprise-dashboard-accessibility-guard tests passed (${tests.length})`);