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 `
+
+`;
+}
+
+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 @@
+
+
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 @@
+
+
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 @@
+
+
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.");