From aea22c0303f7d60731d13e18ef14837b6c31a237 Mon Sep 17 00:00:00 2001 From: Michelle Villagomez Date: Wed, 15 Apr 2026 23:10:15 -0700 Subject: [PATCH] Add Wellby extension and refine game UI --- client/src/App.jsx | 292 ++++++-- client/src/components/BreakMode.jsx | 27 +- client/src/components/SettingsPage.jsx | 47 +- client/src/components/games/ChessGame.jsx | 53 +- client/src/components/games/SnakeGame.jsx | 43 +- client/src/components/games/TicTacToeGame.jsx | 113 +++- client/src/components/games/UnoGame.jsx | 177 +++-- client/src/lib/constants.js | 10 +- extension/wellby-float/README.md | 26 + extension/wellby-float/background.js | 108 +++ extension/wellby-float/content.css | 242 +++++++ extension/wellby-float/content.js | 637 ++++++++++++++++++ extension/wellby-float/manifest.json | 38 ++ 13 files changed, 1655 insertions(+), 158 deletions(-) create mode 100644 extension/wellby-float/README.md create mode 100644 extension/wellby-float/background.js create mode 100644 extension/wellby-float/content.css create mode 100644 extension/wellby-float/content.js create mode 100644 extension/wellby-float/manifest.json diff --git a/client/src/App.jsx b/client/src/App.jsx index aad8377..a2eee9c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; import Dashboard from "./components/Dashboard.jsx"; import OnboardingFlow from "./components/OnboardingFlow.jsx"; @@ -26,7 +26,8 @@ function createSession() { activeTask: null, completedTasks: [], actions: 0, - moodScore: 3 + moodScore: 3, + stressLevel: 3 }; } @@ -51,6 +52,30 @@ function getAdaptiveBurnRate(session, baseline) { return Math.max(0, Math.min(1, Number(score.toFixed(2)))); } +function getRecoveredBurnRate(burnRate) { + return Math.max(0, Number((burnRate - 0.1).toFixed(2))); +} + +function clampMoodScore(value) { + return Math.max(1, Math.min(5, Number(value) || 3)); +} + +function getBurnRateFromCheckIn(currentBurnRate, moodScore, stressLevel) { + const normalizedMood = clampMoodScore(moodScore); + const normalizedStress = clampMoodScore(stressLevel); + const adjustment = (normalizedStress - 3) * 0.08 + (3 - normalizedMood) * 0.05; + return Math.max(0, Math.min(1, Number((currentBurnRate + adjustment).toFixed(2)))); +} + +function readStoredExtensionCheckIn() { + try { + const rawValue = window.localStorage.getItem("wellbyExtensionCheckIn"); + return rawValue ? JSON.parse(rawValue) : null; + } catch { + return null; + } +} + export default function App() { const { theme, setTheme, mode, toggleMode, colors } = useContext(ThemeContext); const [profile, setProfile] = useLocalStorage(STORAGE_KEYS.profile, null); @@ -58,6 +83,10 @@ export default function App() { const [sessions, setSessions] = useLocalStorage(STORAGE_KEYS.sessions, []); const [fatigueOptIn, setFatigueOptIn] = useLocalStorage(STORAGE_KEYS.fatigueOptIn, false); const [breakLogs, setBreakLogs] = useLocalStorage(STORAGE_KEYS.breakLogs, []); + const [extensionPromptInterval, setExtensionPromptInterval] = useLocalStorage( + STORAGE_KEYS.extensionPromptInterval, + 5 + ); const [session, setSession] = useState(createSession); const [apiBurnRate, setApiBurnRate] = useState(0.18); const [breakOpen, setBreakOpen] = useState(false); @@ -78,31 +107,39 @@ export default function App() { const [flowState, setFlowState] = useState("stable"); const [flowRatio, setFlowRatio] = useState(1); const [notificationState, setNotificationState] = useState(null); + const [burnRateRecoveryOverride, setBurnRateRecoveryOverride] = useState(null); const [lastApiUpdatedAt, setLastApiUpdatedAt] = useState(0); const apiRefreshRef = useRef(0); const activeToastIdRef = useRef(null); const escalateOnNextRef = useRef(false); + const lastExtensionMoodAtRef = useRef(0); - const baseline = useMemo(() => { - if (sessions.length < 3) { - return null; - } - const seed = sessions.slice(0, 3); - return { - avgTaskSeconds: average(seed.map((item) => item.avgTaskSeconds || 0)), - avgSessionSeconds: average(seed.map((item) => item.durationSeconds || 0)) - }; - }, [sessions]); - + const baseline = useMemo(() => getFlowBaseline(sessions), [sessions]); const flowBaseline = useMemo(() => getFlowBaseline(sessions), [sessions]); const flowDeviation = useMemo(() => getFlowDeviation(session, flowBaseline), [session, flowBaseline]); const adaptiveBurnRate = useMemo(() => getAdaptiveBurnRate(session, baseline), [session, baseline]); - const effectiveBurnRate = Math.max( - apiBurnRate, - adaptiveBurnRate, - flowDeviation.penalty, - fatigueStatus.fatigueDetected ? 0.6 : 0 + const calculatedBurnRate = Math.max( + 0, + Math.min( + 1, + Number( + ( + Math.max( + apiBurnRate, + adaptiveBurnRate, + flowDeviation.penalty, + fatigueStatus.fatigueDetected ? 0.6 : 0 + ) + ).toFixed(2) + ) + ) ); + const recoveryOverrideActive = + burnRateRecoveryOverride && + Date.now() < burnRateRecoveryOverride.activeUntil; + const effectiveBurnRate = recoveryOverrideActive + ? Math.min(burnRateRecoveryOverride.value, calculatedBurnRate) + : calculatedBurnRate; const breakMinutes = useMemo( () => getBreakMinutes(effectiveBurnRate, fatigueStatus.fatigueDetected), [effectiveBurnRate, fatigueStatus.fatigueDetected] @@ -126,6 +163,16 @@ export default function App() { setNotificationState(null); } + function collapseMildNotification() { + dismissToast(); + setNotificationState("mild-collapsed"); + } + + function reopenMildNotification() { + dismissToast(); + showMildToast(); + } + function triggerFullBreakMode({ noSnooze, reason }) { dismissToast(); setBanner( @@ -160,10 +207,7 @@ export default function App() { flowState={flowState} flowRatio={flowRatio} snoozeCount={snoozeCount} - onDismiss={() => { - dismissToast(); - setNotificationState(null); - }} + onDismiss={collapseMildNotification} onSnooze={handleSnooze} onTakeBreak={() => { dismissToast(); @@ -186,6 +230,120 @@ export default function App() { return () => clearInterval(timer); }, []); + useEffect(() => { + function applyExtensionCheckIn(nextMoodScore, nextStressLevel, intent) { + const moodScore = clampMoodScore(nextMoodScore); + const stressLevel = clampMoodScore(nextStressLevel); + setSession((current) => ({ ...current, moodScore, stressLevel })); + setApiBurnRate((current) => getBurnRateFromCheckIn(current, moodScore, stressLevel)); + + if (intent === "break" || stressLevel >= 4) { + setBanner("Wellby picked up a higher stress check. A short break could help."); + } else if (intent === "check-in") { + setBanner("Wellby logged your browser check-in and updated your session."); + } else { + setBanner("Wellby logged your browser check-in."); + } + } + + function applyStoredCheckInIfNeeded() { + const storedCheckIn = readStoredExtensionCheckIn(); + if (!storedCheckIn) { + return; + } + + const updatedAt = Number(storedCheckIn.updatedAt) || Date.now(); + if (updatedAt <= lastExtensionMoodAtRef.current) { + return; + } + + lastExtensionMoodAtRef.current = updatedAt; + applyExtensionCheckIn(storedCheckIn.moodScore, storedCheckIn.stressLevel, storedCheckIn.intent); + } + + function handleExtensionMessage(event) { + if (event.origin !== window.location.origin) { + return; + } + + if (event.data?.source !== "wellby-extension" || event.data?.type !== "MOOD_SYNC") { + return; + } + + const updatedAt = Number(event.data.updatedAt) || Date.now(); + if (updatedAt <= lastExtensionMoodAtRef.current) { + return; + } + + lastExtensionMoodAtRef.current = updatedAt; + applyExtensionCheckIn(event.data.moodScore, event.data.stressLevel, event.data.intent); + } + + const params = new URLSearchParams(window.location.search); + const extensionMoodScore = params.get("extensionMoodScore"); + const extensionStressLevel = params.get("extensionStressLevel"); + const extensionIntent = params.get("extensionIntent"); + const extensionMoodUpdatedAt = Number(params.get("extensionMoodUpdatedAt")) || Date.now(); + + if (extensionMoodScore !== null || extensionStressLevel !== null) { + lastExtensionMoodAtRef.current = extensionMoodUpdatedAt; + applyExtensionCheckIn( + extensionMoodScore ?? session.moodScore, + extensionStressLevel ?? session.stressLevel, + extensionIntent + ); + params.delete("extensionMoodScore"); + params.delete("extensionStressLevel"); + params.delete("extensionIntent"); + params.delete("extensionMoodUpdatedAt"); + const nextQuery = params.toString(); + const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ""}${window.location.hash}`; + window.history.replaceState({}, "", nextUrl); + } + + window.addEventListener("message", handleExtensionMessage); + window.addEventListener("focus", applyStoredCheckInIfNeeded); + document.addEventListener("visibilitychange", applyStoredCheckInIfNeeded); + applyStoredCheckInIfNeeded(); + + return () => { + window.removeEventListener("message", handleExtensionMessage); + window.removeEventListener("focus", applyStoredCheckInIfNeeded); + document.removeEventListener("visibilitychange", applyStoredCheckInIfNeeded); + }; + }, []); + + useEffect(() => { + window.postMessage( + { + source: "wellby-app", + type: "SETTINGS_SYNC", + extensionPromptInterval, + theme, + mode + }, + window.location.origin + ); + }, [extensionPromptInterval, theme, mode]); + + useEffect(() => { + const syncedTask = session.activeTask?.name ?? session.taskInput ?? ""; + const activeTasks = session.activeTask?.name ? [session.activeTask.name] : []; + + window.localStorage.setItem("wellbyCurrentTask", syncedTask); + window.localStorage.setItem("wellbyActiveTasks", JSON.stringify(activeTasks)); + + window.postMessage( + { + source: "wellby-app", + type: "TASK_SYNC", + currentTask: syncedTask, + activeTasks + }, + window.location.origin + ); + }, [session.activeTask, session.taskInput]); + useEffect(() => { if (!profile) { return; @@ -200,7 +358,8 @@ export default function App() { 10, Math.max( 0, - (6 - session.moodScore) * 1.4 + + (6 - session.moodScore) * 1.1 + + (session.stressLevel - 1) * 1.2 + Math.min(4, session.elapsedSeconds / 3600) + Math.max(0, (averageTaskSeconds - (baseline?.avgTaskSeconds || averageTaskSeconds)) / 600) ) @@ -232,6 +391,9 @@ export default function App() { } const adjusted = Math.max(0, Number((Number(data.burn_rate ?? 0) - breakCredit).toFixed(2))); setApiBurnRate(adjusted); + if (!recoveryOverrideActive) { + setBurnRateRecoveryOverride(null); + } if (breakCredit > 0) { setBreakCredit(0); } @@ -262,6 +424,7 @@ export default function App() { session.completedTasks, session.actions, session.moodScore, + session.stressLevel, baseline, breakCredit, session.startedAt, @@ -321,6 +484,10 @@ export default function App() { return; } + if (notificationState === "mild-collapsed") { + return; + } + if (notificationState !== "mild") { showMildToast(); } @@ -335,26 +502,21 @@ export default function App() { ]); function completeCurrentSession() { - const taskDurations = session.completedTasks.map((task) => task.durationSeconds); - const summary = { - id: session.startedAt, - durationSeconds: session.elapsedSeconds, - avgTaskSeconds: taskDurations.length ? average(taskDurations) : session.elapsedSeconds || 0, - completedTasks: session.completedTasks.length, - breakTakenAt: new Date().toISOString() - }; - - setSessions((current) => [...current, summary]); + const recoveredBurnRate = getRecoveredBurnRate(effectiveBurnRate); + const breakTimestamp = new Date().toISOString(); setBreakLogs((current) => [ ...current, - { timestamp: summary.breakTakenAt, durationMinutes: breakMinutes } + { timestamp: breakTimestamp, durationMinutes: breakMinutes } ]); + setBurnRateRecoveryOverride({ + value: recoveredBurnRate, + activeUntil: Date.now() + 45000 + }); setBreakCredit(0.1); - setApiBurnRate((current) => Math.max(0, Number((current - 0.1).toFixed(2)))); + setApiBurnRate(recoveredBurnRate); setSnoozeCount(0); escalateOnNextRef.current = false; clearAllNotifications(); - setSession(createSession()); } function handleTaskStart() { @@ -380,24 +542,40 @@ export default function App() { return current; } - return { - ...current, - actions: current.actions + 1, - activeTask: null, - completedTasks: [ - ...current.completedTasks, - { - ...current.activeTask, - completedAt: Date.now(), - durationSeconds: Math.max(30, Math.floor((Date.now() - current.activeTask.startedAt) / 1000)) - } - ] + const completedTask = { + ...current.activeTask, + completedAt: Date.now(), + durationSeconds: Math.max(30, Math.floor((Date.now() - current.activeTask.startedAt) / 1000)) }; + const completedTasks = [...current.completedTasks, completedTask]; + const taskDurations = completedTasks.map((task) => task.durationSeconds); + const summary = { + id: current.startedAt, + durationSeconds: current.elapsedSeconds, + avgTaskSeconds: taskDurations.length ? average(taskDurations) : current.elapsedSeconds || 0, + completedTasks: completedTasks.length, + completedAt: new Date().toISOString() + }; + + setSessions((existing) => [...existing, summary]); + clearAllNotifications(); + setBreakOpen(false); + setSnoozeCount(0); + escalateOnNextRef.current = false; + + return createSession(); }); } if (!profile) { - return ; + return ( + { + setProfile(nextProfile); + setCurrentPage("dashboard"); + }} + /> + ); } if (currentPage === "burnout-info") { @@ -409,6 +587,8 @@ export default function App() { setFatigueOptIn((current) => !current)} + extensionPromptInterval={extensionPromptInterval} + onSetExtensionPromptInterval={setExtensionPromptInterval} mode={mode} onToggleMode={toggleMode} activeTheme={theme} @@ -438,12 +618,22 @@ export default function App() { onTaskInputChange={(value) => setSession((current) => ({ ...current, taskInput: value }))} onTaskStart={handleTaskStart} onTaskComplete={handleTaskComplete} - onMoodSelect={(score) => setSession((current) => ({ ...current, moodScore: score }))} + onMoodSelect={(score) => { + const moodScore = clampMoodScore(score); + setSession((current) => ({ ...current, moodScore })); + setApiBurnRate((current) => getBurnRateFromCheckIn(current, moodScore, session.stressLevel)); + }} + onStressSelect={(score) => { + const stressLevel = clampMoodScore(score); + setSession((current) => ({ ...current, stressLevel })); + setApiBurnRate((current) => getBurnRateFromCheckIn(current, session.moodScore, stressLevel)); + }} onStartBreak={() => triggerFullBreakMode({ noSnooze: false, reason: "manual" })} onOpenBurnoutInfo={() => setCurrentPage("burnout-info")} onOpenSettings={() => setCurrentPage("settings")} banner={banner} notificationState={notificationState} + onExpandNotification={reopenMildNotification} /> {breakOpen ? ( { setBreakOpen(false); completeCurrentSession(); diff --git a/client/src/components/BreakMode.jsx b/client/src/components/BreakMode.jsx index 21d5681..a2133d5 100644 --- a/client/src/components/BreakMode.jsx +++ b/client/src/components/BreakMode.jsx @@ -103,35 +103,44 @@ export default function BreakMode({ initialGame, onClose, noSnooze, reason, befo ) : phase === "games" ? (
-
-
+
+

Wellby Game Lounge

-

Recharge in a way that actually feels fun

-

{breakMessage}

+

Recharge in a way that actually feels fun

+

{breakMessage}

-
+
+

+ Pick a game, settle in for a minute, and then head back when you feel reset. + Right now you're in {GAME_OPTIONS.find((game) => game.id === selectedGame)?.label}. +

+
+
{GAME_OPTIONS.map((game) => ( ))}
diff --git a/client/src/components/SettingsPage.jsx b/client/src/components/SettingsPage.jsx index b09239d..2689e7b 100644 --- a/client/src/components/SettingsPage.jsx +++ b/client/src/components/SettingsPage.jsx @@ -1,6 +1,7 @@ -import { useContext } from "react"; +import React,{ useContext } from "react"; import { ThemeContext } from "../context/ThemeContext.jsx"; import LeafIcon from "./LeafIcon.jsx"; +import { EXTENSION_PROMPT_INTERVAL_OPTIONS } from "../lib/constants.js"; const THEME_OPTIONS = [ { id: "warm", label: "Warm", description: "Soft browns and caramels - cozy and grounding", color: "#B5967E" }, @@ -12,6 +13,8 @@ const THEME_OPTIONS = [ export default function SettingsPage({ fatigueOptIn, onToggleFatigue, + extensionPromptInterval, + onSetExtensionPromptInterval, mode, onToggleMode, activeTheme, @@ -98,6 +101,48 @@ export default function SettingsPage({ {fatigueOptIn ? "Enabled" : "Disabled"}
+ +
+
+
+

Browser check-in timer

+

+ Pick how often the tiny Wellby tab should expand on other websites and ask for a stress check-in. +

+
+
+ {extensionPromptInterval} min +
+
+ +
+ {EXTENSION_PROMPT_INTERVAL_OPTIONS.map((option) => ( + + ))} +
+
diff --git a/client/src/components/games/ChessGame.jsx b/client/src/components/games/ChessGame.jsx index 1d99e6a..66da3de 100644 --- a/client/src/components/games/ChessGame.jsx +++ b/client/src/components/games/ChessGame.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useState } from "react"; +import React,{ useContext, useEffect, useMemo, useState } from "react"; import { Chess } from "chess.js"; import { ThemeContext } from "../../context/ThemeContext.jsx"; @@ -81,6 +81,11 @@ export default function ChessGame({ onExit }) { const [captured, setCaptured] = useState({ white: [], black: [] }); const board = useMemo(() => game.board().flat(), [game]); + const winnerText = game.isGameOver() + ? game.isCheckmate() + ? `You ${game.turn() === "b" ? "win" : "lose"}!` + : "Game over." + : null; useEffect(() => { if (mode !== "ai" || game.turn() !== "b" || game.isGameOver()) { @@ -146,14 +151,16 @@ export default function ChessGame({ onExit }) { return (
-
-
-

Chess

-

Pass-and-play or a simple depth-3 AI opponent.

-
- +
+

Chess

+

+ Play pass-and-play or challenge a lighter AI opponent. +

+
-
-
+
+
{board.map((piece, index) => { const isLight = (Math.floor(index / 8) + index) % 2 === 0; const square = algebraic(index); @@ -190,7 +197,7 @@ export default function ChessGame({ onExit }) {
+
+
+ {winnerText ?? `Turn: ${game.turn() === "w" ? "White" : "Black"}`} +
+
); } diff --git a/client/src/components/games/SnakeGame.jsx b/client/src/components/games/SnakeGame.jsx index 1d753e7..fb8e083 100644 --- a/client/src/components/games/SnakeGame.jsx +++ b/client/src/components/games/SnakeGame.jsx @@ -29,6 +29,7 @@ export default function SnakeGame({ onExit }) { const [food, setFood] = useState(() => randomFood(INITIAL_SNAKE)); const [score, setScore] = useState(0); const [gameOver, setGameOver] = useState(false); + const [isRunning, setIsRunning] = useState(false); const highScores = readStorage(STORAGE_KEYS.gameScores, { snake: 0 }); useEffect(() => { @@ -57,7 +58,7 @@ export default function SnakeGame({ onExit }) { }, []); useEffect(() => { - if (gameOver) { + if (gameOver || !isRunning) { return; } @@ -98,7 +99,7 @@ export default function SnakeGame({ onExit }) { }, 140); return () => clearInterval(interval); - }, [food, gameOver, highScores, score]); + }, [food, gameOver, highScores, isRunning, score]); useEffect(() => { const canvas = canvasRef.current; @@ -126,31 +127,55 @@ export default function SnakeGame({ onExit }) { setFood(randomFood(INITIAL_SNAKE)); setScore(0); setGameOver(false); + setIsRunning(false); } return (
-
-
-

Snake

-

High score: {readStorage(STORAGE_KEYS.gameScores, { snake: 0 }).snake ?? 0}

-
- +
+

Snake

+

+ High score: {readStorage(STORAGE_KEYS.gameScores, { snake: 0 }).snake ?? 0} +

+

+ Glide through the grid, eat the food, and keep your streak alive. +

+
+
+
+ {gameOver ? "You lose!" : isRunning ? "Stay sharp and keep climbing." : "Press start when you're ready."} +
+

Score: {score}

{gameOver ? (
- Ready to get back to it?
+ ) : !isRunning ? ( +
+ Use arrow keys or WASD + +
) : ( Use arrow keys or WASD )} diff --git a/client/src/components/games/TicTacToeGame.jsx b/client/src/components/games/TicTacToeGame.jsx index 5409353..8d1436b 100644 --- a/client/src/components/games/TicTacToeGame.jsx +++ b/client/src/components/games/TicTacToeGame.jsx @@ -21,25 +21,43 @@ function getWinner(board) { return board.every(Boolean) ? "draw" : null; } -function minimax(board, isMaximizing) { - const winner = getWinner(board); - if (winner === "O") return { score: 1 }; - if (winner === "X") return { score: -1 }; - if (winner === "draw") return { score: 0 }; +function findLineMove(board, marker) { + for (const [a, b, c] of lines) { + const values = [board[a], board[b], board[c]]; + const markerCount = values.filter((value) => value === marker).length; + const emptyIndex = [a, b, c].find((index) => !board[index]); + if (markerCount === 2 && emptyIndex !== undefined) { + return emptyIndex; + } + } + return null; +} - const moves = []; - board.forEach((cell, index) => { - if (!cell) { - const nextBoard = [...board]; - nextBoard[index] = isMaximizing ? "O" : "X"; - const result = minimax(nextBoard, !isMaximizing); - moves.push({ index, score: result.score }); +function chooseAiMove(board) { + const winningMove = findLineMove(board, "O"); + if (winningMove !== null) { + return winningMove; + } + + const shouldBlock = Math.random() > 0.35; + if (shouldBlock) { + const blockingMove = findLineMove(board, "X"); + if (blockingMove !== null) { + return blockingMove; } - }); + } + + const preferredMoves = [4, 0, 2, 6, 8, 1, 3, 5, 7]; + const availableMoves = preferredMoves.filter((index) => !board[index]); + if (!availableMoves.length) { + return undefined; + } + + if (Math.random() > 0.55) { + return availableMoves[0]; + } - return isMaximizing - ? moves.reduce((best, move) => (move.score > best.score ? move : best), { score: -Infinity }) - : moves.reduce((best, move) => (move.score < best.score ? move : best), { score: Infinity }); + return availableMoves[Math.floor(Math.random() * availableMoves.length)]; } export default function TicTacToeGame({ onExit }) { @@ -68,7 +86,7 @@ export default function TicTacToeGame({ onExit }) { return; } - const aiMove = minimax(nextBoard, true).index; + const aiMove = chooseAiMove(nextBoard); if (aiMove !== undefined) { nextBoard[aiMove] = "O"; setBoard([...nextBoard]); @@ -76,16 +94,28 @@ export default function TicTacToeGame({ onExit }) { } } + const resultMessage = winner + ? winner === "draw" + ? "Draw! That round was close." + : mode === "ai" + ? winner === "X" + ? "You win!" + : "The computer wins." + : `${winner} wins!` + : null; + return (
-
-
-

Tic Tac Toe

-

Perfect-play AI or pass-and-play on the same screen.

-
- +
+

Tic Tac Toe

+

+ Take on the AI or pass and play on the same screen. +

+
-
-

- {winner ? (winner === "draw" ? "Draw! Nicely matched." : `${winner} wins!`) : `Turn: ${turn}`} -

+
+
+ {resultMessage ?? (mode === "ai" ? "Beat the AI or force a draw." : `Turn: ${turn}`)} +
+
+
diff --git a/client/src/components/games/UnoGame.jsx b/client/src/components/games/UnoGame.jsx index ca036e6..58eea3c 100644 --- a/client/src/components/games/UnoGame.jsx +++ b/client/src/components/games/UnoGame.jsx @@ -5,6 +5,14 @@ const UNO_COLORS = ["red", "yellow", "green", "blue"]; const ACTION_TYPES = ["skip", "reverse", "draw2"]; const AI_TURN_DELAY_MS = 5000; +const CARD_STYLES = { + red: { background: "#f7d9d3", border: "#df9a8b", text: "#7f3c2e" }, + yellow: { background: "#f8efc9", border: "#d8bc61", text: "#7a5a00" }, + green: { background: "#d9f0dd", border: "#8cc39a", text: "#28583a" }, + blue: { background: "#dbe8fb", border: "#92b3e3", text: "#284a7a" }, + wild: { background: "#ebe4da", border: "#bba997", text: "#5d4d41" } +}; + function shuffle(deck) { const next = [...deck]; for (let index = next.length - 1; index > 0; index -= 1) { @@ -61,6 +69,10 @@ function cardLabel(card) { return `${card.color} ${card.type}`; } +function getCardTheme(card) { + return CARD_STYLES[card.color] ?? CARD_STYLES.wild; +} + function cloneState(state) { return { ...state, @@ -360,28 +372,57 @@ export default function UnoGame({ onExit }) { const topCard = getTopCard(state); const activeColor = getActiveColor(state); + const currentPlayerLabel = state.currentPlayer === 0 ? "Your turn" : `Player ${state.currentPlayer + 1}'s turn`; + const winnerLabel = + winner === -1 ? null : winner === 0 ? "You win the round!" : `Player ${winner + 1} wins the round.`; return (
-
-
-

UNO

-

Single-player vs two AI opponents with slower turns and hidden opponent hands.

-
- +
+

UNO

+

+ Single-player vs two AI opponents with slower turns and hidden opponent hands. +

+
-
-