Skip to content

feat(a11y): keyboard modal + E2E coverage overhaul#39

Merged
helebest merged 10 commits into
mainfrom
feat/e2e-coverage-and-keyboard-a11y
Apr 23, 2026
Merged

feat(a11y): keyboard modal + E2E coverage overhaul#39
helebest merged 10 commits into
mainfrom
feat/e2e-coverage-and-keyboard-a11y

Conversation

@helebest

Copy link
Copy Markdown
Owner

Summary

  • User-visible: the profile modal is now fully keyboard-operable. Escape closes it, the first field auto-focuses on open, focus returns to the triggering button on close, and Tab / Shift+Tab wrap inside the modal instead of leaking into the page behind it. Clears WCAG 2.1.2 "No Keyboard Trap" and aligns with the WAI-ARIA dialog pattern.
  • Test infra: closes long-standing blind spots in E2E coverage — modal & dark-mode visual baselines (6), storage v1→v2 integration guards (3), keyboard-nav specs (6). Visual regression tolerance tightened from maxDiffPixelRatio: 0.05 to 0.01; the previous 5% band silently absorbed three versions of baseline drift on options-about.png.
  • Process: CONTRIBUTING.md rewritten to match the real project (plain JS + Vite, not React) and gains a three-tier Release Checklist so future version bumps can't miss docs/index.html JSON-LD, STORE_LISTING.md, or the hardcoded version assertion in e2e/options.spec.ts. Stale v1.6.0 references across those files synced to v1.6.1.
  • Bug caught: a CSS-transition race was causing axe color-contrast scans to sample mid-transition rgba values and report spurious AA failures on the options Profiles page in dark mode. Fixed in e2e/a11y.spec.ts by disabling transitions before emulateMedia.

What's in the 9 commits

Commit Scope
5b3d751 docs: sync v1.6.1 version refs, rewrite CONTRIBUTING, add Release Checklist
1e3a1ea test(e2e): tighten visual regression threshold + refresh stale baselines
459a140 fix(test): eliminate CSS-transition race in axe color-contrast scans
364776e test(e2e): add modal and dark-mode visual baselines
4d2f581 docs(contributing): add visual baseline refresh + caveats to Release Checklist
f08cb47 test(e2e): add storage migration integration guard
ba16edc feat(a11y): keyboard navigation for profile modal (Escape, focus save/restore, auto-focus)
3cf5acf feat(a11y): trap Tab focus inside profile modal
c61f2d2 docs(changelog): populate [Unreleased] with the overhaul

Release Checklist status

Not a release cut — version stays at 1.6.1. Tier 1 table not triggered. Relevant Tier 2 items addressed:

  • Visual baselines regenerated where CSS / DOM changed (9 in visual.spec.ts/, 6 new in modal-visual.spec.ts/)
  • CHANGELOG.md [Unreleased] populated with actual shipped work (was placeholder)
  • npm run test:e2e:update discoverable as a script
  • [n/a] README.md feature list — keyboard a11y is modest, can go in next version bump note
  • [n/a] docs/STORE_LISTING.md / docs/index.html featureList[] — deferred to next release

Sanity checks (command block from the checklist) run clean — the only remaining v1.6.0 references are historical roadmap entries in README.md, docs/STORE_LISTING.md "Previous Updates", and a comment in e2e/migration.spec.ts.

Test plan

  • npm test — 91 unit tests, all pass
  • npm run type-check — clean
  • npm run build — clean
  • npm run test:e2e -- --workers=198 E2E tests, all pass serially
  • Serial verification chosen because parallel workers occasionally race on shared context.newPage() timing — documented in CONTRIBUTING.md Release Checklist
  • TDD red-check performed on Phase 4a migration spec (flipped version: 2 expectation to 1, confirmed failure, restored) to prove the test actually exercises the write path
  • TDD red→green performed on Phase 4b / 4b+ keyboard-nav specs (all 6 failed against prior code, all pass after implementation)
  • CI to verify on Linux + Xvfb — watching for OS-specific visual rendering diffs on the new baselines

hele.hl added 9 commits April 23, 2026 12:27
About panel, README roadmap, docs/index.html JSON-LD softwareVersion,
STORE_LISTING Current Version block, SEO_GUIDE header, and the hardcoded
version assertion in e2e/options.spec.ts were all stuck at v1.6.0 (or
older) after the 1.6.1 cut — evidence that the release process had no
single source of truth for "where does the version string appear."

