diff --git a/components/AppThemeProvider.tsx b/components/AppThemeProvider.tsx new file mode 100644 index 0000000..fb2b4bb --- /dev/null +++ b/components/AppThemeProvider.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { + ThemeProvider, + type ThemeProviderProps, +} from "next-themes"; + +export function AppThemeProvider({ + children, + ...props +}: ThemeProviderProps) { + return {children}; +} diff --git a/components/assessment/AssessmentApp.tsx b/components/assessment/AssessmentApp.tsx index 9cda7c3..4a46633 100644 --- a/components/assessment/AssessmentApp.tsx +++ b/components/assessment/AssessmentApp.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { useEffect, useState } from "react"; import { Pizza } from "@components/pizza/Pizza"; import { useAssessment } from "@hooks/useAssessment"; @@ -18,6 +17,7 @@ import { } from "./Blockers"; import { Intro } from "./Intro"; import { Question } from "./Question"; +import { ThemeToggle } from "./ThemeToggle"; import { LevelUp, ResultHero, ShareModal } from "./Results"; import { BrandAccent, @@ -112,16 +112,13 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { return ( - - - Validator Beat - - + + Validator Beat + v0.1 · self-assessment - - Methodology - + Methodology + @@ -135,7 +132,7 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { onChoose={choose} index={a.step} total={a.total} - onBack={a.back} + onBack={a.step === 0 ? a.toIntro : a.back} onShowResults={ allAnswered(a.answers) ? a.showResults : undefined } diff --git a/components/assessment/Question.tsx b/components/assessment/Question.tsx index b0b298f..0edc8ed 100644 --- a/components/assessment/Question.tsx +++ b/components/assessment/Question.tsx @@ -103,7 +103,7 @@ export function Question({ )} )} - {index > 0 && onBack && ( + {onBack && ( ← Back diff --git a/components/assessment/ThemeToggle.tsx b/components/assessment/ThemeToggle.tsx new file mode 100644 index 0000000..4124c74 --- /dev/null +++ b/components/assessment/ThemeToggle.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { useTheme } from "next-themes"; +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 { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + const isDark = mounted && resolvedTheme === "dark"; + + return ( + setTheme(isDark ? "light" : "dark")} + aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} + title={isDark ? "Switch to light mode" : "Switch to dark mode"} + > + {mounted && isDark ? : } + + ); +} diff --git a/components/assessment/stitches.ts b/components/assessment/stitches.ts index a16b2ba..79c55e4 100644 --- a/components/assessment/stitches.ts +++ b/components/assessment/stitches.ts @@ -1,5 +1,6 @@ import { Box, Text, styled } from "@obolnetwork/obol-ui"; import type { SliceColor } from "@lib/rubric/types"; +import NextLink from "next/link"; /** Validator Beat risk palette — maps to theme-tokens.css */ export const risk = { @@ -46,7 +47,7 @@ export const TopBar = styled(Box, { flexShrink: 0, }); -export const BrandLink = styled("a", { +export const BrandLink = styled(NextLink, { fontSize: "$3", fontWeight: "$bold", letterSpacing: "-0.01em", @@ -70,7 +71,7 @@ export const TagPill = styled(Box, { export const TopSpacer = styled(Box, { marginLeft: "auto" }); -export const TopNavLink = styled("a", { +export const TopNavLink = styled(NextLink, { fontSize: "$2", fontWeight: "$medium", color: "$textMiddle", @@ -78,6 +79,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, @@ -262,12 +278,13 @@ export const LadderName = styled(Text, { export const LadderKind = styled(Text, { fontSize: "9.5px", fontWeight: "$bold", + lineHeight: 1, textTransform: "uppercase", letterSpacing: "0.07em", color: "$textMiddle", border: "1px solid $bg05", borderRadius: "$pill", - padding: "1px 7px", + padding: "3px 7px", variants: { tone: { red: { color: risk.red, borderColor: risk.redB }, @@ -280,9 +297,10 @@ export const LadderKind = styled(Text, { export const LadderHere = styled(Text, { fontSize: "9.5px", fontWeight: "$bold", + lineHeight: 1, textTransform: "uppercase", letterSpacing: "0.06em", - padding: "2px 8px", + padding: "3px 8px", borderRadius: "$pill", whiteSpace: "nowrap", variants: { diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index ccf1ead..a8b52f7 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -1,4 +1,4 @@ -import { Box, Link, Text } from "@obolnetwork/obol-ui"; +import { Box, Text } from "@obolnetwork/obol-ui"; import NextLink from "next/link"; import { useRouter } from "next/router"; import { SITE_NAME } from "@constants/index"; @@ -25,13 +25,11 @@ export function Navbar() { backgroundColor: "$bg01", }} > - - - - {SITE_NAME} - - - + + + {SITE_NAME} + + {NAV_ITEMS.map(({ href, label }) => { const active = @@ -39,18 +37,19 @@ export function Navbar() { ? router.pathname === "/" : router.pathname.startsWith(href.replace(/\/$/, "")); return ( - - - {label} - - + + {label} + ); })} diff --git a/components/pizza/Pizza.tsx b/components/pizza/Pizza.tsx index a687906..14c963e 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; @@ -185,8 +197,8 @@ export function Pizza({ )} {showLabels && ( = 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/useAssessment.ts b/hooks/useAssessment.ts index 460e9fa..98bc04f 100644 --- a/hooks/useAssessment.ts +++ b/hooks/useAssessment.ts @@ -44,6 +44,8 @@ export function useAssessment() { const start = useCallback(() => setStarted(true), []); const back = useCallback(() => setStep((s) => Math.max(s - 1, 0)), []); + /** Return to the intro screen, keeping answers and step so progress resumes. */ + const toIntro = useCallback(() => setStarted(false), []); const goto = useCallback((i: number) => { setStarted(true); setStep(Math.max(0, Math.min(i, total))); @@ -87,6 +89,7 @@ export function useAssessment() { total, choose, back, + toIntro, goto, showResults, takeResultsConfetti, diff --git a/package.json b/package.json index 696331e..88bb8d3 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@stitches/react": "1.2.8", "html-to-image": "1.11.13", "next": "15.5.15", + "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", "react-is": "19.2.3" diff --git a/pages/_app.tsx b/pages/_app.tsx index 6f6d6e8..7dc44e8 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,3 +1,4 @@ +import { AppThemeProvider } from "@components/AppThemeProvider"; import { globalCss, Provider as TooltipProvider } from "@obolnetwork/obol-ui"; import "@styles/colors_and_type.css"; import "@styles/obol-bridge.css"; @@ -48,8 +49,14 @@ export default function App({ Component, pageProps }: AppProps) { : undefined; return ( - - + + + {title} @@ -65,8 +72,9 @@ export default function App({ Component, pageProps }: AppProps) { )} - - - + + + + ); } diff --git a/pages/_document.tsx b/pages/_document.tsx index 8fd08be..69c98f5 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -13,7 +13,7 @@ const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; export default function Document() { return ( - +