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

@@ -7,7 +7,7 @@
<title>Exercise Reminder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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>
<body>
<div id="root"></div>

View File

@@ -1,6 +1,15 @@
import { useEffect, useRef, useState } from 'react'
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 { Icon } from './lib/icon'
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 {
setMode({ kind: 'idle' })
window.api.reminderClose()
@@ -91,11 +120,14 @@ function ExerciseReminder({
}
return (
<div className="reminder-shell flex flex-col h-full">
<div className="titlebar-drag h-8 px-3 flex items-center justify-end">
<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="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
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="Закрыть"
>
<X size={13} />
@@ -103,44 +135,85 @@ function ExerciseReminder({
</div>
<div className="flex-1 flex flex-col items-center justify-center px-10 text-center">
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 18 }}
className="relative mb-5"
initial={{ scale: 0.6, opacity: 0, rotate: -8 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 200, damping: 16 }}
className="relative mb-6"
>
<div className="absolute inset-0 rounded-full bg-accent/30 animate-pulse-ring" />
<div className="relative w-24 h-24 rounded-full bg-accent text-white grid place-items-center shadow-glow">
<Icon name={exercise.icon} size={44} />
{/* Outer rotating ring */}
<motion.div
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>
</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-5xl font-extrabold text-accent tabular-nums mt-1">
<div className="text-[10px] uppercase tracking-[0.28em] text-muted font-semibold">
Время размяться
</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}
<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 className="text-xs text-muted mt-2">
Следующее напоминание через {formatInterval(exercise.intervalMinutes)}
<div className="text-[11px] text-muted mt-4 inline-flex items-center gap-1.5">
<Clock size={10} />
Next drop через {formatInterval(exercise.intervalMinutes)}
</div>
</div>
<div className="px-6 pb-6 grid grid-cols-3 gap-2">
<button
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} /> Пропустить
</span>
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
ESC
</span>
</button>
<button
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}м
</span>
<span className="text-[9px] opacity-50 font-mono-num group-hover:opacity-100">
SPACE
</span>
</button>
<button
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} /> Сделал
</span>
<span className="text-[9px] opacity-70 font-mono-num">ENTER</span>
</button>
</div>
</div>
@@ -163,16 +236,24 @@ function MatchSummaryView({
const remainingReps = summary.results
.filter((r) => !done.has(r.challengeId))
.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 (
<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="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}
</div>
<button
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="Закрыть"
>
<X size={13} />
@@ -181,38 +262,48 @@ function MatchSummaryView({
<div className="px-6 pt-2 pb-4 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 20 }}
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-accent/15 text-accent mb-3"
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl text-white mb-3"
>
{summary.won === true ? (
<Trophy size={28} />
) : summary.won === false ? (
<Skull size={28} />
<div className={`absolute inset-0 rounded-2xl ${heroGradient} blur-md opacity-70`} />
<div className={`relative w-16 h-16 rounded-2xl ${heroGradient} grid place-items-center shadow-glow`}>
{won ? (
<Trophy size={30} />
) : lost ? (
<Skull size={30} />
) : (
<Gamepad2 size={28} />
<Gamepad2 size={30} />
)}
</div>
</motion.div>
<h1 className="text-xl font-bold">
{summary.won === true
? 'Победа! Время заработанных упражнений'
: summary.won === false
? 'Поражение. Но тело — нет'
<h1 className="font-display font-bold text-xl uppercase tracking-wide">
{won
? 'Victory · упражнения заработаны'
: lost
? 'Defeat · но тело — нет'
: 'Матч завершён'}
</h1>
<p className="text-xs text-muted mt-1">
{Math.floor(summary.durationMs / 60_000)} мин · {summary.results.length}{' '}
<p className="text-[11px] text-muted mt-1.5">
<span className="font-mono-num font-semibold text-text">
{Math.floor(summary.durationMs / 60_000)}
</span>{' '}
мин · {summary.results.length}{' '}
челлендж{summary.results.length === 1 ? '' : 'а'} ·{' '}
{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>
</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) => (
<ChallengeRow
key={r.challengeId}
@@ -223,18 +314,24 @@ function MatchSummaryView({
))}
</div>
<div className="px-6 pb-6 pt-3 flex items-center gap-2">
<div className="flex-1 text-xs text-muted">
Всего: <span className="text-text font-semibold">{totalReps}</span>{' '}
повторений
<div className="px-6 pb-6 pt-3 flex items-center gap-3 border-t border-border/40">
<div className="flex-1 text-[11px] text-muted uppercase tracking-[0.15em] font-semibold">
Total ·{' '}
<span className="text-gradient-brand font-mono-num text-base font-bold tracking-normal">
{totalReps}
</span>{' '}
reps
</div>
<button
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 ? (
<>
<Check size={16} /> Готово
<Check size={16} /> GG
</>
) : (
'Позже'
@@ -262,27 +359,41 @@ function ChallengeRow({
className={[
'flex items-center gap-3 rounded-xl p-3 border transition-colors',
done
? 'border-emerald-500/40 bg-emerald-500/10'
: 'border-border bg-surface-elevated'
? 'border-victory/40 bg-victory/10'
: 'border-border/70 bg-surface-elevated/60'
].join(' ')}
>
<div
className={[
'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(' ')}
>
<Icon name={result.icon} size={22} />
</div>
<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}
</div>
<div className="text-xs text-muted mt-0.5">
{result.statValue} {result.statLabel} {result.name}
<div className="text-[11px] text-muted mt-0.5">
<span className="font-mono-num font-bold text-text">
{result.statValue}
</span>{' '}
{result.statLabel} {' '}
<span className="text-accent">{result.name}</span>
</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}
</div>
<button
@@ -291,8 +402,8 @@ function ChallengeRow({
className={[
'h-9 w-9 grid place-items-center rounded-lg transition-colors',
done
? 'bg-emerald-500 text-white cursor-default'
: 'bg-accent text-white hover:brightness-110'
? 'bg-victory text-white cursor-default'
: 'bg-gradient-brand text-white hover:brightness-110 shadow-glow'
].join(' ')}
aria-label="Готово"
>

View File

@@ -1,5 +1,5 @@
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 { Icon } from '../lib/icon'
import { formatCountdown, formatInterval } from '../lib/format'
@@ -23,39 +23,100 @@ export function ExerciseCard({
onMarkDone
}: Props): JSX.Element {
const ms = tick?.msUntilFire ?? exercise.nextFireAt - Date.now()
const progressPct = (() => {
const total = exercise.intervalMinutes * 60_000
const remaining = Math.max(0, Math.min(total, ms))
const elapsed = total - remaining
return Math.max(0, Math.min(100, (elapsed / total) * 100))
})()
const elapsedPct = total > 0 ? 1 - remaining / total : 0
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 (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
whileHover={{ y: -2 }}
transition={{ type: 'spring', stiffness: 280, damping: 24 }}
className={[
'group rounded-2xl border bg-surface p-5 flex flex-col gap-4 transition-shadow',
isDue ? 'border-accent shadow-glow' : 'border-border hover:shadow-soft'
'group relative rounded-2xl border bg-surface/80 backdrop-blur-sm p-5 flex flex-col gap-4',
'transition-shadow',
isDue
? 'neon-border hud-pulse border-transparent'
: 'border-border/70 hover:border-accent/40 hover:shadow-soft'
].join(' ')}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{/* Glow corner accent */}
<div
className={[
'w-12 h-12 rounded-xl grid place-items-center',
exercise.enabled ? 'bg-accent/15 text-accent' : 'bg-surface-elevated text-muted'
'absolute -top-12 -right-12 w-32 h-32 rounded-full blur-3xl pointer-events-none transition-opacity',
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(' ')}
>
<Icon name={exercise.icon} size={22} />
<Icon name={exercise.icon} size={20} />
</div>
<div>
<div className="font-semibold leading-tight">{exercise.name}</div>
<div className="text-xs text-muted mt-0.5">
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
</div>
<div className="min-w-0">
<div className="font-semibold leading-tight truncate font-display text-lg tracking-wide">
{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>
@@ -66,46 +127,49 @@ export function ExerciseCard({
/>
</div>
<div>
<div className="relative">
<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
className={[
'text-sm font-mono font-semibold tabular-nums',
'text-sm font-mono-num font-bold',
isDue ? 'text-accent' : 'text-text'
].join(' ')}
>
{exercise.enabled ? formatCountdown(ms) : 'пауза'}
{exercise.enabled ? formatCountdown(ms) : 'PAUSED'}
</span>
</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
className="h-full rounded-full bg-accent"
animate={{ width: `${exercise.enabled ? progressPct : 0}%` }}
className={[
'h-full rounded-full',
isDue ? 'bg-gradient-brand' : 'bg-accent'
].join(' ')}
animate={{ width: `${exercise.enabled ? elapsedPct * 100 : 0}%` }}
transition={{ duration: 0.5, ease: 'linear' }}
/>
</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
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
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="Редактировать"
>
<Pencil size={14} />
</button>
<button
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="Удалить"
>
<Trash2 size={14} />

View File

@@ -4,7 +4,8 @@ import {
ListChecks,
Gamepad2,
Target,
Settings as SettingsIcon
Settings as SettingsIcon,
Dumbbell
} from 'lucide-react'
const links = [
@@ -17,19 +18,32 @@ const links = [
export function Sidebar(): JSX.Element {
return (
<aside className="w-56 shrink-0 border-r border-border/60 bg-surface/40 flex flex-col">
<div className="px-5 py-5">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-accent grid place-items-center text-white font-bold shadow-glow">
R
<aside className="w-60 shrink-0 border-r border-border/60 bg-surface/40 backdrop-blur-sm flex flex-col relative">
<div className="absolute inset-0 dot-grid opacity-40 pointer-events-none" />
<div className="relative px-5 py-5">
<div className="flex items-center gap-3">
<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 className="font-semibold text-sm leading-tight">Reminder</div>
<div className="text-xs text-muted">Будь в движении</div>
<div className="font-display font-bold text-lg leading-none uppercase tracking-wider">
<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>
<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 }) => (
<NavLink
key={to}
@@ -37,20 +51,50 @@ export function Sidebar(): JSX.Element {
end={end}
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
? 'bg-accent/15 text-accent'
: 'text-muted hover:text-text hover:bg-surface-elevated'
? 'text-text bg-surface-elevated/80'
: 'text-muted hover:text-text hover:bg-surface-elevated/50'
].join(' ')
}
>
<Icon size={18} />
{label}
{({ isActive }) => (
<>
<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>
))}
</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>
</aside>
)

View File

@@ -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 {
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="flex items-center gap-2 text-sm font-medium text-muted">
<span className="w-2 h-2 rounded-full bg-accent shadow-glow" />
<span>{title}</span>
<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-xs font-medium">
<div className="relative">
<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 className="titlebar-nodrag flex items-center gap-1">
<button
@@ -24,7 +33,7 @@ export function Titlebar({ title }: { title: string }): JSX.Element {
</button>
<button
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="Закрыть"
>
<X size={14} />

View File

@@ -1,6 +1,6 @@
import { ButtonHTMLAttributes, forwardRef } from 'react'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'victory'
type Size = 'sm' | 'md' | 'lg'
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
@@ -10,11 +10,13 @@ type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
const variantClasses: Record<Variant, string> = {
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:
'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',
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> = {
@@ -31,7 +33,7 @@ export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
<button
ref={ref}
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],
sizeClasses[size],
className

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>
)
}

View File

@@ -1,14 +1,6 @@
import { ReactNode, useEffect, useState } from 'react'
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 {
const settings = useAppStore((s) => s.state?.settings)
const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')
@@ -19,19 +11,8 @@ export function ThemeProvider({ children }: { children: ReactNode }): JSX.Elemen
return unsub
}, [])
useEffect(() => {
window.api.getAccentColor().then((color) => {
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
}, [])
// Brand palette is fixed (cyan + violet neon). We deliberately do not
// overwrite --accent with the OS accent — keeping the esports HUD identity.
useEffect(() => {
const pref = settings?.theme ?? 'system'

View File

@@ -3,30 +3,36 @@
@tailwind utilities;
:root {
/* Default accent (Windows blue), overridden at runtime via systemPreferences.getAccentColor */
--accent: 91 141 239;
--accent-soft: 91 141 239;
/* Brand neon palette — overridden at runtime if user picks OS accent */
--accent: 34 211 238; /* cyan-400 — primary energy */
--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;
}
/* Light theme (default) */
/* Light theme — kept clean and modern, sport vibe */
:root {
--bg: 245 247 251;
--bg: 244 246 252;
--bg-deep: 230 234 244;
--surface: 255 255 255;
--surface-elevated: 255 255 255;
--border: 226 230 240;
--text: 17 24 39;
--muted: 107 114 128;
--surface-elevated: 248 250 254;
--border: 224 228 240;
--text: 13 18 32;
--muted: 102 112 134;
}
/* Dark theme */
/* Dark theme — esports HUD vibe (default for gamers) */
.dark {
--bg: 15 17 23;
--surface: 24 27 35;
--surface-elevated: 32 36 47;
--border: 45 50 64;
--text: 235 238 245;
--muted: 148 156 173;
--bg: 8 11 20;
--bg-deep: 4 6 12;
--surface: 16 20 33;
--surface-elevated: 22 27 44;
--border: 38 46 70;
--text: 232 237 250;
--muted: 138 150 178;
}
html,
@@ -39,11 +45,48 @@ body,
}
body {
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
background: rgb(var(--bg));
font-family: 'Inter', 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
color: rgb(var(--text));
-webkit-font-smoothing: antialiased;
-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 */
@@ -64,9 +107,13 @@ body {
::-webkit-scrollbar-thumb {
background: rgb(var(--border));
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-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 {
background: transparent;
@@ -74,20 +121,138 @@ body {
/* Selection */
::selection {
background: rgb(var(--accent) / 0.35);
background: rgb(var(--accent) / 0.4);
color: rgb(var(--text));
}
/* Reminder-window root: rounded corners & subtle border */
/* Reminder-window root: neon HUD frame */
.reminder-shell {
border: 1px solid rgb(var(--border));
border-radius: 18px;
background: linear-gradient(
180deg,
rgb(var(--surface-elevated)) 0%,
rgb(var(--surface)) 100%
);
box-shadow: 0 24px 60px -20px rgb(0 0 0 / 0.55);
position: relative;
border: 1px solid rgb(var(--accent) / 0.5);
border-radius: 20px;
background:
radial-gradient(
circle at 50% -20%,
rgb(var(--accent) / 0.22),
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;
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;
}

View File

@@ -7,7 +7,12 @@ export default {
colors: {
accent: 'rgb(var(--accent) / <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-deep': 'rgb(var(--bg-deep) / <alpha-value>)',
surface: 'rgb(var(--surface) / <alpha-value>)',
'surface-elevated': 'rgb(var(--surface-elevated) / <alpha-value>)',
border: 'rgb(var(--border) / <alpha-value>)',
@@ -15,19 +20,44 @@ export default {
muted: 'rgb(var(--muted) / <alpha-value>)'
},
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: {
soft: '0 8px 30px -12px rgb(0 0 0 / 0.25)',
glow: '0 0 0 1px rgb(var(--accent) / 0.4), 0 8px 24px -8px rgb(var(--accent) / 0.5)'
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.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: {
'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: {
'pulse-ring': {
'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%' }
}
}
}