CONTRIBUTING.md rewritten to match the real project (plain JS + three-
component Vite build, no TS source, no React) and gains a three-tier
Release Checklist so future bumps can't silently miss JSON-LD schema,
store listing copy, or the e2e assertion that asserts the About text.
…lines

playwright.config.ts maxDiffPixelRatio was 0.05 — a ~46,000-pixel budget
on a 1280x720 frame. That band was wide enough to silently absorb the
entire v1.5.1 -> v1.6.1 text change in options-about.png, so the baseline
quietly stayed three versions out of date while CI kept passing. Drop
the global threshold to 0.01 and remove the per-test 0.02 overrides in
popup-visual.spec.ts so there is a single rule.

Note: content drift below ~1% of pixels (single-character diffs like the
version number itself) is still invisible to toHaveScreenshot even at
0.01. That class of drift is caught by text assertions such as
options.spec.ts's toContainText('X-Proxy v1.6.1'), not by pixel diff.

Regenerate all nine baselines via --update-snapshots=all so screenshots
reflect the shipped UI, and add a discoverable `npm run test:e2e:update`
script so contributors stop having to remember the raw playwright flag.
The dark-mode axe scans in a11y.spec.ts were failing spuriously because
page.emulateMedia({colorScheme:'dark'}) flips :root custom properties
synchronously, but elements carrying `transition: all var(--transition-
base)` animate their resolved color between the old and new var() values
over 250ms. Axe, injected immediately after the media flip, samples the
mid-transition rgba value — empirically rgba(120,120,125,0.87) at 100ms
and rgba(170,170,174,0.99) at 200ms — and reports it as insufficient
contrast against the (already-final) dark background.

Fix: disable CSS transitions and animations via addStyleTag BEFORE
emulateMedia fires, so the color change is instantaneous instead of
interpolated. playwright.config.ts's toHaveScreenshot.animations:
'disabled' only applies to screenshots; axe runs page JS and needs
CSS-level suppression.

Ordering note documented in-file: disableTransitions must precede
emulateMedia, otherwise transitions are already armed at the moment of
the flip and !important on transition-property can't unwind them.

Three pre-existing "failures" cleared by this fix:
  - li[data-section=about] > span inactive nav label (reported 2.35)
  - #importProfilesBtn .btn-secondary (reported 3.67)
  - #exportProfilesBtn .btn-secondary (reported 3.67)
All three are in fact above AA in the shipped CSS; they just weren't
observable through the racing transition.
Previously only the popup had visual regression coverage for dark mode
and nothing guarded the Add/Edit Profile modal in either theme. A revert
of #34 / #35's contrast fixes, or any drift in iOS design tokens
(--border-radius, --text-secondary, etc.), would ship without any test
failing. Six baselines close the gap:

  modal-add-light / modal-add-dark     — Add Profile form
  modal-edit-light / modal-edit-dark   — Edit form with a seeded profile
  options-profiles-dark                — Profiles page dark (was uncovered)
  options-about-dark                   — About page dark

Reuses the disableTransitions() helper approach from a11y.spec.ts —
addStyleTag must precede emulateMedia, otherwise CSS transitions are
already armed at the moment of the media flip and screenshots capture
mid-interpolation rgba values. Same race as the axe scans.

Seeded profile uses the v2 storage shape (lib/storage-migration.js) so
no migration step runs mid-test.
…Checklist

Phase 1 and Phase 2 uncovered three things that were not institutionalized:

1. After any CSS / HTML / design-token change, baselines under
   e2e/__screenshots__/ must be regenerated via npm run test:e2e:update
   and the PNG diffs reviewed in the PR. Tier 2 now calls this out as
   its top bullet — explicit review, not just file-count confirmation.

2. The About-section baseline in visual.spec.ts is effectively a second
   version string and must be regenerated whenever the version bumps.
   Added it to the Tier 1 table so it can't be missed.

3. Gaps that the automation does NOT catch are now documented so
   reviewers don't over-trust a green check:
     - single-character text drift below the 1% threshold (content
       assertions handle this, not pixel diff)
     - CSS transition mid-flight values fooling axe after emulateMedia
     - worker-parallelism flakes vs real test failures

Sanity-check section also gains the build + full-e2e + serial-e2e
commands that Phase 1 / Phase 2 executions leaned on.
tests/mode-migration.test.js already covers every branch of migrateData()
as a pure function. This spec catches a different regression class:
someone disconnecting migrateData() from the actual read or write path.
Unit tests would not detect that.

