web-player: single source of truth for theme, no light-mode flash

Remove the duplication of the ~18 per-hue dynamic tokens between
theme.css and buildThemeTokens(): theme.css now only defines fixed
neutrals, surfaces, layout and z-index; hue/mode-dependent values
come solely from tokens.ts, injected via SSR as an inline <style> in
+layout.svelte (tokensToCss helper) and reapplied on every hue
change. app.html sets data-theme before first paint by reading
localStorage, removing the dark flash when light mode was saved.

The two inline-style backgrounds become @utility
app-backdrop/app-vignette; the main's bottom padding becomes
@utility app-main, driven by --mf-dock-h via [data-has-track].

Note: <style>{expr}</style> doesn't interpolate in Svelte (it
compiles as component-scoped CSS) — the SSR block is injected with
{@html `<style>${css}</style>`} since the content is fully generated
(no user input).

Verified in the browser: --mf-accent/--mf-bg resolve correctly during
SSR, data-theme='light' applies before the first render with no
flash.
This commit is contained in:
2026-09-20 22:26:37 +02:00
parent ea9bf07126
commit 7b1b81c45a
6 changed files with 60 additions and 83 deletions
+7
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
try {
if (localStorage.getItem('musify.theme') === 'light') {
document.documentElement.dataset.theme = 'light';
}
} catch {}
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+7 -66
View File
@@ -1,55 +1,17 @@
:root {
/* Dynamic tokens: overwritten at runtime from the playing track's hue.
These are the fallback values for the default accent (hue 145) so the
page never flashes unstyled before hydration. See theme/tokens.ts. */
--mf-bg: oklch(0.068 0.008 145);
--mf-elevated: oklch(0.11 0.012 145);
--mf-panel-bg: oklch(0.09 0.01 145 / 0.28);
--mf-bar-bg: oklch(0.105 0.012 145 / 0.9);
--mf-hairline: oklch(0.62 0.03 145 / 0.08);
--mf-ambient:
radial-gradient(52% 38% at 14% 0%, oklch(0.2611 0.0913 145 / 0.26), transparent 54%),
radial-gradient(46% 34% at 78% 0%, oklch(0.2347 0.085 145 / 0.2), transparent 56%);
--mf-hero-bg: linear-gradient(
105deg,
oklch(0.2295 0.0838 145 / 0.34) 0%,
oklch(0.1558 0.0663 145 / 0.11) 37%,
oklch(0.1084 0.055 145 / 0.03) 65%,
rgba(6, 6, 9, 0) 92%
);
--mf-spotlight-bg: linear-gradient(
100deg,
oklch(0.2189 0.0813 145 / 0.28) 0%,
oklch(0.1453 0.0638 145 / 0.1) 40%,
oklch(0.1084 0.055 145 / 0.03) 69%,
rgba(6, 6, 9, 0) 96%
);
--mf-modal-glow: linear-gradient(
135deg,
oklch(0.2295 0.0838 145 / 0.34) 0%,
oklch(0.1558 0.0663 145 / 0.11) 28%,
oklch(0.1084 0.055 145 / 0.03) 50%,
rgba(6, 6, 9, 0) 70%
);
--mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%);
--mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145));
/* Hue-based tokens (--mf-accent*, --mf-bg/elevated/panel-bg/bar-bg/hairline,
--mf-ambient, --mf-hero-bg/spotlight-bg/modal-glow, --mf-logo-grad,
--mf-cover-grad) are NOT defined here: they come only from
buildThemeTokens() in theme/tokens.ts, inlined as SSR <style> by
+layout.svelte and reapplied on every hue change. A duplicate default
here could never be overridden by a light-mode selector (see A.2). */
--mf-liked-grad: linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10));
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
--mf-tile-l: 0.34;
--mf-tile-c: 0.1;
--mf-tile-shade-l: 0.14;
--mf-tile-shade-c: 0.035;
--mf-accent: oklch(0.74 0.145 145);
--mf-accent-title: oklch(0.87 0.07 145);
--mf-accent-muted: oklch(0.79 0.035 145);
--mf-accent-soft-bg: oklch(0.74 0.145 145 / 0.08);
--mf-accent-line: oklch(0.74 0.145 145 / 0.2);
--mf-accent-hair: oklch(0.74 0.145 145 / 0.09);
--mf-accent-btn-bg: oklch(0.74 0.145 145 / 0.15);
--mf-accent-btn-bg-hover: oklch(0.74 0.145 145 / 0.24);
--mf-accent-glow: color-mix(in oklch, var(--mf-accent) 45%, transparent);
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
/* Fixed neutrals: deliberately hue-less, so only the accent carries color. */
--mf-text: #d9d8dc;
--mf-text-2: #8a8a8e;
@@ -124,12 +86,6 @@
palette. Components that reference these tokens re-theme correctly;
components hardcoding literal dark-mode hex colors do not. */
:root[data-theme='light'] {
--mf-bg: oklch(0.97 0.006 90);
--mf-elevated: oklch(0.99 0.003 90);
--mf-panel-bg: oklch(0.97 0.006 90 / 0.7);
--mf-bar-bg: oklch(0.97 0.006 90 / 0.9);
--mf-hairline: rgba(0, 0, 0, 0.08);
--mf-text: #17171a;
--mf-text-2: #4a4a4e;
--mf-text-3: #6b6b6f;
@@ -159,19 +115,4 @@
--mf-tile-shade-l: 0.42;
--mf-tile-shade-c: 0.05;
--mf-liked-grad: linear-gradient(150deg, oklch(0.62 0.12 330), oklch(0.46 0.08 10));
--mf-ambient: none;
--mf-hero-bg: linear-gradient(oklch(0.789 0.0926 145 / 0.16), oklch(0.789 0.0926 145 / 0.16));
--mf-spotlight-bg: linear-gradient(
oklch(0.789 0.0926 145 / 0.16),
oklch(0.789 0.0926 145 / 0.16)
);
--mf-modal-glow: linear-gradient(
135deg,
oklch(0.7531 0.1172 145 / 0.34) 0%,
oklch(0.789 0.0926 145 / 0.2) 30%,
oklch(0.8303 0.0641 145 / 0.1) 55%,
oklch(0.8717 0.0357 145 / 0.04) 80%,
oklch(0.88 0.03 145 / 0.02) 100%
);
}
+6
View File
@@ -97,3 +97,9 @@ export function applyThemeTokens(
) {
for (const [key, value] of Object.entries(tokens)) target.style.setProperty(key, value);
}
export function tokensToCss(tokens: ThemeTokens): string {
return Object.entries(tokens)
.map(([key, value]) => `${key}:${value}`)
.join(';');
}
+10 -16
View File
@@ -16,7 +16,7 @@
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import { ACCENT_HUE } from '$lib/theme/color';
import { buildThemeTokens, applyThemeTokens } from '$lib/theme/tokens';
import { buildThemeTokens, applyThemeTokens, tokensToCss } from '$lib/theme/tokens';
import { themeMode } from '$lib/theme/mode.svelte';
import { trackNavigation } from '$lib/navigation.svelte';
import { page } from '$app/state';
@@ -32,6 +32,8 @@
const isAuthPage = $derived(page.url.pathname === '/login');
const hasTrack = $derived(player.currentId !== null);
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
let hadTrack = false;
$effect(() => {
if (hasTrack && !hadTrack) queuePanel.show();
@@ -83,7 +85,10 @@
<svelte:window onkeydown={onWindowKeydown} />
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<svelte:head>
<link rel="icon" href={favicon} />
{@html `<style>${initialThemeCss}</style>`}
</svelte:head>
{#if isAuthPage || (!data.user && !data.allowAnonymousListening)}
<div class="min-h-screen bg-bg text-fg antialiased">
@@ -91,14 +96,8 @@
</div>
{:else}
<div class="relative flex min-h-screen text-fg antialiased">
<div
class="pointer-events-none fixed inset-0 -z-10 bg-bg"
style="background-image:var(--mf-ambient)"
></div>
<div
class="pointer-events-none fixed inset-0 -z-10"
style="background:radial-gradient(130% 85% at 50% 118%, color-mix(in oklch, var(--mf-scrim) 80%, transparent), transparent 62%)"
></div>
<div class="app-backdrop"></div>
<div class="app-vignette"></div>
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} />
@@ -106,12 +105,7 @@
<div class="flex min-w-0 flex-1 flex-col">
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
<TopBar user={data.user} accountUrl={data.accountUrl} />
<main
class="flex-1 transition-[padding]"
style="padding-bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + {hasTrack
? 'var(--mf-player-h)'
: '0px'})"
>
<main class="flex-1 app-main" data-has-track={hasTrack ? '' : undefined}>
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
+29
View File
@@ -168,6 +168,35 @@
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
}
@utility app-backdrop {
pointer-events: none;
position: fixed;
inset: 0;
z-index: -10;
background-color: var(--mf-bg);
background-image: var(--mf-ambient);
}
@utility app-vignette {
pointer-events: none;
position: fixed;
inset: 0;
z-index: -10;
background: radial-gradient(
130% 85% at 50% 118%,
color-mix(in oklch, var(--mf-scrim) 80%, transparent),
transparent 62%
);
}
@utility app-main {
--mf-dock-h: 0px;
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h));
transition: padding 0.3s;
&[data-has-track] {
--mf-dock-h: var(--mf-player-h);
}
}
@layer base {
svg.lucide {
stroke-width: 1.8;