yurii.
back to journal
BlogAugust 2026 · 18 min read

Procedural Cover Generation: Designing Without a Designer

Procedural Cover Generation: Designing Without a Designer

There is a particular kind of silence that follows the question "who's going to make the cover art?" when the answer is, plainly, nobody. A personal blog with exactly one administrator has no art department to delegate to, no stock-photo budget to draw against, and — if it is honest with itself — no time to spend hunting for a gradient that looks vaguely on-brand every time a new post goes live. And yet a wall of identical grey rectangles where a cover image should be is its own kind of failure, a small tax on credibility paid on every single page. This piece is the story of how I refused to pay either tax: not by hiring anyone, and not by uploading anything, but by designing a procedural cover generation algorithm that draws the cover itself, the moment a post or a project is created, and never draws the same one twice by accident.

What follows is not a marketing pitch for that procedural cover generation system. It is closer to a lab notebook: the mathematics that actually does the work, the code that implements it, the mistakes that came before the version that shipped, and — because good ideas are meant to travel — a demonstration that the entire core of it fits comfortably in fifteen lines of code in more or less any programming language you care to name.

The Constraint That Shaped Everything

Before a single line of this system existed, I wrote down three properties it had to have, and I want to state them plainly because everything downstream is a consequence of taking them seriously rather than as aspirations to be softened later.

  1. Deterministic. The same post, rendered twice, on two different machines, after two different deployments, must produce the byte-identical image. A cover that depends on wall-clock time or an uncached random seed is not a feature; it is a bug waiting for someone to notice it.
  2. Branded, not random. Ten covers on a journal listing page have to read as a family — the same visual dialect spoken in ten different accents — rather than as ten unrelated experiments in what a gradient can do.
  3. Free at read time. A visitor scrolling through a list of posts should cost the server nothing beyond serving bytes that were already computed once, at publish time, by someone who is not them.

Every decision described below — a particular sequence chosen over a hash function, a particular pseudo-random number generator chosen over Math.random, rasterizing to WebP instead of shipping raw SVG to the browser — is a direct answer to one of those three constraints. None of it is decoration. Robert Martin's advice about "screaming architecture" — that a system's structure should shout its purpose at anyone who opens it — applies just as well to an algorithm as it does to a folder layout, and I want this article to demonstrate that idea in miniature: read the code, and you should be able to reconstruct the constraints that produced it.

First Attempt, First Mistake: Hashing a Category to a Color

My first instinct, and I suspect most engineers' first instinct, was to hash the category name into a hue. hash("Kotlin") % 360 is four characters longer than actually calling it "an algorithm," and it is deterministic, and it does look reasonably like a solution until you ask a slightly sharper question: does it guarantee that two different categories get two different, visually distinguishable colors?

It does not, and it cannot, for a reason worth sitting with rather than rushing past: distinguishability is a property of a set of hues, not of any single string. A pure hash function looks at one input at a time and has no way of knowing which hues are already spoken for by categories created earlier. You can get unlucky — two categories landing 4° apart on a 360° wheel is a real, not hypothetical, outcome of hashing short strings — and there is no way to detect that unluckiness from inside the hash function itself, because the hash function has no memory of anything outside its own input.

The fix, once stated this way, follows almost mechanically: stop hashing, and start assigning. Keep a running counter of "how many identities have ever been created," and let that counter — not the category's own name — decide the hue. This turns an uncoordinated, memoryless computation into a coordinated one, and coordination is exactly what a fairness guarantee requires.

The Mathematics of Fair Division: The van der Corput Sequence

Once the rule became "assign the N-th hue ever requested," the next question was arithmetic: what function of N actually spreads points around a circle as evenly as possible, for every prefix of the sequence — not just once N categories are all known in advance, but at every intermediate point, because a real blog adds categories one at a time, unpredictably, over years?

The answer is a hundred-year-old piece of number theory called the van der Corput sequence, and the intuition behind it is disarmingly simple once you see it: cut the circle in half, then cut each remaining half in half again, forever. The first point lands at the very start. The second point bisects the entire circle. The third and fourth points bisect the two halves that resulted. The fifth through eighth bisect the four quarters. Every new point always lands exactly in the middle of the largest remaining gap, which is precisely the greedy strategy you would design by hand if someone asked you to place dots on a circle one at a time such that they never clump.

The closed-form way to compute the N-th point without simulating this bisection by hand is to write N in binary, reverse its bits, and read the reversed bits as a binary fraction. Multiply that fraction by 360°, and you have the hue. It sounds almost too neat to be true, so let us not take it on faith — let us trace it by hand for ordinal 5, the same way I traced it on paper the first time I implemented it, before trusting a single automated test to tell me it was right.

Here is the actual implementation, unmodified, exactly as it has run in production since the day this algorithm shipped:

// cover-hue.ts
function vanDerCorput(ordinal: number): number {
    let bits = ordinal >>> 0;
    let result = 0;
    let denominator = 1;
    while (bits > 0) {
        denominator *= 2;
        result += (bits & 1) / denominator;
        bits >>>= 1;
    }
    return result;
}

export function hueForOrdinal(ordinal: number): number {
    return vanDerCorput(ordinal) * 360;
}

Fourteen lines, no dependency, no floating-point trickery beyond what any language's standard arithmetic already provides. Feed it 0 through 8 and you get exactly the sequence the diagram above predicts: 0°, 180°, 90°, 270°, 45°, 225°, 135°, 315°, 22.5°, ... — a fact I did not just assert but pinned with a test that will fail the instant anyone, myself included, "simplifies" this function into something that merely looks equivalent:

it("matches the worked van der Corput sequence from the design doc", () => {
    expect(hueForOrdinal(0)).toBeCloseTo(0);
    expect(hueForOrdinal(1)).toBeCloseTo(180);
    expect(hueForOrdinal(2)).toBeCloseTo(90);
    expect(hueForOrdinal(3)).toBeCloseTo(270);
    expect(hueForOrdinal(4)).toBeCloseTo(45);
    expect(hueForOrdinal(5)).toBeCloseTo(225);
    expect(hueForOrdinal(6)).toBeCloseTo(135);
    expect(hueForOrdinal(7)).toBeCloseTo(315);
    expect(hueForOrdinal(8)).toBeCloseTo(22.5);
});

The result, stated precisely, and not merely hoped for: for any n identities assigned this way, the minimum angular gap between any two of their hues never falls below half of the theoretical best-case ceiling of 360° / n — and it hits that ceiling exactly whenever n happens to be a power of two. I did not take this on faith either; a second test sweeps every n from 2 to 64, computes every pairwise gap, and asserts the bound holds for all of them. That is the difference between "I believe this is well-distributed" and "I can show you the number."

It is worth naming, and rejecting, the alternative I considered and discarded: the golden angle, 137.508°, beloved of phyllotaxis simulations and famous for producing beautiful, never-repeating spirals when you plot thousands of points with it. It is the asymptotically optimal choice for an infinite sequence. But a personal blog does not have an infinite number of categories — it has a handful, growing slowly, over years — and the golden angle's worst-case minimum gap for a small, finite prefix works out to roughly 38% of the 360/n ceiling, meaningfully worse than the van der Corput sequence's guaranteed 50%. A famous algorithm chosen for its reputation rather than for the metric that actually mattered here would have been a mistake dressed up as sophistication — the kind of decision Clean Code warns against under a different name: solving the problem you wish you had instead of the one in front of you.

From One Number to a Living Image: Deterministic Randomness Without Math.random

A hue answers "what color family," but a cover is not one color — it is a small constellation of blurred, related-hue spots, and their exact positions, sizes, and drift need to look organic without ever being genuinely nondeterministic. Those spots are mixed in OKLCH, a perceptually uniform color space, specifically so that two hues 20° apart actually look 20° apart to a human eye — the RGB cube's own distances lie about this constantly, which is a subject for another article entirely. Math.random() was disqualified on sight: it cannot be seeded, which means it cannot satisfy the very first constraint this whole system exists to serve.

The replacement is two small, well-known, public-domain algorithms, chained together: cyrb128, which hashes an arbitrary string into four 32-bit integers, feeding sfc32, a compact pseudo-random number generator that treats those four integers as its internal state and produces an endless, repeatable stream of numbers in [0, 1).

// cover-seed.ts
function cyrb128(value: string): [number, number, number, number] {
    let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762;
    for (let i = 0; i < value.length; i++) {
        const k = value.charCodeAt(i);
        h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
        h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
        h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
        h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
    }
    h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
    h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
    h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
    h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
    h1 ^= h2 ^ h3 ^ h4;
    h2 ^= h1; h3 ^= h1; h4 ^= h1;
    return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0];
}

function sfc32(a: number, b: number, c: number, d: number) {
    return function next(): number {
        a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
        let t = (a + b) | 0;
        a = b ^ (b >>> 9);
        b = (c + (c << 3)) | 0;
        c = (c << 21) | (c >>> 11);
        d = (d + 1) | 0;
        t = (t + d) | 0;
        c = (c + t) | 0;
        return (t >>> 0) / 4294967296;
    };
}

export function prngFromSeed(seed: string) {
    const [a, b, c, d] = cyrb128(seed);
    return sfc32(a, b, c, d);
}

Neither algorithm is cryptographic, and deliberately so: nothing here needs to resist an adversary deliberately crafting slugs to break the layout. The only requirement is that the same string always produces the same stream of numbers, on any machine, forever — a property I verified not by reading the code and nodding, but by generating a cover, restarting the entire process, generating it again from the identical input, and diffing the resulting content hash. It matched. That single, boring, unglamorous check is worth more than any amount of confidence in the algorithm's elegance.

Turning Text Into Geometry: The Six-Layer Composition

A hue and a random stream are ingredients, not a finished dish. What actually gets rendered is a six-layer SVG composition, and the detail I am proudest of is that most of those layers are not shaped by the PRNG at all — they are shaped directly by the post's own title and excerpt, so that two posts sharing a category still produce visibly different, content-specific covers.

The wave-ridge layer is the one I find most quietly satisfying, precisely because it contains no randomness whatsoever — it samples the character codes of the title and excerpt directly and turns them into a smooth ridge line, which means the exact same text always produces the exact same wave, independent of slug or variant:

// cover-wave.ts (trimmed)
function charCodesOf(text: string): number[] {
    const codes = [...text.toLowerCase()].map((c) => c.codePointAt(0) ?? 0);
    return codes.length > 0 ? codes : [0];
}

export function buildWaveRidges(sourceText: string, width: number, height: number): WaveRidge[] {
    const codes = charCodesOf(sourceText);
    // ...sample SAMPLES_PER_RIDGE points, interpolating between adjacent
    // character codes so a one-word title and a full sentence both produce
    // an equally smooth curve, never a jagged one for short input.
}

The flow-curve layer, by contrast, does use the PRNG for its shape, but its count and amplitude are dictated by real text statistics — a post with twenty-five words across title and excerpt gets roughly five curves, and a post built from longer words gets curves with more visual amplitude:

// cover-flow.ts (trimmed)
export function buildFlowCurves(prng: Prng, stats: CoverTextStats, width: number, height: number): FlowCurve[] {
    const count = Math.max(1, Math.round(stats.wordCount / WORDS_PER_CURVE));
    const amplitude = height * AMPLITUDE_HEIGHT_FRACTION * (0.4 + stats.avgWordLen / AMPLITUDE_WORD_LEN_DIVISOR);
    // ...builds `count` curves, each a set of control points nudged by
    // both the PRNG and a sine wave whose frequency depends on vowelRatio.
}

And the assembler that ties all six layers together is deliberately dumb in the best sense of the word — it makes no decisions of its own, it only calls, in a fixed order, the functions that already made theirs:

// cover-composition.ts (trimmed)
export function buildCoverComposition(input: CoverCompositionInput, prng: Prng, fonts: CoverFonts): CoverComposition {
    const stats = statsFor(input.title, input.excerpt);
    const palette = buildCoverPalette(input.categoryHue, prng, SPOT_COUNT);

    const spots = palette.spots.map((color) => ({
        cx: randomInRange(prng, 0.05, 0.95) * COVER_WIDTH,
        cy: randomInRange(prng, 0.05, 0.95) * COVER_HEIGHT,
        r: SPOT_RADIUS_FRACTION * COVER_WIDTH,
        color,
    }));

    const flowCurves = buildFlowCurves(prng, stats, COVER_WIDTH, COVER_HEIGHT);
    const waveRidges = buildWaveRidges(`${input.title} ${input.excerpt}`, COVER_WIDTH, COVER_HEIGHT);
    const letterformClip = buildLetterformClip(firstWordOf(input.title), COVER_WIDTH, COVER_HEIGHT, "letterform-clip");
    const titleTextLayout = buildTitleTextLayout(/* fontkit measurer, title, canvas size */);

    return { base: palette.base, spots, flowCurves, waveRidges, letterformClip, titleTextLayout,
        stampText: buildStampText(input.category, input.ref, input.date) };
}

Notice the order of operations: every single call that consumes the PRNG happens in a fixed, unchanging sequence. This is not incidental — it is the reason the whole cover reproduces byte-for-byte. Swap two of those calls, or add a new random draw in the middle of the function, and every cover generated before that change silently becomes unreproducible, because the PRNG's internal state at every later call now diverges from what it used to be. I mention this not as trivia but as the sharpest lesson the whole project taught me: determinism is a property of an entire call graph, not of any one function in isolation, and it is exactly the kind of invariant that is invisible in a diff and catastrophic in practice if broken silently.

The Missing Link: How a Journal Post Learns to Match Its Work Item

This blog is not only a journal — it also catalogs Work items, the case studies and projects behind the writing. When a post is about a specific project, I wanted its cover to visually announce that relationship rather than leave the reader to infer it from a hyperlink buried in the text. The mechanism is the same hue-assignment machinery described above, generalized one level: instead of two independent counters — one for post categories, one for Work projects — there is exactly one shared ordinal sequence, so a category and a project can never, even by coincidence, be assigned the same hue.

(This is the paragraph to link to a concrete example once this post is published — pick the actual Work item this post is paired with, e.g. /work/<its-slug>, so a reader can see the shared hue in the wild rather than take the claim on faith.)

IdentityHue is the single row-per-identity table backing this: kind is either "category" or "work", key is the normalized category name or the Work's own slug, and ordinal — the number that feeds directly into hueForOrdinal — carries a single database-level uniqueness constraint shared across both kinds, which is what turns "please don't collide" from a hope into a guarantee enforced by the schema itself:

// covers.ts
export async function resolveIdentityHue(kind: string, key: string): Promise<number> {
    const existing = await prisma.identityHue.findUnique({ where: { kind_key: { kind, key } } });
    if (existing) return existing.hue;

    const highest = await prisma.identityHue.aggregate({ _max: { ordinal: true } });
    const ordinal = (highest._max.ordinal ?? -1) + 1;
    const hue = hueForOrdinal(ordinal);

    const created = await prisma.identityHue.create({ data: { kind, key, hue, ordinal } });
    return created.hue;
}

And the decision a post's own cover generator actually makes, every time, is this: if the post links to a Work item that still exists, inherit that project's hue rather than compute a fresh one from the post's category.

export async function resolvePostHue(post: { categoryEn: string; relatedWorkSlug?: string | null }): Promise<number> {
    if (post.relatedWorkSlug) {
        const work = await prisma.work.findUnique({ where: { slug: post.relatedWorkSlug }, select: { slug: true } });
        if (work) {
            return resolveWorkHue(work.slug);
        }
    }
    return resolveCategoryHue(post.categoryEn);
}

The graceful fallback in that last branch — a stale or invalid relatedWorkSlug degrading quietly to an ordinary category hue instead of throwing — is not an afterthought bolted on for robustness's sake; it is what makes the function safe to call from a publish path that must never fail a post's publication over a data-entry mistake in an unrelated field. A function that can fail in a way its caller cannot recover from is a function that has leaked a decision it had no business making.

From Vectors to Pixels: Why the Browser Never Draws This Twice

The composition described above produces an SVG string. It is not served to the browser as SVG. It is rasterized, once, on the server, into two WebP variants (1200 and 640 pixels wide) plus an inline low-quality placeholder, and only those finished pixels ever leave the server.

// image-processing.ts (trimmed)
export async function rasterizeCover(bytes: Buffer, mimeType: string): Promise<ProcessedCover> {
    const source = sharp(bytes, { density: 220 }); // crisp at 1200px, empirically tuned
    const full = await source.clone().resize(1200, 630, { fit: "cover" }).webp({ quality: 82 }).toBuffer();
    const narrow = await source.clone().resize(640).webp({ quality: 82 }).toBuffer();
    const placeholder = await source.clone().resize(24).webp({ quality: 40 }).toBuffer();

    return {
        contentHash: sha256Hex(full),   // what the database dedups on
        mimeType: "image/webp",
        width: 1200, height: 630,
        placeholder: `data:image/webp;base64,${placeholder.toString("base64")}`,
        full, narrow,
    };
}

Two reasons justify paying for a server-side rasterization step rather than shipping the SVG raw, and neither of them has anything to do with file size:

  • One canonical image. A server-side raster is the one picture the author actually saw. A client-rendered SVG is as many pictures as there are combinations of browser, OS font-hinting, and mix-blend-mode support among your readers.
  • Zero rendering cost for the reader. Ten cards on a journal listing page are ten gradient computations per scroll, per device if a browser has to do the work; they are ten already-decoded WebP images, at essentially no cost, if a server did it once.

The contentHash line deserves a second look, because it is doing more than bookkeeping: it dedups covers on the rendered bytes, not on the inputs that produced them. Two different posts that happen to render pixel-for-pixel identical images — an edge case, not the common one, but a real one for a small blog with a small set of hue families — automatically share one MediaAsset row rather than storing the same picture twice. Deduplication is not a caching optimization bolted on afterward; it falls straight out of choosing content-hashing as the identity of a "cover" in the first place.

One boundary I want to be explicit about, because it is the kind of detail that looks obvious only in hindsight: this cover is deliberately never reused as the og:image a chat application unfurls when a link is shared. A messenger preview needs a readable headline, not a decorative gradient — so the Open Graph image is its own small text-card renderer, sharing only the accent hue with the cover sitting next to it on the page. Two problems that look similar on the surface ("make a nice-looking image for this post") are not always the same problem, and conflating them here would have produced a worse answer to both.

The Architecture Behind the Algorithm: A Port Sized for a Future It Doesn't Use Yet

Everything above describes today's generator. But the interface it implements was not written for today's generator — it was written for the generator I have not built yet.

export interface ImageGenerator {
    generate(brief: CoverBrief, signal?: AbortSignal): Promise<GeneratedImage>;
}

export class ProceduralImageGenerator implements ImageGenerator {
    async generate(brief: CoverBrief): Promise<GeneratedImage> {
        // ...builds the SVG described above and returns it as bytes.
        // Never actually awaits a network call, never actually throws.
    }
}

Read that interface again: it is async, and its Promise can reject. Today's only real implementation never awaits anything meaningful and never throws — it would be entirely honest, in isolation, to make generate a plain synchronous function. I did not, and the reason is a direct, deliberate application of the Liskov Substitution Principle, read in the direction that actually matters for API design: a contract should be shaped by its most demanding future implementer, not its easiest current one. An adapter that calls out to an image-generation API over the network must be async and must be able to fail — so writing the interface that way from day one means that adding such an adapter later is a new implementation of an existing contract, not a breaking change to every caller of the old one. The weak implementation trivially satisfies the strong contract; the reverse would not be true, and reversing it later would have meant touching every call site this interface has, at the exact moment I would rather be focused on the new adapter itself.

FailingImageGenerator, which does nothing but reject, exists purely so the error-handling path through the orchestration layer has something real to exercise in a test today, without waiting for that future adapter to exist first — a small, deliberate investment in being able to prove a corner of the system works before the system that will actually need it has been built.

Porting the Idea: The Same Algorithm, Fifteen Lines, Any Language

Nothing described above is specific to TypeScript, or to Node, or to this codebase. The van der Corput sequence is arithmetic on integers; the PRNG is arithmetic on 32-bit integers. Here is the entire hue-assignment algorithm in Python:

MASK32 = 0xFFFFFFFF

def van_der_corput(ordinal: int) -> float:
    bits = ordinal
    result = 0.0
    denominator = 1
    while bits > 0:
        denominator *= 2
        result += (bits & 1) / denominator
        bits >>= 1
    return result

def hue_for_ordinal(ordinal: int) -> float:
    return van_der_corput(ordinal) * 360.0

And the deterministic PRNG. The one wrinkle worth calling out explicitly, because it is exactly the kind of gap that silently produces "it mostly works, except the numbers are subtly wrong" bugs: Python integers have arbitrary precision, and JavaScript's do not. JavaScript's |0, >>> 0, and Math.imul are all doing one job — forcing an intermediate result back into 32-bit unsigned arithmetic with wraparound — and Python has no such implicit behavior. Every place the original does that silently, the port has to do it explicitly, with & MASK32:

def cyrb128(value: str) -> tuple[int, int, int, int]:
    h1, h2, h3, h4 = 1779033703, 3144134277, 1013904242, 2773480762
    for ch in value:
        k = ord(ch)
        h1 = (h2 ^ ((h1 ^ k) * 597399067)) & MASK32
        h2 = (h3 ^ ((h2 ^ k) * 2869860233)) & MASK32
        h3 = (h4 ^ ((h3 ^ k) * 951274213)) & MASK32
        h4 = (h1 ^ ((h4 ^ k) * 2716044179)) & MASK32
    h1 = ((h3 ^ (h1 >> 18)) * 597399067) & MASK32
    h2 = ((h4 ^ (h2 >> 22)) * 2869860233) & MASK32
    h3 = ((h1 ^ (h3 >> 17)) * 951274213) & MASK32
    h4 = ((h2 ^ (h4 >> 19)) * 2716044179) & MASK32
    h1 ^= h2 ^ h3 ^ h4
    h2 ^= h1; h3 ^= h1; h4 ^= h1
    return h1 & MASK32, h2 & MASK32, h3 & MASK32, h4 & MASK32

def sfc32(seed_a: int, seed_b: int, seed_c: int, seed_d: int):
    state = [seed_a & MASK32, seed_b & MASK32, seed_c & MASK32, seed_d & MASK32]

    def next_value() -> float:
        a, b, c, d = state
        t = (a + b) & MASK32
        a = (b ^ (b >> 9)) & MASK32
        b = (c + ((c << 3) & MASK32)) & MASK32
        c = (((c << 21) & MASK32) | (c >> 11)) & MASK32
        d = (d + 1) & MASK32
        t = (t + d) & MASK32
        c = (c + t) & MASK32
        state[0], state[1], state[2], state[3] = a, b, c, d
        return t / 4294967296

    return next_value

def prng_from_seed(seed: str):
    a, b, c, d = cyrb128(seed)
    return sfc32(a, b, c, d)

Go tells the opposite story, and it is worth seeing right after Python for the contrast alone: Go's uint32 is a genuinely fixed-width type, so overflow wraps automatically, and the masking Python had to spell out by hand simply disappears. The port becomes close to a transliteration:

func vanDerCorput(ordinal uint32) float64 {
    bits := ordinal
    result := 0.0
    denominator := 1.0
    for bits > 0 {
        denominator *= 2
        result += float64(bits&1) / denominator
        bits >>= 1
    }
    return result
}

func hueForOrdinal(ordinal uint32) float64 {
    return vanDerCorput(ordinal) * 360.0
}

func cyrb128(value string) (uint32, uint32, uint32, uint32) {
    h1, h2, h3, h4 := uint32(1779033703), uint32(3144134277), uint32(1013904242), uint32(2773480762)
    for _, r := range value {
        k := uint32(r)
        h1 = h2 ^ ((h1 ^ k) * 597399067)
        h2 = h3 ^ ((h2 ^ k) * 2869860233)
        h3 = h4 ^ ((h3 ^ k) * 951274213)
        h4 = h1 ^ ((h4 ^ k) * 2716044179)
    }
    h1 = (h3 ^ (h1 >> 18)) * 597399067
    h2 = (h4 ^ (h2 >> 22)) * 2869860233
    h3 = (h1 ^ (h3 >> 17)) * 951274213
    h4 = (h2 ^ (h4 >> 19)) * 2716044179
    h1 ^= h2 ^ h3 ^ h4
    h2 ^= h1
    h3 ^= h1
    h4 ^= h1
    return h1, h2, h3, h4
}

type Prng func() float64

func sfc32(a, b, c, d uint32) Prng {
    return func() float64 {
        t := a + b
        a = b ^ (b >> 9)
        b = c + (c << 3)
        c = (c << 21) | (c >> 11)
        d = d + 1
        t = t + d
        c = c + t
        return float64(t) / 4294967296
    }
}

The lesson generalizes past these two languages: any language with a genuine fixed-width unsigned integer type (uint32_t in C, u32 in Rust, an Int masked appropriately in Swift, an int treated as unsigned via >>> in Java) ports this with almost no friction. Any language with arbitrary-precision integers by default (Python being the obvious example, but also Ruby or a naive port to a big-integer library in any ecosystem) needs exactly one disciplined habit: mask after every operation that JavaScript's |0/>>> 0/Math.imul was masking implicitly. Miss one, and you will not get an error — you will get a different but plausible-looking stream of numbers, silently, which is the most dangerous kind of bug this whole system exists to prevent.

What I Would Tell Anyone Building This Themselves

If there is a single idea worth carrying away from this whole exercise, it is that procedural generation is not a euphemism for randomness — it is the opposite of randomness, dressed up to look spontaneous. Every visible decision in a generated cover — which hue, how many curves, how tall the waveform, which word gets clipped into a letterform — traces back to either a piece of real content (the title, the excerpt, the category) or a deterministic function of a stable identity (the slug). Nothing is left to chance in the sense that matters: the chance of an unreproducible result is exactly zero, by construction, and that is the entire point.

The three constraints from the very first section — deterministic, branded, free at read time — are not a checklist I filled in after the fact to justify what I had already built. They were the specification, in the truest sense Robert Martin uses the word: not a wish list, but a set of forces that, taken together, leave surprisingly little room for the design to be anything other than what it became. A hash function fails the second constraint. Math.random fails the first. Client-side SVG rendering fails the third. What survives the elimination is, in essence, the system described above — which is a more useful thing to have learned than the system itself.

The full implementation lives in backend/src/media/* of* this site's own repository* —* cover-hue.ts, cover-seed.ts, cover-composition.ts, and their neighbors — should you want to read further than an article can comfortably reproduce.

RELATED PROJECT

How I Built This Site: From a Static Portfolio to a Self-Hosted CMS
This site didn't start as a CMS - it started as a static Vite SPA with a hand-built design system. Here's the full story of how it turned into a database-backed Next.js app with its own admin panel, bilingual content, and a VPS I provisioned myself.
View case study