From a6ce04109ecb8e705cd4529bfe563e64182931b0 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Sun, 13 Sep 2026 15:14:33 +0200 Subject: [PATCH 01/15] WIP: intermediate level (pre-move) Signed-off-by: sindurigf --- .../devcontainer.json | 42 + .../post-create.sh | 22 + .../post-start.sh | 42 + Makefile | 5 +- .../docs/intermediate.yaml | 236 +++ .../intermediate/.gitignore | 5 + .../intermediate/Makefile | 14 + .../intermediate/index.html | 16 + .../intermediate/package-lock.json | 1755 +++++++++++++++++ .../intermediate/package.json | 24 + .../intermediate/playwright.config.js | 24 + .../intermediate/public/images/headphones.svg | 6 + .../intermediate/public/images/shoes.svg | 5 + .../intermediate/public/images/store.svg | 7 + .../intermediate/public/images/watch.svg | 6 + .../intermediate/src/App.jsx | 62 + .../src/components/BasketDialog.jsx | 21 + .../src/components/CheckoutForm.jsx | 71 + .../src/components/SizePicker.jsx | 35 + .../intermediate/src/dev/announcements.js | 103 + .../intermediate/src/main.jsx | 19 + .../intermediate/src/pages/Checkout.jsx | 18 + .../intermediate/src/pages/Home.jsx | 46 + .../intermediate/src/pages/Product.jsx | 50 + .../intermediate/src/products.js | 30 + .../intermediate/src/styles.css | 387 ++++ .../intermediate/tests/checkout.spec.js | 368 ++++ .../intermediate/tests/lib/checkout.js | 132 ++ .../intermediate/tests/lib/screen-reader.js | 85 + .../intermediate/verify.sh | 90 + .../intermediate/vite.config.js | 23 + lib/scripts/loader.sh | 1 + lib/scripts/node.sh | 65 + 33 files changed, 3814 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/adventure-accessibility-nightmare_intermediate/devcontainer.json create mode 100755 .devcontainer/adventure-accessibility-nightmare_intermediate/post-create.sh create mode 100755 .devcontainer/adventure-accessibility-nightmare_intermediate/post-start.sh create mode 100644 adventures/planned/adventure-accessibility-nightmare/docs/intermediate.yaml create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/.gitignore create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/Makefile create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/index.html create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/package-lock.json create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/package.json create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/playwright.config.js create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/headphones.svg create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/shoes.svg create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/store.svg create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/watch.svg create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/App.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/BasketDialog.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/CheckoutForm.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/SizePicker.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/dev/announcements.js create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/main.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Checkout.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Home.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Product.jsx create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/products.js create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/src/styles.css create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/tests/checkout.spec.js create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/checkout.js create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/screen-reader.js create mode 100755 adventures/planned/adventure-accessibility-nightmare/intermediate/verify.sh create mode 100644 adventures/planned/adventure-accessibility-nightmare/intermediate/vite.config.js create mode 100644 lib/scripts/node.sh diff --git a/.devcontainer/adventure-accessibility-nightmare_intermediate/devcontainer.json b/.devcontainer/adventure-accessibility-nightmare_intermediate/devcontainer.json new file mode 100644 index 0000000..f3f6693 --- /dev/null +++ b/.devcontainer/adventure-accessibility-nightmare_intermediate/devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "โ™ฟ The Accessibility Nightmare | ๐ŸŸก Intermediate (The Checkout Trap)", + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/adventures/planned/adventure-accessibility-nightmare/intermediate", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "postCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/adventure-accessibility-nightmare_intermediate/post-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/adventure-accessibility-nightmare_intermediate/post-start.sh", + "customizations": { + "vscode": { + "extensions": [ + "ms-playwright.playwright" + ] + }, + "codespaces": { + "openFiles": [ + "src/components/SizePicker.jsx", + "src/components/BasketDialog.jsx", + "src/components/CheckoutForm.jsx" + ], + "permissions": { + "codespaces": "write" + } + } + }, + "forwardPorts": [ + 5173 + ], + "portsAttributes": { + "5173": { + "label": "ShopSmart", + "onAutoForward": "openBrowser" + } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } +} diff --git a/.devcontainer/adventure-accessibility-nightmare_intermediate/post-create.sh b/.devcontainer/adventure-accessibility-nightmare_intermediate/post-create.sh new file mode 100755 index 0000000..a2922ff --- /dev/null +++ b/.devcontainer/adventure-accessibility-nightmare_intermediate/post-create.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/scripts/tracker.sh" +set_tracking_context "adventure-accessibility-nightmare" "intermediate" "" "TODO" "TODO" +track_container_created + +"$REPO_ROOT/lib/shared/init.sh" --version v0.17.0 + +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/adventure-accessibility-nightmare/intermediate" + +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/adventure-accessibility-nightmare_intermediate/post-start.sh b/.devcontainer/adventure-accessibility-nightmare_intermediate/post-start.sh new file mode 100755 index 0000000..4d3bedd --- /dev/null +++ b/.devcontainer/adventure-accessibility-nightmare_intermediate/post-start.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/adventure-accessibility-nightmare/intermediate" + +echo "โœจ Starting The Accessibility Nightmare - Intermediate 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" + nohup npm run dev "$LOG_FILE" 2>&1 & +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 intermediate directory to start it manually." + exit 1 +fi + +echo "" +echo "๐Ÿ›’ The checkout is on the product page: http://localhost:5173/#/product/running-shoes" +echo "" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/scripts/tracker.sh" +set_tracking_context "adventure-accessibility-nightmare" "intermediate" "" "TODO" "TODO" +track_container_initialized diff --git a/Makefile b/Makefile index 01274b7..a49b5a6 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Open Ecosystem Challenges # ============================================================================== -.PHONY: help new-adventure docs accessibility-nightmare-beginner +.PHONY: help new-adventure docs accessibility-nightmare-beginner accessibility-nightmare-intermediate # Default target - show help help: @@ -22,3 +22,6 @@ docs: accessibility-nightmare-beginner: $(MAKE) -C adventures/planned/adventure-accessibility-nightmare/beginner app + +accessibility-nightmare-intermediate: + $(MAKE) -C adventures/planned/adventure-accessibility-nightmare/intermediate app diff --git a/adventures/planned/adventure-accessibility-nightmare/docs/intermediate.yaml b/adventures/planned/adventure-accessibility-nightmare/docs/intermediate.yaml new file mode 100644 index 0000000..11dab30 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/docs/intermediate.yaml @@ -0,0 +1,236 @@ +level: intermediate +emoji: "๐ŸŸก" +title: "The Checkout Trap" +devcontainer: adventure-accessibility-nightmare_intermediate +community_url: "" # TODO: add community thread URL once the adventure is live + +topics: + - axe-core + - Playwright + - Guidepup Virtual Screen Reader + - WCAG 2.2 + - React + +meta_description: >- + Repair a React checkout that passes every automated accessibility scan and still cannot be used. + Focus management across a modal, the ARIA combobox pattern, and live-region error announcement, + verified with Playwright and a virtual screen reader. + +summary: >- + Repair the shared checkout components so keyboard and screen reader users can + complete a purchase, on a page where the automated scanner already reports no + violations. + +audience: >- + Frontend developers who have run an accessibility scanner before and fixed + what it reported. Comfortable with React state, effects and refs, and ready + for the faults that no scanner will hand them. + +backstory: + - >- + The homepage is fixed and every automated scan comes back green, so the team + declared the problem solved. + - >- + The complaints kept arriving, and support has traced all of them to the + checkout. One customer adds a pair of shoes to their basket, and the + confirmation that appears never receives the keyboard. Their next Tab lands + somewhere behind it, on the page it is covering, and they carry on through + controls they can no longer see. + - >- + Another fills in the delivery details, submits, and is told nothing at all: + the error sits on screen in red, and their screen reader never mentions it. + - >- + The design team keeps shipping these same patterns, and nobody can see the + problem, because the scanner insists there isn't one. Fix the checkout + before the next batch of complaints reaches legal. + +objective: + - >- + Complete the checkout end to end using only the keyboard. + - >- + Have every step of that journey announced to a screen reader, including why + a submission was rejected. + - >- + Rebuild the size picker as a select-only combobox, so it exposes a role, a + name and the size that is chosen. + - >- + Keep the axe-core scan reporting no violations throughout. + +what_you_learn: + - >- + How to move, hold and restore focus across a modal boundary, and why hiding + the background is not the same as putting it out of reach. + ([Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)) + - >- + How to build a control that reports its own role, name, value and state, + following the + [select-only combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/). + - >- + How a validation failure actually reaches a screen reader user, through + [live regions](https://www.w3.org/WAI/ARIA/apg/practices/live-regions/) and + the attributes that bind a message to its field. + - >- + Why an automated scan can report a clean page that no keyboard or screen + reader user can operate. + ([Understanding WCAG 4.1.2](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value)) + +architecture: + - >- + A React and Vite storefront runs on port 5173 inside the Dev Container. The + homepage arrives with the beginner level's repairs already applied; the + checkout on the product page does not. + - >- + Buying something takes two pages. On /#/product/running-shoes you choose a + size and add the item to your basket, which opens a confirmation. From there + /#/checkout collects the delivery details and places the order. + - >- + Three components carry the faults, and all three are what you edit: + src/components/SizePicker.jsx, src/components/BasketDialog.jsx and + src/components/CheckoutForm.jsx. The pages that host them, + src/pages/Product.jsx and src/pages/Checkout.jsx, are yours to change too. + - >- + Playwright drives the browser with real key presses and runs the + [Guidepup Virtual Screen Reader](https://github.com/guidepup/virtual-screen-reader) + inside the page, reading back the log of what a screen reader would + announce. axe-core scans the same page and should stay silent from start to + finish. + - >- + Leave tests/ and verify.sh alone. They define the required outcomes and + should not be weakened or bypassed. + - >- + Do not add dependencies. Radix, React Aria and their equivalents solve the + panel and the picker in a single import, and reaching for a maintained + library is the right call on real work. The ban exists because the exercise + is understanding what those libraries do for you. verify.sh checks that the + dependency list is unchanged. + +architecture_diagram: "" + +toolbox: + - name: "Guidepup Virtual Screen Reader" + url: "https://github.com/guidepup/virtual-screen-reader" + description: >- + Produces a text log of what a screen reader would announce. It is a + simulation built from the accessibility tree, not NVDA, JAWS or VoiceOver, + and it will not reproduce the differences between them. Passing it means + the accessibility tree says the right thing, which is necessary but not + sufficient. Test with a real screen reader on real work. + - name: "Playwright" + url: "https://playwright.dev/" + description: "Drives real Tab, Escape and arrow presses and reports where focus actually lands." + - name: "axe-core" + url: "https://github.com/dequelabs/axe-core" + description: "The automated scanner that reports this page as clean, before and after." + - name: "ARIA Authoring Practices Guide" + url: "https://www.w3.org/WAI/ARIA/apg/patterns/" + description: "The reference implementations for the dialog and combobox patterns." + +services: + - name: "ShopSmart" + url: "http://localhost:5173" + description: "The storefront. The product page is at /#/product/running-shoes and the checkout at /#/checkout." + +how_to_play: + - id: start + title: "Start ShopSmart" + content: | + The storefront is already running in your Codespace. If you need to start + it yourself, run this from the repository root: + + ```bash + make accessibility-nightmare-intermediate + ``` + + Then open port 5173 and go to the product page: + + ```text + http://localhost:5173/#/product/running-shoes + ``` + + Buying something takes two pages: choose a size and add to basket there, + then go on to /#/checkout to place the order. + + - id: explore + title: "Explore the Broken Checkout" + content: | + Run the checks first, and notice which one passes: + + ```bash + npm run test:a11y + ``` + + The axe-core scan is green. It was green before you arrived and it has to + stay green, so it will not be telling you what to fix. + + Then put the mouse away. From the product page, press Tab and keep + pressing it. Try to choose a size. Add the item to your basket and see + where the keyboard goes when the confirmation appears, where it goes next, + and what happens when you press Escape. Then go on to the checkout and + submit the form empty. + + To hear what a screen reader would say at each of those steps: + + ```bash + npm run listen + ``` + + That walks the same journey and prints the announcements, marking any + step that produced nothing at all. It grades nothing. Use it as often as + you like while you work, and run it again after each repair to see what + changed. + + - id: implement + title: "Repair the Checkout" + content: | + Work in: + + ```text + src/components/SizePicker.jsx + src/components/BasketDialog.jsx + src/components/CheckoutForm.jsx + src/pages/Product.jsx + src/pages/Checkout.jsx + ``` + + One thing is worth knowing before you start, because it costs hours and + teaches nothing: **a live region that appears at the same moment as its + text will never announce.** The element has to already be in the document, + empty, with its content filled in later. This is a React rendering + question rather than an accessibility one. + + Everything else is the exercise. Do not add dependencies, and do not + weaken the tests. + + `npm run listen` is there throughout, and costs nothing to run. + + When you are ready: + + ```bash + ./verify.sh + ``` + + - id: reflect + title: "Ask What the Scan Measured" + content: | + The axe-core scan reported no violations before you started and reports + none now. Nothing you fixed was ever visible to it. + + What did the scan actually measure, and what would you have to add to + catch any of this automatically? That question is the expert level. + +helpful_links: + - title: "ARIA Authoring Practices: Dialog (Modal)" + url: "https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/" + description: "The keyboard and focus contract a modal is expected to keep." + - title: "ARIA Authoring Practices: Select-Only Combobox" + url: "https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/" + description: "A worked listbox popup with no editable text input, which is the picker you need." + - title: "Understanding WCAG 4.1.3: Status Messages" + url: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages" + description: "Why a message that appears without moving focus has to be announced some other way." + - title: "Understanding WCAG 2.1.2: No Keyboard Trap" + url: "https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap" + description: "Holding the keyboard inside a modal is only acceptable when there is a way out." + - title: "Using ARIA: live regions" + url: "https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions" + description: "How assistive technology decides that new text is worth announcing." diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/.gitignore b/adventures/planned/adventure-accessibility-nightmare/intermediate/.gitignore new file mode 100644 index 0000000..ecbbfe5 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.lighthouseci/ +test-results/ +playwright-report/ \ No newline at end of file diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/Makefile b/adventures/planned/adventure-accessibility-nightmare/intermediate/Makefile new file mode 100644 index 0000000..0893359 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/Makefile @@ -0,0 +1,14 @@ +.DEFAULT_GOAL := help + +.PHONY: help app verify + +help: + @echo "The Accessibility Nightmare - Intermediate: Available Commands:" + @echo " make app Start the ShopSmart application" + @echo " make verify Run the verification script" + +app: + @npm run dev + +verify: + @./verify.sh diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/index.html b/adventures/planned/adventure-accessibility-nightmare/intermediate/index.html new file mode 100644 index 0000000..bad6e2b --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/index.html @@ -0,0 +1,16 @@ + + + + + + + + ShopSmart + + + +
+ + + + diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/package-lock.json b/adventures/planned/adventure-accessibility-nightmare/intermediate/package-lock.json new file mode 100644 index 0000000..353924f --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/package-lock.json @@ -0,0 +1,1755 @@ +{ + "name": "accessibility-nightmare-intermediate", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "accessibility-nightmare-intermediate", + "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.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "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.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "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.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "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.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "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.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "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.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "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.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "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.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "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.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.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.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "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.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "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.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "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.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "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.4.0 || ^0.5.0", + "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/vite/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/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/planned/adventure-accessibility-nightmare/intermediate/package.json b/adventures/planned/adventure-accessibility-nightmare/intermediate/package.json new file mode 100644 index 0000000..e534c52 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/package.json @@ -0,0 +1,24 @@ +{ + "name": "accessibility-nightmare-intermediate", + "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", + "listen": "node tools/listen.mjs", + "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/planned/adventure-accessibility-nightmare/intermediate/playwright.config.js b/adventures/planned/adventure-accessibility-nightmare/intermediate/playwright.config.js new file mode 100644 index 0000000..347a70f --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/playwright.config.js @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + 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/planned/adventure-accessibility-nightmare/intermediate/public/images/headphones.svg b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/headphones.svg new file mode 100644 index 0000000..1bc613f --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/headphones.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/shoes.svg b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/shoes.svg new file mode 100644 index 0000000..0a325c8 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/shoes.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/store.svg b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/store.svg new file mode 100644 index 0000000..6055f42 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/store.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/watch.svg b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/watch.svg new file mode 100644 index 0000000..be13fa2 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/public/images/watch.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/App.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/App.jsx new file mode 100644 index 0000000..4623de6 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/App.jsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import Home from './pages/Home.jsx'; +import Product from './pages/Product.jsx'; +import Checkout from './pages/Checkout.jsx'; +import { findProduct } from './products.js'; + +const PRODUCT_ROUTE = /^\/product\/([\w-]+)$/; + +function readRoute() { + return window.location.hash.replace(/^#/, '') || '/'; +} + +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 (product) { + page = ; + } + + return ( +
+
+ + ShopSmart + + + + + +
+ + {page} + +
+

ยฉ 2026 ShopSmart

+
+
+ ); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/BasketDialog.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/BasketDialog.jsx new file mode 100644 index 0000000..bb8cd6c --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/BasketDialog.jsx @@ -0,0 +1,21 @@ +export default function BasketDialog({ product, size, onClose }) { + return ( +
+
+

Added to basket

+ +

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

+ + + Checkout + + + +
+
+ ); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/CheckoutForm.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/CheckoutForm.jsx new file mode 100644 index 0000000..6c227f2 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/CheckoutForm.jsx @@ -0,0 +1,71 @@ +import { 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); + + function handleChange(event) { + const { name, value } = event.target; + setValues((current) => ({ ...current, [name]: value })); + } + + function handleSubmit(event) { + event.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); + } + + return ( +
+ {FIELDS.map((field) => ( +
+ + + {errors[field.name] && ( +

{errors[field.name]}

+ )} +
+ ))} + + + + {placed && ( +

Order placed. Thank you.

+ )} +
+ ); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/SizePicker.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/SizePicker.jsx new file mode 100644 index 0000000..5310c5e --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/components/SizePicker.jsx @@ -0,0 +1,35 @@ +import { useState } from 'react'; + +export default function SizePicker({ sizes, value, onChange }) { + const [open, setOpen] = useState(false); + + return ( +
+ Size + +
setOpen((wasOpen) => !wasOpen)} + > + {value} +
+ + {open && ( +
+ {sizes.map((size) => ( +
{ + onChange(size); + setOpen(false); + }} + > + {size} +
+ ))} +
+ )} +
+ ); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/dev/announcements.js b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/dev/announcements.js new file mode 100644 index 0000000..592fb2f --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/dev/announcements.js @@ -0,0 +1,103 @@ +// Development tool. Renders a live log of what a screen reader would announce, +// beside 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; +const PANEL_WIDTH = '22rem'; + +const PANEL_STYLE = ` + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 9999; + width: ${PANEL_WIDTH}; + display: flex; + flex-direction: column; + 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; + + const heading = document.createElement('div'); + heading.textContent = 'What a screen reader would say'; + heading.style.cssText = ` + padding: 0.75rem 0.9rem; + border-bottom: 1px solid #2c2c3a; + font-weight: 700; + `; + + const note = document.createElement('div'); + note.textContent = 'a simulation, not real assistive technology'; + note.style.cssText = ` + padding: 0.5rem 0.9rem; + border-bottom: 1px solid #2c2c3a; + color: #9a9aae; + `; + + const list = document.createElement('div'); + list.style.cssText = ` + flex: 1; + overflow-y: auto; + padding: 0.5rem 0.9rem 1rem; + `; + + const empty = document.createElement('p'); + empty.textContent = + 'Nothing yet. Move around the page with the keyboard.'; + empty.style.cssText = 'color: #9a9aae; margin: 0.5rem 0;'; + list.append(empty); + + panel.append(heading, note, list); + document.body.append(panel); + document.body.style.paddingRight = PANEL_WIDTH; + + // Read only the application, never the panel itself. + await virtual.start({ container: root }); + + let shown = 0; + + // Deliberately never cleared: the panel lives for as long as the page does. + setInterval(async () => { + const log = await virtual.spokenPhraseLog(); + if (log.length === shown) return; + + empty.remove(); + for (const phrase of log.slice(shown)) { + const line = document.createElement('p'); + line.textContent = phrase; + const isLive = /^(polite|assertive):/.test(phrase); + line.style.cssText = ` + margin: 0 0 0.35rem; + padding-left: 0.6rem; + border-left: 2px solid ${isLive ? '#57d977' : '#3a3a4c'}; + color: ${isLive ? '#8ff0a4' : '#e8e8ef'}; + word-break: break-word; + `; + list.append(line); + } + shown = log.length; + list.scrollTop = list.scrollHeight; + }, POLL_MS); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/main.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/main.jsx new file mode 100644 index 0000000..9097544 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/main.jsx @@ -0,0 +1,19 @@ +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. +if ( + import.meta.env.DEV && + new URLSearchParams(window.location.search).has('listen') +) { + import('./dev/announcements.js').then((tool) => tool.start()); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Checkout.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Checkout.jsx new file mode 100644 index 0000000..a959264 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/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/planned/adventure-accessibility-nightmare/intermediate/src/pages/Home.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Home.jsx new file mode 100644 index 0000000..9da006b --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/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/planned/adventure-accessibility-nightmare/intermediate/src/pages/Product.jsx b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Product.jsx new file mode 100644 index 0000000..3534ce5 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/pages/Product.jsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import SizePicker from '../components/SizePicker.jsx'; +import BasketDialog from '../components/BasketDialog.jsx'; + +export default function Product({ product, onAddToBasket }) { + const [size, setSize] = useState(product.sizes[1]); + const [dialogOpen, setDialogOpen] = useState(false); + + function addToBasket() { + onAddToBasket({ product, size }); + setDialogOpen(true); + } + + return ( +
+ {product.imageAlt} + +
+

{product.name}

+

{product.description}

+ + + + +
+ + {dialogOpen && ( + setDialogOpen(false)} + /> + )} +
+ ); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/src/products.js b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/products.js new file mode 100644 index 0000000..6e54bd0 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/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/planned/adventure-accessibility-nightmare/intermediate/src/styles.css b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/styles.css new file mode 100644 index 0000000..0792fe5 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/src/styles.css @@ -0,0 +1,387 @@ +: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; +} + +@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/planned/adventure-accessibility-nightmare/intermediate/tests/checkout.spec.js b/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/checkout.spec.js new file mode 100644 index 0000000..4f54759 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/checkout.spec.js @@ -0,0 +1,368 @@ +import { expect, test } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { + CHECKOUT_URL, + NO_DIALOG, + PRODUCT_URL, + STARTING_SIZE, + activeOptionName, + addToBasket, + addWithMouse, + basketDialog, + checkoutLink, + chooseAnotherSize, + continueShopping, + describeFocus, + focusIsInsideDialog, + placeOrder, + sizePicker, + tabTo, +} from './lib/checkout.js'; +import { + attachScreenReader, + clearSpokenPhrases, + liveAnnouncements, + settle, + spokenPhrases, + startScreenReader, +} from './lib/screen-reader.js'; + +async function seriousViolations(page) { + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + + return results.violations + .filter((violation) => + ['serious', 'critical'].includes(violation.impact), + ) + .map((violation) => ({ + id: violation.id, + impact: violation.impact, + affectedElements: violation.nodes.length, + help: violation.help, + })); +} + +async function expectClean(page, state) { + const found = await seriousViolations(page); + expect( + found, + `axe reported violations in the "${state}" state:\n${JSON.stringify(found, null, 2)}`, + ).toEqual([]); +} + +// ----------------------------------------------------------------------------- +// The premise. This is green before a single line is changed, and it has to +// stay green: the repairs below are invisible to the scanner in both directions. +// ----------------------------------------------------------------------------- + +test('@scan the automated scan reports no violations', async ({ page }) => { + await page.goto('/'); + await expectClean(page, 'homepage'); + + await page.goto(PRODUCT_URL); + await expectClean(page, 'product page'); + + await addWithMouse(page); + await expectClean(page, 'basket confirmation open'); + + await page.goto(CHECKOUT_URL); + await expectClean(page, 'checkout page'); + + await placeOrder(page).click(); + await expectClean(page, 'submission rejected'); + + await page.getByLabel('Full name').fill('Ada Lovelace'); + await page.getByLabel('Email').fill('ada@example.com'); + await placeOrder(page).click(); + await expectClean(page, 'order placed'); +}); + +// ----------------------------------------------------------------------------- +// The size picker +// ----------------------------------------------------------------------------- + +test('@picker the size picker is reachable and operable from the keyboard', async ({ + page, +}) => { + await page.goto(PRODUCT_URL); + + const chosen = await chooseAnotherSize(page); + expect(chosen).not.toBe(STARTING_SIZE); +}); + +test('@picker the size picker announces its name, its state and its value', async ({ + page, +}) => { + await attachScreenReader(page); + await page.goto(PRODUCT_URL); + await startScreenReader(page); + + await clearSpokenPhrases(page); + await tabTo(page, sizePicker(page)); + await settle(page); + + const [heard] = (await spokenPhrases(page)).slice(-1); + expect(heard, 'Nothing was announced when the picker took focus.').toBeTruthy(); + expect( + heard, + `A screen reader should hear what the control is called. Heard: ${heard}`, + ).toMatch(/size/i); + expect( + heard, + `A screen reader should hear whether the list of sizes is open. Heard: ${heard}`, + ).toMatch(/not expanded|collapsed/i); + + await clearSpokenPhrases(page); + await page.keyboard.press('ArrowDown'); + await settle(page); + + expect( + await activeOptionName(page), + 'Opening the list should put the keyboard on one of the options.', + ).not.toBeNull(); + expect( + (await spokenPhrases(page)).join(' | '), + 'A screen reader should hear the sizes it landed among, not loose text.', + ).toMatch(/option/i); + + // The picker's current value is the option it reports as selected. The + // virtual screen reader does not read a value off a combobox that has no + // input, so this is asserted where ARIA actually carries it. + await expect( + page.getByRole('option', { selected: true }), + 'The size that is currently chosen should be marked as the selected option.', + ).toHaveText(STARTING_SIZE); +}); + +test('@picker the open list of sizes still scans clean', async ({ page }) => { + await page.goto(PRODUCT_URL); + + await tabTo(page, sizePicker(page)); + await page.keyboard.press('ArrowDown'); + await expect(page.getByRole('listbox')).toBeVisible(); + + await expectClean(page, 'size list open'); +}); + +// ----------------------------------------------------------------------------- +// The basket confirmation dialog +// ----------------------------------------------------------------------------- + +test('@dialog adding to the basket moves the keyboard into the confirmation', async ({ + page, +}) => { + await page.goto(PRODUCT_URL); + + await tabTo(page, addToBasket(page)); + await page.keyboard.press('Enter'); + + await expect(basketDialog(page), NO_DIALOG).toBeVisible(); + expect( + await focusIsInsideDialog(page), + `The keyboard stayed behind the dialog, on ${await describeFocus(page)}.`, + ).toBe(true); +}); + +test('@dialog the keyboard cannot wander out of the open dialog', async ({ + page, +}) => { + await page.goto(PRODUCT_URL); + + await tabTo(page, addToBasket(page)); + await page.keyboard.press('Enter'); + await expect(basketDialog(page), NO_DIALOG).toBeVisible(); + + const escaped = []; + for (let step = 0; step < 12; step++) { + await page.keyboard.press('Tab'); + if (!(await focusIsInsideDialog(page))) { + escaped.push(`Tab ${step + 1}: ${await describeFocus(page)}`); + } + } + for (let step = 0; step < 6; step++) { + await page.keyboard.press('Shift+Tab'); + if (!(await focusIsInsideDialog(page))) { + escaped.push(`Shift+Tab ${step + 1}: ${await describeFocus(page)}`); + } + } + + expect( + escaped, + `The keyboard left the dialog and landed on the page behind it:\n ${escaped.join('\n ')}`, + ).toEqual([]); +}); + +test('@dialog Escape closes the dialog and gives the keyboard back', async ({ + page, +}) => { + await page.goto(PRODUCT_URL); + + const trigger = addToBasket(page); + await tabTo(page, trigger); + await page.keyboard.press('Enter'); + await expect(basketDialog(page), NO_DIALOG).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(basketDialog(page)).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test('@dialog dismissing the dialog returns the keyboard to what opened it', async ({ + page, +}) => { + await page.goto(PRODUCT_URL); + + const trigger = addToBasket(page); + await tabTo(page, trigger); + await page.keyboard.press('Enter'); + await expect(basketDialog(page), NO_DIALOG).toBeVisible(); + + await continueShopping(page).click(); + + await expect(basketDialog(page)).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test('@dialog the confirmation is announced as a dialog with a name', async ({ + page, +}) => { + await attachScreenReader(page); + await page.goto(PRODUCT_URL); + await startScreenReader(page); + + await tabTo(page, addToBasket(page)); + await clearSpokenPhrases(page); + await page.keyboard.press('Enter'); + await settle(page); + + const heard = (await spokenPhrases(page)).join(' | '); + expect( + heard, + `A screen reader user should be told a dialog opened. Heard: ${heard}`, + ).toMatch(/dialog/i); + expect( + heard, + `The dialog should say what it is for. Heard: ${heard}`, + ).toMatch(/added to basket/i); +}); + +// ----------------------------------------------------------------------------- +// The checkout form +// ----------------------------------------------------------------------------- + +test('@form a rejected submission is announced and the field is marked', async ({ + page, +}) => { + await attachScreenReader(page); + await page.goto(CHECKOUT_URL); + await startScreenReader(page); + + await clearSpokenPhrases(page); + await placeOrder(page).click(); + await settle(page); + + const announced = await liveAnnouncements(page); + expect( + announced, + 'Nothing was announced. The error is on screen and silent.', + ).not.toEqual([]); + expect( + announced.join(' | '), + `The announcement should say what went wrong. Heard: ${announced.join(' | ')}`, + ).toMatch(/full name/i); + + const nameField = page.getByLabel('Full name'); + await expect( + nameField, + 'The field that was rejected should say so.', + ).toHaveAttribute('aria-invalid', 'true'); + + await expect( + nameField, + 'The keyboard should be taken to the first field that needs attention.', + ).toBeFocused(); + + await clearSpokenPhrases(page); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + await settle(page); + + const heard = (await spokenPhrases(page)).join(' | '); + expect( + heard, + `Landing on the rejected field should carry its error with it. Heard: ${heard}`, + ).toMatch(/full name/i); + expect(heard, `Heard: ${heard}`).toMatch(/invalid/i); +}); + +test('@form a completed order is announced', async ({ page }) => { + await attachScreenReader(page); + await page.goto(CHECKOUT_URL); + await startScreenReader(page); + + await page.getByLabel('Full name').fill('Ada Lovelace'); + await page.getByLabel('Email').fill('ada@example.com'); + + await clearSpokenPhrases(page); + await placeOrder(page).click(); + await settle(page); + + const announced = await liveAnnouncements(page); + expect( + announced.join(' | '), + `A screen reader user should be told the order went through. Heard: ${announced.join(' | ')}`, + ).toMatch(/order placed/i); +}); + +// ----------------------------------------------------------------------------- +// The whole thing, end to end, without touching a mouse +// ----------------------------------------------------------------------------- + +test('@journey the checkout can be completed with the keyboard alone', async ({ + page, +}) => { + await attachScreenReader(page); + await page.goto(PRODUCT_URL); + await startScreenReader(page); + + const chosen = await chooseAnotherSize(page); + + await tabTo(page, addToBasket(page)); + await page.keyboard.press('Enter'); + await expect(basketDialog(page), NO_DIALOG).toBeVisible(); + expect(await focusIsInsideDialog(page)).toBe(true); + + await expect( + basketDialog(page), + 'The confirmation should say which size was added.', + ).toContainText(chosen); + + await tabTo(page, checkoutLink(page), 5); + await page.keyboard.press('Enter'); + + await expect( + page.getByRole('heading', { name: 'Checkout', level: 1 }), + ).toBeVisible(); + await expect( + page.getByText(chosen, { exact: false }).first(), + 'The size chosen on the product page should carry through to checkout.', + ).toBeVisible(); + + await tabTo(page, page.getByLabel('Full name'), 8); + await page.keyboard.type('Ada Lovelace'); + await page.keyboard.press('Tab'); + await page.keyboard.type('ada@example.com'); + + await clearSpokenPhrases(page); + await tabTo(page, placeOrder(page), 4); + await page.keyboard.press('Enter'); + await settle(page); + + expect( + (await liveAnnouncements(page)).join(' | '), + 'The order was placed with the keyboard, but nothing said so.', + ).toMatch(/order placed/i); +}); diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/checkout.js b/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/checkout.js new file mode 100644 index 0000000..d1594f4 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/checkout.js @@ -0,0 +1,132 @@ +import { expect } from '@playwright/test'; + +export const PRODUCT_URL = '/#/product/running-shoes'; +export const CHECKOUT_URL = '/#/checkout'; +export const SIZES = ['41', '42', '43']; +export const STARTING_SIZE = '42'; + +export function sizePicker(page) { + return page.getByRole('combobox', { name: /size/i }); +} + +export function addToBasket(page) { + return page.getByRole('button', { name: 'Add to basket' }); +} + +export function basketDialog(page) { + return page.getByRole('dialog'); +} + +export function checkoutLink(page) { + return page.getByRole('link', { name: 'Checkout' }); +} + +export function continueShopping(page) { + return page.getByRole('button', { name: 'Continue shopping' }); +} + +export function placeOrder(page) { + return page.getByRole('button', { name: 'Place order' }); +} + +export const NO_DIALOG = + 'The basket confirmation covers the page, but nothing tells assistive technology that a dialog opened.'; + +// Presses Tab until the target has focus. Fails with the tab order it walked +// through, which is the useful part when a control cannot be reached at all. +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) { + // count() resolves immediately. Going straight to evaluate() would wait for + // a control that is not there yet, and the walk would stall rather than + // report where the keyboard actually went. + 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}"`; + }); +} + +export async function focusIsInsideDialog(page) { + return page.evaluate( + () => !!document.activeElement?.closest('[role="dialog"]'), + ); +} + +// The option the keyboard is currently on, whichever technique the picker +// uses: real DOM focus on the option, or aria-activedescendant on the +// combobox. Returns null when the keyboard is not on an option at all. +export async function activeOptionName(page) { + return page.evaluate(() => { + const active = document.activeElement; + if (!active) return null; + const pointer = active.getAttribute('aria-activedescendant'); + const node = pointer ? document.getElementById(pointer) : active; + if (!node || node.getAttribute('role') !== 'option') return null; + return (node.textContent ?? '').trim(); + }); +} + +// Opens the picker, moves one option with the arrow keys and commits, all from +// the keyboard. Returns the size that was committed. +export async function chooseAnotherSize(page) { + const picker = sizePicker(page); + expect( + await picker.count(), + 'No control on the product page presents itself as a size picker.', + ).toBeGreaterThan(0); + await tabTo(page, picker); + + await page.keyboard.press('ArrowDown'); + await expect(page.getByRole('listbox')).toBeVisible(); + await expect(page.getByRole('option')).toHaveCount(SIZES.length); + + const before = await activeOptionName(page); + expect( + before, + 'Opening the list should put the keyboard on one of the options.', + ).not.toBeNull(); + + await page.keyboard.press('ArrowDown'); + const after = await activeOptionName(page); + expect( + after, + 'An arrow key should move the keyboard to a different option.', + ).not.toBe(before); + + await page.keyboard.press('Enter'); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect(picker).toBeFocused(); + await expect(picker).toHaveText(after); + + return after; +} + +// Adds the item with the mouse, for the tests that are about what the dialog +// announces rather than about how it handles focus. Waits on the confirmation +// text rather than the dialog role, so it works before the dialog is repaired. +export async function addWithMouse(page) { + await addToBasket(page).click(); + await expect(page.getByText('Added to basket')).toBeVisible(); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/screen-reader.js b/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/screen-reader.js new file mode 100644 index 0000000..f168a45 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/tests/lib/screen-reader.js @@ -0,0 +1,85 @@ +// 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, and it will not reproduce the differences between them. +// Passing these assertions means the accessibility tree says the right thing, +// which is necessary but not sufficient. Test with a real screen reader on +// real work. +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', +); + +// The published bundle is an ES module. Rewrite its export statement into +// window assignments so it can be injected as a classic script, which is not +// subject to the page's own CSP and runs before first paint. +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'), + ); + + // The bundle's last line is a `//# sourceMappingURL` comment, so the + // closing braces must start on a line of their own or they are swallowed. + return `(() => {\n${source}\n})();\n`; +} + +const INIT_SCRIPT = buildInitScript(); + +// Call before page.goto(). Loads the screen reader into every document the +// page creates. +export async function attachScreenReader(page) { + await page.addInitScript({ content: INIT_SCRIPT }); +} + +// Starts listening. From here the reader's cursor follows real focus, so +// driving the keyboard with Playwright is enough to move it. +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 ones are dropped: clearing a live region registers as a change here, +// but a real screen reader has nothing to say about it. +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(); + }); +} + +// Announcements are queued through a microtask, and React commits its updates +// asynchronously, so give both a moment to settle before reading the log. +export async function settle(page) { + await page.waitForTimeout(250); +} diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/verify.sh b/adventures/planned/adventure-accessibility-nightmare/intermediate/verify.sh new file mode 100755 index 0000000..34d9b52 --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/verify.sh @@ -0,0 +1,90 @@ +#!/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 "adventure-accessibility-nightmare" "intermediate" "" "TODO" "TODO" + +OBJECTIVE=" +- Complete the checkout end to end using only the keyboard. +- Have every step of that journey announced to a screen reader, including why a submission was rejected. +- Rebuild the size picker as a select-only combobox, so it exposes a role, a name and the size that is chosen. +- Keep the axe-core scan reporting no violations throughout." + +DOCS_URL="https://offon.dev/adventures/adventure-accessibility-nightmare/levels/intermediate" + +# The exercise is understanding what a headless UI library does for you, so the +# dependency set is fixed for the duration. +EXPECTED_DEPENDENCIES="@axe-core/playwright @guidepup/virtual-screen-reader @playwright/test @vitejs/plugin-react react react-dom vite" + +print_header \ + 'The Accessibility Nightmare' \ + 'The Checkout Trap' \ + 'Verification' + +# Init test counters +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_CHECKS=() + +check_prerequisites node npm npx jq + +print_sub_header "Running verification checks..." + +check_npm_dependencies \ + "package.json" \ + "$EXPECTED_DEPENDENCIES" \ + "The checkout is built by hand, with no new dependencies" \ + "Radix, React Aria and the rest solve the panel and the picker in a single import, which is the right call on real work and the wrong one here. Put the dependency list back and build the behaviour yourself." + +check_playwright_tests \ + "@scan" \ + "The automated scan still reports no violations" \ + "This was green before you touched anything. A component that claims a role takes on a contract the scanner does check, so read what it is objecting to." + +check_playwright_tests \ + "@picker" \ + "The size picker can be reached, operated and understood" \ + "Nothing tells assistive technology what the size control is, what it is called, or which size is chosen. Work out where each of those three things is supposed to come from." + +check_playwright_tests \ + "@dialog" \ + "The basket confirmation keeps hold of the keyboard" \ + "Follow the keyboard through the confirmation's whole life: opening it, moving around inside it, and leaving it again. Check where it actually ends up at each of those moments." + +check_playwright_tests \ + "@form" \ + "A rejected submission reaches a screen reader user" \ + "The message is already on the screen. Ask what has to be true of text that appears after the page has loaded before a screen reader will say anything about it." + +check_playwright_tests \ + "@journey" \ + "The whole checkout can be completed without a mouse" \ + "Put your hands on the keyboard only and buy something. Stop at the first place you cannot get past." + +# ============================================================================= +# 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 "adventure-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 "adventure-accessibility-nightmare" "intermediate" diff --git a/adventures/planned/adventure-accessibility-nightmare/intermediate/vite.config.js b/adventures/planned/adventure-accessibility-nightmare/intermediate/vite.config.js new file mode 100644 index 0000000..c6fa76c --- /dev/null +++ b/adventures/planned/adventure-accessibility-nightmare/intermediate/vite.config.js @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// 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()], + 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/loader.sh b/lib/scripts/loader.sh index 2d5ff10..85bbce2 100644 --- a/lib/scripts/loader.sh +++ b/lib/scripts/loader.sh @@ -13,6 +13,7 @@ source "$LIB_DIR/output.sh" source "$LIB_DIR/prerequisites.sh" source "$LIB_DIR/http.sh" source "$LIB_DIR/filesystem.sh" +source "$LIB_DIR/node.sh" source "$LIB_DIR/kubernetes.sh" source "$LIB_DIR/kyverno.sh" source "$LIB_DIR/prometheus.sh" diff --git a/lib/scripts/node.sh b/lib/scripts/node.sh new file mode 100644 index 0000000..5ec8884 --- /dev/null +++ b/lib/scripts/node.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +# node.sh - Helper functions for challenges built as Node projects +# These functions verify JavaScript project state and browser-driven tests + +# ----------------------------------------------------------------------------- +# Run the Playwright tests carrying a tag and report them as one check +# Usage: check_playwright_tests "@tag" "Display Name" "Hint message" [project_dir] +# ----------------------------------------------------------------------------- +check_playwright_tests() { + 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=list); then + print_success_indent "$display_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "$display_name" + print_hint "$hint" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("playwright:${tag#@}") + 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" +# ----------------------------------------------------------------------------- +check_npm_dependencies() { + local manifest=$1 + local expected=$2 + local display_name=$3 + local hint=$4 + + print_test_section "Checking $display_name..." + + local actual + actual=$(jq -r ' + [(.dependencies // {}), (.devDependencies // {})] + | add + | keys_unsorted + | sort + | join(" ") + ' "$manifest" 2>/dev/null) + + if [[ "$actual" == "$expected" ]]; then + print_success_indent "$display_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "$display_name" + print_info_indent "expected: $expected" + print_info_indent "found: $actual" + print_hint "$hint" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("npm_dependencies") + fi + + print_new_line +} From ee077389f883b5d4a6ceef4329a30964713a3a17 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Sun, 13 Sep 2026 20:57:22 +0200 Subject: [PATCH 02/15] Add contributor to intermediate docs Signed-off-by: sindurigf --- .../devcontainer-lock.json | 14 ++++++++++++++ .../accessibility-nightmare/docs/intermediate.yaml | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 .devcontainer/accessibility-nightmare_intermediate/devcontainer-lock.json diff --git a/.devcontainer/accessibility-nightmare_intermediate/devcontainer-lock.json b/.devcontainer/accessibility-nightmare_intermediate/devcontainer-lock.json new file mode 100644 index 0000000..3f866d1 --- /dev/null +++ b/.devcontainer/accessibility-nightmare_intermediate/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "1.1.2", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:7c409bf6316ffd85f04fd92fba85a744282639e603417dd4796409866bdb6805", + "integrity": "sha256:7c409bf6316ffd85f04fd92fba85a744282639e603417dd4796409866bdb6805" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + } + } +} diff --git a/adventures/accessibility-nightmare/docs/intermediate.yaml b/adventures/accessibility-nightmare/docs/intermediate.yaml index aadb2d8..f0669de 100644 --- a/adventures/accessibility-nightmare/docs/intermediate.yaml +++ b/adventures/accessibility-nightmare/docs/intermediate.yaml @@ -11,6 +11,14 @@ topics: - WCAG 2.2 - React +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: >- Repair a React checkout that passes every automated accessibility scan and still cannot be used. Focus management across a modal, the ARIA combobox pattern, and live-region error announcement, From 62496cbf624443cd2a24969773036dcc065f88e6 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 09:11:12 +0200 Subject: [PATCH 03/15] Fix Codespaces: keep the dev server alive and stop forcing the embedded preview post-start.sh backgrounded the dev server with a plain `nohup ... &`, which Codespaces kills when the lifecycle command's process group is torn down. The health check passed because it polled while the process was briefly alive, so the script reported success and the port was dead moments later. Players saw a 502 from the forwarded URL. setsid puts the server in its own session. Port 5173 no longer auto-opens. openBrowser launched an external window; openPreview used the editor's Simple Browser, which intercepts Tab and Escape and makes a keyboard-driven level impossible to work. notify leaves the choice to the player, and the docs now say to use a real browser tab and why. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../accessibility-nightmare_intermediate/devcontainer.json | 2 +- .../accessibility-nightmare_intermediate/post-start.sh | 6 +++++- adventures/accessibility-nightmare/docs/intermediate.yaml | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json b/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json index 095757f..40ce3f7 100644 --- a/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json +++ b/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json @@ -33,7 +33,7 @@ "portsAttributes": { "5173": { "label": "ShopSmart", - "onAutoForward": "openBrowser" + "onAutoForward": "notify" } }, "otherPortsAttributes": { diff --git a/.devcontainer/accessibility-nightmare_intermediate/post-start.sh b/.devcontainer/accessibility-nightmare_intermediate/post-start.sh index 638164e..8f25814 100755 --- a/.devcontainer/accessibility-nightmare_intermediate/post-start.sh +++ b/.devcontainer/accessibility-nightmare_intermediate/post-start.sh @@ -12,7 +12,11 @@ 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" - nohup npm run dev "$LOG_FILE" 2>&1 & + # setsid puts the server in its own session. A plain `nohup ... &` is killed + # when the Codespaces lifecycle command's process group is torn down, which + # leaves the port dead a moment after this script reports success. + setsid nohup npm run dev "$LOG_FILE" 2>&1 & + disown 2>/dev/null || true fi for _ in {1..30}; do diff --git a/adventures/accessibility-nightmare/docs/intermediate.yaml b/adventures/accessibility-nightmare/docs/intermediate.yaml index f0669de..6214244 100644 --- a/adventures/accessibility-nightmare/docs/intermediate.yaml +++ b/adventures/accessibility-nightmare/docs/intermediate.yaml @@ -155,6 +155,10 @@ how_to_play: http://localhost:5173/#/product/running-shoes ``` + **Open it in a real browser tab, not the editor's built-in preview.** This + level is worked almost entirely from the keyboard, and the embedded + preview intercepts Tab and Escape before the page ever sees them. + Buying something takes two pages: choose a size and add to basket there, then go on to /#/checkout to place the order. From fc2aae15ed582513facbca01930b4b343733a1e9 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 09:28:27 +0200 Subject: [PATCH 04/15] Stop the listen panel from crushing the page it is inspecting The panel reserved 22rem of body padding-right beside itself. In a narrow window, an editor preview pane or a phone viewport that left almost nothing for the storefront: measured at a 420px viewport, the page rendered 68px wide. It now floats over the page instead of reserving space, scales with min(20rem, 38vw), and sets pointer-events: none so every click reaches the page underneath. Measured after the change, the page keeps its full width at 420, 800 and 1400px, and Tab still walks the page at each. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../intermediate/src/dev/announcements.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js b/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js index 592fb2f..567dcc2 100644 --- a/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js +++ b/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js @@ -11,18 +11,22 @@ // cannot reach it: a tool that changed the tab order would be measuring itself. const POLL_MS = 200; -const PANEL_WIDTH = '22rem'; +// The panel floats over the page rather than reserving space beside it. +// Reserving space meant a narrow window, an editor preview pane or a phone +// ended up with a few dozen pixels left for the storefront itself. +// pointer-events: none keeps every click going through to the page underneath. const PANEL_STYLE = ` position: fixed; top: 0; right: 0; bottom: 0; z-index: 9999; - width: ${PANEL_WIDTH}; + width: min(20rem, 38vw); display: flex; flex-direction: column; - background: #14141c; + pointer-events: none; + background: rgb(20 20 28 / 94%); color: #e8e8ef; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; `; @@ -71,7 +75,6 @@ export async function start() { panel.append(heading, note, list); document.body.append(panel); - document.body.style.paddingRight = PANEL_WIDTH; // Read only the application, never the panel itself. await virtual.start({ container: root }); From 306e02ed045e2c1fe2fe56460a48357f3fb5f611 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 09:46:45 +0200 Subject: [PATCH 05/15] Open the storefront in the editor preview, and correct the keyboard note The claim that the embedded preview intercepts Tab and Escape was wrong. It was written from a report of the keyboard not working there, but the real cause was Safari: on macOS it does not move focus to links or buttons unless keyboard navigation is enabled. The product page has no form fields, so Tab looked completely dead. Verified once the setting was on: Tab walks the page correctly in the editor's Simple Browser. Port 5173 goes back to openPreview, and the docs now carry the Safari note instead, which is the issue players will actually hit. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../devcontainer.json | 2 +- adventures/accessibility-nightmare/docs/intermediate.yaml | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json b/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json index 40ce3f7..c7b9afb 100644 --- a/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json +++ b/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json @@ -33,7 +33,7 @@ "portsAttributes": { "5173": { "label": "ShopSmart", - "onAutoForward": "notify" + "onAutoForward": "openPreview" } }, "otherPortsAttributes": { diff --git a/adventures/accessibility-nightmare/docs/intermediate.yaml b/adventures/accessibility-nightmare/docs/intermediate.yaml index 6214244..05e762f 100644 --- a/adventures/accessibility-nightmare/docs/intermediate.yaml +++ b/adventures/accessibility-nightmare/docs/intermediate.yaml @@ -155,9 +155,11 @@ how_to_play: http://localhost:5173/#/product/running-shoes ``` - **Open it in a real browser tab, not the editor's built-in preview.** This - level is worked almost entirely from the keyboard, and the embedded - preview intercepts Tab and Escape before the page ever sees them. + **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. Without it Tab appears to do + nothing at all here, which looks exactly like a broken page. Chrome and + Firefox have it on already. Buying something takes two pages: choose a size and add to basket there, then go on to /#/checkout to place the order. From dc0089a99d144bec096c37158dcbe8fd1d7993a2 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 10:00:57 +0200 Subject: [PATCH 06/15] Notify on port 5173 instead of opening the editor preview Simple Browser fails two different ways here. In Safari it renders the page in a small corner of the pane, a webview sizing bug in VS Code. In Chrome it refuses to connect, because a private forwarded port redirects through GitHub's auth proxy and an iframe cannot complete that flow. Making the port public would fix only the Chrome half, exposes the storefront to anyone holding the URL, and leaves Safari users with the corner rendering. The repo has that pattern in 02-building-cloudhaven_03-expert via postAttachCommand if it is ever wanted here. notify works in every browser and costs one click. The docs now say to use a browser tab and why. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../accessibility-nightmare_intermediate/devcontainer.json | 2 +- adventures/accessibility-nightmare/docs/intermediate.yaml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json b/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json index c7b9afb..40ce3f7 100644 --- a/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json +++ b/.devcontainer/accessibility-nightmare_intermediate/devcontainer.json @@ -33,7 +33,7 @@ "portsAttributes": { "5173": { "label": "ShopSmart", - "onAutoForward": "openPreview" + "onAutoForward": "notify" } }, "otherPortsAttributes": { diff --git a/adventures/accessibility-nightmare/docs/intermediate.yaml b/adventures/accessibility-nightmare/docs/intermediate.yaml index 05e762f..1b091a3 100644 --- a/adventures/accessibility-nightmare/docs/intermediate.yaml +++ b/adventures/accessibility-nightmare/docs/intermediate.yaml @@ -155,6 +155,10 @@ how_to_play: http://localhost:5173/#/product/running-shoes ``` + Open it in a browser tab rather than the editor's built-in preview. The + preview cannot load a private forwarded port, and you will want a real + browser for this level anyway. + **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. Without it Tab appears to do From eaaa8763d78e9e7375b1badbff6a745c8582ef41 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 10:18:27 +0200 Subject: [PATCH 07/15] Move the listen panel to a bottom strip, and document the public-port option A side rail could not win. Reserving width squeezed the storefront, and floating over it hid the header and nav on the right. Either way the page lost the very controls the level asks you to reach. The panel is now a full-width strip along the bottom, with matching padding on so it never covers the page. Measured at 420, 800 and 1400px: the page keeps its full width and the header, nav and menu are all fully in view at each. The start step also now explains that a private forwarded port cannot load in the editor preview, and how to make it public if you would rather work there. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../docs/intermediate.yaml | 12 +++-- .../intermediate/src/dev/announcements.js | 49 +++++++++++-------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/adventures/accessibility-nightmare/docs/intermediate.yaml b/adventures/accessibility-nightmare/docs/intermediate.yaml index 1b091a3..ae1cf09 100644 --- a/adventures/accessibility-nightmare/docs/intermediate.yaml +++ b/adventures/accessibility-nightmare/docs/intermediate.yaml @@ -155,9 +155,15 @@ how_to_play: http://localhost:5173/#/product/running-shoes ``` - Open it in a browser tab rather than the editor's built-in preview. The - preview cannot load a private forwarded port, and you will want a real - browser for this level anyway. + Open it in a browser tab. The editor's built-in preview cannot load the + forwarded port while the port is private, because the request is sent + through GitHub's authentication and a preview pane has no way to complete + that. + + 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. A public port can be reached by anyone who + has the URL, so 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 diff --git a/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js b/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js index 567dcc2..d1c4741 100644 --- a/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js +++ b/adventures/accessibility-nightmare/intermediate/src/dev/announcements.js @@ -1,5 +1,5 @@ // Development tool. Renders a live log of what a screen reader would announce, -// beside the storefront. Switched on by adding ?listen to the URL, and stripped +// 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 @@ -12,21 +12,24 @@ const POLL_MS = 200; -// The panel floats over the page rather than reserving space beside it. -// Reserving space meant a narrow window, an editor preview pane or a phone -// ended up with a few dozen pixels left for the storefront itself. -// pointer-events: none keeps every click going through to the page underneath. +// 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; - top: 0; + left: 0; right: 0; bottom: 0; + height: ${PANEL_HEIGHT}; z-index: 9999; - width: min(20rem, 38vw); display: flex; flex-direction: column; - pointer-events: none; - background: rgb(20 20 28 / 94%); + border-top: 1px solid #2c2c3a; + background: #14141c; color: #e8e8ef; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; `; @@ -44,27 +47,31 @@ export async function start() { 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.textContent = 'What a screen reader would say'; heading.style.cssText = ` - padding: 0.75rem 0.9rem; + display: flex; + gap: 0.6rem; + align-items: baseline; + flex-wrap: wrap; + padding: 0.5rem 0.9rem; border-bottom: 1px solid #2c2c3a; - font-weight: 700; `; - const note = document.createElement('div'); + 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 = ` - padding: 0.5rem 0.9rem; - border-bottom: 1px solid #2c2c3a; - color: #9a9aae; - `; + 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 1rem; + padding: 0.5rem 0.9rem 0.75rem; `; const empty = document.createElement('p'); @@ -73,8 +80,10 @@ export async function start() { empty.style.cssText = 'color: #9a9aae; margin: 0.5rem 0;'; list.append(empty); - panel.append(heading, note, list); + 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 }); From f7b22724148f13fd8352d266a811b90d7673aac1 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 10:34:48 +0200 Subject: [PATCH 08/15] Accept ?listen on either side of the hash route The forwarded port URL cannot carry a query: portsAttributes allows only label, protocol, onAutoForward, requireLocalPort and elevateIfNeeded, and the schema sets additionalProperties false. So players add ?listen themselves, and the obvious move is to append it to whatever is in the address bar. That used to fail silently. Routes live in the hash, so ?listen had to precede it, and appending put the flag somewhere main.jsx never looked. Worse, the route matcher then read 'product/running-shoes?listen' as the path and fell through to the homepage, so appending it quietly sent you to the wrong page. main.jsx now checks the query and the hash, and the router strips anything after a '?'. Verified: the panel opens and the route resolves for /?listen#/product/..., /#/product/...?listen and /#/checkout?listen, and stays closed without the flag. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../docs/intermediate.yaml | 10 +++++++++- .../intermediate/src/App.jsx | 4 +++- .../intermediate/src/main.jsx | 17 +++++++++++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/adventures/accessibility-nightmare/docs/intermediate.yaml b/adventures/accessibility-nightmare/docs/intermediate.yaml index ae1cf09..b2c13ff 100644 --- a/adventures/accessibility-nightmare/docs/intermediate.yaml +++ b/adventures/accessibility-nightmare/docs/intermediate.yaml @@ -192,7 +192,15 @@ how_to_play: and what happens when you press Escape. Then go on to the checkout and submit the form empty. - To hear what a screen reader would say at each of those steps: + To watch it live while you work, add `?listen` to the address. It can go + either side of the route, so appending it to whatever is in your address + bar works: + + ```text + http://localhost:5173/#/product/running-shoes?listen + ``` + + Or to print the same journey in the terminal: ```bash npm run listen diff --git a/adventures/accessibility-nightmare/intermediate/src/App.jsx b/adventures/accessibility-nightmare/intermediate/src/App.jsx index 4623de6..31c7a83 100644 --- a/adventures/accessibility-nightmare/intermediate/src/App.jsx +++ b/adventures/accessibility-nightmare/intermediate/src/App.jsx @@ -7,7 +7,9 @@ import { findProduct } from './products.js'; const PRODUCT_ROUTE = /^\/product\/([\w-]+)$/; function readRoute() { - return window.location.hash.replace(/^#/, '') || '/'; + // Anything after a "?" in the hash is a flag rather than part of the route. + const [path] = window.location.hash.replace(/^#/, '').split('?'); + return path || '/'; } export default function App() { diff --git a/adventures/accessibility-nightmare/intermediate/src/main.jsx b/adventures/accessibility-nightmare/intermediate/src/main.jsx index 9097544..7103788 100644 --- a/adventures/accessibility-nightmare/intermediate/src/main.jsx +++ b/adventures/accessibility-nightmare/intermediate/src/main.jsx @@ -11,9 +11,18 @@ 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. -if ( - import.meta.env.DEV && - new URLSearchParams(window.location.search).has('listen') -) { +// +// 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()); } From 4f67d32ad490840b0f56b90820b1c10e27fb0a46 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 10:37:00 +0200 Subject: [PATCH 09/15] Anchor the tools/ ignore rule, which was swallowing the listen script .gitignore had an unanchored "tools/", so it matched a tools directory at any depth. adventures/accessibility-nightmare/intermediate/tools/listen.mjs was therefore never committed, and `git add -A` skipped it silently. npm run listen failed for anyone who cloned the repo: Error: Cannot find module '.../intermediate/tools/listen.mjs' The rule exists for the OpenTelemetry agent jar that 04-blind-by-design_03-expert downloads to $REPO_ROOT/tools, which is the only tools directory in the repo, so anchoring it to /tools/ keeps that ignored and stops it reaching into challenge levels. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .gitignore | 6 +- .../intermediate/tools/listen.mjs | 106 ++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 adventures/accessibility-nightmare/intermediate/tools/listen.mjs diff --git a/.gitignore b/.gitignore index 7b53497..5079122 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,10 @@ venv/ # Maven build artifacts target/ -# OpenTelemetry Java Agent (downloaded per-Codespace by post-create.sh) -tools/ +# OpenTelemetry Java Agent (downloaded per-Codespace by post-create.sh). +# Anchored: unanchored "tools/" silently swallowed a tools directory inside a +# challenge level, and `git add -A` skipped it without a word. +/tools/ # Go build artifacts infra/tracker/tracker diff --git a/adventures/accessibility-nightmare/intermediate/tools/listen.mjs b/adventures/accessibility-nightmare/intermediate/tools/listen.mjs new file mode 100644 index 0000000..1d6d235 --- /dev/null +++ b/adventures/accessibility-nightmare/intermediate/tools/listen.mjs @@ -0,0 +1,106 @@ +// npm run listen +// +// Walks the checkout the way a keyboard user would and prints what a screen +// reader would announce at each step. Nothing here grades you; it is a +// microscope, not a test. +// +// This 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. +import { chromium } from 'playwright'; +import { + attachScreenReader, + clearSpokenPhrases, + settle, + spokenPhrases, + startScreenReader, +} from '../tests/lib/screen-reader.js'; + +const BASE = process.env.LISTEN_URL ?? 'http://127.0.0.1:5173'; + +const dim = (s) => `\x1b[2m${s}\x1b[0m`; +const bold = (s) => `\x1b[1m${s}\x1b[0m`; +const red = (s) => `\x1b[31m${s}\x1b[0m`; +const green = (s) => `\x1b[32m${s}\x1b[0m`; + +async function reachable(url) { + try { + return (await fetch(url)).ok; + } catch { + return false; + } +} + +if (!(await reachable(BASE))) { + console.error(`\nShopSmart is not running at ${BASE}.`); + console.error('Start it with `npm run dev`, then run this again.\n'); + process.exit(1); +} + +const browser = await chromium.launch(); +const context = await browser.newContext(); +const page = await context.newPage(); +await attachScreenReader(page); +await page.goto(`${BASE}/#/product/running-shoes`, { waitUntil: 'networkidle' }); +await startScreenReader(page); + +let silences = 0; + +async function step(what, action) { + await clearSpokenPhrases(page); + await action(); + await settle(page); + const heard = await spokenPhrases(page); + + console.log(`\n${bold(what)}`); + if (heard.length === 0) { + silences += 1; + console.log(` ${red('(silence)')}`); + return; + } + for (const phrase of heard) { + const live = /^(polite|assertive):/.test(phrase); + console.log(` ${live ? green(phrase) : phrase}`); + } +} + +console.log(bold('\nWhat a screen reader would announce')); +console.log(dim(`${BASE} ยท a simulation, not real assistive technology`)); + +await step('Tabbing through the product page', async () => { + for (let i = 0; i < 6; i += 1) await page.keyboard.press('Tab'); +}); + +await step('Adding the item to the basket', async () => { + await page.getByRole('button', { name: 'Add to basket' }).click(); +}); + +await step('Pressing Escape on the confirmation', async () => { + await page.keyboard.press('Escape'); +}); + +await page.goto(`${BASE}/#/checkout`, { waitUntil: 'networkidle' }); + +await step('Submitting the checkout form with nothing filled in', async () => { + await page.getByRole('button', { name: 'Place order' }).click(); +}); + +await step('Filling the fields in and submitting again', async () => { + await page.getByLabel('Full name').fill('Ada Lovelace'); + await page.getByLabel('Email').fill('ada@example.com'); + await page.getByRole('button', { name: 'Place order' }).click(); +}); + +console.log( + `\n${dim('Announcements from a live region are prefixed polite: or assertive:')}`, +); +if (silences > 0) { + console.log( + red( + `${silences} step${silences === 1 ? '' : 's'} said nothing at all.`, + ), + ); +} +console.log(); + +await browser.close(); From 1b846fcaae105f4db42049e15c042c5668eb1e38 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 10:47:45 +0200 Subject: [PATCH 10/15] Make listen flag missing announcements, not just empty logs The footer counted steps whose spoken log was empty, so opening the basket dialog reported "button, Add to basket" and passed unremarked, as did a rejected submission and a completed order. Only Escape produced an empty log, so the summary read "1 step said nothing at all" on a page with four separate announcement faults. A player could reasonably conclude only one thing was broken. Steps that change the page without moving focus there now declare what a screen reader should learn from them, and the step reports when nothing said it. Broken build flags three; the repaired build flags none. It still only reports what was and was not said, never what to change. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../intermediate/tools/listen.mjs | 73 ++++++++++++++----- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/adventures/accessibility-nightmare/intermediate/tools/listen.mjs b/adventures/accessibility-nightmare/intermediate/tools/listen.mjs index 1d6d235..4b8c1ab 100644 --- a/adventures/accessibility-nightmare/intermediate/tools/listen.mjs +++ b/adventures/accessibility-nightmare/intermediate/tools/listen.mjs @@ -44,26 +44,46 @@ await attachScreenReader(page); await page.goto(`${BASE}/#/product/running-shoes`, { waitUntil: 'networkidle' }); await startScreenReader(page); -let silences = 0; +const unheard = []; -async function step(what, action) { +// `expects` says this step changes something on screen without moving focus +// there, so a screen reader only learns about it from a live region. Saying +// nothing and saying the wrong thing both leave the user uninformed, and only +// the first shows up as an empty log. +async function step(what, action, { expects } = {}) { await clearSpokenPhrases(page); await action(); await settle(page); const heard = await spokenPhrases(page); console.log(`\n${bold(what)}`); + if (heard.length === 0) { - silences += 1; console.log(` ${red('(silence)')}`); - return; + } else { + for (const phrase of heard) { + const live = /^(polite|assertive):/.test(phrase); + console.log(` ${live ? green(phrase) : phrase}`); + } } - for (const phrase of heard) { - const live = /^(polite|assertive):/.test(phrase); - console.log(` ${live ? green(phrase) : phrase}`); + + if (!expects) return; + + const announced = heard.some((phrase) => expects.test(phrase)); + if (!announced) { + unheard.push(what); + console.log(` ${red(`\u2192 nothing here announced ${expects.label}`)}`); } } +// Attach a human-readable name to each pattern so the arrow line reads as +// English rather than as a regular expression. +function expecting(label, pattern) { + const re = new RegExp(pattern, 'i'); + re.label = label; + return re; +} + console.log(bold('\nWhat a screen reader would announce')); console.log(dim(`${BASE} ยท a simulation, not real assistive technology`)); @@ -71,9 +91,13 @@ await step('Tabbing through the product page', async () => { for (let i = 0; i < 6; i += 1) await page.keyboard.press('Tab'); }); -await step('Adding the item to the basket', async () => { - await page.getByRole('button', { name: 'Add to basket' }).click(); -}); +await step( + 'Adding the item to the basket', + async () => { + await page.getByRole('button', { name: 'Add to basket' }).click(); + }, + { expects: expecting('that a dialog opened', 'dialog') }, +); await step('Pressing Escape on the confirmation', async () => { await page.keyboard.press('Escape'); @@ -81,25 +105,34 @@ await step('Pressing Escape on the confirmation', async () => { await page.goto(`${BASE}/#/checkout`, { waitUntil: 'networkidle' }); -await step('Submitting the checkout form with nothing filled in', async () => { - await page.getByRole('button', { name: 'Place order' }).click(); -}); +await step( + 'Submitting the checkout form with nothing filled in', + async () => { + await page.getByRole('button', { name: 'Place order' }).click(); + }, + { expects: expecting('why the order was rejected', '^(polite|assertive):') }, +); -await step('Filling the fields in and submitting again', async () => { - await page.getByLabel('Full name').fill('Ada Lovelace'); - await page.getByLabel('Email').fill('ada@example.com'); - await page.getByRole('button', { name: 'Place order' }).click(); -}); +await step( + 'Filling the fields in and submitting again', + async () => { + await page.getByLabel('Full name').fill('Ada Lovelace'); + await page.getByLabel('Email').fill('ada@example.com'); + await page.getByRole('button', { name: 'Place order' }).click(); + }, + { expects: expecting('that the order went through', '^(polite|assertive):') }, +); console.log( `\n${dim('Announcements from a live region are prefixed polite: or assertive:')}`, ); -if (silences > 0) { +if (unheard.length > 0) { console.log( red( - `${silences} step${silences === 1 ? '' : 's'} said nothing at all.`, + `${unheard.length} step${unheard.length === 1 ? '' : 's'} changed the page without telling anyone:`, ), ); + for (const what of unheard) console.log(red(` ${what}`)); } console.log(); From 7d0239dfdc9eb7a9f481bac21cf01bb23e7291e0 Mon Sep 17 00:00:00 2001 From: sindurigf Date: Mon, 14 Sep 2026 12:26:05 +0200 Subject: [PATCH 11/15] Soften the three places the picker loses people The picker is the largest of the three repairs and had the worst failure mode. A native and say plainly that it is right on real work, out of scope here because the exercise is understanding what it does for you, and link the pattern to build instead. - SizePicker.jsx carries that link in a comment, where someone deep in the file will actually see it, along with a note that it is the largest repair. - The docs recommend an order: form, then confirmation, then picker. Three visible wins before the long one, rather than the wall first. Verified: the shape check rejects a native select and passes the APG combobox; broken still 4 fail / 2 pass; solution still 12/12. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sindurigf --- .../docs/intermediate.yaml | 11 +++++--- .../src/components/SizePicker.jsx | 9 +++++++ .../intermediate/tests/checkout.spec.js | 3 +++ .../intermediate/tests/lib/checkout.js | 26 ++++++++++++++++--- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/adventures/accessibility-nightmare/docs/intermediate.yaml b/adventures/accessibility-nightmare/docs/intermediate.yaml index b2c13ff..86602e3 100644 --- a/adventures/accessibility-nightmare/docs/intermediate.yaml +++ b/adventures/accessibility-nightmare/docs/intermediate.yaml @@ -214,14 +214,17 @@ how_to_play: - id: implement title: "Repair the Checkout" content: | + Take them smallest first. The checkout form is a handful of attributes and + two elements, the basket confirmation is a known pattern, and the size + picker is a component to build. Doing them in that order gets you three + separate wins before the long one. + Work in: ```text - src/components/SizePicker.jsx - src/components/BasketDialog.jsx src/components/CheckoutForm.jsx - src/pages/Product.jsx - src/pages/Checkout.jsx + src/components/BasketDialog.jsx with src/pages/Product.jsx + src/components/SizePicker.jsx ``` One thing is worth knowing before you start, because it costs hours and diff --git a/adventures/accessibility-nightmare/intermediate/src/components/SizePicker.jsx b/adventures/accessibility-nightmare/intermediate/src/components/SizePicker.jsx index 5310c5e..c78598f 100644 --- a/adventures/accessibility-nightmare/intermediate/src/components/SizePicker.jsx +++ b/adventures/accessibility-nightmare/intermediate/src/components/SizePicker.jsx @@ -1,5 +1,14 @@ import { useState } from 'react'; +// This is the largest of the three repairs, so it is worth doing last. +// +// The pattern it needs is the select-only combobox, which has a worked example +// and a full keyboard contract here: +// https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ +// +// A native . On real work that is the right answer, ' + + 'and it is accessible as it stands. It is out of scope here because the point ' + + 'of the exercise is understanding what a native control does for you, so build ' + + 'the select-only combobox instead: ' + + 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/'; + +const NO_PICKER = + 'No control on the product page presents itself as a size picker. It needs a ' + + 'role, an accessible name and its current value.'; + +// Fails with an explanation when the picker is a native