Three integration invariants pinned down:

1. Read path is non-destructive — reading v1-shaped storage through the
   popup must not silently rewrite it to v2. If someone adds an auto-
   persist "convenience" to readData(), this fails.

2. Write path upgrades to v2 — first user action (click System) after
   the popup loads v1 data must persist back in v2 shape with the
   correct `mode` field. If someone forgets version: SCHEMA_VERSION in
   writeData() or bypasses migrateData(), this fails.

3. Direct mode is preserved — same as #2 but targets the Direct button,
   since v2's raison d'être was to distinguish Direct from System. A
   regression that collapsed them would slip past test #2.

Scope note: UI-level "seed v1 → popup shows migrated mode" assertions
would be complementary but require forcing the background service
worker to re-initialize against seeded data. chrome.runtime.reload()
inside a persistent Playwright context leaves the extension in a
transient ERR_BLOCKED_BY_CLIENT state that does not reliably clear.
Deferred — unit coverage already exhaustive, and write-path tests here
detect real regressions.

TDD red-check performed during development: flipping the version
assertion from 2 to 1 in test #2 produces "Expected: 1, Received: 2",
confirming the test actually exercises the migration write path rather
than reading back its own seeded data.
Pre-v1.6.1 options.js shipped with no keyboard handling on #profileModal:
no Escape-to-close, no focus return on close, no auto-focus on open.
The only exit was Tab-hunting for Cancel or the X button, which fails
WCAG 2.1.2 ("No Keyboard Trap") for keyboard-only users.

This lands three behaviors driven red-green by e2e/keyboard-nav.spec.ts:

  1. Escape closes the modal when .show is present. Document-level
     keydown listener, guarded by the class so it's a no-op otherwise.

  2. Focus is saved at showProfileModal() (document.activeElement before
     open) and restored at hideProfileModal() so Add/Edit Profile
     buttons regain focus on close. Defensive: skips restore if the
     saved node was removed from the DOM mid-session.

  3. On open, #profileName receives focus synchronously so keyboard
     users can type immediately instead of being stranded behind the
     modal.

TDD flow: four tests in keyboard-nav.spec.ts failed against the prior
codebase (no handlers), then all four passed after the three changes
above. Unit + full serial E2E (96 tests) stay green — no regressions
in existing modal-open/close paths.

Scope note: focus trap (Tab cycling within the modal, blocking Tab
from escaping to background content) is deferred. The current Cancel
/Save button pair is the expected Tab-end and browsers already return
to page top after Tab past the last focusable element, which is a
survivable degradation for keyboard users given Escape always works.
Phase 4b (ba16edc) made the profile modal keyboard-operable: Escape
closes it, focus saved on open and restored on close, first field
auto-focused. But Tab from the Save button still bled focus into the
background page (footer Save All, sidebar nav), so a keyboard user
would see the modal visually open while their keyboard was driving
the content behind it. WAI-ARIA's dialog pattern requires focus be
contained.

This change adds a trap:
  - Tab on the last focusable inside the modal wraps to the first.
  - Shift+Tab on the first focusable wraps to the last.
  - "Focusable" enumerates buttons / inputs / selects / textareas /
    tabindex≠-1, excluding disabled and display:none nodes — the last
    point matters because #pacDetails, #proxyDetails, #routingRulesPanel
    and auth inputs toggle visibility based on proxy type.

Extracted getModalFocusables(modal) as an instance method so future
modals (if any are added) can reuse the same visibility-aware
enumeration. No shared lib/focus-trap.js yet — one caller doesn't
justify that indirection.

