diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 34d9f94a0..828ae4ea4 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -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 { + const host = pickRequestHost(await headers()); + const override = matchDomainBranding( + host, + parseDomainBranding(configManager.get("domainBranding", [])), + )[key]; + if (typeof override === "string" && override.length > 0) return override; + return configManager.get(key, fallback); +} + +export async function generateViewport(): Promise { + await configManager.ensureLoaded(); + // Chromium uses 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 { await configManager.ensureLoaded(); @@ -49,15 +70,7 @@ export async function generateMetadata(): Promise { // /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("domainBranding", [])), - ).faviconUrl; - const faviconUrl = - domainOverride && domainOverride.length > 0 - ? domainOverride - : configManager.get("faviconUrl", "/branding/Bulwark_Favicon.svg"); + const faviconUrl = await brandedValue("faviconUrl", "/branding/Bulwark_Favicon.svg"); // Localize the 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 ( - ({})); +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) { + 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'); + }); +});