Skip to content

Commit 7a104d8

Browse files
bloveclaude
andauthored
feat(website): the social card shows the product, and the brand gets a real mark (#1047)
* docs(website): design for the framed social card kit One card kit on the site's own light surface, showing the approval beat inside the site's browser frame. Records two constraints found while prototyping: Satori kills the render worker on WebP, so card art must be generated PNG; and Inter and the mono face are fetched from Google Fonts on every render, so they should be bundled like Garamond already is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): the social card shows the product, and the brand gets a real mark The card was dark, centred, and built from a seam and a glow that exist nowhere else, so the light page it opened looked like a different product. It now renders on the site's own ground through a shared kit: the rail rule and mono eyebrow, the BrowserFrame with its traffic lights, the pills, the wordmark. Inside the frame, three beats drawn rather than screenshotted — an ask, a proposal, and an Approve/Decline pair — so the card shows the human-in-the-loop claim the copy makes. The blog card moves onto the same kit, which retires the near-black blue it alone used. The airplane emoji is replaced by a drawn paper plane, shared by the nav and footer wordmark, the card, src/app/icon.svg as the favicon, a regenerated favicon.ico, and a square logo that finally lets the Organization structured data assert a logo. Two constraints found while prototyping and recorded in the spec: Satori kills the render worker on WebP, which rules out every screenshot we own; and Inter and the mono face were fetched from Google Fonts on every render, failing silently when unreachable. All four faces are now bundled, instanced and subset by scripts/build-card-fonts.py, and together are smaller than the one unsubsetted Garamond they replace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b77f8f6 commit 7a104d8

28 files changed

Lines changed: 682 additions & 344 deletions

apps/website/e2e/website.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ test('landing page renders hero headline', async ({ page }) => {
2727
await expect(page.locator('.hero-eyebrow')).toContainText('Angular');
2828
});
2929

30+
test('the default social card renders as a PNG', async ({ request }) => {
31+
// The default card is rendered at request time, so a Satori rejection — a
32+
// div with two children and no explicit `display`, a font it cannot parse —
33+
// is a 500 on the live route rather than a build failure. The blog's cards
34+
// are prerendered and already fail the build, so only this one needs a
35+
// runtime check.
36+
const res = await request.get('/opengraph-image');
37+
expect(res.status()).toBe(200);
38+
expect(res.headers()['content-type']).toContain('image/png');
39+
expect((await res.body()).byteLength).toBeGreaterThan(10_000);
40+
});
41+
3042
test('landing page renders the dark proof band', async ({ page }) => {
3143
await page.goto('/');
3244
await expect(page.locator('#proof-heading')).toBeVisible();
3.43 KB
Loading
14.7 KB
Loading

apps/website/public/brand/mark.svg

Lines changed: 3 additions & 0 deletions
Loading

apps/website/public/favicon.ico

-9.38 KB
Binary file not shown.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""
2+
Generate the static, subsetted TTFs the social cards render with.
3+
4+
Why this script exists
5+
----------------------
6+
Satori (the engine behind Next.js ImageResponse) cannot decode woff2, which is
7+
the only format Google Fonts serves, and it crashes on variable-weight TTFs
8+
with "Cannot read properties of undefined (reading '256')". So every face a
9+
card uses has to be instanced to a single weight, stripped of its variable
10+
tables, and committed.
11+
12+
Until now only Garamond was bundled (see instance-garamond.py, which this
13+
script supersedes). Inter and JetBrains Mono were scraped from the Google
14+
Fonts CSS API on every card render. That is a network round trip inside an
15+
image render, and when it fails there is no error — the card silently falls
16+
back to whatever loaded, which is how a card whose eyebrow and pills are
17+
specified in mono came out set in serif. Bundling removes the dependency.
18+
19+
The fonts are subsetted to Latin plus the punctuation the site actually uses,
20+
which is what keeps four faces under 150KB total rather than well over 1MB.
21+
Blog post titles are the only unbounded text on a card; anything outside this
22+
range falls back to Satori's bundled Noto Sans rather than failing.
23+
24+
Usage:
25+
pip install --user fonttools brotli
26+
python3 apps/website/scripts/build-card-fonts.py
27+
28+
Re-run if an upstream font is updated, and commit the result.
29+
"""
30+
31+
import os
32+
import tempfile
33+
import urllib.request
34+
35+
from fontTools import subset
36+
from fontTools.ttLib import TTFont
37+
from fontTools.varLib.instancer import instantiateVariableFont
38+
39+
HERE = os.path.dirname(os.path.abspath(__file__))
40+
OUT_DIR = os.path.join(os.path.dirname(HERE), "src", "app", "card", "fonts")
41+
42+
# Basic Latin, Latin-1 Supplement, and the General Punctuation the site uses
43+
# (typographic quotes, en/em dashes, ellipsis, the middot separator).
44+
UNICODES = "U+0020-007E,U+00A0-00FF,U+2010-2015,U+2018-201A,U+201C-201E,U+2022,U+2026,U+2030,U+2039,U+203A,U+20AC,U+00B7"
45+
46+
FACES = [
47+
{
48+
"name": "EBGaramond-Bold.ttf",
49+
"url": "https://github.com/google/fonts/raw/main/ofl/ebgaramond/EBGaramond%5Bwght%5D.ttf",
50+
"weight": 700,
51+
},
52+
{
53+
"name": "Inter-Regular.ttf",
54+
"url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
55+
"weight": 400,
56+
},
57+
{
58+
"name": "Inter-SemiBold.ttf",
59+
"url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
60+
"weight": 600,
61+
},
62+
{
63+
"name": "JetBrainsMono-Bold.ttf",
64+
"url": "https://github.com/google/fonts/raw/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf",
65+
"weight": 700,
66+
},
67+
]
68+
69+
70+
def build(face: dict) -> None:
71+
with tempfile.NamedTemporaryFile(suffix=".ttf", delete=False) as tmp:
72+
print(f" downloading {face['url']}")
73+
with urllib.request.urlopen(face["url"]) as res:
74+
tmp.write(res.read())
75+
raw = tmp.name
76+
77+
font = TTFont(raw)
78+
axes = {"wght": face["weight"]}
79+
# Inter carries an optical-size axis as well; pin it to its text setting so
80+
# instancing leaves no variable tables behind for Satori to trip over.
81+
if "fvar" in font and any(a.axisTag == "opsz" for a in font["fvar"].axes):
82+
axes["opsz"] = 14
83+
font = instantiateVariableFont(font, axes, updateFontNames=False, inplace=True)
84+
for table in ("fvar", "STAT", "MVAR", "HVAR", "VVAR", "gvar", "cvar", "avar"):
85+
if table in font:
86+
del font[table]
87+
88+
options = subset.Options()
89+
options.set(layout_features=["*"], name_IDs=["*"], notdef_outline=True)
90+
subsetter = subset.Subsetter(options=options)
91+
subsetter.populate(unicodes=subset.parse_unicodes(UNICODES))
92+
subsetter.subset(font)
93+
94+
out = os.path.join(OUT_DIR, face["name"])
95+
font.save(out)
96+
os.unlink(raw)
97+
print(f" wrote {face['name']} ({os.path.getsize(out) // 1024}KB)")
98+
99+
100+
def main() -> None:
101+
os.makedirs(OUT_DIR, exist_ok=True)
102+
for face in FACES:
103+
print(f"{face['name']} @ {face['weight']}")
104+
build(face)
105+
106+
107+
if __name__ == "__main__":
108+
main()

apps/website/scripts/instance-garamond.py

Lines changed: 0 additions & 66 deletions
This file was deleted.
-509 KB
Binary file not shown.

apps/website/src/app/blog/[slug]/opengraph-image.tsx

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { ImageResponse } from 'next/og';
22
import { getAllPosts, getPostBySlug } from '../../../lib/blog';
33
import { getAuthor } from '../../../lib/blog-authors';
44
import { loadCardFonts } from '../../og-font';
5+
import { CARD } from '../../card/tokens';
6+
import { Rail, Wordmark } from '../../card/chrome';
57

68
export const runtime = 'nodejs';
79
export const alt = 'Threadplane blog post';
@@ -43,8 +45,8 @@ export default async function og({ params }: Params) {
4345
display: 'flex',
4446
alignItems: 'center',
4547
justifyContent: 'center',
46-
background: '#0b0d12',
47-
color: '#ffffff',
48+
background: CARD.ground,
49+
color: CARD.ink,
4850
fontSize: 64,
4951
}}
5052
>
@@ -55,7 +57,7 @@ export default async function og({ params }: Params) {
5557
);
5658
}
5759

58-
const fonts = await loadCardFonts();
60+
const fonts = await loadCardFonts({ mono: true });
5961
const author = getAuthor(post.frontmatter.author);
6062

6163
return new ImageResponse(
@@ -68,42 +70,37 @@ export default async function og({ params }: Params) {
6870
flexDirection: 'column',
6971
justifyContent: 'space-between',
7072
padding: 64,
71-
background: '#0b0d12',
72-
color: '#ffffff',
73+
background: CARD.ground,
74+
color: CARD.ink,
7375
fontFamily: 'Inter, sans-serif',
7476
}}
7577
>
76-
<div
77-
style={{
78-
fontSize: 24,
79-
textTransform: 'uppercase',
80-
letterSpacing: '0.12em',
81-
opacity: 0.6,
82-
}}
83-
>
84-
Threadplane Blog
85-
</div>
78+
<Rail text="THREADPLANE BLOG" />
8679
<div
8780
style={{
8881
fontFamily: 'EB Garamond, Georgia, serif',
8982
fontSize: 64,
9083
fontWeight: 700,
9184
lineHeight: 1.1,
9285
letterSpacing: '-0.02em',
93-
maxWidth: '90%',
86+
color: CARD.ink,
87+
maxWidth: '92%',
9488
}}
9589
>
9690
{post.frontmatter.title}
9791
</div>
9892
{/*
9993
Satori requires an explicit `display` on any div with more than one
100-
child node, and throws otherwise. This byline has three (name,
101-
separator, date), so the `display: flex` is load-bearing — its
102-
absence is what 500ed every post's card. The two divs above have a
103-
single child each and need no `display`.
94+
child node, and throws otherwise. This row has two (the byline and
95+
the wordmark), and the byline itself has three (name, separator,
96+
date), so both `display: flex` are load-bearing — their absence is
97+
what 500ed every post's card.
10498
*/}
105-
<div style={{ display: 'flex', fontSize: 24, opacity: 0.7 }}>
106-
{author.name} · {post.frontmatter.date}
99+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
100+
<div style={{ display: 'flex', fontSize: 24, color: CARD.inkMuted }}>
101+
{author.name} · {post.frontmatter.date}
102+
</div>
103+
<Wordmark size={30} />
107104
</div>
108105
</div>
109106
),
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { readFileSync, statSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
import { CARD, MIN_READABLE_PX } from './tokens';
5+
import { alt } from '../opengraph-image';
6+
import { HERO_SUBHEAD, PRIMARY_TAGLINE } from '../../lib/positioning';
7+
8+
const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..');
9+
const THEME_CSS = join(REPO_ROOT, 'libs', 'design-tokens', 'src', 'lib', 'theme.css');
10+
11+
/** `rgb(28, 28, 28)` → `#1c1c1c`. Values in theme.css use either form. */
12+
function toHex(value: string): string {
13+
const rgb = value.match(/rgb\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)\s*\)/u);
14+
if (!rgb) return value.trim().toLowerCase();
15+
return `#${[rgb[1], rgb[2], rgb[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
16+
}
17+
18+
function tokenValue(name: string): string {
19+
const css = readFileSync(THEME_CSS, 'utf8');
20+
const match = css.match(new RegExp(`--${name}:\\s*([^;]+);`, 'u'));
21+
if (!match) throw new Error(`token --${name} not found in theme.css`);
22+
return toHex(match[1]);
23+
}
24+
25+
describe('card tokens', () => {
26+
/**
27+
* Satori cannot read CSS variables, so the card palette is a hand-copied
28+
* snapshot of the light design tokens. A snapshot with nothing checking it
29+
* is a snapshot that goes stale silently — the card would keep rendering,
30+
* in last season's colours, and only a human comparing a share preview to
31+
* the live site would ever notice.
32+
*/
33+
it.each([
34+
['ground', CARD.ground, 'color-surface-tinted'],
35+
['dim', CARD.dim, 'color-surface-dim'],
36+
['ink', CARD.ink, 'color-text-primary'],
37+
['inkSecondary', CARD.inkSecondary, 'color-text-secondary'],
38+
['inkMuted', CARD.inkMuted, 'color-text-muted'],
39+
['border', CARD.border, 'color-border'],
40+
['borderStrong', CARD.borderStrong, 'color-border-strong'],
41+
['accent', CARD.accent, 'color-accent'],
42+
])('%s still matches the design token', (_label, resolved, token) => {
43+
expect(resolved.toLowerCase()).toBe(tokenValue(token));
44+
});
45+
});
46+
47+
describe('card fonts', () => {
48+
/**
49+
* These are read off disk at render time. If one goes missing the card does
50+
* not fail — `satoriFonts` drops it and Satori falls back — so a deleted or
51+
* unbuilt face is invisible until someone looks at a card and finds the
52+
* mono eyebrow set in serif. Assert they exist instead.
53+
*/
54+
it.each([
55+
'EBGaramond-Bold.ttf',
56+
'Inter-Regular.ttf',
57+
'Inter-SemiBold.ttf',
58+
'JetBrainsMono-Bold.ttf',
59+
])('%s is bundled', (name) => {
60+
const stat = statSync(join(__dirname, 'fonts', name));
61+
expect(stat.isFile()).toBe(true);
62+
expect(stat.size).toBeGreaterThan(10_000);
63+
});
64+
65+
it('ships static TTFs, not variable ones', () => {
66+
// Satori throws "Cannot read properties of undefined (reading '256')" on a
67+
// variable font, which would 500 the request-time default card. The build
68+
// script strips `fvar`; this asserts the tag is absent from the file.
69+
for (const name of ['EBGaramond-Bold.ttf', 'Inter-Regular.ttf', 'JetBrainsMono-Bold.ttf']) {
70+
const buf = readFileSync(join(__dirname, 'fonts', name));
71+
expect(buf.subarray(0, 2048).includes(Buffer.from('fvar'))).toBe(false);
72+
}
73+
});
74+
});
75+
76+
describe('default card alt text', () => {
77+
it('describes the picture, and quotes the positioning copy rather than retyping it', () => {
78+
expect(alt).toContain(PRIMARY_TAGLINE);
79+
expect(alt).toContain(HERO_SUBHEAD);
80+
// The card's whole claim is that it shows the product stopping for a
81+
// human. Alt text that only names the product would leave a screen-reader
82+
// user with the marketing line and none of the evidence.
83+
expect(alt).toMatch(/Approve and Decline/u);
84+
});
85+
86+
it('keeps a floor for readable type', () => {
87+
expect(MIN_READABLE_PX).toBeGreaterThanOrEqual(18);
88+
});
89+
});

0 commit comments

Comments
 (0)