redesign(ui): полный реворк в стиле Apple iOS/macOS

Ветка для нового интерфейса; main с предыдущим Strava-дизайном
не тронут — можно вернуться через checkout main.

== Design system (foundation) ==
- Шрифты: Geist (sans, display) + Geist Mono (HUD числа) +
  Instrument Serif (hero titles) — все через Google Fonts. Близки
  к SF Pro / SF Mono.
- Палитра: Apple Human Interface Guidelines
  * accent: 255 107 53 (Apple Fitness Move orange)
  * success: 52 199 89 (systemGreen — для switch)
  * destructive: 255 59 48 (systemRed)
  * info: 0 122 255 (systemBlue)
  * warning: 255 159 10
  * Light bg: 242 242 247 (iOS systemGroupedBackground)
  * Dark bg: true black 0 0 0 (OLED-friendly), elevation через
    28/44/56 grey steps как в iOS Settings
- Утилиты: .vibrancy (macOS Big Sur sidebar), .hairline-b/-t (0.5px
  iOS-стиль), .shadow-card (soft layered), .font-display/-serif/-mono-num

== UI primitives ==
- Button: filled / tinted / plain / destructive / success (iOS UIButton);
  active:scale-[0.97] press feedback. Старые имена primary/secondary/
  ghost/danger/victory маппятся через legacyMap для совместимости.
- Switch: настоящий iOS UISwitch 51x31, spring физика knob, success
  цвет on.
- Modal: центрированный sheet с rounded-3xl (22px), backdrop blur,
  spring scale-in. Header с font-display, X в circle.
- Card + Row + SectionHeader: iOS grouped list — белая поверхность,
  hairline-b dividers между rows, last={true} убирает последний.

== App frame ==
- Sidebar: vibrancy (semi-transparent + backdrop-blur saturate 180%),
  font-serif лого, tinted icon-plaques на каждом пункте (как в iOS
  Settings), плавный hover. Drawer на mobile со spring slide.
- Titlebar: центрированный title, window controls без glow, hamburger
  только на <md.
- App.tsx: AnimatePresence cross-fade между маршрутами.

== Pages ==
- Dashboard: hero с font-serif Large Title + датой. 3-card Hero panel
  (Apple Fitness style) с tinted icon squares. ExerciseCard теперь с
  progress-ring вокруг иконки + появляющейся "Готово" pill только при
  due. Three-dot menu (iOS-style popover).
- Exercises: групированный список iOS, разделение Активные/Выключенные,
  chevron-right на каждом row.
- Challenges: тот же групированный паттерн + warning banner если игр
  нет, formula preview карточка в редакторе с big number в accent.
- Games: cards в новом стиле, статус-чипы pulse-dot для LIVE, dev
  кнопки в pill-стиле.
- Settings: классические iOS Settings секции с ToggleRow и SelectRow
  inside Card. UpdaterCard полностью переработан под Cell pattern.

== Reminder window ==
- iOS action sheet: большая иконка в accent-circle сверху, font-serif
  название упражнения, гигантское моноширинное число reps. Кнопки
  стопкой: primary Готово full-width, потом snooze + skip в grid.
  Хоткеи Enter/Space/Esc сохранены.
- Match summary: tone-цветной icon plaque (success/destructive/accent),
  ChallengeRow с pill-shaped check button.

== Анимации ==
- Spring физика везде где layout (Switch knob, Modal, Sidebar drawer,
  карточки)
- active:scale-[0.97] на всех интерактивных элементах (iOS touch feel)
- Cross-fade между страницами через AnimatePresence
- Никаких glow / pulse-ring — apple style это сдержанность

Verified: typecheck OK, 23 tests pass, build 36.35 KB CSS (на 6 KB
меньше предыдущего HUD-стиля), 1.56 MB JS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-17 14:17:35 +07:00
parent 6ffa100645
commit c5a29214d2
20 changed files with 1521 additions and 1618 deletions

View File

