From 11b6bdd8a6ee654a7a1bb7415991fcac8eccbfa6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:23:21 +0200 Subject: [PATCH] fix: sync PWA title bar colour with the active theme #671 --- app/(main)/layout.tsx | 19 ++++++ components/theme-color-sync.tsx | 27 ++++++++ lib/__tests__/theme-color-meta.test.ts | 95 ++++++++++++++++++++++++++ lib/theme-color-meta.ts | 88 ++++++++++++++++++++++++ lib/theme-loader.ts | 5 ++ stores/theme-store.ts | 4 ++ 6 files changed, 238 insertions(+) create mode 100644 components/theme-color-sync.tsx create mode 100644 lib/__tests__/theme-color-meta.test.ts create mode 100644 lib/theme-color-meta.ts diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 828ae4ea4..8dcf70c05 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import { headers } from "next/headers"; import { getLocale, getTranslations } from "next-intl/server"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { FaviconBadge } from "@/components/favicon-badge"; +import { ThemeColorSync } from "@/components/theme-color-sync"; import { configManager } from "@/lib/admin/config-manager"; import { matchDomainBranding, @@ -50,6 +51,21 @@ async function brandedValue(key: BrandingOverrideKey, fallback: string): Promise return configManager.get(key, fallback); } +/** + * Whether an admin explicitly set a PWA theme colour (per-domain override, or + * an admin/env value rather than the built-in default). When they have, it wins + * outright; when they haven't, tracks the active theme. + */ +async function hasExplicitThemeColor(): Promise { + const host = pickRequestHost(await headers()); + const override = matchDomainBranding( + host, + parseDomainBranding(configManager.get("domainBranding", [])), + ).pwaThemeColor; + if (typeof override === "string" && override.length > 0) return true; + return configManager.getAllWithSources().pwaThemeColor?.source !== "default"; +} + export async function generateViewport(): Promise { await configManager.ensureLoaded(); // Chromium uses in preference to the manifest's @@ -103,9 +119,11 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { + await configManager.ensureLoaded(); const locale = await resolveRequestLocale(); const nonce = (await headers()).get("x-nonce") ?? ""; const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; + const themeColorConfigured = await hasExplicitThemeColor(); return ( @@ -145,6 +163,7 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > + {!themeColorConfigured && } {children} diff --git a/components/theme-color-sync.tsx b/components/theme-color-sync.tsx new file mode 100644 index 000000000..e7e5e4a51 --- /dev/null +++ b/components/theme-color-sync.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; +import { enableThemeColorSync, syncThemeColorMeta } from "@/lib/theme-color-meta"; + +/** + * Keeps the PWA title bar colour matched to the active theme (#671). + * + * Mounted by the root layout only when no explicit `pwaThemeColor` branding is + * configured - an admin-set colour is an intentional override and must not be + * second-guessed by the client. See lib/theme-color-meta.ts for the mechanism. + */ +export function ThemeColorSync() { + useEffect(() => { + enableThemeColorSync(); + }, []); + + // Client-side navigation re-applies the route's server-rendered metadata, + // which resets the meta tag to the static viewport value - re-sync after it. + const pathname = usePathname(); + useEffect(() => { + syncThemeColorMeta(); + }, [pathname]); + + return null; +} diff --git a/lib/__tests__/theme-color-meta.test.ts b/lib/__tests__/theme-color-meta.test.ts new file mode 100644 index 000000000..4d138a2c8 --- /dev/null +++ b/lib/__tests__/theme-color-meta.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + enableThemeColorSync, + syncThemeColorMeta, + resetThemeColorSyncForTests, +} from '@/lib/theme-color-meta'; + +const meta = () => document.head.querySelector('meta[name="theme-color"]'); + +/** The module defers to rAF; run callbacks inline so assertions stay synchronous. */ +beforeEach(() => { + resetThemeColorSyncForTests(); + document.head.innerHTML = ''; + document.body.removeAttribute('style'); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return 0; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function withServerMeta(color: string) { + const el = document.createElement('meta'); + el.name = 'theme-color'; + el.content = color; + document.head.appendChild(el); +} + +describe('theme color meta sync (#671)', () => { + it('does nothing until enabled, so an admin-set colour is left alone', () => { + withServerMeta('#123456'); + document.body.style.backgroundColor = '#282a36'; + + syncThemeColorMeta(); + + expect(meta()?.content).toBe('#123456'); + }); + + it('adopts the painted background once enabled', () => { + withServerMeta('#ffffff'); + document.body.style.backgroundColor = 'rgb(40, 42, 54)'; + + enableThemeColorSync(); + + expect(meta()?.content).toBe('rgb(40, 42, 54)'); + }); + + it('follows subsequent theme changes', () => { + withServerMeta('#ffffff'); + document.body.style.backgroundColor = 'rgb(255, 255, 255)'; + enableThemeColorSync(); + expect(meta()?.content).toBe('rgb(255, 255, 255)'); + + document.body.style.backgroundColor = 'rgb(10, 10, 10)'; + syncThemeColorMeta(); + expect(meta()?.content).toBe('rgb(10, 10, 10)'); + }); + + it('creates the meta tag when the document has none', () => { + document.body.style.backgroundColor = 'rgb(1, 2, 3)'; + + enableThemeColorSync(); + + expect(meta()?.content).toBe('rgb(1, 2, 3)'); + }); + + it('keeps the existing value when the background is transparent', () => { + withServerMeta('#abcdef'); + document.body.style.backgroundColor = 'rgba(0, 0, 0, 0)'; + + enableThemeColorSync(); + + expect(meta()?.content).toBe('#abcdef'); + }); + + it('coalesces a burst of changes into a single write', () => { + withServerMeta('#ffffff'); + document.body.style.backgroundColor = 'rgb(9, 9, 9)'; + enableThemeColorSync(); + + const raf = vi.fn(); + vi.stubGlobal('requestAnimationFrame', raf); + + // A theme switch flips the light/dark class and re-injects CSS; only the + // first call should schedule a frame. + syncThemeColorMeta(); + syncThemeColorMeta(); + syncThemeColorMeta(); + + expect(raf).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/theme-color-meta.ts b/lib/theme-color-meta.ts new file mode 100644 index 000000000..bcfd88881 --- /dev/null +++ b/lib/theme-color-meta.ts @@ -0,0 +1,88 @@ +/** + * Keeps in step with the active theme. + * + * Chromium colours an installed desktop PWA's title bar from this meta tag in + * preference to the manifest's `theme_color` (#671). The tag is server-rendered + * from the admin `pwaThemeColor` branding key, but that is a single static + * value: it can't follow the user's light/dark choice or an installed theme + * extension, both of which live entirely client-side. + * + * So when no explicit `pwaThemeColor` is configured, the root layout mounts + * which calls `enableThemeColorSync()`, and every point that + * can change the page background - the light/dark class flip and theme/skin CSS + * injection - calls `syncThemeColorMeta()`. When an admin HAS configured a + * colour, the component is never mounted, `enabled` stays false, and all of + * those calls are no-ops so the configured value stands. + */ + +const META_SELECTOR = 'meta[name="theme-color"]'; +/** rgba() with zero alpha, i.e. an unpainted background we can't derive a colour from. */ +const TRANSPARENT = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)$/; + +let enabled = false; +let scheduled = false; + +/** + * Read the colour the page actually paints. `body` carries + * `background-color: var(--color-background)`, and getComputedStyle resolves it + * to an `rgb()`/`rgba()` string - so this works no matter how a theme authored + * the token (hex, oklch, nested var()) and needs no knowledge of theme + * internals. + */ +function readBackgroundColor(): string | null { + const el = document.body ?? document.documentElement; + if (!el) return null; + + const color = getComputedStyle(el).backgroundColor; + if (!color || color === 'transparent' || TRANSPARENT.test(color)) return null; + return color; +} + +function writeMeta(): void { + const color = readBackgroundColor(); + if (!color) return; + + let meta = document.head?.querySelector(META_SELECTOR); + if (!meta) { + if (!document.head) return; + meta = document.createElement('meta'); + meta.name = 'theme-color'; + document.head.appendChild(meta); + } + + if (meta.content !== color) meta.content = color; +} + +/** + * Update the meta tag to match the current background, coalescing bursts (a + * theme switch flips the class AND re-injects CSS) into a single read on the + * next frame, after the new styles have applied. + */ +export function syncThemeColorMeta(): void { + if (!enabled || typeof document === 'undefined') return; + if (scheduled) return; + + scheduled = true; + const run = () => { + scheduled = false; + writeMeta(); + }; + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(run); + } else { + setTimeout(run, 0); + } +} + +/** Turn syncing on and take a first reading. Called by . */ +export function enableThemeColorSync(): void { + enabled = true; + syncThemeColorMeta(); +} + +/** Test seam - restores the module to its pre-enable state. */ +export function resetThemeColorSyncForTests(): void { + enabled = false; + scheduled = false; +} diff --git a/lib/theme-loader.ts b/lib/theme-loader.ts index 0e49d6cae..5ae8d2bbe 100644 --- a/lib/theme-loader.ts +++ b/lib/theme-loader.ts @@ -1,6 +1,7 @@ // Theme CSS injection and sanitization import { DISALLOWED_CSS_PATTERNS } from './plugin-types'; +import { syncThemeColorMeta } from './theme-color-meta'; const THEME_STYLE_ID = 'active-theme'; const THEME_SKIN_STYLE_ID = 'active-theme-skin'; @@ -76,6 +77,7 @@ export function injectThemeCSS(css: string): void { } styleEl.textContent = css; + syncThemeColorMeta(); } /** @@ -88,6 +90,7 @@ export function removeThemeCSS(): void { if (styleEl) { styleEl.remove(); } + syncThemeColorMeta(); } /** @@ -113,6 +116,7 @@ export function injectThemeSkinCSS(css: string, themeId: string): void { if (document.body) { document.body.setAttribute(THEME_SKIN_BODY_ATTR, themeId); } + syncThemeColorMeta(); } export function removeThemeSkinCSS(): void { @@ -121,6 +125,7 @@ export function removeThemeSkinCSS(): void { const styleEl = document.getElementById(THEME_SKIN_STYLE_ID); if (styleEl) styleEl.remove(); if (document.body) document.body.removeAttribute(THEME_SKIN_BODY_ATTR); + syncThemeColorMeta(); } /** diff --git a/stores/theme-store.ts b/stores/theme-store.ts index 1313ca606..f7f23ad30 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -14,6 +14,7 @@ import { BUILTIN_THEMES } from '@/lib/builtin-themes'; import { usePolicyStore } from '@/stores/policy-store'; import { apiFetch } from '@/lib/browser-navigation'; import { themeHooks } from '@/lib/plugin-hooks'; +import { syncThemeColorMeta } from '@/lib/theme-color-meta'; type Theme = 'light' | 'dark' | 'system'; @@ -69,6 +70,9 @@ const applyTheme = (theme: 'light' | 'dark') => { // Also update color-scheme for native elements (scrollbars, form controls) root.style.colorScheme = theme; + // Keep the PWA title bar in step with the mode switch (#671). + syncThemeColorMeta(); + localStorage.setItem('theme-applied', theme); };