Skip to content

Dark mode #44

Description

@dwalintukan

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

  1. 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.
  2. 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).
  3. The persisted choice survives a full page reload.
  4. 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

  • With localStorage empty and the OS set to dark, first load renders dark.
  • With localStorage empty and the OS set to light, first load renders light.
  • Clicking the toggle flips the theme immediately.
  • After toggling to dark and hard-refreshing, the app is still dark, with no
    white flash before paint.
  • An explicit choice wins over the OS preference on subsequent loads.
  • LoginPage (rendered before auth, outside AppShell) is also themed.
  • npm run build in frontend/ succeeds.

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.

Metadata

Metadata

Assignees

Labels

P2Priority 2 task – nice to haves

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions