From de4e59d9f8d2831d485992cc2cf088973d209f02 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:15:03 +0300 Subject: [PATCH] fix(web): accessibility pass to WCAG 2.1 AA (M7) An axe-core audit of every screen in both themes found low contrast on white text over the accent and on caption text, track rows using row roles without cells, and unlabelled progress bars. Accent fills now use a darker token, caption text meets 4.5:1, track lists are lists and progress bars are labelled. The queue drawer slides in from the right as designed, takes focus, closes on Escape and notes that the wave never ends. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/App.test.tsx | 2 +- apps/web/src/entities/catalog.tsx | 4 +-- apps/web/src/pages/admin/LibraryPage.tsx | 8 +++-- apps/web/src/pages/admin/ScanStatus.tsx | 6 ++-- apps/web/src/pages/home.module.css | 2 +- apps/web/src/pages/onboarding.module.css | 2 +- apps/web/src/pages/uploads/common.tsx | 6 ++-- apps/web/src/shared/ui/ui.module.css | 6 ++-- apps/web/src/styles.css | 8 +++++ apps/web/src/widgets/AppShell.module.css | 2 +- apps/web/src/widgets/PlayerBar.module.css | 10 ++++++- apps/web/src/widgets/PlayerBar.tsx | 30 +++++++++++++++---- docs/design.md | 3 +- docs/web.md | 12 ++++++++ .../design-tokens/src/generated/tokens.css | 16 +++++++--- .../design-tokens/src/generated/tokens.ts | 8 +++-- packages/design-tokens/tokens.json | 10 +++---- packages/i18n/locales/en/common.json | 3 +- packages/i18n/locales/ru/common.json | 3 +- 19 files changed, 106 insertions(+), 35 deletions(-) diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 1ac3b2f..8722b4a 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -250,7 +250,7 @@ describe('player', () => { const alice = makeUser('alice', 'user'); const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice }); - const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!; + const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!; await user.click(row); const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument()); diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index fd4168b..d584e16 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -132,7 +132,7 @@ export function TrackList({ const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); let lastDisc: number | null = null; return ( -
+
{tracks.map((tr, i) => { const disc = tr.disc_no ?? 1; const header = numbered && discs.size > 1 && disc !== lastDisc; @@ -145,7 +145,7 @@ export function TrackList({ {header &&
{t('catalog.disc', { n: disc })}
}
(v === null ? '—' : `${Math.round(v * 100)}% function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) { const value = total > 0 ? Math.round((done / total) * 100) : 0; + const id = useId(); return (
-
{label}
-
+
+ {label} +
+
diff --git a/apps/web/src/pages/admin/ScanStatus.tsx b/apps/web/src/pages/admin/ScanStatus.tsx index 9401c41..fd4f661 100644 --- a/apps/web/src/pages/admin/ScanStatus.tsx +++ b/apps/web/src/pages/admin/ScanStatus.tsx @@ -1,4 +1,5 @@ import type { components } from '@pipu/api-client'; +import { useId } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDateTime } from '../../shared/lib/format'; @@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) { /** Прогресс идущего скана или итог последнего. */ export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) { const { t, i18n } = useTranslation('admin'); + const id = useId(); if (scan?.running) { const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0; return (
-
+
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
-
+
diff --git a/apps/web/src/pages/home.module.css b/apps/web/src/pages/home.module.css index 49d6b80..3f9dbe1 100644 --- a/apps/web/src/pages/home.module.css +++ b/apps/web/src/pages/home.module.css @@ -86,7 +86,7 @@ .chipOn, .chipOn:hover { - background: var(--accent); + background: var(--accent-fill); border-color: var(--accent); color: #fff; box-shadow: var(--glow); diff --git a/apps/web/src/pages/onboarding.module.css b/apps/web/src/pages/onboarding.module.css index 2ebcb50..677e074 100644 --- a/apps/web/src/pages/onboarding.module.css +++ b/apps/web/src/pages/onboarding.module.css @@ -104,7 +104,7 @@ } .genre[aria-pressed='true'] { - background: var(--accent); + background: var(--accent-fill); border-color: var(--accent); color: #fff; box-shadow: var(--glow); diff --git a/apps/web/src/pages/uploads/common.tsx b/apps/web/src/pages/uploads/common.tsx index 2269167..5b5f940 100644 --- a/apps/web/src/pages/uploads/common.tsx +++ b/apps/web/src/pages/uploads/common.tsx @@ -1,6 +1,7 @@ import type { components } from '@pipu/api-client'; import { unwrap } from '@pipu/core'; import { useQuery } from '@tanstack/react-query'; +import { useId } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; @@ -47,18 +48,19 @@ export function useUploadQuota() { export function QuotaMeter() { const { t, i18n } = useTranslation('admin'); const quota = useUploadQuota(); + const id = useId(); if (!quota.data) return null; const q = quota.data; const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1; const fmt = (n: number) => formatBytes(n, i18n.language); return (
- + {t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })} {q.per_user_limit_bytes !== null && ` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`} -
+
diff --git a/apps/web/src/shared/ui/ui.module.css b/apps/web/src/shared/ui/ui.module.css index 2e6b870..0f1a123 100644 --- a/apps/web/src/shared/ui/ui.module.css +++ b/apps/web/src/shared/ui/ui.module.css @@ -53,13 +53,13 @@ } .primary { - background: var(--accent); + background: var(--accent-fill); color: #fff; box-shadow: var(--glow); } .primary:hover:not(:disabled) { - background: var(--accent2); + background: var(--accent-fill2); } .secondary { @@ -186,7 +186,7 @@ } .segmented button[aria-pressed='true'] { - background: var(--accent); + background: var(--accent-fill); color: #fff; } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4ba565d..a65d120 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -53,6 +53,14 @@ button { } } +/* Выезжающие справа панели (очередь, docs/design.md#4). */ +@keyframes pipuSlideIn { + from { + opacity: 0; + transform: translateX(24px); + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index 17eda87..bc3ff30 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -155,7 +155,7 @@ height: 20px; padding: 0 6px; border-radius: var(--radius-pill); - background: var(--accent); + background: var(--accent-fill); color: #fff; font-size: 11px; font-weight: 700; diff --git a/apps/web/src/widgets/PlayerBar.module.css b/apps/web/src/widgets/PlayerBar.module.css index 3750c97..56fb282 100644 --- a/apps/web/src/widgets/PlayerBar.module.css +++ b/apps/web/src/widgets/PlayerBar.module.css @@ -246,7 +246,15 @@ a.source:hover { background: var(--glass); backdrop-filter: blur(28px) saturate(1.3); box-shadow: var(--shadow); - animation: pipuIn 0.2s ease-out; + animation: pipuSlideIn 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.drawerNote { + margin: 0; + padding: 12px 18px 16px; + border-top: 1px solid var(--line); + color: var(--text3); + font-size: 13px; } .drawerIsland { diff --git a/apps/web/src/widgets/PlayerBar.tsx b/apps/web/src/widgets/PlayerBar.tsx index b7c8c2a..0e4ad5b 100644 --- a/apps/web/src/widgets/PlayerBar.tsx +++ b/apps/web/src/widgets/PlayerBar.tsx @@ -1,4 +1,4 @@ -import { useState, type CSSProperties } from 'react'; +import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import { Link } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; @@ -35,6 +35,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) { const player = usePlayer(); const st = usePlayerState(); const [queueOpen, setQueueOpen] = useState(false); + const queueButton = useRef(null); + const queueId = useId(); + const closeQueue = useCallback(() => { + setQueueOpen(false); + queueButton.current?.focus(); // фокус — туда, откуда открыли + }, []); const track = st.track; if (!track) { @@ -167,10 +173,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
- {queueOpen && setQueueOpen(false)} />} + {queueOpen && } ); } /** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */ -function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) { +function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) { const { t } = useTranslation(); const player = usePlayer(); const st = usePlayerState(); const upcoming = st.queue.slice(st.index, st.index + 40); + const close = useRef(null); + // Клавиатура: при открытии фокус в панель, Escape закрывает. + useEffect(() => { + close.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); // Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе. return createPortal( -
+ {st.wave &&

{t('player.queue_wave')}

} , document.body, ); diff --git a/docs/design.md b/docs/design.md index 9f0569c..57af943 100644 --- a/docs/design.md +++ b/docs/design.md @@ -48,8 +48,9 @@ | `elev` | `#111726` | `#FFFFFF` | Поповеры, меню | | `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении | | `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки | -| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#737F9C` | `#0A0F1E` / `#4B5675` / `#677290` | Текст: основной / вторичный / подписи | +| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#7F8BA8` | `#0A0F1E` / `#4B5675` / `#5C6785` | Текст: основной / вторичный / подписи | | `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки | +| `accent-fill` / `accent-fill2` | `#1A66E6` / `#1F6FF2` | `#1A63DB` / `#165ED6` | Заливка под белым текстом (кнопки, выбранные сегменты) и её hover — контраст ≥ 4.5:1 | | `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус | | `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) | | `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех | diff --git a/docs/web.md b/docs/web.md index c3573fe..d7ca76e 100644 --- a/docs/web.md +++ b/docs/web.md @@ -101,3 +101,15 @@ apps/web/src/ ## Доступность - Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA. + +## Доступность + +Цель — WCAG 2.1 AA. Проверено axe-core на всех экранах в тёмной и светлой темах (с играющим треком и открытой +очередью) — нарушений нет. Правила, которые держим: +- текст на заливке акцента — только `--accent-fill` (белый текст ≥ 4.5:1), вторичный текст `--text3` — ≥ 4.5:1 + на фоне и поверхностях; +- у каждого экрана есть `h1`; у кнопок-иконок — `aria-label`, у переключателей — `aria-pressed`; +- списки треков — `role="list"` / `listitem` (строка фокусируется, Enter запускает трек, лайк и ссылки — отдельные + элементы); прогресс-бары подписаны через `aria-labelledby`; +- выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку; +- `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена. diff --git a/packages/design-tokens/src/generated/tokens.css b/packages/design-tokens/src/generated/tokens.css index d892b4d..b2fe815 100644 --- a/packages/design-tokens/src/generated/tokens.css +++ b/packages/design-tokens/src/generated/tokens.css @@ -22,9 +22,11 @@ --line2: rgba(130,160,255,.18); --text: #EDF1FF; --text2: #A0ABC6; - --text3: #737F9C; + --text3: #7F8BA8; --accent: #2F80FF; --accent2: #5E9BFF; + --accent-fill: #1A66E6; + --accent-fill2: #1F6FF2; --accent-soft: rgba(47,128,255,.15); --violet: #9B7CFF; --violet-soft: rgba(124,92,255,.16); @@ -56,9 +58,11 @@ --line2: rgba(20,35,80,.17); --text: #0A0F1E; --text2: #4B5675; - --text3: #677290; + --text3: #5C6785; --accent: #1F6FF2; --accent2: #1F6FF2; + --accent-fill: #1A63DB; + --accent-fill2: #165ED6; --accent-soft: rgba(31,111,242,.10); --violet: #6A47F0; --violet-soft: rgba(106,71,240,.10); @@ -90,9 +94,11 @@ --line2: rgba(20,35,80,.17); --text: #0A0F1E; --text2: #4B5675; - --text3: #677290; + --text3: #5C6785; --accent: #1F6FF2; --accent2: #1F6FF2; + --accent-fill: #1A63DB; + --accent-fill2: #165ED6; --accent-soft: rgba(31,111,242,.10); --violet: #6A47F0; --violet-soft: rgba(106,71,240,.10); @@ -123,9 +129,11 @@ --line2: rgba(130,160,255,.18); --text: #EDF1FF; --text2: #A0ABC6; - --text3: #737F9C; + --text3: #7F8BA8; --accent: #2F80FF; --accent2: #5E9BFF; + --accent-fill: #1A66E6; + --accent-fill2: #1F6FF2; --accent-soft: rgba(47,128,255,.15); --violet: #9B7CFF; --violet-soft: rgba(124,92,255,.16); diff --git a/packages/design-tokens/src/generated/tokens.ts b/packages/design-tokens/src/generated/tokens.ts index b617204..01aad6d 100644 --- a/packages/design-tokens/src/generated/tokens.ts +++ b/packages/design-tokens/src/generated/tokens.ts @@ -13,9 +13,11 @@ export const tokens = { "line2": "rgba(130,160,255,.18)", "text": "#EDF1FF", "text2": "#A0ABC6", - "text3": "#737F9C", + "text3": "#7F8BA8", "accent": "#2F80FF", "accent2": "#5E9BFF", + "accent-fill": "#1A66E6", + "accent-fill2": "#1F6FF2", "accent-soft": "rgba(47,128,255,.15)", "violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)", @@ -42,9 +44,11 @@ export const tokens = { "line2": "rgba(20,35,80,.17)", "text": "#0A0F1E", "text2": "#4B5675", - "text3": "#677290", + "text3": "#5C6785", "accent": "#1F6FF2", "accent2": "#1F6FF2", + "accent-fill": "#1A63DB", + "accent-fill2": "#165ED6", "accent-soft": "rgba(31,111,242,.10)", "violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)", diff --git a/packages/design-tokens/tokens.json b/packages/design-tokens/tokens.json index 514f7d3..b867103 100644 --- a/packages/design-tokens/tokens.json +++ b/packages/design-tokens/tokens.json @@ -1,11 +1,11 @@ { - "$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate", + "$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate. accent-fill — заливка под белым текстом (контраст ≥ 4.5:1, WCAG AA)", "color": { "dark": { "bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236", "elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)", - "text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#737F9C", - "accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)", + "text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#7F8BA8", + "accent": "#2F80FF", "accent2": "#5E9BFF", "accent-fill": "#1A66E6", "accent-fill2": "#1F6FF2", "accent-soft": "rgba(47,128,255,.15)", "violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)", "ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)", "danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)", @@ -14,8 +14,8 @@ "light": { "bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3", "elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)", - "text": "#0A0F1E", "text2": "#4B5675", "text3": "#677290", - "accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)", + "text": "#0A0F1E", "text2": "#4B5675", "text3": "#5C6785", + "accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-fill": "#1A63DB", "accent-fill2": "#165ED6", "accent-soft": "rgba(31,111,242,.10)", "violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)", "ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)", "danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)", diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 2f2b9a6..2b14df4 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -81,7 +81,8 @@ "source_wave": "My Wave", "source_queue": "Queue", "queue": "Queue", - "source_wave_seed": "Artist wave · {{label}}" + "source_wave_seed": "Artist wave · {{label}}", + "queue_wave": "The wave never ends: next tracks are picked as you listen" }, "settings": { "title": "Settings", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 427a587..a329199 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -81,7 +81,8 @@ "source_wave": "Моя волна", "source_queue": "Очередь", "queue": "Очередь", - "source_wave_seed": "Волна по исполнителю · {{label}}" + "source_wave_seed": "Волна по исполнителю · {{label}}", + "queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания" }, "settings": { "title": "Настройки",