PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 53s
The staging E2E gate's one failure. Every link to the site pasted into a chat has been rendering bare. Staging serves og:title, og:description, og:url, og:site_name, og:type and twitter:card, and no og:image at all. So metadata from the layout reaches the page; only the file convention does not. app/opengraph-image.tsx does work — _not-found, which lives in the app root segment, carries an og:image from it in the build output. It does not reach the site's pages, which live in the (frontend) route group whose own layout.tsx is the root layout. The icon conventions are not affected: /icon.png and /apple-icon.png are both linked correctly on the same page, verified against staging. The asymmetry is the whole bug, and it arrived with the route-group split that Payload required. The file stays at the app root. Moving metadata files into a route group is what drops /robots.txt and hashes /icon.png, which CLAUDE.md records and which this must not undo — the build still emits all four of /robots.txt, /icon.png, /apple-icon.png and /opengraph-image. The root layout points at the route instead, and metadataBase makes it absolute, which the journey needs since it calls new URL() on the value. twitter.images is set for the same reason: the card is declared summary_large_image, and claiming a large-image card while supplying no image is worse than claiming a summary card. Checked before fixing that og:image was the only broken assertion in that journey: the test aborts at line 911, so its apple-touch-icon and maskable-icon assertions had never run. All four of those assets return 200 image/png from staging, so this does not simply move the failure further down the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
177 lines
7.6 KiB
TypeScript
177 lines
7.6 KiB
TypeScript
import type { Metadata, Viewport } from 'next';
|
|
import { Manrope, Inter } from 'next/font/google';
|
|
import Script from 'next/script';
|
|
import { Navigation } from '@/components/Navigation';
|
|
import { Footer } from '@/components/Footer';
|
|
import { ComparisonToast } from '@/components/ComparisonToast';
|
|
import { RouteTrail } from '@/components/RouteTrail';
|
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
|
import { SITE_URL } from '@/lib/site';
|
|
import { getFlags } from '@/lib/flags';
|
|
import './globals.css';
|
|
|
|
// Manrope carries headings and key messaging — the guideline's "friendly,
|
|
// modern personality". It never sets body copy or a control.
|
|
const manrope = Manrope({
|
|
subsets: ['latin'],
|
|
weight: ['500', '600', '700'],
|
|
variable: '--font-manrope',
|
|
display: 'swap',
|
|
});
|
|
|
|
// Inter carries body copy, every control and every figure — chosen in the
|
|
// guideline for clarity and legibility. Its tabular numerals hold a column
|
|
// properly, which matters on a page that is mostly percentages.
|
|
const inter = Inter({
|
|
subsets: ['latin'],
|
|
weight: ['400', '500', '600', '700'],
|
|
variable: '--font-inter',
|
|
display: 'swap',
|
|
});
|
|
|
|
export const viewport: Viewport = {
|
|
width: 'device-width',
|
|
initialScale: 1,
|
|
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
|
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
|
viewportFit: 'cover',
|
|
// These must match --bg-primary in globals.css for each theme, or the
|
|
// browser chrome and the page disagree at the top edge of the screen.
|
|
themeColor: [
|
|
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
|
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
|
],
|
|
};
|
|
|
|
export const metadata: Metadata = {
|
|
appleWebApp: {
|
|
capable: true,
|
|
title: 'schoolcompare',
|
|
statusBarStyle: 'default',
|
|
},
|
|
title: {
|
|
default: 'Compare Schools Side by Side | schoolcompare',
|
|
template: '%s | schoolcompare',
|
|
},
|
|
description:
|
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
|
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
|
authors: [{ name: 'schoolcompare' }],
|
|
manifest: '/manifest.json',
|
|
// No `icons` key on purpose: setting it here would override the file
|
|
// conventions. app/icon.png and app/apple-icon.png are the source.
|
|
//
|
|
// og:image and twitter:image are NOT inherited from
|
|
// app/opengraph-image.tsx — see the note on openGraph.images below. The
|
|
// icon conventions do reach these pages; the opengraph-image one does not.
|
|
metadataBase: new URL(SITE_URL),
|
|
openGraph: {
|
|
type: 'website',
|
|
title: 'Compare Schools Side by Side | schoolcompare',
|
|
description:
|
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
|
/*
|
|
* Declared, not inherited.
|
|
*
|
|
* app/opengraph-image.tsx is a metadata file convention, and it does
|
|
* attach to routes in the app root segment — _not-found gets an og:image
|
|
* from it. It does not reach the site's pages, which live in the
|
|
* (frontend) route group whose own layout.tsx is a root layout. Staging
|
|
* served og:title, og:description, og:url, og:site_name and og:type with
|
|
* no og:image at all, so every link pasted into a chat rendered bare.
|
|
*
|
|
* The file stays at the app root: /robots.txt and /icon.png depend on it
|
|
* being there, and moving it is what broke those before. This points at
|
|
* the route it generates instead. metadataBase makes it absolute.
|
|
*/
|
|
images: ['/opengraph-image'],
|
|
url: SITE_URL,
|
|
siteName: 'schoolcompare',
|
|
},
|
|
twitter: {
|
|
// summary_large_image now that there is an image worth showing.
|
|
card: 'summary_large_image',
|
|
title: 'Compare Schools Side by Side | schoolcompare',
|
|
description:
|
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
|
// The card is summary_large_image; claiming that and supplying no image
|
|
// is worse than claiming a summary card.
|
|
images: ['/opengraph-image'],
|
|
},
|
|
};
|
|
|
|
/*
|
|
* The footer's About and Blog links are flagged, which makes this the one
|
|
* place on the site that reads a flag on every route.
|
|
*
|
|
* 604800 is deliberate and load-bearing: it is the revalidate every SEO route
|
|
* here already declares. Next pins a route to the LOWEST revalidate among its
|
|
* fetches, so reading flags at the 300s default would drop the whole school
|
|
* and place corpus from a weekly cache to a 5-minute one — a large origin-load
|
|
* regression to hide two footer links.
|
|
*
|
|
* The cost is latency in one direction only. The pages themselves read the
|
|
* same flags at their own floors and flip within minutes; the footer links
|
|
* follow within a week. Turning a feature on early therefore shows the page
|
|
* before its footer link, which is harmless. Turning one off leaves a link to
|
|
* a 404 until the cache turns over, so a rollback that matters wants a purge.
|
|
*/
|
|
export default async function RootLayout({
|
|
children,
|
|
}: Readonly<{
|
|
children: React.ReactNode;
|
|
}>) {
|
|
const flags = await getFlags(604800);
|
|
return (
|
|
// The font variable classes must sit on <html>, not <body>. globals.css
|
|
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
|
// var(--font-inter), and a custom property's var() references resolve on
|
|
// the element that declares it. With the classes on <body>, those two are
|
|
// undefined at :root, so --font-display computes to the guaranteed-invalid
|
|
// value and every font-family referencing it silently falls back — which
|
|
// once shipped the whole site in Times. Nothing throws and no unit test
|
|
// fails, so the e2e typeface assertion is the only guard.
|
|
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
|
<head>
|
|
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
|
<link rel="preconnect" href="https://api.postcodes.io" />
|
|
{/* data-domains: the tracker only fires on the production hostnames,
|
|
so staging (same image, different host) never pollutes Umami */}
|
|
<Script
|
|
src="https://analytics.schoolcompare.co.uk/script.js"
|
|
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
|
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
|
data-performance="true"
|
|
strategy="afterInteractive"
|
|
/>
|
|
{/* Heatmap / session recorder — same data-domains guard so staging
|
|
(same image, different host) never feeds the heatmap. */}
|
|
<Script
|
|
src="https://analytics.schoolcompare.co.uk/recorder.js"
|
|
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
|
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
|
strategy="afterInteractive"
|
|
/>
|
|
</head>
|
|
<body>
|
|
{/* Records every route so funnel attribution has a previous page to
|
|
name. document.referrer cannot: a soft navigation creates no
|
|
document, so the browser never updates it. */}
|
|
<RouteTrail />
|
|
<ComparisonProvider>
|
|
<a href="#main-content" className="skip-link">Skip to main content</a>
|
|
<Navigation />
|
|
<main id="main-content" className="main">
|
|
{children}
|
|
</main>
|
|
<ComparisonToast />
|
|
<Footer
|
|
aboutEnabled={flags.about_page === true}
|
|
blogEnabled={flags.blog === true}
|
|
/>
|
|
</ComparisonProvider>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|