fix(dashboard): respect defaultMode light and track=false for color mode (#742)
* 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 <rotimi-best@users.noreply.github.com>
* 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 <rotimi-best@users.noreply.github.com>
* 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 <rotimi-best@users.noreply.github.com>
* 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 <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
parent
bce0f582ce
commit
7605395f44
@@ -67,6 +67,9 @@ html {
|
|||||||
--scrollbarBG: #fff;
|
--scrollbarBG: #fff;
|
||||||
--thumbBG: #ccc;
|
--thumbBG: #ccc;
|
||||||
scroll-behavior: smooth;
|
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 {
|
#calendar .grid {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||||
|
|
||||||
|
<!-- External (not inline) so it passes the app's nonce-based CSP; runs before
|
||||||
|
the app bundle to set the color mode and avoid a flash of the wrong theme. -->
|
||||||
|
<script src="/theme-init.js"></script>
|
||||||
|
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { ModeWatcher } from '@cio/ui/base/dark-mode';
|
||||||
import { ModeWatcher, setMode, systemPrefersMode, userPrefersMode } from '@cio/ui/base/dark-mode';
|
|
||||||
|
|
||||||
import { readStoredColorMode } from '$lib/utils/functions/color-mode';
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedPreference = readStoredColorMode();
|
|
||||||
systemPrefersMode.tracking(storedPreference === 'system');
|
|
||||||
|
|
||||||
if (storedPreference !== 'system' && userPrefersMode.current !== storedPreference) {
|
|
||||||
setMode(storedPreference);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModeWatcher defaultMode="light" track={false} />
|
<!-- theme-init.js (loaded from app.html) handles the pre-paint seed + FOUC. Skip
|
||||||
|
mode-watcher's own head-script injection: it is inline and blocked by our CSP. -->
|
||||||
|
<ModeWatcher defaultMode="light" disableHeadScriptInjection={true} />
|
||||||
|
|||||||
@@ -1,30 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { MonitorIcon, SunIcon, MoonIcon } from '@lucide/svelte';
|
import { MonitorIcon, SunIcon, MoonIcon } from '@lucide/svelte';
|
||||||
import { userPrefersMode, setMode } from '@cio/ui/base/dark-mode';
|
import { setMode, userPrefersMode } from '@cio/ui/base/dark-mode';
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'dark' | 'system';
|
import { markColorModeExplicit, type ColorModePreference } from '$lib/utils/functions/color-mode';
|
||||||
|
|
||||||
const themes: { mode: ThemeMode; icon: typeof MonitorIcon }[] = [
|
const themes: { mode: ColorModePreference; icon: typeof MonitorIcon }[] = [
|
||||||
{ mode: 'light', icon: SunIcon },
|
{ mode: 'light', icon: SunIcon },
|
||||||
{ mode: 'dark', icon: MoonIcon },
|
{ mode: 'dark', icon: MoonIcon },
|
||||||
{ mode: 'system', icon: MonitorIcon }
|
{ mode: 'system', icon: MonitorIcon }
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeMode = $derived(userPrefersMode.current ?? 'system');
|
const activeMode = $derived(userPrefersMode.current ?? 'light');
|
||||||
|
|
||||||
const handleThemeChange = (newMode: ThemeMode) => {
|
const handleThemeChange = (newMode: ColorModePreference) => {
|
||||||
if (newMode === 'light') {
|
markColorModeExplicit();
|
||||||
setMode('light');
|
setMode(newMode);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newMode === 'dark') {
|
|
||||||
setMode('dark');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMode('system');
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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<string, string> = {}): Storage {
|
function createMemoryStorage(initial: Record<string, string> = {}): Storage {
|
||||||
const store = new Map(Object.entries(initial));
|
const store = new Map(Object.entries(initial));
|
||||||
@@ -27,14 +33,43 @@ function createMemoryStorage(initial: Record<string, string> = {}): Storage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('readStoredColorMode', () => {
|
describe('resolveStoredColorMode', () => {
|
||||||
it('defaults to light when nothing is stored', () => {
|
it('defaults to light when nothing is stored', () => {
|
||||||
expect(readStoredColorMode(createMemoryStorage())).toBe('light');
|
expect(resolveStoredColorMode(createMemoryStorage())).toBe('light');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the stored sidebar preference', () => {
|
it('returns stored light or dark preferences', () => {
|
||||||
const storage = createMemoryStorage({ [MODE_STORAGE_KEY]: 'dark' });
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
export type ColorModePreference = 'light' | 'dark' | 'system';
|
export type ColorModePreference = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
export const MODE_STORAGE_KEY = 'mode-watcher-mode';
|
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 {
|
export function isColorModePreference(value: string | null | undefined): value is ColorModePreference {
|
||||||
return value === 'light' || value === 'dark' || value === 'system';
|
return value === 'light' || value === 'dark' || value === 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the sidebar theme preference from mode-watcher storage. */
|
export function hasExplicitColorModePreference(storage: Pick<Storage, 'getItem'> = localStorage): boolean {
|
||||||
export function readStoredColorMode(storage: Pick<Storage, 'getItem'> = localStorage): ColorModePreference {
|
return storage.getItem(MODE_EXPLICIT_STORAGE_KEY) === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markColorModeExplicit(storage: Pick<Storage, 'setItem'> = 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<Storage, 'getItem'> = localStorage): ColorModePreference {
|
||||||
const stored = storage.getItem(MODE_STORAGE_KEY);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user