Files
school_compare/nextjs-app/app/opengraph-image.tsx
T
TudorandClaude Opus 5.5 d1688ac150
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
copy(web): replace em dashes in public copy
Rewrites every visitor-facing string that used an em dash, choosing the
punctuation for what the dash was doing: a colon before a list or
explanation, a comma for an aside, a full stop between two thoughts,
parentheses for an aside mid-sentence. Covers page titles and meta
descriptions, the home and admissions guide copy, school page headings
and notes, the compare page, metric labels and tooltips.

Two rewrites also fix the sentence around them: the closure banner no
longer repeats "proposed for closure", and the cut-off caveat's list of
priorities now parses.

A lone dash marking a missing value in a table cell stays: it is a data
convention, not prose. A Jest guard walks the source with the TypeScript
parser and fails on any other em dash in a string or JSX text node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:04:27 +01:00

118 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { ImageResponse } from 'next/og';
/**
* The site had no og:image at all, so every link pasted into a class WhatsApp
* group or a Slack channel rendered as a bare grey text card — on the highest
* intent channel this product has.
*
* The card is the brand's own proposition on the Sand ground, with the mark
* and a suggestion of the illustration's rolling landscape along the bottom.
* It states the promise rather than decorating it.
*/
export const alt = 'schoolcompare: find the school where your child will flourish';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
const SAND = '#F5EFE6';
const INK = '#1C2731';
const MUTED = '#5F6A75';
const TEAL = '#0F766E';
const SAGE = '#A7D7C5';
const SAGE_DEEP = '#7FC3AC';
const SAGE_PALE = '#D6EDE2';
async function font(file: string) {
return readFile(join(process.cwd(), 'assets', file));
}
/**
* The mark, as a data URI. Satori has no access to the public/ URL space, so
* the artwork is read off disk and inlined. This is the same file the header
* serves — public/brand/mark.png — so the card can never drift from the site.
* The 47×56 box below is that file's 176:208 ratio; re-crop the artwork and
* this has to move with it, or Satori will stretch it.
*/
async function markDataUri() {
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
return `data:image/png;base64,${png.toString('base64')}`;
}
export default async function OpengraphImage() {
const [regular, bold, mark] = await Promise.all([
font('Manrope-Regular.ttf'),
font('Manrope-Bold.ttf'),
markDataUri(),
]);
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: SAND,
padding: '58px 72px 0',
fontFamily: 'Manrope',
position: 'relative',
}}
>
{/* Rolling hills along the bottom edge — the illustration style,
reduced to the one gesture that survives at thumbnail size. */}
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
<svg width="1200" height="210" viewBox="0 0 1200 210">
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
</svg>
</div>
{/* Lockup */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={mark} width={47} height={56} alt="" />
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
<span style={{ color: INK }}>school</span>
<span style={{ color: TEAL }}>compare</span>
</div>
</div>
{/* Proposition. Sized so the headline holds two lines and the subhead
one — an orphaned word reads as an accident at this size. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
fontSize: 72,
fontWeight: 700,
letterSpacing: '-0.035em',
lineHeight: 1.05,
color: INK,
maxWidth: 900,
}}
>
<span style={{ marginRight: 18 }}>Find the school where</span>
<span style={{ marginRight: 18 }}>your child will</span>
<span style={{ color: TEAL }}>flourish.</span>
</div>
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
</div>
</div>
</div>
),
{
...size,
fonts: [
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
],
}
);
}