PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m15s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m39s
Both features ship dark. Neither is reachable in an environment where its flag is off, and every flag in this system starts off, so a deploy of this commit makes both disappear until someone turns them on deliberately. Two independent flags rather than one, which makes blog-on-about-off a reachable state. That state is the whole reason the change is larger than four notFound() calls: the blog leans on the About page for its author identity. The Person entity is anchored at /about#tudor, and that URL 404s while about_page is dark, so a post published in that state would claim an author resolving to nothing. Worse than having no named author. Both bylines fall back to unlinked text and the BlogPosting attributes to the publisher instead, so every combination of the two flags renders something correct. Gated: /about, /blog, /blog/[slug], the RSS feed, both footer links, and the matching content-sitemap entries. A sitemap must never advertise a URL that 404s. With both dark it emits a valid empty urlset rather than a 404, because robots.txt names it unconditionally. Not gated: /admin. Posts have to be writable before the blog is worth switching on, so flagging the panel would make the flag unflippable. getFlags takes a revalidate rather than always using the 300s constant. Reading a flag pins the calling route to the lowest revalidate among its fetches, and the footer links live in the root layout, so a naive gate there would have dropped every school and place page from a weekly cache to a 5-minute one. The layout passes 604800, the floor those routes already declare, and the build confirms all four SSG route families still prerender. The cost is one-way latency: pages follow a flip in minutes, footer links within a week. The e2e journeys follow the existing paired shape from the admission_distance flag: a lit journey and a dark one for each flag, reading state from whether /about and /blog respond rather than from /api/flags, which another journey asserts is not publicly reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
156 lines
6.5 KiB
TypeScript
156 lines
6.5 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.svg and app/apple-icon.tsx are the source, and
|
|
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
|
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.',
|
|
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 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>
|
|
);
|
|
}
|