From 016cc645dddb6e6ca24bcecb4f3b86f64882dce4 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Sat, 19 Sep 2026 21:43:02 +0200 Subject: [PATCH 1/8] Add expert level to The Accessibility Nightmare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Compliance Engine asks why a gate that has been green for months never caught the barrier the company is being audited over. The answer is in two parts: it only ever scanned one of the four pages a customer passes through, and the fault it was meant to find is behavioural, so no amount of scanning would have reached it. Players extend scanner coverage, discover that doing so finds nothing, diagnose the silence with a virtual screen reader, repair the routing, and produce a compliance report that records what each check proved. Verification plants a fault on the payment page and requires the scan to go red, so coverage is measured rather than assumed โ€” a suite that stays green never opened that page. Adds check_playwright_tests_detect to lib/scripts/node.sh, which asserts a tagged suite fails. Purely additive; no existing caller changes. Also removes the rewards block from the adventure index, as the adventure has no rewards. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../devcontainer.json | 40 + .../post-create.sh | 21 + .../post-start.sh | 44 + .../accessibility-nightmare-expert.svg | 155 ++ .../accessibility-nightmare/docs/expert.yaml | 261 +++ .../accessibility-nightmare/docs/index.yaml | 8 - .../accessibility-nightmare/expert/.gitignore | 5 + .../accessibility-nightmare/expert/Makefile | 18 + .../accessibility-nightmare/expert/index.html | 16 + .../expert/package-lock.json | 1737 +++++++++++++++++ .../expert/package.json | 23 + .../expert/playwright.config.js | 25 + .../expert/public/coverage-table.html | 759 +++++++ .../expert/public/images/headphones.svg | 6 + .../expert/public/images/shoes.svg | 5 + .../expert/public/images/store.svg | 7 + .../expert/public/images/watch.svg | 6 + .../expert/src/App.jsx | 68 + .../expert/src/components/BasketDialog.jsx | 71 + .../expert/src/components/CheckoutForm.jsx | 96 + .../expert/src/components/SizePicker.jsx | 107 + .../expert/src/dev/announcements.js | 138 ++ .../expert/src/main.jsx | 29 + .../expert/src/pages/Checkout.jsx | 18 + .../expert/src/pages/Home.jsx | 46 + .../expert/src/pages/Payment.jsx | 49 + .../expert/src/pages/Product.jsx | 53 + .../expert/src/products.js | 30 + .../expert/src/styles.css | 443 +++++ .../expert/tests/compliance.spec.js | 38 + .../expert/tests/global-setup.js | 24 + .../expert/tests/lib/navigation.js | 36 + .../expert/tests/lib/screen-reader.js | 75 + .../accessibility-nightmare/expert/verify.sh | 168 ++ .../expert/vite.config.js | 46 + lib/scripts/node.sh | 31 + 36 files changed, 4694 insertions(+), 8 deletions(-) create mode 100644 .devcontainer/accessibility-nightmare_expert/devcontainer.json create mode 100755 .devcontainer/accessibility-nightmare_expert/post-create.sh create mode 100755 .devcontainer/accessibility-nightmare_expert/post-start.sh create mode 100644 adventures/accessibility-nightmare/docs/diagrams/accessibility-nightmare-expert.svg create mode 100644 adventures/accessibility-nightmare/docs/expert.yaml create mode 100644 adventures/accessibility-nightmare/expert/.gitignore create mode 100644 adventures/accessibility-nightmare/expert/Makefile create mode 100644 adventures/accessibility-nightmare/expert/index.html create mode 100644 adventures/accessibility-nightmare/expert/package-lock.json create mode 100644 adventures/accessibility-nightmare/expert/package.json create mode 100644 adventures/accessibility-nightmare/expert/playwright.config.js create mode 100644 adventures/accessibility-nightmare/expert/public/coverage-table.html create mode 100644 adventures/accessibility-nightmare/expert/public/images/headphones.svg create mode 100644 adventures/accessibility-nightmare/expert/public/images/shoes.svg create mode 100644 adventures/accessibility-nightmare/expert/public/images/store.svg create mode 100644 adventures/accessibility-nightmare/expert/public/images/watch.svg create mode 100644 adventures/accessibility-nightmare/expert/src/App.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/components/BasketDialog.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/components/CheckoutForm.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/components/SizePicker.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/dev/announcements.js create mode 100644 adventures/accessibility-nightmare/expert/src/main.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/pages/Checkout.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/pages/Home.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/pages/Payment.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/pages/Product.jsx create mode 100644 adventures/accessibility-nightmare/expert/src/products.js create mode 100644 adventures/accessibility-nightmare/expert/src/styles.css create mode 100644 adventures/accessibility-nightmare/expert/tests/compliance.spec.js create mode 100644 adventures/accessibility-nightmare/expert/tests/global-setup.js create mode 100644 adventures/accessibility-nightmare/expert/tests/lib/navigation.js create mode 100644 adventures/accessibility-nightmare/expert/tests/lib/screen-reader.js create mode 100755 adventures/accessibility-nightmare/expert/verify.sh create mode 100644 adventures/accessibility-nightmare/expert/vite.config.js diff --git a/.devcontainer/accessibility-nightmare_expert/devcontainer.json b/.devcontainer/accessibility-nightmare_expert/devcontainer.json new file mode 100644 index 0000000..062d00c --- /dev/null +++ b/.devcontainer/accessibility-nightmare_expert/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "โ™ฟ The Accessibility Nightmare | ๐Ÿ”ด Expert (The Compliance Engine)", + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/adventures/accessibility-nightmare/expert", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "postCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/accessibility-nightmare_expert/post-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/accessibility-nightmare_expert/post-start.sh", + "customizations": { + "vscode": { + "extensions": [ + "ms-playwright.playwright" + ] + }, + "codespaces": { + "openFiles": [ + "tests/compliance.spec.js" + ], + "permissions": { + "codespaces": "write" + } + } + }, + "forwardPorts": [ + 5173 + ], + "portsAttributes": { + "5173": { + "label": "ShopSmart", + "onAutoForward": "notify" + } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } +} diff --git a/.devcontainer/accessibility-nightmare_expert/post-create.sh b/.devcontainer/accessibility-nightmare_expert/post-create.sh new file mode 100755 index 0000000..93428ed --- /dev/null +++ b/.devcontainer/accessibility-nightmare_expert/post-create.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# shellcheck disable=SC1091 +source "${REPO_ROOT}/lib/scripts/tracker.sh" +set_tracking_context "accessibility-nightmare" "expert" "" "" "" +track_container_created + +"${REPO_ROOT}/lib/shared/init.sh" --version v0.17.0 + +CHALLENGE_DIR="${REPO_ROOT}/adventures/accessibility-nightmare/expert" + +echo "โœจ Installing ShopSmart dependencies..." +cd "${CHALLENGE_DIR}" +npm ci + +echo "โœจ Installing Playwright Chromium..." +npx playwright install --with-deps chromium + +echo "โœ… Post-create complete." diff --git a/.devcontainer/accessibility-nightmare_expert/post-start.sh b/.devcontainer/accessibility-nightmare_expert/post-start.sh new file mode 100755 index 0000000..9322614 --- /dev/null +++ b/.devcontainer/accessibility-nightmare_expert/post-start.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="${REPO_ROOT}/adventures/accessibility-nightmare/expert" + +echo "โœจ Starting The Accessibility Nightmare - Expert Level" + +cd "${CHALLENGE_DIR}" + +LOG_FILE="/tmp/accessibility-nightmare-vite.log" + +if ! pgrep -f "vite --host 0.0.0.0" >/dev/null 2>&1; then + echo "โœจ Starting ShopSmart on port 5173" + setsid nohup npm run dev "${LOG_FILE}" 2>&1 & + disown 2>/dev/null || true +fi + +for _ in {1..30}; do + if curl --fail --silent http://127.0.0.1:5173 >/dev/null; then + echo "โœ… ShopSmart is running on port 5173" + break + fi + sleep 1 +done + +if ! curl --fail --silent http://127.0.0.1:5173 >/dev/null; then + echo "โŒ ShopSmart failed to start." + if [[ -f "${LOG_FILE}" ]]; then + tail -n 20 "${LOG_FILE}" + fi + echo "Run 'npm run dev' from the expert directory to start it manually." + exit 1 +fi + +echo "" +echo "๐Ÿ›’ Open the Ports tab, click ShopSmart on 5173." +echo " The compliance gate is in tests/compliance.spec.js โ€” run it with:" +echo " npm run test:a11y" +echo "" + +# shellcheck disable=SC1091 +source "${REPO_ROOT}/lib/scripts/tracker.sh" +set_tracking_context "accessibility-nightmare" "expert" "" "" "" +track_container_initialized diff --git a/adventures/accessibility-nightmare/docs/diagrams/accessibility-nightmare-expert.svg b/adventures/accessibility-nightmare/docs/diagrams/accessibility-nightmare-expert.svg new file mode 100644 index 0000000..2b049e7 --- /dev/null +++ b/adventures/accessibility-nightmare/docs/diagrams/accessibility-nightmare-expert.svg @@ -0,0 +1,155 @@ + + The Compliance Engine: what the gate covered, and the fault no scan could reach + A customer moves through four pages before an order is placed: the homepage, + the product page, the checkout and the payment page. The compliance gate was written when + the homepage was the only page, and has audited nothing else since, so three of the four + have never been in scope. Extending the scan to all four still reports nothing, because + the remaining fault is not in the markup. Hash routing swaps the page without a document + load: React re-renders, but focus never moves and no live region fires, so the + accessibility tree reports no change and a screen reader user is never told the page + changed. Of the three testing layers, axe-core reports zero violations on all four pages + and a keyboard walk finds every control reachable. Only the virtual screen reader hears + the silence. + + + + + + + + + + + + THE PURCHASE, FOUR ROUTES + + + Homepage + / + Where the gate was + written, years ago + + + + + Product page + /#/product/running-shoes + Choose a size and + add it to the basket + + + + + Checkout + /#/checkout + Delivery details and + form validation + + + + + Payment + /#/payment + Card details, the last + step before the order + + + + Audited on every push + one @scan test, always green + + + Never audited + added to the flow after the gate was written; nobody widened its scope + + + WHAT HAPPENS ON EVERY ROUTE CHANGE + + + A link is followed + The customer moves + to the next step + + + + + hashchange + The address changes, + the document does not + + + + + React re-renders + New page on screen, + pixel perfect + + + + + Nothing is announced + The screen reader has + no idea anything moved + + + + Focus never moves ยท no live region fires ยท the accessibility tree reports no change ยท + WCAG 2.4.2 and 4.1.3 + + + + THREE WAYS OF LOOKING AT THE SAME FOUR PAGES + + + ./verify.sh + runs the suite and + prints a checklist + + + + + Playwright + drives Chromium and + listens to the page + + + + + + + axe-core + zero violations, on all four pages, before and after the repair + + + keyboard walk + every control reachable, nothing trapped, still no fault found + + + Guidepup Virtual Screen Reader + hears the silence โ€” the only layer that can + + + + The scan was never wrong. It was answering a + question nobody had checked the scope of. + diff --git a/adventures/accessibility-nightmare/docs/expert.yaml b/adventures/accessibility-nightmare/docs/expert.yaml new file mode 100644 index 0000000..d14fecb --- /dev/null +++ b/adventures/accessibility-nightmare/docs/expert.yaml @@ -0,0 +1,261 @@ +level: expert +emoji: "๐Ÿ”ด" +title: "The Compliance Engine" +devcontainer: accessibility-nightmare_expert +community_url: "" # TODO: add community thread URL once the adventure is live + +topics: + - axe-core + - Playwright + - WCAG 2.2 + - React + - Screen reader testing + +contributor: + name: "Sinduri Guntupalli" + url: "https://sinduri.lol/" + about: >- + Experienced in product and program management, web development, configuration management, + web analytics, SEO, and community and ecosystem management. Passionate about open source + with active involvement in the Drupal community. + +meta_description: >- + Debug an accessibility compliance gate that has been green for months while blind users + cannot navigate the app. Extend scanner coverage, discover what automated tools cannot + detect, repair what it misses, and produce a compliance report that stands up to audit. + +summary: >- + Repair the automated check that is meant to stop inaccessible code from shipping, and + find out why it has never once caught the navigation barrier the company is being audited over. + +audience: >- + Frontend developers who are comfortable with Playwright and automated accessibility scanning, + and who want to understand what their compliance gate is actually measuring. Assumes familiarity + with axe-core and the ARIA patterns from the intermediate level. + +backstory: + - >- + The components are repaired. The legal team needs proof of continuous compliance for the + EAA audit next week. Every merge passes the accessibility check, and its run history has + been green for months. + - >- + But one complaint in the original legal notice was never reproduced. A user reported that + following any link left them with no idea where they had landed โ€” the page had changed, + and their screen reader carried on as though nothing had. Nobody could make the check + report it. + - >- + The payment page was added to the flow after the gate was written, and the checkout before + that. Nobody widened its scope. Its green history is not a history of the product being + accessible, it is a history of the same page being checked over and over. + +objective: + - >- + Have the gate audit every step a customer moves through, not only the homepage. + - >- + Find the navigation barrier that automated scanning has never reported. + - >- + Fix it and have the gate verify the fix holds. + - >- + Produce a report proving each customer journey step was audited and passes. + +what_you_learn: + - >- + Why a green accessibility gate means nothing until you know which pages and states it + actually tested. ([WCAG 2.4: Navigable](https://www.w3.org/WAI/WCAG22/Understanding/navigable)) + - >- + Why automated scanners cannot detect faults that only exist when a user does something โ€” + and which tool bridges the gap. + ([WCAG 2.4.2: Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled)) + - >- + How a single-page application can silently break the navigation contract that assistive + technology depends on, and what it takes to restore it. + ([WCAG 4.1.3: Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages)) + - >- + How a compliance report is evidence of conformance over time โ€” what was tested, how + it was tested, and that it passed. + +architecture: + - >- + A React and Vite storefront runs on port 5173 inside the Dev Container. All three + checkout components arrive repaired: the basket confirmation is a proper modal, the size + picker follows the ARIA combobox pattern, and the checkout form announces its errors to + screen reader users. + - >- + The checkout flow has four steps: /#/ (homepage), /#/product/running-shoes (choose a + size), /#/checkout (delivery details), and /#/payment (card details). The compliance + gate was written for the first of those and has never been updated. + - >- + Routing lives in src/App.jsx, which decides what renders for each address. That file is + yours to change once you have found the fault. The page components under src/pages/ and + the repaired widgets under src/components/ are not. + - >- + Your editing surface: tests/compliance.spec.js (the gate), playwright.config.js (the + reporter), and src/App.jsx (the fix). Leave everything else alone โ€” it defines the problem. + +architecture_diagram: "accessibility-nightmare-expert.svg" + +toolbox: + - name: "Virtual Screen Reader" + url: "https://github.com/guidepup/virtual-screen-reader" + description: >- + Reads the browser accessibility tree and reports what a real screen reader would + announce. Add ?listen to the storefront address to watch its output live, and + drive the same simulation from your tests via tests/lib/screen-reader.js. + - name: "axe-core" + url: "https://github.com/dequelabs/axe-core" + description: >- + The automated scanner already in the compliance gate. Fast and structural โ€” + it checks what the markup says, not what the browser announces. + - name: "Playwright Reporter API" + url: "https://playwright.dev/docs/test-reporters" + description: >- + How to route test results to a file. The JSON reporter writes a machine-readable + report; test.info().annotations records findings against individual tests. + +services: + - name: "ShopSmart" + port: 5173 + description: "The ShopSmart storefront." + +how_to_play: + - id: browse + title: "Open the Store" + content: | + Add the product route to whatever address the Ports tab gave you: + + ```text + -5173.app.github.dev/#/product/running-shoes + ``` + + Use a browser tab rather than the editor's built-in preview. The preview + cannot load the forwarded port while it is private. + + If you would rather work inside the preview, make the port public first: + in the Ports panel, right-click ShopSmart, choose Port Visibility, then + Public, and reload the preview. Turn it back to Private when you are finished. + + **On a Mac, turn keyboard navigation on before you start.** Safari does + not move focus to links and buttons unless it is enabled, under System + Settings, Keyboard, Keyboard navigation. + + - id: explore + title: "Explore the Gate" + content: | + Run the compliance gate as it stands: + + ```bash + npm run test:a11y + ``` + + It passes. It has always passed. + + Now look at what it actually does. Open `tests/compliance.spec.js` and read it. + Which page does it visit? Follow a customer from the homepage to the payment page + and count the steps the gate never sees. + + Then listen to the storefront. Add `?listen` to the address to open a panel + showing what a screen reader would announce as you move around: + + ```text + -5173.app.github.dev/?listen + ``` + + Move through the page with Tab, then follow a link to another page. The panel + marks the address change with a dim `ยท` line. Read what arrives after that + marker, and what does not. + + The same simulation is available to your tests through the helpers in + `tests/lib/screen-reader.js`. + + A coverage reference is linked in the storefront nav, at + `/coverage-table.html`. It records what the gate checked on its last run, and + which testing layer is capable of detecting each kind of fault. + + - id: implement + title: "Repair the Gate" + content: | + Work in `tests/compliance.spec.js`, `playwright.config.js`, and `src/App.jsx`. + + Start with coverage. Tag your new scanner tests `@scan` so the verify script + can find them. The gate should visit every page a customer passes through โ€” + homepage, product, checkout, and payment. + + When the extended scan still reports nothing wrong, that is the lesson, not a bug. + axe-core reads the markup. If the markup is correct at every moment it looks, there is + nothing for it to report โ€” which tells you where to look next, not that you are done. + + Tag your navigation tests `@transition`. A good detector moves between routes the + way a customer would and asserts that the destination made itself known. Assert the + outcome, not the mechanism, and the test will survive whichever repair you choose. + + Once you have a failing `@transition` test, fix `src/App.jsx` to make it pass. Write + the test against the outcome rather than any particular repair, and more than one + approach will satisfy it. + + For each `@transition` test, use `test.info().annotations.push(...)` to attach a + `route-announcement` annotation recording which WCAG criterion was satisfied. + This is what goes into the compliance report. + + Push the annotation *before* the assertion, not after it. A failed assertion + ends the test where it stands, so anything recorded after it never reaches the + report โ€” and a test that fails without saying what it was checking is the same + blind spot you were sent here to close. + + Finally, add a JSON reporter to `playwright.config.js` so that running + `npm run test:a11y` writes a `compliance-report.json` file: + + ```js + reporter: [['list'], ['json', { outputFile: 'compliance-report.json' }]], + ``` + + When you are ready: + + ```bash + npm run test:a11y # generates the report + ./verify.sh + ``` + + - id: reflect + title: "Ask What the Check Covers" + content: | + The check was green for months while nobody could move through the shop without + sight. Two things made that possible: it never looked past the homepage, and the + tool it used cannot hear what the browser says out loud. + + Every layer of a testing stack catches something the others miss, and a gate built + on one layer reports the absence of the faults it can see. Which layer is missing + from the gate you rely on at work, and what would it take to add it? + +helpful_links: + - title: "Compliance Gate Coverage" + url: "/coverage-table.html" + description: >- + A reference table โ€” served by the dev server โ€” showing which checks each + testing layer (axe-core, keyboard, screen reader) can and cannot detect, + and which gate tag covers each one. + - title: "Understanding WCAG 2.4.2: Page Titled" + url: "https://www.w3.org/WAI/WCAG22/Understanding/page-titled" + description: >- + Why every page in an application needs a title that describes its purpose, + and why this matters especially in single-page applications. + - title: "Understanding WCAG 4.1.3: Status Messages" + url: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages" + description: >- + When content updates without a page reload, assistive technology needs a + programmatic signal before it will report the change. What counts as one, + and when the criterion applies. + - title: "Guidepup Virtual Screen Reader" + url: "https://github.com/guidepup/virtual-screen-reader" + description: >- + A Playwright-compatible library that reads the browser accessibility tree + and reports spoken phrases the way a real screen reader would. + - title: "Playwright: test.info().annotations" + url: "https://playwright.dev/docs/api/class-testinfo#test-info-annotations" + description: >- + Attaches structured metadata to a test result. The JSON reporter includes + annotations in its output, making them machine-readable in the report file. + - title: "Playwright reporters" + url: "https://playwright.dev/docs/test-reporters" + description: >- + How to write test results to a file. The JSON reporter option accepts an + outputFile path. diff --git a/adventures/accessibility-nightmare/docs/index.yaml b/adventures/accessibility-nightmare/docs/index.yaml index 3af8a88..3d7c840 100644 --- a/adventures/accessibility-nightmare/docs/index.yaml +++ b/adventures/accessibility-nightmare/docs/index.yaml @@ -89,11 +89,3 @@ overview: - >- The storefront is pre-provisioned in a Dev Container, so the work is the audit, the repairs and the test coverage. - -rewards: - deadline: "" # TODO: fill in once the adventure goes live - 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." \ No newline at end of file diff --git a/adventures/accessibility-nightmare/expert/.gitignore b/adventures/accessibility-nightmare/expert/.gitignore new file mode 100644 index 0000000..24dd2f1 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +compliance-report.json diff --git a/adventures/accessibility-nightmare/expert/Makefile b/adventures/accessibility-nightmare/expert/Makefile new file mode 100644 index 0000000..91da3a9 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/Makefile @@ -0,0 +1,18 @@ +.DEFAULT_GOAL := help + +.PHONY: help app test verify + +help: + @echo "The Accessibility Nightmare - Expert: Available Commands:" + @echo " make app Start the ShopSmart application" + @echo " make test Run the compliance gate" + @echo " make verify Run the verification script" + +app: + @npm run dev + +test: + @npm run test:a11y + +verify: + @./verify.sh diff --git a/adventures/accessibility-nightmare/expert/index.html b/adventures/accessibility-nightmare/expert/index.html new file mode 100644 index 0000000..bad6e2b --- /dev/null +++ b/adventures/accessibility-nightmare/expert/index.html @@ -0,0 +1,16 @@ + + + + + + + + ShopSmart + + + +
+ + + + diff --git a/adventures/accessibility-nightmare/expert/package-lock.json b/adventures/accessibility-nightmare/expert/package-lock.json new file mode 100644 index 0000000..b8cbf25 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/package-lock.json @@ -0,0 +1,1737 @@ +{ + "name": "accessibility-nightmare-expert", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "accessibility-nightmare-expert", + "version": "1.0.0", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@guidepup/virtual-screen-reader": "^0.32.1", + "@playwright/test": "^1.61.0", + "@vitejs/plugin-react": "^5.0.0", + "vite": "^8.0.0" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@guidepup/virtual-screen-reader": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/@guidepup/virtual-screen-reader/-/virtual-screen-reader-0.32.1.tgz", + "integrity": "sha512-QoxyFmIkE4em+Up0VJ+cchpVndHbwMGUztbTWasNbjWfxT28Sf5XYq3U+ueM1sENk7AV4+0N7ahoYxjcFVAo8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "dom-accessibility-api": "^0.7.0", + "html-aria": "^0.5.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.7.1.tgz", + "integrity": "sha512-vdnCeZD+3wZ+8h8xXL/ZtBlvvoobOFyPzSiIfO6sGOZDqjFx4aLMAjZhl4rawj5xYz3UwP6Tgvyh0iH4IOCVnQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.428", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.428.tgz", + "integrity": "sha512-1JxbaFJj1bRKurj1uY3l4xxpU9kOUAUjcIgApj0qu1Pao5GhoIWI8iL0BeMYJ2njig1hBx0A7eKD9VGjH9wlHw==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-aria": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/html-aria/-/html-aria-0.5.3.tgz", + "integrity": "sha512-2SkCacsjXJu0e6D5vLSHlLs7KEdPZ7pIJqLh9EFedPqrAaywthe7jJWQ+PjEyB7bpmWMMOsBJuV4WlYd9WRiug==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/adventures/accessibility-nightmare/expert/package.json b/adventures/accessibility-nightmare/expert/package.json new file mode 100644 index 0000000..6e02726 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/package.json @@ -0,0 +1,23 @@ +{ + "name": "accessibility-nightmare-expert", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0", + "test:a11y": "playwright test" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@guidepup/virtual-screen-reader": "^0.32.1", + "@playwright/test": "^1.61.0", + "@vitejs/plugin-react": "^5.0.0", + "vite": "^8.0.0" + } +} diff --git a/adventures/accessibility-nightmare/expert/playwright.config.js b/adventures/accessibility-nightmare/expert/playwright.config.js new file mode 100644 index 0000000..2ff7c3d --- /dev/null +++ b/adventures/accessibility-nightmare/expert/playwright.config.js @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + globalSetup: './tests/global-setup.js', + timeout: 30_000, + use: { + baseURL: 'http://127.0.0.1:5173', + trace: 'on-first-retry', + }, + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: true, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}); diff --git a/adventures/accessibility-nightmare/expert/public/coverage-table.html b/adventures/accessibility-nightmare/expert/public/coverage-table.html new file mode 100644 index 0000000..4eb5ced --- /dev/null +++ b/adventures/accessibility-nightmare/expert/public/coverage-table.html @@ -0,0 +1,759 @@ + + + + + +Coverage reference โ€” ShopSmart + + + + + + +
+ + +
+

Expert level ยท The Compliance Engine

+

Conformance coverage

+

+ Every fault class this storefront can be audited for, which testing layer is + capable of detecting it, and whether the compliance gate actually verified it on + its last run. Read from compliance-report.json. +

+ +

+ To hear the screen reader column for yourself, open the storefront at + /?listen and watch the panel while you move between pages. + That panel runs on the storefront, not on this page. +

+ +
+ +
+ Layer key + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ Fault classes grouped by the testing layer capable of detecting them. + The final column reports what the gate verified on its last run. +
Accessibility checkWCAGaxe-coreKeyboardScreen readerVerified by gate
+
+
+ +
+

ยฉ 2026 ShopSmart

+
+
+ + + + + diff --git a/adventures/accessibility-nightmare/expert/public/images/headphones.svg b/adventures/accessibility-nightmare/expert/public/images/headphones.svg new file mode 100644 index 0000000..1bc613f --- /dev/null +++ b/adventures/accessibility-nightmare/expert/public/images/headphones.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/adventures/accessibility-nightmare/expert/public/images/shoes.svg b/adventures/accessibility-nightmare/expert/public/images/shoes.svg new file mode 100644 index 0000000..0a325c8 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/public/images/shoes.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/adventures/accessibility-nightmare/expert/public/images/store.svg b/adventures/accessibility-nightmare/expert/public/images/store.svg new file mode 100644 index 0000000..6055f42 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/public/images/store.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/adventures/accessibility-nightmare/expert/public/images/watch.svg b/adventures/accessibility-nightmare/expert/public/images/watch.svg new file mode 100644 index 0000000..be13fa2 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/public/images/watch.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/adventures/accessibility-nightmare/expert/src/App.jsx b/adventures/accessibility-nightmare/expert/src/App.jsx new file mode 100644 index 0000000..85b6f8a --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/App.jsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import Home from './pages/Home.jsx'; +import Product from './pages/Product.jsx'; +import Checkout from './pages/Checkout.jsx'; +import Payment from './pages/Payment.jsx'; +import { findProduct } from './products.js'; + +const PRODUCT_ROUTE = /^\/product\/([\w-]+)$/; + +function readRoute() { + const [path] = window.location.hash.replace(/^#/, '').split('?'); + return path || '/'; +} + +export default function App() { + const [route, setRoute] = useState('/'); + const [basket, setBasket] = useState(null); + + useEffect(() => { + const onHashChange = () => setRoute(readRoute()); + onHashChange(); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + const productSlug = route.match(PRODUCT_ROUTE)?.[1]; + const product = productSlug ? findProduct(productSlug) : undefined; + + let page = ; + if (route === '/checkout') { + page = ; + } else if (route === '/payment') { + page = ; + } else if (product) { + page = ; + } + + return ( +
+
+ + ShopSmart + + + + + +
+ + {page} + +
+

ยฉ 2026 ShopSmart

+
+
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/components/BasketDialog.jsx b/adventures/accessibility-nightmare/expert/src/components/BasketDialog.jsx new file mode 100644 index 0000000..51a8440 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/components/BasketDialog.jsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; + +export default function BasketDialog({ product, size, triggerRef, onClose }) { + const dialogRef = useRef(null); + + useEffect(() => { + const first = dialogRef.current?.querySelector( + 'a[href], button, [tabindex="0"]', + ); + first?.focus(); + }, []); + + useEffect(() => { + function handleKeyDown(e) { + if (e.key === 'Escape') { + onClose(); + return; + } + if (e.key !== 'Tab') return; + + const dialog = dialogRef.current; + const focusable = Array.from( + dialog.querySelectorAll('a[href], button'), + ).filter((el) => !el.disabled); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + triggerRef?.current?.focus(); + }; + }, [onClose, triggerRef]); + + return ( +
+
+

+ Added to basket +

+ +

+ {product.name}, size {size} +

+ + + Checkout + + + +
+
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/components/CheckoutForm.jsx b/adventures/accessibility-nightmare/expert/src/components/CheckoutForm.jsx new file mode 100644 index 0000000..6cdadd8 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/components/CheckoutForm.jsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from 'react'; + +const FIELDS = [ + { name: 'name', label: 'Full name', type: 'text', error: 'Enter your full name' }, + { name: 'email', label: 'Email', type: 'email', error: 'Enter your email address' }, +]; + +export default function CheckoutForm() { + const [values, setValues] = useState({ name: '', email: '' }); + const [errors, setErrors] = useState({}); + const [placed, setPlaced] = useState(false); + const liveRef = useRef(null); + const firstErrorRef = useRef(null); + + function handleChange(e) { + const { name, value } = e.target; + setValues((prev) => ({ ...prev, [name]: value })); + } + + function handleSubmit(e) { + e.preventDefault(); + + const nextErrors = {}; + for (const field of FIELDS) { + if (!values[field.name].trim()) nextErrors[field.name] = field.error; + } + + setErrors(nextErrors); + setPlaced(Object.keys(nextErrors).length === 0); + firstErrorRef.current = Object.keys(nextErrors)[0] ?? null; + } + + useEffect(() => { + if (firstErrorRef.current) { + document.getElementById(`checkout-${firstErrorRef.current}`)?.focus(); + } + }, [errors]); + + const firstErrorField = FIELDS.find((f) => errors[f.name]); + const liveMessage = placed + ? 'Order placed. Thank you.' + : firstErrorField + ? `${firstErrorField.label}: ${errors[firstErrorField.name]}` + : ''; + + return ( +
+
+ {liveMessage} +
+ + {FIELDS.map((field) => { + const errorId = `checkout-${field.name}-error`; + return ( +
+ + + {errors[field.name] && ( + + )} +
+ ); + })} + + + + {placed && ( + + )} +
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/components/SizePicker.jsx b/adventures/accessibility-nightmare/expert/src/components/SizePicker.jsx new file mode 100644 index 0000000..7e35aea --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/components/SizePicker.jsx @@ -0,0 +1,107 @@ +import { useEffect, useId, useRef, useState } from 'react'; + +export default function SizePicker({ sizes, value, onChange }) { + const [open, setOpen] = useState(false); + const triggerId = useId(); + const listId = useId(); + const activeId = useId(); + const triggerRef = useRef(null); + + const activeIndex = sizes.indexOf(value); + + function openList() { + setOpen(true); + } + + function closeList() { + setOpen(false); + triggerRef.current?.focus(); + } + + function handleKeyDown(e) { + if (!open) { + if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openList(); + } + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + closeList(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = (activeIndex + 1) % sizes.length; + onChange(sizes[next]); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = (activeIndex - 1 + sizes.length) % sizes.length; + onChange(sizes[prev]); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + closeList(); + } else if (e.key === 'Tab') { + closeList(); + } + } + + useEffect(() => { + if (!open) return; + function handleClickOutside(e) { + if (!e.target.closest('.size-picker')) closeList(); + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + return ( +
+ + Size + + +
(open ? closeList() : openList())} + onKeyDown={handleKeyDown} + > + {value} +
+ + {open && ( +
    + {sizes.map((size, i) => ( +
  • { + e.preventDefault(); + onChange(size); + closeList(); + }} + > + {size} +
  • + ))} +
+ )} +
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/dev/announcements.js b/adventures/accessibility-nightmare/expert/src/dev/announcements.js new file mode 100644 index 0000000..d57224e --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/dev/announcements.js @@ -0,0 +1,138 @@ +// Development tool. Renders a live log of what a screen reader would announce, +// below the storefront. Switched on by adding ?listen to the URL, and stripped +// from production builds entirely. +// +// It is a simulation built from the accessibility tree, not NVDA, JAWS or +// VoiceOver, and it will not reproduce the differences between them. Silence +// here means silence there. Sound here is necessary but not sufficient. +// +// The panel is appended to , outside the React root, so React never sees +// it. It is aria-hidden and inert so the reader cannot read it and the keyboard +// cannot reach it: a tool that changed the tab order would be measuring itself. + +const POLL_MS = 200; + +// A strip across the bottom, not a rail down the side. A side rail either +// covered the page or squeezed it: at a narrow width the storefront lost its +// header and nav entirely. Taking height instead leaves the layout at its full +// width at every viewport, and the matching padding on means the panel +// never sits on top of the page either. +const PANEL_HEIGHT = 'min(15rem, 33vh)'; + +const PANEL_STYLE = ` + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: ${PANEL_HEIGHT}; + z-index: 9999; + display: flex; + flex-direction: column; + border-top: 1px solid #2c2c3a; + background: #14141c; + color: #e8e8ef; + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +`; + +export async function start() { + const root = document.getElementById('root'); + if (!root) return; + + const { virtual } = await import( + '@guidepup/virtual-screen-reader/browser.js' + ); + + const panel = document.createElement('aside'); + panel.setAttribute('aria-hidden', 'true'); + panel.inert = true; + panel.style.cssText = PANEL_STYLE; + + // One compact bar, so the strip spends its height on the log itself. + const heading = document.createElement('div'); + heading.style.cssText = ` + display: flex; + gap: 0.6rem; + align-items: baseline; + flex-wrap: wrap; + padding: 0.5rem 0.9rem; + border-bottom: 1px solid #2c2c3a; + `; + + const title = document.createElement('strong'); + title.textContent = 'What a screen reader would say'; + + const note = document.createElement('span'); + note.textContent = 'a simulation, not real assistive technology'; + note.style.cssText = 'color: #9a9aae;'; + + heading.append(title, note); + + const list = document.createElement('div'); + list.style.cssText = ` + flex: 1; + overflow-y: auto; + padding: 0.5rem 0.9rem 0.75rem; + `; + + const empty = document.createElement('p'); + empty.textContent = + 'Nothing yet. Move around with the keyboard, or follow a link to another page.'; + empty.style.cssText = 'color: #9a9aae; margin: 0.5rem 0;'; + list.append(empty); + + panel.append(heading, list); + document.body.append(panel); + // Reserve the strip's height so the page is never hidden behind it. + document.body.style.paddingBottom = PANEL_HEIGHT; + + // Read only the application, never the panel itself. + await virtual.start({ container: root }); + + let shown = 0; + + function append(text, style) { + empty.remove(); + const line = document.createElement('p'); + line.textContent = text; + line.style.cssText = style; + list.append(line); + list.scrollTop = list.scrollHeight; + } + + async function drain() { + const log = await virtual.spokenPhraseLog(); + if (log.length === shown) return; + + for (const phrase of log.slice(shown)) { + const isLive = /^(polite|assertive):/.test(phrase); + append( + phrase, + ` + margin: 0 0 0.35rem; + padding-left: 0.6rem; + border-left: 2px solid ${isLive ? '#57d977' : '#3a3a4c'}; + color: ${isLive ? '#8ff0a4' : '#e8e8ef'}; + word-break: break-word; + `, + ); + } + shown = log.length; + } + + // Marks where the address changed, so what the reader said before a + // navigation can be told apart from what it said after one. + window.addEventListener('hashchange', async () => { + await drain(); + append( + `ยท ${window.location.hash || '#/'}`, + ` + margin: 0.5rem 0 0.35rem; + color: #6f6f85; + word-break: break-word; + `, + ); + }); + + // Deliberately never cleared: the panel lives for as long as the page does. + setInterval(drain, POLL_MS); +} diff --git a/adventures/accessibility-nightmare/expert/src/main.jsx b/adventures/accessibility-nightmare/expert/src/main.jsx new file mode 100644 index 0000000..2b9902d --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/main.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +); + +// Add ?listen to the URL to see what a screen reader would announce as you move +// around. Development only: this whole branch is dropped from a production build. +// +// Accepted before the hash (/?listen#/checkout) and after it +// (/#/checkout?listen), because routes here live in the hash and appending to +// the end of the address is the obvious thing to do. +function wantsListenPanel() { + const { search, hash } = window.location; + return ( + new URLSearchParams(search).has('listen') || + /[?&]listen(=|&|$)/.test(hash) + ); +} + +if (import.meta.env.DEV && wantsListenPanel()) { + import('./dev/announcements.js').then((tool) => tool.start()); +} + diff --git a/adventures/accessibility-nightmare/expert/src/pages/Checkout.jsx b/adventures/accessibility-nightmare/expert/src/pages/Checkout.jsx new file mode 100644 index 0000000..a959264 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/pages/Checkout.jsx @@ -0,0 +1,18 @@ +import CheckoutForm from '../components/CheckoutForm.jsx'; + +export default function Checkout({ basket }) { + return ( +
+

Checkout

+ + {basket && ( +

+ {basket.product.name}, size {basket.size} +

+ )} + +

Delivery details

+ +
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/pages/Home.jsx b/adventures/accessibility-nightmare/expert/src/pages/Home.jsx new file mode 100644 index 0000000..9da006b --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/pages/Home.jsx @@ -0,0 +1,46 @@ +import { PRODUCTS } from '../products.js'; + +export default function Home() { + return ( +
+
+
+

Summer sale

+

Everything you need, delivered fast

+

+ Discover popular products at prices that are hard to ignore. +

+ + + Start shopping + +
+ + A ShopSmart storefront with shelves of boxed products. +
+ +
+

Featured products

+ +
+ {PRODUCTS.map((product) => ( + + ))} +
+
+
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/pages/Payment.jsx b/adventures/accessibility-nightmare/expert/src/pages/Payment.jsx new file mode 100644 index 0000000..5f4afdb --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/pages/Payment.jsx @@ -0,0 +1,49 @@ +export default function Payment({ basket }) { + return ( +
+

Payment

+ + {basket && ( +

+ {basket.product.name}, size {basket.size} +

+ )} + +
e.preventDefault()}> +

Card details

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/pages/Product.jsx b/adventures/accessibility-nightmare/expert/src/pages/Product.jsx new file mode 100644 index 0000000..78f4200 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/pages/Product.jsx @@ -0,0 +1,53 @@ +import { useRef, useState } from 'react'; +import BasketDialog from '../components/BasketDialog.jsx'; +import SizePicker from '../components/SizePicker.jsx'; + +export default function Product({ product, onAddToBasket }) { + const [size, setSize] = useState(product.sizes[1]); + const [dialogOpen, setDialogOpen] = useState(false); + const triggerRef = useRef(null); + + function addToBasket() { + onAddToBasket({ product, size }); + setDialogOpen(true); + } + + return ( +
+ {product.imageAlt} + +
+

{product.name}

+

{product.description}

+ + + + +
+ + {dialogOpen && ( + setDialogOpen(false)} + /> + )} +
+ ); +} diff --git a/adventures/accessibility-nightmare/expert/src/products.js b/adventures/accessibility-nightmare/expert/src/products.js new file mode 100644 index 0000000..6e54bd0 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/products.js @@ -0,0 +1,30 @@ +export const PRODUCTS = [ + { + slug: 'smart-watch', + name: 'Smart watch', + description: 'Track your day with a clean, lightweight design.', + image: '/images/watch.svg', + imageAlt: 'A smart watch with a dark strap and a round face.', + sizes: ['38 mm', '42 mm', '46 mm'], + }, + { + slug: 'wireless-headphones', + name: 'Wireless headphones', + description: 'Comfortable sound for work, travel, and exercise.', + image: '/images/headphones.svg', + imageAlt: 'Over-ear wireless headphones with padded cups.', + sizes: ['Small', 'Medium', 'Large'], + }, + { + slug: 'running-shoes', + name: 'Running shoes', + description: 'Flexible everyday shoes made for active routines.', + image: '/images/shoes.svg', + imageAlt: 'A running shoe in profile, with a ridged sole.', + sizes: ['41', '42', '43'], + }, +]; + +export function findProduct(slug) { + return PRODUCTS.find((product) => product.slug === slug); +} diff --git a/adventures/accessibility-nightmare/expert/src/styles.css b/adventures/accessibility-nightmare/expert/src/styles.css new file mode 100644 index 0000000..4513bc6 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/src/styles.css @@ -0,0 +1,443 @@ +:root { + font-family: + Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #2f2f2f; + background: #f7f7fb; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +:focus-visible { + outline: 3px solid #0b57d0; + outline-offset: 2px; +} + +/* Available to any element that should reach assistive technology without + taking up space on screen. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.page { + min-height: 100vh; +} + +.site-header { + display: flex; + align-items: center; + gap: 2rem; + padding: 1rem 5vw; + background: #ffffff; + border-bottom: 1px solid #e8e8ef; +} + +.logo { + font-size: 1.35rem; + font-weight: 800; + margin-right: auto; +} + +.menu, +.primary-action, +.buy-button, +.size-picker-trigger, +.size-picker-option, +.dialog-close, +.dialog-checkout, +.place-order { + cursor: pointer; +} + +.menu { + padding: 0.5rem 0.8rem; + border: 0; + border-radius: 0.5rem; + background: #ececf4; + color: #2f2f2f; + font: inherit; +} + +.nav-links { + display: flex; + gap: 1.25rem; +} + +.nav-links a { + color: #4a4a55; + text-decoration: underline; +} + +.hero { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 3rem; + align-items: center; + min-height: 520px; + padding: 4rem 5vw; + background: #fff7f1; +} + +.hero img { + width: 100%; + height: 360px; + object-fit: cover; + border-radius: 1.25rem; +} + +.eyebrow { + margin: 0 0 0.75rem; + color: #a4503a; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +h1 { + max-width: 12ch; + margin: 0; + font-size: clamp(2.4rem, 6vw, 4.8rem); + line-height: 1; +} + +.hero-copy { + max-width: 52ch; + color: #55504d; + font-size: 1.1rem; + line-height: 1.7; +} + +.primary-action { + display: inline-block; + margin-top: 1rem; + padding: 0.9rem 1.4rem; + border: 0; + border-radius: 0.65rem; + background: #a4402c; + color: #ffffff; + font: inherit; + font-weight: 700; +} + +.products-section { + padding: 4rem 5vw; +} + +.products-section h2 { + margin-top: 0; + font-size: 2rem; +} + +.product-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; +} + +.product-card { + display: flex; + flex-direction: column; + padding: 1rem; + background: #ffffff; + border-radius: 1rem; + box-shadow: 0 12px 30px rgb(20 20 40 / 8%); +} + +.product-card img { + width: 100%; + height: 220px; + object-fit: cover; + border-radius: 0.75rem; +} + +.product-card p { + color: #55555f; + line-height: 1.6; +} + +.buy-button { + margin-top: auto; + padding: 0.8rem 1rem; + border: 0; + border-radius: 0.6rem; + background: #2f2f3d; + color: #ffffff; + font-weight: 700; + text-align: center; +} + +footer { + padding: 2rem 5vw; + color: #4f4f59; + text-align: center; +} + +/* ------------------------------------------------------------------------- + Product page, basket dialog and checkout + ------------------------------------------------------------------------- */ + +.product-page { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + align-items: start; + padding: 4rem 5vw; +} + +.product-hero { + width: 100%; + height: 420px; + object-fit: cover; + border-radius: 1.25rem; + background: #ffffff; +} + +.product-detail h1 { + max-width: none; + font-size: clamp(2rem, 4vw, 3rem); +} + +.product-copy { + max-width: 48ch; + color: #55504d; + font-size: 1.1rem; + line-height: 1.7; +} + +.size-picker { + position: relative; + margin: 2rem 0; + max-width: 12rem; +} + +.size-picker-label { + display: block; + margin-bottom: 0.35rem; + font-weight: 700; +} + +.size-picker-trigger { + padding: 0.7rem 0.9rem; + border: 1px solid #6b6b76; + border-radius: 0.5rem; + background: #ffffff; + font: inherit; + text-align: left; + width: 100%; +} + +.size-picker-list { + margin-top: 0.25rem; + padding: 0; + border: 1px solid #6b6b76; + border-radius: 0.5rem; + background: #ffffff; + list-style: none; + overflow: hidden; +} + +.size-picker-option { + padding: 0.55rem 0.9rem; +} + +.size-picker-option:hover, +.size-picker-option[aria-selected="true"] { + background: #ececf4; +} + +.dialog-overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 2rem; + background: rgb(20 20 40 / 55%); +} + +.dialog { + width: min(28rem, 100%); + max-height: 100%; + overflow-y: auto; + padding: 1.75rem; + border-radius: 1rem; + background: #ffffff; +} + +.dialog-title { + margin-top: 0; +} + +.dialog-summary { + margin-top: -0.5rem; + color: #55555f; +} + +.checkout-page { + max-width: 34rem; + padding: 4rem 5vw; +} + +.checkout-page h1 { + max-width: none; + font-size: clamp(2rem, 4vw, 3rem); +} + +.checkout-summary { + color: #55555f; +} + +.dialog-checkout { + display: block; + margin-top: 1.25rem; + padding: 0.8rem 1rem; + border-radius: 0.6rem; + background: #a4402c; + color: #ffffff; + font-weight: 700; + text-align: center; +} + +.form-field { + margin-bottom: 1rem; +} + +.form-field label { + display: block; + margin-bottom: 0.25rem; + font-weight: 700; +} + +.form-field input { + width: 100%; + padding: 0.6rem 0.7rem; + border: 1px solid #6b6b76; + border-radius: 0.5rem; + font: inherit; +} + +.field-error { + margin: 0.35rem 0 0; + color: #b3261e; + font-size: 0.9rem; +} + +.place-order { + width: 100%; + padding: 0.8rem 1rem; + border: 0; + border-radius: 0.6rem; + background: #a4402c; + color: #ffffff; + font: inherit; + font-weight: 700; +} + +.order-confirmation { + margin: 1rem 0 0; + color: #1b5e20; + font-weight: 700; +} + +.dialog-close { + width: 100%; + margin-top: 0.75rem; + padding: 0.7rem 1rem; + border: 1px solid #6b6b76; + border-radius: 0.6rem; + background: #ffffff; + color: #2f2f2f; + font: inherit; +} + +/* ------------------------------------------------------------------------- + Payment page + ------------------------------------------------------------------------- */ + +.payment-page { + max-width: 34rem; + padding: 4rem 5vw; +} + +.payment-page h1 { + max-width: none; + font-size: clamp(2rem, 4vw, 3rem); +} + +.payment-form { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + border: 1px solid #d0d0da; + border-radius: 0.75rem; + background: #ffffff; +} + +.payment-form h3 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} + +.payment-form label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-weight: 700; + font-size: 0.95rem; +} + +.payment-form input { + padding: 0.6rem 0.7rem; + border: 1px solid #6b6b76; + border-radius: 0.5rem; + font: inherit; + font-weight: 400; +} + +.payment-form button { + padding: 0.8rem 1rem; + border: 0; + border-radius: 0.6rem; + background: #a4402c; + color: #ffffff; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +@media (max-width: 800px) { + .site-header { + flex-wrap: wrap; + } + + .nav-links { + width: 100%; + justify-content: space-between; + } + + .hero, + .product-grid, + .product-page { + grid-template-columns: 1fr; + } +} diff --git a/adventures/accessibility-nightmare/expert/tests/compliance.spec.js b/adventures/accessibility-nightmare/expert/tests/compliance.spec.js new file mode 100644 index 0000000..be2e490 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/tests/compliance.spec.js @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +// The compliance gate. It has run on every push for months and never reported +// a violation. Work out what it is actually covering before you extend it. +// +// Your objectives: +// 1. Have the gate audit every step a customer moves through, not only the homepage. +// 2. Find the navigation barrier that automated scanning has never reported. +// 3. Fix it and have the gate verify the fix holds. +// 4. Produce a report proving each customer journey step was audited and passes. +// +// Coverage reference โ€” which checks each tool layer can and cannot detect: +// http://localhost:5173/coverage-table.html + +async function seriousViolations(page) { + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + + return results.violations + .filter((v) => ['serious', 'critical'].includes(v.impact)) + .map((v) => ({ id: v.id, impact: v.impact, help: v.help })); +} + +// @scan -- the compliance gate ----------------------------------------------- +// One page. Every push. Always green. +// The customer visits four pages before an order is placed. + +test('@scan the homepage passes the accessibility scan', async ({ page }) => { + await page.goto('/'); + + const found = await seriousViolations(page); + expect( + found, + `axe reported violations:\n${JSON.stringify(found, null, 2)}`, + ).toEqual([]); +}); diff --git a/adventures/accessibility-nightmare/expert/tests/global-setup.js b/adventures/accessibility-nightmare/expert/tests/global-setup.js new file mode 100644 index 0000000..be67979 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/tests/global-setup.js @@ -0,0 +1,24 @@ +import { chromium, expect } from '@playwright/test'; + +export default async function globalSetup(config) { + const { baseURL } = config.projects[0].use; + const browser = await chromium.launch(); + const page = await (await browser.newContext()).newPage(); + + try { + await page.goto(`${baseURL}/#/payment`, { waitUntil: 'networkidle' }); + const heading = page.getByRole('heading', { + name: 'Payment', + level: 1, + }); + + await expect( + heading, + `Something other than this level is serving ${baseURL}. The payment ` + + 'page did not render. If you are running the beginner or intermediate level, ' + + 'stop it and start this one with `npm run dev`.', + ).toBeVisible({ timeout: 15_000 }); + } finally { + await browser.close(); + } +} diff --git a/adventures/accessibility-nightmare/expert/tests/lib/navigation.js b/adventures/accessibility-nightmare/expert/tests/lib/navigation.js new file mode 100644 index 0000000..fe6f1f1 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/tests/lib/navigation.js @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; + +export const PRODUCT_URL = '/#/product/running-shoes'; +export const CHECKOUT_URL = '/#/checkout'; +export const PAYMENT_URL = '/#/payment'; + +// Presses Tab until the target has focus. Throws with the tab order it walked +// when the target cannot be reached. +export async function tabTo(page, target, limit = 15) { + const walked = []; + for (let step = 0; step < limit; step++) { + await page.keyboard.press('Tab'); + walked.push(await describeFocus(page)); + if (await isFocused(page, target)) return walked; + } + throw new Error( + `Tab never reached the expected control. Focus went:\n ${walked.join('\n ')}`, + ); +} + +async function isFocused(page, target) { + if ((await target.count()) === 0) return false; + return target + .evaluate((node) => node === document.activeElement) + .catch(() => false); +} + +export async function describeFocus(page) { + return page.evaluate(() => { + const node = document.activeElement; + if (!node || node === document.body) return '(nothing)'; + const role = node.getAttribute('role') ?? node.tagName.toLowerCase(); + const text = (node.textContent ?? '').trim().slice(0, 30); + return `${role} "${text || node.id || node.className}"`; + }); +} diff --git a/adventures/accessibility-nightmare/expert/tests/lib/screen-reader.js b/adventures/accessibility-nightmare/expert/tests/lib/screen-reader.js new file mode 100644 index 0000000..dd709c6 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/tests/lib/screen-reader.js @@ -0,0 +1,75 @@ +// Drives the Guidepup Virtual Screen Reader inside the page under test. +// +// This is a simulation. It reads the accessibility tree the browser builds +// and reports what a screen reader would announce from it. It is not NVDA, +// JAWS or VoiceOver โ€” passing these assertions means the tree says the right +// thing. Test with a real screen reader before shipping. +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const BUNDLE = path.join( + path.dirname(require.resolve('@guidepup/virtual-screen-reader/package.json')), + 'lib/esm/index.browser.js', +); + +function buildInitScript() { + const source = fs + .readFileSync(BUNDLE, 'utf8') + .replace(/export\s*\{([^}]*)\}\s*;?/, (_match, specifiers) => + specifiers + .split(',') + .map((entry) => entry.trim().split(/\s+as\s+/)) + .map( + ([local, exported = local]) => + `window.__vsr_${exported} = ${local};`, + ) + .join('\n'), + ); + + return `(() => {\n${source}\n})();\n`; +} + +const INIT_SCRIPT = buildInitScript(); + +// Call before page.goto(). Loads the screen reader into every document. +export async function attachScreenReader(page) { + await page.addInitScript({ content: INIT_SCRIPT }); +} + +// Starts listening. From here the reader's cursor follows real focus. +export async function startScreenReader(page) { + await page.evaluate(async () => { + await window.__vsr_virtual.start({ container: document.body }); + }); +} + +// Everything the screen reader has announced since the last clear, oldest first. +// Live region announcements arrive prefixed "polite:" or "assertive:". +export async function spokenPhrases(page) { + return page.evaluate(async () => window.__vsr_virtual.spokenPhraseLog()); +} + +// Just the live region announcements, with their politeness prefix stripped. +// Empty announcements (clearing a region) are dropped. +export async function liveAnnouncements(page) { + const phrases = await spokenPhrases(page); + return phrases + .filter((phrase) => /^(polite|assertive):/.test(phrase)) + .map((phrase) => phrase.replace(/^(polite|assertive):\s*/, '')) + .filter((phrase) => phrase.length > 0); +} + +export async function clearSpokenPhrases(page) { + await page.evaluate(async () => { + await window.__vsr_virtual.clearSpokenPhraseLog(); + }); +} + +// React commits asynchronously and announcements queue through a microtask, +// so give both a moment to settle before reading the log. +export async function settle(page) { + await page.waitForTimeout(250); +} diff --git a/adventures/accessibility-nightmare/expert/verify.sh b/adventures/accessibility-nightmare/expert/verify.sh new file mode 100755 index 0000000..8f4c405 --- /dev/null +++ b/adventures/accessibility-nightmare/expert/verify.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared libraries +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/../../../lib/scripts/loader.sh" + +set_tracking_context "accessibility-nightmare" "expert" "" "" "" + +OBJECTIVE=" +- Have the gate audit every step a customer moves through, not only the homepage. +- Find the navigation barrier that automated scanning has never reported. +- Fix it and have the gate verify the fix holds. +- Produce a report proving each customer journey step was audited and passes." + +DOCS_URL="https://offon.dev/adventures/accessibility-nightmare/levels/expert" + +EXPECTED_DEPENDENCIES="@axe-core/playwright @guidepup/virtual-screen-reader @playwright/test @vitejs/plugin-react react react-dom vite" + +print_header \ + 'The Accessibility Nightmare' \ + 'The Compliance Engine' \ + 'Verification' + +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_CHECKS=() + +check_prerequisites node npm npx jq + +print_sub_header "Running verification checks..." + +# 1. Dependencies unchanged ----------------------------------------------- +check_npm_dependencies \ + "package.json" \ + "${EXPECTED_DEPENDENCIES}" \ + "The app dependencies are unchanged" \ + "This level is about the check, not the app. The dependency set is fixed. If you added or removed a library, revert the change." + +# 2. The scan is green on the product as it stands ------------------------- +FAILURES_BEFORE_SCAN=${TESTS_FAILED} + +check_playwright_tests "@scan" \ + "The accessibility scan passes" \ + "Your scanner tests are reporting a violation. Read what axe returned: it names the rule and the element it found." + +# 3. The scan actually reaches the whole journey --------------------------- +# +# A green suite proves nothing about where it looked. Plant a fault on the last +# step of the journey and require the suite to go red. A gate that still passes +# never opened that page. +# +# Only meaningful while the scan is otherwise green: against an already-failing +# suite a red result says nothing about coverage. + +if [[ ${TESTS_FAILED} -ne ${FAILURES_BEFORE_SCAN} ]]; then + print_test_section "Checking The accessibility scan covers every step a customer moves through..." + print_error_indent "The accessibility scan covers every step a customer moves through" + print_hint "Coverage cannot be measured while the scan is failing. Resolve the violation above, then run this again." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("scan_coverage_unmeasured") + print_new_line +else + PAYMENT="${SCRIPT_DIR}/src/pages/Payment.jsx" + PAYMENT_BAK="${SCRIPT_DIR}/src/pages/.Payment.jsx.orig" + + restore_payment() { + if [[ -f "${PAYMENT_BAK}" ]]; then + mv -f "${PAYMENT_BAK}" "${PAYMENT}" + fi + } + trap restore_payment EXIT + + cp "${PAYMENT}" "${PAYMENT_BAK}" || { + echo "could not read ${PAYMENT}" >&2 + exit 1 + } + + python3 - "${PAYMENT}" <<'PY' +import sys +path = sys.argv[1] +source = open(path).read() +anchor = '

Payment

' +if anchor not in source: + sys.exit('anchor not found') +# An image with no alt text: axe reports image-alt at critical impact. +open(path, 'w').write( + source.replace(anchor, anchor + '\n ', 1) +) +PY + + if ! grep -q 'accepted-cards.png' "${PAYMENT}"; then + restore_payment + echo "could not plant the coverage probe in ${PAYMENT}; leave that file unmodified" >&2 + exit 1 + fi + + # Vite needs a moment to rebuild before the suite reloads the page. + sleep 2 + + check_playwright_tests_detect "@scan" \ + "The accessibility scan covers every step a customer moves through" \ + "A fault was planted on one of the four pages a customer passes through, and your scan stayed green. The gate was written when the homepage was the only page. Which of the others does it open?" + + restore_payment + trap - EXIT + sleep 1 +fi + +# 4. Route transitions are announced -------------------------------------- +check_playwright_tests "@transition" \ + "The gate catches the navigation barrier that the scanner never reported" \ + "Nothing here is wrong in the markup, so the scanner has nothing to report. Move through the app the way someone who cannot see it would, and pay attention to what arrives after the address changes." + +# 5. Compliance report documenting what was audited ----------------------- + +print_test_section "Checking compliance report..." + +REPORT="${SCRIPT_DIR}/compliance-report.json" + +if [[ ! -f "${REPORT}" ]]; then + print_error_indent "Compliance report exists" + print_hint "The run left no record behind. Playwright decides what to emit from its reporter configuration, and by default that is terminal output only." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("compliance_report_missing") +else + HAS_ANNOTATION=$(jq -r ' + [.. | objects | select(has("annotations")) | .annotations[] + | select(.type == "route-announcement")] | length + ' "${REPORT}" 2>/dev/null || echo "0") + + if [[ "${HAS_ANNOTATION}" -gt 0 ]]; then + print_success_indent "Compliance report documents the route announcements that were verified" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "Compliance report documents the route announcements that were verified" + print_hint "The report records that the tests ran, but not what any of them set out to prove. A result with no claim attached is not evidence an auditor can use. Playwright lets a test annotate itself; the gate is looking for the type route-announcement." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("compliance_report_annotation") + fi +fi + +print_new_line + +# ========================================================================== +# Summary +# ========================================================================== + +failed_checks_json="[]" +if [[ -n "${FAILED_CHECKS[*]:-}" ]]; then + failed_checks_json=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) +fi + +if [[ ${TESTS_FAILED} -gt 0 ]]; then + track_verification_completed "failed" "${failed_checks_json}" + print_verification_summary "accessibility-nightmare" "${DOCS_URL}" "${OBJECTIVE}" + exit 1 +fi + +track_verification_completed "success" "${failed_checks_json}" + +print_header "Test Results Summary" +print_success "โœ… PASSED: All ${TESTS_PASSED} verification checks passed!" +print_new_line + +check_submission_readiness "accessibility-nightmare" "expert" diff --git a/adventures/accessibility-nightmare/expert/vite.config.js b/adventures/accessibility-nightmare/expert/vite.config.js new file mode 100644 index 0000000..c957f0d --- /dev/null +++ b/adventures/accessibility-nightmare/expert/vite.config.js @@ -0,0 +1,46 @@ +import { readFile } from 'node:fs/promises'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// The compliance report is written to the project root by the test run, which is +// outside the served directory. The coverage reference page reads it from there +// to show the state of the last run, so serve it read-only in development. +function serveComplianceReport() { + return { + name: 'serve-compliance-report', + apply: 'serve', + configureServer(server) { + server.middlewares.use('/compliance-report.json', async (_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + try { + res.end(await readFile('compliance-report.json', 'utf8')); + } catch { + res.statusCode = 404; + res.end('{"error":"no report"}'); + } + }); + }, + }; +} + +// In a Codespace the browser reaches the dev server through a forwarded +// hostname, not localhost. Vite rejects unknown Host headers by default, so the +// forwarded domain has to be allowed or the page never loads at all. The +// websocket for hot reload needs the public port too, which only applies there. +const inCodespace = Boolean(process.env.CODESPACES); + +export default defineConfig({ + plugins: [react(), serveComplianceReport()], + server: { + host: true, + allowedHosts: ['.app.github.dev'], + ...(inCodespace + ? { hmr: { clientPort: 443, protocol: 'wss' } } + : {}), + }, + preview: { + host: true, + allowedHosts: ['.app.github.dev'], + }, +}); diff --git a/lib/scripts/node.sh b/lib/scripts/node.sh index 8a6c805..69bacc1 100644 --- a/lib/scripts/node.sh +++ b/lib/scripts/node.sh @@ -28,6 +28,37 @@ check_playwright_tests() { print_new_line } +# ----------------------------------------------------------------------------- +# Run the Playwright tests carrying a tag and require them to FAIL. +# +# A suite that passes only proves it ran, not that it looked anywhere useful. +# Inject a fault the suite ought to catch, call this, and a green result means +# the fault went unseen. Test output is suppressed: here a failing run is the +# success case, and printing red output would read as a broken challenge. +# +# Usage: check_playwright_tests_detect "@tag" "Display Name" "Hint message" [project_dir] +# ----------------------------------------------------------------------------- +check_playwright_tests_detect() { + local tag=$1 + local display_name=$2 + local hint=$3 + local project_dir=${4:-.} + + print_test_section "Checking ${display_name}..." + + if (cd "${project_dir}" && npx playwright test --grep "${tag}" --reporter=line >/dev/null 2>&1); then + print_error_indent "${display_name}" + print_hint "${hint}" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("playwright_detect:${tag#@}") + else + print_success_indent "${display_name}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + fi + + print_new_line +} + # ----------------------------------------------------------------------------- # Check a package.json declares exactly the expected set of dependency names # Usage: check_npm_dependencies "package.json" "expected names, space separated" "Display Name" "Hint message" From a112a19d1c0665ae54673eaf3f40cc801ac0a631 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 21 Sep 2026 16:11:02 +0200 Subject: [PATCH 2/8] Harden the expert level's coverage probe and audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check 3 plants a fault in the payment page and requires the player's scan to catch it. Three ways that could go wrong: The recovery that undoes a crashed probe lived inside the branch taken only when the scan was already green, so a run killed mid-probe left the planted fault in place and then failed check 2 โ€” for exactly the player who had widened their scan to reach that page. Recovery now runs before any check, and self-heals. The trap was armed before the backup was known good, so a short write could leave a truncated backup that the handler then moved over the original. The backup is now compared byte for byte before it is relied on, and the handler chains loader.sh's own cleanup rather than replacing it. The inverted check treated any non-zero exit as "fault caught", so Ctrl-C scored a pass and the run carried on. Only exit code 1, a real test failure, counts now; anything else reports that the check could not be measured. Also: drop the python3 dependency the prerequisite check never declared, resolve the report path against the vite server root rather than cwd, read test outcomes after retries rather than from the first attempt, ignore the probe backup, add set -e to the devcontainer scripts to match the sibling levels, and stop pointing players at a localhost URL that does not resolve in a Codespace. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../post-create.sh | 1 + .../post-start.sh | 1 + .../accessibility-nightmare/expert/.gitignore | 1 + .../expert/public/coverage-table.html | 7 +- .../expert/tests/compliance.spec.js | 2 +- .../accessibility-nightmare/expert/verify.sh | 109 +++++++++++------- .../expert/vite.config.js | 4 +- lib/scripts/node.sh | 17 ++- 8 files changed, 95 insertions(+), 47 deletions(-) diff --git a/.devcontainer/accessibility-nightmare_expert/post-create.sh b/.devcontainer/accessibility-nightmare_expert/post-create.sh index 93428ed..49f5f98 100755 --- a/.devcontainer/accessibility-nightmare_expert/post-create.sh +++ b/.devcontainer/accessibility-nightmare_expert/post-create.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -e REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" diff --git a/.devcontainer/accessibility-nightmare_expert/post-start.sh b/.devcontainer/accessibility-nightmare_expert/post-start.sh index 9322614..2e8f515 100755 --- a/.devcontainer/accessibility-nightmare_expert/post-start.sh +++ b/.devcontainer/accessibility-nightmare_expert/post-start.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -e REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" CHALLENGE_DIR="${REPO_ROOT}/adventures/accessibility-nightmare/expert" diff --git a/adventures/accessibility-nightmare/expert/.gitignore b/adventures/accessibility-nightmare/expert/.gitignore index 24dd2f1..dc8834f 100644 --- a/adventures/accessibility-nightmare/expert/.gitignore +++ b/adventures/accessibility-nightmare/expert/.gitignore @@ -3,3 +3,4 @@ dist/ test-results/ playwright-report/ compliance-report.json +.Payment.jsx.orig diff --git a/adventures/accessibility-nightmare/expert/public/coverage-table.html b/adventures/accessibility-nightmare/expert/public/coverage-table.html index 4eb5ced..cd5c622 100644 --- a/adventures/accessibility-nightmare/expert/public/coverage-table.html +++ b/adventures/accessibility-nightmare/expert/public/coverage-table.html @@ -577,8 +577,11 @@

Conformance coverage

let scanTotal = 0, scanFailed = 0; for (const spec of specs) { - const status = spec.tests?.[0]?.results?.[0]?.status ?? 'skipped'; - const ok = status === 'passed'; + // spec.ok is the outcome after retries; results[0] is only the + // first attempt, so a flaky-then-passing test would read as failed. + const attempts = spec.tests?.[0]?.results ?? []; + const status = attempts[attempts.length - 1]?.status ?? 'skipped'; + const ok = spec.ok ?? (status === 'passed'); if (ok) pass++; else if (status === 'skipped') skip++; else fail++; diff --git a/adventures/accessibility-nightmare/expert/tests/compliance.spec.js b/adventures/accessibility-nightmare/expert/tests/compliance.spec.js index be2e490..ce90961 100644 --- a/adventures/accessibility-nightmare/expert/tests/compliance.spec.js +++ b/adventures/accessibility-nightmare/expert/tests/compliance.spec.js @@ -11,7 +11,7 @@ import AxeBuilder from '@axe-core/playwright'; // 4. Produce a report proving each customer journey step was audited and passes. // // Coverage reference โ€” which checks each tool layer can and cannot detect: -// http://localhost:5173/coverage-table.html +// open /coverage-table.html on the storefront async function seriousViolations(page) { const results = await new AxeBuilder({ page }) diff --git a/adventures/accessibility-nightmare/expert/verify.sh b/adventures/accessibility-nightmare/expert/verify.sh index 8f4c405..4a26853 100755 --- a/adventures/accessibility-nightmare/expert/verify.sh +++ b/adventures/accessibility-nightmare/expert/verify.sh @@ -30,6 +30,37 @@ FAILED_CHECKS=() check_prerequisites node npm npx jq +# -------------------------------------------------------------------------- +# Coverage probe plumbing +# +# Check 3 plants a fault in the payment page and requires the player's scan to +# catch it. Everything that makes that safe lives here, above the checks, +# because recovery has to happen before check 2 runs: a run killed mid-probe +# leaves the planted fault in the file, and check 2 would then fail โ€” for the +# one player who did widen their scan to the payment page. +# -------------------------------------------------------------------------- + +PAYMENT="${SCRIPT_DIR}/src/pages/Payment.jsx" +PAYMENT_BAK="${SCRIPT_DIR}/src/pages/.Payment.jsx.orig" + +restore_payment() { + if [[ -f "${PAYMENT_BAK}" ]]; then + mv -f "${PAYMENT_BAK}" "${PAYMENT}" + fi +} + +# loader.sh owns an EXIT/INT/TERM trap of its own. Setting ours would replace +# it, so run both and never clear the handler afterwards โ€” restore_payment is +# a no-op once the backup is gone. +probe_cleanup() { + restore_payment + cleanup_port_forwards 2>/dev/null || true +} +trap probe_cleanup EXIT INT TERM + +# Recover from any earlier run that died holding the probe. +restore_payment + print_sub_header "Running verification checks..." # 1. Dependencies unchanged ----------------------------------------------- @@ -63,50 +94,48 @@ if [[ ${TESTS_FAILED} -ne ${FAILURES_BEFORE_SCAN} ]]; then FAILED_CHECKS+=("scan_coverage_unmeasured") print_new_line else - PAYMENT="${SCRIPT_DIR}/src/pages/Payment.jsx" - PAYMENT_BAK="${SCRIPT_DIR}/src/pages/.Payment.jsx.orig" - - restore_payment() { - if [[ -f "${PAYMENT_BAK}" ]]; then - mv -f "${PAYMENT_BAK}" "${PAYMENT}" - fi - } - trap restore_payment EXIT - - cp "${PAYMENT}" "${PAYMENT_BAK}" || { - echo "could not read ${PAYMENT}" >&2 - exit 1 - } - - python3 - "${PAYMENT}" <<'PY' -import sys -path = sys.argv[1] -source = open(path).read() -anchor = '

Payment

' -if anchor not in source: - sys.exit('anchor not found') -# An image with no alt text: axe reports image-alt at critical impact. -open(path, 'w').write( - source.replace(anchor, anchor + '\n ', 1) -) -PY - - if ! grep -q 'accepted-cards.png' "${PAYMENT}"; then - restore_payment - echo "could not plant the coverage probe in ${PAYMENT}; leave that file unmodified" >&2 - exit 1 + # Take the backup first and prove it is byte-identical. The trap is already + # armed, but it only ever restores a backup that made it to disk intact, so a + # short write here cannot overwrite the original with a truncated copy. + PROBE_PLANTED=1 + cp "${PAYMENT}" "${PAYMENT_BAK}" 2>/dev/null || PROBE_PLANTED=0 + if [[ "${PROBE_PLANTED}" -eq 1 ]] && ! cmp -s "${PAYMENT}" "${PAYMENT_BAK}"; then + rm -f "${PAYMENT_BAK}" + PROBE_PLANTED=0 fi - # Vite needs a moment to rebuild before the suite reloads the page. - sleep 2 + # Node rather than python3: node is already a prerequisite of this level, + # python3 is not guaranteed to be in the image. + [[ "${PROBE_PLANTED}" -eq 1 ]] && node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const source = fs.readFileSync(file, "utf8"); + const anchor = "

Payment

"; + if (!source.includes(anchor)) process.exit(2); + // An image with no alt text: axe reports image-alt at critical impact. + const probe = anchor + "\n "; + fs.writeFileSync(file, source.replace(anchor, probe)); + ' "${PAYMENT}" || PROBE_PLANTED=0 + + if [[ "${PROBE_PLANTED}" -eq 0 ]] || ! grep -q 'accepted-cards.png' "${PAYMENT}"; then + restore_payment + print_test_section "Checking The accessibility scan covers every step a customer moves through..." + print_error_indent "The accessibility scan covers every step a customer moves through" + print_hint "This check plants a fault in src/pages/Payment.jsx and requires your scan to catch it, but that file is no longer in its original shape. Restore it โ€” the page components are not part of this level's editing surface." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("scan_coverage_probe_failed") + print_new_line + else + # Vite needs a moment to rebuild before the suite reloads the page. + sleep 2 - check_playwright_tests_detect "@scan" \ - "The accessibility scan covers every step a customer moves through" \ - "A fault was planted on one of the four pages a customer passes through, and your scan stayed green. The gate was written when the homepage was the only page. Which of the others does it open?" + check_playwright_tests_detect "@scan" \ + "The accessibility scan covers every step a customer moves through" \ + "A fault was planted on one of the four pages a customer passes through, and your scan stayed green. The gate was written when the homepage was the only page. Which of the others does it open?" - restore_payment - trap - EXIT - sleep 1 + restore_payment + sleep 1 + fi fi # 4. Route transitions are announced -------------------------------------- diff --git a/adventures/accessibility-nightmare/expert/vite.config.js b/adventures/accessibility-nightmare/expert/vite.config.js index c957f0d..31a32d9 100644 --- a/adventures/accessibility-nightmare/expert/vite.config.js +++ b/adventures/accessibility-nightmare/expert/vite.config.js @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -10,11 +11,12 @@ function serveComplianceReport() { name: 'serve-compliance-report', apply: 'serve', configureServer(server) { + const root = server.config.root; server.middlewares.use('/compliance-report.json', async (_req, res) => { res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store'); try { - res.end(await readFile('compliance-report.json', 'utf8')); + res.end(await readFile(join(root, 'compliance-report.json'), 'utf8')); } catch { res.statusCode = 404; res.end('{"error":"no report"}'); diff --git a/lib/scripts/node.sh b/lib/scripts/node.sh index 69bacc1..aa45c19 100644 --- a/lib/scripts/node.sh +++ b/lib/scripts/node.sh @@ -43,17 +43,28 @@ check_playwright_tests_detect() { local display_name=$2 local hint=$3 local project_dir=${4:-.} + local status=0 print_test_section "Checking ${display_name}..." - if (cd "${project_dir}" && npx playwright test --grep "${tag}" --reporter=line >/dev/null 2>&1); then + (cd "${project_dir}" && npx playwright test --grep "${tag}" --reporter=line >/dev/null 2>&1) || status=$? + + # Playwright exits 1 when tests fail, which is the outcome this check wants. + # Anything else โ€” no tests matched, a crash, a signal (130 for Ctrl-C) โ€” says + # nothing about whether the fault was caught, so it must not read as success. + if [[ ${status} -eq 1 ]]; then + print_success_indent "${display_name}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + elif [[ ${status} -eq 0 ]]; then print_error_indent "${display_name}" print_hint "${hint}" TESTS_FAILED=$((TESTS_FAILED + 1)) FAILED_CHECKS+=("playwright_detect:${tag#@}") else - print_success_indent "${display_name}" - TESTS_PASSED=$((TESTS_PASSED + 1)) + print_error_indent "${display_name}" + print_hint "The test run ended with exit code ${status} rather than a test failure, so this check could not be measured. Run 'npx playwright test --grep \"${tag}\"' to see what happened." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("playwright_detect_error:${tag#@}") fi print_new_line From 67bb60153cb7b4eb4be9e2f12fbee4c990464e74 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 21 Sep 2026 16:24:17 +0200 Subject: [PATCH 3/8] Stop the coverage reference from naming the fault A cold read of the docs and the reference page found the defect identifiable before the player ran anything, which leaves objective 2 with nothing to find. The reference table paired "the document has a title" with "route change announces the new page" as two halves of one criterion, and highlighted both. That was the sharpest statement of the lesson and also the answer, printed before any investigation. The row is now the fault class rather than this app's instance of it, and the highlight is gone. The WCAG links named the two criteria outright; they now point at the guideline and at the quick reference, so the player still has somewhere to look but has to work out which criterion their tests verify. Two smaller tells removed: the listen-panel instructions said to read what does not arrive after the marker, and the @transition hint said to watch what arrives after the address changes. Both named the conclusion. They now describe how to read the panel and leave the judgement open. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../accessibility-nightmare/docs/expert.yaml | 27 ++++++++----------- .../expert/public/coverage-table.html | 12 +++------ .../accessibility-nightmare/expert/verify.sh | 2 +- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/adventures/accessibility-nightmare/docs/expert.yaml b/adventures/accessibility-nightmare/docs/expert.yaml index d14fecb..7d89662 100644 --- a/adventures/accessibility-nightmare/docs/expert.yaml +++ b/adventures/accessibility-nightmare/docs/expert.yaml @@ -65,11 +65,11 @@ what_you_learn: - >- Why automated scanners cannot detect faults that only exist when a user does something โ€” and which tool bridges the gap. - ([WCAG 2.4.2: Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled)) + ([Test and Evaluate Accessibility](https://www.w3.org/WAI/test-evaluate/)) - >- How a single-page application can silently break the navigation contract that assistive technology depends on, and what it takes to restore it. - ([WCAG 4.1.3: Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages)) + ([WCAG 4.1: Compatible](https://www.w3.org/WAI/WCAG22/Understanding/compatible)) - >- How a compliance report is evidence of conformance over time โ€” what was tested, how it was tested, and that it passed. @@ -160,9 +160,9 @@ how_to_play: -5173.app.github.dev/?listen ``` - Move through the page with Tab, then follow a link to another page. The panel - marks the address change with a dim `ยท` line. Read what arrives after that - marker, and what does not. + Move through the page with Tab, then follow a link to another page. Every + address change is marked with a dim `ยท` line, so you can tell what the reader + said before a navigation from what it said after one. The same simulation is available to your tests through the helpers in `tests/lib/screen-reader.js`. @@ -220,7 +220,7 @@ how_to_play: content: | The check was green for months while nobody could move through the shop without sight. Two things made that possible: it never looked past the homepage, and the - tool it used cannot hear what the browser says out loud. + one tool it did run was the wrong instrument for what was broken. Every layer of a testing stack catches something the others miss, and a gate built on one layer reports the absence of the faults it can see. Which layer is missing @@ -233,17 +233,12 @@ helpful_links: A reference table โ€” served by the dev server โ€” showing which checks each testing layer (axe-core, keyboard, screen reader) can and cannot detect, and which gate tag covers each one. - - title: "Understanding WCAG 2.4.2: Page Titled" - url: "https://www.w3.org/WAI/WCAG22/Understanding/page-titled" + - title: "How to Meet WCAG 2.2 (Quick Reference)" + url: "https://www.w3.org/WAI/WCAG22/quickref/" description: >- - Why every page in an application needs a title that describes its purpose, - and why this matters especially in single-page applications. - - title: "Understanding WCAG 4.1.3: Status Messages" - url: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages" - description: >- - When content updates without a page reload, assistive technology needs a - programmatic signal before it will report the change. What counts as one, - and when the criterion applies. + Every success criterion, filterable by level and guideline. Your compliance + report has to name the ones your tests actually verify, so this is where you + work out which they are. - title: "Guidepup Virtual Screen Reader" url: "https://github.com/guidepup/virtual-screen-reader" description: >- diff --git a/adventures/accessibility-nightmare/expert/public/coverage-table.html b/adventures/accessibility-nightmare/expert/public/coverage-table.html index cd5c622..2576f5c 100644 --- a/adventures/accessibility-nightmare/expert/public/coverage-table.html +++ b/adventures/accessibility-nightmare/expert/public/coverage-table.html @@ -286,11 +286,6 @@ white-space: nowrap; } -/* The two halves of WCAG 2.4.2, one visible to the scanner and one not. */ -tr.is-paired th[scope="row"] { box-shadow: inset 4px 0 0 #a4402c; } -tr.is-paired th[scope="row"], -tr.is-paired td { background: #fdf4f1; } - /* โ”€โ”€ Verified-by-gate column โ”€โ”€ */ .verdict { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; } @@ -503,7 +498,7 @@

Conformance coverage

{ check: 'Form inputs have associated labels', note: 'for/id pairing or aria-labelledby', wcag: '1.3.1', axe: 'yes', kbd: 'no', sr: 'yes', rules: ['label', 'form-field-multiple-labels'] }, { check: 'Images have descriptive alt text', note: 'Or alt="" when the image is decorative', wcag: '1.1.1', axe: 'yes', kbd: 'no', sr: 'yes', rules: ['image-alt'] }, { check: 'Text contrast meets 4.5 : 1', note: '3 : 1 for large text and UI components', wcag: '1.4.3', axe: 'yes', kbd: 'no', sr: 'no', rules: ['color-contrast'] }, - { check: 'The document has a title', note: 'The static half of 2.4.2 โ€” a title exists in the markup', wcag: '2.4.2', axe: 'yes', kbd: 'no', sr: 'yes', rules: ['document-title'], paired: true }, + { check: 'The document has a title', note: 'A title exists in the markup', wcag: '2.4.2', axe: 'yes', kbd: 'no', sr: 'yes', rules: ['document-title'] }, { check: 'A skip mechanism bypasses repeated blocks', note: 'A skip link, or landmark structure to navigate by', wcag: '2.4.1', axe: 'yes', kbd: 'partial', sr: 'yes', rules: ['bypass'] }, { check: 'Interactive targets meet minimum size', note: 'WCAG 2.2 โ€” 24 by 24 CSS pixels', wcag: '2.5.8', axe: 'yes', kbd: 'no', sr: 'no', rules: ['target-size'] }, { check: 'Text can be resized without loss', note: 'The viewport does not lock zoom', wcag: '1.4.4', axe: 'yes', kbd: 'no', sr: 'no', rules: ['meta-viewport'] }, @@ -523,8 +518,8 @@

Conformance coverage

{ group: 'Assistive technology output โ€” what the screen reader hears', rows: [ - { check: 'Route change announces the new page', note: 'The behavioural half of 2.4.2 โ€” no scanner can reach it', wcag: '2.4.2', axe: 'no', kbd: 'no', sr: 'yes', annotation: 'route-announcement', paired: true }, - { check: 'Status messages use the right politeness', note: 'Polite for route changes, assertive only when urgent', wcag: '4.1.3', axe: 'no', kbd: 'no', sr: 'yes', annotation: 'status-politeness' }, + { check: 'Content that changes without a page load is announced', note: 'Anything the user did not type that alters what is on screen', wcag: '4.1.3', axe: 'no', kbd: 'no', sr: 'yes', annotation: 'route-announcement' }, + { check: 'Status messages use the right politeness', note: 'Polite by default, assertive only when genuinely urgent', wcag: '4.1.3', axe: 'no', kbd: 'no', sr: 'yes', annotation: 'status-politeness' }, { check: 'Form errors are announced on submission', note: 'A live region, or focus moved to an error summary', wcag: '4.1.3', axe: 'partial', kbd: 'no', sr: 'yes' }, { check: 'Widget state changes are announced', note: 'Dropdowns, dialogs and toggles report their state', wcag: '4.1.2', axe: 'partial', kbd: 'no', sr: 'yes' }, ], @@ -698,7 +693,6 @@

Conformance coverage

for (const row of group.rows) { const tr = document.createElement('tr'); - if (row.paired) tr.className = 'is-paired'; tr.innerHTML = ` ${esc(row.check)}${esc(row.note)} ${esc(row.wcag)} diff --git a/adventures/accessibility-nightmare/expert/verify.sh b/adventures/accessibility-nightmare/expert/verify.sh index 4a26853..ce8876a 100755 --- a/adventures/accessibility-nightmare/expert/verify.sh +++ b/adventures/accessibility-nightmare/expert/verify.sh @@ -141,7 +141,7 @@ fi # 4. Route transitions are announced -------------------------------------- check_playwright_tests "@transition" \ "The gate catches the navigation barrier that the scanner never reported" \ - "Nothing here is wrong in the markup, so the scanner has nothing to report. Move through the app the way someone who cannot see it would, and pay attention to what arrives after the address changes." + "The markup is correct at every moment the scanner looks, so it has nothing to report. Move through the shop the way someone who cannot see it would, and judge whether you could still follow where you were." # 5. Compliance report documenting what was audited ----------------------- From 880c85315842e2e5d2b6a4b233accdbd4f5de86f Mon Sep 17 00:00:00 2001 From: sindurigf Date: Tue, 22 Sep 2026 11:49:16 +0200 Subject: [PATCH 4/8] Fix announcement ordering in the listen panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route marker was printed after awaiting a drain. That await yields the event loop, React commits its re-render, and the new page's announcement reaches the log in time to be flushed above the marker it belongs below โ€” so the panel showed the arrival before the navigation that caused it. The marker is now queued on hashchange and printed at the end of the drain that call kicks off. React commits after the handler returns, so that drain still reads a log ending at the previous page: phrases spoken before the navigation land above the marker, the arrival lands below it. Overlapping drains are also blocked. Two calls could both slice from the same index and print the same phrases twice, which the 200ms poll made likely during navigation. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../expert/src/dev/announcements.js | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/adventures/accessibility-nightmare/expert/src/dev/announcements.js b/adventures/accessibility-nightmare/expert/src/dev/announcements.js index d57224e..ad8d8fe 100644 --- a/adventures/accessibility-nightmare/expert/src/dev/announcements.js +++ b/adventures/accessibility-nightmare/expert/src/dev/announcements.js @@ -89,6 +89,7 @@ export async function start() { await virtual.start({ container: root }); let shown = 0; + let markerPending = null; function append(text, style) { empty.remove(); @@ -99,9 +100,26 @@ export async function start() { list.scrollTop = list.scrollHeight; } + // Reading the log is asynchronous, so two overlapping calls would both slice + // from the same `shown` index and print the same phrases twice. + let draining = false; + async function drain() { + if (draining) return; + draining = true; + try { + await flush(); + } finally { + draining = false; + } + } + + async function flush() { const log = await virtual.spokenPhraseLog(); - if (log.length === shown) return; + if (log.length === shown) { + drainMarker(); + return; + } for (const phrase of log.slice(shown)) { const isLive = /^(polite|assertive):/.test(phrase); @@ -117,20 +135,34 @@ export async function start() { ); } shown = log.length; + drainMarker(); } - // Marks where the address changed, so what the reader said before a - // navigation can be told apart from what it said after one. - window.addEventListener('hashchange', async () => { - await drain(); + // Appended only once the phrases spoken before the navigation have been + // printed, so the boundary lands in the right place. + function drainMarker() { + if (!markerPending) return; append( - `ยท ${window.location.hash || '#/'}`, + `ยท ${markerPending}`, ` margin: 0.5rem 0 0.35rem; color: #6f6f85; word-break: break-word; `, ); + markerPending = null; + } + + // Marks where the address changed, so what the reader said before a + // navigation can be told apart from what it said after one. + // + // React commits its re-render after this handler returns, so a drain kicked + // off here reads a log that still ends at the previous page. The marker is + // printed at the end of that drain, below the phrases it belongs below and + // above anything the new page goes on to say. + window.addEventListener('hashchange', () => { + markerPending = window.location.hash || '#/'; + drain(); }); // Deliberately never cleared: the panel lives for as long as the page does. From 0006a35cfdf7bf5bc7d51c38511d5837c904053c Mon Sep 17 00:00:00 2001 From: sindurigf Date: Tue, 22 Sep 2026 12:10:51 +0200 Subject: [PATCH 5/8] Require the route announcement to be usable, not merely present The level could be passed by announcing a fixed string. That satisfies a naive "something was announced" assertion and is broken twice over: it never tells the customer which page they reached, and because the text never changes there is no mutation for the browser to announce, so it goes silent from the second navigation onward. Announcing assertively passed too, though it interrupts whatever the user was listening to. A new check drives the repaired storefront directly rather than trusting the player's own tests, which prove only what the player chose to assert. It moves between two pages in a row and requires each arrival to be announced, the two to differ, and neither to interrupt. Verified to discriminate: unfixed fails, a fixed string fails, an assertive live region fails, naming the destination politely passes. An over-announcing check was tried first and dropped. React bails out when the text is unchanged, so a missing dependency array produced no extra announcement and the test would have passed for everyone. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../accessibility-nightmare/docs/expert.yaml | 5 ++ .../accessibility-nightmare/expert/.gitignore | 1 + .../accessibility-nightmare/expert/verify.sh | 76 ++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/adventures/accessibility-nightmare/docs/expert.yaml b/adventures/accessibility-nightmare/docs/expert.yaml index 7d89662..38edaa4 100644 --- a/adventures/accessibility-nightmare/docs/expert.yaml +++ b/adventures/accessibility-nightmare/docs/expert.yaml @@ -192,6 +192,11 @@ how_to_play: the test against the outcome rather than any particular repair, and more than one approach will satisfy it. + The repair has to survive being used. Move between two different pages in a row and + judge both arrivals: a customer who cannot see the screen has to learn where they + have landed each time, without being cut off to hear it. Something that announces + once, or announces the same thing everywhere, is not a fix. + For each `@transition` test, use `test.info().annotations.push(...)` to attach a `route-announcement` annotation recording which WCAG criterion was satisfied. This is what goes into the compliance report. diff --git a/adventures/accessibility-nightmare/expert/.gitignore b/adventures/accessibility-nightmare/expert/.gitignore index dc8834f..9e2edd3 100644 --- a/adventures/accessibility-nightmare/expert/.gitignore +++ b/adventures/accessibility-nightmare/expert/.gitignore @@ -4,3 +4,4 @@ test-results/ playwright-report/ compliance-report.json .Payment.jsx.orig +.announcement-probe.mjs diff --git a/adventures/accessibility-nightmare/expert/verify.sh b/adventures/accessibility-nightmare/expert/verify.sh index ce8876a..3a73d4c 100755 --- a/adventures/accessibility-nightmare/expert/verify.sh +++ b/adventures/accessibility-nightmare/expert/verify.sh @@ -143,7 +143,81 @@ check_playwright_tests "@transition" \ "The gate catches the navigation barrier that the scanner never reported" \ "The markup is correct at every moment the scanner looks, so it has nothing to report. Move through the shop the way someone who cannot see it would, and judge whether you could still follow where you were." -# 5. Compliance report documenting what was audited ----------------------- +# 5. The announcement is actually usable ---------------------------------- +# +# The @transition tests are the player's own, so they prove only what the +# player chose to assert. This drives the repaired app directly. +# +# A fixed string passes a naive "something was announced" test and is still +# broken twice over: it never says where the customer landed, and because the +# text never changes there is no mutation to announce, so it goes silent from +# the second navigation onward. + +PROBE="${SCRIPT_DIR}/.announcement-probe.mjs" + +cat > "${PROBE}" <<'PROBE_EOF' +import { chromium } from '@playwright/test'; +import { + attachScreenReader, startScreenReader, + spokenPhrases, clearSpokenPhrases, settle, +} from './tests/lib/screen-reader.js'; + +const browser = await chromium.launch(); +const page = await (await browser.newContext()).newPage(); + +async function arriveAt(selector) { + await clearSpokenPhrases(page); + await page.click(selector); + await settle(page); + const said = await spokenPhrases(page); + return said.filter((phrase) => /^(polite|assertive):/.test(phrase)); +} + +let problems = []; +try { + await attachScreenReader(page); + await page.goto('http://127.0.0.1:5173/#/checkout'); + await startScreenReader(page); + + const payment = await arriveAt('a[href="#/payment"]'); + const product = await arriveAt('a[href="#/product/running-shoes"]'); + + if (payment.length === 0 || product.length === 0) { + problems.push('one of two consecutive navigations announced nothing at all'); + } else if (payment.join('|') === product.join('|')) { + problems.push('both destinations announced exactly the same words'); + } + if ([...payment, ...product].some((p) => p.startsWith('assertive:'))) { + problems.push('the announcement interrupts rather than waiting its turn'); + } +} catch (error) { + problems.push(`could not drive the storefront: ${error.message}`); +} finally { + await browser.close(); +} + +if (problems.length > 0) { + console.error(problems.join('; ')); + process.exit(1); +} +PROBE_EOF + +print_test_section "Checking The announcement says where the customer landed..." + +if (cd "${SCRIPT_DIR}" && node "${PROBE}" >/dev/null 2>&1); then + print_success_indent "The announcement says where the customer landed" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "The announcement says where the customer landed" + print_hint "Something is announced, but it does not survive contact with a second navigation. Move between two different pages in a row with ?listen open and read both arrivals: does each one tell you where you are, and does the second one arrive at all?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("announcement_not_useful") +fi + +rm -f "${PROBE}" +print_new_line + +# 6. Compliance report documenting what was audited ----------------------- print_test_section "Checking compliance report..." From 32596dc648d46d871c12acba0082c0601df9302b Mon Sep 17 00:00:00 2001 From: sindurigf Date: Tue, 22 Sep 2026 18:21:00 +0200 Subject: [PATCH 6/8] Tighten the level's prose and align it with the sibling levels A tone pass against beginner and intermediate, which set the house voice. Removed the consultancy phrasing ("which tool bridges the gap"), the unearned adjective on axe-core, the benefit claim about a report that "stands up to audit", and two lines that graded the player's work before they had done any. Cut "that is the lesson, not a bug", which congratulated the challenge on its own twist and pre-empted the one thing the player is meant to notice for themselves. Deleted a duplicated instruction to assert the outcome rather than the mechanism, which appeared twice within four lines. Added the storefront opener both siblings carry and this level was missing: where the Ports tab is, that Codespaces serves from .app.github.dev with no localhost to visit, and how to start the server by hand. The level referenced that tab without ever introducing it. Dropped the status-politeness binding from the coverage reference. No doc, hint or spec ever asked for that annotation, so the row was permanently unsatisfiable; politeness is enforced by the new check instead. Also stopped one verify hint from restating the annotation type the docs already give. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../accessibility-nightmare/docs/expert.yaml | 36 ++++++++++--------- .../expert/public/coverage-table.html | 5 ++- .../accessibility-nightmare/expert/verify.sh | 4 +-- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/adventures/accessibility-nightmare/docs/expert.yaml b/adventures/accessibility-nightmare/docs/expert.yaml index 38edaa4..2d668c1 100644 --- a/adventures/accessibility-nightmare/docs/expert.yaml +++ b/adventures/accessibility-nightmare/docs/expert.yaml @@ -22,11 +22,11 @@ contributor: meta_description: >- Debug an accessibility compliance gate that has been green for months while blind users cannot navigate the app. Extend scanner coverage, discover what automated tools cannot - detect, repair what it misses, and produce a compliance report that stands up to audit. + detect, repair what it misses, and produce a report naming what was audited and what it proved. summary: >- Repair the automated check that is meant to stop inaccessible code from shipping, and - find out why it has never once caught the navigation barrier the company is being audited over. + find out why it never caught the navigation barrier the company is being audited over. audience: >- Frontend developers who are comfortable with Playwright and automated accessibility scanning, @@ -64,7 +64,7 @@ what_you_learn: actually tested. ([WCAG 2.4: Navigable](https://www.w3.org/WAI/WCAG22/Understanding/navigable)) - >- Why automated scanners cannot detect faults that only exist when a user does something โ€” - and which tool bridges the gap. + and which layer catches them instead. ([Test and Evaluate Accessibility](https://www.w3.org/WAI/test-evaluate/)) - >- How a single-page application can silently break the navigation contract that assistive @@ -104,8 +104,8 @@ toolbox: - name: "axe-core" url: "https://github.com/dequelabs/axe-core" description: >- - The automated scanner already in the compliance gate. Fast and structural โ€” - it checks what the markup says, not what the browser announces. + The automated scanner already in the compliance gate. Structural: it checks what + the markup says, not what the browser announces. - name: "Playwright Reporter API" url: "https://playwright.dev/docs/test-reporters" description: >- @@ -121,6 +121,13 @@ how_to_play: - id: browse title: "Open the Store" content: | + The storefront is already running. Open the **Ports** tab in the editor, + find **ShopSmart** on port 5173, and click the globe icon to open it in a + browser tab. Codespaces serves it from an address ending in + `.app.github.dev`, so there is no localhost to visit. + + If it is not running, start it from the level directory with `make app`. + Add the product route to whatever address the Ports tab gave you: ```text @@ -180,17 +187,14 @@ how_to_play: can find them. The gate should visit every page a customer passes through โ€” homepage, product, checkout, and payment. - When the extended scan still reports nothing wrong, that is the lesson, not a bug. axe-core reads the markup. If the markup is correct at every moment it looks, there is nothing for it to report โ€” which tells you where to look next, not that you are done. - Tag your navigation tests `@transition`. A good detector moves between routes the + Tag your navigation tests `@transition`. A detector for this moves between routes the way a customer would and asserts that the destination made itself known. Assert the - outcome, not the mechanism, and the test will survive whichever repair you choose. + outcome, not the mechanism. - Once you have a failing `@transition` test, fix `src/App.jsx` to make it pass. Write - the test against the outcome rather than any particular repair, and more than one - approach will satisfy it. + Once you have a failing `@transition` test, fix `src/App.jsx` to make it pass. The repair has to survive being used. Move between two different pages in a row and judge both arrivals: a customer who cannot see the screen has to learn where they @@ -203,8 +207,7 @@ how_to_play: Push the annotation *before* the assertion, not after it. A failed assertion ends the test where it stands, so anything recorded after it never reaches the - report โ€” and a test that fails without saying what it was checking is the same - blind spot you were sent here to close. + report. Finally, add a JSON reporter to `playwright.config.js` so that running `npm run test:a11y` writes a `compliance-report.json` file: @@ -228,7 +231,7 @@ how_to_play: one tool it did run was the wrong instrument for what was broken. Every layer of a testing stack catches something the others miss, and a gate built - on one layer reports the absence of the faults it can see. Which layer is missing + on one layer can only report the absence of the faults that layer sees. Which layer is missing from the gate you rely on at work, and what would it take to add it? helpful_links: @@ -241,9 +244,8 @@ helpful_links: - title: "How to Meet WCAG 2.2 (Quick Reference)" url: "https://www.w3.org/WAI/WCAG22/quickref/" description: >- - Every success criterion, filterable by level and guideline. Your compliance - report has to name the ones your tests actually verify, so this is where you - work out which they are. + Every success criterion, filterable by level and guideline. Your compliance report has to name the + ones your tests actually verify. - title: "Guidepup Virtual Screen Reader" url: "https://github.com/guidepup/virtual-screen-reader" description: >- diff --git a/adventures/accessibility-nightmare/expert/public/coverage-table.html b/adventures/accessibility-nightmare/expert/public/coverage-table.html index 2576f5c..fb1d341 100644 --- a/adventures/accessibility-nightmare/expert/public/coverage-table.html +++ b/adventures/accessibility-nightmare/expert/public/coverage-table.html @@ -401,7 +401,7 @@

Conformance coverage

Every fault class this storefront can be audited for, which testing layer is capable of detecting it, and whether the compliance gate actually verified it on - its last run. Read from compliance-report.json. + its last run. The final column is read from compliance-report.json.

@@ -423,7 +423,6 @@

Conformance coverage

@@ -519,7 +518,7 @@

Conformance coverage

group: 'Assistive technology output โ€” what the screen reader hears', rows: [ { check: 'Content that changes without a page load is announced', note: 'Anything the user did not type that alters what is on screen', wcag: '4.1.3', axe: 'no', kbd: 'no', sr: 'yes', annotation: 'route-announcement' }, - { check: 'Status messages use the right politeness', note: 'Polite by default, assertive only when genuinely urgent', wcag: '4.1.3', axe: 'no', kbd: 'no', sr: 'yes', annotation: 'status-politeness' }, + { check: 'Status messages use the right politeness', note: 'Polite by default, assertive only when genuinely urgent', wcag: '4.1.3', axe: 'no', kbd: 'no', sr: 'yes' }, { check: 'Form errors are announced on submission', note: 'A live region, or focus moved to an error summary', wcag: '4.1.3', axe: 'partial', kbd: 'no', sr: 'yes' }, { check: 'Widget state changes are announced', note: 'Dropdowns, dialogs and toggles report their state', wcag: '4.1.2', axe: 'partial', kbd: 'no', sr: 'yes' }, ], diff --git a/adventures/accessibility-nightmare/expert/verify.sh b/adventures/accessibility-nightmare/expert/verify.sh index 3a73d4c..48e1ca0 100755 --- a/adventures/accessibility-nightmare/expert/verify.sh +++ b/adventures/accessibility-nightmare/expert/verify.sh @@ -141,7 +141,7 @@ fi # 4. Route transitions are announced -------------------------------------- check_playwright_tests "@transition" \ "The gate catches the navigation barrier that the scanner never reported" \ - "The markup is correct at every moment the scanner looks, so it has nothing to report. Move through the shop the way someone who cannot see it would, and judge whether you could still follow where you were." + "The markup is correct at every moment the scanner looks, so it has nothing to report. Move through the shop the way someone who cannot see it would, and judge whether you could still tell where you had arrived." # 5. The announcement is actually usable ---------------------------------- # @@ -239,7 +239,7 @@ else TESTS_PASSED=$((TESTS_PASSED + 1)) else print_error_indent "Compliance report documents the route announcements that were verified" - print_hint "The report records that the tests ran, but not what any of them set out to prove. A result with no claim attached is not evidence an auditor can use. Playwright lets a test annotate itself; the gate is looking for the type route-announcement." + print_hint "The report records that the tests ran, but not what any of them set out to prove. A result with no claim attached is not evidence an auditor can use." TESTS_FAILED=$((TESTS_FAILED + 1)) FAILED_CHECKS+=("compliance_report_annotation") fi From 086359abffe27ce40267dcccec73d79cdd47cf55 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Tue, 22 Sep 2026 18:22:33 +0200 Subject: [PATCH 7/8] Fix a hint that assumed the announcement already existed The coverage hint opened with "Something is announced, but...", which is false in the state every player meets first: nothing is announced at all. It now describes what the two arrivals have to do, which reads correctly whether the player has heard silence, a fixed string, or an announcement that interrupts. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- adventures/accessibility-nightmare/expert/verify.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adventures/accessibility-nightmare/expert/verify.sh b/adventures/accessibility-nightmare/expert/verify.sh index 48e1ca0..65f0d73 100755 --- a/adventures/accessibility-nightmare/expert/verify.sh +++ b/adventures/accessibility-nightmare/expert/verify.sh @@ -209,7 +209,7 @@ if (cd "${SCRIPT_DIR}" && node "${PROBE}" >/dev/null 2>&1); then TESTS_PASSED=$((TESTS_PASSED + 1)) else print_error_indent "The announcement says where the customer landed" - print_hint "Something is announced, but it does not survive contact with a second navigation. Move between two different pages in a row with ?listen open and read both arrivals: does each one tell you where you are, and does the second one arrive at all?" + print_hint "Move between two different pages in a row with ?listen open and read both arrivals. Each one has to tell the customer which page they reached, both have to arrive, and neither should cut the reader off mid-sentence to do it." TESTS_FAILED=$((TESTS_FAILED + 1)) FAILED_CHECKS+=("announcement_not_useful") fi From 3783c9138e57ec95f0b8429f0d2c4eb453373652 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Tue, 22 Sep 2026 18:25:27 +0200 Subject: [PATCH 8/8] Tell the player when the announcement check cannot run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks 2 to 4 reach the storefront through playwright.config.js, which starts the server if it is not already up. Check 5 drives the page itself, so nothing starts it โ€” and a dead port produced the hint about an announcement that does not survive a second navigation, sending the player to debug a fix that was never the problem. It now probes the port first and says so. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- adventures/accessibility-nightmare/expert/verify.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/adventures/accessibility-nightmare/expert/verify.sh b/adventures/accessibility-nightmare/expert/verify.sh index 65f0d73..1f7e810 100755 --- a/adventures/accessibility-nightmare/expert/verify.sh +++ b/adventures/accessibility-nightmare/expert/verify.sh @@ -204,7 +204,15 @@ PROBE_EOF print_test_section "Checking The announcement says where the customer landed..." -if (cd "${SCRIPT_DIR}" && node "${PROBE}" >/dev/null 2>&1); then +# Unlike the checks above, this one drives the storefront itself rather than +# going through playwright.config.js, so nothing starts the server for it. A +# dead port would otherwise be reported as a fault in the announcement. +if ! curl --fail --silent --max-time 5 http://127.0.0.1:5173 >/dev/null; then + print_error_indent "The announcement says where the customer landed" + print_hint "The storefront is not answering on port 5173, so this check could not run. Start it with 'make app' from this directory, then try again." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("announcement_unmeasured_no_server") +elif (cd "${SCRIPT_DIR}" && node "${PROBE}" >/dev/null 2>&1); then print_success_indent "The announcement says where the customer landed" TESTS_PASSED=$((TESTS_PASSED + 1)) else
Fault classes grouped by the testing layer capable of detecting them. - The final column reports what the gate verified on its last run.