fix: sync PWA title bar colour with the active theme #671

This commit is contained in:
Linus Rath
2026-08-06 18:23:21 +02:00
parent d55aba3890
commit 11b6bdd8a6
6 changed files with 238 additions and 0 deletions
+19
View File
@@ -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<string>(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, <ThemeColorSync /> tracks the active theme.
*/
async function hasExplicitThemeColor(): Promise<boolean> {
const host = pickRequestHost(await headers());
const override = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
).pwaThemeColor;
if (typeof override === "string" && override.length > 0) return true;
return configManager.getAllWithSources().pwaThemeColor?.source !== "default";
}
export async function generateViewport(): Promise<Viewport> {
await configManager.ensureLoaded();
// Chromium uses <meta name="theme-color"> 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 (
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
@@ -145,6 +163,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
{!themeColorConfigured && <ThemeColorSync />}
<FaviconBadge />
{children}
</body>
+27
View File
@@ -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;
}
+95
View File
@@ -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<HTMLMetaElement>('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);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Keeps <meta name="theme-color"> 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
* <ThemeColorSync /> 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<HTMLMetaElement>(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 <ThemeColorSync />. */
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;
}
+5
View File
@@ -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();
}
/**
+4
View File
@@ -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);
};