Skip to content

Add dark mode #7

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

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions