840 lines
28 KiB
Markdown
840 lines
28 KiB
Markdown
# 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.
|
||
|
||
**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: A1–A5 (feature checks) and AX1–AX5 (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 15–16 (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 A3a–A3d 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 124–128:
|
||
|
||
```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 129–134:
|
||
|
||
```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 A3a–A3d 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 A4a–A4b 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 30–41):
|
||
|
||
```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 A4a–A4b 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: A1–A4 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: A1–A5 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:** AX1–AX5 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
|
||
// ── AX1–AX5: 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 1–5 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: A1–A5 and AX1–AX5 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"
|
||
```
|