diff --git a/docs/accessibility/screen-reader-scripts.md b/docs/accessibility/screen-reader-scripts.md new file mode 100644 index 0000000..59f5b66 --- /dev/null +++ b/docs/accessibility/screen-reader-scripts.md @@ -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. diff --git a/docs/accessibility/wcag-2.2-aa-checklist.md b/docs/accessibility/wcag-2.2-aa-checklist.md new file mode 100644 index 0000000..addcf97 --- /dev/null +++ b/docs/accessibility/wcag-2.2-aa-checklist.md @@ -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. diff --git a/src/styles/components.css b/src/styles/components.css index 490767a..54367a9 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; diff --git a/src/styles/world.css b/src/styles/world.css index 6234207..17f761f 100644 --- a/src/styles/world.css +++ b/src/styles/world.css @@ -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: diff --git a/tests/browser/accessibility-axe.spec.ts b/tests/browser/accessibility-axe.spec.ts new file mode 100644 index 0000000..2b5cfb5 --- /dev/null +++ b/tests/browser/accessibility-axe.spec.ts @@ -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 { + 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([]); + }); +} diff --git a/tests/browser/accessibility-forced-colors.spec.ts b/tests/browser/accessibility-forced-colors.spec.ts new file mode 100644 index 0000000..83eb87e --- /dev/null +++ b/tests/browser/accessibility-forced-colors.spec.ts @@ -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); +}); diff --git a/tests/browser/accessibility-keyboard.spec.ts b/tests/browser/accessibility-keyboard.spec.ts new file mode 100644 index 0000000..43893ce --- /dev/null +++ b/tests/browser/accessibility-keyboard.spec.ts @@ -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 { + 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( + ".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); +}); diff --git a/tests/browser/accessibility-reflow.spec.ts b/tests/browser/accessibility-reflow.spec.ts new file mode 100644 index 0000000..b87f592 --- /dev/null +++ b/tests/browser/accessibility-reflow.spec.ts @@ -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 { + const result = await page.evaluate(() => { + const root = document.documentElement; + const horizontalCandidates = [ + ...document.querySelectorAll( + ".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( + "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); +});