Skip to content

Commit 7432d6d

Browse files
bloveclaude
andauthored
fix(website): ATC theme follow-ups — favicon, social cards, whitepapers (#1059)
* feat(website): social cards take Archivo Black and Archivo The social card is not rendered by the site's CSS. It is a separate Satori pipeline behind Next's `ImageResponse`, and Satori cannot read woff2 or a `next/font` handle, so the card carries its own TTFs committed under `src/app/card/fonts/`. When the site was rethemed to the ATC language the card's colours were hand-copied across; its fonts were not. Share a link and the preview came back set in EB Garamond while the page it opened was Archivo Black — two products, one link. Rebuilds the bundle onto the site's own faces: Archivo Black for display, Archivo 400/600 for body, JetBrains Mono unchanged for the eyebrow and pills. EB Garamond and both Inter cuts are deleted. Two wrinkles in the build script. Archivo Black is shipped static — it has no `fvar` — so instancing is now conditional rather than unconditional; the instancer does not no-op on a font with no axes. And Archivo's variable source carries a `wdth` axis alongside `wght`, which has to be pinned like the `opsz` pin Inter needed, or variation tables survive into the output and Satori throws at request time. Archivo Black is single-weight, so the `fontWeight: 700` that sat beside every display `fontFamily` is dropped — the same removal the site's CSS made. Verified by rendering, not just by the specs: `/opengraph-image` and a blog card both return 200 image/png at 1200x630 from a built server, set in the new faces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): the whitepapers take the ATC brand These PDFs are the artifact a lead actually downloads from the site's lead form, so leaving them on the old brand meant the document and the site it came from looked like two different products. Display type moves to Archivo Black, body to Archivo, and the old #004090 brand blue to scope navy #15253E, which is the emphasis ink everywhere small text carries it: the cover eyebrow, the chapter eyebrow, the contents numbers, and inline <strong>. Archivo Black ships a single weight and no italic, so nothing pairs it with font-weight or font-style — the same synthesis smear that was swept out of the site's CSS. Aviation yellow appears once per page opener as a short fill under the heading, on the cover, the contents page, and every chapter. It is never type: at 1.84:1 on white it cannot be read. Regenerating the prose would have meant swapping a design change for a content change nobody asked for, so --rerender pours the committed chapters back through the current template without calling the model. It also drops the literal "# Chapter Title" that older runs leaked into the body directly beneath the heading that already said it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): recover the component names the whitepapers had eaten mdToHTML had no inline-code rule and never escaped markup, so every `<chat-message-list>` in the prose reached the reader as an empty pair of backticks — the browser parsed the name as an unknown element, which renders as nothing. Twelve component names were missing from the chat paper alone. It also split paragraphs AFTER building fenced blocks, so any code sample containing a blank line was torn in half and its second half wrapped in a <p>, leaving the sample's own markup unescaped. The Angular template in the chat-debug example lost both of its element lines the same way. The source text survived in the committed HTML, so both are repaired on the way back through --rerender rather than left in a published document with words missing from it. mdToHTML is fixed too, so a fresh generation cannot reintroduce either: fenced blocks are lifted out to placeholders before the paragraph split and escaped exactly once, and inline spans are escaped. Verified by rendering the PDFs and reading them, not by inspecting the HTML. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): favicon, focus halo and the last stale docblocks - icon.svg becomes the yellow squircle with an ink glyph, matching the nav mark. It was still a navy field with a white plane. - --form-focus-ring was retuned to the signal yellow, which composites to 1.19:1 on white — softer than the navy it replaced, so the halo had visibly thinned. Mixed at 55% it reads again while staying the signal colour. The focused control's indicator is its navy border at 15.37:1, so this is the halo around it rather than the indicator itself. - .hero-trust was fully overridden by .hero-strip and did nothing; the class stays on the element for Hero.spec.tsx. - .hero-strip's #ffffff is now explained rather than tokenised: every candidate token is re-pointed by the enclosing signal scope. - PlaneMark's docblock claimed a navy field and an inherited colour, both false. - --color-alert is recorded as reserved with no consumer, so it does not read as an oversight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(website): pin mdToHTML, and fix the bug my own fix introduced The lint error was real: the fence sentinel used NUL, which trips no-control-regex. Swapped for a Private Use Area character, which cannot appear in prose and is not a control character. Writing a test for that then caught a defect in the previous commit. Lifting fenced blocks out to a sentinel meant the paragraph wrapper no longer recognised them — it checks startsWith('<pre') — so the restored block landed inside a <p>. `<p><pre>` is invalid, so the browser auto-closes the paragraph and leaves a stray `</p>`: a milder version of the exact artifact the rewrite existed to remove. The wrapper now treats the sentinel as pre-formatted. mdToHTML has now shipped three separate silent defects into published PDFs, every one of which DELETED text rather than merely misformatting it. It had no spec because the module built an Anthropic client at import time, which throws under jsdom — so the client is now lazy and the helpers are exported and pinned. Seven cases, each one a bug that actually shipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(website): split the whitepaper markdown helpers out so they can be tested `nx test website` failed where `vitest --root apps/website` passed, and nx swallowed the output entirely — the sign of a hard crash during collection rather than a failing assertion. The cause was the spec importing generate-whitepaper.ts, which pulls in puppeteer and the Anthropic SDK at module scope. That is also the reason these functions had no spec in the first place, and therefore the reason three text-DELETING defects reached published PDFs unnoticed. A pure string function should not drag a browser-automation library into a jsdom worker, so mdToHTML and escapeHtml now live in whitepaper-markdown.ts and the generator imports them. Verified the spec genuinely runs rather than being skipped: 137 files / 1382 tests under `nx test website`, with the 7 markdown cases among them. The generator still produces byte-identical HTML. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent aa88fe6 commit 7432d6d

