Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions enterprise-dashboard-accessibility-guard/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
frames/
__pycache__/
*.tmp
33 changes: 33 additions & 0 deletions enterprise-dashboard-accessibility-guard/README.md
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions enterprise-dashboard-accessibility-guard/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -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
```
81 changes: 81 additions & 0 deletions enterprise-dashboard-accessibility-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 `
<g transform="translate(48 ${y})">
<rect width="1104" height="50" rx="6" fill="#f8fafc" stroke="#cbd5e1"/>
<circle cx="28" cy="25" r="11" fill="${color}"/>
<text x="58" y="21" font-size="18" font-family="Arial" fill="#0f172a">${escapeXml(packet.dashboardId)}</text>
<text x="58" y="39" font-size="13" font-family="Arial" fill="#475569">${escapeXml(packet.status)} | findings ${packet.findings.length} | digest ${packet.auditDigest.slice(0, 16)}</text>
</g>`;
}).join('');

return [
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="360" viewBox="0 0 1200 360">',
' <rect width="1200" height="360" fill="#e2e8f0"/>',
' <text x="48" y="52" font-size="31" font-family="Arial" font-weight="700" fill="#0f172a">Enterprise Dashboard Accessibility Guard</text>',
' <text x="48" y="80" font-size="16" font-family="Arial" fill="#334155">Institutional dashboards, exports, and webhook notices are gated before release.</text>',
rows,
'</svg>',
''
].join('\n');
}

function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
226 changes: 226 additions & 0 deletions enterprise-dashboard-accessibility-guard/index.js
Original file line number Diff line number Diff line change
@@ -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
};
Loading