diff --git a/content/posts/free-opus.mdx b/content/posts/free-opus.mdx
index b5c3084..dd70b37 100644
--- a/content/posts/free-opus.mdx
+++ b/content/posts/free-opus.mdx
@@ -406,12 +406,12 @@ the important distinction is that the perfected pipeline had two different cost
without the ~60-70% asset-cache reduction, the proxy share would have been roughly 177-238 MB per account instead, or about $0.67-0.89 per created account at the same proxy rate.
-
-
### was it actually worth it?
so... was all of this actually worth it in the end?
+
+
one live usage snapshot looked like this:
#### usage summary
diff --git a/public/brand/logo.svg b/public/brand/logo.svg
new file mode 100644
index 0000000..0b71c58
--- /dev/null
+++ b/public/brand/logo.svg
@@ -0,0 +1,34 @@
+
+
+
+
diff --git a/public/brand/scythe.svg b/public/brand/scythe.svg
new file mode 100644
index 0000000..0569494
--- /dev/null
+++ b/public/brand/scythe.svg
@@ -0,0 +1,4148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/fonts/anthropic-serif-regular.ttf b/public/fonts/anthropic-serif-regular.ttf
new file mode 100644
index 0000000..a3c37bf
Binary files /dev/null and b/public/fonts/anthropic-serif-regular.ttf differ
diff --git a/public/fonts/anthropic-serif-static.ttf b/public/fonts/anthropic-serif-static.ttf
new file mode 100644
index 0000000..971b4a4
Binary files /dev/null and b/public/fonts/anthropic-serif-static.ttf differ
diff --git a/public/fonts/space-grotesk-400.ttf b/public/fonts/space-grotesk-400.ttf
new file mode 100644
index 0000000..576f9b5
Binary files /dev/null and b/public/fonts/space-grotesk-400.ttf differ
diff --git a/src/app/blog/[slug]/opengraph-image.tsx b/src/app/blog/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..ae27d6a
--- /dev/null
+++ b/src/app/blog/[slug]/opengraph-image.tsx
@@ -0,0 +1,171 @@
+import { ImageResponse } from "next/og";
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { getPostBySlug, getAllPostSlugs } from "@/lib/posts";
+import {
+ DEFAULT_SCYTHE_ROTATION,
+ DEFAULT_SCYTHE_SCALE,
+ DEFAULT_TITLE_SCALE,
+ getOgPreviewTheme,
+ getOgTitleStyle,
+ getScytheFrame,
+ ogLayout,
+} from "@/lib/og";
+import type { FontDefinition } from "@/types/post";
+
+export const dynamic = "force-static";
+
+export async function generateStaticParams() {
+ const slugs = await getAllPostSlugs();
+ return slugs.map((slug) => ({ slug }));
+}
+
+function tintSvg(svg: string, color: string) {
+ return svg
+ .replace(/<\?xml[\s\S]*?\?>/i, "")
+ .replace(//gi, "")
+ .replace(/#000000/gi, color)
+ .replace(/#000\b/gi, color)
+ .replace(/\bblack\b/gi, color);
+}
+
+function cropScytheSvg(svg: string) {
+ return svg.replace(/viewBox="[^"]*"/i, 'viewBox="600 120 6938 2788"');
+}
+
+function svgToDataUri(svg: string) {
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+async function loadOgHeadingFont(font: FontDefinition) {
+ let fontPath: string | null = null;
+
+ if (font.source === "local") {
+ fontPath = font.value === "/fonts/anthropic-serif-regular.woff2"
+ ? path.join(process.cwd(), "public", "fonts", "anthropic-serif-static.ttf")
+ : path.join(process.cwd(), "public", font.value.replace(/^\//, ""));
+ } else if (font.source === "google" && font.family === "Space Grotesk") {
+ fontPath = path.join(process.cwd(), "public", "fonts", "space-grotesk-400.ttf");
+ }
+
+ if (!fontPath) {
+ return null;
+ }
+
+ return fs.readFile(fontPath);
+}
+
+export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const post = await getPostBySlug(slug);
+
+ if (!post) {
+ return new ImageResponse(
+
Not found
,
+ { width: 1200, height: 630 }
+ );
+ }
+
+ const theme = getOgPreviewTheme(post.theme);
+ const headingFont = post.theme.fonts.heading;
+ const [fontData, logoSvg, scytheSvg] = await Promise.all([
+ loadOgHeadingFont(headingFont),
+ fs.readFile(path.join(process.cwd(), "public", "brand", "logo.svg"), "utf8"),
+ fs.readFile(path.join(process.cwd(), "public", "brand", "scythe.svg"), "utf8"),
+ ]);
+
+ const titleStyle = getOgTitleStyle(post.title, DEFAULT_TITLE_SCALE);
+ const scytheFrame = getScytheFrame(1);
+ const logoSrc = svgToDataUri(tintSvg(logoSvg, theme.logo));
+ const scytheSrc = svgToDataUri(tintSvg(cropScytheSvg(scytheSvg), theme.scythe));
+
+ return new ImageResponse(
+ (
+
+
+
+
+
+
+
+ {post.title}
+
+
+ ),
+ {
+ width: ogLayout.width,
+ height: ogLayout.height,
+ fonts: fontData ? [
+ {
+ name: headingFont.family,
+ data: fontData,
+ style: "normal",
+ weight: 400,
+ },
+ ] : [],
+ }
+ );
+}
diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx
index 2ab6cfe..c31ff17 100644
--- a/src/app/blog/[slug]/page.tsx
+++ b/src/app/blog/[slug]/page.tsx
@@ -36,6 +36,13 @@ export async function generateMetadata({
description: post.excerpt,
url: `https://blog.micr.dev/blog/${post.slug}`,
type: "article",
+ images: [
+ {
+ url: `/blog/${post.slug}/opengraph-image`,
+ width: 1200,
+ height: 630,
+ },
+ ],
},
};
}
diff --git a/src/app/og-preview/page.tsx b/src/app/og-preview/page.tsx
new file mode 100644
index 0000000..58d0700
--- /dev/null
+++ b/src/app/og-preview/page.tsx
@@ -0,0 +1,85 @@
+import { getFontStyleSheet } from "@/lib/mdx";
+import {
+ getOgPreviewTheme,
+ type OgPreviewTheme,
+} from "@/lib/og";
+import { getOgBrandAssetUris } from "@/lib/og-assets";
+import { getAllPostSlugs, getPostBySlug } from "@/lib/posts";
+import { PreviewControls } from "@/app/og-preview/preview-controls";
+import {
+ previewGalleryTitle,
+ previewThemePresets,
+} from "@/app/og-preview/preview-presets";
+
+export const metadata = {
+ title: "OG Preview",
+};
+
+async function getPreviewPosts() {
+ const slugs = await getAllPostSlugs();
+ const posts = await Promise.all(slugs.map((slug) => getPostBySlug(slug)));
+
+ return posts.filter((post) => post !== null);
+}
+
+async function getThemeAssets(theme: OgPreviewTheme) {
+ return getOgBrandAssetUris(theme);
+}
+
+function getThemeKey(theme: OgPreviewTheme) {
+ return JSON.stringify(theme);
+}
+
+export default async function OgPreviewPage() {
+ const posts = await getPreviewPosts();
+ const fontCss = [...new Set(posts.map((post) => getFontStyleSheet(post.theme)).filter(Boolean))].join("\n");
+ const uniquePosts = [...new Map(
+ posts.map((post) => {
+ const theme = getOgPreviewTheme(post.theme);
+ return [getThemeKey(theme), { post, theme }] as const;
+ }),
+ ).values()];
+ const liveCards = await Promise.all(
+ uniquePosts.map(async ({ post, theme }) => ({
+ id: `live-${post.slug}`,
+ label: `/blog/${post.slug}`,
+ title: post.title,
+ note: "Actual post theme",
+ theme,
+ assets: await getThemeAssets(theme),
+ })),
+ );
+ const presetCards = await Promise.all(
+ previewThemePresets.map(async (preset) => ({
+ id: preset.id,
+ label: `preset/${preset.id}`,
+ title: previewGalleryTitle,
+ note: preset.name,
+ theme: preset.theme,
+ assets: await getThemeAssets(preset.theme),
+ })),
+ );
+ const cards = [...liveCards, ...presetCards];
+
+ return (
+
+ {fontCss ? : null}
+
+
+
+
+ Internal OG review route
+
+
+ Per-post OG composition preview
+
+
+ One card per theme. Actual post themes are deduped, presets use one shared title, and the previews are scaled down into a compact gallery.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/og-preview/preview-card.tsx b/src/app/og-preview/preview-card.tsx
new file mode 100644
index 0000000..3504e7e
--- /dev/null
+++ b/src/app/og-preview/preview-card.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import {
+ DEFAULT_SCYTHE_ROTATION,
+ DEFAULT_SCYTHE_SCALE,
+ DEFAULT_TITLE_SCALE,
+ getOgTitleStyle,
+ getScytheFrame,
+ ogLayout,
+ type OgPreviewTheme,
+} from "@/lib/og";
+
+export function PreviewCard({
+ title,
+ theme,
+ logoSrc,
+ scytheSrc,
+ titleScale = DEFAULT_TITLE_SCALE,
+ scytheScale = DEFAULT_SCYTHE_SCALE,
+ scytheRotation = DEFAULT_SCYTHE_ROTATION,
+}: {
+ title: string;
+ theme: OgPreviewTheme;
+ logoSrc: string;
+ scytheSrc: string;
+ titleScale?: number;
+ scytheScale?: number;
+ scytheRotation?: number;
+}) {
+ const scytheFrame = getScytheFrame(1);
+
+ return (
+
+
+ {/* Data-URI SVGs are tinted server-side, so Next image optimization is not useful here. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ {title}
+
+
+
+
+ {/* Data-URI SVGs are tinted server-side, so Next image optimization is not useful here. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+ );
+}
diff --git a/src/app/og-preview/preview-controls.tsx b/src/app/og-preview/preview-controls.tsx
new file mode 100644
index 0000000..8d80fe6
--- /dev/null
+++ b/src/app/og-preview/preview-controls.tsx
@@ -0,0 +1,93 @@
+import {
+ DEFAULT_SCYTHE_ROTATION,
+ DEFAULT_SCYTHE_SCALE,
+ DEFAULT_TITLE_SCALE,
+ ogLayout,
+ type OgPreviewTheme,
+} from "@/lib/og";
+import { PreviewCard } from "@/app/og-preview/preview-card";
+
+const GALLERY_SCALE = 0.28;
+
+interface PreviewCardData {
+ id: string;
+ label: string;
+ title: string;
+ note?: string;
+ theme: OgPreviewTheme;
+ assets: {
+ logo: string;
+ scythe: string;
+ };
+}
+
+export function PreviewControls({
+ cards,
+}: {
+ cards: PreviewCardData[];
+}) {
+ return (
+
+
+
+
+ Locked preview values
+
+
+ Title scale: {DEFAULT_TITLE_SCALE.toFixed(2)}x
+
+
+ Scythe scale: {DEFAULT_SCYTHE_SCALE.toFixed(2)}x
+
+
+ Scythe rotation: {DEFAULT_SCYTHE_ROTATION}deg
+
+
+ Title line separation: 3px
+
+
+
+
+
+ {cards.map((card) => (
+
+
+
+ {card.label}
+
+ {card.note ? (
+
+ {card.note}
+
+ ) : null}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/og-preview/preview-presets.ts b/src/app/og-preview/preview-presets.ts
new file mode 100644
index 0000000..9f69be1
--- /dev/null
+++ b/src/app/og-preview/preview-presets.ts
@@ -0,0 +1,120 @@
+import type { OgPreviewTheme } from "@/lib/og";
+
+export interface PreviewThemePreset {
+ id: string;
+ name: string;
+ theme: OgPreviewTheme;
+}
+
+export const previewGalleryTitle = "how i got unlimited claude opus 4.6 for free";
+
+export const previewThemePresets: PreviewThemePreset[] = [
+ {
+ id: "terminal-acid",
+ name: "Terminal Acid",
+ theme: {
+ leftPanel: "#0A1204",
+ rightPanel: "#13260D",
+ logo: "#D9FFD1",
+ title: "#72FD00",
+ scythe: "#F6DD00",
+ fontFamily: "Space Grotesk",
+ },
+ },
+ {
+ id: "warning-tape",
+ name: "Warning Tape",
+ theme: {
+ leftPanel: "#121212",
+ rightPanel: "#2A1A00",
+ logo: "#FFF3B0",
+ title: "#FFD400",
+ scythe: "#FF6A00",
+ fontFamily: "Space Grotesk",
+ },
+ },
+ {
+ id: "cobalt-noise",
+ name: "Cobalt Noise",
+ theme: {
+ leftPanel: "#08111F",
+ rightPanel: "#132744",
+ logo: "#D8E7FF",
+ title: "#7CC4FF",
+ scythe: "#F05DFF",
+ fontFamily: "Space Grotesk",
+ },
+ },
+ {
+ id: "burnt-paper",
+ name: "Burnt Paper",
+ theme: {
+ leftPanel: "#1C120D",
+ rightPanel: "#362018",
+ logo: "#F4DDCE",
+ title: "#FFB38A",
+ scythe: "#FF4D2D",
+ fontFamily: "anthropicSerif",
+ },
+ },
+ {
+ id: "rose-lab",
+ name: "Rose Lab",
+ theme: {
+ leftPanel: "#170B14",
+ rightPanel: "#2E1032",
+ logo: "#FFD9F6",
+ title: "#FF84D8",
+ scythe: "#71FCAA",
+ fontFamily: "anthropicSerif",
+ },
+ },
+ {
+ id: "ivory-night",
+ name: "Ivory Night",
+ theme: {
+ leftPanel: "#101114",
+ rightPanel: "#1A1F29",
+ logo: "#F2EFE8",
+ title: "#FFF7E8",
+ scythe: "#F4A261",
+ fontFamily: "anthropicSerif",
+ },
+ },
+ {
+ id: "cyan-static",
+ name: "Cyan Static",
+ theme: {
+ leftPanel: "#041419",
+ rightPanel: "#08242D",
+ logo: "#D8FBFF",
+ title: "#6BF2FF",
+ scythe: "#FF5D8F",
+ fontFamily: "Space Grotesk",
+ },
+ },
+ {
+ id: "ash-violet",
+ name: "Ash Violet",
+ theme: {
+ leftPanel: "#15131C",
+ rightPanel: "#221E30",
+ logo: "#F2E8FF",
+ title: "#C7A6FF",
+ scythe: "#FFD166",
+ fontFamily: "anthropicSerif",
+ },
+ },
+ {
+ id: "monochrome-signal",
+ name: "Monochrome Signal",
+ theme: {
+ leftPanel: "#0B0B0C",
+ rightPanel: "#19191B",
+ logo: "#F5F5F5",
+ title: "#FFFFFF",
+ scythe: "#FF3B30",
+ fontFamily: "Space Grotesk",
+ },
+ },
+] as const;
diff --git a/src/lib/og-assets.ts b/src/lib/og-assets.ts
new file mode 100644
index 0000000..728a042
--- /dev/null
+++ b/src/lib/og-assets.ts
@@ -0,0 +1,45 @@
+import "server-only";
+import { cache } from "react";
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import type { OgPreviewTheme } from "@/lib/og";
+
+const BRAND_ASSET_PATHS = {
+ logo: path.join(process.cwd(), "public", "brand", "logo.svg"),
+ scythe: path.join(process.cwd(), "public", "brand", "scythe.svg"),
+} as const;
+
+const SCYTHE_CROP_VIEWBOX = "600 120 6938 2788";
+
+const readBrandSvg = cache(async (asset: keyof typeof BRAND_ASSET_PATHS) => {
+ return fs.readFile(BRAND_ASSET_PATHS[asset], "utf8");
+});
+
+function tintSvg(svg: string, color: string) {
+ return svg
+ .replace(/<\?xml[\s\S]*?\?>/i, "")
+ .replace(//gi, "")
+ .replace(/#000000/gi, color)
+ .replace(/#000\b/gi, color)
+ .replace(/\bblack\b/gi, color);
+}
+
+function cropScytheSvg(svg: string) {
+ return svg.replace(/viewBox="[^"]*"/i, `viewBox="${SCYTHE_CROP_VIEWBOX}"`);
+}
+
+function svgToDataUri(svg: string) {
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+export async function getOgBrandAssetUris(theme: OgPreviewTheme) {
+ const [logoSvg, scytheSvg] = await Promise.all([
+ readBrandSvg("logo"),
+ readBrandSvg("scythe"),
+ ]);
+
+ return {
+ logo: svgToDataUri(tintSvg(logoSvg, theme.logo)),
+ scythe: svgToDataUri(tintSvg(cropScytheSvg(scytheSvg), theme.scythe)),
+ };
+}
diff --git a/src/lib/og.tsx b/src/lib/og.tsx
new file mode 100644
index 0000000..082899b
--- /dev/null
+++ b/src/lib/og.tsx
@@ -0,0 +1,95 @@
+import type { CSSProperties } from "react";
+import { postHeadingStyle } from "@/lib/post-heading";
+import type { PostTheme } from "@/types/post";
+
+const OG_WIDTH = 1200;
+const OG_HEIGHT = 630;
+const LEFT_PANEL_RATIO = 0.6727;
+const RIGHT_PANEL_RATIO = 0.3273;
+
+const SCYTHE_VIEWBOX = {
+ width: 8276,
+ height: 3015,
+} as const;
+
+export const DEFAULT_TITLE_SCALE = 0.75;
+export const DEFAULT_SCYTHE_SCALE = 1.51;
+export const DEFAULT_SCYTHE_ROTATION = -100;
+
+const titleStyleByLength = [
+ { maxLength: 34, fontSize: 112 },
+ { maxLength: 52, fontSize: 98 },
+ { maxLength: 72, fontSize: 86 },
+ { maxLength: 96, fontSize: 76 },
+ { maxLength: Number.POSITIVE_INFINITY, fontSize: 68 },
+] as const;
+
+export const ogLayout = {
+ width: OG_WIDTH,
+ height: OG_HEIGHT,
+ leftPanelWidth: OG_WIDTH * LEFT_PANEL_RATIO,
+ rightPanelWidth: OG_WIDTH * RIGHT_PANEL_RATIO,
+ logo: {
+ left: 78,
+ top: 88,
+ width: 60,
+ height: 55,
+ },
+ title: {
+ left: 59,
+ top: 250,
+ width: 674,
+ },
+} as const;
+
+export interface OgPreviewTheme {
+ leftPanel: string;
+ rightPanel: string;
+ logo: string;
+ title: string;
+ scythe: string;
+ fontFamily: string;
+}
+
+export function getOgPreviewTheme(theme: PostTheme): OgPreviewTheme {
+ return {
+ leftPanel: theme.colors.background,
+ rightPanel: theme.colors.codeBackground,
+ logo: theme.colors.body,
+ title: theme.colors.heading,
+ scythe: theme.colors.accent,
+ fontFamily: theme.fonts.heading.family,
+ };
+}
+
+export function getOgTitleStyle(title: string, titleScale = DEFAULT_TITLE_SCALE): CSSProperties {
+ const baseFontSize = titleStyleByLength.find((entry) => title.length <= entry.maxLength)?.fontSize ?? 68;
+ const fontSize = Math.round(baseFontSize * titleScale);
+
+ return {
+ position: "absolute",
+ left: ogLayout.title.left,
+ top: ogLayout.title.top,
+ width: ogLayout.title.width,
+ margin: 0,
+ color: "inherit",
+ fontFamily: "inherit",
+ fontSize,
+ fontWeight: postHeadingStyle.fontWeight,
+ lineHeight: "calc(0.99em + 3px)",
+ letterSpacing: "-0.05em",
+ whiteSpace: "pre-wrap",
+ };
+}
+
+export function getScytheFrame(scytheScale: number) {
+ const scale = Math.max(
+ ogLayout.rightPanelWidth / SCYTHE_VIEWBOX.height,
+ ogLayout.height / SCYTHE_VIEWBOX.width,
+ ) * scytheScale;
+
+ return {
+ width: SCYTHE_VIEWBOX.width * scale,
+ height: SCYTHE_VIEWBOX.height * scale,
+ };
+}
diff --git a/src/lib/post-heading.ts b/src/lib/post-heading.ts
new file mode 100644
index 0000000..ae58c9b
--- /dev/null
+++ b/src/lib/post-heading.ts
@@ -0,0 +1,4 @@
+export const postHeadingStyle = {
+ fontFamily: "var(--font-post-heading)",
+ fontWeight: 320,
+} as const;