Files
webmail/app/(main)/layout.tsx
T
Linus Rath 8008393e59 feat: add an Accessibility page to Settings
Settings get a page of their own for accessibility, with presets on top
(screen reader, low vision, dyslexia, focus, keyboard and motor, motion
and seizure safe, deaf and hard of hearing) that change several options
at once and can be undone, then the options by topic, and a reset.

Text size used to be a fixed 14/16/18px on the root, which threw away
the size people set in their browser. It is now a percentage of the
browser's size, from 87.5% to 200%. Motion, contrast and transparency
follow the system by default and can be overridden either way; the
old Animations switch becomes Reduce motion. The visual options are
applied before the first paint, so the page no longer flashes at the
wrong size.

- `lib/accessibility-settings.ts`: the new settings, their allowed
  values, and the code that writes them onto `<html>`
- `app/accessibility.css`: bundled fonts (Atkinson Hyperlegible Next,
  Lexend, OpenDyslexic), text spacing, a focus outline that survives
  Windows contrast themes, more-contrast colours on any theme, reduced
  transparency, a large pointer
- admins can preset every new option but lock only shortcuts, send
  confirmation and the notification duration; a lock on text size or
  another reading aid works as a default
- always-light mail and toolbar labels now sync, and imports drop
  values the page doesn't know
- font size and animations move off Appearance, the plain text font
  off Reading; everyday settings such as conversation view and undo
  send show on both their own page and Accessibility
2026-10-29 17:53:32 +01:00

334 lines
13 KiB
TypeScript

