🧠 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
-
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.
-
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>
-
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.)
-
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>
-
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
🧠 Context
The site is light-mode only. The design tokens in
src/styles/global.cssare 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.darkselector 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:
prefers-color-scheme.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):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';: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: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:<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.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").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.)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: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
.darkblock inglobal.cssoverrides the semantic tokens; switching it re-themes the whole site with no per-component changes.prefers-color-scheme; after the user toggles, their choice persists inlocalStorageand wins over the OS.<head>init).currentColorwith anaria-label; nothing else in the header changes.pnpm format:check,pnpm check, andpnpm buildall pass.