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:
@@ -7,7 +7,7 @@
|
|||||||
<title>Exercise Reminder</title>
|
<title>Exercise Reminder</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Rajdhani:wght@500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Check, Clock, X, Trophy, Skull, Gamepad2 } from 'lucide-react'
|
import {
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
|
Trophy,
|
||||||
|
Skull,
|
||||||
|
Gamepad2,
|
||||||
|
Flame,
|
||||||
|
Zap
|
||||||
|
} from 'lucide-react'
|
||||||
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
|
import type { Exercise, MatchSummary, Settings, ChallengeResult } from '@shared/types'
|
||||||
import { Icon } from './lib/icon'
|
import { Icon } from './lib/icon'
|
||||||
import { formatInterval } from './lib/format'
|
import { formatInterval } from './lib/format'
|
||||||
@@ -37,6 +46,26 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Keyboard shortcuts on reminder window
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode.kind !== 'exercise') return
|
||||||
|
const ex = mode.exercise
|
||||||
|
const snoozeMin = settings?.snoozeMinutes ?? 5
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
window.api.markDone(ex.id).then(close)
|
||||||
|
} else if (e.key === ' ' || e.code === 'Space') {
|
||||||
|
e.preventDefault()
|
||||||
|
window.api.snooze(ex.id, snoozeMin).then(close)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
window.api.skip(ex.id).then(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mode, settings?.snoozeMinutes])
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
setMode({ kind: 'idle' })
|
setMode({ kind: 'idle' })
|
||||||
window.api.reminderClose()
|
window.api.reminderClose()
|
||||||
@@ -91,11 +120,14 @@ function ExerciseReminder({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reminder-shell flex flex-col h-full">
|
<div className="reminder-shell flex flex-col h-full hud-scanlines">
|
||||||
<div className="titlebar-drag h-8 px-3 flex items-center justify-end">
|
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.2em] text-accent font-display font-semibold inline-flex items-center gap-1.5 px-2">
|
||||||
|
<Flame size={11} /> Cooldown ready
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
|
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<X size={13} />
|
||||||
@@ -103,44 +135,85 @@ function ExerciseReminder({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
|
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.7, opacity: 0 }}
|
initial={{ scale: 0.6, opacity: 0, rotate: -8 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||||
transition={{ type: 'spring', stiffness: 200, damping: 18 }}
|
transition={{ type: 'spring', stiffness: 200, damping: 16 }}
|
||||||
className="relative mb-5"
|
className="relative mb-6"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 rounded-full bg-accent/30 animate-pulse-ring" />
|
{/* Outer rotating ring */}
|
||||||
<div className="relative w-24 h-24 rounded-full bg-accent text-white grid place-items-center shadow-glow">
|
<motion.div
|
||||||
<Icon name={exercise.icon} size={44} />
|
className="absolute -inset-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'conic-gradient(from 0deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 50%, rgb(var(--accent)) 100%)'
|
||||||
|
}}
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 8, repeat: Infinity, ease: 'linear' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute -inset-3 rounded-full bg-surface m-[3px]" />
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/40 blur-2xl animate-pulse-ring" />
|
||||||
|
<div className="relative w-28 h-28 rounded-full bg-gradient-brand text-white grid place-items-center shadow-glow-lg">
|
||||||
|
<Icon name={exercise.icon} size={48} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<div className="text-xs uppercase tracking-[0.2em] text-muted">Время размяться</div>
|
|
||||||
<h1 className="text-3xl font-bold mt-2 mb-1">{exercise.name}</h1>
|
<div className="text-[10px] uppercase tracking-[0.28em] text-muted font-semibold">
|
||||||
<div className="text-5xl font-extrabold text-accent tabular-nums mt-1">
|
Время размяться
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-3xl font-bold mt-2 mb-3 uppercase tracking-wide">
|
||||||
|
{exercise.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* HUD reps counter */}
|
||||||
|
<div className="inline-flex items-baseline gap-2 px-5 py-2 rounded-2xl border border-accent/30 bg-accent/10 shadow-glow">
|
||||||
|
<Zap size={16} className="text-xp" />
|
||||||
|
<span className="font-mono-num font-bold text-5xl text-gradient-brand leading-none">
|
||||||
{exercise.reps}
|
{exercise.reps}
|
||||||
<span className="text-base font-medium text-muted ml-2">раз</span>
|
</span>
|
||||||
|
<span className="text-xs font-display font-semibold text-muted uppercase tracking-widest">
|
||||||
|
REPS
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted mt-2">
|
<div className="text-[11px] text-muted mt-4 inline-flex items-center gap-1.5">
|
||||||
Следующее напоминание через {formatInterval(exercise.intervalMinutes)}
|
<Clock size={10} />
|
||||||
|
Next drop через {formatInterval(exercise.intervalMinutes)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
|
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={skip}
|
onClick={skip}
|
||||||
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-muted hover:text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
|
title="Esc"
|
||||||
|
className="group h-12 rounded-xl bg-surface-elevated hover:bg-defeat/15 hover:text-defeat text-muted text-sm font-semibold inline-flex flex-col items-center justify-center gap-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<X size={14} /> Пропустить
|
<X size={14} /> Пропустить
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
|
||||||
|
ESC
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={snooze}
|
onClick={snooze}
|
||||||
className="h-12 rounded-xl bg-surface-elevated hover:bg-border/60 text-text text-sm font-medium inline-flex items-center justify-center gap-1.5"
|
title="Space"
|
||||||
|
className="group h-12 rounded-xl bg-surface-elevated hover:bg-surface-elevated/80 text-text text-sm font-semibold inline-flex flex-col items-center justify-center gap-0.5 border border-border/60 hover:border-accent/40 transition-colors"
|
||||||
>
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Clock size={14} /> Отложить {snoozeMinutes}м
|
<Clock size={14} /> Отложить {snoozeMinutes}м
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
|
||||||
|
SPACE
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={done}
|
onClick={done}
|
||||||
className="h-12 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center justify-center gap-1.5 shadow-glow"
|
title="Enter"
|
||||||
|
className="group h-12 rounded-xl bg-gradient-victory text-white text-sm font-bold uppercase tracking-wide inline-flex flex-col items-center justify-center gap-0.5 shadow-glow-victory hover:brightness-110 transition-all"
|
||||||
>
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Check size={16} /> Сделал
|
<Check size={16} /> Сделал
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] opacity-70 font-mono-num">ENTER</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,16 +236,24 @@ function MatchSummaryView({
|
|||||||
const remainingReps = summary.results
|
const remainingReps = summary.results
|
||||||
.filter((r) => !done.has(r.challengeId))
|
.filter((r) => !done.has(r.challengeId))
|
||||||
.reduce((s, r) => s + r.reps, 0)
|
.reduce((s, r) => s + r.reps, 0)
|
||||||
|
const won = summary.won === true
|
||||||
|
const lost = summary.won === false
|
||||||
|
|
||||||
|
const heroGradient = won
|
||||||
|
? 'bg-gradient-victory'
|
||||||
|
: lost
|
||||||
|
? 'bg-gradient-defeat'
|
||||||
|
: 'bg-gradient-brand'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reminder-shell flex flex-col h-full">
|
<div className="reminder-shell flex flex-col h-full hud-scanlines">
|
||||||
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
<div className="titlebar-drag h-9 px-3 flex items-center justify-between">
|
||||||
<div className="text-xs text-muted inline-flex items-center gap-1.5 px-2">
|
<div className="text-[10px] uppercase tracking-[0.2em] text-muted font-display font-semibold inline-flex items-center gap-1.5 px-2">
|
||||||
<Gamepad2 size={12} /> {summary.gameName}
|
<Gamepad2 size={12} /> {summary.gameName}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white text-muted"
|
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white text-muted"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<X size={13} />
|
||||||
@@ -181,38 +262,48 @@ function MatchSummaryView({
|
|||||||
|
|
||||||
<div className="px-6 pt-2 pb-4 text-center">
|
<div className="px-6 pt-2 pb-4 text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.7, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ type: 'spring', stiffness: 220, damping: 20 }}
|
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
||||||
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-accent/15 text-accent mb-3"
|
className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl text-white mb-3"
|
||||||
>
|
>
|
||||||
{summary.won === true ? (
|
<div className={`absolute inset-0 rounded-2xl ${heroGradient} blur-md opacity-70`} />
|
||||||
<Trophy size={28} />
|
<div className={`relative w-16 h-16 rounded-2xl ${heroGradient} grid place-items-center shadow-glow`}>
|
||||||
) : summary.won === false ? (
|
{won ? (
|
||||||
<Skull size={28} />
|
<Trophy size={30} />
|
||||||
|
) : lost ? (
|
||||||
|
<Skull size={30} />
|
||||||
) : (
|
) : (
|
||||||
<Gamepad2 size={28} />
|
<Gamepad2 size={30} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-xl font-bold">
|
<h1 className="font-display font-bold text-xl uppercase tracking-wide">
|
||||||
{summary.won === true
|
{won
|
||||||
? 'Победа! Время заработанных упражнений'
|
? 'Victory · упражнения заработаны'
|
||||||
: summary.won === false
|
: lost
|
||||||
? 'Поражение. Но тело — нет'
|
? 'Defeat · но тело — нет'
|
||||||
: 'Матч завершён'}
|
: 'Матч завершён'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted mt-1">
|
<p className="text-[11px] text-muted mt-1.5">
|
||||||
{Math.floor(summary.durationMs / 60_000)} мин · {summary.results.length}{' '}
|
<span className="font-mono-num font-semibold text-text">
|
||||||
|
{Math.floor(summary.durationMs / 60_000)}
|
||||||
|
</span>{' '}
|
||||||
|
мин · {summary.results.length}{' '}
|
||||||
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
||||||
{allDone ? (
|
{allDone ? (
|
||||||
<span className="text-emerald-500 font-medium">всё выполнено</span>
|
<span className="text-victory font-semibold uppercase tracking-wider">
|
||||||
|
all clear
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-accent font-semibold">{remainingReps} ещё</span>
|
<span className="text-accent font-bold font-mono-num">
|
||||||
|
{remainingReps} осталось
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 space-y-2">
|
<div className="flex-1 overflow-y-auto px-4 space-y-2 pb-2">
|
||||||
{summary.results.map((r) => (
|
{summary.results.map((r) => (
|
||||||
<ChallengeRow
|
<ChallengeRow
|
||||||
key={r.challengeId}
|
key={r.challengeId}
|
||||||
@@ -223,18 +314,24 @@ function MatchSummaryView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 pb-6 pt-3 flex items-center gap-2">
|
<div className="px-6 pb-6 pt-3 flex items-center gap-3 border-t border-border/40">
|
||||||
<div className="flex-1 text-xs text-muted">
|
<div className="flex-1 text-[11px] text-muted uppercase tracking-[0.15em] font-semibold">
|
||||||
Всего: <span className="text-text font-semibold">{totalReps}</span>{' '}
|
Total ·{' '}
|
||||||
повторений
|
<span className="text-gradient-brand font-mono-num text-base font-bold tracking-normal">
|
||||||
|
{totalReps}
|
||||||
|
</span>{' '}
|
||||||
|
reps
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="h-11 px-5 rounded-xl bg-accent text-white hover:brightness-110 text-sm font-semibold inline-flex items-center gap-1.5 shadow-glow"
|
className={[
|
||||||
|
'h-11 px-5 rounded-xl text-white text-sm font-bold uppercase tracking-wider inline-flex items-center gap-1.5 transition-all hover:brightness-110',
|
||||||
|
allDone ? 'bg-gradient-victory shadow-glow-victory' : 'bg-gradient-brand shadow-glow'
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{allDone ? (
|
{allDone ? (
|
||||||
<>
|
<>
|
||||||
<Check size={16} /> Готово
|
<Check size={16} /> GG
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Позже'
|
'Позже'
|
||||||
@@ -262,27 +359,41 @@ function ChallengeRow({
|
|||||||
className={[
|
className={[
|
||||||
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
|
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
|
||||||
done
|
done
|
||||||
? 'border-emerald-500/40 bg-emerald-500/10'
|
? 'border-victory/40 bg-victory/10'
|
||||||
: 'border-border bg-surface-elevated'
|
: 'border-border/70 bg-surface-elevated/60'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-11 h-11 rounded-lg grid place-items-center shrink-0',
|
'w-11 h-11 rounded-lg grid place-items-center shrink-0',
|
||||||
done ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent'
|
done ? 'bg-victory/20 text-victory' : 'bg-accent/15 text-accent'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={result.icon} size={22} />
|
<Icon name={result.icon} size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className={['font-medium truncate', done ? 'line-through opacity-60' : ''].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'font-display font-semibold tracking-wide truncate',
|
||||||
|
done ? 'line-through opacity-60' : ''
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
{result.exerciseName}
|
{result.exerciseName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted mt-0.5">
|
<div className="text-[11px] text-muted mt-0.5">
|
||||||
{result.statValue} {result.statLabel} → {result.name}
|
<span className="font-mono-num font-bold text-text">
|
||||||
|
{result.statValue}
|
||||||
|
</span>{' '}
|
||||||
|
{result.statLabel} →{' '}
|
||||||
|
<span className="text-accent">{result.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={['text-2xl font-bold tabular-nums', done ? 'text-emerald-500' : 'text-accent'].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'font-mono-num text-2xl font-bold tabular-nums',
|
||||||
|
done ? 'text-victory' : 'text-gradient-brand'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
{result.reps}
|
{result.reps}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -291,8 +402,8 @@ function ChallengeRow({
|
|||||||
className={[
|
className={[
|
||||||
'h-9 w-9 grid place-items-center rounded-lg transition-colors',
|
'h-9 w-9 grid place-items-center rounded-lg transition-colors',
|
||||||
done
|
done
|
||||||
? 'bg-emerald-500 text-white cursor-default'
|
? 'bg-victory text-white cursor-default'
|
||||||
: 'bg-accent text-white hover:brightness-110'
|
: 'bg-gradient-brand text-white hover:brightness-110 shadow-glow'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
aria-label="Готово"
|
aria-label="Готово"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Check, Pencil, Trash2 } from 'lucide-react'
|
import { Check, Pencil, Trash2, Zap } from 'lucide-react'
|
||||||
import type { Exercise, Tick } from '@shared/types'
|
import type { Exercise, Tick } from '@shared/types'
|
||||||
import { Icon } from '../lib/icon'
|
import { Icon } from '../lib/icon'
|
||||||
import { formatCountdown, formatInterval } from '../lib/format'
|
import { formatCountdown, formatInterval } from '../lib/format'
|
||||||
@@ -23,39 +23,100 @@ export function ExerciseCard({
|
|||||||
onMarkDone
|
onMarkDone
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const ms = tick?.msUntilFire ?? exercise.nextFireAt - Date.now()
|
const ms = tick?.msUntilFire ?? exercise.nextFireAt - Date.now()
|
||||||
const progressPct = (() => {
|
|
||||||
const total = exercise.intervalMinutes * 60_000
|
const total = exercise.intervalMinutes * 60_000
|
||||||
const remaining = Math.max(0, Math.min(total, ms))
|
const remaining = Math.max(0, Math.min(total, ms))
|
||||||
const elapsed = total - remaining
|
const elapsedPct = total > 0 ? 1 - remaining / total : 0
|
||||||
return Math.max(0, Math.min(100, (elapsed / total) * 100))
|
|
||||||
})()
|
|
||||||
const isDue = ms <= 0 && exercise.enabled
|
const isDue = ms <= 0 && exercise.enabled
|
||||||
|
|
||||||
|
// SVG cooldown ring math
|
||||||
|
const R = 22
|
||||||
|
const C = 2 * Math.PI * R
|
||||||
|
const dashOffset = C * (1 - elapsedPct)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0, y: 6 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 280, damping: 24 }}
|
||||||
className={[
|
className={[
|
||||||
'group rounded-2xl border bg-surface p-5 flex flex-col gap-4 transition-shadow',
|
'group relative rounded-2xl border bg-surface/80 backdrop-blur-sm p-5 flex flex-col gap-4',
|
||||||
isDue ? 'border-accent shadow-glow' : 'border-border hover:shadow-soft'
|
'transition-shadow',
|
||||||
|
isDue
|
||||||
|
? 'neon-border hud-pulse border-transparent'
|
||||||
|
: 'border-border/70 hover:border-accent/40 hover:shadow-soft'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
{/* Glow corner accent */}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-12 h-12 rounded-xl grid place-items-center',
|
'absolute -top-12 -right-12 w-32 h-32 rounded-full blur-3xl pointer-events-none transition-opacity',
|
||||||
exercise.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
|
exercise.enabled
|
||||||
|
? 'bg-accent/15 opacity-100'
|
||||||
|
: 'bg-muted/10 opacity-50'
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{/* Hex-like rounded icon plaque with cooldown ring */}
|
||||||
|
<div className="relative w-14 h-14 shrink-0">
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 -rotate-90"
|
||||||
|
viewBox="0 0 56 56"
|
||||||
|
width="56"
|
||||||
|
height="56"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cooldownGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="rgb(var(--accent))" />
|
||||||
|
<stop offset="100%" stopColor="rgb(var(--accent-2))" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle
|
||||||
|
className="cooldown-track"
|
||||||
|
cx="28"
|
||||||
|
cy="28"
|
||||||
|
r={R}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
{exercise.enabled && (
|
||||||
|
<circle
|
||||||
|
className="cooldown-fill"
|
||||||
|
cx="28"
|
||||||
|
cy="28"
|
||||||
|
r={R}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray={C}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'absolute inset-[7px] rounded-full grid place-items-center transition-colors',
|
||||||
|
exercise.enabled
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'bg-surface-elevated text-muted'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon name={exercise.icon} size={22} />
|
<Icon name={exercise.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div className="font-semibold leading-tight">{exercise.name}</div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-muted mt-0.5">
|
<div className="font-semibold leading-tight truncate font-display text-lg tracking-wide">
|
||||||
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
|
{exercise.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted mt-1 inline-flex items-center gap-1.5">
|
||||||
|
<Zap size={11} className="text-xp" />
|
||||||
|
<span className="font-mono-num font-semibold text-text">
|
||||||
|
{exercise.reps}
|
||||||
|
</span>
|
||||||
|
<span>повторов · каждые {formatInterval(exercise.intervalMinutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,46 +127,49 @@ export function ExerciseCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="relative">
|
||||||
<div className="flex items-baseline justify-between mb-1.5">
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
<span className="text-xs uppercase tracking-wider text-muted">
|
<span className="text-[10px] uppercase tracking-[0.18em] text-muted font-semibold">
|
||||||
Следующее
|
Cooldown
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'text-sm font-mono font-semibold tabular-nums',
|
'text-sm font-mono-num font-bold',
|
||||||
isDue ? 'text-accent' : 'text-text'
|
isDue ? 'text-accent' : 'text-text'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
|
{exercise.enabled ? formatCountdown(ms) : 'PAUSED'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full bg-surface-elevated overflow-hidden">
|
<div className="h-1.5 rounded-full bg-surface-elevated/80 overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full rounded-full bg-accent"
|
className={[
|
||||||
animate={{ width: `${exercise.enabled ? progressPct : 0}%` }}
|
'h-full rounded-full',
|
||||||
|
isDue ? 'bg-gradient-brand' : 'bg-accent'
|
||||||
|
].join(' ')}
|
||||||
|
animate={{ width: `${exercise.enabled ? elapsedPct * 100 : 0}%` }}
|
||||||
transition={{ duration: 0.5, ease: 'linear' }}
|
transition={{ duration: 0.5, ease: 'linear' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="relative flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={onMarkDone}
|
onClick={onMarkDone}
|
||||||
className="flex-1 h-9 rounded-lg bg-accent/10 hover:bg-accent/20 text-accent text-xs font-medium inline-flex items-center justify-center gap-1.5"
|
className="flex-1 h-9 rounded-lg bg-victory/15 hover:bg-victory/25 text-victory text-xs font-semibold inline-flex items-center justify-center gap-1.5 transition-colors"
|
||||||
>
|
>
|
||||||
<Check size={14} /> Сделал сейчас
|
<Check size={14} /> Сделал
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text"
|
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-surface-elevated text-muted hover:text-text transition-colors"
|
||||||
aria-label="Редактировать"
|
aria-label="Редактировать"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-red-500/15 hover:text-red-500 text-muted"
|
className="h-9 w-9 grid place-items-center rounded-lg hover:bg-defeat/15 hover:text-defeat text-muted transition-colors"
|
||||||
aria-label="Удалить"
|
aria-label="Удалить"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
Target,
|
Target,
|
||||||
Settings as SettingsIcon
|
Settings as SettingsIcon,
|
||||||
|
Dumbbell
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
@@ -17,19 +18,32 @@ const links = [
|
|||||||
|
|
||||||
export function Sidebar(): JSX.Element {
|
export function Sidebar(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 shrink-0 border-r border-border/60 bg-surface/40 flex flex-col">
|
<aside className="w-60 shrink-0 border-r border-border/60 bg-surface/40 backdrop-blur-sm flex flex-col relative">
|
||||||
<div className="px-5 py-5">
|
<div className="absolute inset-0 dot-grid opacity-40 pointer-events-none" />
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="relative px-5 py-5">
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent grid place-items-center text-white font-bold shadow-glow">
|
<div className="flex items-center gap-3">
|
||||||
R
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-gradient-brand blur-md opacity-60" />
|
||||||
|
<div className="relative w-11 h-11 rounded-2xl bg-gradient-brand grid place-items-center text-white shadow-glow">
|
||||||
|
<Dumbbell size={20} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-sm leading-tight">Reminder</div>
|
<div className="font-display font-bold text-lg leading-none uppercase tracking-wider">
|
||||||
<div className="text-xs text-muted">Будь в движении</div>
|
<span className="text-gradient-brand">Laude</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted uppercase tracking-[0.18em] mt-1">
|
||||||
|
Play hard · Train harder
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-3 flex flex-col gap-1">
|
</div>
|
||||||
|
|
||||||
|
<div className="relative px-5 pb-3">
|
||||||
|
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="relative px-3 flex flex-col gap-0.5">
|
||||||
{links.map(({ to, label, icon: Icon, end }) => (
|
{links.map(({ to, label, icon: Icon, end }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
@@ -37,20 +51,50 @@ export function Sidebar(): JSX.Element {
|
|||||||
end={end}
|
end={end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
'group relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent/15 text-accent'
|
? 'text-text bg-surface-elevated/80'
|
||||||
: 'text-muted hover:text-text hover:bg-surface-elevated'
|
: 'text-muted hover:text-text hover:bg-surface-elevated/50'
|
||||||
].join(' ')
|
].join(' ')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
{({ isActive }) => (
|
||||||
{label}
|
<>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'absolute left-0 top-2 bottom-2 w-[3px] rounded-full transition-all',
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-b from-accent to-accent-2 opacity-100 shadow-glow'
|
||||||
|
: 'opacity-0'
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
className={isActive ? 'text-accent' : ''}
|
||||||
|
strokeWidth={isActive ? 2.4 : 2}
|
||||||
|
/>
|
||||||
|
<span className={isActive ? 'font-semibold' : ''}>{label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="mt-auto p-4 text-xs text-muted">
|
|
||||||
<div className="opacity-60">v0.1 · Windows 11</div>
|
<div className="relative mt-auto p-4">
|
||||||
|
<div className="rounded-xl border border-border/60 bg-surface-elevated/60 p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full rounded-full bg-victory opacity-60 animate-ping" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-victory" />
|
||||||
|
</span>
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.18em] text-muted">
|
||||||
|
Online · v0.1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted/80 leading-snug">
|
||||||
|
GSI-трекинг матчей активен
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { Minus, X, Square } from 'lucide-react'
|
import { Minus, X, Square, Activity } from 'lucide-react'
|
||||||
|
|
||||||
export function Titlebar({ title }: { title: string }): JSX.Element {
|
export function Titlebar({ title }: { title: string }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="titlebar-drag h-10 px-4 flex items-center justify-between border-b border-border/60 bg-surface/60 backdrop-blur">
|
<div className="titlebar-drag relative h-10 px-4 flex items-center justify-between border-b border-border/60 bg-surface/50 backdrop-blur-md hud-scanlines">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-muted">
|
<div className="flex items-center gap-2 text-xs font-medium">
|
||||||
<span className="w-2 h-2 rounded-full bg-accent shadow-glow" />
|
<div className="relative">
|
||||||
<span>{title}</span>
|
<span className="absolute inset-0 rounded-full bg-accent blur-[6px] opacity-70" />
|
||||||
|
<Activity
|
||||||
|
size={12}
|
||||||
|
className="relative text-accent"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="uppercase tracking-[0.18em] text-muted font-display font-semibold">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="titlebar-nodrag flex items-center gap-1">
|
<div className="titlebar-nodrag flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -24,7 +33,7 @@ export function Titlebar({ title }: { title: string }): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.api.closeMain()}
|
onClick={() => window.api.closeMain()}
|
||||||
className="w-9 h-7 grid place-items-center rounded-md hover:bg-red-500/80 hover:text-white transition-colors text-muted"
|
className="w-9 h-7 grid place-items-center rounded-md hover:bg-defeat/80 hover:text-white transition-colors text-muted"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'victory'
|
||||||
type Size = 'sm' | 'md' | 'lg'
|
type Size = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
@@ -10,11 +10,13 @@ type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|||||||
|
|
||||||
const variantClasses: Record<Variant, string> = {
|
const variantClasses: Record<Variant, string> = {
|
||||||
primary:
|
primary:
|
||||||
'bg-accent text-white hover:brightness-110 active:brightness-95 shadow-soft',
|
'bg-gradient-brand text-white shadow-glow hover:shadow-glow-lg hover:brightness-110 active:brightness-95',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border',
|
'bg-surface-elevated text-text hover:bg-surface-elevated/80 border border-border hover:border-accent/40',
|
||||||
ghost: 'text-muted hover:text-text hover:bg-surface-elevated',
|
ghost: 'text-muted hover:text-text hover:bg-surface-elevated',
|
||||||
danger: 'bg-red-500 text-white hover:bg-red-600 shadow-soft'
|
danger: 'bg-defeat text-white hover:brightness-110 shadow-soft',
|
||||||
|
victory:
|
||||||
|
'bg-gradient-victory text-white shadow-glow-victory hover:brightness-110'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses: Record<Size, string> = {
|
const sizeClasses: Record<Size, string> = {
|
||||||
@@ -31,7 +33,7 @@ export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
|||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={[
|
className={[
|
||||||
'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold tracking-wide transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
variantClasses[variant],
|
variantClasses[variant],
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
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 { useAppStore } from '../store/appStore'
|
||||||
import { ExerciseCard } from '../components/ExerciseCard'
|
import { ExerciseCard } from '../components/ExerciseCard'
|
||||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import type { Exercise } from '@shared/types'
|
import type { Exercise } from '@shared/types'
|
||||||
import { formatCountdown } from '../lib/format'
|
import { formatCountdown, formatInterval } from '../lib/format'
|
||||||
|
|
||||||
export default function Dashboard(): JSX.Element {
|
export default function Dashboard(): JSX.Element {
|
||||||
const state = useAppStore((s) => s.state)
|
const state = useAppStore((s) => s.state)
|
||||||
@@ -16,16 +25,27 @@ export default function Dashboard(): JSX.Element {
|
|||||||
|
|
||||||
const exercises = state?.exercises ?? []
|
const exercises = state?.exercises ?? []
|
||||||
const settings = state?.settings
|
const settings = state?.settings
|
||||||
|
const challenges = state?.challenges ?? []
|
||||||
|
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const enabled = exercises.filter((e) => e.enabled)
|
const enabled = exercises.filter((e) => e.enabled)
|
||||||
const next = enabled
|
const next = enabled
|
||||||
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
||||||
.sort((a, b) => a.ms - b.ms)[0]
|
.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 {
|
return {
|
||||||
total: exercises.length,
|
total: exercises.length,
|
||||||
active: enabled.length,
|
active: enabled.length,
|
||||||
nextMs: next?.ms ?? Infinity
|
nextMs: next?.ms ?? Infinity,
|
||||||
|
totalReps,
|
||||||
|
avgInterval
|
||||||
}
|
}
|
||||||
}, [exercises, ticks])
|
}, [exercises, ticks])
|
||||||
|
|
||||||
@@ -63,18 +83,30 @@ export default function Dashboard(): JSX.Element {
|
|||||||
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paused = !settings?.globalEnabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 overflow-y-auto h-full">
|
<div className="p-8 overflow-y-auto h-full">
|
||||||
|
{/* Hero header */}
|
||||||
<div className="flex items-end justify-between mb-6">
|
<div className="flex items-end justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Дашборд</h1>
|
<div className="text-[10px] uppercase tracking-[0.22em] text-muted font-semibold mb-2">
|
||||||
<p className="text-sm text-muted mt-1">
|
Mission control
|
||||||
{stats.active} активных из {stats.total} упражнений
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="secondary" onClick={togglePause}>
|
<Button variant="secondary" onClick={togglePause}>
|
||||||
{settings?.globalEnabled ? (
|
{!paused ? (
|
||||||
<>
|
<>
|
||||||
<Pause size={16} /> Пауза
|
<Pause size={16} /> Пауза
|
||||||
</>
|
</>
|
||||||
@@ -90,27 +122,78 @@ export default function Dashboard(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 rounded-2xl border border-border bg-surface px-5 py-4 flex items-center gap-4">
|
{/* HUD stat strip */}
|
||||||
<div className="w-11 h-11 rounded-xl bg-accent/15 text-accent grid place-items-center">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 mb-6">
|
||||||
<Clock size={20} />
|
<HudStat
|
||||||
</div>
|
icon={<Timer size={18} />}
|
||||||
<div className="flex-1">
|
label="Cooldown"
|
||||||
<div className="text-xs text-muted uppercase tracking-wider">
|
value={
|
||||||
Ближайшее напоминание
|
stats.nextMs === Infinity
|
||||||
</div>
|
? '—'
|
||||||
<div className="text-lg font-semibold mt-0.5">
|
: stats.nextMs <= 0
|
||||||
{stats.nextMs === Infinity
|
? 'READY'
|
||||||
? 'Нет активных упражнений'
|
: formatCountdown(stats.nextMs)
|
||||||
: `через ${formatCountdown(stats.nextMs)}`}
|
}
|
||||||
</div>
|
accent={stats.nextMs <= 0 && stats.nextMs !== Infinity}
|
||||||
</div>
|
/>
|
||||||
{!settings?.globalEnabled && (
|
<HudStat
|
||||||
<div className="px-3 py-1.5 rounded-full bg-amber-500/15 text-amber-500 text-xs font-medium">
|
icon={<Activity size={18} />}
|
||||||
Напоминания на паузе
|
label="Активных"
|
||||||
</div>
|
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>
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{exercises.map((ex) => (
|
{exercises.map((ex) => (
|
||||||
@@ -128,8 +211,16 @@ export default function Dashboard(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{exercises.length === 0 && (
|
{exercises.length === 0 && (
|
||||||
<div className="mt-10 text-center text-muted">
|
<div className="mt-12 text-center">
|
||||||
<p>Нет упражнений. Добавьте первое.</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -142,3 +233,54 @@ export default function Dashboard(): JSX.Element {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { ReactNode, useEffect, useState } from 'react'
|
import { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useAppStore } from '../store/appStore'
|
import { useAppStore } from '../store/appStore'
|
||||||
|
|
||||||
function hexToRgbString(hex: string): string {
|
|
||||||
const cleaned = hex.replace('#', '').slice(0, 6).padEnd(6, '0')
|
|
||||||
const r = parseInt(cleaned.slice(0, 2), 16)
|
|
||||||
const g = parseInt(cleaned.slice(2, 4), 16)
|
|
||||||
const b = parseInt(cleaned.slice(4, 6), 16)
|
|
||||||
return `${r} ${g} ${b}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }): JSX.Element {
|
export function ThemeProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||||
const settings = useAppStore((s) => s.state?.settings)
|
const settings = useAppStore((s) => s.state?.settings)
|
||||||
const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')
|
const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')
|
||||||
@@ -19,19 +11,8 @@ export function ThemeProvider({ children }: { children: ReactNode }): JSX.Elemen
|
|||||||
return unsub
|
return unsub
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
// Brand palette is fixed (cyan + violet neon). We deliberately do not
|
||||||
window.api.getAccentColor().then((color) => {
|
// overwrite --accent with the OS accent — keeping the esports HUD identity.
|
||||||
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
'--accent-soft',
|
|
||||||
hexToRgbString(color)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const unsub = window.api.onAccentChanged((color) => {
|
|
||||||
document.documentElement.style.setProperty('--accent', hexToRgbString(color))
|
|
||||||
})
|
|
||||||
return unsub
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pref = settings?.theme ?? 'system'
|
const pref = settings?.theme ?? 'system'
|
||||||
|
|||||||
@@ -3,30 +3,36 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Default accent (Windows blue), overridden at runtime via systemPreferences.getAccentColor */
|
/* Brand neon palette — overridden at runtime if user picks OS accent */
|
||||||
--accent: 91 141 239;
|
--accent: 34 211 238; /* cyan-400 — primary energy */
|
||||||
--accent-soft: 91 141 239;
|
--accent-soft: 34 211 238;
|
||||||
|
--accent-2: 168 85 247; /* violet-500 — gradient pair */
|
||||||
|
--victory: 132 204 22; /* lime-500 — sport / done */
|
||||||
|
--defeat: 244 63 94; /* rose-500 — danger */
|
||||||
|
--xp: 250 204 21; /* amber-400 — streak */
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme (default) */
|
/* Light theme — kept clean and modern, sport vibe */
|
||||||
:root {
|
:root {
|
||||||
--bg: 245 247 251;
|
--bg: 244 246 252;
|
||||||
|
--bg-deep: 230 234 244;
|
||||||
--surface: 255 255 255;
|
--surface: 255 255 255;
|
||||||
--surface-elevated: 255 255 255;
|
--surface-elevated: 248 250 254;
|
||||||
--border: 226 230 240;
|
--border: 224 228 240;
|
||||||
--text: 17 24 39;
|
--text: 13 18 32;
|
||||||
--muted: 107 114 128;
|
--muted: 102 112 134;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme */
|
/* Dark theme — esports HUD vibe (default for gamers) */
|
||||||
.dark {
|
.dark {
|
||||||
--bg: 15 17 23;
|
--bg: 8 11 20;
|
||||||
--surface: 24 27 35;
|
--bg-deep: 4 6 12;
|
||||||
--surface-elevated: 32 36 47;
|
--surface: 16 20 33;
|
||||||
--border: 45 50 64;
|
--surface-elevated: 22 27 44;
|
||||||
--text: 235 238 245;
|
--border: 38 46 70;
|
||||||
--muted: 148 156 173;
|
--text: 232 237 250;
|
||||||
|
--muted: 138 150 178;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -39,11 +45,48 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
font-family: 'Inter', 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||||
background: rgb(var(--bg));
|
|
||||||
color: rgb(var(--text));
|
color: rgb(var(--text));
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: rgb(var(--bg));
|
||||||
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
|
1200px 600px at 85% -10%,
|
||||||
|
rgb(var(--accent) / 0.12),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
900px 500px at -10% 110%,
|
||||||
|
rgb(var(--accent-2) / 0.1),
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
|
1200px 600px at 85% -10%,
|
||||||
|
rgb(var(--accent) / 0.18),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
900px 500px at -10% 110%,
|
||||||
|
rgb(var(--accent-2) / 0.14),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, rgb(var(--bg-deep)) 0%, rgb(var(--bg)) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display font for big numbers / sport headers */
|
||||||
|
.font-display {
|
||||||
|
font-family: 'Rajdhani', 'Inter', 'Segoe UI Variable', sans-serif;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.font-mono-num {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom titlebar drag region */
|
/* Custom titlebar drag region */
|
||||||
@@ -64,9 +107,13 @@ body {
|
|||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgb(var(--border));
|
background: rgb(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgb(var(--muted) / 0.5);
|
background: rgb(var(--accent) / 0.4);
|
||||||
|
background-clip: padding-box;
|
||||||
|
border: 2px solid transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -74,20 +121,138 @@ body {
|
|||||||
|
|
||||||
/* Selection */
|
/* Selection */
|
||||||
::selection {
|
::selection {
|
||||||
background: rgb(var(--accent) / 0.35);
|
background: rgb(var(--accent) / 0.4);
|
||||||
color: rgb(var(--text));
|
color: rgb(var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reminder-window root: rounded corners & subtle border */
|
/* Reminder-window root: neon HUD frame */
|
||||||
.reminder-shell {
|
.reminder-shell {
|
||||||
border: 1px solid rgb(var(--border));
|
position: relative;
|
||||||
border-radius: 18px;
|
border: 1px solid rgb(var(--accent) / 0.5);
|
||||||
background: linear-gradient(
|
border-radius: 20px;
|
||||||
180deg,
|
background:
|
||||||
rgb(var(--surface-elevated)) 0%,
|
radial-gradient(
|
||||||
rgb(var(--surface)) 100%
|
circle at 50% -20%,
|
||||||
);
|
rgb(var(--accent) / 0.22),
|
||||||
box-shadow: 0 24px 60px -20px rgb(0 0 0 / 0.55);
|
transparent 60%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, rgb(var(--surface-elevated)) 0%, rgb(var(--surface)) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(var(--accent) / 0.15),
|
||||||
|
0 20px 80px -20px rgb(var(--accent) / 0.45),
|
||||||
|
0 24px 60px -20px rgb(0 0 0 / 0.6);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Soft scanline texture for HUD surfaces */
|
||||||
|
.hud-scanlines {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgb(var(--text) / 0.03) 0px,
|
||||||
|
rgb(var(--text) / 0.03) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text and gradient brand */
|
||||||
|
.text-gradient-brand {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgb(var(--accent)) 0%,
|
||||||
|
rgb(var(--accent-2)) 100%
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
.bg-gradient-brand {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgb(var(--accent)) 0%,
|
||||||
|
rgb(var(--accent-2)) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.bg-gradient-victory {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgb(var(--victory)) 0%,
|
||||||
|
rgb(var(--accent)) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neon border (animated gradient stroke for "due" / "active" cards) */
|
||||||
|
.neon-border {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
.neon-border::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgb(var(--accent)) 0%,
|
||||||
|
rgb(var(--accent-2)) 60%,
|
||||||
|
rgb(var(--accent)) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: neon-shift 6s linear infinite;
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) content-box,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HUD pulse — soft outer glow for "due" cards */
|
||||||
|
.hud-pulse {
|
||||||
|
animation: hud-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes neon-shift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes hud-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0 rgb(var(--accent) / 0.45),
|
||||||
|
0 12px 30px -10px rgb(var(--accent) / 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 6px rgb(var(--accent) / 0),
|
||||||
|
0 18px 40px -10px rgb(var(--accent) / 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle dot-grid texture (sidebar / hero strip) */
|
||||||
|
.dot-grid {
|
||||||
|
background-image: radial-gradient(
|
||||||
|
rgb(var(--text) / 0.07) 1px,
|
||||||
|
transparent 1px
|
||||||
|
);
|
||||||
|
background-size: 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cooldown ring SVG helpers */
|
||||||
|
.cooldown-track {
|
||||||
|
stroke: rgb(var(--border));
|
||||||
|
}
|
||||||
|
.cooldown-fill {
|
||||||
|
stroke: url(#cooldownGrad);
|
||||||
|
stroke-linecap: round;
|
||||||
|
filter: drop-shadow(0 0 6px rgb(var(--accent) / 0.6));
|
||||||
|
transition: stroke-dashoffset 0.5s linear;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export default {
|
|||||||
colors: {
|
colors: {
|
||||||
accent: 'rgb(var(--accent) / <alpha-value>)',
|
accent: 'rgb(var(--accent) / <alpha-value>)',
|
||||||
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
|
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
|
||||||
|
'accent-2': 'rgb(var(--accent-2) / <alpha-value>)',
|
||||||
|
victory: 'rgb(var(--victory) / <alpha-value>)',
|
||||||
|
defeat: 'rgb(var(--defeat) / <alpha-value>)',
|
||||||
|
xp: 'rgb(var(--xp) / <alpha-value>)',
|
||||||
bg: 'rgb(var(--bg) / <alpha-value>)',
|
bg: 'rgb(var(--bg) / <alpha-value>)',
|
||||||
|
'bg-deep': 'rgb(var(--bg-deep) / <alpha-value>)',
|
||||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||||
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
|
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
|
||||||
border: 'rgb(var(--border) / <alpha-value>)',
|
border: 'rgb(var(--border) / <alpha-value>)',
|
||||||
@@ -15,19 +20,44 @@ export default {
|
|||||||
muted: 'rgb(var(--muted) / <alpha-value>)'
|
muted: 'rgb(var(--muted) / <alpha-value>)'
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'Segoe UI', 'system-ui', 'sans-serif']
|
sans: ['Inter', 'Segoe UI Variable', 'Segoe UI', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['Rajdhani', 'Inter', 'Segoe UI Variable', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'ui-monospace', 'Cascadia Code', 'Menlo', 'monospace']
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
soft: '0 8px 30px -12px rgb(0 0 0 / 0.25)',
|
soft: '0 8px 30px -12px rgb(0 0 0 / 0.35)',
|
||||||
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.5)'
|
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.55)',
|
||||||
|
'glow-lg':
|
||||||
|
'0 0 0 1px rgb(var(--accent) / 0.45), 0 18px 48px -12px rgb(var(--accent) / 0.7)',
|
||||||
|
'glow-victory':
|
||||||
|
'0 0 0 1px rgb(var(--victory) / 0.45), 0 12px 32px -10px rgb(var(--victory) / 0.55)',
|
||||||
|
hud: '0 1px 0 rgb(var(--text) / 0.04) inset, 0 0 0 1px rgb(var(--border) / 0.8), 0 18px 40px -20px rgb(0 0 0 / 0.4)'
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-brand':
|
||||||
|
'linear-gradient(135deg, rgb(var(--accent)) 0%, rgb(var(--accent-2)) 100%)',
|
||||||
|
'gradient-victory':
|
||||||
|
'linear-gradient(135deg, rgb(var(--victory)) 0%, rgb(var(--accent)) 100%)',
|
||||||
|
'gradient-defeat':
|
||||||
|
'linear-gradient(135deg, rgb(var(--defeat)) 0%, rgb(var(--accent-2)) 100%)'
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'pulse-ring': 'pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'
|
'pulse-ring': 'pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
shimmer: 'shimmer 2.5s linear infinite',
|
||||||
|
'neon-shift': 'neon-shift 6s linear infinite'
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'pulse-ring': {
|
'pulse-ring': {
|
||||||
'0%, 100%': { transform: 'scale(1)', opacity: '0.7' },
|
'0%, 100%': { transform: 'scale(1)', opacity: '0.7' },
|
||||||
'50%': { transform: 'scale(1.05)', opacity: '0.3' }
|
'50%': { transform: 'scale(1.1)', opacity: '0.25' }
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
'0%': { backgroundPosition: '-200% 0' },
|
||||||
|
'100%': { backgroundPosition: '200% 0' }
|
||||||
|
},
|
||||||
|
'neon-shift': {
|
||||||
|
'0%': { backgroundPosition: '0% 50%' },
|
||||||
|
'100%': { backgroundPosition: '200% 50%' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user