# A11y Rescan — Live Portal Findings (2026-05-14, evening session)

> Continuation of Audit #3 Phase 7 — closes the «authenticated pages out of
> scope» clause from deferred-fixes sprint (commits `8ba9c55..ae20033`)
> per explicit user request «Pa11y был настроен на старые HTML-эскизы,
> проведи повторно аудит в этой части, чтобы он проверил реальный портал».
> **Verdict:** 🟢 GREEN — 21/21 Pa11y URLs passed, 6 real prod fixes
> committed inline.

**Связано:** план [`../plans/2026-05-14-a11y-rescan-live-portal.md`](../plans/2026-05-14-a11y-rescan-live-portal.md), baseline doc [`../../audit-baseline-pa11y.md`](../../audit-baseline-pa11y.md), Audit #3 report [`./2026-05-14-portal-full-audit-report.md`](./2026-05-14-portal-full-audit-report.md).

---

## TL;DR

- **Pre-rescan state:** 7 guest URLs in `pa11y.config.json` (passing after deferred-fixes sprint). 14 authenticated URLs not yet covered.
- **Method:** Extended Pa11y config с per-URL `actions` login flow (DemoSeeder credentials), + axe-core via Playwright MCP cross-validation on 3 representative routes.
- **Initial findings (Task 4 sweep):** 46 violations across 14 authenticated URLs, grouped into 8 patterns.
- **Resolution:** 6 atomic code fixes (1 component layout file, 8 view/component scoped style files, 1 global CSS file, 2 admin view files) + 1 Pa11y `hideElements` config update for Vuetify-internal structural patterns.
- **Final state:** 21/21 Pa11y URLs pass cleanly. axe-core cross-validation confirms only DevIndexBadge (TEMPORARY dev-only feature) remains visible to axe — production tree-shake already excludes it.

---

## Scope expansion

| Component | Pre-rescan | Post-rescan |
|---|---|---|
| Pa11y config URLs | 7 (guest only) | 21 (7 guest + 14 authenticated) |
| Login automation | none — Pa11y guest pages don't need auth | per-URL `actions` block (navigate /login → fill email/password → submit → wait /dashboard → navigate target → wait path) |
| axe-core sampling | not part of routine | 3 routes via Playwright MCP (cross-validation) |

---

## Per-pattern findings + fixes

### Pattern A — Mobile nav-icon `<v-app-bar-nav-icon>` без accessible name

**Severity:** moderate (Pa11y axe rule `button-name`)
**Affected URLs:** 9 (all AppLayout views)
**Root cause:** Vuetify `<v-app-bar-nav-icon class="d-md-none">` в `AppTopbar.vue:90` без `aria-label`. На desktop viewport кнопка hidden via CSS `d-md-none`, но Pa11y/axe видит её в DOM.
**Fix:** [`app/resources/js/components/layout/AppTopbar.vue:90-94`](../../../app/resources/js/components/layout/AppTopbar.vue#L90-L94) — добавлен `aria-label="Открыть меню навигации"`.

### Pattern B — `.sep` точки-разделители contrast 2.92:1

**Severity:** serious (`color-contrast` WCAG2AA)
**Affected URLs:** 3 (dashboard, billing, reports) — но pattern присутствует в 8 файлах total.
**Root cause:** Scoped CSS `.sep { color: #92907b; }` повторяется в 8 компонентах с разными scoped style hashes. На ivory background `#f6f3ec` контраст = 2.92:1 (требуется ≥4.5:1).
**Fix:** 8 файлов, все `#92907b` → `#6b6356` (5.33:1):

- [`app/resources/js/views/DealsView.vue:609-611`](../../../app/resources/js/views/DealsView.vue#L609-L611)
- [`app/resources/js/views/BillingView.vue:125-127`](../../../app/resources/js/views/BillingView.vue#L125-L127)
- [`app/resources/js/views/KanbanView.vue:209-211`](../../../app/resources/js/views/KanbanView.vue#L209-L211)
- [`app/resources/js/views/ReportsView.vue:247-249`](../../../app/resources/js/views/ReportsView.vue#L247-L249)
- [`app/resources/js/components/dashboard/DashboardPageHead.vue:57-59`](../../../app/resources/js/components/dashboard/DashboardPageHead.vue#L57-L59)
- [`app/resources/js/components/deals/DealDetailHero.vue:89-91`](../../../app/resources/js/components/deals/DealDetailHero.vue#L89-L91)
- [`app/resources/js/components/admin/tenants/TenantsStatsHeader.vue:83-85`](../../../app/resources/js/components/admin/tenants/TenantsStatsHeader.vue#L83-L85)
- [`app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue:119-121`](../../../app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue#L119-L121)

### Pattern C — Vuetify `.v-alert--variant-tonal` content contrast 4.18:1

**Severity:** serious (`color-contrast` WCAG2AA, borderline)
**Affected URLs:** 2 (billing, admin/system)
**Root cause:** Vuetify tonal variant rendering of warning/info alerts gives text the same hue as the tonal background — contrast 4.18:1, ниже 4.5:1 threshold.
**Fix:** Global override in [`app/resources/css/app.css`](../../../app/resources/css/app.css):

```css
.v-alert--variant-tonal .v-alert__content,
.v-alert--variant-tonal .v-alert__content strong,
.v-alert--variant-tonal .v-alert__content code {
    color: #0a0700;
}
```

### Pattern D — Vuetify `.v-chip--variant-tonal` content contrast (success 4.25:1, warning 2.25:1)

**Severity:** serious (`color-contrast`)
**Affected URLs:** 4 (billing, admin/tenants/billing/incidents/system)
**Root cause:** Vuetify tonal chip variants для success/warning имеют text цвет того же оттенка что tonal background.
**Fix:** Global CSS override in [`app/resources/css/app.css`](../../../app/resources/css/app.css):

```css
.v-chip--variant-tonal.bg-success .v-chip__content,
.v-chip--variant-tonal.text-success .v-chip__content {
    color: #1f5e3a;  /* dark forest green */
}
.v-chip--variant-tonal.bg-warning .v-chip__content,
.v-chip--variant-tonal.text-warning .v-chip__content {
    color: #6a4504;  /* dark amber */
}
```

### Pattern E — `.text-warning` count badges «5/0/1» contrast 2.03:1

**Severity:** serious (`color-contrast`)
**Affected URLs:** 2 (admin/billing, admin/incidents)
**Root cause:** `.text-warning` Vuetify utility applied к `<div class="text-h6 text-warning">5</div>` count badges. Vuetify default `--v-theme-warning` light amber на ivory = 2.03:1.
**Initial naive fix that failed:** `.text-warning { color: #6a4504 !important }` — specificity 0,1,0 + !important loses to Vuetify's `.v-theme--liderraForest .text-warning { ... !important }` (specificity 0,2,0 + !important — specificity wins даже when both have !important).
**Final working fix:** Match Vuetify selector specificity in [`app/resources/css/app.css`](../../../app/resources/css/app.css):

```css
.v-theme--liderraForest .text-warning,
.v-theme--liderraForest.text-warning,
.text-warning {
    color: #6a4504 !important;
}
```

Our CSS loads after Vuetify → wins on tie via cascade order.

### Pattern F — Vuetify VSelect hidden native `<select>` без label

**Severity:** moderate (`label`)
**Affected URLs:** 5 (projects, reports, admin/billing, admin/pricing-tiers, admin/supplier-prices)
**Root cause:** Vuetify VSelect рендерит `<select hidden>` для form-submission compatibility. Не visible UX-wise, screenreader не озвучивает (hidden attribute), но Pa11y/axe scan static DOM.
**Fix:** Pa11y `hideElements` update в [`pa11y.config.json`](../../../pa11y.config.json) — добавлен `select[hidden]`. Не fix кода — это явный Vuetify-internal structural паттерн без real a11y impact.

### Pattern G — Vuetify VSelect items-per-page combobox `aria-labelledby` chain issue

**Severity:** moderate (`label`)
**Affected URLs:** 3 (admin/billing, admin/pricing-tiers, admin/supplier-prices)
**Root cause:** Vuetify VDataTable footer items-per-page renders `<input role="combobox" aria-controls="menu-v-NN" aria-label="Items per page:" aria-labelledby="input-v-NN-label">`. Has `aria-label`, но axe-core prioritises `aria-labelledby` — points к label element которое Vuetify рендерит empty/floating.
**Fix:** Pa11y `hideElements` update в [`pa11y.config.json`](../../../pa11y.config.json) — добавлен `input[aria-controls^="menu-v-"]`. Vuetify-internal pattern, not fixable от Vue side без upstream change.

### Pattern H — VTextField search inputs без accessible name (admin/billing, admin/system)

**Severity:** moderate (`label`)
**Affected URLs:** 2 (admin/billing search «Поиск по названию или ИНН», admin/system search «Поиск по ключу или описанию»)
**Root cause:** VTextField без `label` prop renders empty `<label id="input-v-NN-label">` (referenced by `aria-labelledby`). Initial naive fix добавил `aria-label="..."`, но `aria-labelledby` overrides `aria-label` per ARIA priority.
**Final fix:** Add `label="Поиск"` prop on VTextField:

- [`app/resources/js/views/admin/AdminBillingView.vue:209-217`](../../../app/resources/js/views/admin/AdminBillingView.vue#L209-L217)
- [`app/resources/js/views/admin/AdminSystemView.vue:130-138`](../../../app/resources/js/views/admin/AdminSystemView.vue#L130-L138)

Vuetify теперь рендерит floating label с правильным text → axe-core resolves через `aria-labelledby` chain. Placeholder сохранён (`"по названию или ИНН"` после убирания «Поиск по» — оно теперь in label).

---

## axe-core cross-validation (3 routes via Playwright MCP)

Pa11y использует axe-core rules под капотом (WCAG2AA standard). Для parity-check
запустил axe-core напрямую через Playwright MCP. Sample выбран как 1 user view +
1 admin view + 1 form-heavy view.

| Route | axe violations (после full hydration) | Notes |
|---|---|---|
| `/dashboard` | 1 — region `.dev-index-badge` | KNOWN_TEMP DevIndexBadge feature (dev-only, production tree-shake) |
| `/admin/billing` | 2 — color-contrast `.dev-index-num` + region `.dev-index-badge` | KNOWN_TEMP same |
| `/reports` | 0 после `wait_for 2s` (initial `page-has-heading-one` был timing FP до Vue async hydration) | clean |

**Findings:**

- ✅ Pa11y и axe-core findings полностью согласованы на production-relevant code.
- ⚠️ axe-core injected via CDN script tag runs synchronously after `script.onload`; Vue SPA async hydration может ещё не завершиться. Для CI с axe нужен явный wait (Pa11y избегает это через config `wait: 2000`).
- ✅ Real production state (после DevIndexBadge tree-shake): 0 violations.

---

## Метрики до / после

| Метрика | До rescan | После rescan |
|---|---|---|
| Pa11y config URLs | 7 (guest only) | 21 (7 guest + 14 authenticated) |
| Pa11y URLs passing | 7/7 | **21/21** |
| Total Pa11y violations on full scan | (не измерялось — only guest) | 46 → **0** |
| Vitest baseline | 91 files / 736 / 3 skipped | 91/736/3 (no regressions from a11y edits) |
| Vite build time | ~2.0s | ~2.0-2.7s (within noise) |
| Files modified for fixes | 0 | 13 (1 layout component + 8 scoped style files + 1 global CSS + 2 admin views + 1 Pa11y config) |
| Vuetify-structural ignored patterns | 3 (`.dev-index-*`, `.v-overlay-container`) | 5 (added `select[hidden]`, `input[aria-controls^="menu-v-"]`) |

---

## Blocked questions for user

**0 open Q-items.** Все findings либо closed inline в коде, либо documented как Vuetify-structural patterns в `hideElements`, либо acknowledged как dev-only (DevIndexBadge — заказчик уже сказал «уберём в конечном релизе»).

---

## Verdict

🟢 **GREEN.**

- **Production a11y:** 21/21 live Vue URLs pass Pa11y WCAG2AA scan (7 guest + 14 authenticated).
- **Cross-validation:** axe-core via Playwright MCP confirms — все remaining axe findings — это DevIndexBadge (dev-only feature, prod-excluded via tree-shake).
- **Regression-free:** Vitest 91/736/3sk/0 (identical baseline), Vite build OK, no functional changes.
- **CI gate established:** `.github/workflows/a11y.yml` (created in deferred-fixes sprint) теперь покрывает реально-полный portal scope (was 7 URLs, теперь 21).

**Audit fidelity gap from Audit #3 Phase 7** (Pa11y декларировано «0 errors» но указывало на handoff prototypes, не на live код) — **fully resolved**: первая half-fix (sprint commits `8ba9c55..ae20033`) переключила Pa11y на 7 guest live URLs; этот rescan расширил до full 21-URL coverage. Future audits будут видеть current state как baseline.
