Files
intotheeast-com/docs/superpowers/plans/2026-06-20-accessibility-audit.md
T

842 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Accessibility Audit Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Status:** 🔄 In progress — Task 1 complete (skip link), Tasks 26 open
**Goal:** Fix all eight WCAG 2.1 AA failures identified in the accessibility audit and add axe-core Playwright regression tests.
**Architecture:** Six sequential tasks — each implements one audit finding (or related group), writes a Playwright test first, then implements the fix in the relevant template/CSS/JS files. All tests go into a new `tests/ui/accessibility.spec.js` file that grows task by task. Task 6 adds axe-core automated scans on top of the feature-specific checks.
**Tech Stack:** Grav 2.0 Twig templates, CSS custom properties, vanilla JS, Playwright with `@axe-core/playwright`
## Global Constraints
- Target standard: WCAG 2.1 Level AA
- Dev server: http://localhost:8081 (Docker container `intotheeast_grav`)
- Two git repos: outer at `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast`, user subrepo at `/home/mischa/Projects/travel-blog-intotheeast/user`
- Template files are in the **user subrepo** (`user/themes/intotheeast/templates/`, `user/themes/intotheeast/css/`) — commit there first, then commit the outer repo with the updated `user/` pointer
- Tests and `package.json` are in the **outer repo** only
- Run all Playwright tests with: `cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast && npx playwright test --project=chromium`
- Demo data must be loaded before running tests: `make demo-load` (run from outer repo)
- Never read `.env`; pass it only to `make` / `docker compose`
- Do NOT add comments to CSS or JS unless the WHY is non-obvious
**Note on F8 (lightbox alt text):** The existing JS in `entry.html.twig` already handles this correctly — `open(index)` sets `lbImg.alt = btn.dataset.alt` which is populated from `{{ image.filename }}`. No code change needed for F8.
---
### Task 1: Skip link + `<main id="main-content">`
**Fixes:** F1 (WCAG 2.4.1 Bypass Blocks)
**Files:**
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
- Modify: `user/themes/intotheeast/css/style.css`
- Create: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Produces: `.skip-link` element, `#main-content` id on `<main>` — both consumed by A1 test
- [ ] **Step 1: Create the failing test**
Create `tests/ui/accessibility.spec.js`:
```js
// @ts-check
// Tests: A1A5 (feature checks) and AX1AX5 (axe scans)
const { test, expect } = require('@playwright/test');
// ── A1: Skip link ──────────────────────────────────────────────────────────────
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
await page.goto('/');
const skipLink = page.locator('.skip-link');
await expect(skipLink).toBeAttached();
await expect(skipLink).toHaveAttribute('href', '#main-content');
await expect(page.locator('#main-content')).toBeAttached();
});
```
- [ ] **Step 2: Run A1 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: FAIL — `.skip-link` not found.
- [ ] **Step 3: Add skip link to `base.html.twig`**
In `user/themes/intotheeast/templates/partials/base.html.twig`:
Change line 1516 (the opening `<body>` and `<header>`):
```html
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
<header class="site-header">
```
Replace with:
```html
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
<a class="skip-link" href="#main-content">Skip to main content</a>
<header class="site-header">
```
Then change line 25:
```html
<main class="site-main">
```
Replace with:
```html
<main class="site-main" id="main-content">
```
- [ ] **Step 4: Add skip-link CSS to `style.css`**
In `user/themes/intotheeast/css/style.css`, find the `:focus-visible` rule (around line 782):
```css
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
```
Add this block directly before it:
```css
.skip-link {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus-visible {
left: 0;
top: 0;
width: auto;
height: auto;
overflow: visible;
padding: var(--space-2) var(--space-4);
background: var(--color-accent);
color: var(--color-accent-on);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 600;
text-decoration: none;
z-index: 9999;
}
```
- [ ] **Step 5: Run A1 to verify it passes**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: PASS.
- [ ] **Step 6: Run existing tests to check for regressions**
```bash
npx playwright test --project=chromium tests/ui/nav.spec.js tests/ui/home.spec.js tests/ui/dailies.spec.js
```
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
git commit -m "feat(a11y): add skip-to-main link and main landmark id"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A1 skip link test"
```
---
### Task 2: Color token contrast fixes
**Fixes:** F2 (`--color-ink-muted` fails 4.5:1), F3 (`--color-accent` fails 4.5:1)
**Files:**
- Modify: `user/themes/intotheeast/css/tokens.css`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 1
- Produces: updated token values verified by A2 test
- [ ] **Step 1: Add the failing test**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A2: Color token contrast ───────────────────────────────────────────────────
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
await page.goto('/');
const [muted, accent] = await page.evaluate(() => [
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
]);
expect(muted.toLowerCase()).toBe('#90887e');
expect(accent.toLowerCase()).toBe('#2e9880');
});
```
- [ ] **Step 2: Run A2 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A2"
```
Expected: FAIL — values are `#7a7268` and `#2a8c73`.
- [ ] **Step 3: Update `tokens.css`**
In `user/themes/intotheeast/css/tokens.css`, make three changes:
Change `--color-ink-muted`:
```css
--color-ink-muted: #7A7268; /* labels, timestamps, captions */
```
```css
--color-ink-muted: #90887E; /* labels, timestamps, captions */
```
Change `--color-accent`:
```css
--color-accent: #2A8C73; /* teal — lightened for dark contrast */
```
```css
--color-accent: #2E9880; /* teal — lightened for dark contrast */
```
Change `--color-accent-hover`:
```css
--color-accent-hover: #236655; /* hover/pressed teal */
```
```css
--color-accent-hover: #287A68; /* hover/pressed teal */
```
Contrast verification (for reference only — these numbers are correct):
- `#90887E` on `#1A1814` = 5.07:1 ✓, on `#22201B` = 4.66:1 ✓
- `#2E9880` on `#1A1814` = 5.00:1 ✓, on `#22201B` = 4.59:1 ✓
- `#287A68` on `#1A1814` = 3.58:1 ✓ (non-text, needs 3:1)
- [ ] **Step 4: Run A2 to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A2"
```
Expected: PASS.
- [ ] **Step 5: Run all accessibility tests**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1 and A2 pass.
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/css/tokens.css
git commit -m "feat(a11y): fix --color-ink-muted and --color-accent contrast ratios"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A2 color contrast token test"
```
---
### Task 3: ARIA states for filter buttons and toggle panels
**Fixes:** F4 (filter buttons lack `aria-pressed`), F5 (toggle buttons lack `aria-expanded`)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 2
- Produces: `aria-pressed` on `.trip-filter-btn`, `aria-expanded` + `aria-controls` on `#trip-stats-toggle` and `#trip-cycling-toggle`
- [ ] **Step 1: Add the failing tests**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
const TRIP_URL = '/trips/japan-korea-2026';
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('.trip-filter-btn[data-filter="journal"]');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
});
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
});
```
- [ ] **Step 2: Run A3aA3d to verify they fail**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A3"
```
Expected: all four FAIL — attributes not present.
- [ ] **Step 3: Add `aria-pressed` to filter buttons in template**
In `user/themes/intotheeast/templates/trip.html.twig`, find lines 124128:
```html
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
```
Replace with:
```html
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
</div>
```
- [ ] **Step 4: Add `aria-expanded` + `aria-controls` to toggle buttons in template**
Find lines 129134:
```html
<div class="trip-filter-group">
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
{% if has_gpx %}
<button class="trip-stats-btn" id="trip-cycling-toggle">Cycling</button>
{% endif %}
</div>
```
Replace with:
```html
<div class="trip-filter-group">
<button class="trip-stats-btn" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats</button>
{% if has_gpx %}
<button class="trip-stats-btn" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling</button>
{% endif %}
</div>
```
- [ ] **Step 5: Update the filter JS to toggle `aria-pressed`**
In the same file, find the filter click handler inside the `<script>` block (around line 384):
```js
filterBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
filterBtns.forEach(function(b) { b.classList.remove('is-active'); });
btn.classList.add('is-active');
```
Replace those four lines with:
```js
filterBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
filterBtns.forEach(function(b) { b.classList.remove('is-active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('is-active');
btn.setAttribute('aria-pressed', 'true');
```
- [ ] **Step 6: Update the stats toggle JS to set `aria-expanded`**
Find the stats toggle handler (around line 548):
```js
statsToggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none';
statsBlock.style.display = isOpen ? 'none' : '';
statsToggle.classList.toggle('is-active', !isOpen);
});
```
Replace with:
```js
statsToggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none';
statsBlock.style.display = isOpen ? 'none' : '';
statsToggle.classList.toggle('is-active', !isOpen);
statsToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
});
```
- [ ] **Step 7: Update the cycling toggle JS to set `aria-expanded`**
Find the cycling toggle handler (around line 560):
```js
cycToggle.addEventListener('click', function() {
var isOpen = cycBlock.style.display !== 'none';
cycBlock.style.display = isOpen ? 'none' : '';
cycToggle.classList.toggle('is-active', !isOpen);
});
```
Replace with:
```js
cycToggle.addEventListener('click', function() {
var isOpen = cycBlock.style.display !== 'none';
cycBlock.style.display = isOpen ? 'none' : '';
cycToggle.classList.toggle('is-active', !isOpen);
cycToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
});
```
- [ ] **Step 8: Run A3aA3d to verify they pass**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A3"
```
Expected: all four PASS.
- [ ] **Step 9: Run the existing filter tests to check for regressions**
```bash
npx playwright test --project=chromium tests/ui/trip-filter.spec.js
```
Expected: all pass.
- [ ] **Step 10: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/trip.html.twig
git commit -m "feat(a11y): add aria-pressed to filter buttons and aria-expanded to stats/cycling toggles"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A3a-A3d aria-pressed and aria-expanded tests"
```
---
### Task 4: Photo strip keyboard navigation
**Fixes:** F6 (photo strip not keyboard-navigable)
**Files:**
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
- Modify: `user/themes/intotheeast/css/style.css`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 3
- Produces: `.strip-controls` div with `.strip-prev` / `.strip-next` buttons injected by JS for strips with `data-slides >= 2`; all strips get `role="region"` and `aria-label="Photo strip"`
- [ ] **Step 1: Add the failing tests**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
await page.goto('/trips/japan-korea-2026/dailies');
const strips = page.locator('.journal-photo-strip');
const count = await strips.count();
if (count === 0) return;
for (let i = 0; i < count; i++) {
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
}
});
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
await page.goto('/trips/japan-korea-2026/dailies');
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
);
if (multiCount === 0) return;
await expect(page.locator('.strip-prev').first()).toBeAttached();
await expect(page.locator('.strip-next').first()).toBeAttached();
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
});
```
- [ ] **Step 2: Run A4aA4b to verify they fail**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A4"
```
Expected: A4a FAIL (no `role` attribute), A4b PASS vacuously (no multi-slide strips in demo data).
- [ ] **Step 3: Replace the dot-sync IIFE in `base.html.twig`**
In `user/themes/intotheeast/templates/partials/base.html.twig`, find the IIFE (lines 3041):
```js
<script>
(function () {
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
var dots = strip.nextElementSibling;
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
strip.addEventListener('scroll', function () {
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
}, { passive: true });
});
})();
</script>
```
Replace with:
```js
<script>
(function () {
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
strip.setAttribute('role', 'region');
strip.setAttribute('aria-label', 'Photo strip');
var slideCount = parseInt(strip.dataset.slides, 10) || 1;
var dots = strip.nextElementSibling;
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
strip.addEventListener('scroll', function () {
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
}, { passive: true });
if (slideCount < 2) return;
var prev = document.createElement('button');
prev.className = 'strip-prev';
prev.setAttribute('aria-label', 'Previous photo');
prev.textContent = '';
prev.addEventListener('click', function () {
strip.scrollBy({ left: -strip.offsetWidth, behavior: 'smooth' });
});
var next = document.createElement('button');
next.className = 'strip-next';
next.setAttribute('aria-label', 'Next photo');
next.textContent = '';
next.addEventListener('click', function () {
strip.scrollBy({ left: strip.offsetWidth, behavior: 'smooth' });
});
var controls = document.createElement('div');
controls.className = 'strip-controls';
controls.appendChild(prev);
controls.appendChild(next);
dots.insertAdjacentElement('afterend', controls);
});
})();
</script>
```
- [ ] **Step 4: Add strip-controls CSS to `style.css`**
In `user/themes/intotheeast/css/style.css`, find the `.journal-photo-dot.is-active` rule (around line 245):
```css
.journal-photo-dot.is-active {
background: var(--color-ink-muted);
}
```
Add this block directly after it:
```css
.strip-controls {
display: flex;
justify-content: center;
gap: var(--space-3);
margin-top: calc(-1 * var(--space-2));
margin-bottom: var(--space-4);
}
.strip-prev,
.strip-next {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-ink-2);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-3);
font-size: var(--text-md);
line-height: 1;
cursor: pointer;
}
.strip-prev:hover,
.strip-next:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
```
- [ ] **Step 5: Run A4aA4b to verify they pass**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A4"
```
Expected: both PASS.
- [ ] **Step 6: Run the full accessibility suite**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1A4 all pass.
- [ ] **Step 7: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
git commit -m "feat(a11y): add keyboard prev/next to photo strip and region landmark"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A4a-A4b photo strip keyboard tests"
```
---
### Task 5: GPX delete button unique accessible names
**Fixes:** F7 (delete buttons have no unique name)
**Files:**
- Modify: `user/themes/intotheeast/templates/gpx-manager.html.twig`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 4
- Produces: delete buttons with `aria-label="Delete <filename>"`
- [ ] **Step 1: Add the failing test**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── A5: GPX delete button unique accessible names ──────────────────────────────
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
await page.route('**/api/v1/pages**/media', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
]
})
});
});
await page.goto('/gpx-manager');
const deleteBtn = page.locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
await expect(deleteBtn).toBeVisible();
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
});
```
- [ ] **Step 2: Run A5 to verify it fails**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A5"
```
Expected: FAIL — `aria-label` attribute not present on the delete button.
- [ ] **Step 3: Add `aria-label` to the delete button in `gpx-manager.html.twig`**
In `user/themes/intotheeast/templates/gpx-manager.html.twig`, find line 99 inside the `rows` template string:
```js
<td><button class="gpx-delete" data-filename="${f.filename}">Delete</button></td>
```
Replace with:
```js
<td><button class="gpx-delete" data-filename="${f.filename}" aria-label="Delete ${f.filename}">Delete</button></td>
```
- [ ] **Step 4: Run A5 to verify it passes**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A5"
```
Expected: PASS.
- [ ] **Step 5: Run full accessibility suite**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1A5 all pass.
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Projects/travel-blog-intotheeast/user
git add themes/intotheeast/templates/gpx-manager.html.twig
git commit -m "feat(a11y): add unique aria-label to GPX delete buttons"
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add tests/ui/accessibility.spec.js user
git commit -m "test(a11y): add A5 GPX delete button accessible name test"
```
---
### Task 6: axe-core WCAG 2.1 AA regression scans
**Adds:** AX1AX5 automated axe scans across all main page types
**Files:**
- Modify: `package.json`
- Modify: `tests/ui/accessibility.spec.js`
**Interfaces:**
- Consumes: `tests/ui/accessibility.spec.js` from Task 5
- Produces: five axe scans that fail on any `critical` or `serious` WCAG violation
- [ ] **Step 1: Install `@axe-core/playwright`**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npm install --save-dev @axe-core/playwright
```
After install, `package.json` devDependencies should include `"@axe-core/playwright": "^4.x.x"` (exact semver will vary).
- [ ] **Step 2: Add the axe scans to `accessibility.spec.js`**
Append to `tests/ui/accessibility.spec.js`:
```js
// ── AX1AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
const { AxeBuilder } = require('@axe-core/playwright');
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
const BLOCKING = ['critical', 'serious'];
function axeScan(id, url) {
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
await page.goto(url);
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
expect(
violations,
violations.map(v =>
`[${v.impact}] ${v.id}: ${v.description}\n ` +
v.nodes.map(n => n.html).join('\n ')
).join('\n\n')
).toHaveLength(0);
});
}
axeScan('AX1', '/');
axeScan('AX2', '/trips/japan-korea-2026');
axeScan('AX3', '/trips/japan-korea-2026/dailies');
axeScan('AX4', '/trips/japan-korea-2026/dailies/2026-03-25-1540-wheels-down-narita');
axeScan('AX5', '/trips');
```
- [ ] **Step 3: Run the axe scans**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "AX"
```
Expected: all five PASS (after Tasks 15 fixed the known violations). If any fail, read the violation output — it will name the rule ID, description, and offending HTML. Fix the violation if it represents a real issue, or note it in the ledger if it is a known limitation outside scope (e.g. map canvas element).
- [ ] **Step 4: Run the full accessibility test suite**
```bash
npx playwright test --project=chromium tests/ui/accessibility.spec.js
```
Expected: A1A5 and AX1AX5 all pass (10 tests total).
- [ ] **Step 5: Run the full Playwright suite to check for regressions**
```bash
npx playwright test --project=chromium
```
Expected: all tests pass (or pre-existing failures only — check the progress ledger for known pre-existing failures before marking as blocker).
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git add package.json package-lock.json tests/ui/accessibility.spec.js
git commit -m "test(a11y): add axe-core WCAG 2.1 AA regression scans AX1-AX5"
```