redesign(ui): phase 1 — esports HUD design system + core surfaces

Сменили визуальную ДНК на dark-first гейминг-эстетику: cyan→violet
неоновая палитра, спортивный display-шрифт Rajdhani, моноширинный
HUD для всех счётчиков, градиентные CTA с glow.

Дизайн-система (globals.css + tailwind.config.js):
- Brand-токены accent (cyan), accent-2 (violet), victory (lime),
  defeat (rose), xp (amber); --bg-deep слой
- Утилиты .neon-border (анимированный обводный градиент),
  .hud-pulse, .hud-scanlines, .dot-grid, .text-gradient-brand,
  .bg-gradient-brand/-victory/-defeat
- Радиальные градиенты на body (cyan/violet glow по углам)
- Шрифты Rajdhani (display) и JetBrains Mono подключены через CDN

Компоненты:
- Sidebar: gradient-логотип, активный пункт с вертикальной gradient-
  полосой и shadow-glow, статус-чип GSI tracking
- Titlebar: glass + scanlines, моноширинный лейбл, defeat hover на X
- Button: primary = bg-gradient-brand + shadow-glow; новый variant
  victory (lime-gradient)
- ExerciseCard: SVG cooldown-ring как у способностей в MOBA,
  градиентный stroke с drop-shadow, .neon-border на due, hover lift
- Dashboard: hero с gradient-text заголовком, HUD-полоса из 4 stat-
  карточек (Cooldown / Active / Avg / Game tracking LIVE/OFF)
- ReminderApp: вращающийся conic-gradient вокруг иконки, HUD-блок
  reps в моноширинном шрифте, кнопки с подписями хоткеев,
  Match summary с heroGradient по результату (victory/defeat/brand)

ThemeProvider больше не перезаписывает --accent системным —
у laude теперь стабильная brand-идентичность.

Бонус: хоткеи на reminder-окне (Enter=done, Space=snooze, Esc=skip).

Откат: git revert HEAD  или  git reset --hard 688a86b

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-16 18:36:52 +07:00
parent 688a86b611
commit 4da83761d2
10 changed files with 757 additions and 209 deletions

View File

@@ -1,12 +1,21 @@
import { useMemo, useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import { Plus, Pause, Play, Clock } from 'lucide-react'
import {
Plus,
Pause,
Play,
Timer,
Flame,
Activity,
Gamepad2,
Trophy
} 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 } from '../lib/format'
import { formatCountdown, formatInterval } from '../lib/format'
export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state)
@@ -16,16 +25,27 @@ 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(() => {
const enabled = exercises.filter((e) => e.enabled)
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
nextMs: next?.ms ?? Infinity,
totalReps,
avgInterval
}
}, [exercises, ticks])
@@ -63,18 +83,30 @@ export default function Dashboard(): JSX.Element {
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
}
const paused = !settings?.globalEnabled
return (
<div className="p-8 overflow-y-auto h-full">
{/* Hero header */}
<div className="flex items-end justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Дашборд</h1>
<p className="text-sm text-muted mt-1">
{stats.active} активных из {stats.total} упражнений
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
Mission control
</div>
<h1 className="font-display font-bold 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">
<Button variant="secondary" onClick={togglePause}>
{settings?.globalEnabled ? (
{!paused ? (
<>
<Pause size={16} /> Пауза
</>
@@ -90,27 +122,78 @@ export default function Dashboard(): JSX.Element {
</div>
</div>
<div className="mb-6 rounded-2xl border border-border bg-surface px-5 py-4 flex items-center gap-4">
<div className="w-11 h-11 rounded-xl bg-accent/15 text-accent grid place-items-center">
<Clock size={20} />
</div>
<div className="flex-1">
<div className="text-xs text-muted uppercase tracking-wider">
Ближайшее напоминание
</div>
<div className="text-lg font-semibold mt-0.5">
{stats.nextMs === Infinity
? 'Нет активных упражнений'
: `через ${formatCountdown(stats.nextMs)}`}
</div>
</div>
{!settings?.globalEnabled && (
<div className="px-3 py-1.5 rounded-full bg-amber-500/15 text-amber-500 text-xs font-medium">
Напоминания на паузе
</div>
)}
{/* HUD stat strip */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 mb-6">
<HudStat
icon={<Timer size={18} />}
label="Cooldown"
value={
stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? 'READY'
: 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="Game tracking"
value={gamesEnabled ? 'LIVE' : 'OFF'}
accent={gamesEnabled}
tone={gamesEnabled ? 'victory' : 'muted'}
/>
</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="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) => (
@@ -128,8 +211,16 @@ export default function Dashboard(): JSX.Element {
</div>
{exercises.length === 0 && (
<div className="mt-10 text-center text-muted">
<p>Нет упражнений. Добавьте первое.</p>
<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>
)}
@@ -142,3 +233,54 @@ export default function Dashboard(): JSX.Element {
</div>
)
}
function HudStat({
icon,
label,
value,
accent,
tone = 'accent'
}: {
icon: React.ReactNode
label: string
value: string
accent?: boolean
tone?: 'accent' | 'victory' | 'muted'
}): JSX.Element {
const toneClasses =
tone === 'victory'
? 'text-victory bg-victory/15'
: tone === 'muted'
? 'text-muted bg-surface-elevated'
: 'text-accent bg-accent/15'
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={[
'w-10 h-10 rounded-xl grid place-items-center shrink-0',
toneClasses
].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>
</div>
)
}