From af6f006242f78db8489b622ddf2105aafd5ce5bb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 17:44:01 -0400 Subject: [PATCH] feat(web/m7-363): theme-color meta follows resolved theme --- web/src/lib/theme/__test__/Probe.svelte | 4 ++ .../theme/applyMetaThemeColor.svelte.test.ts | 50 +++++++++++++++++++ .../lib/theme/applyMetaThemeColor.svelte.ts | 28 +++++++++++ web/src/routes/+layout.svelte | 2 + 4 files changed, 84 insertions(+) create mode 100644 web/src/lib/theme/__test__/Probe.svelte create mode 100644 web/src/lib/theme/applyMetaThemeColor.svelte.test.ts create mode 100644 web/src/lib/theme/applyMetaThemeColor.svelte.ts 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();