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": "Настройки",