diff --git a/web/src/lib/theme/__test__/Probe.svelte b/web/src/lib/theme/__test__/Probe.svelte
new file mode 100644
index 00000000..ece87530
--- /dev/null
+++ b/web/src/lib/theme/__test__/Probe.svelte
@@ -0,0 +1,4 @@
+
diff --git a/web/src/lib/theme/applyMetaThemeColor.svelte.test.ts b/web/src/lib/theme/applyMetaThemeColor.svelte.test.ts
new file mode 100644
index 00000000..4ca8038f
--- /dev/null
+++ b/web/src/lib/theme/applyMetaThemeColor.svelte.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { render } from '@testing-library/svelte';
+import { setTheme } from '$lib/stores/theme.svelte';
+import Probe from './__test__/Probe.svelte';
+
+describe('applyMetaThemeColor', () => {
+ beforeEach(() => {
+ document.head.innerHTML = '';
+ });
+
+ afterEach(() => {
+ document.head.innerHTML = '';
+ // Reset to a known baseline; setTheme persists to localStorage,
+ // but each test starts with head cleared anyway.
+ setTheme('dark');
+ });
+
+ it('creates a meta[name="theme-color"]:not([media]) on mount with the dark hex', () => {
+ setTheme('dark');
+ render(Probe);
+ const meta = document.querySelector(
+ 'meta[name="theme-color"]:not([media])'
+ );
+ expect(meta).not.toBeNull();
+ expect(meta!.content).toBe('#14171A');
+ });
+
+ it('updates the meta content when the resolved theme flips to light', async () => {
+ setTheme('dark');
+ render(Probe);
+ setTheme('light');
+ await Promise.resolve();
+ const meta = document.querySelector(
+ 'meta[name="theme-color"]:not([media])'
+ );
+ expect(meta).not.toBeNull();
+ expect(meta!.content).toBe('#F8F5EE');
+ });
+
+ it('reuses an existing meta tag instead of stacking duplicates', async () => {
+ setTheme('dark');
+ render(Probe);
+ setTheme('light');
+ await Promise.resolve();
+ setTheme('dark');
+ await Promise.resolve();
+ const metas = document.querySelectorAll('meta[name="theme-color"]:not([media])');
+ expect(metas.length).toBe(1);
+ });
+});
diff --git a/web/src/lib/theme/applyMetaThemeColor.svelte.ts b/web/src/lib/theme/applyMetaThemeColor.svelte.ts
new file mode 100644
index 00000000..7643a371
--- /dev/null
+++ b/web/src/lib/theme/applyMetaThemeColor.svelte.ts
@@ -0,0 +1,28 @@
+import { resolvedTheme } from '$lib/stores/theme.svelte';
+
+const DARK = '#14171A';
+const LIGHT = '#F8F5EE';
+
+// applyMetaThemeColor wires a Svelte 5 $effect that keeps a single
+// (without a media query) in sync with the
+// resolved theme. The Vite build plugin injects two media-query
+// variants for first paint / pre-hydration; the meta tag this helper
+// manages is added on hydration and tracks the user's explicit
+// Dark / Light / System choice. On a phone, the mobile address bar
+// retints the moment the user changes the theme in Settings.
+//
+// Must be called from a component setup (uses $effect).
+export function applyMetaThemeColor(): void {
+ $effect(() => {
+ const target = resolvedTheme.value === 'light' ? LIGHT : DARK;
+ let meta = document.querySelector(
+ 'meta[name="theme-color"]:not([media])'
+ );
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.name = 'theme-color';
+ document.head.appendChild(meta);
+ }
+ meta.content = target;
+ });
+}
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index c34c9f51..83be0358 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -15,6 +15,7 @@
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
import { useEventsDispatcher } from '$lib/player/events.svelte';
+ import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
let audioEl: HTMLAudioElement | undefined = $state();
@@ -66,6 +67,7 @@
useMediaSession();
useEventsDispatcher();
+ applyMetaThemeColor();