diff --git a/.github/workflows/sync-adventure.yml b/.github/workflows/sync-adventure.yml index 4d143feab..c8b61462f 100644 --- a/.github/workflows/sync-adventure.yml +++ b/.github/workflows/sync-adventure.yml @@ -182,7 +182,8 @@ jobs: ### Before merging - - [ ] Add \`contributor:\` block to \`src/data/adventures/${SLUG}/adventure.yaml\` + - [ ] Check the \`contributor:\` block in \`src/data/adventures/${SLUG}/adventure.yaml\`. It is synced from + \`docs/index.yaml\` in the challenges repo; add it by hand only if the sync log says none was found. \`\`\`yaml contributor: name: "Full Name" diff --git a/ADVENTURES.md b/ADVENTURES.md index e1de41a50..a598bc821 100644 --- a/ADVENTURES.md +++ b/ADVENTURES.md @@ -40,11 +40,11 @@ The authoritative schema is in [`src/content.config.ts`](src/content.config.ts) | `emoji` | Optional | emoji character | Shown on the adventure card. The sync workflow maps it to a Lucide icon via the `EMOJI_TO_ICON` table; add the mapping there first if the emoji is new. | | `month` | **Required** | `MMM YYYY` | Three-letter uppercase abbreviation + four-digit year. Allowed: `JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC`. Validated by Zod regex; wrong format fails sync. | | `tags` | **Required** | `string[]` (min 1) | Technology/topic labels shown as filter chips. Used when the auto-generated `meta_description` falls back to name + backstory. | -| `meta_description` | **Required** | string, max 160 chars | Validated by the Zod schema; missing field fails `npm run sync`. Max 160 chars. No em dashes; no ` - ` used as a dash. | +| `meta_description` | **Required** | string, 160 chars recommended | Validated by the Zod schema; missing field fails `npm run sync`. Over 160 chars logs a warning and still builds, since search engines truncate rather than reject. Trim it before release. No em dashes; no ` - ` used as a dash. | | `story` | Optional | markdown string | Short description shown on adventure cards and at the top of the adventure page. Card views strip HTML; set:html prose uses the rendered version. | | `backstory` | Optional | `string[]` (markdown) | Thematic narrative paragraphs rendered on the adventure page. | | `overview` | Optional | `string[]` (markdown) | Technical/content summary rendered on the adventure page. | -| `contributor` | Optional | object | `name` (required), `url` (optional URL), `about` (optional markdown), `discourse_username` (optional string -- Discourse username used for avatar resolution in community leaderboards). Survives every re-sync once set. | +| `contributor` | Optional | object | `name` (required), `url` (optional URL), `about` (optional markdown), `discourse_username` (optional string -- Discourse username used for avatar resolution in community leaderboards). Copied from the challenges repo's `docs/index.yaml` when absent here; survives every re-sync once set. | | `community_category_id` | Optional | integer | Discourse category ID. Survives every re-sync once set; position is kept directly after `slug`. | | `rewards` | Optional | object | `deadline` (required inside; see format below), `eligibility` (markdown), `tiers` (array of `{label, description}`), `ranking_note` (markdown), `ranking_rules_url` (URL). | | `upcoming_levels` | Optional | object[] | Coming-soon placeholders: `{level?, name, difficulty}`. Survives re-syncs for levels not yet in the challenges repo. | @@ -82,12 +82,12 @@ Each entry in the `levels` array accepts the following fields. | `scenario` | Optional | string (markdown) | Scenario prose shown before the how-to-play steps. | | `services` | Optional | object[] | Services exposed by the devcontainer: `{name, port?, url?, credentials?, description, internal?}`. Use `port` for a bare port number or `url` for a full URL (e.g. `http://localhost:5173`). An injected "Explore the UIs" step is generated automatically when at least one non-internal service has a `port` or `url`. | | `helpful_links` | Optional | object[] | Reference links shown at the bottom of the level: `{title, url, description?}`. | -| `meta_description` | Optional | string, max 160 chars | Level-specific meta description. When absent, the generator builds one from `name`/`title` + `intro[0]` + difficulty + topics. | +| `meta_description` | Optional | string, 160 chars recommended | Level-specific meta description. When absent, the generator builds one from `name`/`title` + `intro[0]` + difficulty + topics. Over 160 chars logs a warning and still builds. | | `what_you_learn` or `learnings` | **One required** | `string[]` (min 1 when present) | Learning objectives list. A Zod `.refine()` requires at least one of the two to be set; if both are absent the build fails. | | `verification` | **Required** | object | `{command, description}` — the verification gate command and its description. | | `codespaces_machine` | Optional | `"4core"` | Machine size override for Codespaces. Only `"4core"` is accepted; other values fail the Zod schema. | | `hook` | Optional | string | Verification hook command. | -| `contributor` | Optional | object | Person who built this specific level. Same subfields as the adventure `contributor` (`name`, `url`, `about`, `discourse_username`). **When omitted, the adventure designer is credited as the builder for this level.** When set, takes precedence over the adventure designer for credit display on the level page and in community leaderboard sections. See note below. | +| `contributor` | Optional | object | Person who built this specific level. Same subfields as the adventure `contributor` (`name`, `url`, `about`, `discourse_username`). Synced from that level's YAML in the challenges repo (e.g. `docs/beginner.yaml`), which sets it only when someone other than the designer built the level. A value already in the website YAML always wins, so re-crediting a level upstream needs the same hand-edit as re-crediting the designer. **When omitted, the adventure designer is credited as the builder for this level.** When set, takes precedence over the adventure designer for credit display on the level page and in community leaderboard sections. See note below. | | `solved_count` | Optional | integer | Override for the displayed solved count. | | `top_players` | Optional | object[] | System-populated leaderboard data: `{username, count}`. Set by the leaderboard refresh script; do not edit by hand. | @@ -123,7 +123,7 @@ Go to **Actions → Sync Adventure from Challenges Repo → Run workflow**. The PR body lists everything that needs to happen before merging. Here is each item explained. -### Add contributor block +### Check the contributor block ```yaml contributor: @@ -133,7 +133,11 @@ contributor: discourse_username: "their_forum_username" ``` -Add this to `src/data/adventures//adventure.yaml`. The `url`, `about`, and `discourse_username` fields are optional but recommended -- `discourse_username` enables avatar resolution in community leaderboards. Once set, this block survives future re-syncs automatically. +This lives in `src/data/adventures//adventure.yaml`. The sync copies it from `contributor:` in the challenges repo's `docs/index.yaml` when the website YAML does not already have one, keeping only the four fields above -- the challenges repo owns its own schema, and passing an unknown field through would fail `npm run sync` against the strict content schema. Write it by hand only when the sync log warns that no contributor was found upstream. + +The `url`, `about`, and `discourse_username` fields are optional but recommended -- `discourse_username` enables avatar resolution in community leaderboards. A hand-edited block always wins over the upstream one and survives future re-syncs. + +An adventure with no designer cannot have levels that name their own builder: `creditIntegrityError` in `src/lib/adventure-credit.ts` fails `npm run sync` on that combination. ### Confirm month @@ -242,7 +246,8 @@ If the challenges repo is updated while your PR is still open, or you want to pr | Field | Preserved | Notes | | --- | --- | --- | -| `contributor:` (adventure) | Always | Survives every re-sync once set | +| `contributor:` (adventure) | Always | Survives every re-sync once set. When unset, the sync copies it from `docs/index.yaml` in the challenges repo | +| `contributor:` (level) | Always | Survives every re-sync once set, even if the level's YAML upstream names someone else. When unset, the sync copies it from that level's YAML in the challenges repo. Re-crediting a level is a deliberate hand-edit | | `community_category_id:` (adventure) | Always | Survives every re-sync once set; position is kept directly after `slug` | | `month:` (adventure) | Always | Survives every re-sync once set | | `discussion_url:` / `community_url:` (level) | Always | Website-only fields; never in the challenges repo. Both field aliases are preserved independently | diff --git a/e2e/routes.ts b/e2e/routes.ts index e8d2e4014..cc5442457 100644 --- a/e2e/routes.ts +++ b/e2e/routes.ts @@ -43,6 +43,10 @@ export const SMOKE_ROUTES: Record = { "/privacy/": "Privacy Policy - OffOn", "/accessibility/": "Accessibility Statement - OffOn", "/404/": "Page Not Found - OffOn", + // GENERATED:adventure-accessibility-nightmare-smoke + "/adventures/adventure-accessibility-nightmare/": "The Accessibility Nightmare - OffOn Adventures", + "/adventures/adventure-accessibility-nightmare/levels/beginner/": "The Initial Audit - The Accessibility Nightmare - OffOn", + // /GENERATED:adventure-accessibility-nightmare-smoke }; export const A11Y_PAGES: string[] = [ @@ -86,6 +90,10 @@ export const A11Y_PAGES: string[] = [ "/adventures/the-ai-observatory/levels/expert/", "/adventures/the-ai-observatory/levels/intermediate/", "/404/", + // GENERATED:adventure-accessibility-nightmare-a11y + "/adventures/adventure-accessibility-nightmare/", + "/adventures/adventure-accessibility-nightmare/levels/beginner/", + // /GENERATED:adventure-accessibility-nightmare-a11y ]; /** @@ -122,4 +130,11 @@ export const ROUTES_WITHOUT_FULL_COVERAGE: string[] = [ "/challenges/tdd/", "/challenges/terraform/", "/challenges/trivy/", + // GENERATED:adventure-accessibility-nightmare-challenges + "/challenges/axe-core/", + "/challenges/playwright/", + "/challenges/lighthouse/", + "/challenges/wcag-2-2/", + "/challenges/react/", + // /GENERATED:adventure-accessibility-nightmare-challenges ]; diff --git a/scripts/sync-adventure.mjs b/scripts/sync-adventure.mjs index cc51aefb0..43d51b0be 100644 --- a/scripts/sync-adventure.mjs +++ b/scripts/sync-adventure.mjs @@ -152,6 +152,19 @@ function buildAdventureMetaDescription(indexData, activeLevels) { return truncateAtWord(full, 160); } +// The website's contributor schema is strict, so only the four fields it knows +// survive the trip from the challenges repo. Anything else in the upstream block +// would fail `astro sync` on a field the reviewer never wrote. +export function pickContributor(raw) { + if (!raw || typeof raw !== "object" || !raw.name) return null; + return { + name: raw.name, + ...(raw.url && { url: raw.url }), + ...(raw.about && { about: raw.about }), + ...(raw.discourse_username && { discourse_username: raw.discourse_username }), + }; +} + function transformStrings(value, fn) { if (typeof value === "string") return fn(value); if (Array.isArray(value)) return value.map((v) => transformStrings(v, fn)); @@ -268,14 +281,20 @@ function addToLucideIconsMap(content, iconName, kebab) { return content.replace(re, m[1] + block + m[3]); } -function buildLevel(raw, adventureTags, rewardsDeadline) { +export function buildLevel(raw, adventureTags, rewardsDeadline) { // architecture_diagram is stripped here. After all levels are fetched, the sync attempts // to pull the SVG from docs/diagrams/ in the challenges repo and re-adds the field if // successful. If not found there, it must be added manually to src/assets/diagrams/. const { architecture_diagram: _ignored, ...rest } = raw; - const cleaned = transformStrings(rest, stripCodeInLinks); + const { contributor: rawContributor, ...cleaned } = transformStrings(rest, stripCodeInLinks); + // A level `contributor` is the challenge builder, set upstream only when someone + // other than the adventure designer built this level. Filtered through the same + // picker as the designer: the website's contributor schema is strict, so a field + // the challenges repo carries but this site has no column for would fail sync. + const contributor = pickContributor(rawContributor); return { ...cleaned, + ...(contributor && { contributor }), ...(cleaned.deadline && { deadline: parseDeadline(cleaned.deadline, PRESERVE_TZ) }), topics: cleaned.topics || deriveTopics(adventureTags), verification: cleaned.verification || VERIFICATION_STUB, @@ -295,7 +314,7 @@ function buildLevel(raw, adventureTags, rewardsDeadline) { * injected by buildLevel, so manual edits are only preserved when * the upstream did not intentionally change the field. */ -function mergeLevels(existing, incoming, rawFetched) { +export function mergeLevels(existing, incoming, rawFetched) { const levelMap = Object.fromEntries((existing || []).map((l) => [l.level, l])); const rawMap = Object.fromEntries((rawFetched || []).map((l) => [l.level, l])); @@ -303,6 +322,18 @@ function mergeLevels(existing, incoming, rawFetched) { const prev = levelMap[l.level]; const raw = rawMap[l.level]; + // The challenge builder, already credited in the website YAML. Held aside + // because it wins over the upstream value rather than only filling a gap. + const preservedContributor = pickContributor(prev?.contributor); + const upstreamContributor = pickContributor(raw?.contributor); + if (preservedContributor && upstreamContributor && upstreamContributor.name !== preservedContributor.name) { + console.warn( + ` Level "${l.level}": keeping the builder already credited on the website ` + + `(${preservedContributor.name}) over the one named upstream (${upstreamContributor.name}). ` + + "Edit adventure.yaml by hand to re-credit this level." + ); + } + levelMap[l.level] = { ...l, // Preserve the discussion URL set by the add-discussion-url workflow. @@ -320,6 +351,12 @@ function mergeLevels(existing, incoming, rawFetched) { // intentional upstream changes come through. When the upstream did not set them (buildLevel // derived them from adventure tags), preserve any manual refinements from the website. ...(!raw?.topics && prev?.topics && { topics: prev.topics }), + // contributor: the challenge builder. A builder already credited on the website + // always wins, matching the adventure designer: the sync fills this field in from + // the level YAML, it never overwrites it. Re-crediting a level to someone else is + // a hand-edit either way, and losing an existing credit silently misattributes a + // person's work, which is worse than a stale credit a reviewer can see and fix. + ...(preservedContributor && { contributor: preservedContributor }), }; } @@ -471,6 +508,13 @@ async function main() { // Used for level flags and e2e/routes.ts generation below. const allLiveLevels = mergeLevels(existing?.levels, activeLevels, rawFetchedLevels); + const resolvedContributor = pickContributor(existing?.contributor) ?? pickContributor(indexData.contributor); + if (!existing?.contributor && resolvedContributor) { + console.log(` Contributor from docs/index.yaml: ${resolvedContributor.name}`); + } else if (!resolvedContributor) { + console.warn(" No contributor found in docs/index.yaml. Add a `contributor:` block to adventure.yaml before merging."); + } + // Build the combined adventure object using challenges repo field names. // The generator accepts all aliases (name/title, emoji → icon, etc.). const adventure = { @@ -494,8 +538,11 @@ async function main() { ...(indexData.rewards.deadline && { deadline: parseDeadline(indexData.rewards.deadline, PRESERVE_TZ) }), }, }), - // Preserve contributor set by a reviewer; omit otherwise (PR checklist item) - ...(existing?.contributor && { contributor: existing.contributor }), + // A reviewer's hand-edited contributor wins; otherwise take the designer the + // challenges repo already names in docs/index.yaml. Levels may credit their own + // builder, and the content schema rejects that on an adventure with no designer, + // so an unsynced upstream contributor fails the build rather than just losing a pill. + ...(resolvedContributor && { contributor: resolvedContributor }), ...(upcomingLevels.length > 0 && { upcoming_levels: upcomingLevels }), levels: allLiveLevels, }; diff --git a/src/content.config.ts b/src/content.config.ts index bb2cb8233..d11ba49e7 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -126,7 +126,8 @@ const levelSchema = z how_to_play: z.array(howToPlayStepSchema), verification: verificationSchema, helpful_links: z.array(helpfulLinkSchema).optional(), - meta_description: z.string().max(160).optional(), + // Length is warned about in renderLevel, not enforced: see warnIfMetaDescriptionLong. + meta_description: z.string().optional(), solved_count: z.number().int().optional(), top_players: z .array(z.object({ username: z.string(), count: z.number().int() }).strict()) @@ -144,6 +145,20 @@ const levelSchema = z // --- Resolvers --- +const META_DESCRIPTION_MAX = 160; + +// Over-length meta descriptions are an SEO smell, not a content error: search +// engines truncate the tail and the page still renders correctly. Warn so the +// sync PR gets refined before release, but never fail `astro sync` (and with it +// the whole sync-adventure workflow) over a description a reviewer can trim. +function warnIfMetaDescriptionLong(value: string | undefined, where: string): void { + if (value && value.length > META_DESCRIPTION_MAX) { + console.warn( + `[content] ${where}: meta_description is ${value.length} chars, over the ${META_DESCRIPTION_MAX}-char SEO limit. Search engines will truncate it.`, + ); + } +} + function requireEither(a: string | undefined | null, b: string | undefined | null, field: string): string { const value = a ?? b; if (value != null && value !== "") return value; @@ -187,7 +202,8 @@ function assertDifficulty( // AdventureLevel (from data/adventures/types.ts) is the single source of truth // for the rendered level shape. renderLevel's return type is checked against it, // so the two cannot drift silently. -async function renderLevel(level: z.infer): Promise { +async function renderLevel(level: z.infer, slug: string): Promise { + warnIfMetaDescriptionLong(level.meta_description, `${slug} → level "${level.level}"`); const difficulty = level.difficulty ?? (level.emoji ? LEVEL_DIFFICULTY_BY_EMOJI[level.emoji as keyof typeof LEVEL_DIFFICULTY_BY_EMOJI] : undefined); const learnings = level.learnings ?? level.what_you_learn ?? []; const intro = level.intro ?? (level.summary ? [level.summary] : undefined); @@ -345,7 +361,8 @@ const adventures = defineCollection({ tags: z.array(z.string()).min(1), contributor: contributorSchema.optional(), community_category_id: z.number().int().optional(), - meta_description: z.string().max(160), + // Length is warned about in the transform, not enforced: see warnIfMetaDescriptionLong. + meta_description: z.string(), backstory: z.array(z.string()).optional(), overview: z.array(z.string()).optional(), rewards: rewardsSchema.optional(), @@ -361,6 +378,7 @@ const adventures = defineCollection({ if (message) ctx.addIssue({ code: "custom", message, path: ["contributor"] }); }) .transform(async (data) => { + warnIfMetaDescriptionLong(data.meta_description, `adventure "${data.slug}"`); const title = requireEither(data.title, data.name, "adventure title/name"); const story = data.story ?? data.meta_description ?? (data.backstory && data.backstory.length > 0 ? data.backstory[0] : ""); @@ -370,7 +388,7 @@ const adventures = defineCollection({ mdToInline(story), data.contributor?.about ? mdToInline(data.contributor.about) : Promise.resolve(null), data.backstory ? mdToInlineArray(data.backstory) : Promise.resolve(null), - Promise.all(data.levels.map(renderLevel)), + Promise.all(data.levels.map((level) => renderLevel(level, data.slug))), data.rewards ? renderRewards(data.rewards) : Promise.resolve(null), ]); diff --git a/src/data/adventures/adventure-accessibility-nightmare/adventure.yaml b/src/data/adventures/adventure-accessibility-nightmare/adventure.yaml new file mode 100644 index 000000000..2ea1f0a9e --- /dev/null +++ b/src/data/adventures/adventure-accessibility-nightmare/adventure.yaml @@ -0,0 +1,204 @@ +slug: adventure-accessibility-nightmare +meta_description: Audit the ShopSmart homepage and fix critical WCAG 2.2 accessibility violations before the initial compliance review. +name: The Accessibility Nightmare +icon: Accessibility +emoji: ♿ +month: SEP 2026 +tags: + - Accessibility + - WCAG 2.2 + - React + - Playwright + - axe-core + - Lighthouse + - Guidepup Virtual Screen Reader +backstory: + - "ShopSmart is an online retailer moving into the European market. The redesign brief was explicit: anyone should be + able to shop there, on any device, with any input method or assistive technology. Months on, support has traced a + run of abandoned orders back to the storefront itself." + - Product photos carry no text alternative. Neither described nor marked decorative, they leave a screen reader + stopping on each one with nothing to say. The banner image at the top, which no nearby text describes, is silent + too. + - Submitting the checkout form produces a validation error no screen reader will announce. The message sits on screen + in red beside its field, but nothing in the markup ties the two together, and nothing tells assistive technology + that new text has appeared. The customer knows the order failed and cannot find out why. + - Others never reach checkout. Someone with a tremor or limited hand movement works from the keyboard instead of a + mouse. The main menu cannot be reached that way, and the focus outline is styled off across the site, so anyone on a + keyboard has no idea where they are on the page. + - There is no accessible fallback and no second route to a purchase. Complaints reached the company, then a legal + notice citing the ADA and the European Accessibility Act. The EU launch is on hold until the storefront can be shown + to work. + - You are the lead frontend engineer. Audit the storefront, fix what blocks these customers, and add checks that stop + the same faults shipping again. +overview: + - "Assistive technology works from the semantics a page exposes: what each element is, what it is called, what state + it is in, what it belongs to. A screen reader, a switch device and a braille display all read that same layer. + Replace a button with a styled div and the information is gone for all of them, and nothing downstream can + reconstruct it." + - Around 16% of people live with a significant disability. Since June 2025 the European Accessibility Act has put EU + e-commerce in legal scope, and the standard it points to, EN 301 549, sets WCAG 2.2 AA as the bar in practice. + - "The premise here is that accessibility is how a page behaves under real use, not what its markup contains. Scanners + inspect the rendered DOM and settle only what is statically decidable: a missing alt attribute, a contrast ratio, an + ARIA role that contradicts its required children. Whether alt text is meaningful, whether focus order follows the + layout, whether an error is announced, whether a widget behaves like the role it claims, none of that is decidable + from a snapshot. A page can pass every rule in the ruleset and remain unusable." + - "Three levels cover both sides of that line. The homepage audit takes what scanning does well, contrast ratios and + missing text alternatives, then the first thing it cannot see, whether the page works from a keyboard at all. + Checkout takes the rest: containers dressed as controls that report clean while stranding a keyboard user behind a + modal and swallowing a validation error. The last level turns to the accessibility gate, green for months without + once loading the pages a customer pays on." + - What you leave with is a sense of how far each layer of testing reaches. Linting reads your source and catches + authoring mistakes before anything renders, but it stops at code you wrote. Scanning reads the rendered DOM and + catches faults in a snapshot. Driving the page with a keyboard catches behavior, which a snapshot never shows. + Watching real sessions catches what changed after you shipped, which is the only layer that reaches code you do not + control. + - The storefront is pre-provisioned in a Dev Container, so the work is the audit, the repairs and the test coverage. +rewards: + deadline: "" + tiers: + - label: 1st place + description: Complete every published level and submit a verified solution. + - label: Top 3 + description: Finish the adventure with all verification checks passing. +contributor: + name: Homayoun Mohammadi + url: https://homayounmmdy.vercel.app + about: "Frontend developer with 4+ years building production apps in Next.js, React, and TypeScript. Open-source + contributor to projects at Microsoft, W3C, and GitHub. Author of two npm packages: a 12-language RTL text engine and + a TypeScript SVG mapping toolkit." +upcoming_levels: + - level: intermediate + name: Intermediate + difficulty: Intermediate + - level: expert + name: Expert + difficulty: Expert +levels: + - level: beginner + emoji: 🟢 + title: The Initial Audit + devcontainer: adventure-accessibility-nightmare_beginner + community_url: "" + topics: + - axe-core + - Playwright + - Lighthouse + - WCAG 2.2 + - React + meta_description: Audit a React storefront for WCAG 2.2 failures with axe-core, Playwright, and Lighthouse. Fix color + contrast, alt text, semantic HTML, keyboard navigation, and visible focus indicators. + summary: Audit the ShopSmart homepage and fix critical WCAG 2.2 accessibility violations before the initial compliance + review. + audience: Frontend developers who understand basic HTML, CSS, React, and browser developer tools but are new to + systematic accessibility testing. + backstory: + - ShopSmart has received an urgent legal notice about accessibility barriers on its public storefront. + - Customers cannot reliably navigate the page by keyboard, important images have no text alternatives, several + controls use incorrect semantics, and the visual design does not meet WCAG contrast requirements. + - Your task is to audit the homepage, understand the failures, and repair the most serious problems before + regulators escalate the case. + objective: + - Remove all serious and critical axe-core violations from the ShopSmart homepage. + - Provide meaningful alternative text for every informative image. + - Ensure the menu and primary call-to-action are semantic, keyboard-focusable controls. + - Add visible focus indicators for interactive elements. + - Achieve a Lighthouse accessibility score of at least 95. + what_you_learn: + - How to interpret automated [axe-core](https://github.com/dequelabs/axe-core) accessibility reports. + - How semantic HTML affects keyboard and assistive-technology behavior. + - How to identify and correct WCAG color-contrast failures. + - How to provide useful text alternatives for images. + - How to verify accessibility outcomes with [Playwright](https://playwright.dev/) and + [Lighthouse](https://developer.chrome.com/docs/lighthouse/overview/). + architecture: + - A React and Vite storefront runs on port 5173 inside the Dev Container. + - Playwright launches Chromium and uses axe-core to scan the rendered homepage. + - Lighthouse CI measures the accessibility category and requires a score of 95 or higher. + - Players edit src/App.jsx and src/styles.css. The tests and verification scripts define the required outcomes and + should not be weakened or bypassed. + toolbox: + - name: axe-core + url: https://github.com/dequelabs/axe-core + description: Automated accessibility engine used to detect WCAG violations. + - name: Playwright + url: https://playwright.dev/ + description: Browser automation framework used for accessibility and keyboard tests. + - name: Lighthouse + url: https://developer.chrome.com/docs/lighthouse/ + description: Browser audit tool used to measure the final accessibility score. + - name: WCAG 2.2 + url: https://www.w3.org/TR/WCAG22/ + description: Accessibility standard that defines the success criteria for this level. + services: + - name: ShopSmart + url: http://localhost:5173 + description: The intentionally inaccessible React storefront. + how_to_play: + - id: start + title: Start ShopSmart + content: | + Start the application from the repository root: + + ```bash + make accessibility-nightmare-beginner + ``` + + Then open port 5173 in your browser. + - id: explore + title: Explore the Broken Storefront + content: | + Open ShopSmart on port 5173 and navigate the page using only the keyboard. + + Run the automated checks: + + ```bash + npm run test:a11y + npm run test:lighthouse + ``` + + Review the reported axe rule IDs, affected elements, keyboard failures, + and Lighthouse score before changing the implementation. + - id: implement + title: Repair the Accessibility Failures + content: | + Work primarily in: + + ```text + src/App.jsx + src/styles.css + ``` + + Correct the semantic controls, image alternatives, contrast problems, + keyboard behavior, and focus appearance. + + Do not remove tests, lower score thresholds, disable axe rules, or weaken + the verification logic. + + When ready, run: + + ```bash + ./verify.sh + ``` + helpful_links: + - title: Understanding WCAG 2.2 + url: https://www.w3.org/WAI/WCAG22/Understanding/ + description: Plain-language explanations of the relevant WCAG success criteria. + - title: axe-core Playwright integration + url: https://playwright.dev/docs/accessibility-testing + description: Guidance for running axe accessibility scans in Playwright. + - title: Keyboard accessibility + url: https://www.w3.org/WAI/WCAG22/Understanding/keyboard + description: Explains why functionality must be available from a keyboard. + - title: Focus appearance + url: https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance + description: Explains the WCAG 2.2 requirements for visible keyboard focus. + contributor: + name: Ehtasham Yasin + url: https://weblight-app.vercel.app + about: Full-stack and AI Automation Engineer with 4+ years of frontend experience building React and Next.js + applications, API-driven systems, and automated workflows with Docker and n8n. Interested in DevSecOps and + open-source development. + verification: + command: ./verify.sh + description: Once you think you've solved the challenge, run the verification script. If it fails it will tell you which + checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion. diff --git a/src/data/adventures/adventure-accessibility-nightmare/beginner-posts.json b/src/data/adventures/adventure-accessibility-nightmare/beginner-posts.json new file mode 100644 index 000000000..bacedaacf --- /dev/null +++ b/src/data/adventures/adventure-accessibility-nightmare/beginner-posts.json @@ -0,0 +1,5 @@ +{ + "discussionUrl": "", + "discussionPosts": [], + "totalReplies": 0 +} diff --git a/src/lib/adventure-credit.ts b/src/lib/adventure-credit.ts index 7f96afe74..c8785169c 100644 --- a/src/lib/adventure-credit.ts +++ b/src/lib/adventure-credit.ts @@ -103,8 +103,9 @@ export type PillCredit = { * An adventure with no designer has no level builders either, because the * content schema rejects that combination (see `creditIntegrityError`), so this * returns null rather than promoting a builder into the pill. The no-designer - * case is real: `sync-adventure.mjs` deliberately omits `contributor`, and a - * reviewer adds it as a PR checklist item. + * case is still real: `sync-adventure.mjs` copies `contributor` from the + * challenges repo's `docs/index.yaml`, so an adventure whose upstream names no + * designer yet arrives without one until a reviewer adds it. */ export function adventurePillCredit(adventure: CreditSource): PillCredit | null { const designer = adventure.contributor; diff --git a/src/test/lib/adventure-credit.test.ts b/src/test/lib/adventure-credit.test.ts index 32159e0f3..f3aa2c980 100644 --- a/src/test/lib/adventure-credit.test.ts +++ b/src/test/lib/adventure-credit.test.ts @@ -229,7 +229,8 @@ describe("creditIntegrityError", () => { expect(creditIntegrityError(adventure("a", KAT, [undefined, SIMON, undefined]))).toBeNull(); }); - // The freshly-synced state: sync-adventure.mjs omits `contributor` and a + // The freshly-synced state when the challenges repo names no designer in + // docs/index.yaml yet: sync-adventure.mjs emits no `contributor` and a // reviewer adds it later, so no-designer-and-no-builders must stay valid. it("passes an adventure with neither, which is what sync-adventure emits", () => { expect(creditIntegrityError(adventure("a", undefined))).toBeNull(); diff --git a/src/test/scripts/sync-adventure.test.ts b/src/test/scripts/sync-adventure.test.ts index 35b948c9b..a9b483158 100644 --- a/src/test/scripts/sync-adventure.test.ts +++ b/src/test/scripts/sync-adventure.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { currentMonth } from "../../../scripts/sync-adventure.mjs"; +import { buildLevel, currentMonth, mergeLevels, pickContributor } from "../../../scripts/sync-adventure.mjs"; const MONTH_SCHEMA = /^[A-Z]{3} \d{4}$/; @@ -54,3 +54,99 @@ describe("currentMonth", () => { expect(result).not.toMatch(MONTH_SCHEMA); }); }); + +describe("pickContributor", () => { + it("keeps the four fields the content schema accepts", () => { + expect( + pickContributor({ + name: "Ada Lovelace", + url: "https://example.com", + about: "Writes notes.", + discourse_username: "ada", + }), + ).toEqual({ + name: "Ada Lovelace", + url: "https://example.com", + about: "Writes notes.", + discourse_username: "ada", + }); + }); + + it("drops fields the strict content schema would reject", () => { + // The challenges repo owns its own index.yaml schema and may carry fields + // this site has no column for. Passing them through fails `astro sync`. + const result = pickContributor({ name: "Ada Lovelace", github: "ada", avatar: "a.png" }); + expect(result).toEqual({ name: "Ada Lovelace" }); + }); + + it("omits absent optional fields rather than emitting empty values", () => { + expect(pickContributor({ name: "Ada Lovelace", url: "", about: undefined })).toEqual({ + name: "Ada Lovelace", + }); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ["a block with no name", { url: "https://example.com" }], + ["a non-object", "Ada Lovelace"], + ])("returns null for %s", (_label, input) => { + expect(pickContributor(input)).toBeNull(); + }); +}); + +describe("buildLevel contributor (the challenge builder)", () => { + // The challenges repo puts the designer in docs/index.yaml and, only when a + // different person built a level, a `contributor:` in that level's YAML. When + // designer and builder are the same person the level YAML carries no + // contributor at all and the site falls back to the designer. + const base = { level: "beginner", topics: ["a11y"], verification: { command: "./v.sh", description: "d" } }; + + it("carries a level builder through from the level YAML", () => { + const result = buildLevel({ ...base, contributor: { name: "Grace Hopper", url: "https://example.com" } }, ["a11y"]); + expect(result.contributor).toEqual({ name: "Grace Hopper", url: "https://example.com" }); + }); + + it("drops builder fields the strict content schema would reject", () => { + const result = buildLevel({ ...base, contributor: { name: "Grace Hopper", github: "grace" } }, ["a11y"]); + expect(result.contributor).toEqual({ name: "Grace Hopper" }); + }); + + it("emits no contributor when the level YAML has none, so the designer is credited", () => { + const result = buildLevel({ ...base }, ["a11y"]); + expect(result).not.toHaveProperty("contributor"); + }); +}); + +describe("mergeLevels contributor preservation", () => { + const GRACE = { name: "Grace Hopper", url: "https://example.com" }; + const ADA = { name: "Ada Lovelace" }; + const lvl = (extra: object = {}) => ({ level: "beginner", topics: ["a11y"], ...extra }); + + it("keeps a builder already credited on the website when upstream names none", () => { + const merged = mergeLevels([lvl({ contributor: GRACE })], [lvl()], [lvl()]); + expect(merged[0].contributor).toEqual(GRACE); + }); + + it("keeps the website builder even when upstream names a different one", () => { + // Losing an existing credit silently misattributes someone's work. Re-crediting + // a level is a deliberate hand-edit, the same rule as the adventure designer. + const merged = mergeLevels([lvl({ contributor: GRACE })], [lvl({ contributor: ADA })], [lvl({ contributor: ADA })]); + expect(merged[0].contributor).toEqual(GRACE); + }); + + it("takes the upstream builder when the website has none", () => { + const merged = mergeLevels([lvl()], [lvl({ contributor: ADA })], [lvl({ contributor: ADA })]); + expect(merged[0].contributor).toEqual(ADA); + }); + + it("leaves a level with no builder on either side uncredited, so the designer is credited", () => { + const merged = mergeLevels([lvl()], [lvl()], [lvl()]); + expect(merged[0]).not.toHaveProperty("contributor"); + }); + + it("adds a builder to a level that is brand new to the website", () => { + const merged = mergeLevels([], [lvl({ contributor: ADA })], [lvl({ contributor: ADA })]); + expect(merged[0].contributor).toEqual(ADA); + }); +});