Files
school_compare/nextjs-app/app/opengraph-image.tsx
T
Tudor d5b93a2896 feat(brand): use the supplied logo artwork instead of a reconstruction
The mark shipped so far was my SVG approximation, and it was wrong: the real
mark is a teardrop pin with a white window and a path flowing out of its base,
carrying three leaves — not a circle with a separate tail.

Both colourways are extracted from the supplied sheet, which has a genuinely
transparent background, so these are the artwork rather than a trace:

  public/brand/mark.png          teal pin, white window and path, green leaves
  public/brand/mark-on-dark.png  white pin with the counter knocked through

Two colourways are needed, not one. Rendered against every real ground, the
teal pin holds up on Warm White, white cards, Sand and both dark-theme grounds
— but it vanishes on the teal footer band, where only the white window
survives. The header therefore serves the teal artwork and swaps to the
on-dark artwork for dark-theme viewers through a <picture> source, needing no
JavaScript; the footer forces on-dark, because its band is teal in both
themes.

Everything downstream now derives from those two files: the favicon
(app/icon.png, replacing icon.svg), the touch icon (app/apple-icon.png,
replacing the generated apple-icon.tsx), the three PWA rasters, and the share
card, which reads public/brand/mark.png off disk so it can never drift from
the header. The logo sizing CSS keyed off a square box, which would have
squashed a 153:189 artwork — height now drives and width follows.

The wordmark stays live text in Manrope. The sheet's wordmark is a raster with
visible edge fringing, and the written style guide specifies Manrope; live
text also stays selectable, scales cleanly and recolours with the theme.

KNOWN LIMITATION: the largest instance on the sheet is 153×189. That is ample
for the header at 38px, the favicon and the share card, but short of the 512px
PWA icon, which is upscaled and slightly soft. A vector would fix it and is a
single swap — every consumer goes through components/Logo.tsx or public/brand.

Verified: tsc clean, 159/159 tests, build green, /icon.png and /apple-icon.png
emit as static routes, and the header, footer, share card and all five icons
were rendered and inspected in both themes.
2026-08-08 22:21:11 +01:00

116 lines
4.3 KiB
TypeScript

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.
*/
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={45} 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' },
],
}
);
}