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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
21
src/renderer/src/components/ui/Skeleton.tsx
Normal file
21
src/renderer/src/components/ui/Skeleton.tsx
Normal 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(' ')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
src/renderer/src/components/ui/Spinner.tsx
Normal file
29
src/renderer/src/components/ui/Spinner.tsx
Normal 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(' ')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
49
src/renderer/src/lib/useAnnounce.ts
Normal file
49
src/renderer/src/lib/useAnnounce.ts
Normal 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
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user