Repository navigation
Add dark mode #7
Description
🧠 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
localStorageand 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
-
Dark token values in
global.css. Add a.darkoverride block after the@theme. The utilities reference the CSS variables, so overriding the variables underhtml.darkre-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 followsprefers-color-scheme, not a class). Add this near the top ofglobal.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 thehtml.darkclass. -
No-flash init in
Base.astro<head>. Add anis:inlinescript (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>
-
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>withid="theme-toggle",type="button", and anaria-label(e.g. "Toggle dark mode") — that label is the button's screen-reader name. Token classes liketext-muted hover:text-accent transition-colorsfit the rest of the header. - Inside it, two lucide icons via
astro-icon(importIconfromastro-icon/componentsin the frontmatter):lucide:sunandlucide:moon. Botharia-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 anddark:hidden, the sun ishiddenanddark:inline. Because this is driven off thehtml.darkclass in CSS, the correct icon shows immediately after the no-flash init, no JS needed for the initial state. (Fordark:to track the.darkclass, the custom-variant from step 2 must be wired.)
- A
-
Toggle handler. Add a small
is:inline<script>at the bottom ofHeader.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>
-
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
.darkblock inglobal.cssoverrides 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 inlocalStorageand 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
currentColorwith anaria-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, andpnpm buildall pass.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
Add dark mode CarletonComputerScienceSociety#7
Add dark mode #7 (#17)