Turning one TypeScript CardSpec into 49 static palette PNGs
Belle AI (aicoloranalysis.io) publishes downloadable color-palette cards: one for each of the twelve sub-seasons and four parent seasons, plus cards for 20 celebrity pages and 13 "color analysis for β¦" audience pages. Al
Belle AI (aicoloranalysis.io) publishes downloadable color-palette cards: one for each of the twelve sub-seasons and four parent seasons, plus cards for 20 celebrity pages and 13 "color analysis for β¦" audience pages. All 49 come from one CardSpec shape and one React layout, and each has a fixed .png URL. This post walks through how that is wired and what I checked on the live site.
The code is from the repository as of 2026-11-04 (commit d2a4095, the head on that day, no uncommitted changes). The live HTTP checks are from the same day.
One spec, four builders
Season colors live in SEASON_PALETTES in src/lib/color-palette-data.ts. Celebrity and audience pages have their own data files. src/lib/palette-card-spec.ts turns all of them into one shape, and the renderer only understands that shape:
export interface CardSpec {
key: string;
eyebrow: string;
title: string;
subtitle: string;
traits?: { label: string; value: string }[];
sections: CardSection[];
stripe: string[];
footerPath: string;
footerTagline: string;
}
A section is either a grid of color chips (kind: "chips") or a list of rows (kind: "rows"), which the parent-season cards use. Four builder functions fill the spec:
-
buildSeasonSpectakes one of the twelve sub-season slugs (light-springand so on) -
buildParentSpectakesspring,summer,autumnorwinter -
buildCelebritySpecreadsCELEBRITY_DATAand prefixes the card key withcelebrity- -
buildAttributeSpecreadsATTRIBUTESand prefixes the key withfor-
getPaletteCardSpec(key) picks the builder from the key and returns null for an unknown key.
The file comments explain why the text on the cards is English-only: the fonts bundled for the cards only have Latin glyphs, so names in other scripts would render as boxes (the comment names CJK and Cyrillic). The rest of the site is multilingual; this limit applies to the PNGs only.
How the 49 file names are listed
allPaletteCardKeys() is the one list that static generation uses:
export function allPaletteCardKeys(): string[] {
return [
...SEASON_SLUGS,
...PARENT_SEASONS,
...CELEBRITY_SLUGS.map(celebrityCardKey),
...ATTRIBUTE_SLUGS.map(attributeCardKey),
];
}
I ran that function against the current tree by importing the data files with jiti. It returned 49 keys: 12 sub-seasons, 4 parent seasons, 20 celebrities and 13 audience pages, and every key resolved through getPaletteCardSpec (no null).
The two prefixes in src/lib/palette-card.ts keep those names from colliding with the season slugs:
export function celebrityCardKey(slug: string): string {
return `celebrity-${slug}`;
}
export function attributeCardKey(slug: string): string {
return `for-${slug}`;
}
The route: static params and a PNG response
The handler is src/app/palette-cards/[file]/route.tsx, outside the locale routes. A comment in palette-card.ts gives the reason: the URL ends in a real .png, and paths containing a dot are excluded from the next-intl proxy, so a language cookie cannot redirect it and next/image can fetch it. Here it is, comments removed:
export function generateStaticParams() {
return allPaletteCardKeys().map((key) => ({ file: `${key}.png` }));
}
export async function GET(_req: Request, { params }: { params: Promise<{ file: string }> }) {
const { file } = await params;
if (!file.endsWith(".png")) return new Response("Not found", { status: 404 });
const spec = getPaletteCardSpec(file.slice(0, -".png".length));
if (!spec) return new Response("Not found", { status: 404 });
return new ImageResponse(renderPaletteCard(spec), {
...PALETTE_CARD_SIZE,
fonts: await loadCardFonts(),
headers: {
"Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
},
});
}
PALETTE_CARD_SIZE is { width: 1200, height: 1600 }. The comment at the top of the route says all 49 are generated as static images at build time. I did not run a build, so what I can show for that is the live response headers below. The comment next to the headers explains the one-day s-maxage: next/image works out the lifetime of its optimized copies from it, so after a redesign the cards refresh within a day. The browser's max-age is one hour.
The layout is in src/lib/palette-card-render.tsx. It uses explicit display: flex on every div with several children (a Satori requirement, according to a comment there) and loads Playfair Display and Inter from src/lib/card-fonts. Those are .woff files because, per another comment, Satori does not support woff2. The footer prints the page path and "Free to share and print with credit to Belle AI". Pages show the same PNGs through a PaletteCardImage component that wraps next/image; I did not test the optimized copies that produces.
Live spot checks
On 2026-11-04 I requested five cards and one wrong name from production with curl -4:
| File | HTTP | Content-Type | Bytes | PNG |
|---|---|---|---|---|
light-spring.png |
200 | image/png | 238,147 | 1200Γ1600, RGBA |
deep-winter.png |
200 | image/png | 281,643 | 1200Γ1600, RGBA |
spring.png |
200 | image/png | 212,706 | 1200Γ1600, RGBA |
for-dark-skin.png |
200 | image/png | 232,365 | 1200Γ1600, RGBA |
celebrity-<slug>.png (one of the 20) |
200 | image/png | 226,077 | 1200Γ1600, RGBA |
nonexistent-slug.png |
404 | text/plain | 9 | β |
All five cards returned Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400, the same string as in the route. The 404 returned public, max-age=0, must-revalidate. The pixel size matches PALETTE_CARD_SIZE. I did not fetch all 49 files.
The live image sitemap (/sitemap-images.xml, HTTP 200) lists 49 distinct /palette-cards/ image URLs, which matches the count from the code. I did not check anything else in it. The page that shows the cards, /color-palette, embeds the 16 season cards (12 sub-seasons and 4 parent seasons).
What I would change
I have not changed any of this.
- If cards must carry non-Latin names, either bundle fonts that cover those scripts or keep the English-only rule, which is what the code does today.
- The repository has no test files (
git ls-filesmatches no test or spec files;package.jsonhasdev,buildandlint). The "49 keys, none resolving tonull" check I ran by hand would fit in a short test. -
generateStaticParamstakes its list fromallPaletteCardKeys(), so adding a celebrity row should add one more image at build time without touching the renderer. I did not run a build to confirm it.
What this does not show
- I did not run
next build, so I cannot show how many files the build writes or how long it takes. - I downloaded five of the 49 PNGs. The 49-key count comes from importing the data files, which checks the list, not that every card renders.
- I did not run
ImageResponselocally, so the description of the layout comes from readingpalette-card-render.tsx. - Beyond the response headers, I did not measure CDN behavior (hit rates, regions).
- Nothing here checks the color data itself, how accurate any analysis is, or whether anyone should reuse the images. It covers how the PNGs are generated and served.
How do you keep marketing images and the on-site UI in sync: one spec like this, or separate design exports?
The color palette overview on Belle AI shows the season cards.
This article was drafted with help from an AI assistant and edited by me. The code excerpts come from the Belle AI web repository, and the measurements are from my own runs on 2026-11-04.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.