@@ -1,21 +1,12 @@
import { useMemo, useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import {
Plus,
Pause,
Play,
Timer,
Flame,
Activity,
Gamepad2,
Trophy
} from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button'
import type { Exercise } from '@shared/types'
import { formatCountdown, formatInterval } from '../lib/format'
import { formatCountdown } from '../lib/format'
export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state)
@@ -25,7 +16,6 @@ export default function Dashboard(): JSX.Element {
const exercises = state?.exercises ?? []
const settings = state?.settings
const challenges = state?.challenges ?? []
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
const stats = useMemo(() => {
@@ -33,32 +23,24 @@ export default function Dashboard(): JSX.Element {
const next = enabled
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
.sort((a, b) => a.ms - b.ms)[0]
const totalReps = enabled.reduce((s, e) => s + e.reps, 0)
const avgInterval =
enabled.length > 0
? Math.round(
enabled.reduce((s, e) => s + e.intervalMinutes, 0) / enabled.length
)
: 0
return {
total: exercises.length,
active: enabled.length,
nextMs: next?.ms ?? Infinity,
totalReps,
avgInterval
totalReps: enabled.reduce((s, e) => s + e.reps, 0)
}
}, [exercises, ticks])
const paused = !settings?.globalEnabled
function openCreate(): void {
setEditing(null)
setEditorOpen(true)
}
function openEdit(ex: Exercise): void {
setEditing(ex)
setEditorOpen(true)
}
async function handleSave(draft: {
name: string
reps: number
@@ -66,221 +48,197 @@ export default function Dashboard(): JSX.Element {
intervalMinutes: number
enabled: boolean
}): Promise<void> {
if (editing) {
await window.api.updateExercise(editing.id, draft)
} else {
await window.api.addExercise(draft)
}
if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(draft)
setEditorOpen(false)
}
async function handleDelete(id: string): Promise<void> {
await window.api.deleteExercise(id)
}
async function togglePause(): Promise<void> {
if (!settings) return
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
}
const paused = !settings?.globalEnabled
const today = new Date().toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long'
})
return (
<div className="p-4 sm:p-6 lg:p-8 overflow-y-auto h-full">
{/* Hero header */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Тренировка дня
<div className="h-full overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
{/* Hero — iOS Large Title */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="min-w-0">
<div className="text-[13px] text-text/45 font-medium capitalize">
{today}
</div>
<h1 className="font-serif text-[40px] sm:text-[44px] leading-[1.05] tracking-tight mt-1">
Сегодня
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="tinted" onClick={togglePause}>
{!paused ? (
<>
<Pause size={14} strokeWidth={2.5} /> Пауза
</>
) : (
<>
<Play size={14} strokeWidth={2.5} /> Старт
</>
)}
</Button>
<Button onClick={openCreate}>
<Plus size={15} strokeWidth={2.5} /> Добавить
</Button>
</div>
<h1 className="font-display font-bold text-3xl sm:text-4xl leading-none uppercase tracking-wide">
<span className="text-gradient-brand">Дашборд</span>
</h1>
<p className="text-sm text-muted mt-2">
{stats.active} активных из {stats.total} упражнений ·{' '}
<span className="text-text font-mono-num font-semibold">
{stats.totalReps}
</span>{' '}
повторов за цикл
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="secondary" onClick={togglePause}>
{!paused ? (
<>
<Pause size={16} /> Пауза
</>
) : (
<>
<Play size={16} /> Возобновить
</>
)}
</Button>
<Button onClick={openCreate}>
<Plus size={16} /> Новое
</Button>
</div>
</div>
{/* HUD stat strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<HudStat
icon={<Timer size={18} />}
label="До следующего"
value={
stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? 'СЕЙЧАС'
: formatCountdown(stats.nextMs)
}
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity}
/>
<HudStat
icon={<Activity size={18} />}
label="Активных"
value={`${stats.active}/${stats.total}`}
/>
<HudStat
icon={<Flame size={18} />}
label="Avg интервал"
value={stats.avgInterval ? formatInterval(stats.avgInterval) : '—'}
/>
<HudStat
icon={<Gamepad2 size={18} />}
label="Трекинг матчей"
value={gamesEnabled ? 'LIVE' : 'OFF'}
accent={gamesEnabled}
tone={gamesEnabled ? 'victory' : 'muted'}
{/* Hero stat panel — Apple Fitness style */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
<HeroStat
tone="accent"
label="Активных"
value={`${stats.active}`}
subvalue={`из ${stats.total}`}
icon={<Activity size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone="info"
label="До следующего"
value={
stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? 'Сейчас'
: formatCountdown(stats.nextMs)
}
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
icon={<Flame size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone={gamesEnabled ? 'success' : 'muted'}
label="Трекинг матчей"
value={gamesEnabled ? 'On' : 'Off'}
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
icon={
<span
className={[
'w-1.5 h-1.5 rounded-full',
gamesEnabled ? 'bg-white' : 'bg-text/30'
].join(' ')}
/>
}
/>
</div>
{/* Paused banner */}
{paused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-center gap-3"
>
<div className="w-9 h-9 rounded-xl bg-warning/15 text-warning grid place-items-center">
<Pause size={16} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-semibold">
Напоминания на паузе
</div>
<div className="text-[12px] text-text/55 mt-0.5">
Возобнови, чтобы продолжить отсчёт
</div>
</div>
<Button variant="filled" size="sm" onClick={togglePause}>
<Play size={13} strokeWidth={2.5} /> Старт
</Button>
</motion.div>
)}
{/* Cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
<ExerciseCard
key={ex.id}
exercise={ex}
tick={ticks[ex.id]}
onEdit={() => openEdit(ex)}
onDelete={() => window.api.deleteExercise(ex.id)}
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
onMarkDone={() => window.api.markDone(ex.id)}
/>
))}
</AnimatePresence>
</div>
{exercises.length === 0 && (
<div className="mt-12 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="font-display text-[20px] font-semibold">
Программа пуста
</div>
<p className="text-[14px] text-text/55 mt-1">
Добавь первое упражнение, чтобы начать
</p>
</div>
)}
<ExerciseEditor
open={editorOpen}
exercise={editing}
onClose={() => setEditorOpen(false)}
onSave={handleSave}
/>
</div>
{/* Paused banner */}
{paused && (
<div className="mb-6 rounded-2xl border border-xp/30 bg-xp/10 px-5 py-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-xp/20 text-xp grid place-items-center">
<Pause size={16} />
</div>
<div className="flex-1">
<div className="font-semibold text-sm">Тренировка на паузе</div>
<div className="text-xs text-muted mt-0.5">
Напоминания не сработают, пока не возобновишь
</div>
</div>
<Button variant="victory" size="sm" onClick={togglePause}>
<Play size={14} /> GO
</Button>
</div>
)}
{/* Challenges shortcut */}
{challenges.length > 0 && (
<div className="mb-6 rounded-2xl border border-border/70 bg-surface/60 backdrop-blur-sm px-5 py-4 flex items-center gap-4">
<div className="w-11 h-11 rounded-xl bg-accent-2/15 text-accent-2 grid place-items-center">
<Trophy size={20} />
</div>
<div className="flex-1">
<div className="text-[10px] text-muted uppercase tracking-[0.18em] font-semibold">
Активные челленджи
</div>
<div className="text-base font-display font-semibold mt-0.5">
{challenges.length} правил привязано к матчам
</div>
</div>
<div className="hidden sm:block text-xs text-muted">
См. вкладку <span className="text-accent font-semibold">Челленджи</span>
</div>
</div>
)}
{/* Exercise grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
<ExerciseCard
key={ex.id}
exercise={ex}
tick={ticks[ex.id]}
onEdit={() => openEdit(ex)}
onDelete={() => handleDelete(ex.id)}
onToggle={(enabled) => window.api.toggleExercise(ex.id, enabled)}
onMarkDone={() => window.api.markDone(ex.id)}
/>
))}
</AnimatePresence>
</div>
{exercises.length === 0 && (
<div className="mt-12 text-center">
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-brand items-center justify-center text-white shadow-glow mb-4">
<Plus size={28} />
</div>
<div className="font-display text-xl font-semibold uppercase tracking-wider mb-1">
Старт пуст
</div>
<p className="text-sm text-muted">
Добавь первое упражнение и поехали
</p>
</div>
)}
<ExerciseEditor
open={editorOpen}
exercise={editing}
onClose={() => setEditorOpen(false)}
onSave={handleSave}
/>
</div>
)
}
function HudStat({
icon,
function HeroStat({
tone,
label,
value,
accent,
tone = 'accent'
subvalue,
icon
}: {
icon: React.ReactNode
tone: 'accent' | 'info' | 'success' | 'muted'
label: string
value: string
accent?: boolean
tone?: 'accent' | 'victory' | 'muted'
subvalue?: string
icon?: React.ReactNode
}): JSX.Element {
const toneClasses =
tone === 'victory'
? 'text-victory bg-victory/15'
: tone === 'muted'
? 'text-muted bg-surface-elevated'
: 'text-accent bg-accent/15'
const toneBg =
tone === 'accent'
? 'bg-accent'
: tone === 'info'
? 'bg-info'
: tone === 'success'
? 'bg-success'
: 'bg-text/40'
return (
<div
className={[
'relative rounded-2xl border bg-surface/60 backdrop-blur-sm px-4 py-3 overflow-hidden',
accent ? 'border-accent/40 shadow-glow' : 'border-border/70'
].join(' ')}
>
{accent && (
<div className="absolute -top-8 -right-8 w-24 h-24 rounded-full bg-accent/20 blur-2xl pointer-events-none" />
)}
<div className="relative flex items-center gap-3">
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-center gap-2 mb-3">
<div
className={[
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
toneClasses
'w-6 h-6 rounded-md grid place-items-center text-white',
toneBg
].join(' ')}
>
{icon}
</div>
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
{label}
</div>
<div className="font-display font-bold text-xl tracking-wide truncate">
{value}
</div>
</div>
<div className="text-[12px] text-text/55 font-medium">{label}</div>
</div>
<div className="font-display text-[30px] font-semibold tracking-tight leading-none">
{value}
</div>
{subvalue && (
<div className="text-[12px] text-text/45 mt-1.5">{subvalue}</div>
)}
</div>
)
}