From 7605395f448a8f2617f56d2e8289ea2e862531ed Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 12 Jul 2026 22:07:36 +0100 Subject: [PATCH] fix(dashboard): respect defaultMode light and track=false for color mode (#742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): respect defaultMode light and track=false for color mode ModeWatcher defaults userPrefersMode to system and enables OS tracking before components mount, so prefers-color-scheme won on first paint even with defaultMode="light" and track={false}. - Add a blocking init script in app.html that defaults empty storage to light before any JS bundle runs - Disable ModeWatcher head injection to avoid duplicate init scripts - Keep track={false} on ModeWatcher and sync system tracking only when userPrefersMode is explicitly system - Update the sidebar theme toggle to enable OS tracking only when the user selects System Co-authored-by: Rotimi Best * fix(dashboard): ignore stale system color mode until user opts in mode-watcher leaves mode-watcher-mode as system for anyone who never picked a theme. ModeWatcher onMount then calls setMode('system'), which overrides defaultMode="light" and track={false}. Track an explicit sidebar choice separately and normalize stale system values to light during the app.html init script and on mount. Co-authored-by: Rotimi Best * fix(dashboard): bootstrap color mode before mode-watcher module init Even with cleared localStorage, mode-watcher's module-level derivedMode could apply the OS color-scheme before ModeWatcher mounted. Stop relying on ModeWatcher for document classes and bootstrap light mode from hooks.client.ts immediately after the app.html script runs. Co-authored-by: Rotimi Best * refactor(dashboard): collapse color-mode workaround onto mode-watcher The scaffolding (color-mode.svelte.ts state module, color-mode-document.ts, init-color-mode.client.ts) existed only to work around disableHeadScriptInjection, which had turned off mode-watcher's own setInitialMode script — the very script that seeds localStorage before the module's eager "system" write and prevents FOUC. Let ModeWatcher inject that script again (defaultMode="light") and drive theme application, tracking, and Sonner theming. Keep only the app.html bootstrap for the one thing mode-watcher can't do: treat a returning user's stale "system" as light unless they explicitly chose it. The theme toggle now calls mode-watcher's setMode directly and marks the choice explicit. * fix(dashboard): run color-mode bootstrap as external script to satisfy CSP The inline app.html bootstrap and mode-watcher's own setInitialMode script were both blocked by the nonce-based CSP (script-src has no 'unsafe-inline'), so localStorage was never seeded before mode-watcher's eager "system" write — every fresh load followed the OS and came up dark. Move the seed + FOUC logic to a same-origin static file (static/theme-init.js), which 'self' allows without a nonce, and load it as a parser-blocking script in app.html so it still runs before the app bundle. Disable ModeWatcher's inline head-script injection since it is CSP-blocked and now redundant, and paint the root element with var(--background) so the pre-hydration shell never flashes white in dark mode. Verified with Playwright across fresh/explicit/stale-system cases in light and dark OS settings; no theme-related CSP violations remain. --------- Co-authored-by: Rotimi Best --- apps/dashboard/src/app.css | 3 ++ apps/dashboard/src/app.html | 4 ++ .../lib/features/app/app-mode-watcher.svelte | 22 ++------- .../ui/sidebar/footer/theme-toggle.svelte | 23 +++------ .../lib/utils/functions/color-mode.test.ts | 47 ++++++++++++++++--- .../src/lib/utils/functions/color-mode.ts | 30 ++++++++++-- apps/dashboard/static/theme-init.js | 41 ++++++++++++++++ 7 files changed, 127 insertions(+), 43 deletions(-) create mode 100644 apps/dashboard/static/theme-init.js diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index d01b8942..a76b25ed 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -67,6 +67,9 @@ html { --scrollbarBG: #fff; --thumbBG: #ccc; scroll-behavior: smooth; + /* Paint the root with the theme background so the pre-hydration shell + matches the mode set by app.html and never flashes white in dark mode. */ + background-color: var(--background); } #calendar .grid { diff --git a/apps/dashboard/src/app.html b/apps/dashboard/src/app.html index 58aee939..120e2833 100644 --- a/apps/dashboard/src/app.html +++ b/apps/dashboard/src/app.html @@ -7,6 +7,10 @@ + + + %sveltekit.head% diff --git a/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte b/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte index ad7d10a4..cded7b0e 100644 --- a/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte +++ b/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte @@ -1,21 +1,7 @@ - + + diff --git a/apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte b/apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte index 070b6448..11dc8a3f 100644 --- a/apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte +++ b/apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte @@ -1,30 +1,21 @@ diff --git a/apps/dashboard/src/lib/utils/functions/color-mode.test.ts b/apps/dashboard/src/lib/utils/functions/color-mode.test.ts index 343168b7..cbd8d6bc 100644 --- a/apps/dashboard/src/lib/utils/functions/color-mode.test.ts +++ b/apps/dashboard/src/lib/utils/functions/color-mode.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { MODE_STORAGE_KEY, readStoredColorMode } from './color-mode'; +import { + MODE_EXPLICIT_STORAGE_KEY, + MODE_STORAGE_KEY, + hasExplicitColorModePreference, + markColorModeExplicit, + resolveStoredColorMode +} from './color-mode'; function createMemoryStorage(initial: Record = {}): Storage { const store = new Map(Object.entries(initial)); @@ -27,14 +33,43 @@ function createMemoryStorage(initial: Record = {}): Storage { }; } -describe('readStoredColorMode', () => { +describe('resolveStoredColorMode', () => { it('defaults to light when nothing is stored', () => { - expect(readStoredColorMode(createMemoryStorage())).toBe('light'); + expect(resolveStoredColorMode(createMemoryStorage())).toBe('light'); }); - it('returns the stored sidebar preference', () => { - const storage = createMemoryStorage({ [MODE_STORAGE_KEY]: 'dark' }); + it('returns stored light or dark preferences', () => { + const lightStorage = createMemoryStorage({ [MODE_STORAGE_KEY]: 'light' }); + const darkStorage = createMemoryStorage({ [MODE_STORAGE_KEY]: 'dark' }); - expect(readStoredColorMode(storage)).toBe('dark'); + expect(resolveStoredColorMode(lightStorage)).toBe('light'); + expect(resolveStoredColorMode(darkStorage)).toBe('dark'); + }); + + it('treats stale system values as light until the user explicitly chooses system', () => { + const staleSystemStorage = createMemoryStorage({ [MODE_STORAGE_KEY]: 'system' }); + + expect(resolveStoredColorMode(staleSystemStorage)).toBe('light'); + }); + + it('honors system when the user explicitly chose it', () => { + const explicitSystemStorage = createMemoryStorage({ + [MODE_STORAGE_KEY]: 'system', + [MODE_EXPLICIT_STORAGE_KEY]: 'true' + }); + + expect(resolveStoredColorMode(explicitSystemStorage)).toBe('system'); + }); +}); + +describe('markColorModeExplicit', () => { + it('marks the preference as user-selected', () => { + const storage = createMemoryStorage(); + + expect(hasExplicitColorModePreference(storage)).toBe(false); + + markColorModeExplicit(storage); + + expect(hasExplicitColorModePreference(storage)).toBe(true); }); }); diff --git a/apps/dashboard/src/lib/utils/functions/color-mode.ts b/apps/dashboard/src/lib/utils/functions/color-mode.ts index e2b6b09c..7d0902f0 100644 --- a/apps/dashboard/src/lib/utils/functions/color-mode.ts +++ b/apps/dashboard/src/lib/utils/functions/color-mode.ts @@ -1,14 +1,38 @@ export type ColorModePreference = 'light' | 'dark' | 'system'; export const MODE_STORAGE_KEY = 'mode-watcher-mode'; +export const MODE_EXPLICIT_STORAGE_KEY = 'mode-watcher-explicit'; export function isColorModePreference(value: string | null | undefined): value is ColorModePreference { return value === 'light' || value === 'dark' || value === 'system'; } -/** Read the sidebar theme preference from mode-watcher storage. */ -export function readStoredColorMode(storage: Pick = localStorage): ColorModePreference { +export function hasExplicitColorModePreference(storage: Pick = localStorage): boolean { + return storage.getItem(MODE_EXPLICIT_STORAGE_KEY) === 'true'; +} + +export function markColorModeExplicit(storage: Pick = localStorage): void { + storage.setItem(MODE_EXPLICIT_STORAGE_KEY, 'true'); +} + +/** + * Resolve the stored preference to apply on load. + * + * mode-watcher used to default to "system" and persist it eagerly, so returning + * users still carry a stored "system" they never chose. Only honor "system" + * when the user explicitly picked it in the theme toggle; otherwise use light. + * This mirrors the inline bootstrap script in `app.html`. + */ +export function resolveStoredColorMode(storage: Pick = localStorage): ColorModePreference { const stored = storage.getItem(MODE_STORAGE_KEY); - return isColorModePreference(stored) ? stored : 'light'; + if (!isColorModePreference(stored)) { + return 'light'; + } + + if (stored === 'system' && !hasExplicitColorModePreference(storage)) { + return 'light'; + } + + return stored; } diff --git a/apps/dashboard/static/theme-init.js b/apps/dashboard/static/theme-init.js new file mode 100644 index 00000000..ce48f30b --- /dev/null +++ b/apps/dashboard/static/theme-init.js @@ -0,0 +1,41 @@ +// Runs before the app bundle to set the color mode and prevent a flash of the +// wrong theme. Lives as an external same-origin file (not inline in app.html) +// because the app's CSP has a nonce and no 'unsafe-inline', which blocks inline +// scripts — an external 'self' script is allowed. Seeding localStorage here also +// beats mode-watcher's eager "system" default write when the module loads. +// +// Keep the stale-"system" rule in sync with resolveStoredColorMode in +// src/lib/utils/functions/color-mode.ts. +(function () { + try { + var storageKey = 'mode-watcher-mode'; + var explicitKey = 'mode-watcher-explicit'; + var stored = localStorage.getItem(storageKey); + var isExplicit = localStorage.getItem(explicitKey) === 'true'; + var mode = 'light'; + + if (stored === 'light' || stored === 'dark') { + mode = stored; + } else if (stored === 'system' && isExplicit) { + mode = 'system'; + } + + var isLight = + mode === 'light' || + (mode === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches); + + var root = document.documentElement; + + if (isLight) { + root.classList.remove('dark'); + root.style.colorScheme = 'light'; + } else { + root.classList.add('dark'); + root.style.colorScheme = 'dark'; + } + + localStorage.setItem(storageKey, mode); + } catch (error) { + // Ignore storage / matchMedia errors in restricted environments. + } +})();