diff --git a/README.md b/README.md index 49ae306..f1c55b8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Raidy is a raid scheduling project for Discord statics: the web console is where everyone plans, and Discord carries the announcements. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL. -**Status:** `v0.0.19` is an early development snapshot. +**Status:** `v0.0.20` is an early development snapshot. ```text backend/ Go service, Discord interactions, web API, PostgreSQL migrations diff --git a/web/e2e/product.spec.ts b/web/e2e/product.spec.ts index 6dc0c91..f2fc62a 100644 --- a/web/e2e/product.spec.ts +++ b/web/e2e/product.spec.ts @@ -42,3 +42,17 @@ test("the page never scrolls sideways on a phone", async ({ browser }) => { const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(0); }); + +test("the theme switch overrides the system and carries over to the demo", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/"); + const html = page.locator("html"); + await expect(html).toHaveClass(/dark/); + await page.getByRole("radio", { name: "Light theme" }).click(); + await expect(html).not.toHaveClass(/dark/); + await page.reload(); + await expect(html).not.toHaveClass(/dark/); + await page.getByRole("link", { name: "Try the demo" }).click(); + await expect(page.getByRole("table", { name: /availability/i })).toBeVisible({ timeout: 30_000 }); + await expect(html).not.toHaveClass(/dark/); +}); diff --git a/web/package-lock.json b/web/package-lock.json index fea1fed..29caa32 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "raidy-web", - "version": "0.0.19", + "version": "0.0.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "raidy-web", - "version": "0.0.19", + "version": "0.0.20", "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", diff --git a/web/package.json b/web/package.json index 9677871..823cc9c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "raidy-web", "private": true, - "version": "0.0.19", + "version": "0.0.20", "type": "module", "scripts": { "dev": "vite", diff --git a/web/product/index.html b/web/product/index.html index 21654a2..21259d9 100644 --- a/web/product/index.html +++ b/web/product/index.html @@ -17,9 +17,20 @@ diff --git a/web/src/App.tsx b/web/src/App.tsx index c73e6a8..9620cc1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,7 +11,7 @@ import { SchedulePanel } from "@/features/schedule/schedule-panel"; import { TeamPanel } from "@/features/team/team-panel"; import { message } from "@/lib/format"; import { isDemo, productURL, teamURL } from "@/lib/paths"; -import { applyTheme, type Theme, type ThemePreference } from "@/lib/theme"; +import { useTheme } from "@/lib/theme"; import { cn } from "@/lib/utils"; type Tab = "overview" | "schedule" | "team"; @@ -33,11 +33,7 @@ export default function App() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [revision, setRevision] = useState(0); - const [themePreference, setThemePreference] = useState(() => { - const saved = document.documentElement.dataset.themePreference; - return saved === "light" || saved === "dark" ? saved : "system"; - }); - const [resolvedTheme, setResolvedTheme] = useState("light"); + const { preference: themePreference, resolved: resolvedTheme, choose: chooseTheme } = useTheme(); async function loadData(preferred?: number) { const [nextTeams, nextGuilds] = await Promise.all([api("/api/teams"), api("/api/guilds")]); @@ -68,18 +64,6 @@ export default function App() { })(); }, []); - useEffect(() => { - const media = matchMedia("(prefers-color-scheme: dark)"); - const update = () => { - const theme = themePreference === "system" ? (media.matches ? "dark" : "light") : themePreference; - applyTheme(theme); - setResolvedTheme(theme); - }; - update(); - if (themePreference === "system") media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, [themePreference]); - const selected = teams.find((team) => team.id === teamID) ?? null; function selectTeam(id: number) { @@ -100,16 +84,6 @@ export default function App() { location.assign(isDemo ? productURL : "/"); } - function chooseTheme(next: ThemePreference) { - document.documentElement.dataset.themePreference = next; - try { - localStorage.setItem("raidy-theme", next); - } catch { - /* Theme still applies for this page. */ - } - setThemePreference(next); - } - const toaster = ; if (loading) return ; diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts index 93e1378..dd1e86e 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.ts @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; + export type Theme = "light" | "dark"; export type ThemePreference = Theme | "system"; @@ -9,3 +11,39 @@ export function applyTheme(theme: Theme) { .querySelector('meta[name="theme-color"]') ?.setAttribute("content", theme === "dark" ? "#121110" : "#fafaf9"); } + +/** + * The viewer's theme choice (System, Light or Dark), saved per browser and + * shared by every page on the site, and the theme it resolves to. + */ +export function useTheme() { + const [preference, setPreference] = useState(() => { + const saved = document.documentElement.dataset.themePreference; + return saved === "light" || saved === "dark" ? saved : "system"; + }); + const [resolved, setResolved] = useState("light"); + + useEffect(() => { + const media = matchMedia("(prefers-color-scheme: dark)"); + const update = () => { + const theme = preference === "system" ? (media.matches ? "dark" : "light") : preference; + applyTheme(theme); + setResolved(theme); + }; + update(); + if (preference === "system") media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, [preference]); + + function choose(next: ThemePreference) { + document.documentElement.dataset.themePreference = next; + try { + localStorage.setItem("raidy-theme", next); + } catch { + /* Theme still applies for this page. */ + } + setPreference(next); + } + + return { preference, resolved, choose }; +} diff --git a/web/src/product/product-page.tsx b/web/src/product/product-page.tsx index 74ffd2b..7728254 100644 --- a/web/src/product/product-page.tsx +++ b/web/src/product/product-page.tsx @@ -1,6 +1,9 @@ import { CalendarClock, CalendarSync, Globe, MessageSquareText, MousePointerClick, ShieldCheck } from "lucide-react"; import { Wordmark } from "@/components/common"; +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { ThemeToggle } from "@/components/user-menu"; +import { type Theme, useTheme } from "@/lib/theme"; import { HeroDemo } from "./hero-demo"; const github = "https://github.com/joshchen-dev/raidy"; @@ -64,6 +67,7 @@ const questions = [ ]; export function ProductPage() { + const theme = useTheme(); return (
@@ -81,9 +85,14 @@ export function ProductPage() { FAQ - +
+
+ +
+ +
@@ -145,8 +154,12 @@ export function ProductPage() { Inside the app
- - + +
@@ -194,6 +207,9 @@ export function ProductPage() {