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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions challenge-accessibility-localization-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions challenge-accessibility-localization-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="420" viewBox="0 0 900 420">
<rect width="900" height="420" fill="#0f172a"/>
<text x="42" y="70" fill="#f8fafc" font-family="Arial" font-size="33">Challenge Accessibility Localization Guard</text>
<text x="42" y="118" fill="#cbd5e1" font-family="Arial" font-size="20">${report.challengeId}</text>
<rect x="42" y="156" width="220" height="82" rx="8" fill="${color}"/>
<text x="68" y="207" fill="#fff" font-family="Arial" font-size="30">${report.decision.toUpperCase()}</text>
<text x="42" y="286" fill="#e5e7eb" font-family="Arial" font-size="22">Findings: ${report.summary.findings}</text>
<text x="42" y="326" fill="#fecaca" font-family="Arial" font-size="20">High: ${report.summary.high}</text>
<text x="172" y="326" fill="#fed7aa" font-family="Arial" font-size="20">Medium: ${report.summary.medium}</text>
<text x="342" y="326" fill="#bfdbfe" font-family="Arial" font-size="20">Low: ${report.summary.low}</text>
<text x="42" y="372" fill="#94a3b8" font-family="Arial" font-size="18">Synthetic challenge packet only. No external translation or private data.</text>
</svg>`;
}

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)`);
}
46 changes: 46 additions & 0 deletions challenge-accessibility-localization-guard/demo_video.py
Original file line number Diff line number Diff line change
@@ -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}")
222 changes: 222 additions & 0 deletions challenge-accessibility-localization-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
13 changes: 13 additions & 0 deletions challenge-accessibility-localization-guard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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."
]
}
Loading