Files
MichilisandClaude Opus 5 8c8463924f fix: the theme belongs in a cookie, and the offline page in the shell
next/script rendered the theme script with a nonce, and browsers hide a
nonce attribute from the DOM once they have parsed it, so the server sent
nonce="" and the client read undefined. Every page load logged a
hydration mismatch.

The cookie is the fix rather than a workaround for it. The server reads
it and stamps data-theme on the html element, so the first frame is
already the right colour and there is no inline script at all.

Chasing that turned up worse. /offline sat outside [locale], which made
it a sibling of the root layout, so Next gave it a generated one: no
stylesheet, no font, no theme, and a second html element. The phase 7
check asserted the text and a button and passed while the page was
plainly broken.

It is a normal screen now. The service worker keeps one offline copy per
language the reader actually visits, learned from their own successful
navigations, so no list of locales lives in the worker and a third
language stays a catalog file.

The e2e warm-up also asks for the dynamic routes, which cost a compile of
their own and were being paid for by whichever test reached one first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:04:05 +00:00

79 lines
2.5 KiB
TypeScript

import { isLocale } from '@impuestos/i18n';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { getT } from '@/i18n/t';
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
import { cookies } from 'next/headers';
import { Providers } from '@/components/providers';
import { routing } from '@/i18n/routing';
import { THEME_COOKIE } from '@/lib/theme';
import '../globals.css';
// Self hosted by next/font: no third party font request at runtime, which keeps the
// CSP tight (SPEC.md section 14).
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
/**
* The browser chrome follows the theme, and the accent is the same deep teal the icon and
* the manifest use.
*/
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fbfbfc' },
{ media: '(prefers-color-scheme: dark)', color: '#1b1d20' },
],
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await props.params;
const t = await getT(locale);
return {
title: t('common.appName'),
description: t('landing.hero.subtitle'),
applicationName: t('common.appName'),
appleWebApp: { capable: true, title: t('common.appName'), statusBarStyle: 'default' },
icons: { icon: '/icon-192.png', apple: '/apple-touch-icon.png' },
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
setRequestLocale(locale);
// Read here rather than in a script the browser runs: the first frame is already the
// right colour, and there is no inline script for a nonce policy to argue with.
const stored = (await cookies()).get(THEME_COOKIE)?.value;
const theme = stored === 'light' || stored === 'dark' ? stored : undefined;
return (
<html
lang={locale}
className={inter.variable}
{...(theme ? { 'data-theme': theme } : {})}
suppressHydrationWarning
>
<body className="min-h-dvh font-sans antialiased">
<NextIntlClientProvider>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}