Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/components/App/App.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="wrapper">
<Header />
<Header
theme={theme}
onToggleTheme={toggleTheme}
colorblindMode={colorblindMode}
onToggleColorblindMode={toggleColorblindMode}
/>

<div className="game-wrapper">
<Game />
Expand Down
68 changes: 67 additions & 1 deletion src/components/Header/Header.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,75 @@
import React from 'react';

function Header() {
function Header({
theme,
onToggleTheme,
colorblindMode,
onToggleColorblindMode,
}) {
const isDark = theme === 'dark';

return (
<header>
<div className="side" />
<h1>Word Game</h1>
<div className="side header-controls">
<button
className="theme-toggle-btn"
onClick={onToggleColorblindMode}
aria-pressed={colorblindMode}
aria-label={
colorblindMode
? 'Turn off colorblind-friendly colors'
: 'Turn on colorblind-friendly colors'
}
title={
colorblindMode
? 'Turn off colorblind-friendly colors'
: 'Turn on colorblind-friendly colors'
}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="22"
height="22"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 5c-5.05 0-9.29 3.11-11 7.5 1.71 4.39 5.95 7.5 11 7.5s9.29-3.11 11-7.5C21.29 8.11 17.05 5 12 5Zm0 12.5a5 5 0 1 1 5-5 5 5 0 0 1-5 5Zm0-8a3 3 0 1 0 3 3 3 3 0 0 0-3-3Z" />
</svg>
</button>
<button
className="theme-toggle-btn"
onClick={onToggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 4a1 1 0 0 1-1-1V1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Zm0 18a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1ZM4 13H2a1 1 0 1 1 0-2h2a1 1 0 1 1 0 2Zm18 0h-2a1 1 0 1 1 0-2h2a1 1 0 1 1 0 2ZM5.64 6.36a1 1 0 0 1-.7-.29L3.5 4.63a1 1 0 1 1 1.42-1.42L6.35 4.65a1 1 0 0 1-.71 1.71Zm12.73 12.73a1 1 0 0 1-.7-.3l-1.44-1.43a1 1 0 1 1 1.42-1.42l1.43 1.43a1 1 0 0 1-.71 1.72ZM4.22 20.49a1 1 0 0 1-.71-1.71l1.43-1.43a1 1 0 1 1 1.42 1.42l-1.43 1.43a1 1 0 0 1-.71.29Zm13.14-13.14a1 1 0 0 1-.71-1.71l1.43-1.43a1 1 0 1 1 1.42 1.42l-1.43 1.43a1 1 0 0 1-.71.29ZM12 18a6 6 0 1 1 6-6 6 6 0 0 1-6 6Z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12.34 2.02a1 1 0 0 1 .82 1.36 8 8 0 0 0 10.46 10.46 1 1 0 0 1 1.29 1.29A10 10 0 1 1 11.05.73a1 1 0 0 1 1.29 1.29Z" />
</svg>
)}
</button>
</div>
</header>
);
}
Expand Down
36 changes: 36 additions & 0 deletions src/hooks/useColorblindMode.js
Original file line number Diff line number Diff line change
@@ -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
// <html> 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;
40 changes: 40 additions & 0 deletions src/hooks/useTheme.js
Original file line number Diff line number Diff line change
@@ -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 <html> 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;
102 changes: 94 additions & 8 deletions src/styles.css
Original file line number Diff line number Diff line change
@@ -1,27 +1,85 @@
/*
Theme and global variables

Palette inspired by Monet's "Water Lilies" (navy, steel blue,
olive green, sage green, dusty rose).
*/
html {
overflow-y: scroll;

--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 <html>
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 <html> (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%);
}

/*
Custom styles
*/
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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down