Phase 8: Asset Pipeline
Total Size: M + L + L + S
Prerequisites: Phases 1–7 complete (every primitive + every page surface is brand-correct)
New Types: None
New Files: public/favicon.svg, public/apple-touch-icon.png, public/android-chrome-{192,512}.png, public/fonts/{Cinzel-Black,Inter-Regular,Inter-SemiBold,Inter-Bold}.ttf, src/app/opengraph-image.tsx, src/app/episodes/[slug]/opengraph-image.tsx, src/app/cases/[slug]/opengraph-image.tsx, src/app/(internal)/thumb-preview/page.tsx
Phase 8 ships the brand beyond the site itself. The logo SVG + favicon
suite tighten the browser-chrome impression; the dynamic opengraph-image
routes carry the brand into every social-share preview; the dev-only
/thumb-preview route gives the user a 1280×720 brand-correct canvas for
capturing YouTube thumbnails by hand. Phase 8.4 finalizes the
design-system doc with the final asset paths and tokens.
Feature 8.1: Logo SVG suite + favicon set
Complexity: M — Ship public/logo.svg (from Phase 4.1) alongside a
favicon set: favicon.svg, apple-touch-icon.png (180×180),
android-chrome-192.png, android-chrome-512.png. Wire them via the
metadata.icons field in src/app/layout.tsx so Next.js renders the right
<link rel> tags.
Problem
The browser tab favicon is a small but persistent brand surface — and right now there's nothing. iOS users adding to home screen need an apple-touch-icon. Android PWA install (if it ever happens) needs the 192/512 raster set. The logo SVG itself was shipped in Phase 4.1; this section completes the asset set and wires it up.
Implementation
NEW public/favicon.svg — square 32×32 SVG with just the {10x}
mark in JetBrains Mono on a Marble background. The wordmark is too
horizontal for favicon use.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" fill="#F7F4EC"/>
<text x="16" y="22" text-anchor="middle" font-family="JetBrains Mono, monospace" font-weight="500" font-size="14" fill="#6B2020">{10x}</text>
</svg>
NEW public/apple-touch-icon.png (180×180) — rendered from the
favicon SVG at 180×180, committed as PNG. Pre-rendered (no build step)
because Next.js serves it as a static asset from /public/.
NEW public/android-chrome-192.png, public/android-chrome-512.png
— same SVG rendered at those sizes.
MODIFY src/app/layout.tsx — add the icons field to metadata:
export const metadata: Metadata = {
metadataBase: new URL('https://fabled10x.com'),
title: { default: 'fabled10x', template: '%s · fabled10x' },
description: 'One person. An agent team. Full SaaS delivery.',
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/android-chrome-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/android-chrome-512.png', sizes: '512x512', type: 'image/png' },
],
apple: [
{ url: '/apple-touch-icon.png', sizes: '180x180' },
],
},
// existing openGraph, twitter, robots ...
};
NEW public/fonts/Cinzel-Black.ttf, public/fonts/Inter-Regular.ttf,
public/fonts/Inter-SemiBold.ttf, public/fonts/Inter-Bold.ttf — local
TTF files needed by Phase 8.2's ImageResponse rendering. Fetch from the
Google Fonts release (Cinzel: SIL OFL; Inter: SIL OFL) and commit. Sizes
are small (Cinzel Black ~50KB, Inter regular ~300KB each); committing
beats fetching at runtime.
Design Decisions
- Favicon is the
{10x}mark, not the wordmark. A 32×32 wordmark is unreadable. The curly-brace mark stands alone, and a developer reading the tab sees{10x}immediately. - Marble background on the favicon. Some browsers render favicons inverted in dark-mode tab strips. The brand has no dark mode, so the favicon presents marble — and dark-mode browser chrome that inverts it reads as Shadow + Oxblood, which is also brand-correct by accident.
- Pre-rendered PNGs, not generated. Apple touch icons + Android
Chrome icons could be generated by a build step (e.g.
sharp), but three sizes × one master = three files committed once. The design-system doc (8.4) lists the regeneration source. - Local TTFs in
public/fonts/.next/og'sImageResponsecannot consumenext/font/googledirectly — it needsfetchable font data. Committed TTFs are the simplest path (Phase 8.2 loads them viafs/promisesin the route handler). - No favicon.ico. Next.js 16's metadata.icons handles modern
browsers via SVG; the legacy
.icois unneeded.
Files
| Action | File |
|---|---|
| MODIFY | src/app/layout.tsx (icons metadata) |
| NEW | public/favicon.svg |
| NEW | public/apple-touch-icon.png |
| NEW | public/android-chrome-192.png |
| NEW | public/android-chrome-512.png |
| NEW | public/fonts/Cinzel-Black.ttf |
| NEW | public/fonts/Inter-Regular.ttf |
| NEW | public/fonts/Inter-SemiBold.ttf |
| NEW | public/fonts/Inter-Bold.ttf |
Feature 8.2: Dynamic og:images
Complexity: L — Three opengraph-image.tsx routes using Next.js'
next/og ImageResponse to render brand-consistent 1200×630 social-share
previews: a root version for all-other-pages, per-episode, per-case. Each
composition shows the brushstroke-seam at scale, the title in Cinzel, the
series/tier label in Oxblood Inter, and a DropAccent glyph.
Problem
When fabled10x.com links are shared on Twitter, LinkedIn, Bluesky, or
preview-aware messengers, the default OG image is the static
/og-default.png referenced in layout.tsx. Without a dynamic image,
every shared episode shows the same generic preview. Dynamic og:images
make every shared link feel composed for that page — a strong brand
surface that travels off-site.
Implementation
NEW src/app/opengraph-image.tsx — root og:image, used for any
route that doesn't define its own. Renders the channel-level composition.
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export const alt = 'Fabled 10X — One person. An agent team.';
export default async function OgImage() {
const cinzel = await readFile(join(process.cwd(), 'public/fonts/Cinzel-Black.ttf'));
const interBold = await readFile(join(process.cwd(), 'public/fonts/Inter-Bold.ttf'));
const interRegular = await readFile(join(process.cwd(), 'public/fonts/Inter-Regular.ttf'));
return new ImageResponse(
(
<div
style={{
display: 'flex',
width: '100%', height: '100%',
background: '#2A2520', // Shadow
}}
>
{/* Left marble panel — 60% width */}
<div
style={{
display: 'flex', flexDirection: 'column',
width: '60%', height: '100%',
background: '#F7F4EC',
padding: '64px 80px',
justifyContent: 'space-between',
// Brushstroke feather on right edge — emulated with a clip-path
clipPath: 'polygon(0 0, 92% 0, 100% 6%, 96% 18%, 100% 28%, 94% 42%, 100% 56%, 95% 70%, 100% 82%, 93% 94%, 100% 100%, 0 100%)',
}}
>
<span style={{ fontFamily: 'Inter Bold', fontSize: 28, color: '#6B2020', letterSpacing: 6, textTransform: 'uppercase' }}>
The Fabled 10X Developer
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontFamily: 'Cinzel', fontSize: 88, color: '#1C1814', letterSpacing: 3, textTransform: 'uppercase', lineHeight: 1.05 }}>
Build the whole
</span>
<span style={{ fontFamily: 'Cinzel', fontSize: 88, color: '#1C1814', letterSpacing: 3, textTransform: 'uppercase', lineHeight: 1.05 }}>
thing alone<span style={{ color: '#6B2020', fontSize: 132 }}>?</span>
</span>
</div>
<span style={{ fontFamily: 'Inter Regular', fontSize: 24, color: '#1C1814', opacity: 0.7 }}>
fabled10x.com
</span>
</div>
</div>
),
{
...size,
fonts: [
{ name: 'Cinzel', data: cinzel, weight: 900 },
{ name: 'Inter Bold', data: interBold, weight: 700 },
{ name: 'Inter Regular', data: interRegular, weight: 400 },
],
}
);
}
NEW src/app/episodes/[slug]/opengraph-image.tsx:
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getEpisodeBySlug } from '@/lib/content/episodes';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
interface Params { params: Promise<{ slug: string }> }
export default async function OgImage({ params }: Params) {
const { slug } = await params;
const ep = await getEpisodeBySlug(slug);
if (!ep) {
// Fallback to channel OG composition (root og handles this anyway via Next routing)
return new ImageResponse((<div />), { ...size });
}
const [cinzel, interBold, interRegular] = await Promise.all([
readFile(join(process.cwd(), 'public/fonts/Cinzel-Black.ttf')),
readFile(join(process.cwd(), 'public/fonts/Inter-Bold.ttf')),
readFile(join(process.cwd(), 'public/fonts/Inter-Regular.ttf')),
]);
return new ImageResponse(
(
<div style={{ display: 'flex', width: '100%', height: '100%', background: '#2A2520' }}>
<div
style={{
display: 'flex', flexDirection: 'column',
width: '62%', height: '100%',
background: '#F7F4EC',
padding: '56px 72px',
justifyContent: 'space-between',
clipPath: 'polygon(0 0, 92% 0, 100% 6%, 96% 18%, 100% 28%, 94% 42%, 100% 56%, 95% 70%, 100% 82%, 93% 94%, 100% 100%, 0 100%)',
}}
>
<span style={{ fontFamily: 'Inter Bold', fontSize: 22, color: '#6B2020', letterSpacing: 5, textTransform: 'uppercase' }}>
{ep.series} · Episode {ep.episode}
</span>
<span style={{
fontFamily: 'Cinzel', fontSize: 72, color: '#1C1814',
letterSpacing: 2.5, textTransform: 'uppercase', lineHeight: 1.08
}}>
{ep.title}<span style={{ color: '#6B2020', fontSize: 108 }}>?</span>
</span>
<span style={{ fontFamily: 'Inter Regular', fontSize: 20, color: '#1C1814', opacity: 0.7 }}>
fabled10x.com
</span>
</div>
</div>
),
{
...size,
fonts: [
{ name: 'Cinzel', data: cinzel, weight: 900 },
{ name: 'Inter Bold', data: interBold, weight: 700 },
{ name: 'Inter Regular', data: interRegular, weight: 400 },
],
}
);
}
NEW src/app/cases/[slug]/opengraph-image.tsx:
Same shape as episodes, with case-appropriate vocabulary: tag reads
Case · {client}, DropAccent uses . instead of ?, title from the
case record.
Design Decisions
- Brushstroke approximated with
clip-path: polygon(...).ImageResponseruns Satori under the hood, which doesn't support SVGfeTurbulenceor CSSmask-image. A hand-tuned polygon with irregular vertices reads as brushstroke at OG-image resolution. It's the one place the brushstroke is geometric, and only because the renderer can't do better — at OG scale (1200×630) the eye won't catch it. - All three routes load TTFs at request time.
ImageResponseis per-request;readFileis fast enough (50–300KB files cached by the OS). Could move to ISR if shared lots, but Next 16's default behavior is to cacheopengraph-imageoutputs anyway. - Title size scales by length. Episodes and cases have variable
title length; the code above uses a fixed 72px. A real implementation
would
clampbased on title length (40+ chars drops to 60px, 70+ to 48px). Phase 8.2's/discoveryshould decide the exact scale. ?accent for episodes,.for cases. Same vocabulary as in-site pages (Phases 7.2 / 7.3). Brand consistency between in-site headline and og:image headline matters.- No images in OG composition. Per spec the right (Shadow) zone would be a photo on a real homepage hero. At OG scale, photos clutter the composition; Shadow flat fill with the marble headline panel reads cleanly even at LinkedIn's small preview crop.
Files
| Action | File |
|---|---|
| NEW | src/app/opengraph-image.tsx |
| NEW | src/app/episodes/[slug]/opengraph-image.tsx |
| NEW | src/app/cases/[slug]/opengraph-image.tsx |
Feature 8.3: Dev-only /thumb-preview route
Complexity: L — src/app/(internal)/thumb-preview/page.tsx renders
a 1280×720 brand-consistent YouTube thumbnail composition driven by query
string. The route is gated behind process.env.NODE_ENV === 'development'
so it ships nowhere near production. The user captures screenshots from
this route to upload to YouTube.
Problem
Producing branded YouTube thumbnails today is manual — every episode
needs a fresh composition with the brushstroke seam, Cinzel title,
Oxblood series tag, DropAccent glyph. Designing each one from scratch in
Figma fragments the brand. A dev-only preview route that takes
?title=...&series=...&tier=...&accent=? as input and renders the canonical
1280×720 composition turns thumbnail production into "open URL, screenshot,
upload." It's the brand's offsite reach with the same discipline as the
site itself.
Implementation
NEW src/app/(internal)/thumb-preview/page.tsx:
import { notFound } from 'next/navigation';
import { BrushstrokeSeam } from '@/components/brand/BrushstrokeSeam';
import { DropAccent, type AccentGlyph } from '@/components/brand/DropAccent';
import Image from 'next/image';
export const dynamic = 'force-dynamic'; // query-string driven; never static
const ALLOWED_GLYPHS: AccentGlyph[] = ['?', '.', '!', '→', '✕', '✓', '—'];
interface Search {
searchParams: Promise<{
title?: string;
series?: string;
tier?: string;
accent?: string;
photo?: string;
}>;
}
export default async function ThumbPreviewPage({ searchParams }: Search) {
if (process.env.NODE_ENV !== 'development') return notFound();
const sp = await searchParams;
const title = sp.title ?? 'Build the whole thing alone';
const series = sp.series ?? 'Zero to 10x';
const tier = sp.tier ?? 'FLAGSHIP';
const accentRaw = sp.accent ?? '?';
const accent = (ALLOWED_GLYPHS as string[]).includes(accentRaw)
? (accentRaw as AccentGlyph)
: '?';
const photo = sp.photo ?? '/hero/marble-column.jpg';
return (
<div
className="bg-(--color-shadow)"
style={{ width: '1280px', height: '720px', overflow: 'hidden' }}
>
<BrushstrokeSeam
direction="left"
feather="12rem"
foreground="var(--color-marble)"
background="var(--color-shadow)"
backgroundContent={
<Image src={photo} alt="" fill className="object-cover opacity-95" />
}
>
<div
style={{
width: '1280px', height: '720px',
padding: '72px 88px',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
}}
>
<div className="flex items-center gap-(--space-4)">
<span className="label text-(--color-oxblood) text-2xl">{tier}</span>
<span className="mono text-(--color-ink)">·</span>
<span className="label text-(--color-ink) text-xl">{series}</span>
</div>
<h1
className="display-1"
style={{ fontSize: '120px', lineHeight: 1.02, maxWidth: '900px' }}
>
<DropAccent glyph={accent} size="thumbnail">{title}</DropAccent>
</h1>
<span className="label text-(--color-ink) opacity-70">fabled10x.com</span>
</div>
</BrushstrokeSeam>
</div>
);
}
Provide an internal "index" route src/app/(internal)/thumb-preview/index/page.tsx
(or document in the design-system doc) listing example URLs for common
accents and titles so the user has copy-pasteable starting points.
Design Decisions
process.env.NODE_ENVgate. Returns 404 in production. The route is a tool, not a product surface. If a future episode wants public preview-art for embed (e.g. on the episode page), build that surface on/episodes/[slug]/previewdeliberately, not by un-gating this.(internal)route group. Next.js route groups: parentheses-wrapped segment is logical grouping without affecting URL. The group also signals "not for users" in the file tree.- Query-string driven, no form UI. Simpler than building a thumb
editor. The user already has the episode title / series in
pipeline/active/session.yaml; copy-paste into URL, screenshot, done. - Fixed 1280×720 viewport via
width/heightstyle. No responsive behavior; the route exists for one screenshot size. The user's browser window can be any size, the composition renders at 1280×720. force-dynamic. Query-string driven; no static caching.- Accent glyph validation. Only allowed glyphs render. Unknown
glyphs default to
?— the brand's catch-all opening question.
Files
| Action | File |
|---|---|
| NEW | src/app/(internal)/thumb-preview/page.tsx |
| NEW (optional) | src/app/(internal)/thumb-preview/index/page.tsx (example URLs) |
Feature 8.4: Design-system doc finalize
Complexity: S — Update docs/fabled10x-design-system.md (created in
Phase 1.3) with the final token values, font file paths, asset paths,
opengraph-image route paths, and thumb-preview usage. Anchor the doc as
the canonical reference future contributors land on first.
Problem
Phase 1.3 created the design-system doc with placeholder values where the implementation hadn't landed yet (final font paths, og:image routes, thumb-preview URL conventions). Phase 8.4 fills those in and finalizes the doc.
Implementation
MODIFY docs/fabled10x-design-system.md:
Add / update sections:
## Asset paths
| Asset | Path |
|-------|------|
| Logo SVG (wordmark) | `public/logo.svg` |
| Favicon SVG | `public/favicon.svg` |
| Apple touch icon | `public/apple-touch-icon.png` (180×180) |
| Android Chrome 192 | `public/android-chrome-192.png` |
| Android Chrome 512 | `public/android-chrome-512.png` |
| Hero texture (default) | `public/hero/marble-column.jpg` |
| Cinzel Black TTF | `public/fonts/Cinzel-Black.ttf` |
| Inter Regular TTF | `public/fonts/Inter-Regular.ttf` |
| Inter SemiBold TTF | `public/fonts/Inter-SemiBold.ttf` |
| Inter Bold TTF | `public/fonts/Inter-Bold.ttf` |
## Off-site brand surfaces
### Open Graph images
Three dynamic `opengraph-image.tsx` routes render 1200×630 brand-consistent
social-share previews:
- `src/app/opengraph-image.tsx` — channel-level, used as fallback for any
route without a per-page og:image.
- `src/app/episodes/[slug]/opengraph-image.tsx` — per-episode, headline =
episode title, accent = `?`.
- `src/app/cases/[slug]/opengraph-image.tsx` — per-case, headline = case
title, accent = `.`.
Composition: brushstroke-seam approximated with `clip-path: polygon(...)`
(Satori doesn't support `mask-image` or SVG filters). Marble panel on
the left contains tag (Oxblood Inter), headline (Cinzel Ink), DropAccent
glyph (Oxblood Cinzel oversize). Background is flat Shadow.
### YouTube thumbnail composer
Dev-only route: `/thumb-preview?title=...&series=...&tier=...&accent=?`.
Renders the canonical 1280×720 brand composition. Capture by screenshot.
404s in production.
Example URL: `http://localhost:3000/thumb-preview?title=Ship%20it%20alone&series=Zero%20to%2010x&tier=FLAGSHIP&accent=?`
Allowed accent glyphs: `? . ! → ✕ ✓ —`.
## Implementation references
- Token source — `src/app/globals.css` (`@theme inline` block)
- Surface primitives — `src/components/brand/{Marble,Parchment,Bone,Shadow}.tsx`
- Brushstroke seam — `src/components/brand/BrushstrokeSeam.tsx`
- Logo — `src/components/brand/Logo.tsx`
- Editorial card — `src/components/brand/EditorialCard.tsx`
- Drop accent — `src/components/brand/DropAccent.tsx`
- Button — `src/components/brand/Button.tsx`
- Section + divider — `src/components/brand/Section.tsx`, `SectionDivider.tsx`
- Sentinels — `src/__tests__/brand/{forbidden-patterns,contrast,legibility}.test.ts`
## Regeneration notes
- `public/apple-touch-icon.png` and `public/android-chrome-{192,512}.png`
are rendered once from `public/favicon.svg` at the target sizes. To
regenerate (font change, color change): export from the SVG at each
size using any vector → raster tool (Figma, Sketch, or `rsvg-convert`).
No build-step automation; the assets change at most once per brand
iteration.
- TTFs in `public/fonts/` are checked-in copies from Google Fonts'
release. To update: download fresh from fonts.google.com and replace.
Used only by `next/og`'s `ImageResponse` — the site itself loads fonts
via `next/font/google`.
Design Decisions
- Reference doc, not narrative. Phase 1.3's brand-identity doc is narrative (voice, mission, why). This doc is reference (paths, tokens, regeneration). Different reading modes, different docs.
- Document the Satori workaround. Future contributors looking at
clip-path: polygon(...)inopengraph-image.tsxwill wonder why it's not the SVG-mask brushstroke. The doc explains. - Regeneration notes for raster assets. Anyone tempted to "improve the icons" will see what tooling is involved and either do it correctly or open an issue rather than ad-hoc edits.
Files
| Action | File |
|---|---|
| MODIFY | docs/fabled10x-design-system.md |
Phase 8 Exit Criteria
public/logo.svg,public/favicon.svg,apple-touch-icon.png, and the two Android Chrome PNGs exist;metadata.iconsreferences them; browser tab shows the favicon.- TTF files in
public/fonts/are committed. /opengraph-image,/episodes/<slug>/opengraph-image,/cases/<slug>/opengraph-imageall return 1200×630 PNGs with the brand composition./thumb-preview?title=...&accent=?renders 1280×720 in dev; 404s in prod.docs/fabled10x-design-system.mdreflects final paths, routes, and regeneration notes.npm test— forbidden-pattern sentinel green.npm run lint,npx tsc --noEmit,npm run buildall clean.- Manual verification: share a localhost URL through a preview-aware tool (or use Twitter's card validator with a ngrok tunnel) — preview shows the brand composition with title.