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 { join } from 'node:path';
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
@@ -28,10 +27,23 @@ 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] = await Promise.all([
const [regular, bold, mark] = await Promise.all([
font('Manrope-Regular.ttf'),
font('Manrope-Bold.ttf'),
markDataUri(),
]);
return new ImageResponse(
@@ -62,7 +74,7 @@ export default async function OpengraphImage() {
{/* Lockup */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* 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' }}>
<span style={{ color: INK }}>school</span>
<span style={{ color: TEAL }}>compare</span>
+4 -3
View File
@@ -47,13 +47,14 @@
.lockupMark {
display: inline-flex;
flex: 0 0 auto;
width: 34px;
/* Height drives; the artwork is taller than it is wide. */
height: 34px;
}
.lockupMark svg {
width: 100%;
.lockupMark img {
display: block;
height: 100%;
width: auto;
}
/* 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}>
<span className={styles.lockupMark}>
<LogoMark pin="var(--on-sunken)" leaf="var(--surface-sunken)" />
<LogoMark variant="onDark" size={34} />
</span>
<span className={styles.wordmark}>
school<span className={styles.wordmarkAccent}>compare</span>
+80 -85
View File
@@ -1,107 +1,102 @@
/**
* The schoolcompare mark.
*
* A location pin with a leaf growing inside it: where a school is, and a child
* growing there. The pin's tail sweeps left as the guideline's "signature path
* shape" — the journey a parent takes to find the right school — so the mark
* and the illustration style share one gesture.
* This is the supplied brand artwork, not a reconstruction of it. Two
* colourways were extracted from the logo sheet and live in public/brand:
*
* This is the single source for the mark. app/icon.svg, app/apple-icon.tsx and
* app/opengraph-image.tsx all derive from the same geometry (see MARK_PATHS),
* because the header and the favicon had previously drifted into two different
* logos.
* mark.png the primary lockup's mark — teal pin, white window and
* path, green leaves. Reads correctly on Warm White,
* white cards and Sand, and on both dark-theme grounds.
* 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
* out of it, so the mark needs exactly two colours and works on any ground.
* Both are written onto one 176×208 canvas with the artwork at the same
* 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. */
export const MARK_PATHS = {
/** 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;
const MARK_LIGHT = '/brand/mark.png';
const MARK_ON_DARK = '/brand/mark-on-dark.png';
/**
* 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
* renders inline SVG children, so the generated icon and share card both draw
* the mark this way — from the same geometry as the React component above,
* which is the whole point of MARK_PATHS.
* Both colourways are deliberately written onto the SAME canvas at the same
* artwork height, so this one ratio is correct for either of them. That is not
* tidiness — it is required. `variant="auto"` renders a single <img> whose
* 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 {
return [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">',
`<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')}`;
}
const ART_WIDTH = 176;
const ART_HEIGHT = 208;
const ASPECT = ART_WIDTH / ART_HEIGHT;
interface LogoMarkProps {
className?: string;
/** Rendered size in px. Defaults to inheriting via CSS. */
/** Rendered height in px. Width follows the artwork's aspect ratio. */
size?: number;
/** The pin. Defaults to the brand token so it inverts with the theme. */
pin?: string;
/** The leaf knocked out of the pin. */
leaf?: string;
/**
* Which colourway to serve.
* - `auto` teal pin, swapping to the on-dark artwork in the dark theme
* - `onDark` always the white pin — for teal grounds, which are teal in
* both themes (the footer band)
*/
variant?: 'auto' | 'onDark';
title?: string;
}
export function LogoMark({
className,
size,
pin = 'var(--brand)',
leaf = 'var(--bg-card)',
title,
}: LogoMarkProps) {
export function LogoMark({ className, size = 36, variant = 'auto', title }: LogoMarkProps) {
const height = size;
const width = Math.round(size * ASPECT);
const alt = title ?? '';
if (variant === 'onDark') {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
className={className}
src={MARK_ON_DARK}
width={width}
height={height}
alt={alt}
aria-hidden={title ? undefined : true}
decoding="async"
/>
);
}
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role={title ? 'img' : undefined}
aria-hidden={title ? undefined : true}
aria-label={title}
>
{title ? <title>{title}</title> : null}
<path
d={MARK_PATHS.tail}
stroke={pin}
strokeWidth={8.5}
strokeLinecap="round"
fill="none"
<picture>
<source media="(prefers-color-scheme: dark)" srcSet={MARK_ON_DARK} />
{/* 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"
/>
<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}
strokeWidth={2.6}
strokeLinecap="round"
fill="none"
/>
<path d={MARK_PATHS.leafUpper} fill={leaf} />
<path d={MARK_PATHS.leafLower} fill={leaf} />
</svg>
</picture>
);
}
+6 -4
View File
@@ -57,13 +57,15 @@
.logoIcon {
display: inline-flex;
flex: 0 0 auto;
width: 36px;
height: 36px;
/* The artwork is taller than it is wide (153:189), so height drives the box
and width follows. Constraining both would squash the pin. */
height: 38px;
}
.logoIcon svg {
width: 100%;
.logoIcon img {
display: block;
height: 100%;
width: auto;
}
/*
+5 -6
View File
@@ -102,14 +102,13 @@ export function Navigation() {
<div className={styles.container}>
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
{/*
LogoMark's defaults are already correct for this ground: the pin
takes var(--brand) and the leaf is knocked out in var(--bg-card),
which is exactly the header's own background in both themes. No
explicit pin/leaf needed here — the footer, which sits on the
sunken teal band, does have to pass them.
variant="auto": the teal pin on the white header, swapping to the
on-dark artwork for dark-theme viewers. The footer has to force
onDark instead, because its band is teal in both themes and the
teal pin has no silhouette against it.
*/}
<span className={styles.logoIcon}>
<LogoMark />
<LogoMark size={38} />
</span>
<span className={styles.logoText}>
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
// source in components/Logo.tsx); this rule was caching a 404.
source: '/icon.svg',
// The mark is now the supplied raster artwork, so the favicon is
// app/icon.png rather than an SVG. Pointing this at the old path was
// caching a 404.
source: '/icon.png',
headers: [
{
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",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
"src": "/icon.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192.png",