feat(robustness+ui): отказоустойчивость main, тесты, a11y-полировка, лицензия

Надёжность main-процесса:
- глобальные uncaughtException/unhandledRejection (лог + flushNow)
- safeHandle/safeOn вокруг всех IPC-хендлеров (не падаем молча, generic-ошибка наружу)
- таймаут 4s на tasklist, Atomics.wait вместо busy-spin на exit-записи
- единый log.error для фоновых сбоев вместо console.error/тишины

Тесты (178 -> 203): meeting-detect, scheduler-gating, store (миграции/карантин/cap).

UI/UX:
- prefers-reduced-motion через MotionConfig + CSS media-блок
- Spinner/Skeleton примитивы, loading-состояния вместо пустых заглушек
- aria-live анонсы достижений и выполнения (useAnnounce)
- оформленные пустые состояния, клавиатура в меню ExerciseCard

Лицензия: проприетарный LICENSE + правка README/CLAUDE.md, счётчик тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-29 13:23:53 +07:00
parent 8a62ebc9fe
commit 46b3d59b66
24 changed files with 979 additions and 98 deletions

View File

@@ -5,6 +5,7 @@ import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import { WhatsNewModal } from './components/WhatsNewModal'
import { Skeleton } from './components/ui/Skeleton'
import { unseenVersions } from '@shared/release-notes'
import Dashboard from './pages/Dashboard'
import Exercises from './pages/Exercises'
@@ -100,8 +101,23 @@ export default function App(): JSX.Element {
<RoutedPages onNav={() => setMobileNavOpen(false)} />
</ErrorBoundary>
) : (
// Neutral placeholder — settings (and lang) aren't loaded yet.
<div className="p-8 text-text/45" />
// Skeleton на время гидрации — settings (и язык) ещё не
// загружены, текст показывать рано, но пустота выглядит как
// зависание. Каркас задаёт ожидание «сейчас появится контент».
<div
className="p-6 sm:p-8 space-y-5 max-w-3xl"
role="status"
aria-label="Loading"
>
<Skeleton className="h-9 w-48" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-40" />
<Skeleton className="h-28" />
</div>
)}
</main>
</div>

View File

@@ -7,6 +7,7 @@ import {
type AchievementProgress
} from '../lib/achievements'
import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
const CELEBRATED_KEY = 'laude:celebratedAchievements'
@@ -48,6 +49,7 @@ type Props = {
*/
export function AchievementsCard({ history, exercises }: Props): JSX.Element {
const { t } = useT()
const announce = useAnnounce()
const achievements = useMemo(
() => computeAchievements(history, exercises),
@@ -73,11 +75,19 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
if (fresh.size > 0) {
setFreshlyUnlocked(fresh)
saveCelebrated(celebrated)
// Озвучиваем разблокировку для screen-reader'ов — pulse-анимацию они
// не видят.
for (const a of achievements) {
if (fresh.has(a.def.id)) {
announce(t('achievements.announce', { title: t(a.def.titleKey) }))
}
}
// Снимаем «свежесть» через 5 сек чтобы pulse не крутился вечно.
const t = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000)
return () => clearTimeout(t)
const timer = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000)
return () => clearTimeout(timer)
}
return undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [achievements])
const unlocked = achievements.filter((a) => a.unlocked)

View File

