Skip to content
Merged
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
17 changes: 17 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>
(() => {
let storedTheme = null;
try {
storedTheme = window.localStorage.getItem('hackweek.theme');
} catch {}
const prefersDark =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme =
storedTheme === 'light' || storedTheme === 'dark'
? storedTheme
: prefersDark
? 'dark'
: 'light';
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#130f1c" media="(prefers-color-scheme: dark)" />
Expand Down
2 changes: 2 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Redirect, Route, Switch} from 'wouter';

import {AppLayout, HackweekLoader, PageState} from './components/AppLayout';
import {GoogleIcon} from './components/GoogleIcon';
import {ThemeToggle} from './components/ThemeToggle';
import {AdminAnalyticsPage} from './routes/AdminAnalyticsPage';
import {AdminPage} from './routes/AdminPage';
import {EditProjectPage, NewProjectPage} from './routes/ProjectEditorPage';
Expand Down Expand Up @@ -116,6 +117,7 @@ function AuthState({
}) {
return (
<main className={`authShell${loading ? ' authShell--loading' : ''}`}>
<ThemeToggle className="authThemeToggle" />
<section
className={`authState${loading ? ' authState--loading' : ''}`}
aria-busy={loading || undefined}
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Link, useRoute} from 'wouter';

import type {SessionUser, SessionViewMode} from '../../shared/api';
import {SentrySymbol} from './SentrySymbol';
import {ThemeToggle} from './ThemeToggle';
import {UserAvatar} from './UserAvatar';

export function AppLayout({
Expand Down Expand Up @@ -52,6 +53,7 @@ export function AppLayout({
)}
</nav>
<div className="identityActions">
<ThemeToggle />
{user.actualRole === 'admin' && (
<div className="viewModeSwitch">
<span>viewing as {user.role === 'admin' ? 'admin' : 'user'}</span>
Expand Down
60 changes: 60 additions & 0 deletions src/app/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {useEffect, useState} from 'react';

import {
applyTheme,
getStoredTheme,
getSystemTheme,
storeTheme,
type Theme,
} from '../theme';

export function ThemeToggle({className = ''}: {className?: string}) {
const [theme, setTheme] = useState<Theme>(() => getStoredTheme() ?? getSystemTheme());

useEffect(() => {
const storedTheme = getStoredTheme();
const activeTheme = storedTheme ?? getSystemTheme();
setTheme(activeTheme);
applyTheme(activeTheme);

if (storedTheme || !window.matchMedia) return;

const media = window.matchMedia('(prefers-color-scheme: dark)');
const syncWithSystem = (event: MediaQueryListEvent) => {
if (getStoredTheme()) return;
const nextTheme = event.matches ? 'dark' : 'light';
setTheme(nextTheme);
applyTheme(nextTheme);
};
media.addEventListener('change', syncWithSystem);
return () => media.removeEventListener('change', syncWithSystem);
}, []);

function toggleTheme() {
const nextTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(nextTheme);
applyTheme(nextTheme);
storeTheme(nextTheme);
}

const nextTheme = theme === 'dark' ? 'light' : 'dark';

return (
<button
className={`themeToggle${className ? ` ${className}` : ''}`}
type="button"
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
aria-pressed={theme === 'dark'}
onClick={toggleTheme}
>
<span className="themeToggleSun" aria-hidden="true">
</span>
<span className="themeToggleMoon" aria-hidden="true">
</span>
<span className="themeToggleThumb" aria-hidden="true" />
</button>
);
}
160 changes: 96 additions & 64 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,50 +53,48 @@
color-scheme: light;
}

@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--ink: #f3eef8;
--muted: #b3a7bf;
--paper: #130f1c;
--soft: #1a1524;
--lavender: #241c33;
--line: #3a314a;
--border-strong: #4a415c;
--surface-raised: #1c1728;
--surface-tint: #221b31;
--surface-tint-strong: #2a2140;
--surface-tint-hover: #32274b;
--header-bg: rgba(19, 15, 28, 0.92);
--header-border: rgba(58, 49, 74, 0.9);
--on-accent: #fff;
--shadow-ink: 0, 0, 0;
--blurple: #9b82ff;
--dark-blurple: #6b45c8;
--pink: #ff8acc;
--green: #a8e84a;
--yellow: #ffc94a;
--danger: #ff6b7d;
--success-ink: #d4f59a;
--success-border: #5f8a2a;
--success-bg: #1f2e12;
--warning-ink: #ffd88a;
--warning-ink-strong: #ffe3a8;
--warning-border: #8a6a1e;
--warning-bg: #2f2612;
--danger-ink: #ffb3bc;
--danger-border: #8a3a46;
--danger-bg: #2f151a;
--pink-ink: #ffb3db;
--pink-border: #8a3a66;
--pink-bg: #2f1524;
--pink-bg-strong: #3d1c30;
--violet-border: #5b4a8a;
--violet-border-strong: #6a58a0;
--violet-border-hot: #7d6bb8;
--violet-bg: #2d2348;
--neutral-border: #5a5168;
Comment thread
HazAT marked this conversation as resolved.
}
:root[data-theme='dark'] {
color-scheme: dark;
--ink: #f3eef8;
--muted: #b3a7bf;
--paper: #130f1c;
--soft: #1a1524;
--lavender: #241c33;
--line: #3a314a;
--border-strong: #4a415c;
--surface-raised: #1c1728;
--surface-tint: #221b31;
--surface-tint-strong: #2a2140;
--surface-tint-hover: #32274b;
--header-bg: rgba(19, 15, 28, 0.92);
--header-border: rgba(58, 49, 74, 0.9);
--on-accent: #fff;
--shadow-ink: 0, 0, 0;
--blurple: #9b82ff;
--dark-blurple: #6b45c8;
--pink: #ff8acc;
--green: #a8e84a;
--yellow: #ffc94a;
--danger: #ff6b7d;
--success-ink: #d4f59a;
--success-border: #5f8a2a;
--success-bg: #1f2e12;
--warning-ink: #ffd88a;
--warning-ink-strong: #ffe3a8;
--warning-border: #8a6a1e;
--warning-bg: #2f2612;
--danger-ink: #ffb3bc;
--danger-border: #8a3a46;
--danger-bg: #2f151a;
--pink-ink: #ffb3db;
--pink-border: #8a3a66;
--pink-bg: #2f1524;
--pink-bg-strong: #3d1c30;
--violet-border: #5b4a8a;
--violet-border-strong: #6a58a0;
--violet-border-hot: #7d6bb8;
--violet-bg: #2d2348;
--neutral-border: #5a5168;
}

* {
Expand Down Expand Up @@ -282,6 +280,55 @@ input:disabled {
.textButton:hover {
color: var(--blurple);
}
.themeToggle {
position: relative;
display: grid;
width: 3rem;
height: 1.65rem;
flex: 0 0 3rem;
grid-template-columns: 1fr 1fr;
align-items: center;
padding: 0 0.22rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 999px;
background: var(--soft);
transition:
border-color 120ms ease,
background 120ms ease;
}
.themeToggle:hover {
color: var(--ink);
border-color: var(--blurple);
}
.themeToggleSun,
.themeToggleMoon {
position: relative;
z-index: 1;
font-size: 0.7rem;
line-height: 1;
text-align: center;
}
.themeToggleThumb {
position: absolute;
top: 0.17rem;
left: 0.17rem;
width: 1.18rem;
height: 1.18rem;
border-radius: 50%;
background: var(--surface-raised);
box-shadow: 0 1px 3px rgba(var(--shadow-ink), 0.24);
transition: transform 160ms ease;
}
:root[data-theme='dark'] .themeToggleThumb {
transform: translateX(1.41rem);
}
.authThemeToggle {
position: fixed;
top: 1.25rem;
right: 1.25rem;
z-index: 1;
}
.identityCopy,
.identityCopy > span,
.identityCopy > small {
Expand Down Expand Up @@ -2727,10 +2774,6 @@ main {
}
.authShell--loading {
overflow: hidden;
background:
radial-gradient(circle at 20% 15%, rgba(255, 112, 188, 0.14), transparent 28rem),
radial-gradient(circle at 82% 78%, rgba(117, 83, 255, 0.14), transparent 32rem),
var(--soft);
}
.pageState,
.authState {
Expand Down Expand Up @@ -2767,27 +2810,16 @@ main {
line-height: 1.7;
}
.authState--loading {
overflow: hidden;
border-color: transparent;
background: transparent;
box-shadow: none;
animation: loadingStateIn 400ms ease-out both;
}
.pageState--loading {
background: transparent;
animation: loadingStateIn 400ms ease-out 200ms both;
}
.authState--loading {
overflow: hidden;
border-color: rgba(117, 83, 255, 0.18);
box-shadow: 0 24px 70px rgba(78, 42, 154, 0.1);
}
.authState--loading::before {
position: absolute;
top: -8rem;
left: 50%;
width: 24rem;
height: 14rem;
content: '';
background: radial-gradient(circle, rgba(117, 83, 255, 0.1), transparent 70%);
transform: translateX(-50%);
pointer-events: none;
}
.hackweekLoader {
position: relative;
width: min(13rem, 72vw);
Expand Down
28 changes: 28 additions & 0 deletions src/app/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type Theme = 'light' | 'dark';

const THEME_STORAGE_KEY = 'hackweek.theme';

export function getStoredTheme(): Theme | null {
try {
const theme = window.localStorage.getItem(THEME_STORAGE_KEY);
return theme === 'light' || theme === 'dark' ? theme : null;
} catch {
return null;
}
}

export function getSystemTheme(): Theme {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
Comment thread
HazAT marked this conversation as resolved.

export function applyTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
}

export function storeTheme(theme: Theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
return;
}
}
23 changes: 23 additions & 0 deletions test/app/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {afterEach, describe, expect, it} from 'vitest';

import {ThemeToggle} from '../../src/app/components/ThemeToggle';

afterEach(() => {
window.localStorage.removeItem('hackweek.theme');
delete document.documentElement.dataset.theme;
});

describe('theme toggle', () => {
it('switches modes and remembers the selected theme', async () => {
render(<ThemeToggle />);

const toggle = screen.getByRole('button', {name: 'Switch to dark mode'});
await userEvent.click(toggle);

expect(document.documentElement.dataset.theme).toBe('dark');
expect(window.localStorage.getItem('hackweek.theme')).toBe('dark');
expect(toggle.getAttribute('aria-label')).toBe('Switch to light mode');
});
});
Loading