From d6dceb34eeb29c170b5425007f08013cb270521d Mon Sep 17 00:00:00 2001
From: Braden Wong <13159333+braden-w@users.noreply.github.com>
Date: Wed, 24 Dec 2025 20:17:24 -0800
Subject: [PATCH 1/2] docs: add FOUC prevention guide for CSR/SPA applications
---
docs/src/content/guides/csr-spa-fouc.md | 105 ++++++++++++++++++++++++
docs/src/lib/navigation.ts | 55 ++++++++-----
2 files changed, 138 insertions(+), 22 deletions(-)
create mode 100644 docs/src/content/guides/csr-spa-fouc.md
diff --git a/docs/src/content/guides/csr-spa-fouc.md b/docs/src/content/guides/csr-spa-fouc.md
new file mode 100644
index 0000000..32b815e
--- /dev/null
+++ b/docs/src/content/guides/csr-spa-fouc.md
@@ -0,0 +1,105 @@
+---
+title: CSR/SPA FOUC Prevention
+description: Prevent flash of unstyled content in client-side rendered applications.
+section: Guides
+---
+
+
+
+If you're using mode-watcher in a client-side rendered application (SvelteKit with `ssr: false`, Vite SPA, or any static site), you may experience a flash of unstyled content (FOUC) before mode-watcher hydrates.
+
+## Why This Happens
+
+In CSR apps, JavaScript executes after the initial HTML renders. The browser shows unstyled content (usually light mode) until mode-watcher reads localStorage and applies the theme.
+
+This differs from SSR apps where the server can inject the correct theme before the page reaches the browser.
+
+## Solution
+
+Add this script to your `
` **before** any stylesheets:
+
+```html
+
+```
+
+This script:
+
+- Runs synchronously before first paint
+- Reads the same localStorage key mode-watcher uses
+- Handles `light`, `dark`, and `system` modes
+- Sets both the `dark` class and `color-scheme` property
+
+## Customizing the Default
+
+Change the fallback value if your app defaults to a specific theme:
+
+```javascript
+// Default to dark mode for new users
+const mode = localStorage.getItem("mode-watcher-mode") ?? "dark";
+```
+
+## Why Not CSS-Only?
+
+A CSS-only approach using `@media (prefers-color-scheme: dark)` only respects the OS preference, not the user's stored choice.
+
+**Failure case:**
+
+1. User's OS is set to light mode
+2. User explicitly chooses dark mode in your app (stored in localStorage)
+3. On reload: CSS sees "light" OS preference, shows light background
+4. mode-watcher hydrates, reads "dark" from localStorage, switches to dark
+5. User sees a light-to-dark flash
+
+The JavaScript approach reads localStorage directly, respecting the user's explicit choice.
+
+## SvelteKit with adapter-static
+
+For SvelteKit apps using `adapter-static` with `ssr: false`:
+
+### 1. Add the script to `src/app.html`
+
+```html title="src/app.html"
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
+```
+
+### 2. Common Pitfalls
+
+
+**Do not** use `nonce="%sveltekit.nonce%"` with prerendered pages. The placeholder isn't replaced during prerendering and will cause CSP failures.
+
+
+
+**Do not** use `hooks.server.ts` for script injection. Server hooks don't run for prerendered pages; they only execute during SSR.
+
+
+## SSR Applications
+
+If you're using SSR and need CSP compliance, see the [`createInitialModeExpression`](/docs/utilities/create-initial-mode-expression) utility instead. It's designed for server-rendered apps where `hooks.server.ts` runs on each request.
+
+## References
+
+- [SvelteKit CSP nonce limitations with prerendering](https://github.com/sveltejs/kit/issues/13307)
+- [Real-world implementation in Epicenter](https://github.com/EpicenterHQ/epicenter/pull/1168)
diff --git a/docs/src/lib/navigation.ts b/docs/src/lib/navigation.ts
index 58e9785..5e9c386 100644
--- a/docs/src/lib/navigation.ts
+++ b/docs/src/lib/navigation.ts
@@ -1,33 +1,40 @@
-import { defineNavigation } from "@svecodocs/kit";
-import ChalkboardTeacher from "phosphor-svelte/lib/ChalkboardTeacher";
-import RocketLaunch from "phosphor-svelte/lib/RocketLaunch";
-import NoteBlank from "phosphor-svelte/lib/NoteBlank";
-import Tag from "phosphor-svelte/lib/Tag";
-import { getAllDocs } from "./utils.js";
+import { defineNavigation } from '@svecodocs/kit';
+import ChalkboardTeacher from 'phosphor-svelte/lib/ChalkboardTeacher';
+import RocketLaunch from 'phosphor-svelte/lib/RocketLaunch';
+import NoteBlank from 'phosphor-svelte/lib/NoteBlank';
+import Tag from 'phosphor-svelte/lib/Tag';
+import { getAllDocs } from './utils.js';
const allDocs = getAllDocs();
const components = allDocs
- .filter((doc) => doc.section === "Components")
+ .filter((doc) => doc.section === 'Components')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
}));
const states = allDocs
- .filter((doc) => doc.section === "States")
+ .filter((doc) => doc.section === 'States')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
}));
const utilities = allDocs
- .filter((doc) => doc.section === "Utilities")
+ .filter((doc) => doc.section === 'Utilities')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
}));
const testing = allDocs
- .filter((doc) => doc.section === "Testing")
+ .filter((doc) => doc.section === 'Testing')
+ .map((doc) => ({
+ title: doc.title,
+ href: `/docs/${doc.slug}`,
+ }));
+
+const guides = allDocs
+ .filter((doc) => doc.section === 'Guides')
.map((doc) => ({
title: doc.title,
href: `/docs/${doc.slug}`,
@@ -36,41 +43,45 @@ const testing = allDocs
export const navigation = defineNavigation({
anchors: [
{
- title: "Introduction",
- href: "/docs",
+ title: 'Introduction',
+ href: '/docs',
icon: ChalkboardTeacher,
},
{
- title: "Getting Started",
- href: "/docs/getting-started",
+ title: 'Getting Started',
+ href: '/docs/getting-started',
icon: RocketLaunch,
},
{
- title: "Mode vs Theme",
- href: "/docs/mode-vs-theme",
+ title: 'Mode vs Theme',
+ href: '/docs/mode-vs-theme',
icon: NoteBlank,
},
{
- title: "Releases",
- href: "https://github.com/svecosystem/mode-watcher/releases",
+ title: 'Releases',
+ href: 'https://github.com/svecosystem/mode-watcher/releases',
icon: Tag,
},
],
sections: [
{
- title: "Components",
+ title: 'Guides',
+ items: guides,
+ },
+ {
+ title: 'Components',
items: components,
},
{
- title: "States",
+ title: 'States',
items: states,
},
{
- title: "Utilities",
+ title: 'Utilities',
items: utilities,
},
{
- title: "Testing",
+ title: 'Testing',
items: testing,
},
],
From 7bed79e52be4fe2540e4ca3c5660296345bf608a Mon Sep 17 00:00:00 2001
From: Braden Wong <13159333+braden-w@users.noreply.github.com>
Date: Wed, 24 Dec 2025 20:28:41 -0800
Subject: [PATCH 2/2] docs: add cross-links to CSR/SPA guide from
getting-started and utilities
---
docs/src/content/getting-started.md | 6 ++++++
.../src/content/utilities/create-initial-mode-expression.md | 6 ++++++
2 files changed, 12 insertions(+)
diff --git a/docs/src/content/getting-started.md b/docs/src/content/getting-started.md
index 1c23232..528aac7 100644
--- a/docs/src/content/getting-started.md
+++ b/docs/src/content/getting-started.md
@@ -51,3 +51,9 @@ Here's an example of how to use the `toggleMode` function to toggle the mode:
For additional information and configuration, please refer to the [API reference](/docs/api-reference/mode-watcher).
+
+## CSR/SPA/SSG Users
+
+If you're using SvelteKit with `ssr: false`, `adapter-static` with a fallback page, or any other client-side rendered setup, you may experience a flash of unstyled content (FOUC) before mode-watcher hydrates.
+
+See the [CSR/SPA FOUC Prevention](/docs/guides/csr-spa-fouc) guide for a simple inline script solution.
diff --git a/docs/src/content/utilities/create-initial-mode-expression.md b/docs/src/content/utilities/create-initial-mode-expression.md
index 5f0500c..ae5be2b 100644
--- a/docs/src/content/utilities/create-initial-mode-expression.md
+++ b/docs/src/content/utilities/create-initial-mode-expression.md
@@ -21,6 +21,12 @@ Use `createInitialModeExpression` when:
This approach is ideal for security-sensitive environments or platforms with strict CSP headers, where inline scripts must include a trusted nonce.
+
+
+**Using CSR/SPA/SSG?** This utility requires server hooks, which don't run for prerendered or client-side rendered pages. See the [CSR/SPA FOUC Prevention](/docs/guides/csr-spa-fouc) guide instead.
+
+
+
## Usage
To use `createInitialModeExpression`, you need two things: