diff --git a/collaborative-alt-text-export-guard/.gitignore b/collaborative-alt-text-export-guard/.gitignore new file mode 100644 index 00000000..2bf074d6 --- /dev/null +++ b/collaborative-alt-text-export-guard/.gitignore @@ -0,0 +1 @@ +reports/frames/ diff --git a/collaborative-alt-text-export-guard/README.md b/collaborative-alt-text-export-guard/README.md new file mode 100644 index 00000000..2ab2a0f4 --- /dev/null +++ b/collaborative-alt-text-export-guard/README.md @@ -0,0 +1,36 @@ +# Collaborative Alt Text Export Guard + +This module is a self-contained accessibility export-readiness slice for the SCIBASE real-time collaborative research editor bounty. + +It focuses on publication content accessibility, not editor control accessibility. Before a collaborative manuscript is exported, it checks whether figures and tables have current, non-private, scientifically useful alt text, long descriptions, table header metadata, and accessibility sign-off. + +## What It Checks + +- Missing or generic alt text for exportable figures and tables +- Alt text that only repeats the caption +- Complex visuals that need a long description or data summary +- Color-only encodings without non-color labels or patterns +- Table headers, scopes, and summaries for screen-reader navigation +- Stale alt text after collaborative edits or source-data changes +- Open blocking accessibility comments +- Alt text changes inside locked final-review sections without approval +- Leaks of private reviewer notes, local file paths, or protected data markers + +## Files + +- `index.js` exports the evaluator plus Markdown/SVG renderers. +- `sample-data.js` contains clean, revision-required, and hold-required synthetic packets. +- `test.js` covers the release decisions and high-risk findings. +- `demo.js` writes JSON, Markdown, and SVG reviewer artifacts under `reports/`. +- `make-demo-video.js` writes a short local `reports/demo.mp4` artifact with the three decisions. + +## Local Verification + +```sh +npm run check +npm test +npm run demo +npm run verify-video +``` + +No production credentials, private data, external APIs, or package installs are required. diff --git a/collaborative-alt-text-export-guard/demo.js b/collaborative-alt-text-export-guard/demo.js new file mode 100644 index 00000000..9f0a0ef0 --- /dev/null +++ b/collaborative-alt-text-export-guard/demo.js @@ -0,0 +1,38 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + evaluateAccessibleExportPacket, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, revisionPacket, holdPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const scenarios = [ + ["clean", cleanPacket], + ["revision", revisionPacket], + ["hold", holdPacket] +]; + +const manifest = scenarios.map(([name, packet]) => { + const result = evaluateAccessibleExportPacket(packet, { + now: "2026-06-01T10:15:00.000Z" + }); + fs.writeFileSync(path.join(reportsDir, `${name}-report.md`), renderMarkdownReport(result)); + fs.writeFileSync(path.join(reportsDir, `${name}-summary.svg`), renderSvgSummary(result)); + fs.writeFileSync(path.join(reportsDir, `${name}-result.json`), `${JSON.stringify(result, null, 2)}\n`); + return { + scenario: name, + decision: result.decision, + exportAllowed: result.exportAllowed, + findings: result.findings.length, + digest: result.digest + }; +}); + +fs.writeFileSync(path.join(reportsDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +console.log(JSON.stringify(manifest, null, 2)); diff --git a/collaborative-alt-text-export-guard/index.js b/collaborative-alt-text-export-guard/index.js new file mode 100644 index 00000000..478bd128 --- /dev/null +++ b/collaborative-alt-text-export-guard/index.js @@ -0,0 +1,486 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const SEVERITY_ORDER = ["critical", "high", "warning", "info"]; +const GENERIC_ALT_PATTERN = /\b(image|picture|photo|graph|chart|table|figure)\s+(of|showing)\b/i; +const PRIVATE_PATH_PATTERN = /\b(?:\/Users\/|C:\\Users\\|private\/|reviewer-only|blind-review|internal-note|phi:|patient-id)\b/i; +const REVIEWER_ID_PATTERN = /\b(?:reviewer\s*[a-z]?\s*#?\d+|dr\.\s+[a-z]+|anonymous\s+reviewer\s+[a-z]?)\b/i; +const COLOR_ONLY_PATTERN = /\b(red|green|blue|yellow|orange|purple|cyan|magenta)\s+(means|indicates|denotes|shows)\b/i; + +function evaluateAccessibleExportPacket(packet, options = {}) { + if (!isPlainObject(packet)) { + throw new TypeError("evaluateAccessibleExportPacket expects a packet object"); + } + + const minAltWords = options.minAltWords ?? 8; + const now = options.now ?? new Date().toISOString(); + const findings = []; + const document = isPlainObject(packet.document) ? packet.document : {}; + const artifacts = asArray(packet.artifacts); + const comments = asArray(packet.comments); + const approvals = asArray(packet.accessibilityApprovals); + const sections = new Map(asArray(packet.sections).map((section) => [String(section.id), section])); + + if (!document.id || !document.title) { + findings.push( + finding( + "PACKET_SCHEMA_MISSING_DOCUMENT", + "high", + "Accessible export packet is missing a document id or title.", + "Document metadata anchors the export audit and reviewer packet.", + "document", + "Include stable manuscript metadata before running the export guard." + ) + ); + } + + if (artifacts.length === 0) { + findings.push( + finding( + "PACKET_SCHEMA_MISSING_ARTIFACTS", + "high", + "Accessible export packet has no figures or tables to inspect.", + "The guard cannot prove content accessibility without export artifacts.", + "artifacts", + "Attach the exportable figure and table inventory." + ) + ); + } + + artifacts.forEach((artifact, index) => { + const path = `artifacts[${index}]`; + const id = String(artifact.id ?? ""); + const type = String(artifact.type ?? "artifact"); + const caption = String(artifact.caption ?? ""); + const altText = String(artifact.altText ?? ""); + const longDescription = String(artifact.longDescription ?? ""); + const section = sections.get(String(artifact.sectionId ?? "")); + const relatedComments = comments.filter((comment) => String(comment.artifactId) === id); + const artifactApprovals = approvals.filter((approval) => String(approval.artifactId) === id); + + if (!id) { + findings.push( + finding( + "ARTIFACT_MISSING_ID", + "high", + `A ${type} is missing a stable artifact id.`, + "Reviewer comments, locks, and accessibility approvals need a stable target.", + `${path}.id`, + "Assign a stable id before export." + ) + ); + } + + if (!section) { + findings.push( + finding( + "UNKNOWN_SECTION", + "high", + `Artifact ${id || index} points at an unknown section.`, + "Section-scoped locks and blinded-review visibility cannot be enforced.", + `${path}.sectionId`, + "Bind each artifact to a known manuscript section." + ) + ); + } + + if (artifact.exportable === false) { + return; + } + + if (!caption.trim()) { + findings.push( + finding( + "MISSING_CAPTION", + "high", + `Exportable ${type} ${id || index} has no caption.`, + "Captions are required for scientific navigation and cross-reference context.", + `${path}.caption`, + "Add a concise caption before export." + ) + ); + } + + if (!altText.trim()) { + findings.push( + finding( + "MISSING_ALT_TEXT", + "high", + `Exportable ${type} ${id || index} has no alt text.`, + "Screen-reader users cannot understand the artifact in the exported manuscript.", + `${path}.altText`, + "Add outcome-focused alt text that summarizes the scientific takeaway." + ) + ); + } else { + const words = countWords(altText); + if (words < minAltWords || GENERIC_ALT_PATTERN.test(altText)) { + findings.push( + finding( + "THIN_OR_GENERIC_ALT_TEXT", + "warning", + `Alt text for ${id || type} is too thin or generic.`, + `Detected ${words} words and ${GENERIC_ALT_PATTERN.test(altText) ? "generic visual phrasing" : "too little detail"}.`, + `${path}.altText`, + "Describe the key variables, trend, and scientific conclusion without relying on sight." + ) + ); + } + + if (normalize(altText) === normalize(caption)) { + findings.push( + finding( + "ALT_TEXT_DUPLICATES_CAPTION", + "warning", + `Alt text for ${id || type} duplicates the caption.`, + "Captions and alt text serve different accessibility needs.", + `${path}.altText`, + "Keep the caption bibliographic and make the alt text describe the non-visual takeaway." + ) + ); + } + + collectSensitiveLeaks(altText).forEach((leak) => { + findings.push( + finding( + leak.code, + "critical", + `Alt text for ${id || type} exposes ${leak.label}.`, + "Accessible exports must not leak blinded-review notes, private paths, or protected data.", + `${path}.altText`, + "Rewrite the alt text using public manuscript language only." + ) + ); + }); + } + + if (isComplexVisual(artifact) && !longDescription.trim() && !artifact.dataSummary) { + findings.push( + finding( + "COMPLEX_VISUAL_NEEDS_LONG_DESCRIPTION", + "high", + `Complex ${type} ${id || index} lacks a long description or data summary.`, + "Dense scientific visualizations need a structured non-visual explanation.", + `${path}.longDescription`, + "Add a long description or machine-readable data summary for complex figures." + ) + ); + } + + const usesColorChannel = asArray(artifact.visualChannels) + .map((channel) => String(channel).toLowerCase()) + .includes("color"); + if ((COLOR_ONLY_PATTERN.test(`${altText} ${caption}`) || usesColorChannel) && !artifact.nonColorEncoding) { + findings.push( + finding( + "COLOR_ONLY_ENCODING", + "high", + `Artifact ${id || index} relies on color-only interpretation.`, + "Color-only distinctions are not accessible to all readers or output formats.", + path, + "Add shape, pattern, label, or textual encoding alongside color." + ) + ); + } + + if (type === "table") { + evaluateTableArtifact(artifact, path, findings); + } + + if (artifact.sourceHash && artifact.altTextSourceHash && artifact.sourceHash !== artifact.altTextSourceHash) { + findings.push( + finding( + "ALT_TEXT_STALE_SOURCE_HASH", + "high", + `Alt text for ${id || type} was reviewed against an older source artifact.`, + `Expected source hash ${artifact.sourceHash}; found ${artifact.altTextSourceHash}.`, + `${path}.altTextSourceHash`, + "Regenerate or re-approve alt text after the source data or rendering changes." + ) + ); + } + + if (artifact.updatedAt && artifact.altTextReviewedAt && compareIso(artifact.updatedAt, artifact.altTextReviewedAt) > 0) { + findings.push( + finding( + "ALT_TEXT_REVIEW_STALE", + "high", + `Alt text for ${id || type} predates the latest artifact edit.`, + `Artifact updated at ${artifact.updatedAt}; alt text reviewed at ${artifact.altTextReviewedAt}.`, + `${path}.altTextReviewedAt`, + "Re-review alt text after collaborative edits." + ) + ); + } + + relatedComments + .filter((comment) => comment.status !== "resolved" && comment.blocking === true) + .forEach((comment) => { + findings.push( + finding( + "UNRESOLVED_ACCESSIBILITY_COMMENT", + "high", + `Blocking accessibility comment remains open on ${id || type}.`, + String(comment.summary ?? "An unresolved comment blocks accessible export."), + `comments.${comment.id ?? id}`, + "Resolve or explicitly waive blocking accessibility comments before export." + ) + ); + }); + + if (section && section.locked === true && artifact.altTextChangedInLock === true) { + const hasLockApproval = artifactApprovals.some((approval) => approval.scope === "locked-section-alt-text" && approval.status === "approved"); + if (!hasLockApproval) { + findings.push( + finding( + "LOCKED_SECTION_ALT_TEXT_UNAPPROVED", + "high", + `Alt text changed inside locked section ${section.id} without approval.`, + "Final review locks should protect export wording for scientific artifacts.", + `${path}.altTextChangedInLock`, + "Collect a locked-section accessibility approval or reopen the section for review." + ) + ); + } + } + + if (!artifactApprovals.some((approval) => approval.scope === "accessible-export" && approval.status === "approved")) { + findings.push( + finding( + "MISSING_ACCESSIBLE_EXPORT_APPROVAL", + "warning", + `Artifact ${id || index} has no accessible-export approval.`, + "A reviewer sign-off makes export readiness auditable.", + "accessibilityApprovals", + "Record accessibility review approval after alt text and descriptions are current." + ) + ); + } + }); + + const summary = summarizeFindings(findings); + const decision = decide(summary); + + return { + guard: "collaborative-alt-text-export-guard", + evaluatedAt: now, + document: { + id: document.id ?? null, + title: document.title ?? null, + exportFormat: document.exportFormat ?? "unknown" + }, + artifactCount: artifacts.length, + decision, + exportAllowed: decision === "export_ready", + summary, + findings, + digest: digest({ document, artifacts, comments, approvals }) + }; +} + +function evaluateTableArtifact(artifact, path, findings) { + const columns = asArray(artifact.columns); + if (columns.length === 0) { + findings.push( + finding( + "TABLE_MISSING_COLUMNS", + "high", + `Table ${artifact.id ?? "(unknown)"} has no column metadata.`, + "Accessible table exports need headers and cell relationships.", + `${path}.columns`, + "Attach column ids, labels, and header scope metadata." + ) + ); + return; + } + + columns.forEach((column, index) => { + if (!column.header || !column.scope) { + findings.push( + finding( + "TABLE_HEADER_SCOPE_MISSING", + "high", + `Table ${artifact.id ?? "(unknown)"} column ${index + 1} lacks header or scope metadata.`, + "Screen readers depend on explicit header scope for scientific tables.", + `${path}.columns[${index}]`, + "Add header text and row/column scope metadata." + ) + ); + } + }); + + if (!artifact.tableSummary) { + findings.push( + finding( + "TABLE_SUMMARY_MISSING", + "warning", + `Table ${artifact.id ?? "(unknown)"} has no concise table summary.`, + "A summary helps readers understand table purpose before navigating cells.", + `${path}.tableSummary`, + "Add a short table summary that names rows, columns, units, and main comparison." + ) + ); + } +} + +function isComplexVisual(artifact) { + if (artifact.requiresLongDescription === true) return true; + const channels = asArray(artifact.visualChannels); + const panels = Number(artifact.panelCount ?? 1); + const type = String(artifact.visualization ?? ""); + return panels > 1 || channels.length > 2 || /heatmap|network|multi[-\s]?panel|microscopy|flow|survival/i.test(type); +} + +function collectSensitiveLeaks(text) { + const leaks = []; + if (PRIVATE_PATH_PATTERN.test(text)) { + leaks.push({ code: "PRIVATE_PATH_OR_DATA_LEAK", label: "private path or protected data marker" }); + } + if (REVIEWER_ID_PATTERN.test(text)) { + leaks.push({ code: "REVIEWER_IDENTITY_LEAK", label: "reviewer identity material" }); + } + return leaks; +} + +function renderMarkdownReport(result) { + const lines = [ + "# Collaborative Alt Text Export Guard", + "", + `- Document: ${result.document.title ?? "(untitled)"}`, + `- Decision: ${result.decision}`, + `- Export allowed: ${result.exportAllowed ? "yes" : "no"}`, + `- Findings: ${result.findings.length}`, + `- Digest: ${result.digest}`, + "", + "## Summary", + "", + `- Critical: ${result.summary.critical}`, + `- High: ${result.summary.high}`, + `- Warning: ${result.summary.warning}`, + `- Info: ${result.summary.info}`, + "", + "## Findings" + ]; + + if (result.findings.length === 0) { + lines.push("", "No accessibility export blockers detected."); + } else { + result.findings.forEach((item, index) => { + lines.push( + "", + `### ${index + 1}. ${item.code}`, + "", + `- Severity: ${item.severity}`, + `- Path: ${item.path}`, + `- Message: ${item.message}`, + `- Evidence: ${item.evidence}`, + `- Remediation: ${item.remediation}` + ); + }); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(result) { + const width = 960; + const height = 540; + const color = result.decision === "export_ready" ? "#16794c" : result.decision === "revise_before_export" ? "#a15c00" : "#a11b32"; + const title = escapeXml(result.document.title ?? "Untitled manuscript"); + const decision = escapeXml(result.decision); + const digestShort = escapeXml(result.digest.slice(0, 16)); + + return ` + + Collaborative alt text export guard summary + Decision ${decision} for ${title} + + + Accessible Export Guard + ${title} + + ${decision} + + Critical: ${result.summary.critical} + High: ${result.summary.high} + Warning: ${result.summary.warning} + Info: ${result.summary.info} + + Digest: ${digestShort} + +`; +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function decide(summary) { + if (summary.critical > 0) return "hold_accessible_export"; + if (summary.high > 0) return "revise_before_export"; + return "export_ready"; +} + +function summarizeFindings(findings) { + const summary = { critical: 0, high: 0, warning: 0, info: 0 }; + findings.forEach((item) => { + summary[item.severity] += 1; + }); + return summary; +} + +function finding(code, severity, message, evidence, path, remediation) { + if (!SEVERITY_ORDER.includes(severity)) { + throw new Error(`Unknown severity: ${severity}`); + } + return { code, severity, message, evidence, path, remediation }; +} + +function countWords(text) { + return String(text).trim().split(/\s+/).filter(Boolean).length; +} + +function normalize(text) { + return String(text).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function compareIso(left, right) { + return new Date(left).getTime() - new Date(right).getTime(); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (isPlainObject(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +module.exports = { + evaluateAccessibleExportPacket, + renderMarkdownReport, + renderSvgSummary, + digest +}; diff --git a/collaborative-alt-text-export-guard/make-demo-video.js b/collaborative-alt-text-export-guard/make-demo-video.js new file mode 100644 index 00000000..0628afec --- /dev/null +++ b/collaborative-alt-text-export-guard/make-demo-video.js @@ -0,0 +1,127 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WIDTH = 960; +const HEIGHT = 540; +const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"], + X: ["10001", "01010", "00100", "00100", "00100", "01010", "10001"], + Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"] +}; + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +for (const file of fs.readdirSync(framesDir)) { + fs.unlinkSync(path.join(framesDir, file)); +} + +const slides = [ + { label: "READY", color: [22, 121, 76], fill: 0.95 }, + { label: "REVISE", color: [161, 92, 0], fill: 0.68 }, + { label: "HOLD", color: [161, 27, 50], fill: 0.36 } +]; + +let frameIndex = 0; +for (const slide of slides) { + for (let i = 0; i < 8; i += 1) { + const progress = (i + 1) / 8; + const buffer = createFrame(slide, progress); + fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer); + frameIndex += 1; + } +} + +const output = path.join(reportsDir, "demo.mp4"); +execFileSync( + "ffmpeg", + [ + "-y", + "-framerate", + "8", + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output + ], + { stdio: "ignore" } +); + +const stats = fs.statSync(output); +console.log(`Wrote ${output} (${stats.size} bytes)`); + +function createFrame(slide, progress) { + const pixels = Buffer.alloc(WIDTH * HEIGHT * 3); + fillRect(pixels, 0, 0, WIDTH, HEIGHT, [17, 24, 39]); + fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]); + fillRect(pixels, 80, 190, 800, 88, [226, 232, 240]); + fillRect(pixels, 80, 190, Math.round(800 * slide.fill * progress), 88, slide.color); + fillRect(pixels, 80, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 344, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 608, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 80, 322, 40, 42, [161, 27, 50]); + fillRect(pixels, 344, 322, 120, 42, [161, 92, 0]); + fillRect(pixels, 608, 322, 200, 42, [22, 121, 76]); + drawText(pixels, "ALT TEXT EXPORT GUARD", 82, 104, 5, [17, 24, 39]); + drawText(pixels, slide.label, 108, 214, 7, [255, 255, 255]); + drawText(pixels, "ACCESSIBLE EXPORT", 82, 414, 4, [51, 65, 85]); + return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]); +} + +function fillRect(pixels, x, y, width, height, color) { + const x2 = Math.min(WIDTH, x + width); + const y2 = Math.min(HEIGHT, y + height); + for (let row = Math.max(0, y); row < y2; row += 1) { + for (let col = Math.max(0, x); col < x2; col += 1) { + const offset = (row * WIDTH + col) * 3; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + } + } +} + +function drawText(pixels, text, x, y, scale, color) { + let cursor = x; + for (const rawChar of text) { + const char = rawChar.toUpperCase(); + if (char === " ") { + cursor += 4 * scale; + continue; + } + const glyph = FONT[char]; + if (!glyph) { + cursor += 6 * scale; + continue; + } + glyph.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < row.length; colIndex += 1) { + if (row[colIndex] === "1") { + fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color); + } + } + }); + cursor += 6 * scale; + } +} diff --git a/collaborative-alt-text-export-guard/package.json b/collaborative-alt-text-export-guard/package.json new file mode 100644 index 00000000..1259acd8 --- /dev/null +++ b/collaborative-alt-text-export-guard/package.json @@ -0,0 +1,21 @@ +{ + "name": "collaborative-alt-text-export-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic accessibility export guard for collaborative scientific manuscripts.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js && node make-demo-video.js", + "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4" + }, + "keywords": [ + "scientific-editor", + "accessibility", + "alt-text", + "collaboration", + "synthetic" + ], + "license": "MIT" +} diff --git a/collaborative-alt-text-export-guard/reports/clean-report.md b/collaborative-alt-text-export-guard/reports/clean-report.md new file mode 100644 index 00000000..9dc1eda7 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/clean-report.md @@ -0,0 +1,18 @@ +# Collaborative Alt Text Export Guard + +- Document: Collaborative cardiometabolic atlas draft +- Decision: export_ready +- Export allowed: yes +- Findings: 0 +- Digest: be08d841061e2814c94ee6109490d10134a94cf4a1968fc0c3691eb15af4b16c + +## Summary + +- Critical: 0 +- High: 0 +- Warning: 0 +- Info: 0 + +## Findings + +No accessibility export blockers detected. diff --git a/collaborative-alt-text-export-guard/reports/clean-result.json b/collaborative-alt-text-export-guard/reports/clean-result.json new file mode 100644 index 00000000..9d56fef4 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/clean-result.json @@ -0,0 +1,20 @@ +{ + "guard": "collaborative-alt-text-export-guard", + "evaluatedAt": "2026-06-01T10:15:00.000Z", + "document": { + "id": "manuscript-cardiometabolic-001", + "title": "Collaborative cardiometabolic atlas draft", + "exportFormat": "pdf-html" + }, + "artifactCount": 2, + "decision": "export_ready", + "exportAllowed": true, + "summary": { + "critical": 0, + "high": 0, + "warning": 0, + "info": 0 + }, + "findings": [], + "digest": "be08d841061e2814c94ee6109490d10134a94cf4a1968fc0c3691eb15af4b16c" +} diff --git a/collaborative-alt-text-export-guard/reports/clean-summary.svg b/collaborative-alt-text-export-guard/reports/clean-summary.svg new file mode 100644 index 00000000..549bdeba --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/clean-summary.svg @@ -0,0 +1,18 @@ + + + Collaborative alt text export guard summary + Decision export_ready for Collaborative cardiometabolic atlas draft + + + Accessible Export Guard + Collaborative cardiometabolic atlas draft + + export_ready + + Critical: 0 + High: 0 + Warning: 0 + Info: 0 + + Digest: be08d841061e2814 + diff --git a/collaborative-alt-text-export-guard/reports/demo.mp4 b/collaborative-alt-text-export-guard/reports/demo.mp4 new file mode 100644 index 00000000..6b3738d7 Binary files /dev/null and b/collaborative-alt-text-export-guard/reports/demo.mp4 differ diff --git a/collaborative-alt-text-export-guard/reports/hold-report.md b/collaborative-alt-text-export-guard/reports/hold-report.md new file mode 100644 index 00000000..ae9c30e3 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/hold-report.md @@ -0,0 +1,40 @@ +# Collaborative Alt Text Export Guard + +- Document: Blinded neuroimaging collaboration +- Decision: hold_accessible_export +- Export allowed: no +- Findings: 3 +- Digest: 76c61e090701f62d8a84fb15d6cb8120d1e3183c47c83c62f8560c6a6e16f00f + +## Summary + +- Critical: 2 +- High: 0 +- Warning: 1 +- Info: 0 + +## Findings + +### 1. THIN_OR_GENERIC_ALT_TEXT + +- Severity: warning +- Path: artifacts[0].altText +- Message: Alt text for fig-3 is too thin or generic. +- Evidence: Detected 17 words and generic visual phrasing. +- Remediation: Describe the key variables, trend, and scientific conclusion without relying on sight. + +### 2. PRIVATE_PATH_OR_DATA_LEAK + +- Severity: critical +- Path: artifacts[0].altText +- Message: Alt text for fig-3 exposes private path or protected data marker. +- Evidence: Accessible exports must not leak blinded-review notes, private paths, or protected data. +- Remediation: Rewrite the alt text using public manuscript language only. + +### 3. REVIEWER_IDENTITY_LEAK + +- Severity: critical +- Path: artifacts[0].altText +- Message: Alt text for fig-3 exposes reviewer identity material. +- Evidence: Accessible exports must not leak blinded-review notes, private paths, or protected data. +- Remediation: Rewrite the alt text using public manuscript language only. diff --git a/collaborative-alt-text-export-guard/reports/hold-result.json b/collaborative-alt-text-export-guard/reports/hold-result.json new file mode 100644 index 00000000..a4413b39 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/hold-result.json @@ -0,0 +1,45 @@ +{ + "guard": "collaborative-alt-text-export-guard", + "evaluatedAt": "2026-06-01T10:15:00.000Z", + "document": { + "id": "manuscript-neuro-003", + "title": "Blinded neuroimaging collaboration", + "exportFormat": "html" + }, + "artifactCount": 1, + "decision": "hold_accessible_export", + "exportAllowed": false, + "summary": { + "critical": 2, + "high": 0, + "warning": 1, + "info": 0 + }, + "findings": [ + { + "code": "THIN_OR_GENERIC_ALT_TEXT", + "severity": "warning", + "message": "Alt text for fig-3 is too thin or generic.", + "evidence": "Detected 17 words and generic visual phrasing.", + "path": "artifacts[0].altText", + "remediation": "Describe the key variables, trend, and scientific conclusion without relying on sight." + }, + { + "code": "PRIVATE_PATH_OR_DATA_LEAK", + "severity": "critical", + "message": "Alt text for fig-3 exposes private path or protected data marker.", + "evidence": "Accessible exports must not leak blinded-review notes, private paths, or protected data.", + "path": "artifacts[0].altText", + "remediation": "Rewrite the alt text using public manuscript language only." + }, + { + "code": "REVIEWER_IDENTITY_LEAK", + "severity": "critical", + "message": "Alt text for fig-3 exposes reviewer identity material.", + "evidence": "Accessible exports must not leak blinded-review notes, private paths, or protected data.", + "path": "artifacts[0].altText", + "remediation": "Rewrite the alt text using public manuscript language only." + } + ], + "digest": "76c61e090701f62d8a84fb15d6cb8120d1e3183c47c83c62f8560c6a6e16f00f" +} diff --git a/collaborative-alt-text-export-guard/reports/hold-summary.svg b/collaborative-alt-text-export-guard/reports/hold-summary.svg new file mode 100644 index 00000000..b71a8373 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/hold-summary.svg @@ -0,0 +1,18 @@ + + + Collaborative alt text export guard summary + Decision hold_accessible_export for Blinded neuroimaging collaboration + + + Accessible Export Guard + Blinded neuroimaging collaboration + + hold_accessible_export + + Critical: 2 + High: 0 + Warning: 1 + Info: 0 + + Digest: 76c61e090701f62d + diff --git a/collaborative-alt-text-export-guard/reports/manifest.json b/collaborative-alt-text-export-guard/reports/manifest.json new file mode 100644 index 00000000..100fd1e7 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/manifest.json @@ -0,0 +1,23 @@ +[ + { + "scenario": "clean", + "decision": "export_ready", + "exportAllowed": true, + "findings": 0, + "digest": "be08d841061e2814c94ee6109490d10134a94cf4a1968fc0c3691eb15af4b16c" + }, + { + "scenario": "revision", + "decision": "revise_before_export", + "exportAllowed": false, + "findings": 12, + "digest": "607b93c7dd47480c767a1c1c82689037a95376fdeb97edea7a45d46941ff1fb3" + }, + { + "scenario": "hold", + "decision": "hold_accessible_export", + "exportAllowed": false, + "findings": 3, + "digest": "76c61e090701f62d8a84fb15d6cb8120d1e3183c47c83c62f8560c6a6e16f00f" + } +] diff --git a/collaborative-alt-text-export-guard/reports/revision-report.md b/collaborative-alt-text-export-guard/reports/revision-report.md new file mode 100644 index 00000000..f909565b --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/revision-report.md @@ -0,0 +1,112 @@ +# Collaborative Alt Text Export Guard + +- Document: Shared wetland recovery manuscript +- Decision: revise_before_export +- Export allowed: no +- Findings: 12 +- Digest: 607b93c7dd47480c767a1c1c82689037a95376fdeb97edea7a45d46941ff1fb3 + +## Summary + +- Critical: 0 +- High: 7 +- Warning: 5 +- Info: 0 + +## Findings + +### 1. THIN_OR_GENERIC_ALT_TEXT + +- Severity: warning +- Path: artifacts[0].altText +- Message: Alt text for fig-2 is too thin or generic. +- Evidence: Detected 4 words and generic visual phrasing. +- Remediation: Describe the key variables, trend, and scientific conclusion without relying on sight. + +### 2. COMPLEX_VISUAL_NEEDS_LONG_DESCRIPTION + +- Severity: high +- Path: artifacts[0].longDescription +- Message: Complex figure fig-2 lacks a long description or data summary. +- Evidence: Dense scientific visualizations need a structured non-visual explanation. +- Remediation: Add a long description or machine-readable data summary for complex figures. + +### 3. COLOR_ONLY_ENCODING + +- Severity: high +- Path: artifacts[0] +- Message: Artifact fig-2 relies on color-only interpretation. +- Evidence: Color-only distinctions are not accessible to all readers or output formats. +- Remediation: Add shape, pattern, label, or textual encoding alongside color. + +### 4. ALT_TEXT_STALE_SOURCE_HASH + +- Severity: high +- Path: artifacts[0].altTextSourceHash +- Message: Alt text for fig-2 was reviewed against an older source artifact. +- Evidence: Expected source hash sha256:map-current; found sha256:map-old. +- Remediation: Regenerate or re-approve alt text after the source data or rendering changes. + +### 5. ALT_TEXT_REVIEW_STALE + +- Severity: high +- Path: artifacts[0].altTextReviewedAt +- Message: Alt text for fig-2 predates the latest artifact edit. +- Evidence: Artifact updated at 2026-05-31T18:00:00.000Z; alt text reviewed at 2026-05-31T17:00:00.000Z. +- Remediation: Re-review alt text after collaborative edits. + +### 6. UNRESOLVED_ACCESSIBILITY_COMMENT + +- Severity: high +- Path: comments.c-2 +- Message: Blocking accessibility comment remains open on fig-2. +- Evidence: Alt text must describe the nutrient gradient and not just the visual map. +- Remediation: Resolve or explicitly waive blocking accessibility comments before export. + +### 7. LOCKED_SECTION_ALT_TEXT_UNAPPROVED + +- Severity: high +- Path: artifacts[0].altTextChangedInLock +- Message: Alt text changed inside locked section results without approval. +- Evidence: Final review locks should protect export wording for scientific artifacts. +- Remediation: Collect a locked-section accessibility approval or reopen the section for review. + +### 8. MISSING_ACCESSIBLE_EXPORT_APPROVAL + +- Severity: warning +- Path: accessibilityApprovals +- Message: Artifact fig-2 has no accessible-export approval. +- Evidence: A reviewer sign-off makes export readiness auditable. +- Remediation: Record accessibility review approval after alt text and descriptions are current. + +### 9. THIN_OR_GENERIC_ALT_TEXT + +- Severity: warning +- Path: artifacts[1].altText +- Message: Alt text for tbl-2 is too thin or generic. +- Evidence: Detected 7 words and too little detail. +- Remediation: Describe the key variables, trend, and scientific conclusion without relying on sight. + +### 10. TABLE_HEADER_SCOPE_MISSING + +- Severity: high +- Path: artifacts[1].columns[0] +- Message: Table tbl-2 column 1 lacks header or scope metadata. +- Evidence: Screen readers depend on explicit header scope for scientific tables. +- Remediation: Add header text and row/column scope metadata. + +### 11. TABLE_SUMMARY_MISSING + +- Severity: warning +- Path: artifacts[1].tableSummary +- Message: Table tbl-2 has no concise table summary. +- Evidence: A summary helps readers understand table purpose before navigating cells. +- Remediation: Add a short table summary that names rows, columns, units, and main comparison. + +### 12. MISSING_ACCESSIBLE_EXPORT_APPROVAL + +- Severity: warning +- Path: accessibilityApprovals +- Message: Artifact tbl-2 has no accessible-export approval. +- Evidence: A reviewer sign-off makes export readiness auditable. +- Remediation: Record accessibility review approval after alt text and descriptions are current. diff --git a/collaborative-alt-text-export-guard/reports/revision-result.json b/collaborative-alt-text-export-guard/reports/revision-result.json new file mode 100644 index 00000000..490888e7 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/revision-result.json @@ -0,0 +1,117 @@ +{ + "guard": "collaborative-alt-text-export-guard", + "evaluatedAt": "2026-06-01T10:15:00.000Z", + "document": { + "id": "manuscript-ecology-002", + "title": "Shared wetland recovery manuscript", + "exportFormat": "docx" + }, + "artifactCount": 2, + "decision": "revise_before_export", + "exportAllowed": false, + "summary": { + "critical": 0, + "high": 7, + "warning": 5, + "info": 0 + }, + "findings": [ + { + "code": "THIN_OR_GENERIC_ALT_TEXT", + "severity": "warning", + "message": "Alt text for fig-2 is too thin or generic.", + "evidence": "Detected 4 words and generic visual phrasing.", + "path": "artifacts[0].altText", + "remediation": "Describe the key variables, trend, and scientific conclusion without relying on sight." + }, + { + "code": "COMPLEX_VISUAL_NEEDS_LONG_DESCRIPTION", + "severity": "high", + "message": "Complex figure fig-2 lacks a long description or data summary.", + "evidence": "Dense scientific visualizations need a structured non-visual explanation.", + "path": "artifacts[0].longDescription", + "remediation": "Add a long description or machine-readable data summary for complex figures." + }, + { + "code": "COLOR_ONLY_ENCODING", + "severity": "high", + "message": "Artifact fig-2 relies on color-only interpretation.", + "evidence": "Color-only distinctions are not accessible to all readers or output formats.", + "path": "artifacts[0]", + "remediation": "Add shape, pattern, label, or textual encoding alongside color." + }, + { + "code": "ALT_TEXT_STALE_SOURCE_HASH", + "severity": "high", + "message": "Alt text for fig-2 was reviewed against an older source artifact.", + "evidence": "Expected source hash sha256:map-current; found sha256:map-old.", + "path": "artifacts[0].altTextSourceHash", + "remediation": "Regenerate or re-approve alt text after the source data or rendering changes." + }, + { + "code": "ALT_TEXT_REVIEW_STALE", + "severity": "high", + "message": "Alt text for fig-2 predates the latest artifact edit.", + "evidence": "Artifact updated at 2026-05-31T18:00:00.000Z; alt text reviewed at 2026-05-31T17:00:00.000Z.", + "path": "artifacts[0].altTextReviewedAt", + "remediation": "Re-review alt text after collaborative edits." + }, + { + "code": "UNRESOLVED_ACCESSIBILITY_COMMENT", + "severity": "high", + "message": "Blocking accessibility comment remains open on fig-2.", + "evidence": "Alt text must describe the nutrient gradient and not just the visual map.", + "path": "comments.c-2", + "remediation": "Resolve or explicitly waive blocking accessibility comments before export." + }, + { + "code": "LOCKED_SECTION_ALT_TEXT_UNAPPROVED", + "severity": "high", + "message": "Alt text changed inside locked section results without approval.", + "evidence": "Final review locks should protect export wording for scientific artifacts.", + "path": "artifacts[0].altTextChangedInLock", + "remediation": "Collect a locked-section accessibility approval or reopen the section for review." + }, + { + "code": "MISSING_ACCESSIBLE_EXPORT_APPROVAL", + "severity": "warning", + "message": "Artifact fig-2 has no accessible-export approval.", + "evidence": "A reviewer sign-off makes export readiness auditable.", + "path": "accessibilityApprovals", + "remediation": "Record accessibility review approval after alt text and descriptions are current." + }, + { + "code": "THIN_OR_GENERIC_ALT_TEXT", + "severity": "warning", + "message": "Alt text for tbl-2 is too thin or generic.", + "evidence": "Detected 7 words and too little detail.", + "path": "artifacts[1].altText", + "remediation": "Describe the key variables, trend, and scientific conclusion without relying on sight." + }, + { + "code": "TABLE_HEADER_SCOPE_MISSING", + "severity": "high", + "message": "Table tbl-2 column 1 lacks header or scope metadata.", + "evidence": "Screen readers depend on explicit header scope for scientific tables.", + "path": "artifacts[1].columns[0]", + "remediation": "Add header text and row/column scope metadata." + }, + { + "code": "TABLE_SUMMARY_MISSING", + "severity": "warning", + "message": "Table tbl-2 has no concise table summary.", + "evidence": "A summary helps readers understand table purpose before navigating cells.", + "path": "artifacts[1].tableSummary", + "remediation": "Add a short table summary that names rows, columns, units, and main comparison." + }, + { + "code": "MISSING_ACCESSIBLE_EXPORT_APPROVAL", + "severity": "warning", + "message": "Artifact tbl-2 has no accessible-export approval.", + "evidence": "A reviewer sign-off makes export readiness auditable.", + "path": "accessibilityApprovals", + "remediation": "Record accessibility review approval after alt text and descriptions are current." + } + ], + "digest": "607b93c7dd47480c767a1c1c82689037a95376fdeb97edea7a45d46941ff1fb3" +} diff --git a/collaborative-alt-text-export-guard/reports/revision-summary.svg b/collaborative-alt-text-export-guard/reports/revision-summary.svg new file mode 100644 index 00000000..5be186a0 --- /dev/null +++ b/collaborative-alt-text-export-guard/reports/revision-summary.svg @@ -0,0 +1,18 @@ + + + Collaborative alt text export guard summary + Decision revise_before_export for Shared wetland recovery manuscript + + + Accessible Export Guard + Shared wetland recovery manuscript + + revise_before_export + + Critical: 0 + High: 7 + Warning: 5 + Info: 0 + + Digest: 607b93c7dd47480c + diff --git a/collaborative-alt-text-export-guard/sample-data.js b/collaborative-alt-text-export-guard/sample-data.js new file mode 100644 index 00000000..b565f02f --- /dev/null +++ b/collaborative-alt-text-export-guard/sample-data.js @@ -0,0 +1,150 @@ +"use strict"; + +const cleanPacket = { + document: { + id: "manuscript-cardiometabolic-001", + title: "Collaborative cardiometabolic atlas draft", + exportFormat: "pdf-html" + }, + sections: [ + { id: "results", title: "Results", locked: true }, + { id: "supplement", title: "Supplementary tables", locked: false } + ], + artifacts: [ + { + id: "fig-1", + type: "figure", + sectionId: "results", + caption: "Figure 1. Multimodal cardiometabolic risk clusters across three cohorts.", + altText: + "Three cohort panels show the same upward risk gradient from cluster A to C, with cluster C carrying the highest inflammatory marker load.", + longDescription: + "Each panel contains three clusters ordered by risk score. Cluster C is consistently highest for CRP and fasting glucose, while cluster A remains lowest across cohorts.", + visualization: "multi-panel scatter", + visualChannels: ["position", "shape", "color"], + nonColorEncoding: true, + sourceHash: "sha256:figure-one-current", + altTextSourceHash: "sha256:figure-one-current", + updatedAt: "2026-05-31T15:00:00.000Z", + altTextReviewedAt: "2026-05-31T16:00:00.000Z", + altTextChangedInLock: true + }, + { + id: "tbl-1", + type: "table", + sectionId: "supplement", + caption: "Table 1. Cohort sample counts and assay coverage.", + altText: + "The table compares sample counts, assay coverage, and missingness rates, showing cohort B has the broadest assay coverage and lowest missingness.", + tableSummary: + "Rows are cohorts and columns summarize sample count, proteomics coverage, metabolomics coverage, and missingness percentage.", + columns: [ + { id: "cohort", header: "Cohort", scope: "row" }, + { id: "n", header: "Samples", scope: "col" }, + { id: "proteomics", header: "Proteomics coverage", scope: "col" }, + { id: "missingness", header: "Missingness", scope: "col" } + ], + sourceHash: "sha256:table-one-current", + altTextSourceHash: "sha256:table-one-current", + updatedAt: "2026-05-31T15:15:00.000Z", + altTextReviewedAt: "2026-05-31T16:00:00.000Z" + } + ], + comments: [ + { + id: "c-1", + artifactId: "fig-1", + status: "resolved", + blocking: true, + summary: "Alt text now names the cross-cohort trend." + } + ], + accessibilityApprovals: [ + { artifactId: "fig-1", scope: "accessible-export", status: "approved" }, + { artifactId: "fig-1", scope: "locked-section-alt-text", status: "approved" }, + { artifactId: "tbl-1", scope: "accessible-export", status: "approved" } + ] +}; + +const revisionPacket = { + document: { + id: "manuscript-ecology-002", + title: "Shared wetland recovery manuscript", + exportFormat: "docx" + }, + sections: [{ id: "results", title: "Results", locked: true }], + artifacts: [ + { + id: "fig-2", + type: "figure", + sectionId: "results", + caption: "Figure 2. Nutrient recovery map after restoration.", + altText: "Figure showing a map.", + visualization: "heatmap", + visualChannels: ["position", "color", "intensity"], + nonColorEncoding: false, + sourceHash: "sha256:map-current", + altTextSourceHash: "sha256:map-old", + updatedAt: "2026-05-31T18:00:00.000Z", + altTextReviewedAt: "2026-05-31T17:00:00.000Z", + altTextChangedInLock: true + }, + { + id: "tbl-2", + type: "table", + sectionId: "results", + caption: "Table 2. Sensor calibration ranges.", + altText: "Sensor calibration ranges by station and month.", + columns: [{ id: "station", header: "Station" }], + sourceHash: "sha256:table-two", + altTextSourceHash: "sha256:table-two", + updatedAt: "2026-05-31T16:00:00.000Z", + altTextReviewedAt: "2026-05-31T16:30:00.000Z" + } + ], + comments: [ + { + id: "c-2", + artifactId: "fig-2", + status: "open", + blocking: true, + summary: "Alt text must describe the nutrient gradient and not just the visual map." + } + ], + accessibilityApprovals: [] +}; + +const holdPacket = { + document: { + id: "manuscript-neuro-003", + title: "Blinded neuroimaging collaboration", + exportFormat: "html" + }, + sections: [{ id: "blind-results", title: "Blind review results", locked: false }], + artifacts: [ + { + id: "fig-3", + type: "figure", + sectionId: "blind-results", + caption: "Figure 3. Functional connectivity change after protocol revision.", + altText: + "Dr. Smith noted this image of cohort changes in /Users/reviewer/private/holdout, including patient-id 8842 from the reviewer-only packet.", + longDescription: "Reviewer-only note should not leave the blinded workspace.", + visualization: "network", + visualChannels: ["position", "color", "edgeWidth"], + nonColorEncoding: true, + sourceHash: "sha256:network-current", + altTextSourceHash: "sha256:network-current", + updatedAt: "2026-05-31T15:00:00.000Z", + altTextReviewedAt: "2026-05-31T15:10:00.000Z" + } + ], + comments: [], + accessibilityApprovals: [{ artifactId: "fig-3", scope: "accessible-export", status: "approved" }] +}; + +module.exports = { + cleanPacket, + revisionPacket, + holdPacket +}; diff --git a/collaborative-alt-text-export-guard/test.js b/collaborative-alt-text-export-guard/test.js new file mode 100644 index 00000000..202af133 --- /dev/null +++ b/collaborative-alt-text-export-guard/test.js @@ -0,0 +1,61 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + evaluateAccessibleExportPacket, + renderMarkdownReport, + renderSvgSummary, + digest +} = require("./index"); +const { cleanPacket, revisionPacket, holdPacket } = require("./sample-data"); + +const fixedNow = "2026-06-01T10:15:00.000Z"; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function codes(result) { + return new Set(result.findings.map((finding) => finding.code)); +} + +const cleanBefore = clone(cleanPacket); +const cleanResult = evaluateAccessibleExportPacket(cleanPacket, { now: fixedNow }); +assert.equal(cleanResult.decision, "export_ready"); +assert.equal(cleanResult.exportAllowed, true); +assert.equal(cleanResult.findings.length, 0); +assert.deepEqual(cleanPacket, cleanBefore, "evaluation must not mutate the packet"); + +const revisionResult = evaluateAccessibleExportPacket(revisionPacket, { now: fixedNow }); +assert.equal(revisionResult.decision, "revise_before_export"); +assert.equal(revisionResult.exportAllowed, false); +assert.equal(codes(revisionResult).has("THIN_OR_GENERIC_ALT_TEXT"), true); +assert.equal(codes(revisionResult).has("COMPLEX_VISUAL_NEEDS_LONG_DESCRIPTION"), true); +assert.equal(codes(revisionResult).has("COLOR_ONLY_ENCODING"), true); +assert.equal(codes(revisionResult).has("ALT_TEXT_STALE_SOURCE_HASH"), true); +assert.equal(codes(revisionResult).has("ALT_TEXT_REVIEW_STALE"), true); +assert.equal(codes(revisionResult).has("UNRESOLVED_ACCESSIBILITY_COMMENT"), true); +assert.equal(codes(revisionResult).has("LOCKED_SECTION_ALT_TEXT_UNAPPROVED"), true); +assert.equal(codes(revisionResult).has("TABLE_HEADER_SCOPE_MISSING"), true); + +const holdResult = evaluateAccessibleExportPacket(holdPacket, { now: fixedNow }); +assert.equal(holdResult.decision, "hold_accessible_export"); +assert.equal(holdResult.exportAllowed, false); +assert.equal(codes(holdResult).has("PRIVATE_PATH_OR_DATA_LEAK"), true); +assert.equal(codes(holdResult).has("REVIEWER_IDENTITY_LEAK"), true); + +const report = renderMarkdownReport(holdResult); +assert.match(report, /Collaborative Alt Text Export Guard/); +assert.match(report, /hold_accessible_export/); +assert.match(report, /PRIVATE_PATH_OR_DATA_LEAK/); + +const svg = renderSvgSummary(revisionResult); +assert.match(svg, /^<\?xml version="1\.0"/); +assert.match(svg, /Accessible Export Guard/); +assert.match(svg, /revise_before_export/); + +assert.equal(digest(cleanPacket), digest(clone(cleanPacket))); +assert.notEqual(digest(cleanPacket), digest(revisionPacket)); +assert.throws(() => evaluateAccessibleExportPacket(null), /packet object/); + +console.log("All collaborative alt text export guard tests passed.");