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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0e50bf88e
commit
de4e59d9f8
19 files changed
+106
-35
No files matched your search
@@ -250,7 +250,7 @@ describe('player', () => {
|
|||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
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);
|
await user.click(row);
|
||||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function TrackList({
|
|||||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||||
let lastDisc: number | null = null;
|
let lastDisc: number | null = null;
|
||||||
return (
|
return (
|
||||||
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
|
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
||||||
{tracks.map((tr, i) => {
|
{tracks.map((tr, i) => {
|
||||||
const disc = tr.disc_no ?? 1;
|
const disc = tr.disc_no ?? 1;
|
||||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||||
@@ -145,7 +145,7 @@ export function TrackList({
|
|||||||
<Fragment key={`${tr.id}-${i}`}>
|
<Fragment key={`${tr.id}-${i}`}>
|
||||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||||
<div
|
<div
|
||||||
role="row"
|
role="listitem"
|
||||||
tabIndex={tr.available ? 0 : -1}
|
tabIndex={tr.available ? 0 : -1}
|
||||||
aria-current={isCurrent || undefined}
|
aria-current={isCurrent || undefined}
|
||||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../../app/session';
|
import { useSession, useUser } from '../../app/session';
|
||||||
@@ -160,10 +161,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
|
|||||||
|
|
||||||
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
|
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
|
||||||
const value = total > 0 ? Math.round((done / total) * 100) : 0;
|
const value = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||||
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className={l.scan}>
|
<div className={l.scan}>
|
||||||
<div className={l.scanText}>{label}</div>
|
<div className={l.scanText} id={id}>
|
||||||
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${value}%` }} />
|
<span style={{ width: `${value}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { formatDateTime } from '../../shared/lib/format';
|
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 }) {
|
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
|
||||||
const { t, i18n } = useTranslation('admin');
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const id = useId();
|
||||||
if (scan?.running) {
|
if (scan?.running) {
|
||||||
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div className={s.scan}>
|
<div className={s.scan}>
|
||||||
<div className={s.scanText}>
|
<div className={s.scanText} id={id}>
|
||||||
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
||||||
</div>
|
</div>
|
||||||
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${pct}%` }} />
|
<span style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
.chipOn,
|
.chipOn,
|
||||||
.chipOn:hover {
|
.chipOn:hover {
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: var(--glow);
|
box-shadow: var(--glow);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genre[aria-pressed='true'] {
|
.genre[aria-pressed='true'] {
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: var(--glow);
|
box-shadow: var(--glow);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../../app/session';
|
import { useSession } from '../../app/session';
|
||||||
@@ -47,18 +48,19 @@ export function useUploadQuota() {
|
|||||||
export function QuotaMeter() {
|
export function QuotaMeter() {
|
||||||
const { t, i18n } = useTranslation('admin');
|
const { t, i18n } = useTranslation('admin');
|
||||||
const quota = useUploadQuota();
|
const quota = useUploadQuota();
|
||||||
|
const id = useId();
|
||||||
if (!quota.data) return null;
|
if (!quota.data) return null;
|
||||||
const q = quota.data;
|
const q = quota.data;
|
||||||
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
||||||
const fmt = (n: number) => formatBytes(n, i18n.language);
|
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||||
return (
|
return (
|
||||||
<div className={u.quota}>
|
<div className={u.quota}>
|
||||||
<span className={u.quotaText}>
|
<span className={u.quotaText} id={id}>
|
||||||
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
||||||
{q.per_user_limit_bytes !== null &&
|
{q.per_user_limit_bytes !== null &&
|
||||||
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
||||||
</span>
|
</span>
|
||||||
<div className={u.progress} role="progressbar" aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${ratio * 100}%` }} />
|
<span style={{ width: `${ratio * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,13 +53,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: var(--glow);
|
box-shadow: var(--glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary:hover:not(:disabled) {
|
.primary:hover:not(:disabled) {
|
||||||
background: var(--accent2);
|
background: var(--accent-fill2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.segmented button[aria-pressed='true'] {
|
.segmented button[aria-pressed='true'] {
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Выезжающие справа панели (очередь, docs/design.md#4). */
|
||||||
|
@keyframes pipuSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -246,7 +246,15 @@ a.source:hover {
|
|||||||
background: var(--glass);
|
background: var(--glass);
|
||||||
backdrop-filter: blur(28px) saturate(1.3);
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
box-shadow: var(--shadow);
|
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 {
|
.drawerIsland {
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -35,6 +35,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const queueButton = useRef<HTMLButtonElement>(null);
|
||||||
|
const queueId = useId();
|
||||||
|
const closeQueue = useCallback(() => {
|
||||||
|
setQueueOpen(false);
|
||||||
|
queueButton.current?.focus(); // фокус — туда, откуда открыли
|
||||||
|
}, []);
|
||||||
const track = st.track;
|
const track = st.track;
|
||||||
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
@@ -167,10 +173,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
ref={queueButton}
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.icon, queueOpen && s.accent)}
|
className={cx(s.icon, queueOpen && s.accent)}
|
||||||
aria-label={t('player.queue')}
|
aria-label={t('player.queue')}
|
||||||
aria-expanded={queueOpen}
|
aria-expanded={queueOpen}
|
||||||
|
aria-controls={queueOpen ? queueId : undefined}
|
||||||
onClick={() => setQueueOpen((v) => !v)}
|
onClick={() => setQueueOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<Icon src={queueIcon} size={20} />
|
<Icon src={queueIcon} size={20} />
|
||||||
@@ -195,23 +203,33 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
|
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||||||
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 { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const upcoming = st.queue.slice(st.index, st.index + 40);
|
const upcoming = st.queue.slice(st.index, st.index + 40);
|
||||||
|
const close = useRef<HTMLButtonElement>(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-потомков относительными к себе.
|
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||||
<div className={s.drawerHead}>
|
<div className={s.drawerHead}>
|
||||||
<span>{t('player.queue')}</span>
|
<span>{t('player.queue')}</span>
|
||||||
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||||
<Icon src={closeIcon} size={20} />
|
<Icon src={closeIcon} size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,6 +239,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
|
|||||||
key={`${tr.id}-${k}`}
|
key={`${tr.id}-${k}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
||||||
|
aria-current={k === 0 || undefined}
|
||||||
onClick={() => player.jump(st.index + k)}
|
onClick={() => player.jump(st.index + k)}
|
||||||
>
|
>
|
||||||
<Cover album={tr.album} size={40} />
|
<Cover album={tr.album} size={40} />
|
||||||
@@ -232,6 +251,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
||||||
</aside>,
|
</aside>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-1
@@ -48,8 +48,9 @@
|
|||||||
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
|
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
|
||||||
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
|
| `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` | Разделители / рамки |
|
| `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` / `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)` | Активный пункт меню, фокус |
|
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
|
||||||
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
|
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
|
||||||
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
|
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
|
||||||
|
|||||||
+12
@@ -101,3 +101,15 @@ apps/web/src/
|
|||||||
|
|
||||||
## Доступность
|
## Доступность
|
||||||
- Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA.
|
- Вся навигация с клавиатуры, видимый фокус, 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 выключены, волна замедлена.
|
||||||
@@ -22,9 +22,11 @@
|
|||||||
--line2: rgba(130,160,255,.18);
|
--line2: rgba(130,160,255,.18);
|
||||||
--text: #EDF1FF;
|
--text: #EDF1FF;
|
||||||
--text2: #A0ABC6;
|
--text2: #A0ABC6;
|
||||||
--text3: #737F9C;
|
--text3: #7F8BA8;
|
||||||
--accent: #2F80FF;
|
--accent: #2F80FF;
|
||||||
--accent2: #5E9BFF;
|
--accent2: #5E9BFF;
|
||||||
|
--accent-fill: #1A66E6;
|
||||||
|
--accent-fill2: #1F6FF2;
|
||||||
--accent-soft: rgba(47,128,255,.15);
|
--accent-soft: rgba(47,128,255,.15);
|
||||||
--violet: #9B7CFF;
|
--violet: #9B7CFF;
|
||||||
--violet-soft: rgba(124,92,255,.16);
|
--violet-soft: rgba(124,92,255,.16);
|
||||||
@@ -56,9 +58,11 @@
|
|||||||
--line2: rgba(20,35,80,.17);
|
--line2: rgba(20,35,80,.17);
|
||||||
--text: #0A0F1E;
|
--text: #0A0F1E;
|
||||||
--text2: #4B5675;
|
--text2: #4B5675;
|
||||||
--text3: #677290;
|
--text3: #5C6785;
|
||||||
--accent: #1F6FF2;
|
--accent: #1F6FF2;
|
||||||
--accent2: #1F6FF2;
|
--accent2: #1F6FF2;
|
||||||
|
--accent-fill: #1A63DB;
|
||||||
|
--accent-fill2: #165ED6;
|
||||||
--accent-soft: rgba(31,111,242,.10);
|
--accent-soft: rgba(31,111,242,.10);
|
||||||
--violet: #6A47F0;
|
--violet: #6A47F0;
|
||||||
--violet-soft: rgba(106,71,240,.10);
|
--violet-soft: rgba(106,71,240,.10);
|
||||||
@@ -90,9 +94,11 @@
|
|||||||
--line2: rgba(20,35,80,.17);
|
--line2: rgba(20,35,80,.17);
|
||||||
--text: #0A0F1E;
|
--text: #0A0F1E;
|
||||||
--text2: #4B5675;
|
--text2: #4B5675;
|
||||||
--text3: #677290;
|
--text3: #5C6785;
|
||||||
--accent: #1F6FF2;
|
--accent: #1F6FF2;
|
||||||
--accent2: #1F6FF2;
|
--accent2: #1F6FF2;
|
||||||
|
--accent-fill: #1A63DB;
|
||||||
|
--accent-fill2: #165ED6;
|
||||||
--accent-soft: rgba(31,111,242,.10);
|
--accent-soft: rgba(31,111,242,.10);
|
||||||
--violet: #6A47F0;
|
--violet: #6A47F0;
|
||||||
--violet-soft: rgba(106,71,240,.10);
|
--violet-soft: rgba(106,71,240,.10);
|
||||||
@@ -123,9 +129,11 @@
|
|||||||
--line2: rgba(130,160,255,.18);
|
--line2: rgba(130,160,255,.18);
|
||||||
--text: #EDF1FF;
|
--text: #EDF1FF;
|
||||||
--text2: #A0ABC6;
|
--text2: #A0ABC6;
|
||||||
--text3: #737F9C;
|
--text3: #7F8BA8;
|
||||||
--accent: #2F80FF;
|
--accent: #2F80FF;
|
||||||
--accent2: #5E9BFF;
|
--accent2: #5E9BFF;
|
||||||
|
--accent-fill: #1A66E6;
|
||||||
|
--accent-fill2: #1F6FF2;
|
||||||
--accent-soft: rgba(47,128,255,.15);
|
--accent-soft: rgba(47,128,255,.15);
|
||||||
--violet: #9B7CFF;
|
--violet: #9B7CFF;
|
||||||
--violet-soft: rgba(124,92,255,.16);
|
--violet-soft: rgba(124,92,255,.16);
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ export const tokens = {
|
|||||||
"line2": "rgba(130,160,255,.18)",
|
"line2": "rgba(130,160,255,.18)",
|
||||||
"text": "#EDF1FF",
|
"text": "#EDF1FF",
|
||||||
"text2": "#A0ABC6",
|
"text2": "#A0ABC6",
|
||||||
"text3": "#737F9C",
|
"text3": "#7F8BA8",
|
||||||
"accent": "#2F80FF",
|
"accent": "#2F80FF",
|
||||||
"accent2": "#5E9BFF",
|
"accent2": "#5E9BFF",
|
||||||
|
"accent-fill": "#1A66E6",
|
||||||
|
"accent-fill2": "#1F6FF2",
|
||||||
"accent-soft": "rgba(47,128,255,.15)",
|
"accent-soft": "rgba(47,128,255,.15)",
|
||||||
"violet": "#9B7CFF",
|
"violet": "#9B7CFF",
|
||||||
"violet-soft": "rgba(124,92,255,.16)",
|
"violet-soft": "rgba(124,92,255,.16)",
|
||||||
@@ -42,9 +44,11 @@ export const tokens = {
|
|||||||
"line2": "rgba(20,35,80,.17)",
|
"line2": "rgba(20,35,80,.17)",
|
||||||
"text": "#0A0F1E",
|
"text": "#0A0F1E",
|
||||||
"text2": "#4B5675",
|
"text2": "#4B5675",
|
||||||
"text3": "#677290",
|
"text3": "#5C6785",
|
||||||
"accent": "#1F6FF2",
|
"accent": "#1F6FF2",
|
||||||
"accent2": "#1F6FF2",
|
"accent2": "#1F6FF2",
|
||||||
|
"accent-fill": "#1A63DB",
|
||||||
|
"accent-fill2": "#165ED6",
|
||||||
"accent-soft": "rgba(31,111,242,.10)",
|
"accent-soft": "rgba(31,111,242,.10)",
|
||||||
"violet": "#6A47F0",
|
"violet": "#6A47F0",
|
||||||
"violet-soft": "rgba(106,71,240,.10)",
|
"violet-soft": "rgba(106,71,240,.10)",
|
||||||
|
|||||||
@@ -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": {
|
"color": {
|
||||||
"dark": {
|
"dark": {
|
||||||
"bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236",
|
"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)",
|
"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",
|
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#7F8BA8",
|
||||||
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)",
|
"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)",
|
"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)",
|
"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)",
|
"danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)",
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
"light": {
|
"light": {
|
||||||
"bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3",
|
"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)",
|
"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",
|
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#5C6785",
|
||||||
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)",
|
"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)",
|
"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)",
|
"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)",
|
"danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)",
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
"source_wave": "My Wave",
|
"source_wave": "My Wave",
|
||||||
"source_queue": "Queue",
|
"source_queue": "Queue",
|
||||||
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
"source_wave": "Моя волна",
|
"source_wave": "Моя волна",
|
||||||
"source_queue": "Очередь",
|
"source_queue": "Очередь",
|
||||||
"queue": "Очередь",
|
"queue": "Очередь",
|
||||||
"source_wave_seed": "Волна по исполнителю · {{label}}"
|
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||||
|
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
|
|||||||
Reference in new issue
Block a user