diff --git a/challenge-accessibility-localization-guard/README.md b/challenge-accessibility-localization-guard/README.md
new file mode 100644
index 00000000..5f1d68e7
--- /dev/null
+++ b/challenge-accessibility-localization-guard/README.md
@@ -0,0 +1,17 @@
+# Challenge Accessibility Localization Guard
+
+Self-contained SCIBASE Scientific Bounty System slice for issue #18. The guard checks whether global solver teams can understand and access challenge materials before submissions open.
+
+## Why this slice is distinct
+
+Existing #18 submissions cover intake, rubric readiness, arbitration, workspace privacy, clarification freeze, deadline fairness, communication parity, onboarding clock parity, debrief feedback, duplicate-solver checks, escrow, payout eligibility, sponsor compliance, and award transparency. This module focuses only on accessibility and localization parity: languages, translation freshness, captions/transcripts, screen-reader formats, timezone displays, and accessible prequalification forms.
+
+## Run
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files.
diff --git a/challenge-accessibility-localization-guard/demo.js b/challenge-accessibility-localization-guard/demo.js
new file mode 100644
index 00000000..de73b759
--- /dev/null
+++ b/challenge-accessibility-localization-guard/demo.js
@@ -0,0 +1,59 @@
+const fs = require("fs");
+const path = require("path");
+
+const { assessAccessibilityLocalization } = require("./index");
+const { accessibleChallenge, riskyChallenge } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+function markdownReport(name, report) {
+ const findings = report.findings.length
+ ? report.findings
+ .map((item) => `- ${item.severity.toUpperCase()} ${item.code}: ${item.message} Affected: ${item.affectedTeams.join(", ")}`)
+ .join("\n")
+ : "- No accessibility or localization findings.";
+ return `# ${report.title}
+
+Scenario: ${name}
+
+Decision: ${report.decision.toUpperCase()}
+
+Reviewed ${report.summary.teamsReviewed} solver teams, ${report.summary.materialsReviewed} materials, and ${report.summary.formsReviewed} forms.
+
+## Findings
+
+${findings}
+
+## Release Criteria
+
+${report.releaseCriteria.map((item) => `- ${item}`).join("\n")}
+`;
+}
+
+function svgReport(report) {
+ const color = report.decision === "hold" ? "#dc2626" : report.decision === "revise" ? "#d97706" : "#16a34a";
+ return ``;
+}
+
+for (const [name, challenge] of [
+ ["accessible-challenge", accessibleChallenge],
+ ["risky-challenge", riskyChallenge],
+]) {
+ const report = assessAccessibilityLocalization(challenge);
+ fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2));
+ fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report));
+ fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report));
+ console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`);
+}
diff --git a/challenge-accessibility-localization-guard/demo_video.py b/challenge-accessibility-localization-guard/demo_video.py
new file mode 100644
index 00000000..15e37d42
--- /dev/null
+++ b/challenge-accessibility-localization-guard/demo_video.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import imageio.v3 as iio
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parent
+REPORTS = ROOT / "reports"
+REPORTS.mkdir(exist_ok=True)
+
+
+def font(size):
+ for name in ("arial.ttf", "segoeui.ttf"):
+ try:
+ return ImageFont.truetype(name, size)
+ except OSError:
+ pass
+ return ImageFont.load_default()
+
+
+slides = [
+ ("Accessibility Localization Guard", "Scientific Bounty System #18"),
+ ("Checks", "required languages + translation freshness"),
+ ("Checks", "captions, transcripts, screen-reader formats"),
+ ("Decision", "hold challenge launch until global teams have parity"),
+]
+
+frames = []
+for index, (title, subtitle) in enumerate(slides, start=1):
+ image = Image.new("RGB", (960, 544), "#111827")
+ draw = ImageDraw.Draw(image)
+ draw.rectangle((44, 52, 916, 492), outline="#38bdf8", width=3)
+ draw.text((80, 124), title, fill="#f8fafc", font=font(40))
+ draw.text((80, 206), subtitle, fill="#e0f2fe", font=font(26))
+ draw.rectangle((80, 326, 794, 382), fill="#075985")
+ draw.text((104, 342), "challenge materials must be understandable and accessible", fill="#f0f9ff", font=font(22))
+ draw.text((80, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#cbd5e1", font=font(20))
+ frames.extend([image] * 14)
+
+gif_path = REPORTS / "demo.gif"
+mp4_path = REPORTS / "demo.mp4"
+frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
+iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
+print(f"wrote {gif_path}")
+print(f"wrote {mp4_path}")
diff --git a/challenge-accessibility-localization-guard/index.js b/challenge-accessibility-localization-guard/index.js
new file mode 100644
index 00000000..05bc2a7a
--- /dev/null
+++ b/challenge-accessibility-localization-guard/index.js
@@ -0,0 +1,222 @@
+const HIGH = "high";
+const MEDIUM = "medium";
+const LOW = "low";
+
+function requiredString(value, field) {
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new TypeError(`${field} must be a non-empty string`);
+ }
+ return value.trim();
+}
+
+function array(value, field) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(`${field} must be an array`);
+ }
+ return value;
+}
+
+function unique(values) {
+ return [...new Set(values.map(String))];
+}
+
+function normalizeChallenge(raw) {
+ return {
+ challengeId: requiredString(raw.challengeId, "challengeId"),
+ title: requiredString(raw.title, "title"),
+ currentRuleDigest: requiredString(raw.currentRuleDigest, "currentRuleDigest"),
+ solverTeams: array(raw.solverTeams || [], "solverTeams").map((team) => ({
+ id: requiredString(team.id, "team.id"),
+ requiredLanguages: unique(array(team.requiredLanguages || [], "team.requiredLanguages")),
+ accessibilityNeeds: unique(array(team.accessibilityNeeds || [], "team.accessibilityNeeds")),
+ timezone: requiredString(team.timezone || "UTC", "team.timezone"),
+ })),
+ materials: array(raw.materials || [], "materials").map((material) => ({
+ id: requiredString(material.id, "material.id"),
+ required: Boolean(material.required),
+ type: requiredString(material.type, "material.type"),
+ languageVersions: material.languageVersions || {},
+ accessibleFormats: unique(array(material.accessibleFormats || [], "material.accessibleFormats")),
+ })),
+ deadlines: array(raw.deadlines || [], "deadlines").map((deadline) => ({
+ id: requiredString(deadline.id, "deadline.id"),
+ timestamp: requiredString(deadline.timestamp, "deadline.timestamp"),
+ displayTimezones: unique(array(deadline.displayTimezones || [], "deadline.displayTimezones")),
+ })),
+ forms: array(raw.forms || [], "forms").map((form) => ({
+ id: requiredString(form.id, "form.id"),
+ required: Boolean(form.required),
+ languageVersions: form.languageVersions || {},
+ accessibleFormats: unique(array(form.accessibleFormats || [], "form.accessibleFormats")),
+ })),
+ };
+}
+
+function finding(code, severity, sourceId, message, affectedTeams, remediation) {
+ return { code, severity, sourceId, message, affectedTeams, remediation };
+}
+
+function hasNeedCoverage(formats, need, materialType) {
+ if (need === "captions") {
+ if (materialType !== "video") {
+ return true;
+ }
+ return formats.includes("captions") || formats.includes("transcript");
+ }
+ if (need === "screen-reader") {
+ if (materialType === "video") {
+ return formats.includes("transcript") || formats.includes("audio-description");
+ }
+ return formats.includes("screen-reader") || formats.includes("html") || formats.includes("pdf-tagged");
+ }
+ return formats.includes(need);
+}
+
+function assessAccessibilityLocalization(rawChallenge) {
+ const challenge = normalizeChallenge(rawChallenge);
+ const findings = [];
+ const allLanguages = unique(challenge.solverTeams.flatMap((team) => team.requiredLanguages));
+
+ for (const material of challenge.materials.filter((item) => item.required)) {
+ for (const language of allLanguages) {
+ const digest = material.languageVersions[language];
+ if (!digest) {
+ const teams = challenge.solverTeams
+ .filter((team) => team.requiredLanguages.includes(language))
+ .map((team) => team.id);
+ findings.push(
+ finding(
+ "MISSING_REQUIRED_LANGUAGE",
+ HIGH,
+ material.id,
+ `${material.id} is missing required ${language} language coverage.`,
+ teams,
+ "Provide the missing translation before opening submissions or mark the language as not required."
+ )
+ );
+ } else if (digest !== challenge.currentRuleDigest) {
+ const teams = challenge.solverTeams
+ .filter((team) => team.requiredLanguages.includes(language))
+ .map((team) => team.id);
+ findings.push(
+ finding(
+ "STALE_TRANSLATION_DIGEST",
+ HIGH,
+ material.id,
+ `${material.id} ${language} version references ${digest}, not current ${challenge.currentRuleDigest}.`,
+ teams,
+ "Regenerate translated materials from the current rule digest and restart any affected timing windows."
+ )
+ );
+ }
+ }
+
+ for (const team of challenge.solverTeams) {
+ const missingNeeds = team.accessibilityNeeds.filter((need) => !hasNeedCoverage(material.accessibleFormats, need, material.type));
+ if (missingNeeds.length > 0) {
+ findings.push(
+ finding(
+ "ACCESSIBILITY_FORMAT_GAP",
+ HIGH,
+ material.id,
+ `${material.id} lacks accessible formats for ${missingNeeds.join(", ")}.`,
+ [team.id],
+ "Add equivalent accessible formats, then notify affected teams before submissions open."
+ )
+ );
+ }
+ }
+
+ if (material.type === "video" && !material.accessibleFormats.includes("captions") && !material.accessibleFormats.includes("transcript")) {
+ findings.push(
+ finding(
+ "VIDEO_CAPTION_TRANSCRIPT_GAP",
+ MEDIUM,
+ material.id,
+ `${material.id} is a required video without captions or transcript.`,
+ challenge.solverTeams.map((team) => team.id),
+ "Add captions or a transcript before treating the walkthrough as required."
+ )
+ );
+ }
+ }
+
+ for (const deadline of challenge.deadlines) {
+ const missingTimezones = challenge.solverTeams
+ .filter((team) => !deadline.displayTimezones.includes(team.timezone))
+ .map((team) => team.id);
+ if (missingTimezones.length > 0) {
+ findings.push(
+ finding(
+ "TIMEZONE_DISPLAY_GAP",
+ MEDIUM,
+ deadline.id,
+ `${deadline.id} deadline is not displayed in every solver team's timezone.`,
+ missingTimezones,
+ "Display UTC plus each eligible team's local timezone before the challenge clock starts."
+ )
+ );
+ }
+ }
+
+ for (const form of challenge.forms.filter((item) => item.required)) {
+ for (const language of allLanguages) {
+ if (!form.languageVersions[language]) {
+ const teams = challenge.solverTeams
+ .filter((team) => team.requiredLanguages.includes(language))
+ .map((team) => team.id);
+ findings.push(
+ finding(
+ "FORM_LANGUAGE_GAP",
+ MEDIUM,
+ form.id,
+ `${form.id} form is missing ${language} localization.`,
+ teams,
+ "Localize prequalification forms before requiring teams to complete them."
+ )
+ );
+ }
+ }
+ if (!form.accessibleFormats.includes("keyboard") || !form.accessibleFormats.includes("screen-reader")) {
+ findings.push(
+ finding(
+ "FORM_ACCESSIBILITY_GAP",
+ MEDIUM,
+ form.id,
+ `${form.id} form is not keyboard and screen-reader accessible.`,
+ challenge.solverTeams.map((team) => team.id),
+ "Add keyboard navigation and screen-reader labels before using the form for eligibility."
+ )
+ );
+ }
+ }
+
+ const high = findings.filter((item) => item.severity === HIGH).length;
+ const medium = findings.filter((item) => item.severity === MEDIUM).length;
+ return {
+ challengeId: challenge.challengeId,
+ title: challenge.title,
+ decision: high > 0 ? "hold" : medium > 0 ? "revise" : "release",
+ summary: {
+ teamsReviewed: challenge.solverTeams.length,
+ materialsReviewed: challenge.materials.length,
+ formsReviewed: challenge.forms.length,
+ findings: findings.length,
+ high,
+ medium,
+ low: findings.filter((item) => item.severity === LOW).length,
+ },
+ findings,
+ releaseCriteria: [
+ "Required challenge materials cover every solver-team language.",
+ "Translations reference the current immutable rule digest.",
+ "Required documents, videos, and forms include equivalent accessible formats.",
+ "Deadlines are shown in UTC and each eligible team's local timezone.",
+ ],
+ };
+}
+
+module.exports = {
+ assessAccessibilityLocalization,
+ normalizeChallenge,
+};
diff --git a/challenge-accessibility-localization-guard/package.json b/challenge-accessibility-localization-guard/package.json
new file mode 100644
index 00000000..8964025f
--- /dev/null
+++ b/challenge-accessibility-localization-guard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "challenge-accessibility-localization-guard",
+ "version": "1.0.0",
+ "description": "Accessibility and localization parity guard for SCIBASE scientific bounty challenges",
+ "main": "index.js",
+ "type": "commonjs",
+ "scripts": {
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "python demo_video.py"
+ },
+ "license": "MIT"
+}
diff --git a/challenge-accessibility-localization-guard/reports/accessible-challenge.json b/challenge-accessibility-localization-guard/reports/accessible-challenge.json
new file mode 100644
index 00000000..06f5ac1a
--- /dev/null
+++ b/challenge-accessibility-localization-guard/reports/accessible-challenge.json
@@ -0,0 +1,21 @@
+{
+ "challengeId": "sb-access-001",
+ "title": "Open climate forecasting challenge",
+ "decision": "release",
+ "summary": {
+ "teamsReviewed": 2,
+ "materialsReviewed": 2,
+ "formsReviewed": 1,
+ "findings": 0,
+ "high": 0,
+ "medium": 0,
+ "low": 0
+ },
+ "findings": [],
+ "releaseCriteria": [
+ "Required challenge materials cover every solver-team language.",
+ "Translations reference the current immutable rule digest.",
+ "Required documents, videos, and forms include equivalent accessible formats.",
+ "Deadlines are shown in UTC and each eligible team's local timezone."
+ ]
+}
\ No newline at end of file
diff --git a/challenge-accessibility-localization-guard/reports/accessible-challenge.md b/challenge-accessibility-localization-guard/reports/accessible-challenge.md
new file mode 100644
index 00000000..6d879327
--- /dev/null
+++ b/challenge-accessibility-localization-guard/reports/accessible-challenge.md
@@ -0,0 +1,18 @@
+# Open climate forecasting challenge
+
+Scenario: accessible-challenge
+
+Decision: RELEASE
+
+Reviewed 2 solver teams, 2 materials, and 1 forms.
+
+## Findings
+
+- No accessibility or localization findings.
+
+## Release Criteria
+
+- Required challenge materials cover every solver-team language.
+- Translations reference the current immutable rule digest.
+- Required documents, videos, and forms include equivalent accessible formats.
+- Deadlines are shown in UTC and each eligible team's local timezone.
diff --git a/challenge-accessibility-localization-guard/reports/accessible-challenge.svg b/challenge-accessibility-localization-guard/reports/accessible-challenge.svg
new file mode 100644
index 00000000..49b4342a
--- /dev/null
+++ b/challenge-accessibility-localization-guard/reports/accessible-challenge.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/challenge-accessibility-localization-guard/reports/demo.gif b/challenge-accessibility-localization-guard/reports/demo.gif
new file mode 100644
index 00000000..0d97a866
Binary files /dev/null and b/challenge-accessibility-localization-guard/reports/demo.gif differ
diff --git a/challenge-accessibility-localization-guard/reports/demo.mp4 b/challenge-accessibility-localization-guard/reports/demo.mp4
new file mode 100644
index 00000000..9e3a7e99
Binary files /dev/null and b/challenge-accessibility-localization-guard/reports/demo.mp4 differ
diff --git a/challenge-accessibility-localization-guard/reports/risky-challenge.json b/challenge-accessibility-localization-guard/reports/risky-challenge.json
new file mode 100644
index 00000000..0f3e9e62
--- /dev/null
+++ b/challenge-accessibility-localization-guard/reports/risky-challenge.json
@@ -0,0 +1,145 @@
+{
+ "challengeId": "sb-access-002",
+ "title": "Open climate forecasting challenge",
+ "decision": "hold",
+ "summary": {
+ "teamsReviewed": 2,
+ "materialsReviewed": 2,
+ "formsReviewed": 1,
+ "findings": 12,
+ "high": 7,
+ "medium": 5,
+ "low": 0
+ },
+ "findings": [
+ {
+ "code": "STALE_TRANSLATION_DIGEST",
+ "severity": "high",
+ "sourceId": "rules",
+ "message": "rules es version references rules-v4, not current rules-v5.",
+ "affectedTeams": [
+ "team-mx"
+ ],
+ "remediation": "Regenerate translated materials from the current rule digest and restart any affected timing windows."
+ },
+ {
+ "code": "MISSING_REQUIRED_LANGUAGE",
+ "severity": "high",
+ "sourceId": "rules",
+ "message": "rules is missing required ja language coverage.",
+ "affectedTeams": [
+ "team-jp"
+ ],
+ "remediation": "Provide the missing translation before opening submissions or mark the language as not required."
+ },
+ {
+ "code": "ACCESSIBILITY_FORMAT_GAP",
+ "severity": "high",
+ "sourceId": "rules",
+ "message": "rules lacks accessible formats for screen-reader.",
+ "affectedTeams": [
+ "team-jp"
+ ],
+ "remediation": "Add equivalent accessible formats, then notify affected teams before submissions open."
+ },
+ {
+ "code": "MISSING_REQUIRED_LANGUAGE",
+ "severity": "high",
+ "sourceId": "walkthrough",
+ "message": "walkthrough is missing required es language coverage.",
+ "affectedTeams": [
+ "team-mx"
+ ],
+ "remediation": "Provide the missing translation before opening submissions or mark the language as not required."
+ },
+ {
+ "code": "MISSING_REQUIRED_LANGUAGE",
+ "severity": "high",
+ "sourceId": "walkthrough",
+ "message": "walkthrough is missing required ja language coverage.",
+ "affectedTeams": [
+ "team-jp"
+ ],
+ "remediation": "Provide the missing translation before opening submissions or mark the language as not required."
+ },
+ {
+ "code": "ACCESSIBILITY_FORMAT_GAP",
+ "severity": "high",
+ "sourceId": "walkthrough",
+ "message": "walkthrough lacks accessible formats for captions.",
+ "affectedTeams": [
+ "team-mx"
+ ],
+ "remediation": "Add equivalent accessible formats, then notify affected teams before submissions open."
+ },
+ {
+ "code": "ACCESSIBILITY_FORMAT_GAP",
+ "severity": "high",
+ "sourceId": "walkthrough",
+ "message": "walkthrough lacks accessible formats for screen-reader.",
+ "affectedTeams": [
+ "team-jp"
+ ],
+ "remediation": "Add equivalent accessible formats, then notify affected teams before submissions open."
+ },
+ {
+ "code": "VIDEO_CAPTION_TRANSCRIPT_GAP",
+ "severity": "medium",
+ "sourceId": "walkthrough",
+ "message": "walkthrough is a required video without captions or transcript.",
+ "affectedTeams": [
+ "team-mx",
+ "team-jp"
+ ],
+ "remediation": "Add captions or a transcript before treating the walkthrough as required."
+ },
+ {
+ "code": "TIMEZONE_DISPLAY_GAP",
+ "severity": "medium",
+ "sourceId": "submission",
+ "message": "submission deadline is not displayed in every solver team's timezone.",
+ "affectedTeams": [
+ "team-mx",
+ "team-jp"
+ ],
+ "remediation": "Display UTC plus each eligible team's local timezone before the challenge clock starts."
+ },
+ {
+ "code": "FORM_LANGUAGE_GAP",
+ "severity": "medium",
+ "sourceId": "prequalification",
+ "message": "prequalification form is missing es localization.",
+ "affectedTeams": [
+ "team-mx"
+ ],
+ "remediation": "Localize prequalification forms before requiring teams to complete them."
+ },
+ {
+ "code": "FORM_LANGUAGE_GAP",
+ "severity": "medium",
+ "sourceId": "prequalification",
+ "message": "prequalification form is missing ja localization.",
+ "affectedTeams": [
+ "team-jp"
+ ],
+ "remediation": "Localize prequalification forms before requiring teams to complete them."
+ },
+ {
+ "code": "FORM_ACCESSIBILITY_GAP",
+ "severity": "medium",
+ "sourceId": "prequalification",
+ "message": "prequalification form is not keyboard and screen-reader accessible.",
+ "affectedTeams": [
+ "team-mx",
+ "team-jp"
+ ],
+ "remediation": "Add keyboard navigation and screen-reader labels before using the form for eligibility."
+ }
+ ],
+ "releaseCriteria": [
+ "Required challenge materials cover every solver-team language.",
+ "Translations reference the current immutable rule digest.",
+ "Required documents, videos, and forms include equivalent accessible formats.",
+ "Deadlines are shown in UTC and each eligible team's local timezone."
+ ]
+}
\ No newline at end of file
diff --git a/challenge-accessibility-localization-guard/reports/risky-challenge.md b/challenge-accessibility-localization-guard/reports/risky-challenge.md
new file mode 100644
index 00000000..5ba295ef
--- /dev/null
+++ b/challenge-accessibility-localization-guard/reports/risky-challenge.md
@@ -0,0 +1,29 @@
+# Open climate forecasting challenge
+
+Scenario: risky-challenge
+
+Decision: HOLD
+
+Reviewed 2 solver teams, 2 materials, and 1 forms.
+
+## Findings
+
+- HIGH STALE_TRANSLATION_DIGEST: rules es version references rules-v4, not current rules-v5. Affected: team-mx
+- HIGH MISSING_REQUIRED_LANGUAGE: rules is missing required ja language coverage. Affected: team-jp
+- HIGH ACCESSIBILITY_FORMAT_GAP: rules lacks accessible formats for screen-reader. Affected: team-jp
+- HIGH MISSING_REQUIRED_LANGUAGE: walkthrough is missing required es language coverage. Affected: team-mx
+- HIGH MISSING_REQUIRED_LANGUAGE: walkthrough is missing required ja language coverage. Affected: team-jp
+- HIGH ACCESSIBILITY_FORMAT_GAP: walkthrough lacks accessible formats for captions. Affected: team-mx
+- HIGH ACCESSIBILITY_FORMAT_GAP: walkthrough lacks accessible formats for screen-reader. Affected: team-jp
+- MEDIUM VIDEO_CAPTION_TRANSCRIPT_GAP: walkthrough is a required video without captions or transcript. Affected: team-mx, team-jp
+- MEDIUM TIMEZONE_DISPLAY_GAP: submission deadline is not displayed in every solver team's timezone. Affected: team-mx, team-jp
+- MEDIUM FORM_LANGUAGE_GAP: prequalification form is missing es localization. Affected: team-mx
+- MEDIUM FORM_LANGUAGE_GAP: prequalification form is missing ja localization. Affected: team-jp
+- MEDIUM FORM_ACCESSIBILITY_GAP: prequalification form is not keyboard and screen-reader accessible. Affected: team-mx, team-jp
+
+## Release Criteria
+
+- Required challenge materials cover every solver-team language.
+- Translations reference the current immutable rule digest.
+- Required documents, videos, and forms include equivalent accessible formats.
+- Deadlines are shown in UTC and each eligible team's local timezone.
diff --git a/challenge-accessibility-localization-guard/reports/risky-challenge.svg b/challenge-accessibility-localization-guard/reports/risky-challenge.svg
new file mode 100644
index 00000000..ee5c4f9d
--- /dev/null
+++ b/challenge-accessibility-localization-guard/reports/risky-challenge.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/challenge-accessibility-localization-guard/requirements-map.md b/challenge-accessibility-localization-guard/requirements-map.md
new file mode 100644
index 00000000..afcd7829
--- /dev/null
+++ b/challenge-accessibility-localization-guard/requirements-map.md
@@ -0,0 +1,13 @@
+# Requirements Map
+
+Issue #18 asks for a Scientific Bounty System that supports global research participation, challenge posting, secure submissions, arbitration, rewards, and trust between sponsors and solvers.
+
+| Issue capability | This implementation |
+| --- | --- |
+| Global participation | Validates required-language coverage for every eligible solver team. |
+| Challenge posting portal | Holds publication when required rules, walkthroughs, or forms are inaccessible. |
+| Timeline fairness | Checks UTC and local-time display for solver-team deadlines. |
+| Prequalification support | Validates localized and keyboard/screen-reader accessible prequalification forms. |
+| Trust and arbitration readiness | Emits deterministic `release`, `revise`, or `hold` decisions with remediation steps. |
+
+The module uses synthetic data only and does not contact credentials, private challenge records, payment systems, external APIs, or live translation services.
diff --git a/challenge-accessibility-localization-guard/sample-data.js b/challenge-accessibility-localization-guard/sample-data.js
new file mode 100644
index 00000000..59c7d9c5
--- /dev/null
+++ b/challenge-accessibility-localization-guard/sample-data.js
@@ -0,0 +1,63 @@
+const accessibleChallenge = {
+ challengeId: "sb-access-001",
+ title: "Open climate forecasting challenge",
+ currentRuleDigest: "rules-v5",
+ solverTeams: [
+ { id: "team-mx", requiredLanguages: ["en", "es"], accessibilityNeeds: ["captions"], timezone: "America/Mexico_City" },
+ { id: "team-jp", requiredLanguages: ["en", "ja"], accessibilityNeeds: ["screen-reader"], timezone: "Asia/Tokyo" },
+ ],
+ materials: [
+ {
+ id: "rules",
+ required: true,
+ type: "document",
+ languageVersions: { en: "rules-v5", es: "rules-v5", ja: "rules-v5" },
+ accessibleFormats: ["html", "pdf-tagged", "screen-reader"],
+ },
+ {
+ id: "walkthrough",
+ required: true,
+ type: "video",
+ languageVersions: { en: "rules-v5", es: "rules-v5", ja: "rules-v5" },
+ accessibleFormats: ["captions", "transcript", "audio-description"],
+ },
+ ],
+ deadlines: [
+ { id: "submission", timestamp: "2026-07-10T17:00:00Z", displayTimezones: ["UTC", "America/Mexico_City", "Asia/Tokyo"] },
+ ],
+ forms: [
+ { id: "prequalification", required: true, languageVersions: { en: "rules-v5", es: "rules-v5", ja: "rules-v5" }, accessibleFormats: ["keyboard", "screen-reader"] },
+ ],
+};
+
+const riskyChallenge = {
+ ...accessibleChallenge,
+ challengeId: "sb-access-002",
+ materials: [
+ {
+ id: "rules",
+ required: true,
+ type: "document",
+ languageVersions: { en: "rules-v5", es: "rules-v4" },
+ accessibleFormats: ["pdf"],
+ },
+ {
+ id: "walkthrough",
+ required: true,
+ type: "video",
+ languageVersions: { en: "rules-v5" },
+ accessibleFormats: ["video-only"],
+ },
+ ],
+ deadlines: [
+ { id: "submission", timestamp: "2026-07-10T17:00:00Z", displayTimezones: ["UTC"] },
+ ],
+ forms: [
+ { id: "prequalification", required: true, languageVersions: { en: "rules-v5" }, accessibleFormats: ["mouse"] },
+ ],
+};
+
+module.exports = {
+ accessibleChallenge,
+ riskyChallenge,
+};
diff --git a/challenge-accessibility-localization-guard/test.js b/challenge-accessibility-localization-guard/test.js
new file mode 100644
index 00000000..3dc77508
--- /dev/null
+++ b/challenge-accessibility-localization-guard/test.js
@@ -0,0 +1,36 @@
+const assert = require("assert");
+
+const { assessAccessibilityLocalization, normalizeChallenge } = require("./index");
+const { accessibleChallenge, riskyChallenge } = require("./sample-data");
+
+const clean = assessAccessibilityLocalization(accessibleChallenge);
+assert.strictEqual(clean.decision, "release");
+assert.strictEqual(clean.summary.findings, 0);
+
+const risky = assessAccessibilityLocalization(riskyChallenge);
+assert.strictEqual(risky.decision, "hold");
+for (const code of [
+ "MISSING_REQUIRED_LANGUAGE",
+ "STALE_TRANSLATION_DIGEST",
+ "ACCESSIBILITY_FORMAT_GAP",
+ "VIDEO_CAPTION_TRANSCRIPT_GAP",
+ "TIMEZONE_DISPLAY_GAP",
+ "FORM_LANGUAGE_GAP",
+ "FORM_ACCESSIBILITY_GAP",
+]) {
+ assert(risky.findings.some((finding) => finding.code === code), `missing ${code}`);
+}
+
+const partial = assessAccessibilityLocalization({
+ ...accessibleChallenge,
+ deadlines: [{ id: "final", timestamp: "2026-07-10T17:00:00Z", displayTimezones: ["UTC"] }],
+});
+assert.strictEqual(partial.decision, "revise");
+assert(partial.findings.some((finding) => finding.code === "TIMEZONE_DISPLAY_GAP"));
+
+assert.throws(
+ () => normalizeChallenge({ ...accessibleChallenge, challengeId: "" }),
+ /challengeId must be a non-empty string/
+);
+
+console.log("challenge accessibility localization guard tests passed");