#!/usr/bin/env node /** * Generate design-system translation cards for the concept world catalog. * * Each card renders one world as the web design system it implies: palette * swatches and material chips, a type specimen, core UI components, and one * small screen composition. The prompt is built from the concept's authored * Palette/material and Type/composition rules, so the card tests exactly the * translation the catalog claims. * * Output: site/public/worlds/cards/.webp (16:9, 1K, WebP) plus * manifest.json mapping id -> { hash, generatedAt }. hash is the concept's * content hash (same fingerprint reviews pin), so an edited concept marks its * card stale and a rerun regenerates only what changed. * * Two image kinds per concept: * board (.webp): the design-system specimen sheet (palette, type, * components, phone composition). * hero (-hero.webp): a full-frame desktop landing page composed in the * world's system; the 16:9 frame IS the viewport, proving the world holds * at desktop scale. * * Usage: * node scripts/generate-world-cards.mjs # all stale/missing * node scripts/generate-world-cards.mjs --kind hero # board | hero | both * node scripts/generate-world-cards.mjs --only # specific concepts * node scripts/generate-world-cards.mjs --limit 5 # first N stale jobs * node scripts/generate-world-cards.mjs --force # ignore manifest * node scripts/generate-world-cards.mjs --model pro # lite | pro | gpt * node scripts/generate-world-cards.mjs --out # alternate output dir */ import { GoogleGenAI } from '@google/genai'; import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { conceptContentHash, readConceptCatalog } from '../skill/scripts/lib/concept-catalog.mjs'; import { compositionContentHash, readCompositionCatalog } from '../skill/scripts/lib/composition-catalog.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const CARD_DIR = join(ROOT, 'site', 'public', 'worlds', 'cards'); // gpt (the default) won the human calibration bake-off decisively; lite and // pro remain for future comparisons. const MODELS = { lite: { kind: 'gemini', id: 'models/gemini-3.1-flash-lite-image', imageSize: '1K' }, pro: { kind: 'gemini', id: 'models/gemini-3-pro-image', imageSize: '2K' }, gpt: { kind: 'openai', id: 'gpt-image-2', size: '2048x1152', quality: 'high' }, }; const DEFAULT_MODEL = 'gpt'; const CONCURRENCY = 6; const MAX_ATTEMPTS = 4; function loadEnv() { if (process.env.GEMINI_API_KEY || process.env.GOOGLE_CLOUD_API_KEY) return; const envPath = join(ROOT, '.env'); if (!existsSync(envPath)) return; for (const line of readFileSync(envPath, 'utf8').split('\n')) { const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); if (match && !process.env[match[1]]) process.env[match[1]] = match[2].replace(/^"|"$/g, ''); } } function boardTitle(concept) { return concept.form .split(',')[0] .replace(/^(a|an|the)\s+/i, '') .replace(/\b\w/g, letter => letter.toUpperCase()); } function buildHeroPrompt(concept) { const palette = concept.system[0].replace(/^Palette\/material:\s*/, ''); const type = concept.system[1].replace(/^Type\/composition:\s*/, ''); return `A complete desktop landing page filling the entire 16:9 frame edge to edge, as if screenshotted at 1440 pixels wide: a slim top navigation with a wordmark and a few links, a hero section with a large headline, one supporting line, and a primary call-to-action button, and the top of the next content section just visible at the bottom edge. No browser chrome, no device mockup, no specimen-board framing, no caption: the page itself is the entire image. The page is designed wholly inside this visual world: ${concept.form}. Palette and materials: ${palette} Typography and composition: ${type} Atmosphere, never written on the page but carried into it: ${concept.spark} Light the page like the world: its hour, light quality, and mood shape the page ground, surfaces, and imagery, not just the accent color. A nocturnal or interior world produces a genuinely dark, atmospherically lit page. Invent a plausible fictional product or brand this world would naturally serve and write short realistic copy for it (plain punctuation, never an em dash): an invented name that reuses no brand, label, designer, or place name from the world description, a headline of at most eight words, one supporting sentence, and button labels. The result must read as a real, current, award-caliber website built from this world's laws: disciplined grid, aligned edges, believable interface details, generous intentional spacing. Not a poster, not a pastiche, a landing page. The world must live in the interface itself, not only in imagery: the navigation, buttons, cards, dividers, and type voices are built from the world's materials, colors, textures, and lettering traditions. A photograph or illustration may appear as content, but a generic clean website wearing a themed hero image is a failure; a stranger shown only the page footer or a single button should still recognize the world. Craft rules, all mandatory: every piece of interface copy is English, even when the world is Japanese, Arabic, or otherwise non-Latin in origin; at most one small non-Latin glyph may appear as a decorative motif, never in the wordmark, navigation, headlines, or body text, and when in doubt use none. Rendered materials must read premium and physically plausible: real metal, wood, paper, and glass with honest light, never plasticky gradient fakes of them. This is a product or editorial website, not a video-game HUD: no health bars, minimaps, or game-menu chrome unless the world itself is a game interface; when the world is screen- or game-born, art-direct it to contemporary award standard, never dated chrome. Vary the composition beyond the world's single most famous motif and refuse AI-cliche renderings of it (Matrix-style glyph rain, red recording dots); use the world's wider grammar.`; } function buildPrompt(concept) { const palette = concept.system[0].replace(/^Palette\/material:\s*/, ''); const type = concept.system[1].replace(/^Type\/composition:\s*/, ''); return `A single flat design-system specimen board, the kind a design studio produces to prove a visual world translates into web and app design. The board is titled "${boardTitle(concept)}" and fills the whole image edge to edge: no desk, wall, binder clips, pins, or drop shadows around it. The visual world being translated: ${concept.form}. Mood reference for art direction only: ${concept.spark} Board sections, left to right: 1. COLOR & MATERIAL column: five to seven labeled palette swatches plus two or three material texture chips, derived strictly from: ${palette}. 2. TYPOGRAPHY panel: a large display headline specimen and a short body-text block obeying: ${type}. 3. COMPONENTS panel: a primary button, a secondary button, a text input, one content card, and a small navigation bar, all styled by this exact system, with normal and active states. 4. One small phone-screen composition in the corner showing the system as a real app or landing screen. The board ground and dividers take their tone from the world's palette. Precise, crisp, flat graphic rendering like a printed specimen sheet; not a photograph or illustration of the world itself, only the interface system it yields. The only text on the board is the title, short section labels, and brief invented specimen words; never transcribe any sentence from these instructions onto the board.`; } async function extractImage(interaction) { if (interaction?.output_image?.data) return Buffer.from(interaction.output_image.data, 'base64'); for (const step of interaction?.steps || []) { if (step.type !== 'model_output' || !step.content) continue; for (const part of step.content) { if (part.type === 'image' && part.data) return Buffer.from(part.data, 'base64'); } } return null; } // Sequence-heavy families render two consecutive beats as a split frame; a // still image cannot show motion any other honest way. const SEQUENCE_FAMILIES = new Set(['narrative-scroll', 'reveal-transform', 'interaction-physics']); const MODE_FRAMES = { persuade: 'an extraordinary marketing page at desktop scale', operate: 'an extraordinary application workspace at desktop scale, real work visibly in progress', read: 'an extraordinary documentation or long-form reading page at desktop scale, structure visibly serving comprehension', experience: 'an extraordinary portfolio or showcase surface at desktop scale, the work itself owning the frame', }; function buildStagingPrompt(composition) { const modeFrame = MODE_FRAMES[composition.surface] || MODE_FRAMES.persuade; const hierarchy = composition.grammar[0].replace(/^Staging\/hierarchy:\s*/, ''); const sequence = composition.grammar[1].replace(/^Sequence\/attention:\s*/, ''); const controls = composition.grammar[2].replace(/^Controls\/state:\s*/, ''); const capture = SEQUENCE_FAMILIES.has(composition.familyId) ? 'Render two consecutive beats of the same page as one hard-cut split frame, a thin seam dividing left and right halves like film frames seconds apart. The staging visibly advances between the beats: something mid-reveal on the left has landed on the right, attention has moved, state has changed.' : composition.familyId === 'first-viewport' ? 'Render one cinematic frame of the opening screen at the most dramatic moment of its signature staging.' : 'Render one cinematic frame of the page caught mid-sequence, never its resting opening state: elements between states, partial reveals, momentum readable in the composition itself.'; const copy = composition.surface === 'operate' ? 'Invent a plausible product with short realistic English copy (plain punctuation, never an em dash): an invented name and real-looking task content, labels, and data, never placeholder text.' : 'Invent a plausible product with short realistic English copy (plain punctuation, never an em dash): an invented name, a headline of at most eight words, one supporting line, one committed action.'; return `${capture} The subject: ${modeFrame}, filling the entire 16:9 frame edge to edge. No browser chrome, no device mockup, no board framing, no caption. The staging: ${composition.form} What owns the frame: ${hierarchy} The motion to make visible: ${sequence} One interaction in play: ${controls}. Show exactly one control or element mid-gesture with its state visibly changing: a cursor in contact, a drag mid-flight, a value mid-update. Everything else holds still. This must read as interactive, dynamic, fully realized web design that raises the bar: monumental scale contrast, fearless negative space, editorial typographic confidence, real art direction discipline, and web technology pushed as far as the staging implies (${composition.webLeverage}). Choose a product domain the staging serves best; when it is technical or game-adjacent, art-direct it to contemporary award standard, never dated game chrome or generic sci-fi mission styling, and never lean on cliche AI-generated motifs like recording indicators. Identity stays quiet so the staging is the star: a monochrome ground, one accent color, one strong typeface family, wielded with award-winning conviction, never as a wireframe. Only the elements this staging requires exist on the page: no toolbars, settings chrome, feature grids, or decorative widgets beyond the one interaction in play. ${copy} Never transcribe these instructions onto the image. The viewer should gasp at the design first, then understand the mechanism from what the frame implies.`; } function promptFor(concept, kind) { if (kind === 'staging') return buildStagingPrompt(concept); return kind === 'hero' ? buildHeroPrompt(concept) : buildPrompt(concept); } async function generateGemini(ai, model, concept, kind) { const interaction = await ai.interactions.create({ model: model.id, input: promptFor(concept, kind), generation_config: { temperature: 1, top_p: 0.95, thinking_level: 'low', image_config: { image_size: model.imageSize, aspect_ratio: '16:9' }, }, response_modalities: ['image', 'text'], }); return extractImage(interaction); } // Set by main() so hero generation can pick up the concept's board as a // binding style reference (the board/hero pair must read as one system). let BOARD_REFERENCE_DIR = null; async function generateOpenAI(model, concept, kind) { // Heroes are generated from the specimen board when it exists: the edits // endpoint takes the board as reference so both images share one system. if (kind === 'hero' && BOARD_REFERENCE_DIR) { const boardPath = join(BOARD_REFERENCE_DIR, `${concept.id}.webp`); if (existsSync(boardPath)) { try { const form = new FormData(); form.append('model', model.id); form.append('image[]', new Blob([readFileSync(boardPath)], { type: 'image/webp' }), `${concept.id}.webp`); form.append('prompt', `The attached image is this world's design-system specimen board. Treat its palette, materials, type voices, and component grammar as binding reference; the page you render must read as the same system. ${promptFor(concept, kind)}`); form.append('size', model.size); form.append('quality', model.quality); const response = await fetch('https://api.openai.com/v1/images/edits', { method: 'POST', headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` }, body: form, }); const json = await response.json(); if (!response.ok) throw new Error(json.error?.message || `HTTP ${response.status}`); const b64 = json.data?.[0]?.b64_json; if (b64) return Buffer.from(b64, 'base64'); } catch (error) { process.stderr.write(` board-reference hero failed for ${concept.id} (${error.message}); falling back to plain generation\n`); } } } const response = await fetch('https://api.openai.com/v1/images/generations', { method: 'POST', headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: model.id, prompt: promptFor(concept, kind), size: model.size, quality: model.quality, }), }); const json = await response.json(); if (!response.ok) throw new Error(json.error?.message || `HTTP ${response.status}`); const b64 = json.data?.[0]?.b64_json; return b64 ? Buffer.from(b64, 'base64') : null; } async function generateCard(ai, model, concept, kind) { let lastError; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { try { const raw = model.kind === 'openai' ? await generateOpenAI(model, concept, kind) : await generateGemini(ai, model, concept, kind); if (!raw) throw new Error('no image part in response'); return await sharp(raw).webp({ quality: 90 }).toBuffer(); } catch (error) { lastError = error; const wait = attempt * 8000; process.stderr.write(` retry ${concept.id} (${attempt}/${MAX_ATTEMPTS}): ${error.message}\n`); await new Promise(resolve => setTimeout(resolve, wait)); } } throw lastError; } async function main() { loadEnv(); const args = process.argv.slice(2); const modelKey = args.includes('--model') ? args[args.indexOf('--model') + 1] : DEFAULT_MODEL; const model = MODELS[modelKey]; if (!model) throw new Error(`unknown --model ${modelKey}; use ${Object.keys(MODELS).join(', ')}`); const outDir = args.includes('--out') ? args[args.indexOf('--out') + 1] : CARD_DIR; const manifestPath = join(outDir, 'manifest.json'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1].split(',').filter(Boolean) : null; const limit = args.includes('--limit') ? Number(args[args.indexOf('--limit') + 1]) : Infinity; const force = args.includes('--force'); const kindArg = args.includes('--kind') ? args[args.indexOf('--kind') + 1] : 'both'; if (!['board', 'hero', 'both', 'staging'].includes(kindArg)) throw new Error(`unknown --kind ${kindArg}; use board, hero, both, staging`); const kinds = kindArg === 'both' ? ['board', 'hero'] : [kindArg]; const isStaging = kindArg === 'staging'; const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_CLOUD_API_KEY; if (model.kind === 'gemini' && !geminiKey) throw new Error('set GEMINI_API_KEY or GOOGLE_CLOUD_API_KEY (repo .env)'); if (model.kind === 'openai' && !process.env.OPENAI_API_KEY) throw new Error('set OPENAI_API_KEY (repo .env)'); const ai = model.kind === 'gemini' ? new GoogleGenAI({ apiKey: geminiKey }) : null; // Staging images render composition-catalog entries; boards and heroes // render world-catalog concepts. Both share the manifest and output dir. const concepts = isStaging ? readCompositionCatalog( join(ROOT, 'skill', 'scripts', 'composition-ingredients.json'), join(ROOT, 'skill', 'scripts', 'composition-reviews.json') ).compositions : readConceptCatalog( join(ROOT, 'skill', 'scripts', 'concept-ingredients.json'), join(ROOT, 'skill', 'scripts', 'concept-reviews.json') ).concepts; const hashOf = isStaging ? compositionContentHash : conceptContentHash; mkdirSync(outDir, { recursive: true }); BOARD_REFERENCE_DIR = outDir; const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {}; const writeManifest = () => writeFileSync( manifestPath, `${JSON.stringify(Object.fromEntries(Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b))), null, 2)}\n` ); const fileFor = (concept, kind) => join(outDir, `${concept.id}${kind === 'hero' ? '-hero' : ''}.webp`); const stampKey = kind => (kind === 'hero' ? 'heroGeneratedAt' : 'generatedAt'); const queue = []; for (const concept of concepts) { if (only && !only.includes(concept.id)) continue; const hash = hashOf(concept); const row = manifest[concept.id]; for (const kind of kinds) { const needed = force || !existsSync(fileFor(concept, kind)) || row?.hash !== hash || !row?.[stampKey(kind)]; if (needed || only) queue.push({ concept, kind }); } } queue.splice(limit); if (only) { const found = new Set(queue.map(job => job.concept.id)); const missing = only.filter(id => !found.has(id)); if (missing.length) throw new Error(`concept not found: ${missing.join(', ')}`); } process.stdout.write(`generating ${queue.length} images (${kinds.join('+')}) across ${concepts.length} concepts with ${modelKey} -> ${outDir}\n`); let done = 0; let failed = 0; const worker = async () => { while (queue.length > 0) { const { concept, kind } = queue.shift(); try { const webp = await generateCard(ai, model, concept, kind); writeFileSync(fileFor(concept, kind), webp); const row = manifest[concept.id] || {}; row.hash = hashOf(concept); row.model = modelKey; row[stampKey(kind)] = new Date().toISOString(); manifest[concept.id] = row; writeManifest(); done += 1; process.stdout.write(` [${done}] ${concept.id} ${kind} (${Math.round(webp.length / 1024)}KB)\n`); } catch (error) { failed += 1; process.stderr.write(` FAILED ${concept.id} ${kind}: ${error.message}\n`); } } }; await Promise.all(Array.from({ length: CONCURRENCY }, worker)); // Prune manifest entries for ids that left both catalogs (they share the // manifest, so pruning must check the union). const worldIds = readConceptCatalog( join(ROOT, 'skill', 'scripts', 'concept-ingredients.json'), join(ROOT, 'skill', 'scripts', 'concept-reviews.json') ).concepts.map(concept => concept.id); const stagingIds = readCompositionCatalog( join(ROOT, 'skill', 'scripts', 'composition-ingredients.json'), join(ROOT, 'skill', 'scripts', 'composition-reviews.json') ).compositions.map(composition => composition.id); const liveIds = new Set([...worldIds, ...stagingIds]); for (const id of Object.keys(manifest)) { if (!liveIds.has(id)) delete manifest[id]; } writeManifest(); process.stdout.write(`done: ${done} generated, ${failed} failed\n`); if (failed > 0) process.exitCode = 1; } main().catch(error => { process.stderr.write(`${error.message}\n`); process.exit(1); });