Dark mode
Problem
The frontend already ships a complete dark palette — frontend/src/index.css
defines a .dark token block (lines 60–79) and the dark variant (line 6) —
but nothing ever puts the dark class on the document, so it is unreachable.
Users on a dark OS get a full-white app.
Scope
frontend/ only. The Astro landing/ site is a separate app and is out of
scope for this issue.
Requirements
- On load, the app resolves a theme: the value previously saved by the user,
or prefers-color-scheme when they have never chosen. It applies it by
toggling the dark class on document.documentElement.
- A toggle in the
AppShell header switches between light and dark and
persists the choice to localStorage under the key forkcast-theme
(values: light | dark).
- The persisted choice survives a full page reload.
- No flash of the wrong theme on first paint, including a hard refresh in
dark mode.
Non-goals
- A three-way light / dark / system selector. Two states plus a
system-derived default is enough; do not build a settings surface.
- Re-theming any component. They already use design tokens.
- Changing the
.dark token values in index.css.
- The Google logo SVG in
LoginPage.tsx keeps its literal brand hex colors.
- Theming the
landing/ Astro site.
Constraints
- Follow
CLAUDE.md. Most relevant: minimum code that solves the problem, no
speculative features, touch only what you must.
- Match existing conventions: hooks live in
frontend/src/hooks/ and are named
useX.ts (see useAuth.ts); icons come from lucide-react; buttons use
buttonVariants from @/components/ui/button with cn from @/lib/utils.
- The FOUC fix must run before React mounts. A small blocking inline script in
frontend/index.html is the expected approach — a useEffect is not,
because it runs after first paint.
- The toggle must be reachable by keyboard and carry an accessible name
(aria-label or title, matching the sign-out link in AppShell.tsx).
Suggested shape
Not binding — deviate if something simpler works.
frontend/index.html — inline <script> in <head> that reads
localStorage['forkcast-theme'], falls back to
matchMedia('(prefers-color-scheme: dark)'), and sets the class on
document.documentElement.
frontend/src/hooks/useTheme.ts — reads the class already on the element as
its initial state (the inline script is the source of truth), exposes the
current theme and a toggle that writes both the class and localStorage.
frontend/src/components/AppShell.tsx — a ghost / icon button next to
the sign-out link, using Sun and Moon from lucide-react.
Acceptance criteria
Verification
Add a vitest test alongside the hook covering the resolution precedence —
saved choice beats OS preference, OS preference is the fallback — since that
is the rule most likely to regress. frontend/src/lib/sse.test.ts is the
existing test convention.
Note: the CI runner that implements this issue has no Node toolchain, so
npm run build and npm test cannot be run there. State that under a
"Not verified" heading in the PR body and list what a reviewer must check.
Dark mode
Problem
The frontend already ships a complete dark palette —
frontend/src/index.cssdefines a
.darktoken block (lines 60–79) and thedarkvariant (line 6) —but nothing ever puts the
darkclass on the document, so it is unreachable.Users on a dark OS get a full-white app.
Scope
frontend/only. The Astrolanding/site is a separate app and is out ofscope for this issue.
Requirements
or
prefers-color-schemewhen they have never chosen. It applies it bytoggling the
darkclass ondocument.documentElement.AppShellheader switches between light and dark andpersists the choice to
localStorageunder the keyforkcast-theme(values:
light|dark).dark mode.
Non-goals
system-derived default is enough; do not build a settings surface.
.darktoken values inindex.css.LoginPage.tsxkeeps its literal brand hex colors.landing/Astro site.Constraints
CLAUDE.md. Most relevant: minimum code that solves the problem, nospeculative features, touch only what you must.
frontend/src/hooks/and are nameduseX.ts(seeuseAuth.ts); icons come fromlucide-react; buttons usebuttonVariantsfrom@/components/ui/buttonwithcnfrom@/lib/utils.frontend/index.htmlis the expected approach — auseEffectis not,because it runs after first paint.
(
aria-labelortitle, matching the sign-out link inAppShell.tsx).Suggested shape
Not binding — deviate if something simpler works.
frontend/index.html— inline<script>in<head>that readslocalStorage['forkcast-theme'], falls back tomatchMedia('(prefers-color-scheme: dark)'), and sets the class ondocument.documentElement.frontend/src/hooks/useTheme.ts— reads the class already on the element asits initial state (the inline script is the source of truth), exposes the
current theme and a toggle that writes both the class and
localStorage.frontend/src/components/AppShell.tsx— aghost/iconbutton next tothe sign-out link, using
SunandMoonfromlucide-react.Acceptance criteria
localStorageempty and the OS set to dark, first load renders dark.localStorageempty and the OS set to light, first load renders light.white flash before paint.
LoginPage(rendered before auth, outsideAppShell) is also themed.npm run buildinfrontend/succeeds.Verification
Add a
vitesttest alongside the hook covering the resolution precedence —saved choice beats OS preference, OS preference is the fallback — since that
is the rule most likely to regress.
frontend/src/lib/sse.test.tsis theexisting test convention.
Note: the CI runner that implements this issue has no Node toolchain, so
npm run buildandnpm testcannot be run there. State that under a"Not verified" heading in the PR body and list what a reviewer must check.