From 972981350070d067ffbb85e55a5936d0a278e08d Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 27 Aug 2026 12:51:39 +0200 Subject: [PATCH 1/2] feat(ui): add theme toggle and clean up loading state Add a compact light/dark control to authenticated and sign-in surfaces while preserving the system color scheme as the first-visit default. Persist an explicit selection and keep system changes in sync until the user chooses a mode. Remove the raised panel, gradient, border, and shadow treatments from loading states so the branded animation sits directly on the page background in either theme. --- src/app/App.tsx | 2 + src/app/components/AppLayout.tsx | 2 + src/app/components/ThemeToggle.tsx | 60 +++++++++++ src/app/main.tsx | 3 + src/app/styles.css | 160 +++++++++++++++++------------ src/app/theme.ts | 32 ++++++ test/app/ThemeToggle.test.tsx | 23 +++++ 7 files changed, 218 insertions(+), 64 deletions(-) create mode 100644 src/app/components/ThemeToggle.tsx create mode 100644 src/app/theme.ts create mode 100644 test/app/ThemeToggle.test.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 1570291..eb5ff57 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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'; @@ -116,6 +117,7 @@ function AuthState({ }) { return (
+
+ {user.actualRole === 'admin' && (
viewing as {user.role === 'admin' ? 'admin' : 'user'} diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..f8344f1 --- /dev/null +++ b/src/app/components/ThemeToggle.tsx @@ -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(() => 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 ( + + ); +} diff --git a/src/app/main.tsx b/src/app/main.tsx index 4c347a1..c9ee91e 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -4,8 +4,11 @@ import {createRoot} from 'react-dom/client'; import {App} from './App'; import {SessionProvider} from './session'; +import {initializeTheme} from './theme'; import './styles.css'; +initializeTheme(); + const root = document.getElementById('root'); if (!root) { diff --git a/src/app/styles.css b/src/app/styles.css index 0d8e635..4ce5876 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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; - } +: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; } * { @@ -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 { @@ -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 { @@ -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); diff --git a/src/app/theme.ts b/src/app/theme.ts new file mode 100644 index 0000000..6cf1815 --- /dev/null +++ b/src/app/theme.ts @@ -0,0 +1,32 @@ +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'; +} + +export function applyTheme(theme: Theme) { + document.documentElement.dataset.theme = theme; +} + +export function initializeTheme() { + applyTheme(getStoredTheme() ?? getSystemTheme()); +} + +export function storeTheme(theme: Theme) { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + return; + } +} diff --git a/test/app/ThemeToggle.test.tsx b/test/app/ThemeToggle.test.tsx new file mode 100644 index 0000000..559f8e5 --- /dev/null +++ b/test/app/ThemeToggle.test.tsx @@ -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(); + + 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'); + }); +}); From 1969cb49f1566e54bff05b6e722dcd3285c877d0 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 27 Aug 2026 13:04:47 +0200 Subject: [PATCH 2/2] fix(ui): apply theme before first paint Resolve the saved or system theme in a parser-blocking head script so the document receives its theme attribute before the generated stylesheet loads. Remove the deferred module initializer, while leaving the interactive toggle responsible for subsequent theme changes. --- index.html | 17 +++++++++++++++++ src/app/main.tsx | 3 --- src/app/theme.ts | 4 ---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 2b553d9..1cbed43 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,23 @@ + diff --git a/src/app/main.tsx b/src/app/main.tsx index c9ee91e..4c347a1 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -4,11 +4,8 @@ import {createRoot} from 'react-dom/client'; import {App} from './App'; import {SessionProvider} from './session'; -import {initializeTheme} from './theme'; import './styles.css'; -initializeTheme(); - const root = document.getElementById('root'); if (!root) { diff --git a/src/app/theme.ts b/src/app/theme.ts index 6cf1815..3eeace3 100644 --- a/src/app/theme.ts +++ b/src/app/theme.ts @@ -19,10 +19,6 @@ export function applyTheme(theme: Theme) { document.documentElement.dataset.theme = theme; } -export function initializeTheme() { - applyTheme(getStoredTheme() ?? getSystemTheme()); -} - export function storeTheme(theme: Theme) { try { window.localStorage.setItem(THEME_STORAGE_KEY, theme);