+
05
State Management & Computed Functions
diff --git a/apps/website/public/whitepapers/render.pdf b/apps/website/public/whitepapers/render.pdf
index 0f0cacb2..3559958d 100644
Binary files a/apps/website/public/whitepapers/render.pdf and b/apps/website/public/whitepapers/render.pdf differ
diff --git a/apps/website/scripts/instance-garamond.py b/apps/website/scripts/instance-garamond.py
new file mode 100644
index 00000000..53e70f4f
--- /dev/null
+++ b/apps/website/scripts/instance-garamond.py
@@ -0,0 +1,66 @@
+"""
+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/scripts/refresh-whitepaper-covers.ts b/apps/website/scripts/refresh-whitepaper-covers.ts
new file mode 100644
index 00000000..4abbccf4
--- /dev/null
+++ b/apps/website/scripts/refresh-whitepaper-covers.ts
@@ -0,0 +1,118 @@
+/**
+ * Refresh whitepaper PDF covers without regenerating chapter content.
+ *
+ * The chapter prose in apps/website/public/whitepaper-preview.html and
+ * apps/website/public/whitepapers/*-preview.html is LLM-generated and
+ * acceptable as-is. Only the cover styling needs to match the new
+ * Statusbrew aesthetic (PR #277 updated the source script; this refreshes
+ * the rendered artifacts surgically without needing ANTHROPIC_API_KEY).
+ *
+ * What it does:
+ * 1. For each preview HTML, find/replace the legacy 4-stop pastel cover
+ * gradient with a per-paper subtle 2-stop tint matching the new tokens.
+ * 2. Update the cover footer color (#888 → #8b8fa3 / textMuted).
+ * 3. Update TOC row colors (rgba(0,0,0,.06) → #e6e8ee / border;
+ * #444 → #555770 / textSecondary).
+ * 4. Write the updated HTML back.
+ * 5. Render to PDF using Puppeteer.
+ *
+ * Usage:
+ * pnpm tsx apps/website/scripts/refresh-whitepaper-covers.ts
+ */
+import { readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import puppeteer from 'puppeteer';
+
+interface Paper {
+ /** Per-paper cover gradient (matches the source updated in PR #277). */
+ newGradient: string;
+ htmlPath: string;
+ pdfPath: string;
+}
+
+const ROOT = join(process.cwd(), 'apps', 'website', 'public');
+
+const PAPERS: Paper[] = [
+ {
+ newGradient: 'linear-gradient(135deg, #fafbfc 0%, #eaf3ff 100%)',
+ htmlPath: join(ROOT, 'whitepaper-preview.html'),
+ pdfPath: join(ROOT, 'whitepaper.pdf'),
+ },
+ {
+ newGradient: 'linear-gradient(135deg, #fafbfc 0%, #eaf3ff 100%)',
+ htmlPath: join(ROOT, 'whitepapers', 'angular-preview.html'),
+ pdfPath: join(ROOT, 'whitepapers', 'angular.pdf'),
+ },
+ {
+ newGradient: 'linear-gradient(135deg, #fafbfc 0%, #e8f5e9 100%)',
+ htmlPath: join(ROOT, 'whitepapers', 'render-preview.html'),
+ pdfPath: join(ROOT, 'whitepapers', 'render.pdf'),
+ },
+ {
+ newGradient: 'linear-gradient(135deg, #fafbfc 0%, #f3e8ff 100%)',
+ htmlPath: join(ROOT, 'whitepapers', 'chat-preview.html'),
+ pdfPath: join(ROOT, 'whitepapers', 'chat.pdf'),
+ },
+];
+
+const LEGACY_GRADIENT_RE =
+ /background:linear-gradient\(135deg,\s*#[0-9a-fA-F]+\s+0%,\s*#[0-9a-fA-F]+\s+45%,\s*#[0-9a-fA-F]+\s+70%,\s*#[0-9a-fA-F]+\s+100%\)/;
+
+function refreshHtml(html: string, paper: Paper): string {
+ let out = html;
+
+ if (!LEGACY_GRADIENT_RE.test(out)) {
+ throw new Error(`No legacy gradient found in ${paper.htmlPath}`);
+ }
+ out = out.replace(LEGACY_GRADIENT_RE, `background:${paper.newGradient}`);
+
+ // Cover footer: #888 → #8b8fa3 (textMuted)
+ out = out.replace(/font-size:13px;color:#888/g, 'font-size:13px;color:#8b8fa3');
+
+ // TOC row: rgba(0,0,0,.06) border → #e6e8ee (border token); #444 text → #555770 (textSecondary)
+ out = out.replace(
+ /border-bottom:1px solid rgba\(0,0,0,\.06\);font-size:15px;color:#444/g,
+ 'border-bottom:1px solid #e6e8ee;font-size:15px;color:#555770',
+ );
+
+ return out;
+}
+
+async function renderPdf(htmlPath: string, pdfPath: string): Promise
{
+ const html = await readFile(htmlPath, 'utf8');
+ const browser = await puppeteer.launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ await page.setContent(html, { waitUntil: 'networkidle0' });
+ await page.pdf({
+ path: pdfPath,
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '0', right: '0', bottom: '0', left: '0' },
+ });
+ } finally {
+ await browser.close();
+ }
+}
+
+async function main(): Promise {
+ for (const paper of PAPERS) {
+ console.log(`\n— ${paper.htmlPath}`);
+ const original = await readFile(paper.htmlPath, 'utf8');
+ const refreshed = refreshHtml(original, paper);
+ if (refreshed === original) {
+ console.log(' (no changes — already on new aesthetic)');
+ } else {
+ await writeFile(paper.htmlPath, refreshed, 'utf8');
+ console.log(' HTML refreshed');
+ }
+ await renderPdf(paper.htmlPath, paper.pdfPath);
+ console.log(` PDF rendered → ${paper.pdfPath}`);
+ }
+ console.log('\n✓ All 4 whitepaper covers refreshed.');
+}
+
+main().catch((err) => {
+ console.error('Refresh failed:', err);
+ process.exit(1);
+});
diff --git a/apps/website/src/app/EBGaramond-Bold.ttf b/apps/website/src/app/EBGaramond-Bold.ttf
new file mode 100644
index 00000000..5106c29b
Binary files /dev/null and b/apps/website/src/app/EBGaramond-Bold.ttf differ
diff --git a/apps/website/src/app/opengraph-image.tsx b/apps/website/src/app/opengraph-image.tsx
index 3e88f28d..1c24eb27 100644
--- a/apps/website/src/app/opengraph-image.tsx
+++ b/apps/website/src/app/opengraph-image.tsx
@@ -7,7 +7,8 @@
*/
import { ImageResponse } from 'next/og';
-export const runtime = 'edge';
+// Node runtime (not edge) so we can read the bundled Garamond TTF off disk.
+export const runtime = 'nodejs';
export const alt = 'Angular Agent Framework — Signal-native streaming for Angular + LangGraph';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
@@ -31,9 +32,35 @@ async function loadFont(family: string, weight: number): 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));
+ 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('opengraph-image: failed to load local Garamond TTF', err);
+ return null;
+ }
+}
+
export default async function OpenGraphImage() {
const [garamondBold, interRegular, interBold, monoBold] = await Promise.all([
- loadFont('EB+Garamond', 700),
+ loadLocalGaramond(),
loadFont('Inter', 400),
loadFont('Inter', 600),
loadFont('JetBrains+Mono', 700),