Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ test('landing page renders hero headline', async ({ page }) => {
await expect(page.locator('.hero-eyebrow')).toContainText('Angular');
});

test('the default social card renders as a PNG', async ({ request }) => {
// The default card is rendered at request time, so a Satori rejection — a
// div with two children and no explicit `display`, a font it cannot parse —
// is a 500 on the live route rather than a build failure. The blog's cards
// are prerendered and already fail the build, so only this one needs a
// runtime check.
const res = await request.get('/opengraph-image');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('image/png');
expect((await res.body()).byteLength).toBeGreaterThan(10_000);
});

test('landing page renders the dark proof band', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#proof-heading')).toBeVisible();
Expand Down
Binary file added apps/website/public/brand/logo-180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/website/public/brand/logo-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/website/public/brand/mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/website/public/favicon.ico
Binary file not shown.
108 changes: 108 additions & 0 deletions apps/website/scripts/build-card-fonts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
Generate the static, subsetted TTFs the social cards render with.

Why this script exists
----------------------
Satori (the engine behind Next.js ImageResponse) cannot decode woff2, which is
the only format Google Fonts serves, and it crashes on variable-weight TTFs
with "Cannot read properties of undefined (reading '256')". So every face a
card uses has to be instanced to a single weight, stripped of its variable
tables, and committed.

Until now only Garamond was bundled (see instance-garamond.py, which this
script supersedes). Inter and JetBrains Mono were scraped from the Google
Fonts CSS API on every card render. That is a network round trip inside an
image render, and when it fails there is no error — the card silently falls
back to whatever loaded, which is how a card whose eyebrow and pills are
specified in mono came out set in serif. Bundling removes the dependency.

The fonts are subsetted to Latin plus the punctuation the site actually uses,
which is what keeps four faces under 150KB total rather than well over 1MB.
Blog post titles are the only unbounded text on a card; anything outside this
range falls back to Satori's bundled Noto Sans rather than failing.

Usage:
pip install --user fonttools brotli
python3 apps/website/scripts/build-card-fonts.py

Re-run if an upstream font is updated, and commit the result.
"""

import os
import tempfile
import urllib.request

from fontTools import subset
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.join(os.path.dirname(HERE), "src", "app", "card", "fonts")

# Basic Latin, Latin-1 Supplement, and the General Punctuation the site uses
# (typographic quotes, en/em dashes, ellipsis, the middot separator).
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"

FACES = [
{
"name": "EBGaramond-Bold.ttf",
"url": "https://github.com/google/fonts/raw/main/ofl/ebgaramond/EBGaramond%5Bwght%5D.ttf",
"weight": 700,
},
{
"name": "Inter-Regular.ttf",
"url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
"weight": 400,
},
{
"name": "Inter-SemiBold.ttf",
"url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
"weight": 600,
},
{
"name": "JetBrainsMono-Bold.ttf",
"url": "https://github.com/google/fonts/raw/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf",
"weight": 700,
},
]


def build(face: dict) -> None:
with tempfile.NamedTemporaryFile(suffix=".ttf", delete=False) as tmp:
print(f" downloading {face['url']}")
with urllib.request.urlopen(face["url"]) as res:
tmp.write(res.read())
raw = tmp.name

font = TTFont(raw)
axes = {"wght": face["weight"]}
# Inter carries an optical-size axis as well; pin it to its text setting so
# instancing leaves no variable tables behind for Satori to trip over.
if "fvar" in font and any(a.axisTag == "opsz" for a in font["fvar"].axes):
axes["opsz"] = 14
font = instantiateVariableFont(font, axes, updateFontNames=False, inplace=True)
for table in ("fvar", "STAT", "MVAR", "HVAR", "VVAR", "gvar", "cvar", "avar"):
if table in font:
del font[table]

options = subset.Options()
options.set(layout_features=["*"], name_IDs=["*"], notdef_outline=True)
subsetter = subset.Subsetter(options=options)
subsetter.populate(unicodes=subset.parse_unicodes(UNICODES))
subsetter.subset(font)

out = os.path.join(OUT_DIR, face["name"])
font.save(out)
os.unlink(raw)
print(f" wrote {face['name']} ({os.path.getsize(out) // 1024}KB)")


def main() -> None:
os.makedirs(OUT_DIR, exist_ok=True)
for face in FACES:
print(f"{face['name']} @ {face['weight']}")
build(face)


if __name__ == "__main__":
main()
66 changes: 0 additions & 66 deletions apps/website/scripts/instance-garamond.py

This file was deleted.

Binary file removed apps/website/src/app/EBGaramond-Bold.ttf
Binary file not shown.
41 changes: 19 additions & 22 deletions apps/website/src/app/blog/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { ImageResponse } from 'next/og';
import { getAllPosts, getPostBySlug } from '../../../lib/blog';
import { getAuthor } from '../../../lib/blog-authors';
import { loadCardFonts } from '../../og-font';
import { CARD } from '../../card/tokens';
import { Rail, Wordmark } from '../../card/chrome';

export const runtime = 'nodejs';
export const alt = 'Threadplane blog post';
Expand Down Expand Up @@ -43,8 +45,8 @@ export default async function og({ params }: Params) {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0b0d12',
color: '#ffffff',
background: CARD.ground,
color: CARD.ink,
fontSize: 64,
}}
>
Expand All @@ -55,7 +57,7 @@ export default async function og({ params }: Params) {
);
}

const fonts = await loadCardFonts();
const fonts = await loadCardFonts({ mono: true });
const author = getAuthor(post.frontmatter.author);

return new ImageResponse(
Expand All @@ -68,42 +70,37 @@ export default async function og({ params }: Params) {
flexDirection: 'column',
justifyContent: 'space-between',
padding: 64,
background: '#0b0d12',
color: '#ffffff',
background: CARD.ground,
color: CARD.ink,
fontFamily: 'Inter, sans-serif',
}}
>
<div
style={{
fontSize: 24,
textTransform: 'uppercase',
letterSpacing: '0.12em',
opacity: 0.6,
}}
>
Threadplane Blog
</div>
<Rail text="THREADPLANE BLOG" />
<div
style={{
fontFamily: 'EB Garamond, Georgia, serif',
fontSize: 64,
fontWeight: 700,
lineHeight: 1.1,
letterSpacing: '-0.02em',
maxWidth: '90%',
color: CARD.ink,
maxWidth: '92%',
}}
>
{post.frontmatter.title}
</div>
{/*
Satori requires an explicit `display` on any div with more than one
child node, and throws otherwise. This byline has three (name,
separator, date), so the `display: flex` is load-bearing — its
absence is what 500ed every post's card. The two divs above have a
single child each and need no `display`.
child node, and throws otherwise. This row has two (the byline and
the wordmark), and the byline itself has three (name, separator,
date), so both `display: flex` are load-bearing — their absence is
what 500ed every post's card.
*/}
<div style={{ display: 'flex', fontSize: 24, opacity: 0.7 }}>
{author.name} · {post.frontmatter.date}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', fontSize: 24, color: CARD.inkMuted }}>
{author.name} · {post.frontmatter.date}
</div>
<Wordmark size={30} />
</div>
</div>
),
Expand Down
89 changes: 89 additions & 0 deletions apps/website/src/app/card/card.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { CARD, MIN_READABLE_PX } from './tokens';
import { alt } from '../opengraph-image';
import { HERO_SUBHEAD, PRIMARY_TAGLINE } from '../../lib/positioning';

const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..');
const THEME_CSS = join(REPO_ROOT, 'libs', 'design-tokens', 'src', 'lib', 'theme.css');

/** `rgb(28, 28, 28)` → `#1c1c1c`. Values in theme.css use either form. */
function toHex(value: string): string {
const rgb = value.match(/rgb\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)\s*\)/u);
if (!rgb) return value.trim().toLowerCase();
return `#${[rgb[1], rgb[2], rgb[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
}

function tokenValue(name: string): string {
const css = readFileSync(THEME_CSS, 'utf8');
const match = css.match(new RegExp(`--${name}:\\s*([^;]+);`, 'u'));
if (!match) throw new Error(`token --${name} not found in theme.css`);
return toHex(match[1]);
}

describe('card tokens', () => {
/**
* Satori cannot read CSS variables, so the card palette is a hand-copied
* snapshot of the light design tokens. A snapshot with nothing checking it
* is a snapshot that goes stale silently — the card would keep rendering,
* in last season's colours, and only a human comparing a share preview to
* the live site would ever notice.
*/
it.each([
['ground', CARD.ground, 'color-surface-tinted'],
['dim', CARD.dim, 'color-surface-dim'],
['ink', CARD.ink, 'color-text-primary'],
['inkSecondary', CARD.inkSecondary, 'color-text-secondary'],
['inkMuted', CARD.inkMuted, 'color-text-muted'],
['border', CARD.border, 'color-border'],
['borderStrong', CARD.borderStrong, 'color-border-strong'],
['accent', CARD.accent, 'color-accent'],
])('%s still matches the design token', (_label, resolved, token) => {
expect(resolved.toLowerCase()).toBe(tokenValue(token));
});
});

describe('card fonts', () => {
/**
* These are read off disk at render time. If one goes missing the card does
* not fail — `satoriFonts` drops it and Satori falls back — so a deleted or
* unbuilt face is invisible until someone looks at a card and finds the
* mono eyebrow set in serif. Assert they exist instead.
*/
it.each([
'EBGaramond-Bold.ttf',
'Inter-Regular.ttf',
'Inter-SemiBold.ttf',
'JetBrainsMono-Bold.ttf',
])('%s is bundled', (name) => {
const stat = statSync(join(__dirname, 'fonts', name));
expect(stat.isFile()).toBe(true);
expect(stat.size).toBeGreaterThan(10_000);
});

it('ships static TTFs, not variable ones', () => {
// Satori throws "Cannot read properties of undefined (reading '256')" on a
// variable font, which would 500 the request-time default card. The build
// script strips `fvar`; this asserts the tag is absent from the file.
for (const name of ['EBGaramond-Bold.ttf', 'Inter-Regular.ttf', 'JetBrainsMono-Bold.ttf']) {
const buf = readFileSync(join(__dirname, 'fonts', name));
expect(buf.subarray(0, 2048).includes(Buffer.from('fvar'))).toBe(false);
}
});
});

describe('default card alt text', () => {
it('describes the picture, and quotes the positioning copy rather than retyping it', () => {
expect(alt).toContain(PRIMARY_TAGLINE);
expect(alt).toContain(HERO_SUBHEAD);
// The card's whole claim is that it shows the product stopping for a
// human. Alt text that only names the product would leave a screen-reader
// user with the marketing line and none of the evidence.
expect(alt).toMatch(/Approve and Decline/u);
});

it('keeps a floor for readable type', () => {
expect(MIN_READABLE_PX).toBeGreaterThanOrEqual(18);
});
});
Loading
Loading