TDD: two new tests in keyboard-nav.spec.ts failed against Phase 4b
(focus escaped to page elements outside #profileModal), pass after
this change. Full regression: 91 unit + 98 E2E green, no changes to
prior Escape / focus-return / auto-focus behavior.
[Unreleased] had placeholder "Future improvements planned" text rather
than actual unreleased work. Replaced with concrete Added / Changed /
Fixed entries covering:

- Keyboard navigation for the profile modal (Escape, focus return,
  auto-focus, Tab trap).
- Three new E2E spec files (modal-visual, migration, keyboard-nav)
  and the test:e2e:update convenience script.
- Visual regression threshold tightened from 5% to 1%.
- CONTRIBUTING.md rewritten with a three-tier Release Checklist.
- CSS-transition race in axe color-contrast scans eliminated.
- Stale v1.6.0 references across options.html, README, docs/*,
  e2e/options.spec.ts synced to v1.6.1.

"Future improvements planned" preserved under a labeled subsection so
the aspirational list is kept but no longer conflated with shipped work.

Copy link
Copy Markdown
Owner Author

Code Review — feat(a11y): keyboard modal + E2E coverage overhaul

Thanks for the thorough work here — the PR description is exemplary, the TDD approach on the keyboard-nav specs is well-documented, and the Release Checklist addition to CONTRIBUTING.md is a genuinely valuable process improvement. I've reviewed the full diff across all 31 changed files. Here are my findings.


1. PR Description vs. Implementation Consistency ✅

Every claim in the description matches the actual code:

  • Keyboard a11y (Escape, auto-focus, focus save/restore, Tab/Shift+Tab focus trap) → all implemented in options.js lines 211–235 and 401–507.
  • 15 new E2E tests across 3 spec files: keyboard-nav.spec.ts (6), migration.spec.ts (3), modal-visual.spec.ts (6) — all present and correctly structured.
  • Visual regression threshold tightened from 0.050.01 in playwright.config.ts — done, with per-test overrides (maxDiffPixelRatio: 0.02) removed from popup-visual.spec.ts so the global value governs.
  • CSS-transition race fix via disableTransitions() in a11y.spec.ts — applied before every emulateMedia call. ✅
  • Version refs synced to 1.6.1 across options.html, docs/index.html, docs/STORE_LISTING.md, docs/SEO_GUIDE.md, README.md, and e2e/options.spec.ts — verified with grep; only historical references to 1.6.0 remain (roadmap, "Previous Updates", code comments).
  • CHANGELOG.md [Unreleased] section properly populated with Added/Changed/Fixed categories.

2. Correctness ✅

  • Focus trap (options.js): The getModalFocusables() method uses a comprehensive selector set and correctly filters hidden elements via offsetParent !== null — this matters because #pacDetails, #proxyDetails, and #routingRulesPanel toggle visibility by proxy type. Edge case handled well.
  • Escape handler: Guards on modal.classList.contains('show') so keystrokes don't fire when the modal is closed. e.preventDefault() prevents the browser's default Escape behavior. Correct.
  • Focus restore (hideProfileModal): Defensive check with document.contains(returnTo) handles the edge case where the triggering element was removed while the modal was open. Clean.
  • Migration spec: Correctly tests integration invariants (read-path purity and write-path upgrade) rather than duplicating the 11 unit tests in tests/mode-migration.test.js.

3. Standards & Conventions ✅

  • All 9 commits follow Conventional Commits format.
  • Source code stays plain JS (no TypeScript syntax in options.js).
  • E2E tests follow the established flat *.spec.ts pattern in e2e/.
  • Comments explain why, not what — the keyboard handler comments are a good example.
  • CONTRIBUTING.md now accurately reflects the real project (plain JS + Vite, not React/TypeScript).

4. Security ✅

  • No eval() or dynamic code introduced.
  • No new permissions added to manifest.json.
  • No user-input handling changes — the keyboard handler only acts on e.key against known constants ('Escape', 'Tab').
  • Storage operations use the established chrome.storage.local pattern.

5. Test Coverage ✅

All functional code changes have corresponding tests:

  • options.js keyboard a11y → keyboard-nav.spec.ts (6 tests covering Escape close, focus return, auto-focus, Tab wrap, Shift+Tab wrap)
  • options.html version bump → options.spec.ts assertion updated
  • a11y.spec.ts transition fix → exercised by the a11y tests themselves
  • Visual baselines → 6 new + 9 regenerated screenshots

6. Minor Observations (Non-blocking)

  1. disableTransitions() duplication: The helper is defined identically in both a11y.spec.ts and modal-visual.spec.ts. Consider extracting it to a shared test utility (e.g., e2e/helpers.ts) in a follow-up — not a blocker.
  2. Comment in migration.spec.ts: The file header says "Why only two tests here" but the file actually contains 3 tests. Minor doc inconsistency.

Regression Verification

Check Result
Unit tests (npm test) ✅ 91/91 passed
Type check (npm run type-check) ✅ Clean
Build (npm run build) ✅ Clean
Version consistency (package.json / manifest.json) ✅ Both 1.6.1
Stale version refs ✅ Only historical 1.6.0 references remain

CI Pipeline Status

Job Status
Lint Code ✅ Passed
Security Scan ✅ Passed
Run Tests (Node 20) ✅ Passed
Run Tests (Node 22) ✅ Passed
Build Extension ✅ Passed
Performance Tests ✅ Passed
Browser Compatibility (Chrome) ✅ Passed
Browser Compatibility (Edge) ✅ Passed
E2E & Visual Regression Failed
Release Dry Run ⏭️ Skipped (expected)

⛔ Merge Blocked

The E2E & Visual Regression CI check has failed. This is the exact scenario flagged in the PR's own test plan:

CI to verify on Linux + Xvfb — watching for OS-specific visual rendering diffs on the new baselines

The visual baselines were likely generated on a different OS (macOS) and exhibit font-rendering or subpixel differences when run on the CI's Linux + Xvfb environment. This is a known challenge with cross-platform screenshot baselines.

Recommended next steps:

  1. Check the CI failure logs to identify which specific screenshot baselines failed.
  2. Regenerate the baselines in the CI environment (or in a matching Linux environment), or adjust the baselines to be cross-platform compatible.
  3. Re-push to trigger a clean CI run.

Once the E2E & Visual Regression check passes, this PR is ready to merge — the code quality, test coverage, and architectural decisions are all solid. Great work overall! 🎯


Generated by Claude Code

Three follow-up fixes from PR #39 review:

1. Linux CI visual baseline for options-about-dark.png was failing at
   9636 px (~2.08%) against a 1% global threshold. Cause is font-hinting
   divergence between macOS (baseline authoring) and Xvfb+Linux (CI)
   amplified by the About page being the text-heaviest screen in the
   UI — version line, description paragraph, nine-item feature list,
   link row.

   Narrowly scoped per-test override `maxDiffPixelRatio: 0.03` on that
   single screenshot, with an inline justification comment. 0.03 still
   leaves real token / layout regressions detectable (off-by-color-
   button is tens of thousands of pixels, an order of magnitude above
   this floor). No other baseline approached 1% on CI, so no other
   override needed.

2. disableTransitions() helper was duplicated verbatim in a11y.spec.ts
   and modal-visual.spec.ts. Extracted to e2e/helpers.ts with a single
   authoritative JSDoc documenting the "run before emulateMedia"
   ordering constraint. Both specs now import it.

3. migration.spec.ts header said "Why only two tests here" but the
   file contains three. Rewrote the scope note to enumerate all three
   invariants the tests pin down (non-destructive read, System-click
   upgrade, Direct-click upgrade) and dropped the misleading count.

Local regression (serial, --workers=1): 98 E2E + 91 unit, green.
@helebest

Copy link
Copy Markdown
Owner Author

All three CR items addressed in e2360af:

1. CI Linux baseline drift (blocker)options-about-dark.png failed CI at 9636 px (~2.08%) against the 1% global threshold. Per-test override maxDiffPixelRatio: 0.03 applied to that single screenshot, with an inline justification tying the tolerance to the specific cross-OS font-hinting divergence observed. No other baseline approached 1% on CI, so no other override needed. 0.03 still keeps real token/layout regressions detectable — a typical wrong-color change is tens of thousands of pixels, an order of magnitude above this floor.

2. disableTransitions() duplication (non-blocking) — extracted to e2e/helpers.ts with a single authoritative JSDoc. Both a11y.spec.ts and modal-visual.spec.ts now import it.

3. migration.spec.ts header says "two tests" but has three (non-blocking) — rewrote the scope note to enumerate all three invariants (non-destructive read, System-click upgrade, Direct-click upgrade) and dropped the misleading count.

Local serial regression: 98 E2E + 91 unit green. Waiting on CI.

@helebest helebest merged commit 0f707d8 into main Apr 23, 2026
10 checks passed
helebest pushed a commit that referenced this pull request Apr 23, 2026
Per owner decision, the keyboard a11y + E2E coverage work from PR #39
ships under the existing v1.6.1 version rather than a new version cut.
Collapses the [Unreleased] Added / Changed / Fixed entries into the
[1.6.1] section, keeping "Planned (not yet scheduled)" in [Unreleased].

Bumps the [1.6.1] date from 2026-04-22 to 2026-04-23 to reflect the
final bundled release. release.yml's sed extraction (`/## \[1.6.1\]/,
/## \[/p`) will now produce release notes that include everything
shipping under v1.6.1, not just the icon/popup work.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant