From b207c74ec43213e85ff097ec3db14ec6d519ac23 Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:41:26 -0400 Subject: [PATCH] Add collaborative alt text export guard --- .../.gitignore | 1 + collaborative-alt-text-export-guard/README.md | 36 ++ collaborative-alt-text-export-guard/demo.js | 38 ++ collaborative-alt-text-export-guard/index.js | 486 ++++++++++++++++++ .../make-demo-video.js | 127 +++++ .../package.json | 21 + .../reports/clean-report.md | 18 + .../reports/clean-result.json | 20 + .../reports/clean-summary.svg | 18 + .../reports/demo.mp4 | Bin 0 -> 16947 bytes .../reports/hold-report.md | 40 ++ .../reports/hold-result.json | 45 ++ .../reports/hold-summary.svg | 18 + .../reports/manifest.json | 23 + .../reports/revision-report.md | 112 ++++ .../reports/revision-result.json | 117 +++++ .../reports/revision-summary.svg | 18 + .../sample-data.js | 150 ++++++ collaborative-alt-text-export-guard/test.js | 61 +++ 19 files changed, 1349 insertions(+) create mode 100644 collaborative-alt-text-export-guard/.gitignore create mode 100644 collaborative-alt-text-export-guard/README.md create mode 100644 collaborative-alt-text-export-guard/demo.js create mode 100644 collaborative-alt-text-export-guard/index.js create mode 100644 collaborative-alt-text-export-guard/make-demo-video.js create mode 100644 collaborative-alt-text-export-guard/package.json create mode 100644 collaborative-alt-text-export-guard/reports/clean-report.md create mode 100644 collaborative-alt-text-export-guard/reports/clean-result.json create mode 100644 collaborative-alt-text-export-guard/reports/clean-summary.svg create mode 100644 collaborative-alt-text-export-guard/reports/demo.mp4 create mode 100644 collaborative-alt-text-export-guard/reports/hold-report.md create mode 100644 collaborative-alt-text-export-guard/reports/hold-result.json create mode 100644 collaborative-alt-text-export-guard/reports/hold-summary.svg create mode 100644 collaborative-alt-text-export-guard/reports/manifest.json create mode 100644 collaborative-alt-text-export-guard/reports/revision-report.md create mode 100644 collaborative-alt-text-export-guard/reports/revision-result.json create mode 100644 collaborative-alt-text-export-guard/reports/revision-summary.svg create mode 100644 collaborative-alt-text-export-guard/sample-data.js create mode 100644 collaborative-alt-text-export-guard/test.js 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 0000000000000000000000000000000000000000..6b3738d72f1694fa8ebc29ab7006c0143f4d2f87 GIT binary patch literal 16947 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 + + 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.");