test: enforce accessible portfolio interaction
This commit is contained in:
210
docs/accessibility/screen-reader-scripts.md
Normal file
210
docs/accessibility/screen-reader-scripts.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Physical screen-reader test scripts
|
||||
|
||||
Run these scripts against the exact release candidate. Do not simulate a
|
||||
screen reader with the accessibility tree, and do not mark a row complete from
|
||||
automated output. Record the operating-system, browser, and screen-reader
|
||||
versions plus the commit/build identifier in the WCAG checklist.
|
||||
|
||||
## Test states
|
||||
|
||||
Use a fresh private window/profile for each state unless the step explicitly
|
||||
requires saved preference state.
|
||||
|
||||
1. **Default** — normal motion preference, sound not started.
|
||||
2. **Audio running** — activate **START THE DRIVE** once.
|
||||
3. **Static** — choose **Static** from **Quality**.
|
||||
4. **Reduced motion** — enable the OS reduced-motion preference before load.
|
||||
5. **Media/scene failure** — use the release test build's documented failure
|
||||
setup; never alter production code or expose a public test hook.
|
||||
6. **Missing social** — use the approved optional-social build fixture; the
|
||||
email action must remain.
|
||||
|
||||
For each platform, repeat Tasks 1–3 and Task 7 in all six states. Run Tasks
|
||||
4–6 in every state where their content is present. If final project media is
|
||||
still awaiting approval, record Tasks 5–6 as **not present in candidate — must
|
||||
re-run when approved media enters the manifest**, not as PASS.
|
||||
|
||||
## NVDA + Firefox on Windows
|
||||
|
||||
Setup:
|
||||
|
||||
- Start NVDA, then Firefox. Record exact versions from NVDA Help → About and
|
||||
Firefox Help → About.
|
||||
- Use a fresh Firefox private window at 100% zoom for the default pass.
|
||||
- Keep NVDA speech viewer open only if it does not change viewport geometry.
|
||||
- Use `NVDA+F7` for the Elements List, `H`/`Shift+H` for headings,
|
||||
`D`/`Shift+D` for landmarks, `K`/`Shift+K` for links, `B` for buttons,
|
||||
`F` for form fields, `Tab`/`Shift+Tab` for focus order, and `Enter`/`Space`
|
||||
for activation.
|
||||
|
||||
Run the seven literal tasks below. Record the exact announcement when an
|
||||
oracle fails; do not paraphrase the failure.
|
||||
|
||||
## VoiceOver + Safari on macOS
|
||||
|
||||
Setup:
|
||||
|
||||
- Record macOS and Safari versions, enable VoiceOver with `Command+F5`, and
|
||||
record the VoiceOver version from VoiceOver Utility → About.
|
||||
- Enable Safari's **Press Tab to highlight each item on a webpage**.
|
||||
- Use `VO` (`Control+Option`) + `U` for the rotor, `VO+Command+H` for the next
|
||||
heading, `VO+Command+L` for the next link, `VO+Command+J` for the next form
|
||||
control, `VO+Right/Left Arrow` for sequential reading, and `VO+Space` to
|
||||
activate.
|
||||
|
||||
Run the seven literal tasks below in Safari. Note whether Quick Nav is on for
|
||||
each run because it changes key behavior, not page semantics.
|
||||
|
||||
## VoiceOver + Safari on iOS
|
||||
|
||||
Setup:
|
||||
|
||||
- Record device model, iOS version, and Safari build. Enable VoiceOver in
|
||||
Settings → Accessibility → VoiceOver.
|
||||
- Set the rotor to Headings, Links, Form Controls, and Containers as needed.
|
||||
- Use one-finger right/left swipes for sequential navigation, rotor swipes for
|
||||
category navigation, double-tap to activate, and a four-finger tap near the
|
||||
top/bottom to move to the first/last accessible item.
|
||||
- Test portrait at the 320 CSS px-equivalent supported width and landscape.
|
||||
|
||||
Run the same seven tasks. Also confirm that touch exploration never lands on
|
||||
the decorative canvas, road signs, speed, particles, or HUD.
|
||||
|
||||
## Seven literal tasks and pass oracles
|
||||
|
||||
### 1. Load, identity, structure, and scene description
|
||||
|
||||
1. Load the page at the top without interacting.
|
||||
2. Read from the first accessible item through the main navigation.
|
||||
3. Open the landmarks/headings rotor or Elements List.
|
||||
|
||||
Pass only when:
|
||||
|
||||
- the document title identifies Luca Pîslaru and the portfolio role;
|
||||
- **Skip to portfolio content** is the first actionable item;
|
||||
- exactly one H1 announces **Luca Pîslaru**, followed by **System
|
||||
Administrator & Full-Stack Developer**;
|
||||
- the portfolio navigation has a useful name and ordered section links;
|
||||
- one concise description explains the restrained retro-futuristic road
|
||||
journey and portfolio progress;
|
||||
- the canvas, poster, car, road signs, speed, particles, and HUD do not appear
|
||||
as separate accessible objects or repeated live announcements.
|
||||
|
||||
### 2. Skip link, section navigation, focus, and passive scrolling
|
||||
|
||||
1. Return to the top and focus **Skip to portfolio content** with the keyboard
|
||||
or rotor.
|
||||
2. Activate it and read the focused item.
|
||||
3. Navigate back to the section navigation, activate **Infrastructure**, and
|
||||
read the focused item.
|
||||
4. Move through **Selected Builds**, **Toolkit**, and **Contact** using only
|
||||
the navigation links.
|
||||
5. Put the reading cursor on a navigation link, then scroll manually without
|
||||
activating it. Use Back/Forward once after an explicit section activation.
|
||||
|
||||
Pass only when:
|
||||
|
||||
- skip activation lands on the portfolio main content;
|
||||
- explicit section navigation focuses and announces the correct H2 once;
|
||||
- logical focus order matches the visible document order;
|
||||
- focus is visible and not covered by the fixed HUD or drive controls;
|
||||
- passive scrolling and history restoration do not steal keyboard focus or
|
||||
relocate the screen-reader reading cursor.
|
||||
|
||||
### 3. Start the Drive and audio controls
|
||||
|
||||
1. Find **START THE DRIVE** by button navigation.
|
||||
2. Read the button and its adjacent supporting description before activating.
|
||||
3. Activate it once, then navigate through the newly exposed sound controls.
|
||||
4. Activate **Mute sound**, verify the name becomes **Unmute sound**, adjust
|
||||
**Sound volume**, and mute again before continuing.
|
||||
|
||||
Pass only when:
|
||||
|
||||
- the control is announced as a button and the nearby text explains that it
|
||||
enables the interactive soundtrack;
|
||||
- sound never begins before explicit activation;
|
||||
- **Mute sound**/**Unmute sound** and **Sound volume** have correct role,
|
||||
name, and current value/state;
|
||||
- audio state changes are understandable without repeated or continuous live
|
||||
announcements and do not drown out assistive-technology speech;
|
||||
- audio failure, when tested, announces **Audio unavailable** while all
|
||||
portfolio content remains usable.
|
||||
|
||||
### 4. Toolkit labels
|
||||
|
||||
1. Navigate to **Toolkit** and enter each technology group.
|
||||
2. Read at least Linux, Docker, Python, Vue.js, C# / .NET, and the Additional
|
||||
experience group in sequence.
|
||||
|
||||
Pass only when each technology is announced once from its visible text label;
|
||||
the decorative repeated icon/dot is silent; group headings are announced in
|
||||
the expected hierarchy; and no percentage, rating, or invented expertise
|
||||
claim is exposed.
|
||||
|
||||
### 5. Chart descriptions and data tables
|
||||
|
||||
1. Navigate to the Opinion Dynamics Simulation chart group, if present in the
|
||||
approved candidate.
|
||||
2. Read the figure caption, adjacent Variables, Trend, and Conclusion fields.
|
||||
3. Enter any data table by table navigation and traverse its headers and at
|
||||
least two data cells.
|
||||
|
||||
Pass only when the purpose, axes/variables, trend, and conclusion are usable
|
||||
without inspecting color or pixels; each table has a useful caption and
|
||||
associated row/column headers; and the image alt does not redundantly recite
|
||||
the full adjacent description.
|
||||
|
||||
### 6. Authored video controls and failure fallback
|
||||
|
||||
1. Navigate to an approved project video, if present in the candidate.
|
||||
2. Read its name, caption, structured description, initial status, and authored
|
||||
**Play video** control.
|
||||
3. Activate Play, verify **Pause video**, pause, then repeat with OS reduced
|
||||
motion enabled.
|
||||
4. Run the approved media-failure state and revisit the same figure.
|
||||
|
||||
Pass only when no sound autoplays; reduced motion begins from a poster until
|
||||
explicit Play; the authored button exposes the correct play/pause state; the
|
||||
caption/description remains available; failure exposes **Video unavailable**
|
||||
and explanatory text; and inaccessible user-agent shadow controls are not the
|
||||
only control surface.
|
||||
|
||||
### 7. Contact and optional social links
|
||||
|
||||
1. Navigate to **Contact** by heading and by the section navigation.
|
||||
2. Read Luca's name, role, visible email address, CV/contact note, and every
|
||||
optional profile link.
|
||||
3. Inspect the email link's destination/purpose without sending mail.
|
||||
4. Repeat in the missing-social state.
|
||||
|
||||
Pass only when:
|
||||
|
||||
- the visible email and primary `mailto:` action are announced with an
|
||||
unambiguous purpose;
|
||||
- GitHub, GitLab, and LinkedIn have distinct visible/accessibility names when
|
||||
present;
|
||||
- missing optional social links are omitted rather than announced as empty,
|
||||
disabled, or placeholder controls;
|
||||
- the email action remains reachable and the page exposes no CV download.
|
||||
|
||||
## Result template
|
||||
|
||||
Copy one row per state/platform into the WCAG checklist evidence log:
|
||||
|
||||
```text
|
||||
Date/time:
|
||||
Build/commit:
|
||||
OS/device:
|
||||
Browser + version:
|
||||
Screen reader + version:
|
||||
State:
|
||||
Tasks run: 1 2 3 4 5 6 7
|
||||
Result: PASS / FAIL / NOT PRESENT — RE-RUN REQUIRED
|
||||
Exact failed announcement or interaction:
|
||||
Screenshot/speech-viewer/video evidence location (private):
|
||||
Tester:
|
||||
```
|
||||
|
||||
Never place private hostnames, private project names, credentials, personal
|
||||
account identifiers, or uncleared media in the committed evidence log.
|
||||
119
docs/accessibility/wcag-2.2-aa-checklist.md
Normal file
119
docs/accessibility/wcag-2.2-aa-checklist.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# WCAG 2.2 A/AA release checklist
|
||||
|
||||
This is a release checkpoint for the portfolio, not a claim that automated
|
||||
testing can certify accessibility. Automated checks are complete for the
|
||||
current preview build. The named physical screen-reader runs and final visual
|
||||
review remain **pending release evidence**, not code failures.
|
||||
|
||||
## Evidence status
|
||||
|
||||
| Evidence | Current result | Re-run trigger |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| Axe WCAG A/AA scan | **PASS** — default, audio running, Static, reduced motion, scene/media failure, and missing optional social links; desktop and mobile Chromium | Any rendered markup, color token, control, or state change |
|
||||
| Author target geometry | **PASS** — every enabled `a`, `button`, `input`, `select`, and `textarea` is at least 44×44 CSS px; only a paragraph-contained link with `data-target-size-exception="inline-link"` may be exempt | Any new or restyled interactive element |
|
||||
| Keyboard and focus geometry | **PASS** — logical navigation reaches Contact; focus is visible, in the visual viewport, and has zero intersection with unrelated fixed controls | Navigation, focus, HUD, or control-surface changes |
|
||||
| Reflow | **PASS** — 320 CSS px mobile and desktop-Chromium effective 320 CSS px at the 400% zoom boundary | Layout, typography, media, or control changes |
|
||||
| WCAG text spacing | **PASS** — 1.5 line height, 0.12em letter spacing, 0.16em word spacing, and 2em paragraph spacing preserve content and controls | Copy or layout changes |
|
||||
| Forced colors | **PASS** — focus, borders, controls, content, and Contact remain visible and usable | Color, border, focus, or control changes |
|
||||
| No-JavaScript and SSR | **PASS in the project browser/build gates** — semantic sections, native links, and Contact do not depend on hydration | Rendering or hydration changes |
|
||||
| NVDA + Firefox on Windows | **PENDING PHYSICAL RUN** — use `screen-reader-scripts.md`; record versions and results below | Before public release and after material semantic changes |
|
||||
| VoiceOver + Safari on macOS | **PENDING PHYSICAL RUN** — use `screen-reader-scripts.md`; record versions and results below | Before public release and after material semantic changes |
|
||||
| VoiceOver + Safari on iOS | **PENDING PHYSICAL RUN** — use `screen-reader-scripts.md`; record versions and results below | Before public release and after material semantic/touch changes |
|
||||
| Final visual focus/overflow review | **PENDING RELEASE REVIEW** — automated geometry is necessary but not sufficient | Before public release |
|
||||
| Final encoded-media flash/caption review | **PENDING ASSET APPROVAL** — current public preview has no cleared project media | When any approved video/chart/image derivative is added |
|
||||
|
||||
Focused command:
|
||||
|
||||
```bash
|
||||
npm run test:browser -- tests/browser/accessibility-axe.spec.ts tests/browser/accessibility-keyboard.spec.ts tests/browser/accessibility-reflow.spec.ts tests/browser/accessibility-forced-colors.spec.ts
|
||||
```
|
||||
|
||||
The focused run on 2026-09-02 produced 24 passes and two intentional
|
||||
project-profile skips: the 320 CSS px mobile case runs only in the mobile
|
||||
profile, and the desktop 400% zoom-equivalent case runs only in the desktop
|
||||
profile. No axe violation is suppressed. No false-positive exception is
|
||||
currently recorded.
|
||||
|
||||
## Criterion checklist
|
||||
|
||||
Status meanings:
|
||||
|
||||
- **Automated PASS**: the current preview has repeatable browser/build evidence.
|
||||
- **Manual pending**: a physical assistive-technology or visual run is required
|
||||
before release.
|
||||
- **N/A current surface**: the current public output does not expose the
|
||||
relevant feature. Adding that feature reopens the criterion.
|
||||
|
||||
| WCAG 2.2 criterion | Status | Current evidence / release action |
|
||||
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1.1.1 Non-text Content | Automated PASS; manual pending | Decorative world/canvas/poster are hidden or empty-alt; one concise scene description is semantic. Approved project media must keep authored alt/description. |
|
||||
| 1.2.1 Audio-only and Video-only (Prerecorded) | N/A current surface | Generated ambience is interactive, not prerecorded speech. Reopen for approved prerecorded media. |
|
||||
| 1.2.2 Captions (Prerecorded) | N/A current surface | No cleared public video. Reopen and verify captions when a video derivative is approved. |
|
||||
| 1.2.3 Audio Description or Media Alternative | N/A current surface | Video component supports an adjacent structured description; verify the final approved clip manually. |
|
||||
| 1.2.4 Captions (Live) | N/A current surface | No live media. |
|
||||
| 1.2.5 Audio Description (Prerecorded) | N/A current surface | Reopen for approved prerecorded video. |
|
||||
| 1.3.1 Info and Relationships | Automated PASS; manual pending | Native landmarks, headings, lists, figures, captions, definition lists, and tables; confirm screen-reader output. |
|
||||
| 1.3.2 Meaningful Sequence | Automated PASS; manual pending | SSR DOM order remains the reading order; decorative world is excluded. |
|
||||
| 1.3.3 Sensory Characteristics | Automated PASS | Instructions and state do not depend only on location, shape, or color. |
|
||||
| 1.3.4 Orientation | Automated PASS | Responsive checks do not lock orientation. |
|
||||
| 1.3.5 Identify Input Purpose | N/A current surface | No personal-data form inputs. |
|
||||
| 1.4.1 Use of Color | Automated PASS; visual pending | State controls use visible text and native state, not color alone. |
|
||||
| 1.4.2 Audio Control | Automated PASS; manual pending | Sound starts only after explicit activation and immediately exposes Mute and Sound volume. |
|
||||
| 1.4.3 Contrast (Minimum) | Automated PASS; visual pending | Axe reports no contrast violation in all named states; final visual review is required. |
|
||||
| 1.4.4 Resize Text | Automated PASS | Desktop Chromium effective 320 CSS px gate represents the 400% zoom boundary. |
|
||||
| 1.4.5 Images of Text | Automated PASS | Information is text/semantic HTML; project screenshots do not replace portfolio copy. |
|
||||
| 1.4.10 Reflow | Automated PASS | No horizontal loss at 320 CSS px mobile or desktop zoom-equivalent width. |
|
||||
| 1.4.11 Non-text Contrast | Automated PASS; visual pending | Control/focus/border visibility survives forced colors; final visual review remains. |
|
||||
| 1.4.12 Text Spacing | Automated PASS | WCAG spacing override preserves all checked content and Contact. |
|
||||
| 1.4.13 Content on Hover or Focus | N/A current surface | No hover/focus-triggered disclosure or tooltip. |
|
||||
| 2.1.1 Keyboard | Automated PASS; manual pending | Native links/buttons/select/range and section navigation are operable by keyboard. |
|
||||
| 2.1.2 No Keyboard Trap | Automated PASS; manual pending | Tab traversal reaches Contact and continues to the drive controls. |
|
||||
| 2.1.4 Character Key Shortcuts | N/A current surface | No single-character shortcuts. |
|
||||
| 2.2.1 Timing Adjustable | N/A current surface | No session or content timeout. |
|
||||
| 2.2.2 Pause, Stop, Hide | Automated PASS; manual pending | Pause/Resume/Static controls stop decorative motion; authored video controls are required when media ships. |
|
||||
| 2.3.1 Three Flashes or Below Threshold | Manual pending | Final live scene and any encoded media need release visual/flash review. No flashing project media ships currently. |
|
||||
| 2.4.1 Bypass Blocks | Automated PASS; manual pending | First focusable control is the visible Skip to portfolio content link. |
|
||||
| 2.4.2 Page Titled | Automated PASS | Generated document title names Luca and the hybrid role. |
|
||||
| 2.4.3 Focus Order | Automated PASS; manual pending | Skip, ordered section navigation, content actions, Contact, then drive controls. |
|
||||
| 2.4.4 Link Purpose (In Context) | Automated PASS; manual pending | Visible labels identify section, email, and profile destinations. |
|
||||
| 2.4.5 Multiple Ways | Automated PASS | Persistent section navigation and native document scrolling provide two ways to reach content. |
|
||||
| 2.4.6 Headings and Labels | Automated PASS; manual pending | Ordered headings and explicit control labels; confirm announcements. |
|
||||
| 2.4.7 Focus Visible | Automated PASS; visual pending | At least 2px visible indicator or an inset main-content indicator; forced-colors gate passes. |
|
||||
| 2.4.11 Focus Not Obscured (Minimum) | Automated PASS; visual pending | Focus bounds have zero intersection with unrelated fixed HUD/controls; short viewports move controls into document flow. |
|
||||
| 2.5.1 Pointer Gestures | N/A current surface | No multipoint or path-based gesture. |
|
||||
| 2.5.2 Pointer Cancellation | Automated PASS | Native click activation occurs on release; no custom down-only action. |
|
||||
| 2.5.3 Label in Name | Automated PASS | Visible button/link labels are their accessible names. |
|
||||
| 2.5.4 Motion Actuation | N/A current surface | No device-motion input. |
|
||||
| 2.5.7 Dragging Movements | N/A current surface | No drag-only operation. |
|
||||
| 2.5.8 Target Size (Minimum) | Automated PASS | 44×44 CSS px author-target gate on desktop and mobile; exception syntax/context is validated. |
|
||||
| 3.1.1 Language of Page | Automated PASS | Generated root document declares English. |
|
||||
| 3.1.2 Language of Parts | N/A current surface | No passage in another natural language. |
|
||||
| 3.2.1 On Focus | Automated PASS | Focus alone does not navigate or change context. |
|
||||
| 3.2.2 On Input | Automated PASS | Quality/volume changes are explicit and do not unexpectedly navigate. |
|
||||
| 3.2.3 Consistent Navigation | Automated PASS | One canonical ordered section navigation is retained across states. |
|
||||
| 3.2.4 Consistent Identification | Automated PASS | Motion/audio controls retain consistent names and semantics. |
|
||||
| 3.2.6 Consistent Help | N/A current surface | No repeated help mechanism. |
|
||||
| 3.3.1 Error Identification | Automated PASS for current controls | Audio/media failures expose concise text status without blocking content. |
|
||||
| 3.3.2 Labels or Instructions | Automated PASS; manual pending | Quality and Sound volume are labeled; Start the Drive has adjacent purpose text. |
|
||||
| 3.3.3 Error Suggestion | N/A current surface | No correctable data-entry error. |
|
||||
| 3.3.4 Error Prevention (Legal, Financial, Data) | N/A current surface | No transaction or user-data submission. |
|
||||
| 3.3.7 Redundant Entry | N/A current surface | No multi-step data entry. |
|
||||
| 3.3.8 Accessible Authentication (Minimum) | N/A current surface | No authentication. |
|
||||
| 4.1.2 Name, Role, Value | Automated PASS; manual pending | Native controls plus current `aria-current`, status roles, and range/select values; confirm platform output. |
|
||||
| 4.1.3 Status Messages | Automated PASS; manual pending | Motion/audio/video status text is exposed without forced focus; verify it is useful and not repetitive. |
|
||||
|
||||
## Physical evidence log
|
||||
|
||||
Do not change a row to PASS without running the literal script in
|
||||
`screen-reader-scripts.md` on the named platform.
|
||||
|
||||
| Date | Platform | Browser + version | Screen reader + version | Build/commit | States covered | Result | Notes/evidence |
|
||||
| ------- | -------- | ------------------------ | ---------------------------------- | ------------- | ---------------------------------------- | ----------- | ----------------------------------------- |
|
||||
| Pending | Windows | Firefox — record version | NVDA — record version | record commit | all six | **PENDING** | Physical run required |
|
||||
| Pending | macOS | Safari — record version | VoiceOver + macOS — record version | record commit | all six | **PENDING** | Physical run required |
|
||||
| Pending | iOS | Safari — record version | VoiceOver + iOS — record version | record commit | default, reduced, Static, audio, failure | **PENDING** | Physical touch/screen-reader run required |
|
||||
|
||||
Any failed manual oracle is a release blocker until fixed or, when genuinely
|
||||
not applicable, documented with concrete evidence. Pending platform evidence
|
||||
does not imply that the implementation test suite is failing; it means the
|
||||
release checkpoint is not yet signed off.
|
||||
@@ -141,6 +141,16 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-link,
|
||||
.contact-link,
|
||||
.contact-social-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-inline-size: 44px;
|
||||
min-block-size: 44px;
|
||||
padding-inline: 0.55rem;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.chart-description {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -181,6 +181,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 420px) {
|
||||
.journey-hud {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drive-controls {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
max-inline-size: calc(100% - 1rem);
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
#contact {
|
||||
padding-block-end: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.journey-poster {
|
||||
background:
|
||||
|
||||
99
tests/browser/accessibility-axe.spec.ts
Normal file
99
tests/browser/accessibility-axe.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
|
||||
type AccessibilityState =
|
||||
| "default"
|
||||
| "audio-running"
|
||||
| "static"
|
||||
| "reduced-motion"
|
||||
| "media-failure"
|
||||
| "missing-social";
|
||||
|
||||
const states: readonly AccessibilityState[] = [
|
||||
"default",
|
||||
"audio-running",
|
||||
"static",
|
||||
"reduced-motion",
|
||||
"media-failure",
|
||||
"missing-social",
|
||||
];
|
||||
|
||||
async function enterState(
|
||||
page: Page,
|
||||
distUrl: string,
|
||||
state: AccessibilityState,
|
||||
): Promise<void> {
|
||||
if (state === "reduced-motion") {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
}
|
||||
if (state === "media-failure") {
|
||||
await page.addInitScript(() => {
|
||||
const nativeGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
|
||||
configurable: true,
|
||||
value(
|
||||
this: HTMLCanvasElement,
|
||||
contextId: string,
|
||||
options?: unknown,
|
||||
): RenderingContext | null {
|
||||
if (
|
||||
contextId === "webgl" ||
|
||||
contextId === "webgl2" ||
|
||||
contextId === "experimental-webgl"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Reflect.apply(nativeGetContext, this, [
|
||||
contextId,
|
||||
options,
|
||||
]) as RenderingContext | null;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
if (state === "audio-running") {
|
||||
await page.getByRole("button", { name: "START THE DRIVE" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Mute sound" }),
|
||||
).toBeVisible();
|
||||
} else if (state === "static") {
|
||||
await page.getByLabel("Quality control").selectOption("static");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Enable motion" }),
|
||||
).toBeVisible();
|
||||
} else if (state === "reduced-motion") {
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"reduced motion requested",
|
||||
);
|
||||
} else if (state === "media-failure") {
|
||||
await expect(page.locator(".journey-world")).toHaveAttribute(
|
||||
"data-scene-state",
|
||||
"failed",
|
||||
);
|
||||
} else if (state === "missing-social") {
|
||||
await page.locator('#contact a[target="_blank"]').evaluateAll((links) => {
|
||||
for (const link of links) link.closest("li")?.remove();
|
||||
});
|
||||
await expect(page.locator('#contact a[target="_blank"]')).toHaveCount(0);
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
test(`${state} has no WCAG 2.2 A or AA axe violations`, async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await enterState(page, distUrl, state);
|
||||
|
||||
const result = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
|
||||
.analyze();
|
||||
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
58
tests/browser/accessibility-forced-colors.spec.ts
Normal file
58
tests/browser/accessibility-forced-colors.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
|
||||
test("forced colors preserves visible focus, controls, content, and the Contact action", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.emulateMedia({ forcedColors: "active" });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
const focus = await page.locator(".skip-link").evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
outlineStyle: style.outlineStyle,
|
||||
outlineWidth: Number.parseFloat(style.outlineWidth),
|
||||
forcedColorAdjust: style.forcedColorAdjust,
|
||||
};
|
||||
});
|
||||
expect(focus.outlineStyle).not.toBe("none");
|
||||
expect(focus.outlineWidth).toBeGreaterThanOrEqual(2);
|
||||
expect(focus.forcedColorAdjust).toBe("auto");
|
||||
|
||||
const controls = await page
|
||||
.locator(".drive-controls button, .drive-controls select")
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.filter(
|
||||
(element) =>
|
||||
!(element instanceof HTMLButtonElement && element.disabled),
|
||||
)
|
||||
.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
borderStyle: style.borderTopStyle,
|
||||
visible:
|
||||
element.getBoundingClientRect().width > 0 &&
|
||||
element.getBoundingClientRect().height > 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
controls.every(
|
||||
(control) => control.visible && control.borderStyle !== "none",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth + 1,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
188
tests/browser/accessibility-keyboard.spec.ts
Normal file
188
tests/browser/accessibility-keyboard.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
|
||||
interface FocusGeometry {
|
||||
readonly description: string;
|
||||
readonly focusVisible: boolean;
|
||||
readonly insideSafeBounds: boolean;
|
||||
readonly overlapArea: number;
|
||||
}
|
||||
|
||||
async function currentFocusGeometry(page: Page): Promise<FocusGeometry> {
|
||||
return page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
"Keyboard traversal did not leave an HTMLElement focused.",
|
||||
);
|
||||
}
|
||||
|
||||
const rect = active.getBoundingClientRect();
|
||||
const style = getComputedStyle(active);
|
||||
const viewport = window.visualViewport;
|
||||
const left = viewport?.offsetLeft ?? 0;
|
||||
const top = viewport?.offsetTop ?? 0;
|
||||
const right = left + (viewport?.width ?? window.innerWidth);
|
||||
const bottom = top + (viewport?.height ?? window.innerHeight);
|
||||
const outlineWidth = Number.parseFloat(style.outlineWidth) || 0;
|
||||
const outlineOffset = Number.parseFloat(style.outlineOffset) || 0;
|
||||
const hasInsetIndicator = /\binset\b/iu.test(style.boxShadow);
|
||||
const indicatorExtent = hasInsetIndicator
|
||||
? 0
|
||||
: Math.max(0, outlineWidth + outlineOffset);
|
||||
const fixedSurfaces = [
|
||||
...document.querySelectorAll<HTMLElement>(
|
||||
".journey-hud, .drive-controls",
|
||||
),
|
||||
].filter((surface) => !surface.contains(active));
|
||||
const overlapArea = fixedSurfaces.reduce((total, surface) => {
|
||||
const fixed = surface.getBoundingClientRect();
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(rect.right, fixed.right) - Math.max(rect.left, fixed.left),
|
||||
);
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(rect.bottom, fixed.bottom) - Math.max(rect.top, fixed.top),
|
||||
);
|
||||
return total + width * height;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
description:
|
||||
active.getAttribute("href") ??
|
||||
active.getAttribute("aria-label") ??
|
||||
active.textContent?.trim() ??
|
||||
active.tagName,
|
||||
focusVisible:
|
||||
((style.outlineStyle !== "none" && outlineWidth >= 2) ||
|
||||
hasInsetIndicator) &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0,
|
||||
insideSafeBounds:
|
||||
rect.left - indicatorExtent >= left - 1 &&
|
||||
rect.right + indicatorExtent <= right + 1 &&
|
||||
rect.top - indicatorExtent >= top - 1 &&
|
||||
rect.bottom + indicatorExtent <= bottom + 1,
|
||||
overlapArea,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("every enabled author pointer target is 44 by 44 CSS pixels or a valid inline sentence link", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const failures = await page
|
||||
.locator("a[href], button, input, select, textarea")
|
||||
.evaluateAll((targets) =>
|
||||
targets.flatMap((target) => {
|
||||
if (!(target instanceof HTMLElement)) return [];
|
||||
if (
|
||||
(target instanceof HTMLButtonElement ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement) &&
|
||||
target.disabled
|
||||
)
|
||||
return [];
|
||||
if (target.getAttribute("aria-disabled") === "true") return [];
|
||||
const style = getComputedStyle(target);
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
rect.width === 0 ||
|
||||
rect.height === 0
|
||||
)
|
||||
return [];
|
||||
|
||||
const exception = target.getAttribute("data-target-size-exception");
|
||||
const validInlineException =
|
||||
exception === "inline-link" &&
|
||||
target instanceof HTMLAnchorElement &&
|
||||
target.closest("p") !== null;
|
||||
const invalidException = exception !== null && !validInlineException;
|
||||
if (
|
||||
!invalidException &&
|
||||
(validInlineException || (rect.width >= 44 && rect.height >= 44))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
target: target.outerHTML.slice(0, 180),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
exception,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
test("keyboard traversal reaches Contact in document order with visible unobscured focus", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const visited: string[] = [];
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
return {
|
||||
focusVisible: geometry.focusVisible,
|
||||
insideSafeBounds: geometry.insideSafeBounds,
|
||||
overlapArea: geometry.overlapArea,
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
focusVisible: true,
|
||||
insideSafeBounds: true,
|
||||
overlapArea: 0,
|
||||
});
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
visited.push(geometry.description);
|
||||
if (geometry.description.startsWith("mailto:")) break;
|
||||
}
|
||||
|
||||
expect(visited.at(0)).toBe("#portfolio-content");
|
||||
expect(visited.slice(1, 9)).toEqual([
|
||||
"#start",
|
||||
"#profile",
|
||||
"#experience",
|
||||
"#infrastructure",
|
||||
"#selected-builds",
|
||||
"#additional-work",
|
||||
"#toolkit",
|
||||
"#contact",
|
||||
]);
|
||||
expect(visited.some((description) => description.startsWith("mailto:"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit keyboard navigation focuses a section heading without a fixed-surface collision", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
for (let index = 0; index < 9; index += 1) await page.keyboard.press("Tab");
|
||||
await expect(page.locator('nav a[href="#contact"]')).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
expect(geometry.focusVisible).toBe(true);
|
||||
expect(geometry.insideSafeBounds).toBe(true);
|
||||
expect(geometry.overlapArea).toBe(0);
|
||||
});
|
||||
104
tests/browser/accessibility-reflow.spec.ts
Normal file
104
tests/browser/accessibility-reflow.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
|
||||
const textSpacingOverride = `
|
||||
* {
|
||||
line-height: 1.5 !important;
|
||||
letter-spacing: 0.12em !important;
|
||||
word-spacing: 0.16em !important;
|
||||
}
|
||||
p { margin-block-end: 2em !important; }
|
||||
`;
|
||||
|
||||
async function assertReflowGeometry(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const horizontalCandidates = [
|
||||
...document.querySelectorAll<HTMLElement>(
|
||||
".site-header, .portfolio-main, .portfolio-section, .site-footer, .drive-controls, .journey-hud, h1, h2, h3, p, a, button, input, select, figure, img, video, table",
|
||||
),
|
||||
].filter((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
);
|
||||
});
|
||||
const clippedText = [
|
||||
...document.querySelectorAll<HTMLElement>(
|
||||
"h1, h2, h3, p, li, dt, dd, a, button, label, figcaption, caption, th, td",
|
||||
),
|
||||
].filter((element) => {
|
||||
if (element.classList.contains("visually-hidden")) return false;
|
||||
const style = getComputedStyle(element);
|
||||
return style.overflow === "hidden" || style.textOverflow === "ellipsis";
|
||||
});
|
||||
const horizontalFailures = horizontalCandidates.flatMap((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.left >= -1 && rect.right <= root.clientWidth + 1) return [];
|
||||
return [
|
||||
{
|
||||
element: element.outerHTML.slice(0, 140),
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
clientWidth: root.clientWidth,
|
||||
},
|
||||
];
|
||||
});
|
||||
return {
|
||||
clientWidth: root.clientWidth,
|
||||
scrollWidth: root.scrollWidth,
|
||||
horizontalFailures,
|
||||
clippedText: clippedText.map((element) =>
|
||||
element.outerHTML.slice(0, 140),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.scrollWidth).toBeLessThanOrEqual(result.clientWidth + 1);
|
||||
expect(result.horizontalFailures).toEqual([]);
|
||||
expect(result.clippedText).toEqual([]);
|
||||
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
}
|
||||
|
||||
test("reflows at a 320 CSS pixel mobile viewport without lost content or controls", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "mobile-chromium",
|
||||
"Mobile CSS viewport gate.",
|
||||
);
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await assertReflowGeometry(page);
|
||||
});
|
||||
|
||||
test("reflows in desktop Chromium at the effective 320 CSS pixel width produced by 400 percent zoom", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "desktop-chromium",
|
||||
"Desktop zoom-equivalent gate.",
|
||||
);
|
||||
await page.setViewportSize({ width: 320, height: 225 });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await assertReflowGeometry(page);
|
||||
});
|
||||
|
||||
test("preserves content and controls with the WCAG text-spacing override", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.addStyleTag({ content: textSpacingOverride });
|
||||
await assertReflowGeometry(page);
|
||||
});
|
||||
Reference in New Issue
Block a user