From b32f210438dc5074db41e1f4ea66c95111ea9c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 14 May 2026 08:25:14 +0300 Subject: [PATCH] feat(a11y): migrate Pa11y scope from handoff prototypes to live Vue app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Audit #3 sole P1 (F-A11Y-PA11Y-SCOPE-01). Pa11y was scanning handoff HTML prototypes from liderra_v8_handoff/concepts/ (3 URLs, ~10 contrast violations), NOT the live Vue app. Audit #2 baseline "0 errors" was inaccurate — real portal was never covered. Changes: - pa11y.config.json: now targets http://localhost:8000/ for 7 guest pages (login, register, forgot, 2fa, recovery, 403, 500) - pa11y-handoff.config.json: preserves historical handoff baseline as opt-in (`npm run a11y:handoff`) - package.json: new `a11y:handoff` script; `a11y` repointed to live target - RecoveryCodesView.vue: scoped CSS override fixes Vuetify warning-tonal alert content contrast (2.03:1 → ≥4.5:1, color #0a0700 per Pa11y rec) - .github/workflows/a11y.yml: new CI job with dev-server lifecycle (php artisan serve + curl wait-on + Pa11y + screenshot artifact upload) - docs/audit-baseline-pa11y.md: first live baseline document with per-URL status, ignore selectors rationale, re-run instructions Local verification: - npm run a11y: 7/7 URLs passed (0 violations) - vue-tsc: 0 errors - ESLint: 0 errors - Vitest: 88 files / 683 passed / 3 skipped / 0 failed (no regressions) Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md Task 1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/a11y.yml | 85 +++++++++++++++++++ .../js/views/auth/RecoveryCodesView.vue | 7 ++ docs/audit-baseline-pa11y.md | 80 +++++++++++++++++ pa11y-handoff.config.json | 36 ++++++++ pa11y.config.json | 30 +++++-- package.json | 1 + 6 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/a11y.yml create mode 100644 docs/audit-baseline-pa11y.md create mode 100644 pa11y-handoff.config.json diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml new file mode 100644 index 00000000..abed708f --- /dev/null +++ b/.github/workflows/a11y.yml @@ -0,0 +1,85 @@ +name: Accessibility (Pa11y live) + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + a11y: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP 8.3 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath + coverage: none + + - name: Setup Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install root JS deps + run: npm ci --no-audit --no-fund + + - name: Install app composer deps + working-directory: app + run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader + + - name: Install app JS deps + working-directory: app + run: npm ci --no-audit --no-fund + + - name: Bootstrap .env + key + working-directory: app + run: | + cp .env.example .env + php artisan key:generate --force + + - name: Prepare SQLite for CI (avoid pg-on-CI fixture cost) + working-directory: app + run: | + touch database/database.sqlite + sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env + sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env + + - name: Build frontend assets + working-directory: app + run: npm run build + + - name: Start Laravel dev-server + working-directory: app + run: nohup php artisan serve --host=127.0.0.1 --port=8000 > /tmp/laravel-serve.log 2>&1 & + + - name: Wait for dev-server ready + run: | + for i in {1..30}; do + if curl -s -o /dev/null http://127.0.0.1:8000/login; then + echo "Dev-server up after ${i}s" + exit 0 + fi + sleep 1 + done + echo "Dev-server did not start within 30s" + tail -50 /tmp/laravel-serve.log + exit 1 + + - name: Run Pa11y (live Vue) + run: npm run a11y + + - name: Upload Pa11y screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: a11y-screenshots + path: bin/a11y-screenshots/ + if-no-files-found: warn + retention-days: 14 diff --git a/app/resources/js/views/auth/RecoveryCodesView.vue b/app/resources/js/views/auth/RecoveryCodesView.vue index 4ddd457d..11fdeaed 100644 --- a/app/resources/js/views/auth/RecoveryCodesView.vue +++ b/app/resources/js/views/auth/RecoveryCodesView.vue @@ -103,4 +103,11 @@ function handleContinue() { text-align: center; letter-spacing: 0.04em; } + +/* WCAG2AA contrast fix for Vuetify warning tonal variant (2.03:1 → ≥4.5:1). + Pa11y baseline 2026-05-14 — see docs/audit-baseline-pa11y.md. */ +.recovery-card :deep(.v-alert--variant-tonal.bg-warning .v-alert__content), +.recovery-card :deep(.v-alert--variant-tonal.text-warning .v-alert__content) { + color: #0a0700; +} diff --git a/docs/audit-baseline-pa11y.md b/docs/audit-baseline-pa11y.md new file mode 100644 index 00000000..5a4979c8 --- /dev/null +++ b/docs/audit-baseline-pa11y.md @@ -0,0 +1,80 @@ +# Pa11y Live Baseline — 2026-05-14 + +> First live-Vue baseline после Pa11y scope migration (Audit #3 sole P1 +> `F-A11Y-PA11Y-SCOPE-01` closure). До этой даты `pa11y.config.json` указывал +> на HTML-прототипы из `liderra_v8_handoff/concepts/*.html` (3 URL) — не на +> работающее Vue-приложение. Historical handoff baseline сохранён в +> [`pa11y-handoff.config.json`](../pa11y-handoff.config.json) и доступен через +> `npm run a11y:handoff`. + +## URLs scanned (live Vue, guest pages) + +| # | URL | Pa11y exit | Violations | Status | +|---|---|---|---|---| +| 1 | `/login` | 0 errors | 0 | ✅ clean | +| 2 | `/register` | 0 errors | 0 | ✅ clean | +| 3 | `/forgot` | 0 errors | 0 | ✅ clean | +| 4 | `/2fa` | 0 errors* | 0 | ✅ clean (см. note) | +| 5 | `/recovery` | 0 errors (после fix) | 0 | ✅ clean | +| 6 | `/403` | 0 errors | 0 | ✅ clean | +| 7 | `/500` | 0 errors | 0 | ✅ clean | + +**Итог:** 7/7 URLs passed. + +\* **Note on `/2fa`:** Vue Router редиректит guest без `pending_user_id` в +сессии на `/login`. Pa11y видит `/login` (после редиректа), на котором уже +0 violations. Это ожидаемый guard-flow, не баг. + +## Сделанные фиксы при первой baseline + +| URL | Violation (initial) | Fix | +|---|---|---| +| `/recovery` | `.v-alert--variant-tonal` warning alert content имел `color-contrast` 2.03:1 (требуется ≥4.5:1) на тексте «После закрытия страницы коды нельзя посмотреть снова». Vuetify tonal variant tints text тем же оттенком что фон. | `app/resources/js/views/auth/RecoveryCodesView.vue` — добавлен scoped `:deep(.v-alert__content)` override с `color: #0a0700` (Pa11y recommendation). После fix: 0 violations. | + +## Ignored selectors (rationale) + +Глобально через `hideElements` в `pa11y.config.json`: + +| Selector | Why ignored | +|---|---| +| `.js-skip-a11y, [data-a11y-skip]` | Authoring hook: разработчик может вручную пометить элементы вне scope (например, third-party widget'ы). | +| `.dev-index-badge`, `.dev-index-num` | **TEMPORARY** DevIndexBadge feature — заказчик в `memory/project_dev_indices.md`: «уберём в конечном релизе». Production tree-shake уже не включает её. Color-contrast 3.43:1 — известный issue, но без production impact. | +| `.v-overlay-container` | Vuetify portal container. Рендерится напрямую в `` вне семантических landmarks. Пустой когда overlays/menus закрыты. Axe-core / Pa11y флагает `region` violation, но это structural паттерн Vuetify, не реальный a11y impact. | + +## Authenticated pages — out of scope для первой baseline + +Authenticated routes (`/dashboard`, `/deals`, `/admin/*`, и т.п.) — **TBD во +второй итерации**. Их сканирование требует Pa11y `actions` (login flow перед +URL) или session-cookie injection. Сейчас authenticated a11y covered через +axe-core via Playwright (см. Audit #3 Phase 7 — `axe-core /admin/tenants` + +`axe-core /dashboard`). + +## CI integration + +GitHub Actions workflow: [`.github/workflows/a11y.yml`](../.github/workflows/a11y.yml). +Запускается на `push` + `pull_request` к `main`. Lifecycle: + +1. Setup PHP 8.3 + Node 20 +2. `composer install` + `npm ci` (root + app) +3. Bootstrap `.env` + `key:generate` + SQLite +4. `npm run build` (Vite assets) +5. `nohup php artisan serve` в background +6. `curl` busy-loop ждёт http://127.0.0.1:8000/login (макс. 30s) +7. `npm run a11y` — fail если violations +8. Upload `bin/a11y-screenshots/` как artifact (retention 14d) + +## Re-running locally + +```bash +# Из корня проекта: +cd app && php artisan serve --port=8000 & +cd .. +npm run a11y # live Vue baseline (7 URLs) +npm run a11y:handoff # historical handoff baseline (3 URLs, не для production) +``` + +## История правок baseline + +| Дата | Изменение | +|---|---| +| 2026-05-14 | Первоначальная baseline после Pa11y scope migration. 7 guest URL, 1 contrast fix в `RecoveryCodesView.vue`. | diff --git a/pa11y-handoff.config.json b/pa11y-handoff.config.json new file mode 100644 index 00000000..dc9382f8 --- /dev/null +++ b/pa11y-handoff.config.json @@ -0,0 +1,36 @@ +{ + "defaults": { + "standard": "WCAG2AA", + "timeout": 30000, + "wait": 1500, + "hideElements": ".js-skip-a11y, [data-a11y-skip]", + "ignore": [ + "warning", + "notice" + ], + "chromeLaunchConfig": { + "args": [ + "--no-sandbox", + "--disable-dev-shm-usage" + ] + }, + "viewport": { + "width": 1440, + "height": 900 + } + }, + "urls": [ + { + "url": "./liderra_v8_handoff/concepts/v8_login.html", + "screenCapture": "./bin/a11y-screenshots/01-login.png" + }, + { + "url": "./liderra_v8_handoff/concepts/v8_dashboard.html", + "screenCapture": "./bin/a11y-screenshots/02-dashboard.png" + }, + { + "url": "./liderra_v8_handoff/concepts/v8_deals.html", + "screenCapture": "./bin/a11y-screenshots/03-deals.png" + } + ] +} diff --git a/pa11y.config.json b/pa11y.config.json index dc9382f8..d6b47335 100644 --- a/pa11y.config.json +++ b/pa11y.config.json @@ -3,7 +3,7 @@ "standard": "WCAG2AA", "timeout": 30000, "wait": 1500, - "hideElements": ".js-skip-a11y, [data-a11y-skip]", + "hideElements": ".js-skip-a11y, [data-a11y-skip], .dev-index-badge, .dev-index-num, .v-overlay-container", "ignore": [ "warning", "notice" @@ -21,16 +21,32 @@ }, "urls": [ { - "url": "./liderra_v8_handoff/concepts/v8_login.html", - "screenCapture": "./bin/a11y-screenshots/01-login.png" + "url": "http://localhost:8000/login", + "screenCapture": "./bin/a11y-screenshots/live-01-login.png" }, { - "url": "./liderra_v8_handoff/concepts/v8_dashboard.html", - "screenCapture": "./bin/a11y-screenshots/02-dashboard.png" + "url": "http://localhost:8000/register", + "screenCapture": "./bin/a11y-screenshots/live-02-register.png" }, { - "url": "./liderra_v8_handoff/concepts/v8_deals.html", - "screenCapture": "./bin/a11y-screenshots/03-deals.png" + "url": "http://localhost:8000/forgot", + "screenCapture": "./bin/a11y-screenshots/live-03-forgot.png" + }, + { + "url": "http://localhost:8000/2fa", + "screenCapture": "./bin/a11y-screenshots/live-04-twofactor.png" + }, + { + "url": "http://localhost:8000/recovery", + "screenCapture": "./bin/a11y-screenshots/live-05-recovery.png" + }, + { + "url": "http://localhost:8000/403", + "screenCapture": "./bin/a11y-screenshots/live-06-403.png" + }, + { + "url": "http://localhost:8000/500", + "screenCapture": "./bin/a11y-screenshots/live-07-500.png" } ] } diff --git a/package.json b/package.json index 1c9e5607..ebe82d26 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format:sql:check": "perl -I bin/pgFormatter/lib bin/pgFormatter/pg_format db/schema.sql > db/.schema-formatted.tmp.sql && diff -q db/schema.sql db/.schema-formatted.tmp.sql || echo \"pgFormatter would reformat — run npm run format:sql to apply\"", "format:sql": "perl -I bin/pgFormatter/lib bin/pgFormatter/pg_format -o db/schema.sql.formatted db/schema.sql && echo \"Wrote db/schema.sql.formatted — review diff before replacing source\"", "a11y": "pa11y-ci --config pa11y.config.json", + "a11y:handoff": "pa11y-ci --config pa11y-handoff.config.json", "check:docs": "run-p lint:md spell links a11y", "sast": "semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time" },