feat(brand): use the supplied logo artwork instead of a reconstruction #90

Merged
tudor merged 2 commits from feat/brand-logo-artwork into main 2026-08-08 21:41:47 +00:00
17 changed files with 119 additions and 149 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

-34
View File
@@ -1,34 +0,0 @@
import { ImageResponse } from 'next/og';
import { markDataUri } from '@/components/Logo';
// iOS ignores SVG touch icons — it needs a real raster, on an opaque ground,
// with no transparency (it composites its own rounded mask). Previously this
// pointed at favicon.svg, so add-to-home-screen produced a blank tile.
//
// Teal ground with the mark knocked out in white, matching the app icon in the
// guideline's "brand in action" row.
export const size = { width: 180, height: 180 };
export const contentType = 'image/png';
const TEAL = '#0F766E';
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: TEAL,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={markDataUri('#FFFFFF', TEAL)} width={116} height={116} alt="" />
</div>
),
size
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 30 C15 35.6 11.6 40.2 7.8 42.8" stroke="#0F766E" stroke-width="8.5" stroke-linecap="round" fill="none"/>
<circle cx="26.5" cy="18.5" r="16.5" fill="#0F766E"/>
<path d="M26.5 29.5 C26.5 25 26.6 21 27 18" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<path d="M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z" fill="#FFFFFF"/>
<path d="M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 594 B

