fix: honor configured PWA theme color in desktop title bar #671

This commit is contained in:
Linus Rath
2026-08-06 17:40:41 +02:00
parent 23c4ab87da
commit d55aba3890
2 changed files with 101 additions and 15 deletions
+27 -15
View File
@@ -10,6 +10,7 @@ import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from "@/lib/admin/domain-branding";
import { withBasePath } from "@/lib/browser-navigation";
import { locales } from "@/i18n/routing";
@@ -37,11 +38,31 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
// Resolve a branding value for the requesting host: per-domain override first,
// then the global admin/env/default value (same precedence as app/manifest.ts).
async function brandedValue(key: BrandingOverrideKey, fallback: string): Promise<string> {
const host = pickRequestHost(await headers());
const override = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
)[key];
if (typeof override === "string" && override.length > 0) return override;
return configManager.get<string>(key, fallback);
}
export async function generateViewport(): Promise<Viewport> {
await configManager.ensureLoaded();
// Chromium uses <meta name="theme-color"> in preference to the manifest's
// theme_color when coloring an installed desktop PWA's title bar, so a
// hardcoded value here silently overrode the admin setting (#671). Emit the
// configured colour instead, honoring per-domain branding like the manifest.
return {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: (await brandedValue("pwaThemeColor", "")) || "#ffffff",
};
}
export async function generateMetadata(): Promise<Metadata> {
await configManager.ensureLoaded();
@@ -49,15 +70,7 @@ export async function generateMetadata(): Promise<Metadata> {
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
// request host and prefer its override; fall back to the global
// admin/env/default value when the host has no favicon override (#585).
const host = pickRequestHost(await headers());
const domainOverride = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
).faviconUrl;
const faviconUrl =
domainOverride && domainOverride.length > 0
? domainOverride
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
const faviconUrl = await brandedValue("faviconUrl", "/branding/Bulwark_Favicon.svg");
// Localize the <head> description to match the UI language; a hardcoded
// English description is another signal that makes Chrome offer to
// "translate this page". Resolve the locale from the request path, since this
@@ -97,7 +110,6 @@ export default async function RootLayout({
return (
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// The root layout pulls in globals.css, next/font and client components purely
// for rendering; stub them so the test can import generateViewport in isolation.
// globals.css would otherwise drag Tailwind's PostCSS pipeline into vitest.
vi.mock('@/app/globals.css', () => ({}));
vi.mock('next/font/google', () => ({
Geist: () => ({ variable: '--font-geist-sans' }),
Geist_Mono: () => ({ variable: '--font-geist-mono' }),
}));
vi.mock('@/components/service-worker-registration', () => ({ ServiceWorkerRegistration: () => null }));
vi.mock('@/components/favicon-badge', () => ({ FaviconBadge: () => null }));
vi.mock('next-intl/server', () => ({
getLocale: async () => 'en',
getTranslations: async () => (k: string) => k,
}));
vi.mock('next/headers', () => ({ headers: vi.fn() }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: vi.fn(async () => {}), get: vi.fn() },
}));
import { generateViewport } from '@/app/(main)/layout';
import { headers } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
const mockHeaders = headers as unknown as Mock;
const mockGet = configManager.get as unknown as Mock;
/** Config values keyed by admin key; anything unset falls back to the caller's default. */
function withConfig(values: Record<string, unknown>) {
mockGet.mockImplementation((key: string, fallback: unknown) =>
key in values ? values[key] : fallback,
);
}
function onHost(host: string) {
mockHeaders.mockResolvedValue(new Headers({ host }));
}
beforeEach(() => {
vi.clearAllMocks();
onHost('mail.example.com');
withConfig({});
});
describe('root layout generateViewport - theme color (#671)', () => {
it('emits the admin-configured theme color, not a hardcoded white', async () => {
withConfig({ pwaThemeColor: '#1a1b26' });
expect((await generateViewport()).themeColor).toBe('#1a1b26');
});
it('prefers a per-domain branding override for the requesting host', async () => {
withConfig({
pwaThemeColor: '#1a1b26',
domainBranding: [{ host: 'other.example.com', pwaThemeColor: '#7c3aed' }],
});
onHost('other.example.com');
expect((await generateViewport()).themeColor).toBe('#7c3aed');
onHost('mail.example.com');
expect((await generateViewport()).themeColor).toBe('#1a1b26');
});
it('falls back to white when nothing is configured', async () => {
withConfig({ pwaThemeColor: '' });
expect((await generateViewport()).themeColor).toBe('#ffffff');
});
it('keeps the viewport sizing fields intact', async () => {
const viewport = await generateViewport();
expect(viewport.width).toBe('device-width');
expect(viewport.initialScale).toBe(1);
expect(viewport.viewportFit).toBe('cover');
});
});