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.
|
After Width: | Height: | Size: 18 KiB |
@@ -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
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
@@ -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,21 @@ 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] = await Promise.all([
|
||||
const [regular, bold, mark] = await Promise.all([
|
||||
font('Manrope-Regular.ttf'),
|
||||
font('Manrope-Bold.ttf'),
|
||||
markDataUri(),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
@@ -62,7 +72,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={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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,107 +1,84 @@
|
||||
/**
|
||||
* 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.
|
||||
* `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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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')}`;
|
||||
}
|
||||
/** Intrinsic aspect of the artwork, used to derive width from height. */
|
||||
const ASPECT = 153 / 189;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 66 KiB |
@@ -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",
|
||||
|
||||