diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 8cec3e0b6..728694006 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -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(); diff --git a/apps/website/public/brand/logo-180.png b/apps/website/public/brand/logo-180.png new file mode 100644 index 000000000..ed9cb6053 Binary files /dev/null and b/apps/website/public/brand/logo-180.png differ diff --git a/apps/website/public/brand/logo-512.png b/apps/website/public/brand/logo-512.png new file mode 100644 index 000000000..4da18563d Binary files /dev/null and b/apps/website/public/brand/logo-512.png differ diff --git a/apps/website/public/brand/mark.svg b/apps/website/public/brand/mark.svg new file mode 100644 index 000000000..d8431da08 --- /dev/null +++ b/apps/website/public/brand/mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/website/public/favicon.ico b/apps/website/public/favicon.ico index 317ebcb23..1ed5bfb12 100644 Binary files a/apps/website/public/favicon.ico and b/apps/website/public/favicon.ico differ diff --git a/apps/website/scripts/build-card-fonts.py b/apps/website/scripts/build-card-fonts.py new file mode 100644 index 000000000..38dd581d2 --- /dev/null +++ b/apps/website/scripts/build-card-fonts.py @@ -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() diff --git a/apps/website/scripts/instance-garamond.py b/apps/website/scripts/instance-garamond.py deleted file mode 100644 index 53e70f4f4..000000000 --- a/apps/website/scripts/instance-garamond.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Generate apps/website/src/app/EBGaramond-Bold.ttf from the upstream EB -Garamond variable font. - -Why this script exists: -- Satori (the engine behind Next.js ImageResponse) crashes on variable-weight - TTFs with "Cannot read properties of undefined (reading '256')". -- Google Fonts only serves Garamond as woff2, which Satori also can't decode. -- So we instance the upstream variable font to a single weight (Bold, 700) - and strip the now-unused variable-font tables, producing a static TTF - Satori parses cleanly. - -The output is committed to the repo and consumed by -apps/website/src/app/opengraph-image.tsx at request time. - -Usage: - pip install --user fonttools - python3 apps/website/scripts/instance-garamond.py - -Re-run if the upstream font is updated. -""" -import os -import tempfile -import urllib.request - -from fontTools.ttLib import TTFont -from fontTools.varLib.instancer import instantiateVariableFont - -UPSTREAM_URL = "https://github.com/google/fonts/raw/main/ofl/ebgaramond/EBGaramond%5Bwght%5D.ttf" -TARGET_WEIGHT = 700 -OUTPUT_PATH = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "src", - "app", - "EBGaramond-Bold.ttf", -) - - -def main() -> None: - print(f"Downloading variable TTF from {UPSTREAM_URL}") - with tempfile.NamedTemporaryFile(suffix=".ttf", delete=False) as tmp: - with urllib.request.urlopen(UPSTREAM_URL) as response: - tmp.write(response.read()) - src_path = tmp.name - - try: - print(f"Instancing to wght={TARGET_WEIGHT}") - font = TTFont(src_path) - static = instantiateVariableFont(font, {"wght": TARGET_WEIGHT}) - - # Drop variable-font tables that no longer serve a purpose and that - # Satori doesn't need. Shaves ~300KB off the file. - for tag in ("STAT", "fvar", "MVAR", "HVAR"): - if tag in static: - del static[tag] - - print(f"Writing {OUTPUT_PATH}") - static.save(OUTPUT_PATH) - size_kb = os.path.getsize(OUTPUT_PATH) // 1024 - print(f"Done — {size_kb} KB") - finally: - os.unlink(src_path) - - -if __name__ == "__main__": - main() diff --git a/apps/website/src/app/EBGaramond-Bold.ttf b/apps/website/src/app/EBGaramond-Bold.ttf deleted file mode 100644 index 5106c29bd..000000000 Binary files a/apps/website/src/app/EBGaramond-Bold.ttf and /dev/null differ diff --git a/apps/website/src/app/blog/[slug]/opengraph-image.tsx b/apps/website/src/app/blog/[slug]/opengraph-image.tsx index c87daff93..5d2faf477 100644 --- a/apps/website/src/app/blog/[slug]/opengraph-image.tsx +++ b/apps/website/src/app/blog/[slug]/opengraph-image.tsx @@ -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'; @@ -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, }} > @@ -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( @@ -68,21 +70,12 @@ 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', }} > -
- Threadplane Blog -
+
{post.frontmatter.title}
{/* 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. */} -
- {author.name} · {post.frontmatter.date} +
+
+ {author.name} · {post.frontmatter.date} +
+
), diff --git a/apps/website/src/app/card/card.spec.ts b/apps/website/src/app/card/card.spec.ts new file mode 100644 index 000000000..6d7ffc6ac --- /dev/null +++ b/apps/website/src/app/card/card.spec.ts @@ -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); + }); +}); diff --git a/apps/website/src/app/card/chrome.tsx b/apps/website/src/app/card/chrome.tsx new file mode 100644 index 000000000..3951da986 --- /dev/null +++ b/apps/website/src/app/card/chrome.tsx @@ -0,0 +1,179 @@ +/** + * The pieces every social card is built from. + * + * Each one mirrors a device the website already uses, so a card and the page + * it opens read as one product: the rail rule and mono eyebrow from + * `SectionHeader`, the `BrowserFrame` chrome with its traffic lights and mono + * URL pill, the `Pill` primitive, and the `LogoMark` wordmark. + * + * Satori rule: every element carries an explicit `display`. A div with more + * than one child and no `display` is rejected at render time, which for the + * request-time default card would be a 500 on the route. + */ +import { CARD } from './tokens'; + +export function Rail({ text }: { text: string }) { + return ( +
+
+
+ {text} +
+
+ ); +} + +/** The paper plane, inlined. Kept identical to `public/brand/mark.svg`. */ +export function Plane({ size, color = CARD.ink }: { size: number; color?: string }) { + return ( + + + + ); +} + +export function Wordmark({ size = 34, color = CARD.ink }: { size?: number; color?: string }) { + return ( +
+ + Threadplane +
+ ); +} + +export function Pills({ runtimes }: { runtimes: string }) { + return ( +
+
+ {runtimes} +
+
+ MIT +
+
+ ); +} + +export function Frame({ width, children }: { width: number; children: React.ReactNode }) { + return ( +
+
+
+
+
+
+ demo.threadplane.ai +
+
+ {children} +
+ ); +} + +/** + * What the frame holds: one ask, one proposal, one decision. + * + * Drawn rather than screenshotted. Every product screenshot we own carries a + * sidebar, a devtools panel or a table of storage paths, none of which survive + * feed scale as anything but grey noise — and cropping one only trades the + * clutter for a fragment. Drawing it means every size here clears + * MIN_READABLE_PX, and the approval, which is the claim the copy makes, is the + * one thing the picture shows. + */ +export function Conversation() { + return ( +
+
+
+ Delete the stale backups. +
+
+
+
3 backups, 86.5 GB.
+
This cannot be undone.
+
+
+
+ Approve +
+
+ Decline +
+
+
+ ); +} diff --git a/apps/website/src/app/card/fonts/EBGaramond-Bold.ttf b/apps/website/src/app/card/fonts/EBGaramond-Bold.ttf new file mode 100644 index 000000000..e8684b9d4 Binary files /dev/null and b/apps/website/src/app/card/fonts/EBGaramond-Bold.ttf differ diff --git a/apps/website/src/app/card/fonts/Inter-Regular.ttf b/apps/website/src/app/card/fonts/Inter-Regular.ttf new file mode 100644 index 000000000..c8aaeaf2a Binary files /dev/null and b/apps/website/src/app/card/fonts/Inter-Regular.ttf differ diff --git a/apps/website/src/app/card/fonts/Inter-SemiBold.ttf b/apps/website/src/app/card/fonts/Inter-SemiBold.ttf new file mode 100644 index 000000000..84f5fb075 Binary files /dev/null and b/apps/website/src/app/card/fonts/Inter-SemiBold.ttf differ diff --git a/apps/website/src/app/card/fonts/JetBrainsMono-Bold.ttf b/apps/website/src/app/card/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 000000000..31b783d24 Binary files /dev/null and b/apps/website/src/app/card/fonts/JetBrainsMono-Bold.ttf differ diff --git a/apps/website/src/app/card/fonts/index.ts b/apps/website/src/app/card/fonts/index.ts new file mode 100644 index 000000000..4cf1868e4 --- /dev/null +++ b/apps/website/src/app/card/fonts/index.ts @@ -0,0 +1,43 @@ +/** + * Reads the bundled card fonts off disk. + * + * This module lives in the same directory as the TTFs on purpose. Next's file + * tracer (`@vercel/nft`) statically evaluates + * `join(dirname(fileURLToPath(import.meta.url)), 'Name.ttf')` and adds the + * file to the traced bundle of every route that reaches this code. It does + * *not* resolve a parent-traversal form, and it cannot resolve a name built + * from a variable — which is why each face below is read by its own function + * with a literal filename rather than through a loop over a list. + * + * The files are produced by `scripts/build-card-fonts.py`: instanced to a + * single weight, stripped of variable tables Satori cannot parse, and subset + * to Latin plus the punctuation the site uses. + */ +import type { OgFont, OgFontWeight } from '../../og-font'; + +async function readSibling(name: string): Promise { + try { + const { fileURLToPath } = await import('node:url'); + const { readFile } = await import('node:fs/promises'); + const { dirname, join } = await import('node:path'); + const buf = await readFile(join(dirname(fileURLToPath(import.meta.url)), name)); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; + } catch (err) { + console.warn(`card/fonts: failed to load ${name}`, err); + return null; + } +} + +/* Each call passes a literal, so the tracer sees four concrete filenames. */ +export const readGaramondBold = () => readSibling('EBGaramond-Bold.ttf'); +export const readInterRegular = () => readSibling('Inter-Regular.ttf'); +export const readInterSemiBold = () => readSibling('Inter-SemiBold.ttf'); +export const readMonoBold = () => readSibling('JetBrainsMono-Bold.ttf'); + +export function toFont( + name: string, + weight: OgFontWeight, + data: ArrayBuffer | null, +): OgFont | null { + return data ? { name, data, weight, style: 'normal' } : null; +} diff --git a/apps/website/src/app/card/tokens.ts b/apps/website/src/app/card/tokens.ts new file mode 100644 index 000000000..8f1d53b1c --- /dev/null +++ b/apps/website/src/app/card/tokens.ts @@ -0,0 +1,45 @@ +/** + * Social-card palette: the site's light surface, resolved to literals. + * + * Satori cannot read CSS variables, so these are copied from the design + * tokens rather than referenced. Sources: + * - libs/design-tokens/src/lib/theme.css (light `--color-*`) + * - apps/website/src/styles/ui.css (BrowserFrame chrome, traffic lights) + * If those change, re-resolve these. + * + * The card is light because the site is: the hero is flat white and every + * docs page is light, so a dark card advertised a product that looked like + * something else the moment the link was opened. + */ +export const CARD = { + /** --color-surface-tinted, the card ground. */ + ground: '#fbfbfb', + /** --color-canvas */ + canvas: '#ffffff', + /** --color-surface-dim, the user bubble. */ + dim: '#f5f5f5', + /** --color-text-primary / secondary / muted */ + ink: '#1c1c1c', + inkSecondary: '#464646', + inkMuted: '#737373', + /** --color-border / --color-border-strong */ + border: '#e5e5e5', + borderStrong: '#c8c8c8', + /** --color-accent and its surface/border tints. */ + accent: '#004090', + accentSurface: 'rgba(0, 64, 144, 0.06)', + accentBorder: 'rgba(0, 64, 144, 0.30)', + /** BrowserFrame traffic lights (ui.css). */ + trafficRed: '#FF5F57', + trafficAmber: '#FEBC2E', + trafficGreen: '#28C840', +} as const; + +/** + * The floor for readable type on a share card. + * + * Timelines render 1200x630 at roughly 500px, about 0.42x, and Slack unfurls + * it narrower still. Below this a glyph stops being read and becomes texture. + * Everything the card needs a human to actually read stays at or above it. + */ +export const MIN_READABLE_PX = 18; diff --git a/apps/website/src/app/icon.svg b/apps/website/src/app/icon.svg new file mode 100644 index 000000000..050ec643c --- /dev/null +++ b/apps/website/src/app/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index d2d5addc7..08dfe1992 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -41,9 +41,6 @@ export const metadata: Metadata = { metadataBase: new URL(SITE_ORIGIN), title: PRIMARY_TAGLINE, description: DEFAULT_META_DESCRIPTION, - icons: { - icon: 'data:image/svg+xml,🛩️', - }, openGraph: { title: 'Threadplane', description: LONG_SUBHEAD, diff --git a/apps/website/src/app/og-font.ts b/apps/website/src/app/og-font.ts index a271a5028..355392c1a 100644 --- a/apps/website/src/app/og-font.ts +++ b/apps/website/src/app/og-font.ts @@ -1,28 +1,11 @@ /** - * Shared font loading for the `opengraph-image` routes. + * Font types and helpers shared by the `opengraph-image` routes. * - * This module lives next to `EBGaramond-Bold.ttf` on purpose. Next's file - * tracer (`@vercel/nft`) statically evaluates `join(dirname(fileURLToPath( - * import.meta.url)), 'EBGaramond-Bold.ttf')` and adds the TTF to the traced - * bundle of every route that reaches this code. It does *not* resolve a - * parent-traversal form like `join(here, '../../EBGaramond-Bold.ttf')`, which - * is how `blog/[slug]/opengraph-image.tsx` used to read the font: locally the - * source tree is on disk so it worked, but the deployed serverless function - * never received the file. Keeping the read in one colocated module means the - * traversal never has to be written again. - * - * EB Garamond is bundled as a static-weight TTF rather than fetched because: - * 1. Google Fonts only serves Garamond as woff2 — Satori can't decode woff2. - * 2. The variable-weight TTF in Google's fonts repo trips Satori's TTF parser - * ("Cannot read properties of undefined (reading '256')") on variable-font - * tables (fvar/STAT/MVAR/HVAR). - * - * The committed TTF was produced by instancing the upstream variable font to - * wght=700 and stripping the now-unused variable tables — see - * apps/website/scripts/instance-garamond.py. The file is ~500KB, served only - * from this server-side render path (never downloaded by browsers). + * The faces themselves are bundled and read by `./card/fonts`, which is + * colocated with the TTFs so Next's file tracer can resolve them. This module + * keeps only the types, the Satori guard rails, and the optional Google Fonts + * fetch for a face we do not bundle. */ - /** The CSS weight domain Satori accepts — wider than the weights we ship. */ export type OgFontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; @@ -34,25 +17,6 @@ export interface OgFont { style: 'normal'; } -/** - * Reads the bundled Garamond TTF. Returns null (never throws) if the file is - * missing so a card without the serif headline still renders. - */ -export async function loadLocalGaramond(): Promise { - try { - const { fileURLToPath } = await import('node:url'); - const { readFile } = await import('node:fs/promises'); - const { dirname, join } = await import('node:path'); - const here = dirname(fileURLToPath(import.meta.url)); - // Keep this a bare sibling filename — see the module comment above. - const buf = await readFile(join(here, 'EBGaramond-Bold.ttf')); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; - } catch (err) { - console.warn('og-font: failed to load bundled Garamond TTF', err); - return null; - } -} - /** * Best-effort Google Fonts fetch. Purely decorative: every caller must stay * renderable when this returns null, because nothing guarantees the render @@ -89,23 +53,32 @@ export function satoriFonts(candidates: (OgFont | null)[]): OgFont[] | undefined } /** - * Loads the shared card font set: bundled Garamond for headlines, Inter for - * body copy, and optionally JetBrains Mono for the eyebrow/pill lettering. + * Loads the shared card font set: Garamond for display type, Inter for body, + * and JetBrains Mono for the eyebrow and pills. + * + * All four are bundled (see `./card/fonts`). They used to be fetched from + * Google Fonts on every render, which is a network round trip inside an image + * render that fails silently: the card simply came out in whichever faces + * happened to load. A card specified with a mono eyebrow rendered in serif + * that way. `loadGoogleFont` is kept for callers that want a face we do not + * bundle, but no card depends on it. * - * Every load is best-effort, so this returns `undefined` (not `[]`) when the - * TTF is missing *and* Google Fonts is unreachable — see `satoriFonts`. + * Returns `undefined` (not `[]`) when nothing loaded — see `satoriFonts`. */ export async function loadCardFonts(options: { mono?: boolean } = {}): Promise { - const [garamondBold, interRegular, interBold, monoBold] = await Promise.all([ - loadLocalGaramond(), - loadGoogleFont('Inter', 400), - loadGoogleFont('Inter', 600), - options.mono ? loadGoogleFont('JetBrains+Mono', 700) : Promise.resolve(null), + const { readGaramondBold, readInterRegular, readInterSemiBold, readMonoBold, toFont } = await import( + './card/fonts' + ); + const [garamond, interRegular, interSemiBold, mono] = await Promise.all([ + readGaramondBold(), + readInterRegular(), + readInterSemiBold(), + options.mono ? readMonoBold() : Promise.resolve(null), ]); return satoriFonts([ - garamondBold && { name: 'EB Garamond', data: garamondBold, weight: 700, style: 'normal' }, - interRegular && { name: 'Inter', data: interRegular, weight: 400, style: 'normal' }, - interBold && { name: 'Inter', data: interBold, weight: 600, style: 'normal' }, - monoBold && { name: 'JetBrains Mono', data: monoBold, weight: 700, style: 'normal' }, + toFont('EB Garamond', 700, garamond), + toFont('Inter', 400, interRegular), + toFont('Inter', 600, interSemiBold), + toFont('JetBrains Mono', 700, mono), ]); } diff --git a/apps/website/src/app/opengraph-image.tsx b/apps/website/src/app/opengraph-image.tsx index d6ece4ba7..cc01b80a0 100644 --- a/apps/website/src/app/opengraph-image.tsx +++ b/apps/website/src/app/opengraph-image.tsx @@ -1,82 +1,40 @@ /** * Default OpenGraph + Twitter share card for the marketing site. * - * Renders a 1200×630 PNG at request time via Next.js ImageResponse. - * Per-route overrides can be added by dropping an `opengraph-image.tsx` - * file in any route folder. + * Renders a 1200x630 PNG at request time via Next.js ImageResponse. Per-route + * overrides can be added by dropping an `opengraph-image.tsx` in any route + * folder; today the blog is the only one that does. * - * DESIGNED FOR FEED SIZE, NOT FOR THE FULL-SIZE PNG. Timelines render this - * around 500px wide and Slack unfurls it narrower still, so every size here is - * chosen against that ~0.42× rendering: the wordmark's 100px lands at 42px, - * the category line's 52px at 22px, the body's 36px at 15px, the runtime - * pill's 34px at 14px. Nothing is below 30px source, because ~12px rendered is - * where text stops being read and starts being texture. + * DESIGNED FOR FEED SIZE. Timelines render this around 500px wide and Slack + * unfurls it narrower, so every size is chosen against that ~0.42x rendering + * and nothing that has to be read falls below `MIN_READABLE_PX`. * - * That budget is the whole design. The card it replaces spent its legibility - * on an 18px eyebrow, three 15px pills and a three-line 26px paragraph — all - * of which dissolved into grey noise at feed scale — and left the product name - * as the smallest type on the card. Four elements is what fits: who this is, - * what it is, what you get, and what it plugs into. - * - * The stack is centred rather than left-aligned like the site's hero, for one - * reason: surfaces that show a share image as a square or 4:3 thumbnail - * centre-crop it, and a left-aligned column loses its first word or two when - * they do. Centred, the product name survives every crop that keeps the middle. - * - * Colours are the production dark-surface tokens resolved to literals, because - * Satori cannot read CSS variables. Sources: - * - apps/website/src/styles/ui.css `[data-ui="section"][data-surface="dark"]` - * (canvas gradient, text ramp, dark-scope accent, accent seam) and - * landing.css `.proof-strip::before` (the radial accent glow) - * - libs/design-tokens/src/lib/theme.css (--color-angular-red) - * If either changes, re-resolve them here. + * The card is the site: its ground, its rail rule and mono eyebrow, its + * BrowserFrame, its pills, its wordmark — see `./card/chrome`. The card this + * replaces was dark, centred, and assembled from a seam and a glow that exist + * nowhere else, so the page it opened looked like a different product. It also + * only ever asserted what Threadplane does. This one shows it: an agent + * proposing an irreversible action and stopping for a human. */ import { ImageResponse } from 'next/og'; import { HERO_H1_LINES, HERO_SUBHEAD, POSITIONING_PROOF_POINTS, PRIMARY_TAGLINE } from '../lib/positioning'; import { loadCardFonts } from './og-font'; +import { CARD } from './card/tokens'; +import { Conversation, Frame, Pills, Rail, Wordmark } from './card/chrome'; -// Node runtime (not edge) so we can read the bundled Garamond TTF off disk. -// Font loading lives in ./og-font so the TTF stays statically traceable. export const runtime = 'nodejs'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; -/** Dark-surface tokens (ui.css) + brand red (theme.css), resolved for Satori. */ -const TOKENS = { - /** --color-canvas gradient on [data-surface="dark"] */ - canvas: 'linear-gradient(180deg, #161616 0%, #0e0e0e 100%)', - /** --color-text-primary rgb(245, 245, 245) */ - textPrimary: '#f5f5f5', - /** --color-text-secondary rgb(200, 200, 200) */ - textSecondary: '#c8c8c8', - /** --color-text-muted rgb(160, 160, 160) */ - textMuted: '#a0a0a0', - /** --color-accent in the dark scope (= --color-accent-light) */ - accent: '#64c3fd', - /** --color-accent-surface rgba(100, 195, 253, 0.08) */ - accentSurface: 'rgba(100, 195, 253, 0.08)', - /** --color-accent-border rgba(100, 195, 253, 0.2) */ - accentBorder: 'rgba(100, 195, 253, 0.2)', - /** --color-border-strong rgb(60, 60, 60) */ - borderStrong: '#3c3c3c', - /** --color-angular-red */ - angularRed: '#DD0031', -} as const; - -/** - * "Threadplane" — taken from the tagline rather than retyped, so the card and - * the can never disagree about the product name. Falls back to the - * whole tagline if the em dash ever goes away. - */ -const BRAND_NAME = PRIMARY_TAGLINE.split('—')[0].trim() || PRIMARY_TAGLINE; /** "LangGraph + AG-UI" — the first proof point is the runtime claim. */ const RUNTIMES = POSITIONING_PROOF_POINTS[0].label; +const EYEBROW = 'OPEN SOURCE · ANGULAR'; -/** Describes what the card actually says, not just the page it links to. */ -export const alt = `${PRIMARY_TAGLINE}. ${HERO_SUBHEAD} Works with ${RUNTIMES}.`; +/** Describes what the card actually shows, not just the page it links to. */ +export const alt = `${PRIMARY_TAGLINE}. ${HERO_SUBHEAD} Beside the copy, a browser frame shows the product pausing for a human: an agent proposes deleting three backups, with Approve and Decline. Works with ${RUNTIMES}.`; export default async function OpenGraphImage() { - const fonts = await loadCardFonts(); + const fonts = await loadCardFonts({ mono: true }); return new ImageResponse( ( @@ -84,156 +42,53 @@ export default async function OpenGraphImage() { style={{ width: '100%', height: '100%', - background: TOKENS.canvas, display: 'flex', - flexDirection: 'column', - color: TOKENS.textPrimary, + background: CARD.ground, fontFamily: 'Inter, sans-serif', position: 'relative', overflow: 'hidden', }} > - {/* - The accent glow the homepage's dark proof band rises behind - (landing.css `.proof-strip::before`), scaled to the card and pooled - above the wordmark. It gives the flat canvas some depth without - putting anything on it that has to be read. - */} - <div - style={{ - display: 'flex', - position: 'absolute', - top: -230, - right: 190, - width: 820, - height: 720, - background: - 'radial-gradient(circle, rgba(100, 195, 253, 0.15) 0%, rgba(100, 195, 253, 0.05) 55%, rgba(100, 195, 253, 0) 75%)', - }} - /> - - {/* - Brand seam. ui.css draws a 1px accent line at every light→dark - section boundary; at feed scale 1px is invisible, so the card states - it at 8px and runs it Angular-red → accent-blue. At thumbnail sizes - where the copy has gone soft it is still a legible brand signal. - */} - <div - style={{ - display: 'flex', - height: 8, - background: `linear-gradient(90deg, ${TOKENS.angularRed} 0%, ${TOKENS.angularRed} 24%, ${TOKENS.accent} 46%, ${TOKENS.accent} 100%)`, - }} - /> - - <div - style={{ - display: 'flex', - flexDirection: 'column', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - textAlign: 'center', - padding: '0 68px', - }} - > - {/* Wordmark. Biggest thing on the card: nobody knows the name yet. */} + <div style={{ display: 'flex', flexDirection: 'column', width: 600, padding: '58px 0 58px 64px', justifyContent: 'center' }}> + <Rail text={EYEBROW} /> <div style={{ display: 'flex', - alignItems: 'center', - gap: 26, + flexDirection: 'column', + marginTop: 22, fontFamily: 'EB Garamond, Georgia, serif', - fontSize: 100, fontWeight: 700, - lineHeight: 1, - letterSpacing: '-0.015em', - color: TOKENS.textPrimary, - }} - > - <span style={{ fontSize: 68 }}>🛩️</span> - <span>{BRAND_NAME}</span> - </div> - - {/* Category, one line: what the thing is. */} - <div - style={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - marginTop: 34, - fontSize: 52, - fontWeight: 600, - lineHeight: 1.15, - letterSpacing: '-0.01em', - color: TOKENS.textPrimary, + fontSize: 60, + lineHeight: 1.04, + letterSpacing: '-0.02em', + color: CARD.ink, }} > {HERO_H1_LINES.map((line) => ( - <div key={line} style={{ display: 'flex' }}>{line}</div> + <div key={line} style={{ display: 'flex' }}> + {line} + </div> ))} </div> - - {/* What you get. Wrapped to two lines on purpose — see the header. */} - <div - style={{ - display: 'flex', - marginTop: 26, - maxWidth: 800, - fontSize: 36, - lineHeight: 1.38, - color: TOKENS.textSecondary, - }} - > + <div style={{ display: 'flex', marginTop: 20, fontSize: 24, lineHeight: 1.45, color: CARD.inkSecondary, maxWidth: 470 }}> {HERO_SUBHEAD} </div> - - {/* Footer: the runtimes, stated loudly, plus the licence. */} - <div - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: 24, - marginTop: 44, - }} - > - <div - style={{ - display: 'flex', - alignItems: 'center', - padding: '12px 26px', - borderRadius: 999, - background: TOKENS.accentSurface, - border: `1px solid ${TOKENS.accentBorder}`, - fontSize: 34, - fontWeight: 600, - color: TOKENS.accent, - }} - > - {RUNTIMES} - </div> - <div - style={{ - display: 'flex', - alignItems: 'center', - padding: '12px 26px', - borderRadius: 999, - border: `1px solid ${TOKENS.borderStrong}`, - fontSize: 34, - fontWeight: 600, - color: TOKENS.textMuted, - }} - > - MIT · open source - </div> + <div style={{ display: 'flex', marginTop: 26 }}> + <Pills runtimes={RUNTIMES} /> </div> + <div style={{ display: 'flex', marginTop: 30 }}> + <Wordmark /> + </div> + </div> + + {/* Absolutely positioned so the frame keeps its size whatever the copy does. */} + <div style={{ display: 'flex', position: 'absolute', top: 150, left: 656 }}> + <Frame width={480}> + <Conversation /> + </Frame> </div> </div> ), - { - ...size, - fonts, - }, + { ...size, fonts }, ); } diff --git a/apps/website/src/components/ui/LogoMark.tsx b/apps/website/src/components/ui/LogoMark.tsx index fa7b9cce5..f324a1957 100644 --- a/apps/website/src/components/ui/LogoMark.tsx +++ b/apps/website/src/components/ui/LogoMark.tsx @@ -1,5 +1,6 @@ import type { HTMLAttributes } from 'react'; import { cn } from '../../lib/cn'; +import { PlaneMark } from './PlaneMark'; type LogoSize = 'sm' | 'md'; @@ -24,7 +25,7 @@ export function LogoMark({ style={style} {...rest} > - <span aria-hidden="true" data-ui="logo-mark-icon">🛩️</span> + <PlaneMark data-ui="logo-mark-icon" /> {iconOnly ? null : <span>Threadplane</span>} </span> ); diff --git a/apps/website/src/components/ui/PlaneMark.tsx b/apps/website/src/components/ui/PlaneMark.tsx new file mode 100644 index 000000000..3d3b3d2fa --- /dev/null +++ b/apps/website/src/components/ui/PlaneMark.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from 'react'; + +/** + * The Threadplane glyph: a paper plane, drawn once and shared. + * + * It replaces the 🛩️ emoji the wordmark used to render. An emoji is a + * different picture on every platform — Apple's is a shaded propeller plane, + * Google's a blue jet — so the brand had no stable mark at all, and the social + * card, the nav and the favicon each showed whatever the viewer's font + * happened to hold. This ships as a path so all three agree. + * + * Filled with `currentColor`, so it takes the wordmark's own color and needs + * no dark-mode variant. The square app-icon form (navy field, knocked-out + * glyph) lives in `src/app/icon.svg`, which browsers request as the favicon. + */ +export function PlaneMark(props: SVGProps<SVGSVGElement>) { + return ( + <svg viewBox="0 0 64 64" fill="none" aria-hidden="true" focusable="false" {...props}> + <path d="M4 34.5 58 6 40 58l-11.5-16.5L36 22 20 37.5z" fill="currentColor" /> + </svg> + ); +} diff --git a/apps/website/src/lib/site-metadata.ts b/apps/website/src/lib/site-metadata.ts index f1271b077..283aa246a 100644 --- a/apps/website/src/lib/site-metadata.ts +++ b/apps/website/src/lib/site-metadata.ts @@ -20,7 +20,7 @@ export const DEFAULT_SOCIAL_IMAGE_META = { url: DEFAULT_SOCIAL_IMAGE, width: 1200, height: 630, - alt: 'Threadplane — the open-source thread-plane for agents. Chat, durable threads, persistence, human approvals, and generative UI for Angular, on LangGraph and AG-UI.', + alt: 'Threadplane — the open-source thread-plane for agents. Beside the tagline, a browser frame shows the product pausing for a human: an agent proposes deleting three backups, with Approve and Decline.', } as const; export { CODING_AGENT_PROMPT, diff --git a/apps/website/src/lib/structured-data.ts b/apps/website/src/lib/structured-data.ts index b057cd736..3661bc0f4 100644 --- a/apps/website/src/lib/structured-data.ts +++ b/apps/website/src/lib/structured-data.ts @@ -36,11 +36,10 @@ export function organizationJsonLd() { '@id': ORGANIZATION_ID, name: SITE_NAME, url: getCanonicalUrl('/'), - // No `logo`: there is no square brand mark in the repo (the in-app LogoMark - // renders an emoji), and the generated social card is a 1200x630 marketing - // image, not a mark — it would satisfy Google's format floor while asserting - // something false about the brand. Restore this property once a real square - // mark ships in `public/logos/`. + // A real square mark now ships at `public/brand/logo-512.png`: the paper + // plane knocked out of a navy field, the same art the favicon rasterises + // from. The social card is deliberately still not used here — it is a + // 1200x630 marketing image, not a mark. description: 'Threadplane builds the Angular UI layer for production agent applications on LangGraph and AG-UI-compatible runtimes.', sameAs: [REPOSITORY_URL, 'https://www.npmjs.com/package/@threadplane/chat'], @@ -54,6 +53,7 @@ export function websiteJsonLd() { '@id': `${getCanonicalUrl('/')}#website`, name: SITE_NAME, url: getCanonicalUrl('/'), + logo: getCanonicalUrl('/brand/logo-512.png'), description: SHORT_POSITIONING_DESCRIPTION, publisher: { '@id': ORGANIZATION_ID }, }; diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index bc467d381..80a71cac0 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -322,14 +322,21 @@ [data-ui="logo-mark"][data-size="md"] { font-size: 16px; } +/* The glyph is an SVG filled with currentColor, so it inherits the wordmark's + * color. Sized in px rather than em: the wordmark is Garamond, whose em box + * would make the plane read smaller beside it than it measures. */ [data-ui="logo-mark-icon"] { - line-height: 1; + display: block; + flex: none; + color: inherit; } [data-ui="logo-mark"][data-size="sm"] > [data-ui="logo-mark-icon"] { - font-size: 18px; + width: 15px; + height: 15px; } [data-ui="logo-mark"][data-size="md"] > [data-ui="logo-mark-icon"] { - font-size: 22px; + width: 18px; + height: 18px; } /* UI primitive — BrowserFrame. diff --git a/docs/superpowers/specs/2026-09-07-social-card-kit-design.md b/docs/superpowers/specs/2026-09-07-social-card-kit-design.md new file mode 100644 index 000000000..5af06f4df --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-social-card-kit-design.md @@ -0,0 +1,64 @@ +# Social card kit: the framed product card + +Date: 2026-09-07. Status: approved in conversation. Supersedes the card design documented in the header of `apps/website/src/app/opengraph-image.tsx`. + +## Problem + +The share card advertises a product that looks nothing like it. The website is hardwired light: the hero is flat white and all 150 docs pages are light, with one dark proof band as the only exception. The card is dark, centred, and built from a thick seam and a radial glow that appear nowhere else on the site. It also uses none of the site's signature devices, and its only product claim is text. + +The card family has drifted besides. There are two generated cards and they share no code: + +| Surface | Ground | Devices | +| --- | --- | --- | +| Default card, all marketing and 150 docs pages | `#161616 → #0e0e0e` | seam, glow, emoji wordmark, pills | +| Blog card, per post | `#0b0d12`, a value used nowhere else | none | + +Two further brand assets are off-palette and carry retired taglines: the README banner (`public/assets/hero.svg`, deep navy with periwinkle, "Production-ready agent UI") and the whitepaper cover (`public/whitepaper-preview.html`, light gradient with `#004090`, "Enterprise Angular Agent UI"). Both are out of scope here and tracked as follow-ups. + +## Decision + +One card kit, on the site's own light surface, showing the product inside the site's own browser frame. + +**Layout** (1200×630, verified by reference render): + +- Ground `#fbfbfb`, the site's tinted surface. +- Left column, 600px wide, 64px gutter: a 92×3 `#1c1c1c` rail rule; a mono uppercase eyebrow at 19px, `0.12em`, in `#004090`; the tagline from `HERO_H1_LINES` in Garamond 700 at 60px, `-0.02em`, on its three lines; `HERO_SUBHEAD` at 24px in `#464646`; the runtime and licence pills; the wordmark. +- The browser frame is absolutely positioned at `top: 150, left: 656, width: 480`, so its size never moves when the copy reflows. Titlebar with the three traffic lights and a mono URL pill reading `demo.threadplane.ai`, then the conversation. +- The frame is fully contained rather than bled off the edge. A bleed showed more product but cut the message mid-word, which reads as broken rather than as a crop. +- The conversation is three beats and nothing else: the user asks to delete the stale backups, the agent answers with the size and the warning, and an Approve/Decline pair waits on a human. + +**The frame is drawn, not screenshotted.** Every screenshot we own carries a sidebar, a devtools panel, or a table of storage paths. Cropping one trades the clutter for a fragment, and at feed scale the remainder is grey noise. Drawing the conversation means every size clears the readable floor and the picture says one thing: an agent proposed an irreversible action and stopped for a human. That is the claim the copy makes, so it is the only thing the frame shows. + +**Brand mark.** The airplane was the system emoji, a different picture on every platform, so the brand had no stable mark. It is replaced by a drawn paper plane shared by every surface: `PlaneMark` for the wordmark in the nav and footer, an inline copy in the card, `src/app/icon.svg` as the square app icon browsers request as the favicon, a regenerated `favicon.ico`, and `public/brand/logo-512.png`, which finally lets the Organization structured data assert a `logo`. + +## Constraints discovered while prototyping + +**Satori cannot render WebP.** Embedding a WebP data URI kills the render worker: no error, no response, the connection simply closes while the server survives. The identical image as PNG renders correctly. Every screenshot in `public/screenshots` is WebP. This is recorded because it will surface again the moment someone reaches for a screenshot in a card, and because the failure gives no clue what went wrong. + +**Two of the three fonts were fetched at render time.** Garamond was bundled; Inter and JetBrains Mono were scraped from the Google Fonts CSS API on every render. That is a network round trip inside an image render, and it fails silently: the card comes out in whichever faces happened to load. It was not theoretical. A prototype rendered its mono eyebrow and pills in serif because that fetch failed, and nothing reported it. + +All four faces are now bundled, built by `scripts/build-card-fonts.py`, which supersedes `instance-garamond.py`. Each is instanced to one weight, stripped of the variable tables Satori cannot parse, and subset to Latin plus the punctuation the site uses. The four together come to 359KB, less than the single unsubsetted Garamond they replace. `loadGoogleFont` stays for a face we do not bundle, but no card depends on it. + +## Structure + +`apps/website/src/app/card/` owns the kit, and both routes render through it: + +- `tokens.ts` — the light-surface literals, resolved from the design tokens because Satori cannot read CSS variables, plus the readable-type floor. +- `chrome.tsx` — `Rail`, `Frame`, `Conversation`, `Pills`, `Wordmark`, `Plane`. Satori-safe: every element carries an explicit `display`. +- `fonts/` — the four TTFs and the module that reads them, colocated so the file tracer resolves each by a literal filename. A loop over a list of names would not trace. + +The default card becomes the framed card. The blog card keeps its title-led layout but is rebuilt on the kit, which moves it onto the site's ground and gives it the wordmark. Per-section docs cards are now cheap and deliberately not built. + +The kit is added to the inline-style rule's ignore list in `eslint.config.mjs`, alongside the two card routes already there, for the same reason: Satori has no stylesheet. + +## Testing + +- `card.spec.ts` asserts every colour in `CARD` still equals the design token it was copied from, parsed out of `theme.css`. A hand-copied palette with nothing checking it goes stale silently: the card keeps rendering, in last season's colours. +- The same spec asserts all four fonts are present and are static rather than variable. A missing face does not fail a render, it degrades one, so nothing else would catch it. +- It also asserts the alt text quotes the positioning copy rather than retyping it, and describes the approval rather than only naming the product. +- An end-to-end check asserts `/opengraph-image` returns 200 with `image/png`. The blog cards are prerendered, so a Satori rejection there already fails the build; the default card is rendered at request time and needs a runtime check. +- Both guards were mutation-tested: breaking a token and removing a font each fail the suite. + +## Out of scope + +The README banner (`public/assets/hero.svg`) and the whitepaper cover (`public/whitepaper-preview.html`), both stale and off-palette, and both still carrying retired taglines. Per-section docs cards. Any change to the tagline or description, which shipped on 2026-09-06. diff --git a/eslint.config.mjs b/eslint.config.mjs index d4947db4d..71de479ab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -108,6 +108,9 @@ export default [ 'apps/website/src/app/opengraph-image.tsx', // NOTE: [slug] would be a glob character class, so match by wildcard. 'apps/website/src/app/blog/*/opengraph-image.tsx', + // The shared card kit both routes render through. Same reason: Satori + // has no stylesheet, so every value is an inline style. + 'apps/website/src/app/card/**/*.tsx', 'apps/website/src/**/*.spec.tsx', ], rules: {