+15 -3
View File
@@ -1,7 +1,6 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { markDataUri } from '@/components/Logo';
/** /**
* The site had no og:image at all, so every link pasted into a class WhatsApp * The site had no og:image at all, so every link pasted into a class WhatsApp
@@ -28,10 +27,23 @@ async function font(file: string) {
return readFile(join(process.cwd(), 'assets', file)); 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() { export default async function OpengraphImage() {
const [regular, bold] = await Promise.all([ const [regular, bold, mark] = await Promise.all([
font('Manrope-Regular.ttf'), font('Manrope-Regular.ttf'),
font('Manrope-Bold.ttf'), font('Manrope-Bold.ttf'),
markDataUri(),
]); ]);
return new ImageResponse( return new ImageResponse(
@@ -62,7 +74,7 @@ export default async function OpengraphImage() {
{/* Lockup */} {/* Lockup */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={markDataUri(TEAL, SAND)} width={52} height={52} alt="" /> <img src={mark} width={47} height={56} alt="" />
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}> <div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
<span style={{ color: INK }}>school</span> <span style={{ color: INK }}>school</span>
<span style={{ color: TEAL }}>compare</span> <span style={{ color: TEAL }}>compare</span>
+4 -3
View File
@@ -47,13 +47,14 @@
.lockupMark { .lockupMark {
display: inline-flex; display: inline-flex;
flex: 0 0 auto; flex: 0 0 auto;
width: 34px; /* Height drives; the artwork is taller than it is wide. */
height: 34px; height: 34px;
} }
.lockupMark svg { .lockupMark img {
width: 100%; display: block;
height: 100%; height: 100%;
width: auto;
} }
/* Same wordmark as the header, inverted: "school" in the band's foreground, /* Same wordmark as the header, inverted: "school" in the band's foreground,
+1 -1
View File
@@ -25,7 +25,7 @@ export function Footer() {
*/} */}
<h3 className={styles.lockup}> <h3 className={styles.lockup}>
<span className={styles.lockupMark}> <span className={styles.lockupMark}>
<LogoMark pin="var(--on-sunken)" leaf="var(--surface-sunken)" /> <LogoMark variant="onDark" size={34} />
</span> </span>
<span className={styles.wordmark}> <span className={styles.wordmark}>
school<span className={styles.wordmarkAccent}>compare</span> school<span className={styles.wordmarkAccent}>compare</span>
+77 -82
View File
@@ -1,107 +1,102 @@
/** /**
* The schoolcompare mark. * The schoolcompare mark.
* *
* A location pin with a leaf growing inside it: where a school is, and a child * This is the supplied brand artwork, not a reconstruction of it. Two
* growing there. The pin's tail sweeps left as the guideline's "signature path * colourways were extracted from the logo sheet and live in public/brand:
* shape" — the journey a parent takes to find the right school — so the mark
* and the illustration style share one gesture.
* *
* This is the single source for the mark. app/icon.svg, app/apple-icon.tsx and * mark.png the primary lockup's mark — teal pin, white window and
* app/opengraph-image.tsx all derive from the same geometry (see MARK_PATHS), * path, green leaves. Reads correctly on Warm White,
* because the header and the favicon had previously drifted into two different * white cards and Sand, and on both dark-theme grounds.
* logos. * mark-on-dark.png the on-dark colourway — white pin with the counter
* knocked through to the ground. Required wherever the
* ground is teal, because there the teal pin's silhouette
* disappears and only the white window survives.
* *
* Drawn on a 48×48 grid. The pin takes the brand hue and the leaf is knocked * Both are written onto one 176×208 canvas with the artwork at the same
* out of it, so the mark needs exactly two colours and works on any ground. * height, so they are drop-in swappable — see ASPECT below for why that
* matters rather than merely being neat.
*
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
* <picture> source, so this needs no JavaScript and no client boundary.
*
* The wordmark beside it is live text in Manrope rather than the sheet's
* raster: the written style guide specifies Manrope, and live text stays
* selectable, scales cleanly and recolours with the theme.
*
* RESOLUTION CAVEAT: the largest instance on the supplied sheet is 153×189,
* which is ample for the header (36px), the favicon and the share card, but
* short of a 512px PWA icon — that one is upscaled and is slightly soft. Drop
* a vector (SVG/AI/EPS) into public/brand and regenerate to fix it; every
* consumer goes through this component or public/brand, so it is one swap.
*/ */
/** Geometry shared by every rendering of the mark, on a 0 0 48 48 viewBox. */ const MARK_LIGHT = '/brand/mark.png';
export const MARK_PATHS = { const MARK_ON_DARK = '/brand/mark-on-dark.png';
/** The path tail, stroked — 8.5 wide with a round cap. */
tail: 'M17 30 C15 35.6 11.6 40.2 7.8 42.8',
/** The pin head. */
head: { cx: 26.5, cy: 18.5, r: 16.5 },
/** Leaf stem, stroked — 2.6 wide with a round cap. */
stem: 'M26.5 29.5 C26.5 25 26.6 21 27 18',
/** Upper leaf, filled. */
leafUpper: 'M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z',
/** Lower leaf, filled. */
leafLower: 'M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z',
} as const;
/** /**
* The mark as a standalone SVG string. * Intrinsic size of the artwork files, used to derive width from height.
* *
* Satori (next/og) renders `<img>` with a data URI far more reliably than it * Both colourways are deliberately written onto the SAME canvas at the same
* renders inline SVG children, so the generated icon and share card both draw * artwork height, so this one ratio is correct for either of them. That is not
* the mark this way — from the same geometry as the React component above, * tidiness — it is required. `variant="auto"` renders a single <img> whose
* which is the whole point of MARK_PATHS. * srcset swaps the file underneath it, so the width/height attributes are
* shared by both colourways and cannot be varied per file. When the two were
* tightly cropped they had different ratios (0.810 and 0.875) and different
* padding, which meant a wrong aspect hint before load — a reflow on load
* under `width: auto` — and a visible jump in logo size whenever the OS theme
* flipped. Re-crop or replace one file and you must re-normalise both.
*/ */
export function markSvg(pin: string, leaf: string): string { const ART_WIDTH = 176;
return [ const ART_HEIGHT = 208;
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">', const ASPECT = ART_WIDTH / ART_HEIGHT;
`<path d="${MARK_PATHS.tail}" stroke="${pin}" stroke-width="8.5" stroke-linecap="round" fill="none"/>`,
`<circle cx="${MARK_PATHS.head.cx}" cy="${MARK_PATHS.head.cy}" r="${MARK_PATHS.head.r}" fill="${pin}"/>`,
`<path d="${MARK_PATHS.stem}" stroke="${leaf}" stroke-width="2.6" stroke-linecap="round" fill="none"/>`,
`<path d="${MARK_PATHS.leafUpper}" fill="${leaf}"/>`,
`<path d="${MARK_PATHS.leafLower}" fill="${leaf}"/>`,
'</svg>',
].join('');
}
/** The same string as a data URI, ready for `<img src>`. */
export function markDataUri(pin: string, leaf: string): string {
return `data:image/svg+xml;base64,${Buffer.from(markSvg(pin, leaf)).toString('base64')}`;
}
interface LogoMarkProps { interface LogoMarkProps {
className?: string; className?: string;
/** Rendered size in px. Defaults to inheriting via CSS. */ /** Rendered height in px. Width follows the artwork's aspect ratio. */
size?: number; size?: number;
/** The pin. Defaults to the brand token so it inverts with the theme. */ /**
pin?: string; * Which colourway to serve.
/** The leaf knocked out of the pin. */ * - `auto` teal pin, swapping to the on-dark artwork in the dark theme
leaf?: string; * - `onDark` always the white pin — for teal grounds, which are teal in
* both themes (the footer band)
*/
variant?: 'auto' | 'onDark';
title?: string; title?: string;
} }
export function LogoMark({ export function LogoMark({ className, size = 36, variant = 'auto', title }: LogoMarkProps) {
className, const height = size;
size, const width = Math.round(size * ASPECT);
pin = 'var(--brand)', const alt = title ?? '';
leaf = 'var(--bg-card)',
title, if (variant === 'onDark') {
}: LogoMarkProps) {
return ( return (
<svg // eslint-disable-next-line @next/next/no-img-element
<img
className={className} className={className}
width={size} src={MARK_ON_DARK}
height={size} width={width}
viewBox="0 0 48 48" height={height}
fill="none" alt={alt}
xmlns="http://www.w3.org/2000/svg"
role={title ? 'img' : undefined}
aria-hidden={title ? undefined : true} aria-hidden={title ? undefined : true}
aria-label={title} decoding="async"
>
{title ? <title>{title}</title> : null}
<path
d={MARK_PATHS.tail}
stroke={pin}
strokeWidth={8.5}
strokeLinecap="round"
fill="none"
/> />
<circle cx={MARK_PATHS.head.cx} cy={MARK_PATHS.head.cy} r={MARK_PATHS.head.r} fill={pin} /> );
<path }
d={MARK_PATHS.stem}
stroke={leaf} return (
strokeWidth={2.6} <picture>
strokeLinecap="round" <source media="(prefers-color-scheme: dark)" srcSet={MARK_ON_DARK} />
fill="none" {/* eslint-disable-next-line @next/next/no-img-element */}
<img
className={className}
src={MARK_LIGHT}
width={width}
height={height}
alt={alt}
aria-hidden={title ? undefined : true}
decoding="async"
/> />
<path d={MARK_PATHS.leafUpper} fill={leaf} /> </picture>
<path d={MARK_PATHS.leafLower} fill={leaf} />
</svg>
); );
} }
+6 -4
View File
@@ -57,13 +57,15 @@
.logoIcon { .logoIcon {
display: inline-flex; display: inline-flex;
flex: 0 0 auto; flex: 0 0 auto;
width: 36px; /* The artwork is taller than it is wide (153:189), so height drives the box
height: 36px; and width follows. Constraining both would squash the pin. */
height: 38px;
} }
.logoIcon svg { .logoIcon img {
width: 100%; display: block;
height: 100%; height: 100%;
width: auto;
} }
/* /*
+5 -6
View File
@@ -102,14 +102,13 @@ export function Navigation() {
<div className={styles.container}> <div className={styles.container}>
<Link href="/" className={styles.logo} aria-label="schoolcompare home"> <Link href="/" className={styles.logo} aria-label="schoolcompare home">
{/* {/*
LogoMark's defaults are already correct for this ground: the pin variant="auto": the teal pin on the white header, swapping to the
takes var(--brand) and the leaf is knocked out in var(--bg-card), on-dark artwork for dark-theme viewers. The footer has to force
which is exactly the header's own background in both themes. No onDark instead, because its band is teal in both themes and the
explicit pin/leaf needed here — the footer, which sits on the teal pin has no silhouette against it.
sunken teal band, does have to pass them.
*/} */}
<span className={styles.logoIcon}> <span className={styles.logoIcon}>
<LogoMark /> <LogoMark size={38} />
</span> </span>
<span className={styles.logoText}> <span className={styles.logoText}>
school<span className={styles.logoTextAccent}>compare</span> school<span className={styles.logoTextAccent}>compare</span>
+4 -3
View File
@@ -81,9 +81,10 @@ const nextConfig = {
], ],
}, },
{ {
// favicon.svg was replaced by app/icon.svg (the mark now has one // The mark is now the supplied raster artwork, so the favicon is
// source in components/Logo.tsx); this rule was caching a 404. // app/icon.png rather than an SVG. Pointing this at the old path was
source: '/icon.svg', // caching a 404.
source: '/icon.png',
headers: [ headers: [
{ {
key: 'Cache-Control', key: 'Cache-Control',
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+4 -3
View File
@@ -8,9 +8,10 @@
"theme_color": "#0F766E", "theme_color": "#0F766E",
"icons": [ "icons": [
{ {
"src": "/icon.svg", "src": "/icon.png",
"sizes": "any", "sizes": "96x96",
"type": "image/svg+xml" "type": "image/png",
"purpose": "any"
}, },
{ {
"src": "/icon-192.png", "src": "/icon-192.png",