Skip to content

Add dark mode #7

@AJaccP

Description

@AJaccP

🧠 Context

The site is light-mode only. The design tokens in src/styles/global.css are semantic (--color-canvas/surface/ink/muted/accent/accent-contrast/line) precisely so dark mode is a values-only change: override those custom properties under a .dark selector and every utility (bg-canvas, text-ink, border-line, …) flips automatically — no markup changes across components.

This ticket adds (1) a dark palette, (2) a class-based toggle that respects the OS preference on first visit and then persists the user's explicit choice, and (3) a no-flash init so the page never paints the wrong theme first.

Behavior:

  • First visit: follow the OS prefers-color-scheme.
  • After the user clicks the toggle: remember their choice in localStorage and use it on every later visit, ignoring the OS.

Files you'll touch:

  • src/styles/global.css (add the dark token values)
  • src/layouts/Base.astro (one tiny inline <head> script — the no-flash init)
  • src/components/Header.astro (just the toggle button, on the far right — nothing else)

Don't touch: any other component or page. Because the tokens are semantic, you should not need to.


🛠️ Implementation Plan

  1. Dark token values in global.css. Add a .dark override block after the @theme. The utilities reference the CSS variables, so overriding the variables under html.dark re-themes everything. Starting palette (tune as needed — brighten the red a touch so it pops on a dark background):

    html.dark {
      --color-canvas: #131313; /* Carleton black — page background */
      --color-surface: #1d1d1d; /* cards, panels — a step lighter than canvas */
      --color-ink: #f4f3f1; /* primary text — the light off-white */
      --color-muted: #9a9a9a; /* secondary text */
      --color-accent: #ff3b3b; /* slightly brightened Carleton red for dark bg */
      --color-accent-contrast: #ffffff; /* text/icons on an accent fill */
      --color-line: #2c2c2c; /* borders, dividers */
    }

    These are a baseline, not a mandate — adjust for contrast/legibility (e.g. surface vs canvas separation, muted readability). Don't add new token names; just override the existing ones.

    Also wire Tailwind's dark: variant to the class (by default in Tailwind v4 it follows prefers-color-scheme, not a class). Add this near the top of global.css, after the @import 'tailwindcss';:

    @custom-variant dark (&:where(.dark, .dark *));

    That makes dark:inline / dark:hidden (used for the sun/moon swap below) track the html.dark class.

  2. No-flash init in Base.astro <head>. Add an is:inline script (so Astro doesn't defer/bundle it) as early in <head> as possible. It applies the saved choice, else the OS preference, before the body paints:

    <script is:inline>
      const saved = localStorage.getItem('theme');
      if (
        saved === 'dark' ||
        (!saved && matchMedia('(prefers-color-scheme: dark)').matches)
      ) {
        document.documentElement.classList.add('dark');
      }
    </script>
  3. Toggle button in Header.astro, far right. Add a single icon button at the extreme right of the header (i.e. after the closing nav tag). Keep the brand on the left and group the nav + toggle on the right so the layout stays balanced. Don't change anything else in the header. Build it yourself along these lines:

    • A <button> with id="theme-toggle", type="button", and an aria-label (e.g. "Toggle dark mode") — that label is the button's screen-reader name. Token classes like text-muted hover:text-accent transition-colors fit the rest of the header.
    • Inside it, two lucide icons via astro-icon (import Icon from astro-icon/components in the frontmatter): lucide:sun and lucide:moon. Both aria-hidden="true" (decorative — hides them from screen readers only; they stay visible, so the button isn't announced as "image").
    • Show the moon in light mode, sun in dark mode using the dark: variant — e.g. the moon is visible by default and dark:hidden, the sun is hidden and dark:inline. Because this is driven off the html.dark class in CSS, the correct icon shows immediately after the no-flash init, no JS needed for the initial state. (For dark: to track the .dark class, the custom-variant from step 2 must be wired.)
  4. Toggle handler. Add a small is:inline <script> at the bottom of Header.astro, after the closing </header> (it's part of the control, so it lives with it). On click it flips the class and persists the choice:

    <script is:inline>
      document.getElementById('theme-toggle')?.addEventListener('click', () => {
        const isDark = document.documentElement.classList.toggle('dark');
        localStorage.setItem('theme', isDark ? 'dark' : 'light');
      });
    </script>
  5. Verify in pnpm dev: with no stored choice the page follows your OS setting; clicking the toggle flips the theme and the correct sun/moon shows; reloading keeps the chosen theme (and no longer follows the OS once chosen); there's no flash of the wrong theme on load; cards/text/borders/accent all read well in both modes.


✅ Acceptance Criteria

  • A .dark block in global.css overrides the semantic tokens; switching it re-themes the whole site with no per-component changes.
  • First visit follows the OS prefers-color-scheme; after the user toggles, their choice persists in localStorage and wins over the OS.
  • No flash of the wrong theme on initial load (inline <head> init).
  • The header gains only a far-right toggle button (sun in dark / moon in light), themed via currentColor with an aria-label; nothing else in the header changes.
  • Both modes are legible — surface/canvas separation, muted text, and the accent all read well.
  • pnpm format:check, pnpm check, and pnpm build all pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions