A topographic map for every page, drawn from its text and edit date. How we built generative OG images in one session, what we measured along the way, what went wrong, and the full source.
Every page on this site now has its own social preview image: a topographic map drawn from the page's text and its last-edit date. We built it in one working session (Mackenzie Bowes and Claude, Sonnet 5.5), including the research, a few false starts, a refactor and the tests. This is the build log. Numbers come from runs on 2026-10-02 unless a sentence says otherwise, and the full source is at the end.

Motivation
This site stopped selling websites the day we built this. It is now research, references and experiments, written for agents first and people second. A preview image is the part of a page most humans meet before deciding to open it, so it had to change with the site.
The starting state was worse than a bad image. Many pages had no image of their own, so they inherited the home page's: a dark card reading "You Deserve a Website That Actually Works". Pasting a link to a research page into Discord showed exactly that card.
Two real unfurls changed what we thought an image should do. In Discord and on X the title, the description and the domain already sit beside the image, and X puts its own label over the image's bottom-left corner (we saw an "X Article" badge there; the domain label is documented only by secondary sources). Text baked into the picture repeats what the platform shows and collides with what it adds. We had drafted a domain mark and talked about glowing side panels. We dropped both and removed all text.
The second decision was colour. Nearly every web app people use is slightly blue, so a blue-teal image reads as part of the interface, and a warm one reads as contrast. We started with teal on near-black and moved to brown and orange. That is a judgment about perception, not a test.
The requirements we ended up with:
- Unique per page, and derived from the page's own content.
- Changes when the page is edited.
- Deterministic: the same input gives the same bytes, so platform caches behave.
- Generated at build time, with no runtime cost and no per-page manual work.
Plan
The first plan was one idea: turn a page's text into numbers, use the numbers to grow a terrain, and draw the terrain as a topographic map. A map says "this is a map of something", which is all a preview needs to say, and it matched the contour texture the site already used.
The first mapping:
- Sentence rhythm and vocabulary shape the land.
- The page's most frequent distinctive words each raise a hill.
- How numeric the page is sets how many dots are scattered over it.
- The last-edit date adds a weaker layer of weathering and nudges the hue, so editing a page changes its image.
- Page age becomes concentric rings.
We split the work into four stages from the start: acquire the text and date, analyze the text into numbers, synthesize an SVG from the numbers, and rasterize the SVG to a PNG. Only the first and last stages touch files or the framework.
| In the plan | What happened |
|---|---|
| Tree rings for page age | Built, then cut. Concentric circles were the only geometric shape in an organic picture, and nobody could read "months since publishing" from them. |
| Teal on near-black | Replaced by brown and orange (see Motivation). |
| Domain mark and a dark band behind it | Added, then removed along with all text, after looking at real Discord and X cards. |
| Film grain | Considered and declined. |
| Halftone texture on the contours | Added. Dots sized by pixel distance to the nearest contour line, so they trace the terrain. Chosen over plain lines after a side-by-side. |
| WebP output | Compared against a 128-colour PNG. PNG looked better at the same size (see Research). Not wired in yet. |
Research
We ran five parallel research passes: generative examples, editorial and typographic examples, design trends, rendering technique, and platform behaviour. Two things about the process belong on the record before the findings.
First, the framing leaked. Our prompts described the site as having a topographic generator and asked specifically about contour art, so the researchers went looking for topographic precedent and reported on it. Mackenzie pointed out that we were gathering information while trying to raise confidence in a decision we had already made. The rule we kept: research prompts ask what is out there and leave our choices out. Read the findings below knowing they were gathered under the leading frame.
Second, the evidence varied a lot in quality, and the report labelled it. What it found:
- Generative OG art is rare among the sites checked. The strongest cards committed to one owned idea (a painted plant, a big disc, a wordmark, a chart), used two or three colours and left space. Matthew Ström's seeded flow-field system appeared to have been replaced on his live site by a plain typographic card.
- No live site using contour art for OG images was found. Given the leading frame, treat that as weak.
- Trend sources (mostly vendor reports) pointed toward texture and imperfection and called blue-to-purple gradients dated. They also warned that warm-dark with one accent colour is becoming its own default, which is one reason we derived the picture from content and not from a palette.
- Click-through evidence for OG images: none credible. The figures in circulation ("47% lift") are vendor claims with no method. The one real A/B test found was on ad images, where a text overlay raised clicks from 0.81% to 1.4%. That says little about previews.
- WebP: X's documentation lists it, and a December 2024 practitioner test found WebP og:images worked on eleven platforms, including LinkedIn, Slack, iMessage and WhatsApp. Older and SEO-blog sources disagree on LinkedIn and Slack. Next.js's opengraph-image file convention accepts only jpg, jpeg, png and gif.
- Rendering (a research agent's local test of the bundled @vercel/og 0.7.2): SVG filters and blend modes work inside an SVG delivered to Satori as a data-URI image, while CSS mix-blend-mode on an element is silently ignored. A full-frame grain overlay produced PNGs of 606-926 KB, against about 5 KB for a flat card.
Then we measured our own images. Six real pages, three encodings:
| Page | PNG (KB) | WebP q82 (KB) | PNG, 128 colours, dithered (KB) |
|---|---|---|---|
| home | 230 | 58 | 63 |
| about | 377 | 109 | 107 |
| facial-parameters research | 323 | 88 | 88 |
| post (E-E-A-T) | 328 | 89 | 91 |
| story (GPTBoss) | 346 | 101 | 97 |
| framework deep dive | 308 | 89 | 86 |
Mackenzie compared them side by side, at full size, at Discord-embed size and zoomed 3x. The 128-colour PNG looked better than the WebP, and banding in the glows was not a problem. It also carries no compatibility risk. The live images are still ordinary PNGs of 230-377 KB; the palette step is not wired in yet.
Implementation

Acquire. For a page built from structured data (a post, a story), the strings in the data object are the text. For a page written as a TSX file, we extract the prose: string literals and JSX text, minus class names, imports and anything that looks like code. The last-edit date is the latest git commit that touched the page's files. In development we also look at file times so an uncommitted edit shows up. In production with no git history it falls back to the publish date, never to file times, because on a deploy those are just the deploy time and would redraw every image on every deploy.
Analyze. Text becomes six 0-1 features using fixed reference ranges, so a page's features never depend on what else is on the site, plus marks (salient words) and two profiles along the document.
| Feature | Measured as |
|---|---|
| density | log10(words) / 4 |
| variety | distinct words / all words |
| scale | average sentence length / 30.8 words |
| intricacy | punctuation per word x 4.4 (saturates near 0.23) |
| roughness | share of 8+ letter words / 0.2 |
| quantitative | share of words containing a digit / 0.28125 |
Marks are the nine most frequent words of six or more letters, minus stop words. The profiles ("series") are sentence length and numeric-token density along the document, resampled to 64 points.
Synthesize. The terrain synth builds an elevation field on a 120 by 63 grid. Variety sets the noise octaves (3 to 5), roughness the octave gain, scale the breadth of landforms, intricacy the domain warping. Each mark raises a hill, placed by a hash of the word. A second, finer noise layer seeded by the edit date weathers the surface. Ten contour levels are traced with marching squares, and every fourth is an index contour, drawn brighter.
The halftone layer is the part that earned its place. A dot screen at 45 degrees with an 11 px pitch covers the image. For each lattice point we find the nearest contour level and measure the distance to it in pixels (the difference in elevation divided by the local gradient). Inside a ribbon of 22 px for index contours, or 10 px for minor ones, we draw a dot whose radius shrinks with that distance, up to 5 px or 2.8 px. The dots therefore trace the paths the terrain takes.
const val = sampleField(field, x, y)
const gx = (sampleField(field, x + 2, y) - sampleField(field, x - 2, y)) / 4
const gy = (sampleField(field, x, y + 2) - sampleField(field, x, y - 2)) / 4
const grad = Math.hypot(gx, gy) + 1e-6
for (const ribbon of ribbons) {
const dist = Math.abs(val - ribbon.level) / grad // pixels to this contour
if (dist >= ribbon.width) continue
const r = ribbon.rMax * Math.pow(1 - dist / ribbon.width, 1.25)
if (r >= 0.7 && (!best || r > best.r)) best = { r, color: ribbon.color }
}
Rasterize. The SVG goes into a bare ImageResponse as a data-URI image, at 1200 by 630, with no text. Next.js generates every image at build time and adds a content hash to each og:image URL, so platforms re-fetch when an image changes.
What went wrong along the way:
- The domain mark collided with X's own label, and halftone dots ran through the text. We removed all text.
- The first orange came out gold. Hue and lightness landed in amber territory; we lowered both.
- After the palette swap the minor contour lines nearly vanished (dark brown at low opacity on a brown ground). We raised their lightness and opacity.
- Running the generator under bun silently did not execute next/og. We bundled the synths with bun build and ran the bundle under Node with the bundled @vercel/og.
- Two production builds in a row in one directory: the second failed on an internal Next font-module error. A clean rerun passed with identical code.
Where it stands: 21 static images per build, SVGs of 63-138 KB depending on style, PNGs of 230-377 KB, and 68 tests in the repository (36 of them for the generator).
Refactor
After the first pass the generator worked and was thoroughly fitted to one picture. Its "signals" were measured in words and sentences, its constants were tuned to our terrain mapping, and everything drew from one shared random stream. We wanted to publish it for other people and agents to build their own synthesizers on, which meant separating what describes a page from what draws it.
The seed. The result is a small plain-JSON type, ArtSeed, that an analyzer writes and any synth reads. It holds five things, and deliberately does not say how to draw anything:
| Layer | Holds | Why it is synth-neutral |
|---|---|---|
| Entropy | Three independent 32-bit sources: identity (stable across edits), content (changes completely on any edit), revision (changes with the edit day) | Any synth can draw named random streams from them; adding a new consumer does not re-roll the others |
| Facts | Edit and publish times, kind of source, a human label | Plain data. The label can feed alt text |
| Features | 0-1 measures from a small shared vocabulary: density, variety, scale, intricacy, roughness, quantitative | Synths pick the ones they want and fall back when one is missing |
| Marks | Salient named things with weights | A synth may turn them into hills, stars or cell centres |
| Series | Profiles along the source's length, 64 points, 0-1 | Our terrain ignores them; a ridgeline synth lives on them |
Proving the refactor changed nothing. Before touching anything we recorded the SVG hash of 24 renders (four pages, three styles, two dates). After the split, none matched. A numeric diff showed 2.17% of coordinates off, by up to 42 px. The cause was one constant: I had approximated 1/4.4 as 0.2273. With the exact inverse, 24 of 24 were byte-identical, so the refactor changed nothing visible.
Proving the seed is not terrain-shaped. We wrote a second synth, stacked ridgelines, that reads what terrain ignores: the sentence-length profile shapes the ridges, numeric stretches become bright dots, and density sets the row count. It needed no change to the seed.

Every synth runs one shared contract test: deterministic output, identical output after a JSON round trip, a response to content changes and to date-only changes, no crash on an empty seed, and a size bound.
Stable layout across edits. The identity entropy source lets the terrain lay itself out so that an edited page keeps its landscape. Our first test compared contour coordinates and concluded it did not work. That metric was wrong: when a contour's topology shifts, every coordinate after it slides. Measuring the elevation field directly, as the Pearson correlation between the terrain before and after an edit, gave a different answer:
| Edit | Identity layout (r) | Content layout (r) |
|---|---|---|
| one sentence added | 1.00 | 0.18 |
| a paragraph added | 0.91 | 0.50 |
| 10% trimmed | 0.91 | 0.22 |
| half the page replaced | 0.66 | 0.12 |

Rules for writing your own synth, as they ended up in the guide:
- Pure and deterministic: same seed, same bytes. No clock, no Math.random(), no I/O.
- Tolerate sparse seeds: read features with fallbacks, never throw on a missing field.
- Do not assume the seed came from text; use extras only with a fallback.
- Own your look: palette, line weights and composition belong to the synth, not the seed.
- Design for a 200-400 px thumbnail; keep the SVG well under 250 KB.
- No text in the picture; platforms show the title and domain beside it.
What is not done: the 128-colour PNG step and a local bake script that would write the images and a manifest of seeds (so a stale image can fail CI) are planned, not built. The alt text on each image is just the page title, though the seed carries a label that could feed it. In production the edit date needs git history at build time. The identity layout is not switched on. The build still reports one Turbopack tracing warning. And apart from looking at real Discord and X unfurls, we have no measurement of how these images perform in a feed.
The code, as a snapshot of 2026-10-02. Everything between the source adapter and the rasterizer is plain TypeScript with no framework imports. It is yours to copy.
/**
* ArtSeed: a plain-JSON description of *a thing* (a page, a file, a clip), written by an
* analyzer and read by synths. It describes the source; it does not say how to draw it.
*
* Rules that keep it useful to many synths:
* - Descriptive, not prescriptive: no colours, coordinates, line widths or primitive counts.
* - Features are 0..1 and use a small medium-agnostic vocabulary; anything medium-specific
* goes in `extras` under the analyzer's namespace.
* - Everything a synth needs is in the seed. Synths are pure: no clock, no Math.random().
* - Consumers must tolerate missing features, marks and series (use `feature()` fallbacks).
* - Additive changes only within a version; unknown fields are ignored.
*/
export const SEED_VERSION = 1
/**
* The shared feature vocabulary. All values are 0..1. Each analyzer documents how it maps
* its medium onto these.
*
* density how much there is (log-scaled size)
* variety how many different kinds of element there are
* scale typical size of structure: fine (0) to broad (1)
* intricacy how tangled the structure is: plain (0) to nested and clause-heavy (1)
* roughness how abrupt and uneven it is: smooth (0) to jagged (1)
* quantitative how measured or numeric it is
*/
export const CORE_FEATURES = ['density', 'variety', 'scale', 'intricacy', 'roughness', 'quantitative'] as const
export type CoreFeature = (typeof CORE_FEATURES)[number]
/** A salient, named thing in the source. Synths decide whether it becomes a hill, a star, a cell... */
export interface ArtMark {
/** Stable 32-bit id (hash of the label) */
id: number
label: string
/** 0..1 */
weight: number
}
export interface ArtSeed {
v: typeof SEED_VERSION
/** What was analyzed: "text", "code", "image", ... */
kind: string
/** Which analyzer wrote this, with a version: "text@1" */
analyzer: string
/** Human title, for alt text. Never for drawing. */
label?: string
/**
* Three independent 32-bit entropy sources:
* identity - stable for the thing across edits (e.g. derived from its route)
* content - changes completely on any edit
* revision - changes with the last-edit date
*/
entropy: { identity: number; content: number; revision: number }
/** ISO timestamps */
time: { modified: string; published?: string }
features: Partial<Record<CoreFeature, number>> & Record<string, number | undefined>
marks: ArtMark[]
/** Ordered profiles along the source's extent, resampled to a fixed length, values 0..1 */
series: Record<string, number[]>
/** Analyzer-specific raw data under a namespace (e.g. "text.words"). Free-form. */
extras: Record<string, unknown>
}
export function hash32(input: string): number {
// cyrb53, folded to 32 bits
let h1 = 0xdeadbeef
let h2 = 0x41c6ce57
for (let i = 0; i < input.length; i++) {
const ch = input.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return (h2 ^ h1) >>> 0
}
export function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export type EntropySource = keyof ArtSeed['entropy']
/**
* An independent random stream for a named purpose. Different `ns` values never disturb
* each other, so adding a new random consumer to a synth does not re-roll the others.
*/
export function rng(seed: ArtSeed, ns: string, from: EntropySource = 'content'): () => number {
return mulberry32(hash32(`${ns}:${seed.entropy[from]}`))
}
/** A feature value, or `fallback` if the analyzer did not provide it. */
export function feature(seed: ArtSeed, name: string, fallback = 0.5): number {
const v = seed.features[name]
return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : fallback
}
/** A series, or `null` if absent or empty. */
export function series(seed: ArtSeed, name: string): number[] | null {
const s = seed.series[name]
return Array.isArray(s) && s.length > 1 ? s : null
}
const u32 = (n: unknown) => (typeof n === 'number' && Number.isFinite(n) ? n >>> 0 : 0)
const clamp01 = (n: unknown) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0)
/**
* Make an arbitrary value (hand-edited JSON, another tool's output) safe to give to a synth:
* fill defaults, clamp features and weights, drop malformed marks and series.
*/
export function coerceSeed(input: unknown): ArtSeed {
const raw = (input && typeof input === 'object' ? input : {}) as Record<string, any>
const entropy = raw.entropy ?? {}
const features: ArtSeed['features'] = {}
for (const [k, v] of Object.entries(raw.features ?? {})) {
if (typeof v === 'number' && Number.isFinite(v)) features[k] = clamp01(v)
}
const marks: ArtMark[] = (Array.isArray(raw.marks) ? raw.marks : [])
.filter((m: any) => m && typeof m.label === 'string')
.map((m: any) => ({ id: u32(m.id ?? hash32(m.label)), label: m.label, weight: clamp01(m.weight) }))
const outSeries: ArtSeed['series'] = {}
for (const [k, v] of Object.entries(raw.series ?? {})) {
if (Array.isArray(v)) outSeries[k] = v.filter((n) => typeof n === 'number' && Number.isFinite(n)).map(clamp01)
}
return {
v: SEED_VERSION,
kind: typeof raw.kind === 'string' ? raw.kind : 'unknown',
analyzer: typeof raw.analyzer === 'string' ? raw.analyzer : 'unknown',
label: typeof raw.label === 'string' ? raw.label : undefined,
entropy: { identity: u32(entropy.identity), content: u32(entropy.content), revision: u32(entropy.revision) },
time: {
modified: typeof raw.time?.modified === 'string' ? raw.time.modified : '2026-01-01T00:00:00.000Z',
published: typeof raw.time?.published === 'string' ? raw.time.published : undefined,
},
features,
marks,
series: outSeries,
extras: raw.extras && typeof raw.extras === 'object' ? raw.extras : {},
}
}import { SEED_VERSION, hash32, type ArtMark, type ArtSeed } from '../seed'
/**
* The text analyzer: prose in, ArtSeed out.
*
* How text maps onto the shared feature vocabulary (fixed reference ranges, so a page's
* features do not depend on what else is on the site):
* density log10(words) / 4
* variety distinct words / all words
* scale average sentence length / 30.8 words
* intricacy punctuation per word x 4.4 (saturates at about 0.23 per word)
* roughness share of 8+ letter words / 0.2
* quantitative share of words containing a digit / 0.28125
* Raw measures are kept under `extras` as "text.*".
*/
export interface TextSignals {
words: number
avgWordLength: number
avgSentenceLength: number
sentenceLengthSpread: number
uniqueRatio: number
longWordRatio: number
digitRatio: number
punctuationDensity: number
keyWords: Array<{ word: string; weight: number }>
}
const STOP = new Set(
'about above after again also because before being between could does doing during each from have having here into just like more most much only other over same should some such than that their them then there these they this those through under until very were what when where which while with would your'.split(
' ',
),
)
export function readSignals(text: string): TextSignals {
const tokens = text.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []
const words = tokens.length || 1
const sentences = text.split(/[.!?]+\s/).map((s) => s.trim().split(/\s+/).length).filter((n) => n > 1)
const avgSentence = sentences.length ? sentences.reduce((a, b) => a + b, 0) / sentences.length : 12
const spread = sentences.length
? Math.sqrt(sentences.reduce((a, b) => a + (b - avgSentence) ** 2, 0) / sentences.length)
: 4
const freq = new Map<string, number>()
let letters = 0
let long = 0
let digits = 0
for (const t of tokens) {
letters += t.length
if (t.length >= 8) long++
if (/\d/.test(t)) digits++
if (t.length >= 6 && !STOP.has(t) && !/\d/.test(t)) freq.set(t, (freq.get(t) ?? 0) + 1)
}
const keyWords = [...freq.entries()]
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
.slice(0, 9)
.map(([word, n]) => ({ word, weight: Math.min(1, Math.log2(n + 1) / 5) }))
const punctuation = (text.match(/[,;:()—–-]/g) ?? []).length
return {
words,
avgWordLength: letters / words,
avgSentenceLength: avgSentence,
sentenceLengthSpread: spread,
uniqueRatio: new Set(tokens).size / words,
longWordRatio: long / words,
digitRatio: digits / words,
punctuationDensity: punctuation / words,
keyWords,
}
}
const SERIES_POINTS = 64
/** Resample values to a fixed length by averaging equal chunks, then scale to 0..1. */
function resample(values: number[], points: number, scale: (n: number) => number): number[] {
if (values.length === 0) return []
const out: number[] = []
for (let i = 0; i < points; i++) {
const a = Math.floor((i * values.length) / points)
const b = Math.max(a + 1, Math.floor(((i + 1) * values.length) / points))
let sum = 0
for (let j = a; j < b; j++) sum += values[j]
out.push(Math.max(0, Math.min(1, scale(sum / (b - a)))))
}
return out
}
const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
function toDate(value: string | Date): Date {
const d = typeof value === 'string' ? new Date(value) : value
return Number.isNaN(d.getTime()) ? new Date('2026-01-01T00:00:00Z') : d
}
function dayNumber(d: Date): number {
return Math.floor(d.getTime() / 86_400_000)
}
export interface TextInput {
text: string
/** Last edit */
modified: string | Date
published?: string | Date
/** A key that stays the same across edits, e.g. the page's route. Defaults to the text itself. */
identity?: string
/** Human title for alt text */
label?: string
}
export function analyzeText(input: TextInput): ArtSeed {
const { text } = input
const s = readSignals(text)
const modified = toDate(input.modified)
const marks: ArtMark[] = s.keyWords.map((k) => ({ id: hash32(k.word), label: k.word, weight: k.weight }))
// Profiles along the document: sentence length, and how numeric each stretch is
const sentenceLengths = text.split(/[.!?]+\s/).map((x) => x.trim().split(/\s+/).length).filter((n) => n > 1)
const tokens = text.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []
const numeric = tokens.map((t) => (/\d/.test(t) ? 1 : 0))
return {
v: SEED_VERSION,
kind: 'text',
analyzer: 'text@1',
label: input.label,
entropy: {
identity: hash32(`id:${input.identity ?? text}`),
content: hash32(text),
revision: hash32(`day:${dayNumber(modified)}`),
},
time: {
modified: modified.toISOString(),
published: input.published ? toDate(input.published).toISOString() : undefined,
},
features: {
density: clamp01(Math.log10(Math.max(1, s.words)) / 4),
variety: clamp01(s.uniqueRatio),
scale: clamp01(s.avgSentenceLength / 30.8),
intricacy: clamp01(s.punctuationDensity * 4.4),
roughness: clamp01(s.longWordRatio / 0.2),
quantitative: clamp01(s.digitRatio / 0.28125),
},
marks,
series: {
sentenceLength: resample(sentenceLengths, SERIES_POINTS, (n) => n / 40),
numeric: resample(numeric, SERIES_POINTS, (n) => n * 4),
},
extras: {
'text.words': s.words,
'text.avgWordLength': s.avgWordLength,
'text.avgSentenceLength': s.avgSentenceLength,
'text.sentenceLengthSpread': s.sentenceLengthSpread,
'text.uniqueRatio': s.uniqueRatio,
'text.longWordRatio': s.longWordRatio,
'text.digitRatio': s.digitRatio,
'text.punctuationDensity': s.punctuationDensity,
},
}
}import { feature, mulberry32, type ArtSeed } from '../seed'
import { ART_BACKGROUND, ART_HEIGHT, ART_INK, ART_WIDTH, f1, hsl } from '../canvas'
/* ─────────────────────────────────────────────
Value noise
───────────────────────────────────────────── */
function lattice(seed: number, x: number, y: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed, 2147483647)) | 0
h = Math.imul(h ^ (h >>> 13), 1274126177)
return ((h ^ (h >>> 16)) >>> 0) / 4294967296
}
function smooth(t: number): number {
return t * t * (3 - 2 * t)
}
function noise(seed: number, x: number, y: number): number {
const xi = Math.floor(x)
const yi = Math.floor(y)
const fx = smooth(x - xi)
const fy = smooth(y - yi)
const a = lattice(seed, xi, yi)
const b = lattice(seed, xi + 1, yi)
const c = lattice(seed, xi, yi + 1)
const d = lattice(seed, xi + 1, yi + 1)
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy
}
function fbm(seed: number, x: number, y: number, octaves: number, gain: number): number {
let sum = 0
let amp = 1
let norm = 0
let freq = 1
for (let o = 0; o < octaves; o++) {
sum += amp * noise(seed + o * 101, x * freq, y * freq)
norm += amp
amp *= gain
freq *= 2
}
return sum / norm
}
/* ─────────────────────────────────────────────
Scalar field: content terrain + date weathering
───────────────────────────────────────────── */
const GRID_X = 120
const GRID_Y = 63
const ASPECT = ART_WIDTH / ART_HEIGHT
interface Field {
values: Float32Array
min: number
max: number
}
/** `layoutSeed` fixes where the landforms sit; `dateSeed` weathers them. */
function buildField(seed: ArtSeed, layoutSeed: number, dateSeed: number): Field {
const rand = mulberry32(layoutSeed ^ 0x9e3779b9)
const octaves = 3 + Math.round(feature(seed, 'variety') * 2)
const gain = 0.42 + 0.2 * feature(seed, 'roughness')
const scale = 1.6 + 2.2 * feature(seed, 'scale')
const warp = 0.15 + 0.5 * feature(seed, 'intricacy')
const weatherAmp = 0.22
const offX = rand() * 40
const offY = rand() * 40
const hills = seed.marks.map((k) => {
const h = mulberry32(k.id ^ layoutSeed)
return {
x: (0.08 + h() * 0.84) * ASPECT,
y: 0.12 + h() * 0.76,
amp: 0.25 + 0.55 * k.weight + h() * 0.15,
r: 0.1 + h() * 0.16,
}
})
const values = new Float32Array((GRID_X + 1) * (GRID_Y + 1))
let min = Infinity
let max = -Infinity
for (let j = 0; j <= GRID_Y; j++) {
for (let i = 0; i <= GRID_X; i++) {
const x = (i / GRID_X) * ASPECT
const y = j / GRID_Y
const wx = x + warp * (fbm(layoutSeed + 7, x * 1.3 + offX, y * 1.3 + offY, 2, 0.5) - 0.5)
const wy = y + warp * (fbm(layoutSeed + 13, x * 1.3 + offX, y * 1.3 + offY, 2, 0.5) - 0.5)
let v = fbm(layoutSeed, wx * scale + offX, wy * scale + offY, octaves, gain)
for (const hill of hills) {
const d2 = (x - hill.x) ** 2 + (y - hill.y) ** 2
v += hill.amp * Math.exp(-d2 / (2 * hill.r * hill.r)) * 0.55
}
// weathering: a weaker, finer layer seeded by the edit date
v += weatherAmp * (fbm(dateSeed, x * 5.5, y * 5.5, 2, 0.5) - 0.5)
values[j * (GRID_X + 1) + i] = v
if (v < min) min = v
if (v > max) max = v
}
}
return { values, min, max }
}
/* ─────────────────────────────────────────────
Marching squares
───────────────────────────────────────────── */
type Seg = [number, number, number, number]
function contour(field: Field, level: number): Seg[] {
const { values } = field
const stride = GRID_X + 1
const segs: Seg[] = []
const sx = ART_WIDTH / GRID_X
const sy = ART_HEIGHT / GRID_Y
for (let j = 0; j < GRID_Y; j++) {
for (let i = 0; i < GRID_X; i++) {
const v00 = values[j * stride + i]
const v10 = values[j * stride + i + 1]
const v11 = values[(j + 1) * stride + i + 1]
const v01 = values[(j + 1) * stride + i]
const idx = (v00 > level ? 1 : 0) | (v10 > level ? 2 : 0) | (v11 > level ? 4 : 0) | (v01 > level ? 8 : 0)
if (idx === 0 || idx === 15) continue
const lerp = (a: number, b: number) => (level - a) / (b - a)
// edges: 0 top, 1 right, 2 bottom, 3 left
const point = (edge: number): [number, number] => {
switch (edge) {
case 0: return [(i + lerp(v00, v10)) * sx, j * sy]
case 1: return [(i + 1) * sx, (j + lerp(v10, v11)) * sy]
case 2: return [(i + lerp(v01, v11)) * sx, (j + 1) * sy]
default: return [i * sx, (j + lerp(v00, v01)) * sy]
}
}
const add = (a: number, b: number) => {
const [x1, y1] = point(a)
const [x2, y2] = point(b)
segs.push([x1, y1, x2, y2])
}
const centerHigh = (v00 + v10 + v11 + v01) / 4 > level
switch (idx) {
case 1: case 14: add(3, 0); break
case 2: case 13: add(0, 1); break
case 3: case 12: add(3, 1); break
case 4: case 11: add(1, 2); break
case 6: case 9: add(0, 2); break
case 7: case 8: add(3, 2); break
case 5:
if (centerHigh) { add(0, 1); add(2, 3) } else { add(3, 0); add(1, 2) }
break
case 10:
if (centerHigh) { add(3, 0); add(1, 2) } else { add(0, 1); add(2, 3) }
break
}
}
}
return segs
}
/* ─────────────────────────────────────────────
Colour
───────────────────────────────────────────── */
/* ─────────────────────────────────────────────
Halftone: dots on a 45-degree screen, sized by how close each
lattice point sits to a contour line (measured in pixels, using the
field's gradient), so the dots trace the paths the terrain takes.
───────────────────────────────────────────── */
const HALFTONE_PITCH = 11
function sampleField(field: Field, x: number, y: number): number {
const fx = Math.max(0, Math.min(GRID_X - 1e-6, (x / ART_WIDTH) * GRID_X))
const fy = Math.max(0, Math.min(GRID_Y - 1e-6, (y / ART_HEIGHT) * GRID_Y))
const i = Math.floor(fx)
const j = Math.floor(fy)
const tx = fx - i
const ty = fy - j
const stride = GRID_X + 1
const v = field.values
const a = v[j * stride + i]
const b = v[j * stride + i + 1]
const c = v[(j + 1) * stride + i]
const d = v[(j + 1) * stride + i + 1]
return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty
}
interface Ribbon {
level: number
index: boolean
width: number
rMax: number
color: string
}
function halftone(field: Field, ribbons: Ribbon[]): string {
const h = HALFTONE_PITCH / Math.SQRT2
const groups = new Map<string, string[]>()
for (let u = -90; u <= 90; u++) {
for (let v = -90; v <= 90; v++) {
const x = ART_WIDTH / 2 + (u - v) * h
const y = ART_HEIGHT / 2 + (u + v) * h
if (x < -6 || x > ART_WIDTH + 6 || y < -6 || y > ART_HEIGHT + 6) continue
const val = sampleField(field, x, y)
const gx = (sampleField(field, x + 2, y) - sampleField(field, x - 2, y)) / 4
const gy = (sampleField(field, x, y + 2) - sampleField(field, x, y - 2)) / 4
const grad = Math.hypot(gx, gy) + 1e-6
let best: { r: number; color: string } | null = null
for (const ribbon of ribbons) {
const dist = Math.abs(val - ribbon.level) / grad
if (dist >= ribbon.width) continue
const r = ribbon.rMax * Math.pow(1 - dist / ribbon.width, 1.25)
if (r >= 0.7 && (!best || r > best.r)) best = { r, color: ribbon.color }
}
if (!best) continue
const list = groups.get(best.color) ?? []
list.push(`<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(best.r)}"/>`)
groups.set(best.color, list)
}
}
return [...groups.entries()]
.map(([color, circles]) => `<g class="halftone" fill="${color}">${circles.join('')}</g>`)
.join('')
}
/* ─────────────────────────────────────────────
Compose
───────────────────────────────────────────── */
function path(segs: Seg[]): string {
return segs.map(([a, b, c, d]) => `M${f1(a)} ${f1(b)}L${f1(c)} ${f1(d)}`).join('')
}
/**
* How the terrain is drawn.
* - lines: thin contour lines only
* - halftone: contours rendered as ribbons of halftone dots
* - mixed: thin contours everywhere, halftone ribbons on the index contours
*/
export type ArtStyle = 'lines' | 'halftone' | 'mixed'
export interface TerrainOptions {
style?: ArtStyle
/**
* What fixes where the landforms sit. 'content' (default): any edit re-rolls the layout.
* 'identity': the layout stays put across edits and only the details and weathering change,
* so a page reads as the same place, re-surveyed.
*/
layoutFrom?: 'content' | 'identity'
}
/**
* Terrain synth: a topographic map. Features shape the land (variety -> octaves, roughness ->
* gain, scale -> breadth, intricacy -> domain warp), marks raise hills, `quantitative` scatters
* stipple, and the revision entropy weathers the surface and shifts the hue.
*/
/** The raw elevation grid the terrain is drawn from. Exposed for analysis and tests. */
export function terrainField(seed: ArtSeed, options: TerrainOptions = {}): Float32Array {
const layoutSeed = options.layoutFrom === 'identity' ? seed.entropy.identity : seed.entropy.content
return buildField(seed, layoutSeed, seed.entropy.revision).values
}
export function terrain(seed: ArtSeed, options: TerrainOptions = {}): string {
const style: ArtStyle = options.style ?? 'mixed'
const contentSeed = seed.entropy.content
const dateSeed = seed.entropy.revision
const layoutSeed = options.layoutFrom === 'identity' ? seed.entropy.identity : contentSeed
const field = buildField(seed, layoutSeed, dateSeed)
const rand = mulberry32(contentSeed ^ dateSeed)
// Warm family: the edit date nudges the orange between red-orange and amber
const hue = 21 + (dateSeed % 15) - 7
const levels = 10
const span = field.max - field.min
// Low ground is brown, rising through orange to amber at the index contours
const levelColor = (k: number, index: boolean) => {
const t = k / levels
if (index) return hsl(hue + 2, 0.9, 0.46 + 0.1 * t)
return hsl(hue - 5 + 5 * t, 0.5 + 0.35 * t, 0.32 + 0.2 * t)
}
const parts: string[] = []
const ribbons: Ribbon[] = []
for (let k = 1; k <= levels; k++) {
const level = field.min + (span * k) / (levels + 1)
const index = k % 4 === 0
const color = levelColor(k, index)
if (style === 'lines' || style === 'mixed') {
const segs = contour(field, level)
if (segs.length) {
const soft = style === 'mixed' && !index
parts.push(
`<path d="${path(segs)}" fill="none" stroke="${color}" stroke-width="${index ? 1.9 : 1.1}" stroke-linecap="round" opacity="${index ? 0.9 : (soft ? 0.5 : 0.4) + 0.03 * k}"/>`,
)
}
}
if (style === 'halftone' || (style === 'mixed' && index)) {
ribbons.push({
level,
index,
width: index ? 22 : 10,
rMax: index ? 5 : 2.8,
color,
})
}
}
if (ribbons.length) parts.push(halftone(field, ribbons))
// Stipple: more numeric pages are more speckled
const dots = Math.min(900, Math.round(feature(seed, 'quantitative', 0) * 900))
const dotParts: string[] = []
for (let n = 0, tries = 0; n < dots && tries < dots * 12; tries++) {
const x = rand() * ART_WIDTH
const y = rand() * ART_HEIGHT
const v = (sampleField(field, x, y) - field.min) / span
if (rand() > v * v * 1.6) continue
dotParts.push(`<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(0.9 + rand() * 1.5)}"/>`)
n++
}
if (dotParts.length) parts.push(`<g class="stipple" fill="${ART_INK}" opacity="0.5">${dotParts.join('')}</g>`)
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${ART_WIDTH}" height="${ART_HEIGHT}" viewBox="0 0 ${ART_WIDTH} ${ART_HEIGHT}">`,
`<defs><radialGradient id="v" cx="50%" cy="50%" r="75%"><stop offset="55%" stop-color="${ART_BACKGROUND}" stop-opacity="0"/><stop offset="100%" stop-color="${ART_BACKGROUND}" stop-opacity="0.92"/></radialGradient>`,
`<radialGradient id="g" cx="${f1(20 + (contentSeed % 60))}%" cy="${f1(30 + (dateSeed % 50))}%" r="60%"><stop offset="0%" stop-color="${hsl(hue - 3, 0.55, 0.2)}" stop-opacity="0.55"/><stop offset="100%" stop-color="${ART_BACKGROUND}" stop-opacity="0"/></radialGradient></defs>`,
`<rect width="${ART_WIDTH}" height="${ART_HEIGHT}" fill="${ART_BACKGROUND}"/>`,
`<rect width="${ART_WIDTH}" height="${ART_HEIGHT}" fill="url(#g)"/>`,
...parts,
`<rect width="${ART_WIDTH}" height="${ART_HEIGHT}" fill="url(#v)"/>`,
`</svg>`,
].join('')
}import { feature, rng, series, type ArtSeed } from '../seed'
import { ART_BACKGROUND, ART_HEIGHT, ART_INK, ART_WIDTH, f1, hsl } from '../canvas'
/**
* Ridgeline synth: stacked line profiles in the "Unknown Pleasures" form. It reads parts of the
* seed the terrain synth ignores: the `sentenceLength` series becomes the shape of the ridges,
* `numeric` becomes bright dots where the source gets measured, `density` sets the row count.
* Each row draws from its own named random stream, so rows never disturb each other.
*/
export function ridgeline(seed: ArtSeed): string {
const rows = 14 + Math.round(feature(seed, 'density') * 20)
const pts = 96
const profile = series(seed, 'sentenceLength')
const numeric = series(seed, 'numeric')
const tremor = 0.2 + 0.8 * feature(seed, 'roughness')
const hue = 21 + (seed.entropy.revision % 15) - 7
const left = 90
const right = ART_WIDTH - 90
const top = 90
const bottom = ART_HEIGHT - 60
const pitch = (bottom - top) / (rows - 1)
const amp = pitch * (3 + 3 * feature(seed, 'scale'))
const sample = (list: number[], t: number) => {
const f = t * (list.length - 1)
const i = Math.floor(f)
const j = Math.min(list.length - 1, i + 1)
return list[i] + (list[j] - list[i]) * (f - i)
}
const bumps = seed.marks.map((m) => ({ x: 0.1 + ((m.id % 1000) / 1000) * 0.8, w: m.weight }))
const parts: string[] = []
const dots: string[] = []
for (let r = 0; r < rows; r++) {
const noise = rng(seed, `ridge-${r}`)
const wobble = Array.from({ length: pts }, () => noise())
const baseY = top + r * pitch
const t = r / (rows - 1)
const coords: string[] = []
for (let i = 0; i < pts; i++) {
const u = i / (pts - 1)
const window = Math.pow(Math.sin(Math.PI * u), 1.6)
const shape = profile ? sample(profile, u) : 0.5
let h = shape * 0.8 + (wobble[i] - 0.5) * tremor
for (const b of bumps) h += b.w * 0.9 * Math.exp(-((u - b.x) ** 2) / (2 * 0.03 ** 2))
const y = baseY - Math.max(0, h) * window * amp
const x = left + u * (right - left)
coords.push(`${f1(x)} ${f1(y)}`)
if (numeric && sample(numeric, u) > 0.35 && wobble[i] > 0.6) {
dots.push(`<circle cx="${f1(x)}" cy="${f1(y)}" r="1.6"/>`)
}
}
const index = r % 5 === 4
const stroke = hsl(hue - 5 + 6 * t, index ? 0.9 : 0.55 + 0.3 * t, index ? 0.52 : 0.3 + 0.2 * t)
// Fill with the ground colour so lower rows hide the ones behind them, then stroke only the ridge
const line = coords.join('L')
parts.push(
`<path d="M${line}L${f1(right)} ${ART_HEIGHT}L${f1(left)} ${ART_HEIGHT}Z" fill="${ART_BACKGROUND}"/>`,
`<path d="M${line}" fill="none" stroke="${stroke}" stroke-width="${index ? 1.8 : 1.2}" stroke-linejoin="round"/>`,
)
}
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${ART_WIDTH}" height="${ART_HEIGHT}" viewBox="0 0 ${ART_WIDTH} ${ART_HEIGHT}">`,
`<rect width="${ART_WIDTH}" height="${ART_HEIGHT}" fill="${ART_BACKGROUND}"/>`,
...parts,
dots.length ? `<g fill="${ART_INK}" opacity="0.7">${dots.join('')}</g>` : '',
`</svg>`,
].join('')
}/** Shared canvas constants and colour helpers for synths. No framework imports. */
export const ART_WIDTH = 1200
export const ART_HEIGHT = 630
/** Warm dark brown ground */
export const ART_BACKGROUND = '#1a110b'
export function hsl(h: number, s: number, l: number): string {
const a = s * Math.min(l, 1 - l)
const f = (n: number) => {
const k = (n + h / 30) % 12
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
return Math.round(c * 255).toString(16).padStart(2, '0')
}
return `#${f(0)}${f(8)}${f(4)}`
}
/** Warm cream used for dots */
export const ART_INK = hsl(30, 0.6, 0.9)
/** One decimal place keeps SVG path data small. */
export const f1 = (n: number) => (Math.round(n * 10) / 10).toString()import { execFileSync } from 'node:child_process'
import { readFile, stat } from 'node:fs/promises'
import path from 'node:path'
import { analyzeText } from './analyze/text'
import type { ArtSeed } from './seed'
/** Every string value inside a data object (posts, stories, deep dives). */
export function collectText(value: unknown, out: string[] = []): string {
if (typeof value === 'string') out.push(value)
else if (Array.isArray(value)) value.forEach((v) => collectText(v, out))
else if (value && typeof value === 'object') Object.values(value).forEach((v) => collectText(v, out))
return out.join('\n')
}
/**
* Pull human-readable prose out of a TSX/TS source file: string literals and JSX text,
* minus things that look like class names, paths or code. Keeps the signals about the
* page's words and not about its markup.
*/
export function proseFromSource(source: string): string {
const found: string[] = []
const patterns = [/"((?:[^"\\\n]|\\.){12,})"/g, /'((?:[^'\\\n]|\\.){12,})'/g, /`((?:[^`\\]|\\.){12,})`/g, />([^<>{}]{12,})</g]
for (const re of patterns) {
for (const m of source.matchAll(re)) found.push(m[1])
}
return found
.map((t) => t.replace(/\s+/g, ' ').trim())
.filter((t) => {
const words = t.split(' ')
if (words.length < 3) return false
const codey = words.filter((w) => /[-\[\]/:=_]|^\d+(px|rem|%)$/.test(w) && !/^[\p{L}]+-[\p{L}]+$/u.test(w)).length
return codey / words.length < 0.34
})
.join('\n')
}
const FALLBACK_DATE = '2026-01-01T00:00:00Z'
const gitCache = new Map<string, string>()
function gitDate(files: string[]): string {
const key = files.join('|')
const hit = gitCache.get(key)
if (hit !== undefined) return hit
let date = ''
try {
date = execFileSync('git', ['log', '-1', '--format=%cI', '--', ...files], {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
})
.toString()
.trim()
} catch {
// no git here (or a shallow clone without history): fall through
}
gitCache.set(key, date)
return date
}
async function newestMtime(files: string[]): Promise<string> {
let newest = 0
for (const file of files) {
try {
newest = Math.max(newest, (await stat(path.join(process.cwd(), file))).mtimeMs)
} catch {
// missing file: ignore
}
}
return newest ? new Date(newest).toISOString() : ''
}
/**
* Turn a page into an ArtSeed. This is the impure edge: it reads files and git, then hands
* plain text and a date to an analyzer. To support another medium (code, video, binaries),
* write another adapter that ends in an analyzer returning an ArtSeed.
*
* - `data`: structured content (post, story...). Its strings are the text.
* - `files`: source files (relative to client/). Their text is used when there is no `data`,
* and their last-edit date is the page's "modified" date.
* - `published`: first publish date; only used as the fallback "modified" date.
*
* Modified date: the latest git commit touching the files. In development we also look at
* file mtimes so an unsaved-to-git edit shows up immediately. In production a missing git
* history falls back to the publish date, never to mtime (which is just deploy time and
* would redraw every image on every deploy).
*/
export async function pageSeed(opts: {
files: string[]
data?: unknown
published?: string
/** Stable across edits, e.g. the route. Defaults to the page text. */
identity?: string
/** Human title, for alt text */
label?: string
}): Promise<ArtSeed> {
let text: string
if (opts.data !== undefined) {
text = collectText(opts.data)
} else {
const chunks = await Promise.all(
opts.files.map((f) => readFile(path.join(process.cwd(), f), 'utf8').catch(() => '')),
)
text = chunks.map((c, i) => (/\.(tsx?|jsx?)$/.test(opts.files[i]) ? proseFromSource(c) : c)).join('\n')
}
const git = gitDate(opts.files)
const dev = process.env.NODE_ENV !== 'production'
const candidates = [git, dev ? await newestMtime(opts.files) : '']
.filter(Boolean)
.map((d) => new Date(d).getTime())
const modified = candidates.length
? new Date(Math.max(...candidates)).toISOString()
: opts.published ?? FALLBACK_DATE
return analyzeText({
text: text || opts.files.join(' '),
modified,
published: opts.published,
identity: opts.identity,
label: opts.label,
})
}
/** @deprecated Use pageSeed. Kept so existing route files keep working. */
export const pageArt = pageSeedimport { ImageResponse } from 'next/og'
import { ART_BACKGROUND, ART_HEIGHT, ART_WIDTH } from './canvas'
import type { ArtSeed } from './seed'
import { synths, type SynthName } from './synth'
export const OG_SIZE = { width: ART_WIDTH, height: ART_HEIGHT }
export const OG_CONTENT_TYPE = 'image/png'
export interface RenderOptions {
/** Which synth draws the seed. Default: terrain. */
synth?: SynthName
/** Passed through to the synth (for terrain: { style, layoutFrom }). */
options?: unknown
}
/**
* Rasterize a seed. No text on purpose: every platform shows the title, description and domain
* right next to the image (X even overlays its own domain label), so the image only has to say
* "this is a map of something".
*/
export function renderOgArt(seed: ArtSeed, opts: RenderOptions = {}) {
const synth = synths[opts.synth ?? 'terrain'] as (seed: ArtSeed, options?: unknown) => string
const src = `data:image/svg+xml;utf8,${encodeURIComponent(synth(seed, opts.options))}`
return new ImageResponse(
(
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: ART_BACKGROUND }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} width={ART_WIDTH} height={ART_HEIGHT} alt="" />
</div>
),
{ ...OG_SIZE },
)
}