Files
portal/docs/audit-baseline-pa11y.md
T
Дмитрий b73ddaaedd docs(a11y): authenticated rescan baseline + findings (21/21 passing)
Final state docs after a11y rescan session:

- docs/audit-baseline-pa11y.md: «Authenticated rescan 2026-05-14» section
  added (14 new URLs, all 21 passing). Old «out of scope для первой
  baseline» section marked SUPERSEDED. Per-pattern fix table with file
  references + ignored selector rationale. axe-core cross-validation
  results documented (only DevIndexBadge dev-only remains).

- docs/superpowers/audits/2026-05-14-a11y-rescan-findings.md (new):
  Full audit findings doc — TL;DR, scope expansion table, per-pattern
  root cause + fix sections (A-H), axe-core cross-validation, метрики
  до/после, verdict 🟢 GREEN.

Regression sweep:
- Pa11y: 21/21 URLs passed
- Vitest: 91 files / 736 passed / 3 skipped / 0 failed
- Pest --parallel: 742/739/3sk/0
- Vite build: ~2s
- gitleaks: 0 leaks / 457 commits / 12.72 MB
- lychee: 345 OK / 0 errors / 457 total
- markdownlint: 0 errors (after auto-fix)
- cspell: 0 issues

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:08:08 +03:00

