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() {