diff --git a/src/components/App/App.js b/src/components/App/App.js index ce0d236..065cd79 100644 --- a/src/components/App/App.js +++ b/src/components/App/App.js @@ -1,10 +1,20 @@ import Game from '../Game'; import Header from '../Header'; +import useTheme from '../../hooks/useTheme'; +import useColorblindMode from '../../hooks/useColorblindMode'; function App() { + const [theme, toggleTheme] = useTheme(); + const [colorblindMode, toggleColorblindMode] = useColorblindMode(); + return (
-
+
diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js index ce7c934..0e216c8 100644 --- a/src/components/Header/Header.js +++ b/src/components/Header/Header.js @@ -1,9 +1,75 @@ import React from 'react'; -function Header() { +function Header({ + theme, + onToggleTheme, + colorblindMode, + onToggleColorblindMode, +}) { + const isDark = theme === 'dark'; + return (
+

Word Game

+
+ + +
); } diff --git a/src/hooks/useColorblindMode.js b/src/hooks/useColorblindMode.js new file mode 100644 index 0000000..9826218 --- /dev/null +++ b/src/hooks/useColorblindMode.js @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'word-game-colorblind-mode'; + +function getInitialColorblindMode() { + if (typeof window === 'undefined') { + return false; + } + + return window.localStorage.getItem(STORAGE_KEY) === 'true'; +} + +// Manages the colorblind-friendly palette setting, syncing it to the +// element's `data-colorblind` attribute (so CSS can react to it) +// and to localStorage (so the choice persists across visits). +function useColorblindMode() { + const [colorblindMode, setColorblindMode] = useState( + getInitialColorblindMode + ); + + useEffect(() => { + document.documentElement.setAttribute( + 'data-colorblind', + colorblindMode ? 'true' : 'false' + ); + window.localStorage.setItem(STORAGE_KEY, colorblindMode); + }, [colorblindMode]); + + function toggleColorblindMode() { + setColorblindMode((current) => !current); + } + + return [colorblindMode, toggleColorblindMode]; +} + +export default useColorblindMode; diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js new file mode 100644 index 0000000..999805b --- /dev/null +++ b/src/hooks/useTheme.js @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'word-game-theme'; + +function getInitialTheme() { + if (typeof window === 'undefined') { + return 'light'; + } + + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') { + return stored; + } + + const prefersDark = window.matchMedia?.( + '(prefers-color-scheme: dark)' + ).matches; + + return prefersDark ? 'dark' : 'light'; +} + +// Manages the light/dark theme, syncing it to the element's +// `data-theme` attribute (so CSS can react to it) and to localStorage +// (so the choice persists across visits). +function useTheme() { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + window.localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + function toggleTheme() { + setTheme((currentTheme) => (currentTheme === 'dark' ? 'light' : 'dark')); + } + + return [theme, toggleTheme]; +} + +export default useTheme; diff --git a/src/styles.css b/src/styles.css index f54c1cf..361be0f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,5 +1,8 @@ /* Theme and global variables + + Palette inspired by Monet's "Water Lilies" (navy, steel blue, + olive green, sage green, dusty rose). */ html { overflow-y: scroll; @@ -7,14 +10,66 @@ html { --game-spacing: 32px; --header-height: 4rem; - --color-success: hsl(150deg 70% 30%); - --color-warning: hsl(50deg 100% 30%); - --color-error: hsl(0deg 70% 45%); - --color-gray-100: hsl(0deg 0% 10%); - --color-gray-300: hsl(0deg 0% 25%); - --color-gray-500: hsl(0deg 0% 50%); - --color-gray-700: hsl(0deg 0% 75%); - --color-gray-900: hsl(0deg 0% 90%); + /* Monet "Water Lilies" palette */ + --palette-navy: hsl(203deg 60% 26%); + --palette-steel-blue: hsl(215deg 42% 61%); + --palette-olive: hsl(97deg 42% 38%); + --palette-sage: hsl(118deg 28% 57%); + --palette-rose: hsl(333deg 42% 68%); + + --color-success: var(--palette-olive); + --color-warning: var(--palette-rose); + --color-error: hsl(350deg 55% 42%); + --color-gray-100: var(--palette-navy); + --color-gray-300: hsl(205deg 45% 33%); + --color-gray-500: var(--palette-steel-blue); + --color-gray-700: hsl(215deg 45% 78%); + --color-gray-900: hsl(215deg 45% 93%); + + --color-background: hsl(210deg 35% 98%); + --color-text: var(--color-gray-100); +} + +/* + Dark theme overrides. + + The theme is toggled by setting `data-theme="dark"` on the + element (see src/hooks/useTheme.js). Colors are chosen to keep the + same relative contrast as the light theme, using deep navy for the + background and brighter tints of the palette for accents/text. +*/ +html[data-theme='dark'] { + --color-success: hsl(97deg 45% 52%); + --color-warning: hsl(333deg 55% 75%); + --color-error: hsl(350deg 70% 62%); + --color-gray-100: hsl(215deg 45% 93%); + --color-gray-300: var(--palette-steel-blue); + --color-gray-500: hsl(215deg 30% 55%); + --color-gray-700: hsl(205deg 35% 28%); + --color-gray-900: hsl(205deg 35% 16%); + + --color-background: hsl(205deg 45% 11%); + --color-text: var(--color-gray-100); +} + +/* + Colorblind-friendly palette. + + Swaps the green/yellow success & warning colors for an orange/blue + palette (based on the Okabe-Ito colorblind-safe palette), which + remains distinguishable for the most common forms of color vision + deficiency (deuteranopia/protanopia). Toggled via `data-colorblind` + on (see src/hooks/useColorblindMode.js). Declared after the + dark theme block so it takes precedence when both are active. +*/ +html[data-colorblind='true'] { + --color-success: hsl(28deg 90% 48%); + --color-warning: hsl(207deg 79% 58%); +} + +html[data-theme='dark'][data-colorblind='true'] { + --color-success: hsl(28deg 90% 58%); + --color-warning: hsl(207deg 90% 68%); } /* @@ -22,6 +77,9 @@ html { */ body { font-family: sans-serif; + background: var(--color-background); + color: var(--color-text); + transition: background-color 200ms ease, color 200ms ease; } @media (max-height: 600px) { @@ -59,6 +117,32 @@ header .side { place-content: center; } +header .side.header-controls { + width: auto; + display: flex; + align-items: center; + gap: 4px; + padding: 0 12px; +} + +.theme-toggle-btn { + display: grid; + place-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + color: inherit; + transition: background-color 150ms ease, transform 150ms ease; +} + +.theme-toggle-btn:hover { + background: var(--color-gray-900); +} + +.theme-toggle-btn:active { + transform: scale(0.92); +} + h1 { flex: 1; font-size: 2rem; @@ -155,6 +239,8 @@ h1 { display: block; width: 100%; font-size: 2rem; + background: var(--color-background); + color: var(--color-text); border: 2px solid var(--color-gray-300); border-radius: 4px; padding: 8px 16px;