From a1fb777dd39afdbff064ea60a4df2cee5c67685c Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Wed, 11 Mar 2026 21:42:23 +0100 Subject: [PATCH 1/8] feat: add light theme --- client/app/app.css | 115 +++++++++++++++++++- client/app/components/GameModeSelector.tsx | 18 +-- client/app/components/LoadingOverlay.tsx | 6 +- client/app/components/ThemeToggle.tsx | 47 ++++++++ client/app/components/auth/LoginForm.tsx | 26 ++--- client/app/components/auth/RegisterForm.tsx | 34 +++--- client/app/components/game/GameBoard.tsx | 40 +++---- client/app/components/game/GamePage.tsx | 28 ++--- client/app/components/game/OpponentCard.tsx | 18 +-- client/app/components/game/PiecePreview.tsx | 8 +- client/app/components/game/RoomControls.tsx | 56 +++++----- client/app/hooks/useTheme.ts | 30 +++++ client/app/root.tsx | 15 ++- client/app/welcome/welcome.tsx | 80 +++++++------- 14 files changed, 356 insertions(+), 165 deletions(-) create mode 100644 client/app/components/ThemeToggle.tsx create mode 100644 client/app/hooks/useTheme.ts diff --git a/client/app/app.css b/client/app/app.css index d6d62ca..111d7f4 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -3,14 +3,119 @@ @theme { --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + + /* ── Semantic surface colors ── */ + --color-page: var(--page); + --color-surface: var(--surface); + --color-surface-alt: var(--surface-alt); + --color-surface-hover: var(--surface-hover); + + /* ── Semantic foreground (text) colors ── */ + --color-on-surface: var(--on-surface); + --color-on-surface-variant: var(--on-surface-variant); + --color-on-surface-muted: var(--on-surface-muted); + --color-on-surface-faint: var(--on-surface-faint); + + /* ── Borders ── */ + --color-outline: var(--outline); + --color-outline-variant: var(--outline-variant); + + /* ── Overlay ── */ + --color-scrim: var(--scrim); + + /* ── Status colors ── */ + --color-status-success-bg: var(--status-success-bg); + --color-status-success-border: var(--status-success-border); + --color-status-success-text: var(--status-success-text); + --color-status-error-bg: var(--status-error-bg); + --color-status-error-border: var(--status-error-border); + --color-status-error-text: var(--status-error-text); +} + +/* ── Light theme (default) ── */ +:root { + --page: #f1f5f9; + --surface: #ffffff; + --surface-alt: #f1f5f9; + --surface-hover: #e2e8f0; + --on-surface: #0f172a; + --on-surface-variant: #475569; + --on-surface-muted: #64748b; + --on-surface-faint: #94a3b8; + --outline: #cbd5e1; + --outline-variant: #e2e8f0; + --scrim: rgba(0, 0, 0, 0.15); + + --status-success-bg: #f0fdf4; + --status-success-border: #bbf7d0; + --status-success-text: #15803d; + --status-error-bg: #fef2f2; + --status-error-border: #fecaca; + --status-error-text: #dc2626; + + /* Game-board cell tokens */ + --cell-grid: rgba(0, 0, 0, 0.08); + --cell-ghost-bg: rgba(0, 0, 0, 0.05); + --cell-ghost-border-light: rgba(0, 0, 0, 0.06); + --cell-ghost-border-dark: rgba(0, 0, 0, 0.1); + --cell-penalty-bg: #cbd5e1; + --cell-penalty-border-light: rgba(255, 255, 255, 0.4); + --cell-penalty-border-dark: rgba(0, 0, 0, 0.15); + --piece-border-light: rgba(255, 255, 255, 0.25); + --piece-border-dark: rgba(0, 0, 0, 0.2); + --mini-board-bg: #e2e8f0; + --opponent-ghost-alive: rgba(0, 0, 0, 0.04); + --opponent-ghost-dead: rgba(0, 0, 0, 0.02); + --opponent-penalty-alive: #cbd5e1; + --opponent-penalty-dead: #94a3b8; + --opponent-dead-piece: #9ca3af; + + color-scheme: light; +} + +/* ── Dark theme ── */ +.dark { + --page: #0f172a; + --surface: #111827; + --surface-alt: #1f2937; + --surface-hover: #374151; + --on-surface: #ffffff; + --on-surface-variant: #d1d5db; + --on-surface-muted: #9ca3af; + --on-surface-faint: #6b7280; + --outline: #4b5563; + --outline-variant: rgba(255, 255, 255, 0.1); + --scrim: rgba(0, 0, 0, 0.4); + + --status-success-bg: rgba(34, 197, 94, 0.15); + --status-success-border: #166534; + --status-success-text: #4ade80; + --status-error-bg: rgba(239, 68, 68, 0.15); + --status-error-border: #991b1b; + --status-error-text: #fca5a5; + + --cell-grid: rgba(255, 255, 255, 0.12); + --cell-ghost-bg: rgba(255, 255, 255, 0.12); + --cell-ghost-border-light: rgba(255, 255, 255, 0.15); + --cell-ghost-border-dark: rgba(0, 0, 0, 0.3); + --cell-penalty-bg: #374151; + --cell-penalty-border-light: rgba(255, 255, 255, 0.08); + --cell-penalty-border-dark: rgba(0, 0, 0, 0.3); + --piece-border-light: rgba(255, 255, 255, 0.15); + --piece-border-dark: rgba(0, 0, 0, 0.3); + --mini-board-bg: #000000; + --opponent-ghost-alive: rgba(255, 255, 255, 0.06); + --opponent-ghost-dead: rgba(255, 255, 255, 0.03); + --opponent-penalty-alive: #111111; + --opponent-penalty-dead: #444444; + --opponent-dead-piece: #777777; + + color-scheme: dark; } html, body { - background: #0f172a; + background: var(--page); + color: var(--on-surface); min-height: 100vh; - - @media (prefers-color-scheme: dark) { - color-scheme: dark; - } } diff --git a/client/app/components/GameModeSelector.tsx b/client/app/components/GameModeSelector.tsx index f494c74..dff4732 100644 --- a/client/app/components/GameModeSelector.tsx +++ b/client/app/components/GameModeSelector.tsx @@ -25,16 +25,16 @@ export function GameModeSelector({ selected, onChange, disabled, compact }: Game className={`relative rounded-lg p-2.5 text-left transition-all border ${ isActive ? `${info.accentBg} ${info.accentBorder} ring-1 ring-offset-0 ring-current ${info.accentColor}` - : 'bg-gray-800 border-gray-600 hover:border-gray-500' + : 'bg-surface-alt border-outline hover:border-on-surface-faint' } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} >
{info.icon} - + {info.label}
-
{info.boardLabel}
+
{info.boardLabel}
); })} @@ -59,22 +59,22 @@ export function GameModeSelector({ selected, onChange, disabled, compact }: Game className={`relative rounded-lg p-3 text-left transition-all border ${ isActive ? `${info.accentBg} ${info.accentBorder} ring-1 ring-offset-0 ring-current ${info.accentColor}` - : 'bg-gray-800 border-gray-600 hover:border-gray-500 hover:bg-gray-800/80' + : 'bg-surface-alt border-outline hover:border-on-surface-faint hover:bg-surface-alt/80' } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} >
{info.icon}
- + {info.label} -
- {info.boardLabel} - {pieceCount} pcs +
+ {info.boardLabel} + {pieceCount} pcs
-

{info.description}

+

{info.description}

diff --git a/client/app/components/LoadingOverlay.tsx b/client/app/components/LoadingOverlay.tsx index 776469b..2075db4 100644 --- a/client/app/components/LoadingOverlay.tsx +++ b/client/app/components/LoadingOverlay.tsx @@ -1,10 +1,10 @@ export function LoadingOverlay() { return ( -
-
+
+
-

Loading...

+

Loading...

diff --git a/client/app/components/ThemeToggle.tsx b/client/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..f2c1903 --- /dev/null +++ b/client/app/components/ThemeToggle.tsx @@ -0,0 +1,47 @@ +interface ThemeToggleProps { + theme: 'light' | 'dark'; + toggle: () => void; +} + +export function ThemeToggle({ theme, toggle }: ThemeToggleProps) { + return ( + + ); +} diff --git a/client/app/components/auth/LoginForm.tsx b/client/app/components/auth/LoginForm.tsx index b24b9de..50f8412 100644 --- a/client/app/components/auth/LoginForm.tsx +++ b/client/app/components/auth/LoginForm.tsx @@ -28,17 +28,17 @@ export function LoginForm() { }; return ( -
+
{/* Header */}

RED TETRIS

-

Welcome Back!

-

Sign in to continue your game

+

Welcome Back!

+

Sign in to continue your game

{/* Login Form */} -
+
{ @@ -46,8 +46,8 @@ export function LoginForm() { }} > {error && ( -
-
{error}
+
+
{error}
)} @@ -55,7 +55,7 @@ export function LoginForm() {
@@ -64,7 +64,7 @@ export function LoginForm() { name='username' type='text' required - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your username' disabled={isLoading} /> @@ -72,7 +72,7 @@ export function LoginForm() {
@@ -81,7 +81,7 @@ export function LoginForm() { name='password' type='password' required - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your password' disabled={isLoading} /> @@ -91,7 +91,7 @@ export function LoginForm() { -
-

+

+

Don't have an account?{' '} +

{/* Header */}

RED TETRIS

-

Join the Game!

-

Create your account to start playing

+

Join the Game!

+

Create your account to start playing

{/* Register Form */} -
+
{ @@ -58,8 +58,8 @@ export function RegisterForm() { }} > {error && ( -
-
{error}
+
+
{error}
)} @@ -67,7 +67,7 @@ export function RegisterForm() {
@@ -79,7 +79,7 @@ export function RegisterForm() { minLength={3} maxLength={50} pattern='[a-zA-Z0-9_-]+' - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Choose a unique username (3-50 chars)' disabled={isLoading} /> @@ -87,7 +87,7 @@ export function RegisterForm() {
@@ -96,7 +96,7 @@ export function RegisterForm() { name='email' type='email' required - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your email address' disabled={isLoading} /> @@ -104,7 +104,7 @@ export function RegisterForm() {
@@ -114,7 +114,7 @@ export function RegisterForm() { type='password' required minLength={6} - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Create a strong password (6+ chars)' disabled={isLoading} /> @@ -122,7 +122,7 @@ export function RegisterForm() {
@@ -132,7 +132,7 @@ export function RegisterForm() { type='password' required minLength={6} - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Confirm your password' disabled={isLoading} /> @@ -142,7 +142,7 @@ export function RegisterForm() { -
-

+

+

Already have an account?{' '}

-
-
- {score} -
Score
+
+
+ {score} +
Score
@@ -70,7 +70,7 @@ function renderCell( width: cellSize, height: cellSize, boxSizing: 'border-box', - outline: '1px solid rgba(255,255,255,0.12)', + outline: '1px solid var(--cell-grid)', }} /> ); @@ -85,11 +85,11 @@ function renderCell( height: cellSize, boxSizing: 'border-box', borderRadius: 2, - background: 'rgba(255,255,255,0.12)', - borderTop: '1px solid rgba(255,255,255,0.15)', - borderLeft: '1px solid rgba(255,255,255,0.1)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + background: 'var(--cell-ghost-bg)', + borderTop: '1px solid var(--cell-ghost-border-light)', + borderLeft: '1px solid var(--cell-ghost-border-light)', + borderBottom: '1px solid var(--cell-ghost-border-dark)', + borderRight: '1px solid var(--cell-ghost-border-dark)', }} /> ); @@ -104,11 +104,11 @@ function renderCell( height: cellSize, boxSizing: 'border-box', borderRadius: 2, - background: '#374151', - borderTop: '1px solid rgba(255,255,255,0.08)', - borderLeft: '1px solid rgba(255,255,255,0.05)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + background: 'var(--cell-penalty-bg)', + borderTop: '1px solid var(--cell-penalty-border-light)', + borderLeft: '1px solid var(--cell-penalty-border-light)', + borderBottom: '1px solid var(--cell-penalty-border-dark)', + borderRight: '1px solid var(--cell-penalty-border-dark)', }} /> ); @@ -124,10 +124,10 @@ function renderCell( boxSizing: 'border-box', borderRadius: 2, background: color, - borderTop: '1px solid rgba(255,255,255,0.15)', - borderLeft: '1px solid rgba(255,255,255,0.1)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + borderTop: '1px solid var(--piece-border-light)', + borderLeft: '1px solid var(--piece-border-light)', + borderBottom: '1px solid var(--piece-border-dark)', + borderRight: '1px solid var(--piece-border-dark)', }} /> ); diff --git a/client/app/components/game/GamePage.tsx b/client/app/components/game/GamePage.tsx index 9b5407a..465f631 100644 --- a/client/app/components/game/GamePage.tsx +++ b/client/app/components/game/GamePage.tsx @@ -51,20 +51,20 @@ export function GamePage() { return (
Back

RED TETRIS

-

Play Mode

+

Play Mode

@@ -82,10 +82,10 @@ export function GamePage() { {/* Center: Game Area */}
{/* Hold Piece */} -
-

Hold

+
+

Hold

{/* Main Game Board */} -
-

Your Board

+
+

Your Board

{modeInfo.icon} {modeInfo.label} - ({modeInfo.boardLabel}) + ({modeInfo.boardLabel})
{/* Next Piece */} -
-

Next

+
+

Next

0 && (
-
-

Opponents

+
+

Opponents

{game.opponents.map((op) => ( 0; cells.push( @@ -53,18 +53,18 @@ export function OpponentCard({ name, board, isAlive, score, isHost, idColors, wi }; return ( -
+
{/* Left: Info */}
- {name} + {name}
- {isHost && Host} -
- Score: {score} + {isHost && Host} +
+ Score: {score}
{/* Right: Board */} -
+
{renderOpponentBoard()}
diff --git a/client/app/components/game/PiecePreview.tsx b/client/app/components/game/PiecePreview.tsx index 4c1c287..95830c1 100644 --- a/client/app/components/game/PiecePreview.tsx +++ b/client/app/components/game/PiecePreview.tsx @@ -37,10 +37,10 @@ export function PiecePreview({ type, shapes, colors, cellSize }: PiecePreviewPro boxSizing: 'border-box', borderRadius: 2, background: color, - borderTop: '1px solid rgba(255,255,255,0.15)', - borderLeft: '1px solid rgba(255,255,255,0.1)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + borderTop: '1px solid var(--piece-border-light)', + borderLeft: '1px solid var(--piece-border-light)', + borderBottom: '1px solid var(--piece-border-dark)', + borderRight: '1px solid var(--piece-border-dark)', }} /> ) : ( diff --git a/client/app/components/game/RoomControls.tsx b/client/app/components/game/RoomControls.tsx index b6e6847..1e621fc 100644 --- a/client/app/components/game/RoomControls.tsx +++ b/client/app/components/game/RoomControls.tsx @@ -47,23 +47,23 @@ export function RoomControls({ if (compact) { return ( -
-

Room Settings

+
+

Room Settings

- + setRoom(e.target.value)} disabled={!!playerId} - className='w-full px-3 py-2 text-sm bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' + className='w-full px-3 py-2 text-sm bg-surface-alt border border-outline rounded-lg text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' placeholder='Room code' />
- + Create @@ -105,7 +105,7 @@ export function RoomControls({ {playerId && ( @@ -130,23 +130,23 @@ export function RoomControls({ /* Desktop variant */ return (
-
-

Room Settings

+
+

Room Settings

- + setRoom(e.target.value)} disabled={!!playerId} - className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' + className='w-full px-3 py-2 bg-surface-alt border border-outline rounded-lg text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' placeholder='Enter room code' />
- + Join Room @@ -175,7 +175,7 @@ export function RoomControls({ @@ -190,7 +190,7 @@ export function RoomControls({ {playerId && ( @@ -233,26 +233,26 @@ function StatusMessages({ return (
{hostId && playerId === hostId && ( -
- Room: - {room} +
+ Room: + {room}
)} {hostName && ( -
- Host: - {hostName} - {isHost ? (you) : null} +
+ Host: + {hostName} + {isHost ? (you) : null}
)} {error && ( -
- {error} +
+ {error}
)} {message && ( -
- {message} +
+ {message}
)}
diff --git a/client/app/hooks/useTheme.ts b/client/app/hooks/useTheme.ts new file mode 100644 index 0000000..3cd7dee --- /dev/null +++ b/client/app/hooks/useTheme.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'red-tetris-theme'; + +function getInitialTheme(): Theme { + if (typeof window === 'undefined') return 'dark'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +export function useTheme() { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + const root = document.documentElement; + if (theme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const toggle = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + + return { theme, toggle } as const; +} diff --git a/client/app/root.tsx b/client/app/root.tsx index 4eadcc5..86a9ddd 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -5,6 +5,8 @@ import type { Route } from './+types/routes'; import './app.css'; import { store } from './store/store'; import { LoadingOverlay } from './components/LoadingOverlay'; +import { ThemeToggle } from './components/ThemeToggle'; +import { useTheme } from './hooks/useTheme'; export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, @@ -26,6 +28,11 @@ export function Layout({ children }: { children: React.ReactNode }) { /> +