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

28 KiB
Raw Blame History

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.


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:

// @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
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>):

<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:

<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:

    <main class="site-main">

Replace with:

    <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):

:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}

Add this block directly before it:

.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
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
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
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:

// ── 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
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:

    --color-ink-muted:      #7A7268;   /* labels, timestamps, captions */

    --color-ink-muted:      #90887E;   /* labels, timestamps, captions */

Change --color-accent:

    --color-accent:         #2A8C73;   /* teal — lightened for dark contrast */

    --color-accent:         #2E9880;   /* teal — lightened for dark contrast */

Change --color-accent-hover:

    --color-accent-hover:   #236655;   /* hover/pressed teal */

    --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

npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A2"

Expected: PASS.

  • Step 5: Run all accessibility tests
npx playwright test --project=chromium tests/ui/accessibility.spec.js

Expected: A1 and A2 pass.

  • Step 6: Commit
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:

// ── 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
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:

            <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:

            <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:

                <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:

                <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):

    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:

    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):

        statsToggle.addEventListener('click', function() {
            var isOpen = statsBlock.style.display !== 'none';
            statsBlock.style.display = isOpen ? 'none' : '';
            statsToggle.classList.toggle('is-active', !isOpen);
        });

Replace with:

        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):

        cycToggle.addEventListener('click', function() {
            var isOpen = cycBlock.style.display !== 'none';
            cycBlock.style.display = isOpen ? 'none' : '';
            cycToggle.classList.toggle('is-active', !isOpen);
        });

Replace with:

        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
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
npx playwright test --project=chromium tests/ui/trip-filter.spec.js

Expected: all pass.

  • Step 10: Commit
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:

// ── 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
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):

<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:

<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):

.journal-photo-dot.is-active {
    background: var(--color-ink-muted);
}

Add this block directly after it:

.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
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A4"

Expected: both PASS.

  • Step 6: Run the full accessibility suite
npx playwright test --project=chromium tests/ui/accessibility.spec.js

Expected: A1A4 all pass.

  • Step 7: Commit
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:

// ── 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
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:

            <td><button class="gpx-delete" data-filename="${f.filename}">Delete</button></td>

Replace with:

            <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
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A5"

Expected: PASS.

  • Step 5: Run full accessibility suite
npx playwright test --project=chromium tests/ui/accessibility.spec.js

Expected: A1A5 all pass.

  • Step 6: Commit
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

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:

// ── 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
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
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
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
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"