mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: sync PWA title bar colour with the active theme #671
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user