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: