Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,26 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />

<!-- Static CSS imports to ensure theme is always available -->
<!-- All theme CSS files loaded statically - data-theme attribute controls which applies -->
<link rel="stylesheet" href="/light-theme.css" />
<link rel="stylesheet" href="/dark-theme.css" />
<link rel="stylesheet" href="/system-theme.css" />

<!-- Blocking script to prevent theme flash - must run before page renders -->
<script>
;(function () {
var theme = localStorage.getItem('color-mode') || 'system'
document.documentElement.setAttribute('data-theme', theme)
if (
theme === 'dark' ||
(theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark')
}
})()
</script>

<!-- required for triggering custom events in signup form -->
<script>
window.plausible =
Expand Down
12 changes: 10 additions & 2 deletions src/lib/components/ui/ThemeSwitcher.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { colorMode } from '../../stores'
import { onMount } from 'svelte'
import { colorMode, hydrateColorMode } from '../../stores'
import IconSunny from '~icons/ic/round-wb-sunny'
import IconMoon from '~icons/octicon/moon-16'
import IconLaptop from '~icons/bi/laptop'
Expand All @@ -11,18 +12,25 @@
system: [IconLaptop, `dark`, `bi:laptop`],
} as const

let hydrated = $state(false)

function set_color_mode() {
const next = color_mode_icons[$colorMode][1] as typeof $colorMode
$colorMode = next
}

const CurrentIcon = $derived(color_mode_icons[$colorMode][0])

onMount(() => {
hydrateColorMode()
hydrated = true
})
</script>

<button
title="Set color mode"
onclick={set_color_mode}
style="display: flex; color: white;"
style="display: flex; color: white; opacity: {hydrated ? 1 : 0}; transition: opacity 0.15s;"
>
<CurrentIcon title={$colorMode} />
</button>
27 changes: 21 additions & 6 deletions src/lib/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,28 @@ export const colorModeKey = `color-mode`

type ColorMode = `light` | `dark` | `system`

export const colorMode = writable<ColorMode>(
(has_local_store && localStorage[colorModeKey]) || `system`,
)
// Initialize to 'system' for SSR, then hydrate from localStorage on client
export const colorMode = writable<ColorMode>(`system`)

colorMode.subscribe(
(val: ColorMode) => has_local_store && (localStorage[colorModeKey] = val),
)
// Flag to track if we've hydrated from localStorage
let colorModeHydrated = false

export function hydrateColorMode() {
if (!colorModeHydrated && has_local_store) {
const stored = localStorage[colorModeKey] as ColorMode | undefined
if (stored) {
colorMode.set(stored)
}
colorModeHydrated = true
}
}

// Only persist to localStorage AFTER hydration (to avoid overwriting stored value)
colorMode.subscribe((val: ColorMode) => {
if (colorModeHydrated && has_local_store) {
localStorage[colorModeKey] = val
}
})

// Custom session store implementation to replace svelte-zoo
function createSessionStore<T>(key: string, initialValue: T) {
Expand Down
9 changes: 8 additions & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation'
import { browser } from '$app/environment'
import { Footer, Header } from '$lib'
import { colorMode, microcopy } from '$lib/stores'
import type { Snippet } from 'svelte'
Expand All @@ -23,6 +24,13 @@
if (!window.visitedPages) window.visitedPages = [document.referrer]
window.visitedPages.push(location.pathname + location.search)
})

// Sync data-theme attribute when colorMode changes (for client-side theme switching)
$effect(() => {
if (browser) {
document.documentElement.setAttribute('data-theme', $colorMode)
}
})
</script>

<!-- Moved these here, from app.html, so these parameter can get different attributes for each site -->
Expand All @@ -33,7 +41,6 @@
<script defer data-domain={$microcopy?.meta?.url} src="/js/script.js"></script>

<meta name="color-scheme" content={$colorMode || `system`} />
<link rel="stylesheet" href="/{$colorMode || `system`}-theme.css" />
</svelte:head>

<Header nav={data.nav} />
Expand Down
3 changes: 2 additions & 1 deletion static/dark-theme.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
:root {
/* Dark theme - applies when data-theme="dark" or system preference is dark */
[data-theme='dark'] {
--text-color: white;
--link-color: var(--light-blue);
--hover-color: var(--green);
Expand Down
4 changes: 3 additions & 1 deletion static/light-theme.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
:root {
/* Light theme - applies when data-theme="light" or system preference is light */
:root,
[data-theme='light'] {
--text-color: black;
--link-color: var(--blue);
--hover-color: var(--light-blue);
Expand Down
23 changes: 21 additions & 2 deletions static/system-theme.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
@import url('/light-theme.css') (prefers-color-scheme: light);
/* System theme - uses media queries to follow OS preference */
/* Light is the default via light-theme.css :root selector */

@import url('/dark-theme.css') (prefers-color-scheme: dark);
/* Apply dark theme when system preference is dark AND data-theme is "system" */
@media (prefers-color-scheme: dark) {
[data-theme='system'] {
--text-color: white;
--link-color: var(--light-blue);
--hover-color: var(--green);
--body-bg: var(--darker-blue);
--accent-bg: var(--alt-gray);
--light-bg: var(--dark-gray);
--shadow: black;
--border-color: var(--dark-green);
--heading-color: var(--light-green);
--header-bg: var(--darker-blue);
--header-color: var(--light-blue);
--footer-bg: black;
--invert: 1;
--transparent: rgba(0, 0, 0, 0.5);
}
}