import type { Metadata, Viewport } from "next";
import { getLocaleDirection } from "@/i18n/direction";
import { Geist, Geist_Mono, Hanken_Grotesk } from "next/font/google";
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 { IosViewportHeal } from "@/components/ios-viewport-heal";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from "@/lib/admin/domain-branding";
import { withBasePath } from "@/lib/browser-navigation";
import { locales, defaultLocale } from "@/i18n/routing";
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
import { accessibilityBootScript } from "@/lib/accessibility-settings";
import "../globals.css";
import "../print.css";
// This layout renders <html> and sits ABOVE the [locale] segment, so
// next-intl's getLocale() returns the default locale here - emitting
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
// "translate this page". Recover the active locale from the request pathname
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
// Accept-Language) when the path carries no locale segment.
async function resolveRequestLocale(): Promise<string> {
const pathname = (await headers()).get("x-pathname") || "";
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
return seg ?? (await getLocale());
}
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
// The "Flat fields" theme's family. Not preloaded and no metric-adjusted
// fallback: the file is only fetched once that theme renders text in it, and
// scripts Hanken does not cover fall through to the theme's system stack.
const hankenGrotesk = Hanken_Grotesk({
variable: "--font-hanken",
subsets: ["latin", "latin-ext"],
display: "swap",
preload: false,
adjustFontFallback: false,
});
// 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);
}
/**
* 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";
}
/**
* Absolute origin this request came in on, used as `metadataBase` so Next.js
* can turn the relative OG image path into the absolute URL that link-preview
* crawlers require. Returns null when the host header is unusable, in which
* case we omit metadataBase rather than emit a localhost URL.
*/
async function requestOrigin(): Promise<URL | null> {
const h = await headers();
const first = (name: string) => h.get(name)?.split(",")[0]?.trim() ?? "";
// pickRequestHost() strips the port, which the origin needs to keep, so read
// the raw headers here with the same X-Forwarded-Host-first precedence.
let authority = first("x-forwarded-host") || first("host");
if (!authority) return null;
const forwardedProto = first("x-forwarded-proto").toLowerCase();
// Assume TLS when the proxy doesn't say: a webmail served over plain HTTP is
// the exception, and an https:// preview URL is the safer guess.
const proto = forwardedProto === "http" || forwardedProto === "https" ? forwardedProto : "https";
// A proxy (and Next's own dev server) may carry the port separately, leaving
// X-Forwarded-Host portless. Re-attach it unless it's the default for the scheme.
if (!authority.includes(":")) {
const port = first("x-forwarded-port");
const isDefaultPort = (proto === "https" && port === "443") || (proto === "http" && port === "80");
if (/^\d+$/.test(port) && !isDefaultPort) authority += `:${port}`;
}
try {
return new URL(`${proto}://${authority}`);
} catch {
return null;
}
}
/** App name, resolved exactly like app/manifest.ts so <head> and manifest agree. */
async function brandedAppName(): Promise<string> {
if (IS_LITE) return process.env.NEXT_PUBLIC_APP_NAME || "Webmail";
return (
(await brandedValue("appName", "")) ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Webmail"
);
}
export async function generateViewport(): Promise<Viewport> {
if (IS_LITE) {
// No admin config in the static build; <ThemeColorSync /> tracks the theme.
return { width: "device-width", initialScale: 1, viewportFit: "cover", themeColor: "#ffffff" };
}
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",
};
}
/**
* Static-build metadata: no request to read a host or locale from, so the
* <head> carries the build-time app name, the default locale's description
* and the manifest postbuild writes next to index.html. The inline bootstrap
* script below corrects <html lang/dir> per page in the browser.
*/
async function liteMetadata(): Promise<Metadata> {
const appName = await brandedAppName();
const t = await getTranslations({ locale: defaultLocale });
return {
title: appName,
description: t("meta_description"),
applicationName: appName,
robots: { index: false, follow: false },
manifest: withBasePath("/manifest.webmanifest"),
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: appName,
},
formatDetection: {
telephone: false,
},
icons: { icon: withBasePath("/branding/Bulwark_Favicon.svg") },
};
}
export async function generateMetadata(): Promise<Metadata> {
if (IS_LITE) return liteMetadata();
await configManager.ensureLoaded();
// The <head> favicon must honor per-domain branding, exactly like
// /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 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
// layout is above the [locale] segment (see resolveRequestLocale).
const locale = await resolveRequestLocale();
const t = await getTranslations({ locale });
const appName = await brandedAppName();
// An admin-configured description is branding and outranks the built-in
// string; without one, use the localized default so the <head> matches the
// UI language (same precedence as app/manifest.ts).
const description = (await brandedValue("appDescription", "")) || t("meta_description");
const origin = await requestOrigin();
return {
title: appName,
description,
applicationName: appName,
// A private webmail should not be indexed by search engines. This is opt-in
// via Settings -> General; the default (false) emits noindex/nofollow.
robots: configManager.get<boolean>("searchEngineIndexing", false)
? { index: true, follow: true }
: { index: false, follow: false },
// Link previews are worth emitting even under noindex: pasting the webmail
// URL into a chat should unfurl as the deployment's own brand, not as a
// bare link. /api/og-image renders the branded 1200x630 card.
...(origin ? { metadataBase: origin } : {}),
openGraph: {
type: "website",
siteName: appName,
title: appName,
description,
locale,
url: withBasePath("/"),
images: [
{
url: withBasePath("/api/og-image"),
width: 1200,
height: 630,
alt: appName,
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
title: appName,
description,
images: [withBasePath("/api/og-image")],
},
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: appName,
},
formatDetection: {
telephone: false,
},
icons: { icon: withBasePath(faviconUrl) },
};
}
/**
* Lite only: the exported HTML is one document per locale, but this layout
* sits above the [locale] segment and cannot know which one it is rendering.
* Fix <html lang/dir> from the URL before first paint, in the same inline
* script that applies the theme.
*/
function liteLocaleBootstrap(): string {
const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const rtl = locales.filter((l) => getLocaleDirection(l) === "rtl");
return `
try {
var locales = ${JSON.stringify([...locales])};
var rtl = ${JSON.stringify(rtl)};
var base = ${JSON.stringify(basePath)} || (typeof window.${LITE_MOUNT_GLOBAL} === 'string' ? window.${LITE_MOUNT_GLOBAL} : '');
var path = location.pathname;
if (base && path.indexOf(base) === 0) path = path.slice(base.length);
var seg = path.split('/').filter(Boolean)[0];
if (seg && locales.indexOf(seg) !== -1) {
document.documentElement.lang = seg;
document.documentElement.dir = rtl.indexOf(seg) !== -1 ? 'rtl' : 'ltr';
}
} catch (e) {}`;
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
let locale: string;
let nonce: string | undefined;
let themeColorConfigured: boolean;
if (IS_LITE) {
// Static export: no request, no nonce, no admin config.
locale = defaultLocale;
nonce = undefined;
themeColorConfigured = false;
} else {
await configManager.ensureLoaded();
locale = await resolveRequestLocale();
nonce = (await headers()).get("x-nonce") ?? "";
themeColorConfigured = await hasExplicitThemeColor();
}
const appName = await brandedAppName();
return (
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
<head>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content={appName} />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && (
<meta name="parent-origin" content={parentOrigin} />
)}
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
(function() {${IS_LITE ? liteLocaleBootstrap() : ""}
try {
const stored = localStorage.getItem('theme-storage');
const theme = stored ? JSON.parse(stored).state.theme : 'system';
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const resolved = theme === 'system' ? systemTheme : theme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
} catch (e) {
document.documentElement.classList.add('light');
}
${accessibilityBootScript()}
})();
`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
>
<ServiceWorkerRegistration />
{!themeColorConfigured && <ThemeColorSync />}
<FaviconBadge />
<IosViewportHeal />
{children}
</body>
</html>
);
}