From 32de518670060c89f71c9150beb6da0d4949994a Mon Sep 17 00:00:00 2001 From: Kody Sale Date: Mon, 8 Jun 2026 15:37:42 -0700 Subject: [PATCH 1/4] Add Obol-branded dark mode with a light/dark toggle. --- components/assessment/AssessmentApp.tsx | 2 + components/assessment/ThemeToggle.tsx | 44 +++++++++++++++++++ components/assessment/stitches.ts | 15 +++++++ components/pizza/Pizza.tsx | 34 ++++++++++----- hooks/useTheme.ts | 58 +++++++++++++++++++++++++ pages/_document.tsx | 6 +++ pages/methodology.tsx | 4 ++ styles/theme-tokens.css | 52 ++++++++++++++++++++++ 8 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 components/assessment/ThemeToggle.tsx create mode 100644 hooks/useTheme.ts diff --git a/components/assessment/AssessmentApp.tsx b/components/assessment/AssessmentApp.tsx index 9cda7c3..4e17dd0 100644 --- a/components/assessment/AssessmentApp.tsx +++ b/components/assessment/AssessmentApp.tsx @@ -18,6 +18,7 @@ import { } from "./Blockers"; import { Intro } from "./Intro"; import { Question } from "./Question"; +import { ThemeToggle } from "./ThemeToggle"; import { LevelUp, ResultHero, ShareModal } from "./Results"; import { BrandAccent, @@ -122,6 +123,7 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { Methodology + diff --git a/components/assessment/ThemeToggle.tsx b/components/assessment/ThemeToggle.tsx new file mode 100644 index 0000000..7582ed5 --- /dev/null +++ b/components/assessment/ThemeToggle.tsx @@ -0,0 +1,44 @@ +import { useTheme } from "@hooks/useTheme"; +import { ThemeToggleButton } from "./stitches"; + +const SunIcon = () => ( + +); + +const MoonIcon = () => ( + +); + +/** + * Sun/moon button that flips light/dark. Renders the light-mode (moon) icon + * until mounted so it never mismatches the server-rendered markup. + */ +export function ThemeToggle() { + const { theme, toggle, mounted } = useTheme(); + const isDark = theme === "dark"; + + return ( + + {mounted && isDark ? : } + + ); +} diff --git a/components/assessment/stitches.ts b/components/assessment/stitches.ts index a16b2ba..7ab9f54 100644 --- a/components/assessment/stitches.ts +++ b/components/assessment/stitches.ts @@ -78,6 +78,21 @@ export const TopNavLink = styled("a", { "&:hover": { color: "$body" }, }); +export const ThemeToggleButton = styled("button", { + all: "unset", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 30, + height: 30, + borderRadius: "$round", + color: "$textMiddle", + border: "1px solid $bg05", + "&:hover": { color: "$body", backgroundColor: "$bg04" }, + "&:focus-visible": { outline: "2px solid var(--theme-brand)", outlineOffset: 2 }, +}); + export const MainGrid = styled(Box, { flex: 1, minHeight: 0, diff --git a/components/pizza/Pizza.tsx b/components/pizza/Pizza.tsx index a687906..a4f701b 100644 --- a/components/pizza/Pizza.tsx +++ b/components/pizza/Pizza.tsx @@ -1,13 +1,25 @@ import { SLICES } from "@lib/rubric"; -import type { Answers, SliceId, Stage } from "@lib/rubric/types"; -import { - PIZZA_EMPTY, - PIZZA_EMPTY_STROKE, - PIZZA_FILL, - PIZZA_INK, - PIZZA_PLATE, - PIZZA_RING, -} from "@lib/theme/tokens"; +import type { Answers, SliceColor, SliceId, Stage } from "@lib/rubric/types"; + +/** + * Theme-reactive colors. The in-app pizza reads CSS variables so it follows + * light/dark mode; the static OG share images use the hex constants in + * lib/theme/tokens.ts instead (they're pre-rendered at build time). + */ +const PIZZA_FILL: Record = { + green: "var(--vb-green)", + yellow: "var(--vb-yellow)", + red: "var(--vb-red)", +}; +const PIZZA_RING: Record = { + green: "var(--theme-risk-green-ring)", + yellow: "var(--theme-risk-yellow-ring)", + red: "var(--theme-risk-red-ring)", +}; +const PIZZA_PLATE = "var(--vb-plate)"; +const PIZZA_EMPTY = "var(--vb-empty)"; +const PIZZA_EMPTY_STROKE = "var(--vb-empty-stroke)"; +const PIZZA_INK = "var(--vb-ink)"; const RAD = Math.PI / 180; @@ -235,7 +247,7 @@ export function Pizza({ className="vbpizza__progress" style={{ fill: "none", - stroke: ringTone || "#9cc2c9", + stroke: ringTone || "var(--ice)", strokeWidth: 3.5, strokeLinecap: frac >= 1 ? "butt" : "round", strokeDasharray: @@ -257,7 +269,7 @@ export function Pizza({ y={cy - size * 0.028} className="vbpizza__stagek" textAnchor="middle" - style={{ fontSize: size * 0.033 }} + style={{ fill: PIZZA_INK, fontSize: size * 0.033 }} > STAGE diff --git a/hooks/useTheme.ts b/hooks/useTheme.ts new file mode 100644 index 0000000..5cc7184 --- /dev/null +++ b/hooks/useTheme.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; + +export type Theme = "light" | "dark"; + +const STORAGE_KEY = "vb-theme"; + +function applyTheme(theme: Theme) { + const root = document.documentElement; + if (theme === "dark") { + root.setAttribute("data-theme", "dark"); + } else { + root.removeAttribute("data-theme"); + } +} + +/** + * Light/dark theme state, persisted to localStorage and reflected as + * data-theme on . Defaults to light. The pre-paint script in + * _document.tsx sets the attribute before hydration to avoid a flash; + * this hook syncs React state to it on mount. + */ +export function useTheme() { + // Match the server-rendered markup (light) on first client render, then + // reconcile to the persisted choice in the effect below — no hydration gap. + const [theme, setTheme] = useState("light"); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + let stored: Theme | null = null; + try { + stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + } catch { + stored = null; + } + const current = + stored ?? + (document.documentElement.getAttribute("data-theme") === "dark" + ? "dark" + : "light"); + setTheme(current); + setMounted(true); + }, []); + + const toggle = () => { + setTheme((prev) => { + const next: Theme = prev === "dark" ? "light" : "dark"; + applyTheme(next); + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + /* private mode / storage disabled — theme still applies for the session */ + } + return next; + }); + }; + + return { theme, toggle, mounted }; +} diff --git a/pages/_document.tsx b/pages/_document.tsx index 8fd08be..aa12d6b 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -15,6 +15,12 @@ export default function Document() { return ( +