@@ -1,11 +1,12 @@
import { motion } from 'framer-motion'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
import { formatCountdown } from '../lib/format'
import { Switch } from './ui/Switch'
import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
type Props = {
exercise: Exercise
@@ -43,21 +44,64 @@ export function ExerciseCard({
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// При открытии меню переводим фокус на первый пункт — клавиатурный
// пользователь сразу внутри, без слепого Tab'а.
useEffect(() => {
if (!menuOpen) return
menuRef.current
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
?.focus()
}, [menuOpen])
// Esc закрывает и возвращает фокус на триггер; стрелки/Home/End — навигация.
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]'
) ?? []
)
if (items.length === 0) return
const idx = items.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
e.preventDefault()
setMenuOpen(false)
triggerRef.current?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
items[(idx + 1) % items.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
items[(idx - 1 + items.length) % items.length]?.focus()
} else if (e.key === 'Home') {
e.preventDefault()
items[0]?.focus()
} else if (e.key === 'End') {
e.preventDefault()
items[items.length - 1]?.focus()
}
}
// Дедуп rapid double-click на «Готово». Между кликом и обновлением
// nextFireAt (через broadcastState) есть окно ~1 сек, в которое можно
// вызвать markDone повторно и записать лишний entry в историю.
const markDoneInFlightRef = useRef(false)
const { t, lang } = useT()
const announce = useAnnounce()
const handleMarkDone = (): void => {
if (markDoneInFlightRef.current) return
markDoneInFlightRef.current = true
onMarkDone()
// Озвучиваем для screen-reader'ов — кнопка после засчёта исчезает,
// визуальный feedback незрячему недоступен.
announce(`${t('btn.done')}: ${exercise.name}`)
// К моменту окончания таймаута isDue уже false (после store-tick), кнопка
// не рендерится — флаг чистим на всякий случай для будущих кейсов.
setTimeout(() => {
markDoneInFlightRef.current = false
}, 1000)
}
const { t, lang } = useT()
// Ring math
const R = 22
@@ -124,9 +168,12 @@ export function ExerciseCard({
</h3>
<div className="relative">
<button
ref={triggerRef}
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
aria-label={t('titlebar.menu_aria')}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<MoreHorizontal size={16} />
</button>
@@ -136,8 +183,15 @@ export function ExerciseCard({
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden">
<div
ref={menuRef}
role="menu"
aria-label={exercise.name}
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden"
>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onEdit()
@@ -147,6 +201,7 @@ export function ExerciseCard({
{t('btn.edit')}
</button>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onDelete()

View File

@@ -0,0 +1,21 @@
type Props = {
className?: string
}
/**
* Placeholder-блок на время загрузки данных. `aria-hidden` — это чисто
* визуальный шум, screen-reader'у он не нужен (рядом обычно есть role="status"
* или контент появится сам). Пульсация через Tailwind `animate-pulse`
* (гасится при prefers-reduced-motion — см. globals.css).
*/
export function Skeleton({ className = '' }: Props): JSX.Element {
return (
<div
aria-hidden="true"
className={[
'animate-pulse rounded-2xl bg-surface-2/70 dark:bg-surface-2',
className
].join(' ')}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { Loader2 } from 'lucide-react'
type Props = {
size?: number
className?: string
/** Подпись для screen-reader'ов. По умолчанию нейтральное «Loading». */
label?: string
}
/**
* Индикатор асинхронной операции. `role="status"` + aria-label делают его
* слышимым для screen-reader'ов. Вращение через Tailwind `animate-spin`
* (замедляется, но не выключается при prefers-reduced-motion — см. globals.css).
*/
export function Spinner({
size = 16,
className = '',
label = 'Loading'
}: Props): JSX.Element {
return (
<Loader2
size={size}
strokeWidth={2.4}
role="status"
aria-label={label}
className={['animate-spin', className].join(' ')}
/>
)
}

View File

@@ -250,6 +250,7 @@ export const ru: Dict = {
'achievements.title': 'Достижения',
'achievements.unlocked_of': '{n} из {total}',
'achievements.progress': 'осталось {n}',
'achievements.announce': 'Достижение получено: {title}',
'achievement.reps.desc': 'Сделай {target} повторений всего',
'achievement.reps_100.title': 'Сотня',
'achievement.reps_500.title': 'Пятьсот',
@@ -581,6 +582,7 @@ export const en: Dict = {
'achievements.title': 'Achievements',
'achievements.unlocked_of': '{n} of {total}',
'achievements.progress': '{n} to go',
'achievements.announce': 'Achievement unlocked: {title}',
'achievement.reps.desc': '{target} reps total',
'achievement.reps_100.title': 'Century',
'achievement.reps_500.title': 'Five hundred',

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react'
/**
* Озвучивание событий для screen-reader'ов через единый скрытый
* aria-live регион. Анимации/тосты видят зрячие; незрячим нужно явное
* сообщение — иначе «достижение разблокировано» или «упражнение засчитано»
* проходят молча.
*
* Регион — module-level singleton (один на окно), создаётся лениво и живёт
* до закрытия окна. Так не нужен провайдер в дереве компонентов.
*/
let region: HTMLElement | null = null
function ensureRegion(): HTMLElement {
if (region && document.body.contains(region)) return region
const el = document.createElement('div')
el.setAttribute('aria-live', 'polite')
el.setAttribute('aria-atomic', 'true')
el.setAttribute('role', 'status')
// Визуально скрыто, но доступно для screen-reader'ов (sr-only pattern).
Object.assign(el.style, {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: '0',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
border: '0'
})
document.body.appendChild(el)
region = el
return el
}
export function useAnnounce(): (message: string) => void {
return useCallback((message: string) => {
if (!message) return
const el = ensureRegion()
// Сброс перед записью: screen-reader игнорирует повторную установку
// идентичного текста. Разносим очистку и значение по разным кадрам,
// чтобы изменение точно зарегистрировалось.
el.textContent = ''
requestAnimationFrame(() => {
el.textContent = message
})
}, [])
}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { MotionConfig } from 'framer-motion'
import './styles/globals.css'
import App from './App'
import ReminderApp from './ReminderApp'
@@ -8,10 +9,15 @@ import { ThemeProvider } from './providers/ThemeProvider'
const params = new URLSearchParams(window.location.search)
const which = params.get('window') ?? 'main'
// reducedMotion="user" — framer-motion сам читает системную настройку
// «уменьшить движение» и глушит transform/layout-анимации (оставляя opacity).
// Один источник истины для обоих окон и всех motion-компонентов.
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
{which === 'reminder' ? <ReminderApp /> : <App />}
</ThemeProvider>
<MotionConfig reducedMotion="user">
<ThemeProvider>
{which === 'reminder' ? <ReminderApp /> : <App />}
</ThemeProvider>
</MotionConfig>
</React.StrictMode>
)

View File

@@ -139,8 +139,13 @@ export default function ChallengesPage(): JSX.Element {
</>
) : (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
{t('challenges.empty')}
<div className="px-5 py-12 flex flex-col items-center text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Gamepad2 size={24} strokeWidth={2.4} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('challenges.empty')}
</div>
</div>
</Card>
)}

View File

@@ -93,8 +93,13 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
{t('exercises.empty')}
<div className="px-5 py-12 flex flex-col items-center text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('exercises.empty')}
</div>
</div>
</Card>
)}

View File

@@ -11,6 +11,7 @@ import {
import { motion } from 'framer-motion'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Spinner } from '../components/ui/Spinner'
import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types'
import { useT } from '../i18n'
@@ -104,7 +105,11 @@ export default function GamesPage(): JSX.Element {
))}
{games.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
<div
className="px-5 py-12 flex flex-col items-center gap-3 text-center text-text/55 text-[14px]"
role="status"
>
<Spinner size={22} className="text-accent" label={t('games.scanning')} />
{t('games.scanning')}
</div>
</Card>
@@ -201,7 +206,12 @@ function GameCard({
<div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy} size="sm">
<Download size={14} strokeWidth={2.5} /> {t('btn.connect')}
{busy ? (
<Spinner size={14} />
) : (
<Download size={14} strokeWidth={2.5} />
)}{' '}
{t('btn.connect')}
</Button>
)}
{game.integrationActive && (
@@ -211,7 +221,8 @@ function GameCard({
disabled={busy}
size="sm"
>
<Trash2 size={14} strokeWidth={2.5} /> {t('btn.disconnect')}
{busy ? <Spinner size={14} /> : <Trash2 size={14} strokeWidth={2.5} />}{' '}
{t('btn.disconnect')}
</Button>
)}
{!game.installed && (

View File

@@ -5,6 +5,8 @@ import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard'
import { WhatsNewModal } from '../components/WhatsNewModal'
import { ConfirmModal } from '../components/ui/ConfirmModal'
import { Skeleton } from '../components/ui/Skeleton'
import { Spinner } from '../components/ui/Spinner'
import { RELEASE_NOTES } from '@shared/release-notes'
import { useT } from '../i18n'
import type {
@@ -19,7 +21,18 @@ export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
const { t } = useT()
if (!settings)
return <div className="p-8 text-text/45">{t('settings.loading')}</div>
return (
<div
className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
role="status"
aria-label={t('settings.loading')}
>
<Skeleton className="h-10 w-56" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
)
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
@@ -244,7 +257,9 @@ function AboutCard(): JSX.Element {
function DataCard(): JSX.Element {
const { t } = useT()
const [busy, setBusy] = useState(false)
// Какая операция сейчас идёт — чтобы крутить спиннер на нужной кнопке,
// а не на обеих сразу.
const [busy, setBusy] = useState<'export' | 'import' | null>(null)
const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
@@ -256,7 +271,7 @@ function DataCard(): JSX.Element {
}, [toast])
async function onExport(): Promise<void> {
setBusy(true)
setBusy('export')
try {
const r = await window.api.exportState()
if (r.ok && r.path) {
@@ -266,13 +281,13 @@ function DataCard(): JSX.Element {
setToast(t('settings.data.export.err'))
}
} finally {
setBusy(false)
setBusy(null)
}
}
async function performImport(): Promise<void> {
setConfirmOpen(false)
setBusy(true)
setBusy('import')
try {
const r = await window.api.importState()
if (r.ok) setToast(t('settings.data.import.ok'))
@@ -281,7 +296,7 @@ function DataCard(): JSX.Element {
setToast(t('settings.data.import.err'))
}
} finally {
setBusy(false)
setBusy(null)
}
}
@@ -298,9 +313,10 @@ function DataCard(): JSX.Element {
</div>
<button
onClick={onExport}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
disabled={busy !== null}
className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{busy === 'export' && <Spinner size={14} />}
{t('settings.data.export.btn')}
</button>
</Row>
@@ -315,9 +331,10 @@ function DataCard(): JSX.Element {
</div>
<button
onClick={() => setConfirmOpen(true)}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
disabled={busy !== null}
className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{busy === 'import' && <Spinner size={14} />}
{t('settings.data.import.btn')}
</button>
</Row>

View File

@@ -230,3 +230,25 @@ body {
.dark .text-tertiary {
color: rgb(var(--text-tertiary) / 0.3);
}
/* ===== Reduced motion =====
framer-motion закрывает свои анимации через MotionConfig reducedMotion="user"
(см. main.tsx). Этот блок гасит CSS-анимации/переходы, которые framer не
контролирует (Tailwind animate-spin/-pulse, нативные transition). Спиннеры
намеренно НЕ обнуляем полностью — индикатор загрузки должен крутиться, иначе
пропадает смысл; но замедляем, чтобы не мелькал. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
/* Спиннеры — единственное исключение: оставляем вращение, но медленнее. */
.animate-spin {
animation-duration: 1.2s !important;
animation-iteration-count: infinite !important;
}
}