30 files changed

Lines changed: 888 additions & 555 deletions

apps/website/public/whitepaper-preview.html

Lines changed: 128 additions & 120 deletions
Large diffs are not rendered by default.

apps/website/public/whitepaper.pdf

577 KB
Binary file not shown.

apps/website/public/whitepapers/angular-preview.html

Lines changed: 146 additions & 138 deletions
Large diffs are not rendered by default.
572 KB
Binary file not shown.

apps/website/public/whitepapers/chat-preview.html

Lines changed: 88 additions & 80 deletions
Large diffs are not rendered by default.
555 KB
Binary file not shown.

apps/website/public/whitepapers/render-preview.html

Lines changed: 106 additions & 98 deletions
Large diffs are not rendered by default.
615 KB
Binary file not shown.

apps/website/scripts/build-card-fonts.py

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,29 @@
66
Satori (the engine behind Next.js ImageResponse) cannot decode woff2, which is
77
the only format Google Fonts serves, and it crashes on variable-weight TTFs
88
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.
9+
card uses has to reach the renderer as a single static weight, stripped of its
10+
variable tables, and committed.
11+
12+
Every face a card uses is bundled here rather than scraped from the Google
13+
Fonts CSS API at render time. That was a network round trip inside an image
14+
render, and when it failed there was no error — the card silently fell back to
15+
whatever loaded, which is how a card whose eyebrow and pills are specified in
16+
mono came out set in serif. Bundling removes the dependency.
17+
18+
The faces are the site's own: Archivo Black for display type, Archivo for
19+
body, JetBrains Mono for the eyebrow and pills. Archivo Black is shipped by
20+
Google as a *static* font — it has no `fvar` — so instancing is conditional;
21+
running the instancer over it would fail rather than no-op. Archivo's variable
22+
source carries a `wdth` axis alongside `wght`, which has to be pinned too, or
23+
variable tables survive into the output and Satori chokes on them.
1824
1925
The fonts are subsetted to Latin plus the punctuation the site actually uses,
2026
which is what keeps four faces under 150KB total rather than well over 1MB.
2127
Blog post titles are the only unbounded text on a card; anything outside this
2228
range falls back to Satori's bundled Noto Sans rather than failing.
2329
2430
Usage:
25-
pip install --user fonttools brotli
31+
pip install --user fonttools # no brotli: this reads and writes TTF
2632
python3 apps/website/scripts/build-card-fonts.py
2733
2834
Re-run if an upstream font is updated, and commit the result.
@@ -45,18 +51,19 @@
4551

4652
FACES = [
4753
{
48-
"name": "EBGaramond-Bold.ttf",
49-
"url": "https://github.com/google/fonts/raw/main/ofl/ebgaramond/EBGaramond%5Bwght%5D.ttf",
50-
"weight": 700,
54+
# Static — no `fvar`, so `build()` skips instancing for this one.
55+
"name": "ArchivoBlack-Regular.ttf",
56+
"url": "https://github.com/google/fonts/raw/main/ofl/archivoblack/ArchivoBlack-Regular.ttf",
57+
"weight": 400,
5158
},
5259
{
53-
"name": "Inter-Regular.ttf",
54-
"url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
60+
"name": "Archivo-Regular.ttf",
61+
"url": "https://github.com/google/fonts/raw/main/ofl/archivo/Archivo%5Bwdth,wght%5D.ttf",
5562
"weight": 400,
5663
},
5764
{
58-
"name": "Inter-SemiBold.ttf",
59-
"url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
65+
"name": "Archivo-SemiBold.ttf",
66+
"url": "https://github.com/google/fonts/raw/main/ofl/archivo/Archivo%5Bwdth,wght%5D.ttf",
6067
"weight": 600,
6168
},
6269
{
@@ -75,12 +82,20 @@ def build(face: dict) -> None:
7582
raw = tmp.name
7683

7784
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)
85+
# Archivo Black ships static, with no `fvar`. Running the instancer over a
86+
# font with no axes is not a harmless no-op, so only instance when there is
87+
# something to instance. Every *other* axis the source carries has to be
88+
# pinned as well, or its variation tables survive for Satori to trip over.
89+
# Archivo carries `wdth` (pinned to the normal width); `opsz` is handled
90+
# too, since Google ships several text faces with an optical-size axis.
91+
if "fvar" in font:
92+
tags = {a.axisTag for a in font["fvar"].axes}
93+
axes = {"wght": face["weight"]}
94+
if "wdth" in tags:
95+
axes["wdth"] = 100
96+
if "opsz" in tags:
97+
axes["opsz"] = 14
98+
font = instantiateVariableFont(font, axes, updateFontNames=False, inplace=True)
8499
for table in ("fvar", "STAT", "MVAR", "HVAR", "VVAR", "gvar", "cvar", "avar"):
85100
if table in font:
86101
del font[table]

0 commit comments

Comments
 (0)