152 lines
9.7 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.
# 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. Рендерится напрямую в `<body>` вне семантических landmarks. Пустой когда overlays/menus закрыты. Axe-core / Pa11y флагает `region` violation, но это structural паттерн Vuetify, не реальный a11y impact. |
## ~~Authenticated pages — out of scope для первой baseline~~
> **SUPERSEDED 2026-05-14** — см. секцию «Authenticated rescan» ниже. Это
> ограничение закрыто во втором проходе того же дня после явного запроса
> заказчика «Pa11y был настроен на старые HTML-эскизы, проведи повторно аудит
> в этой части, чтобы он проверил реальный портал».
## Authenticated rescan — 2026-05-14 (вечер)
Расширение первой baseline на 14 authenticated routes через Pa11y `actions`
API (per-URL login flow с DemoSeeder credentials `admin@demo.local:password`).
Цикл: navigate `/login` → fill email/password → click submit → wait
`/dashboard` → navigate target URL → wait path → axe scan.
### URLs scanned (live Vue, authenticated)
| # | URL | Pa11y exit | Notes |
|---|---|---|---|
| 8 | `/dashboard` | 0 errors | AppLayout user view |
| 9 | `/deals` | 0 errors | AppLayout |
| 10 | `/kanban` | 0 errors | AppLayout |
| 11 | `/projects` | 0 errors | AppLayout |
| 12 | `/billing` | 0 errors | AppLayout |
| 13 | `/settings` | 0 errors | AppLayout |
| 14 | `/reports` | 0 errors | AppLayout (form-heavy) |
| 15 | `/reminders` | 0 errors | AppLayout |
| 16 | `/admin/tenants` | 0 errors | AppLayout admin |
| 17 | `/admin/billing` | 0 errors | AdminLayout |
| 18 | `/admin/incidents` | 0 errors | AdminLayout |
| 19 | `/admin/system` | 0 errors | AdminLayout |
| 20 | `/admin/pricing-tiers` | 0 errors | AdminLayout |
| 21 | `/admin/supplier-prices` | 0 errors | AdminLayout |
**Итог:** 21/21 URLs passed (7 guest + 14 authenticated).
### Fixes (commit-level) при authenticated rescan
| # | Pattern | URLs было | Fix file(s) |
|---|---|---|---|
| 1 | Mobile nav-icon `<v-app-bar-nav-icon>` без accessible name | 9 (AppLayout views) | `app/resources/js/components/layout/AppTopbar.vue``aria-label="Открыть меню навигации"` |
| 2 | `.sep` точки-разделители contrast 2.92:1 на ivory | 3 (dashboard/billing/reports) | 8 файлов с scoped `.sep { color: #6b6356 }` (было `#92907b`); 5.33:1 |
| 3 | Vuetify `.v-alert--variant-tonal .v-alert__content` contrast 4.18:1 | 2 (billing/admin-system) | `app/resources/css/app.css` — глобальный override на content text → `#0a0700` |
| 4 | Vuetify `.v-chip--variant-tonal.bg-success/warning .v-chip__content` contrast 4.25:1 / 2.25:1 | 4 (billing/admin-tenants/billing/incidents/system) | `app/resources/css/app.css` — success → `#1f5e3a`, warning → `#6a4504` |
| 5 | `.text-warning` utility (count badges «5» / «0» / «1») contrast 2.03:1 | 2 (admin/billing + admin/incidents) | `app/resources/css/app.css` — matched specificity `.v-theme--liderraForest .text-warning, .text-warning { color: #6a4504 !important }` (Vuetify selector 0,2,0 + !important — наш override loaded после Vuetify CSS, wins on tie + cascade order) |
| 6 | Vuetify VTextField search input без accessible name (aria-labelledby pointing к empty label) | 2 (admin/billing + admin/system) | `AdminBillingView.vue` + `AdminSystemView.vue``<v-text-field>` теперь имеет `label="Поиск"` prop, Vuetify рендерит floating label с правильным accessible name |
### Ignored selectors (added в этом проходе)
| Selector | Why ignored |
|---|---|
| `select[hidden]` | Vuetify VSelect рендерит hidden native `<select>` для form-submission compatibility. Не visible UX-wise, screenreader не озвучивает (hidden). Не реальная проблема. |
| `input[aria-controls^="menu-v-"]` | Vuetify VSelect/data-table-footer items-per-page combobox pattern. Имеет `aria-label="Items per page:"` но axe чейн через `aria-labelledby` к internal label фейлит. Vuetify-internal aria binding issue. |
### axe-core cross-validation (sample 3 routes via Playwright MCP)
Pa11y использует axe-core rules под капотом (WCAG2AA standard). Для подтверждения
parity запустил axe-core напрямую через Playwright MCP на 3 representative routes
(/dashboard user, /admin/billing admin, /reports form-heavy):
| Route | axe violations | Pa11y violations | Notes |
|---|---|---|---|
| /dashboard | 1 (region `.dev-index-badge`) | 0 | KNOWN_TEMP — DevIndexBadge dev-only feature, Pa11y hides via `hideElements`; axe-core finds. Production tree-shake уже работает. |
| /admin/billing | 2 (color-contrast `.dev-index-num` + region `.dev-index-badge`) | 0 | KNOWN_TEMP same |
| /reports | 0 после full hydration | 0 | Initial axe вернул `page-has-heading-one` (timing FP — Vue async hydration не закончилось до axe.run); после `wait_for 2s` — clean |
Wait/hydration race observation: axe-core injected via CDN runs synchronously
after script.onload, и Vue SPA hydration может ещё не закончиться. Для CI
integration с axe — нужно явный wait. Pa11y использует `wait: 2000` default,
поэтому не страдает этим race.
**Real production state (без DevIndexBadge dev-only):** 0 violations.
## ~~История правок baseline~~ → History
| Дата | Изменение |
|---|---|
| 2026-05-14 (morning) | Первоначальная baseline после Pa11y scope migration. 7 guest URL, 1 contrast fix в `RecoveryCodesView.vue`. |
| 2026-05-14 (evening) | Authenticated rescan: +14 routes (21 total), 6 fix patterns applied (nav-icon aria-label, .sep contrast, Vuetify tonal alert/chip overrides, .text-warning specificity, search input labels). 21/21 URLs passed. axe-core cross-validation подтверждает Pa11y findings, остаются только DevIndexBadge (dev-only) на production. |
## 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`. |