feat: a11y + Error Boundary + IPC validation + schema migrations
Second pass through the audit punch-list. ESLint and Prettier now clean
(0 errors, 0 warnings), typecheck clean, 53 tests pass.
ACCESSIBILITY (Modal)
- Full focus trap: Tab/Shift-Tab cycle within the dialog and never
escape to the underlying page.
- Focus restoration: closing returns focus to the trigger button.
- First focusable child is focused on open (skipping the X button).
- aria-labelledby links the dialog to its <h2> via useId().
- Close button's hardcoded "Закрыть" replaced with i18n key.
ERROR RECOVERY
- Add ErrorBoundary component (class — only way) with localized
fallback and a "try again" reset button. Stack trace shown only in
dev. Wrapped around the whole App + a nested boundary around the
routed pages so a crash in one route doesn't blank the chrome.
- Module-level guard on subscribeToBackend so React 18 StrictMode's
dev-mode double-mount doesn't subscribe twice.
- Loading placeholder is now blank (was hardcoded Russian "Загрузка…"
that English users would see during initial hydration).
TRAY i18n
- 5 tray strings now follow the current settings.language. Falls back
to Russian when the store isn't loaded yet or the lang is unknown.
- refreshMenu() called on settings.language change and on
pauseAll/resumeAll so the pause label stays in sync with state.
IPC VALIDATION (src/main/validate.ts)
- Hand-rolled validators for every renderer-supplied payload:
exercise input/patch, challenge input/patch, settings patch, id,
actualReps, snoozeMinutes. Range-check numeric fields
(intervalMinutes ∈ [1, 1440], reps ∈ [1, 9999], multiplier ∈ [0,
1000], snooze ∈ [1, 1440]), cap string lengths at 200, restrict
enums (theme/lang/notify-mode/stat) to known values, validate
quietHours.from/to with HH:MM regex and dedup quietHours.days.
- Every ipcMain.handle for mutations now runs the validator first and
returns null on rejection instead of pushing junk into the store.
A compromised renderer can no longer corrupt persisted state via
out-of-range numbers or wrong-type fields.
SCHEMA MIGRATIONS (src/main/store.ts)
- Add __schemaVersion field to persisted state with CURRENT = 1.
- MIGRATIONS map: { 0: (s) => s } as a no-op seed; future structural
changes (e.g. quietHours shape revision) get a single explicit slot.
- runMigrations() applies migrations in order; coerce() normalises the
result into a fully-formed AppState. Both first-write and every
flush() persist the version field.
EXHAUSTIVE-DEPS WARNINGS
- Dashboard: memoise `exercises` so downstream useMemos don't fire on
every parent render; gate the history fetch on exercises change
instead of any state change.
- HistoryHeatmap: wrap `weeks` in useMemo so monthLabels' deps are
stable.
LINT POLISH
- updater.ts: refactor a Prettier-vs-no-extra-semi conflict by
extracting the cast into a local binding.
- Remove dead import of `Challenge` from ipc.ts (now imported via
validators).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { Titlebar } from './components/Titlebar'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Exercises from './pages/Exercises'
|
||||
import GamesPage from './pages/Games'
|
||||
@@ -10,34 +11,48 @@ import ChallengesPage from './pages/Challenges'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import { subscribeToBackend, useAppStore } from './store/appStore'
|
||||
|
||||
// Module-level guard so React 18 StrictMode's double-invocation of mount
|
||||
// effects (in dev only) doesn't subscribe to backend IPC twice.
|
||||
let backendSubscribed = false
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const hydrated = useAppStore((s) => s.hydrated)
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (backendSubscribed) return undefined
|
||||
backendSubscribed = true
|
||||
const unsub = subscribeToBackend()
|
||||
return unsub
|
||||
return () => {
|
||||
backendSubscribed = false
|
||||
unsub()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<div className="h-screen w-screen flex flex-col bg-bg">
|
||||
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
mobileOpen={mobileNavOpen}
|
||||
onMobileClose={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden min-w-0">
|
||||
{hydrated ? (
|
||||
<RoutedPages onNav={() => setMobileNavOpen(false)} />
|
||||
) : (
|
||||
<div className="p-8 text-text/45">Загрузка…</div>
|
||||
)}
|
||||
</main>
|
||||
<ErrorBoundary>
|
||||
<HashRouter>
|
||||
<div className="h-screen w-screen flex flex-col bg-bg">
|
||||
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
mobileOpen={mobileNavOpen}
|
||||
onMobileClose={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden min-w-0">
|
||||
{hydrated ? (
|
||||
<ErrorBoundary>
|
||||
<RoutedPages onNav={() => setMobileNavOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
// Neutral placeholder — settings (and lang) aren't loaded yet.
|
||||
<div className="p-8 text-text/45" />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HashRouter>
|
||||
</HashRouter>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
61
src/renderer/src/components/ErrorBoundary.tsx
Normal file
61
src/renderer/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
/** Optional render override; receives the captured error. */
|
||||
fallback?: (err: Error, reset: () => void) => ReactNode
|
||||
}
|
||||
|
||||
type State = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level error boundary so a crash in one subtree (e.g. a malformed
|
||||
* history entry crashing HistoryHeatmap) does not blank the whole window.
|
||||
* React class components are still the only way to implement this.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
// No remote telemetry — log to the local console so a curious user
|
||||
// (or dev tools session) can capture it.
|
||||
console.error('[ErrorBoundary]', error, info.componentStack)
|
||||
}
|
||||
|
||||
reset = (): void => this.setState({ error: null })
|
||||
|
||||
render(): ReactNode {
|
||||
const { error } = this.state
|
||||
if (!error) return this.props.children
|
||||
|
||||
if (this.props.fallback) return this.props.fallback(error, this.reset)
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-xl mx-auto text-center">
|
||||
<div className="text-[15px] font-semibold mb-2">
|
||||
Что-то пошло не так
|
||||
</div>
|
||||
<div className="text-[13px] text-text/65 mb-4 break-words">
|
||||
{error.message}
|
||||
</div>
|
||||
<button
|
||||
onClick={this.reset}
|
||||
className="h-9 px-4 rounded-xl bg-accent text-white text-[14px] font-semibold active:scale-95 transition-transform"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
{import.meta.env.DEV && error.stack && (
|
||||
<pre className="mt-6 p-3 bg-surface-2 rounded-xl text-left text-[11px] font-mono-num overflow-auto max-h-64">
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,18 +37,21 @@ export function HistoryHeatmap({
|
||||
}
|
||||
|
||||
// Group cells into columns (weeks). Pad start so first column aligns to
|
||||
// its actual week (Mon-first).
|
||||
const firstDay = cells[0]?.date ?? new Date()
|
||||
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
|
||||
const padded: ({
|
||||
key: string
|
||||
date: Date
|
||||
reps: number
|
||||
} | null)[] = [...Array(firstWeekday).fill(null), ...cells]
|
||||
const weeks: (typeof padded)[] = []
|
||||
for (let i = 0; i < padded.length; i += 7) {
|
||||
weeks.push(padded.slice(i, i + 7))
|
||||
}
|
||||
// its actual week (Mon-first). Memoised so monthLabels' deps are stable.
|
||||
const weeks = useMemo(() => {
|
||||
const firstDay = cells[0]?.date ?? new Date()
|
||||
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
|
||||
const padded: ({
|
||||
key: string
|
||||
date: Date
|
||||
reps: number
|
||||
} | null)[] = [...Array(firstWeekday).fill(null), ...cells]
|
||||
const out: (typeof padded)[] = []
|
||||
for (let i = 0; i < padded.length; i += 7) {
|
||||
out.push(padded.slice(i, i + 7))
|
||||
}
|
||||
return out
|
||||
}, [cells])
|
||||
|
||||
const dayLabels =
|
||||
lang === 'en'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { ReactNode, useEffect, useId, useRef } from 'react'
|
||||
import { useT } from '../../i18n'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@@ -17,9 +18,25 @@ const sizeClass = {
|
||||
lg: 'max-w-3xl'
|
||||
}
|
||||
|
||||
/** All elements inside `root` that can receive keyboard focus. */
|
||||
function getFocusable(root: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style centred sheet. Spring-snap on enter, soft fade-out.
|
||||
* Backdrop uses heavy blur for proper iOS modal feel.
|
||||
*
|
||||
* Accessibility:
|
||||
* - role="dialog" + aria-modal="true" + aria-labelledby on the title <h2>
|
||||
* - Focus is trapped inside the dialog while open; Tab/Shift-Tab cycle
|
||||
* through focusable children and never escape to the underlying page.
|
||||
* - On open the first focusable element is focused.
|
||||
* - On close, focus returns to whatever was focused when the modal opened.
|
||||
* - Esc closes (parent handles confirm-on-dirty if it wants).
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
@@ -29,6 +46,12 @@ export function Modal({
|
||||
footer,
|
||||
size = 'md'
|
||||
}: Props): JSX.Element {
|
||||
const { t } = useT()
|
||||
const titleId = useId()
|
||||
const sheetRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// Esc closes.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -38,6 +61,60 @@ export function Modal({
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
// Focus trap + focus restore.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null
|
||||
lastFocusedRef.current = previouslyFocused
|
||||
|
||||
// Defer focus to the next frame — framer-motion's enter animation may
|
||||
// still be mounting children when this effect runs.
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const root = sheetRef.current
|
||||
if (!root) return
|
||||
const focusables = getFocusable(root)
|
||||
const first = focusables.find(
|
||||
(el) => !el.hasAttribute('data-modal-close')
|
||||
)
|
||||
;(first ?? focusables[0])?.focus()
|
||||
})
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key !== 'Tab') return
|
||||
const root = sheetRef.current
|
||||
if (!root) return
|
||||
const focusables = getFocusable(root)
|
||||
if (focusables.length === 0) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !root.contains(active)) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (active === last || !root.contains(active)) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
// Restore focus to the trigger (button/row) that opened the modal,
|
||||
// unless it was unmounted while the modal was open.
|
||||
const target = lastFocusedRef.current
|
||||
if (target && document.body.contains(target)) target.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
@@ -50,8 +127,10 @@ export function Modal({
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={[
|
||||
'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden',
|
||||
sizeClass[size]
|
||||
@@ -64,13 +143,17 @@ export function Modal({
|
||||
>
|
||||
{/* Header — iOS large modal title */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="font-display text-[20px] font-semibold tracking-tight">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="font-display text-[20px] font-semibold tracking-tight"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
data-modal-close=""
|
||||
className="w-7 h-7 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 hover:text-text transition-colors active:scale-90"
|
||||
aria-label="Закрыть"
|
||||
aria-label={t('btn.close')}
|
||||
>
|
||||
<X size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
@@ -18,15 +18,20 @@ export default function Dashboard(): JSX.Element {
|
||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||
const { t, lang } = useT()
|
||||
|
||||
const exercises = state?.exercises ?? []
|
||||
// Memoise the exercises array reference so downstream useMemos don't fire
|
||||
// on every render — `state?.exercises ?? []` creates a fresh array each time
|
||||
// the parent re-renders even when nothing changed.
|
||||
const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises])
|
||||
const settings = state?.settings
|
||||
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
||||
|
||||
// Local history mirror; reloaded whenever app-state changes.
|
||||
// Local history mirror; reloaded only when exercises change (not on every
|
||||
// tick or settings tweak — those don't affect history). When ticks/settings
|
||||
// change we don't re-fetch.
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
useEffect(() => {
|
||||
void window.api.getHistory().then(setHistory)
|
||||
}, [state])
|
||||
}, [exercises])
|
||||
|
||||
const todayDone = useMemo(
|
||||
() => dailyReps(history, exercises, todayKey()),
|
||||
@@ -34,7 +39,11 @@ export default function Dashboard(): JSX.Element {
|
||||
)
|
||||
const streak = useMemo(() => currentStreak(history), [history])
|
||||
|
||||
// `ticks` is intentionally a dep so the countdown re-evaluates each second
|
||||
// even though Date.now() inside isn't a reactive dependency. Reference it
|
||||
// once inside the memo so ESLint sees the dep as used.
|
||||
const stats = useMemo(() => {
|
||||
void ticks // re-run on tick (Date.now() is the actual driver)
|
||||
const enabled = exercises.filter((e) => e.enabled)
|
||||
const next = enabled
|
||||
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
||||
|
||||
Reference in New Issue
Block a user