mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Expand concept system: modes, ratings, re-roll, breadth strategy
Catalog: mode-aligned staging surfaces (persuade/operate/read/experience), star ratings on approvals feeding challenger draw weights, family retirements, authoring strategy and territory guide, rework and breadth authoring rounds, composition mining from rejected worlds. Seed: six challengers (two per tier), --reroll chains, --mode staging filter, rating-weighted draws. New-work: Present/visualize/re-roll flow, image-gen requirement, register-neutral vocabulary. Pipeline: per-mode staging prompts with split frames, hero-from-board reference generation, render-safety guards. Labs: ratings UI, unrated filter, mode chips, composition approve-guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
144cee5c36
commit
7557935fdb
+3
-1
@@ -633,6 +633,8 @@ function generateCFConfig(buildDir) {
|
||||
/docs/teach /docs/init 301
|
||||
/anti-patterns /slop#catalog 301
|
||||
/visual-mode /slop#see-it 301
|
||||
/worlds /labs/worlds 301
|
||||
/worlds/stagings /labs/stagings 301
|
||||
/neon-mirai /neo-mirai/ 301
|
||||
/neon-mirai/ /neo-mirai/ 301
|
||||
/cases/neon-mirai /cases/neo-mirai 301
|
||||
@@ -644,7 +646,7 @@ function generateCFConfig(buildDir) {
|
||||
// Without this, the SPA fallback serves index.html for function routes
|
||||
const routes = {
|
||||
version: 1,
|
||||
include: ['/api/download/*'],
|
||||
include: ['/api/download/*', '/worlds/cards/*'],
|
||||
exclude: [],
|
||||
};
|
||||
fs.writeFileSync(path.join(buildDir, '_routes.json'), JSON.stringify(routes, null, 2));
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/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/<concept-id>.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 (<id>.webp): the design-system specimen sheet (palette, type,
|
||||
* components, phone composition).
|
||||
* hero (<id>-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 <id[,id]> # 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 <dir> # 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);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Upload world design-system cards to the R2 bucket that backs
|
||||
* functions/worlds/cards/[[file]].js. Skips files already uploaded at their
|
||||
* current generation (tracked in a local .published.json sidecar), so routine
|
||||
* runs only push what changed. Requires an authenticated wrangler (same auth
|
||||
* as `bun run deploy`) and the bucket:
|
||||
* wrangler r2 bucket create impeccable-world-cards
|
||||
*
|
||||
* Usage:
|
||||
* bun run world-cards:publish # changed files only
|
||||
* bun run world-cards:publish -- --force # everything
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const run = promisify(execFile);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CARD_DIR = join(ROOT, 'site', 'public', 'worlds', 'cards');
|
||||
const STATE_PATH = join(CARD_DIR, '.published.json');
|
||||
const BUCKET = 'impeccable-world-cards';
|
||||
const CONCURRENCY = 6;
|
||||
|
||||
const force = process.argv.includes('--force');
|
||||
const manifest = JSON.parse(readFileSync(join(CARD_DIR, 'manifest.json'), 'utf8'));
|
||||
const state = existsSync(STATE_PATH) ? JSON.parse(readFileSync(STATE_PATH, 'utf8')) : {};
|
||||
|
||||
const files = readdirSync(CARD_DIR).filter(file => file.endsWith('.webp'));
|
||||
const queue = files.filter(file => {
|
||||
if (force) return true;
|
||||
const id = file.replace(/(-hero)?\.webp$/, '');
|
||||
const stamp = file.endsWith('-hero.webp') ? manifest[id]?.heroGeneratedAt : manifest[id]?.generatedAt;
|
||||
return !stamp || state[file] !== stamp;
|
||||
});
|
||||
|
||||
console.log(`publishing ${queue.length} of ${files.length} cards to r2://${BUCKET}`);
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
|
||||
async function upload(file) {
|
||||
const contentType = 'image/webp';
|
||||
await run('wrangler', [
|
||||
'r2', 'object', 'put', `${BUCKET}/${file}`,
|
||||
'--file', join(CARD_DIR, file),
|
||||
'--content-type', contentType,
|
||||
'--remote',
|
||||
], { cwd: ROOT });
|
||||
}
|
||||
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift();
|
||||
try {
|
||||
await upload(file);
|
||||
const id = file.replace(/(-hero)?\.webp$/, '');
|
||||
state[file] = (file.endsWith('-hero.webp') ? manifest[id]?.heroGeneratedAt : manifest[id]?.generatedAt) || new Date().toISOString();
|
||||
writeFileSync(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
|
||||
done += 1;
|
||||
if (done % 25 === 0 || queue.length === 0) console.log(` ${done} uploaded, ${queue.length} remaining`);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(` FAILED ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
||||
|
||||
try {
|
||||
await run('wrangler', [
|
||||
'r2', 'object', 'put', `${BUCKET}/manifest.json`,
|
||||
'--file', join(CARD_DIR, 'manifest.json'),
|
||||
'--content-type', 'application/json',
|
||||
'--remote',
|
||||
], { cwd: ROOT });
|
||||
console.log(' manifest.json uploaded');
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(` FAILED manifest.json: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log(`done: ${done} uploaded, ${failed} failed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
// World cards are served from R2 by functions/worlds/cards/[[file]].js.
|
||||
// Astro copies site/public/worlds/cards (local generation output) into the
|
||||
// static build; this strips it so deploys stay light and the Function owns
|
||||
// the route. Runs as part of `bun run build`.
|
||||
|
||||
import { rmSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const buildCards = join(dirname(fileURLToPath(import.meta.url)), '..', 'build', 'worlds', 'cards');
|
||||
if (existsSync(buildCards)) {
|
||||
rmSync(buildCards, { recursive: true });
|
||||
console.log('✓ Stripped local world cards from build output (served from R2)');
|
||||
}
|
||||
@@ -25,11 +25,11 @@ export const SUITES = {
|
||||
triggers: [
|
||||
...COMMON_INFRA_PATTERNS,
|
||||
/^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|concept-ingredients|concept-reviews|concept-seed|context|context-signals|critique-storage|design-parser|hook|impeccable-paths|is-generated|lib\/(concept-catalog|provider|surface-briefs|target-slug)|pin|surface-brief|validate-concept-catalog))/,
|
||||
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|composition-ingredients|composition-reviews|concept-ingredients|concept-reviews|concept-seed|context|context-signals|critique-storage|design-parser|hook|impeccable-paths|is-generated|lib\/(composition-catalog|concept-catalog|provider|surface-briefs|target-slug)|pin|surface-brief|validate-concept-catalog))/,
|
||||
/^site\/(pages|content|components|layouts)\//,
|
||||
/^README(\.npm)?\.md$/,
|
||||
/^cli\/bin\//,
|
||||
/^tests\/(build|cleanup-deprecated|cli-ignores|concept-seed|context|context-signals|critique-storage|design-parser|docs-integrity|github-sheriff|hook|hook-build|impeccable-paths|openai-plugin|pin|shiki-theme|skills-cli|slop-catalog|surface-brief|target-args|test-suites|theme|windows-path-fix|worlds-review-vite-plugin|zip)\.test\.(js|mjs)$/,
|
||||
/^tests\/(build|cleanup-deprecated|cli-ignores|concept-seed|context|context-signals|critique-storage|design-parser|docs-integrity|github-sheriff|hook|hook-build|impeccable-paths|live-ui-lab|openai-plugin|pin|shiki-theme|skills-cli|slop-catalog|surface-brief|target-args|test-suites|theme|windows-path-fix|worlds-review-vite-plugin|zip)\.test\.(js|mjs)$/,
|
||||
/^tests\/lib\//,
|
||||
],
|
||||
commands: [
|
||||
@@ -63,6 +63,7 @@ export const SUITES = {
|
||||
'tests/hook-build.test.mjs',
|
||||
'tests/hook.test.mjs',
|
||||
'tests/impeccable-paths.test.mjs',
|
||||
'tests/live-ui-lab.test.mjs',
|
||||
'tests/openai-plugin.test.mjs',
|
||||
'tests/pin.test.mjs',
|
||||
'tests/target-args.test.mjs',
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
conceptContentHash,
|
||||
CONCEPT_STRENGTHS,
|
||||
normalizeConceptForm,
|
||||
SYSTEM_PREFIXES,
|
||||
validateConceptCatalog,
|
||||
validateConceptEntry,
|
||||
WELL_TIERS,
|
||||
} from '../skill/scripts/lib/concept-catalog.mjs';
|
||||
import {
|
||||
compositionContentHash,
|
||||
validateCompositionCatalog,
|
||||
} from '../skill/scripts/lib/composition-catalog.mjs';
|
||||
|
||||
const API_PATH = '/__impeccable/worlds';
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
@@ -51,18 +64,84 @@ function findConcept(catalog, id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertApprovedFloor(catalog, reviewData) {
|
||||
const approvedIds = new Set(
|
||||
Object.entries(reviewData.reviews)
|
||||
.filter(([, review]) => review.status === 'approved')
|
||||
.map(([id]) => id)
|
||||
);
|
||||
const tierByWell = new Map((catalog.wells || []).map(well => [well.id, well.tier]));
|
||||
const approvedTiers = new Set(
|
||||
catalog.families
|
||||
.filter(family => family.concepts.some(concept => approvedIds.has(concept.id)))
|
||||
.map(family => tierByWell.get(family.well))
|
||||
.filter(tier => WELL_TIERS.includes(tier))
|
||||
);
|
||||
if (approvedIds.size < 3 || approvedTiers.size < WELL_TIERS.length) {
|
||||
throw new Error('At least three approved concepts covering every challenger tier must remain available to the challenger');
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidCatalog(catalog, reviewData) {
|
||||
const { errors } = validateConceptCatalog(catalog, reviewData, { requireApprovedMinimum: false });
|
||||
if (errors.length > 0) throw new Error(errors[0]);
|
||||
}
|
||||
|
||||
function validateTags(tags) {
|
||||
return Array.isArray(tags)
|
||||
&& tags.length === 3
|
||||
&& tags.every(tag => typeof tag === 'string' && tag.trim().length >= 2 && tag.trim().length <= 40);
|
||||
}
|
||||
|
||||
function validateSystem(system) {
|
||||
return Array.isArray(system)
|
||||
&& system.length === SYSTEM_PREFIXES.length
|
||||
&& system.every((rule, index) =>
|
||||
typeof rule === 'string'
|
||||
&& rule.trim().length >= 12
|
||||
&& rule.trim().length <= 180
|
||||
&& rule.trim().startsWith(SYSTEM_PREFIXES[index])
|
||||
);
|
||||
}
|
||||
|
||||
export function worldsReviewPlugin({ root = process.cwd() } = {}) {
|
||||
const catalogPath = path.join(root, 'skill', 'scripts', 'concept-ingredients.json');
|
||||
const reviewsPath = path.join(root, 'skill', 'scripts', 'concept-reviews.json');
|
||||
const compositionCatalogPath = path.join(root, 'skill', 'scripts', 'composition-ingredients.json');
|
||||
const compositionReviewsPath = path.join(root, 'skill', 'scripts', 'composition-reviews.json');
|
||||
let mutationQueue = Promise.resolve();
|
||||
|
||||
// Composition-catalog reviews share the review mechanics but none of the
|
||||
// world catalog's floors or editing; v1 supports the review action only.
|
||||
async function mutateComposition(body) {
|
||||
if (body.action !== 'review') throw new Error('Compositions support the review action only');
|
||||
if (!REVIEW_STATUSES.has(body.status)) throw new Error('Review status is invalid');
|
||||
const note = typeof body.note === 'string' ? body.note.trim() : '';
|
||||
if (note.length > 500) throw new Error('Review note must be 500 characters or fewer');
|
||||
const catalog = await readJson(compositionCatalogPath);
|
||||
const reviewData = await readJson(compositionReviewsPath);
|
||||
const entry = (catalog.compositions || []).find(composition => composition.id === body.id);
|
||||
if (!entry) throw new Error('Composition was not found');
|
||||
if (body.status === 'pending') {
|
||||
delete reviewData.reviews[body.id];
|
||||
} else {
|
||||
reviewData.reviews[body.id] = {
|
||||
status: body.status,
|
||||
reviewedBy: 'pbakaus',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
formHash: compositionContentHash(entry),
|
||||
...(note ? { note } : {}),
|
||||
};
|
||||
}
|
||||
reviewData.reviews = Object.fromEntries(Object.entries(reviewData.reviews).sort(([a], [b]) => a.localeCompare(b)));
|
||||
const { errors } = validateCompositionCatalog(catalog, reviewData);
|
||||
if (errors.length > 0) throw new Error(errors[0]);
|
||||
await writeJsonAtomic(compositionReviewsPath, reviewData);
|
||||
return { id: body.id, status: body.status, review: reviewData.reviews[body.id] || null };
|
||||
}
|
||||
|
||||
async function mutate(body) {
|
||||
if (body.catalog === 'compositions') return mutateComposition(body);
|
||||
const catalog = await readJson(catalogPath);
|
||||
const reviewData = await readJson(reviewsPath);
|
||||
const match = findConcept(catalog, body.id);
|
||||
@@ -70,6 +149,16 @@ export function worldsReviewPlugin({ root = process.cwd() } = {}) {
|
||||
|
||||
if (body.action === 'review') {
|
||||
if (!REVIEW_STATUSES.has(body.status)) throw new Error('Review status is invalid');
|
||||
// Composition strength is a routing verdict, not an approvable type:
|
||||
// every composition-typed world entry the reviewer processed was
|
||||
// rejected, and approved stagings live in the composition catalog.
|
||||
if (body.status === 'approved' && match.concept.strength === 'composition') {
|
||||
throw new Error('Stagings live in the composition catalog; reject it here and it joins the mining queue');
|
||||
}
|
||||
const note = typeof body.note === 'string' ? body.note.trim() : '';
|
||||
if (note.length > 500) throw new Error('Review note must be 500 characters or fewer');
|
||||
const previousStatus = reviewData.reviews[body.id]?.status || 'pending';
|
||||
const previousRating = reviewData.reviews[body.id]?.rating;
|
||||
if (body.status === 'pending') {
|
||||
delete reviewData.reviews[body.id];
|
||||
} else {
|
||||
@@ -77,22 +166,61 @@ export function worldsReviewPlugin({ root = process.cwd() } = {}) {
|
||||
status: body.status,
|
||||
reviewedBy: 'pbakaus',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
formHash: conceptContentHash(match.concept),
|
||||
...(note ? { note } : {}),
|
||||
...(body.status === 'approved' && [1, 2, 3].includes(previousRating) ? { rating: previousRating } : {}),
|
||||
};
|
||||
}
|
||||
if (previousStatus === 'approved' && body.status !== 'approved') assertApprovedFloor(catalog, reviewData);
|
||||
reviewData.reviews = Object.fromEntries(Object.entries(reviewData.reviews).sort(([a], [b]) => a.localeCompare(b)));
|
||||
assertValidCatalog(catalog, reviewData);
|
||||
await writeJsonAtomic(reviewsPath, reviewData);
|
||||
return { id: body.id, status: body.status, review: reviewData.reviews[body.id] || null };
|
||||
}
|
||||
|
||||
if (body.action === 'strength') {
|
||||
// Strength is curation metadata outside the content hash, so retyping a
|
||||
// concept never invalidates its human review.
|
||||
if (!CONCEPT_STRENGTHS.has(body.strength)) throw new Error('Strength must be world, composition, or dual');
|
||||
match.concept.strength = body.strength;
|
||||
catalog.catalogVersion = new Date().toISOString();
|
||||
assertValidCatalog(catalog, reviewData);
|
||||
await writeJsonAtomic(catalogPath, catalog);
|
||||
return { id: body.id, strength: body.strength, catalogVersion: catalog.catalogVersion };
|
||||
}
|
||||
|
||||
if (body.action === 'rate') {
|
||||
// Rating grades an approved concept's strength (3 exceptional, 2 solid,
|
||||
// 1 marginal keep) as a calibration signal. It lives on the review but
|
||||
// stays outside the content hash and never changes status.
|
||||
const review = reviewData.reviews[body.id];
|
||||
if (review?.status !== 'approved') throw new Error('Rating only applies to approved concepts');
|
||||
if (body.rating === null) {
|
||||
delete review.rating;
|
||||
} else if ([1, 2, 3].includes(body.rating)) {
|
||||
review.rating = body.rating;
|
||||
} else {
|
||||
throw new Error('Rating must be 1, 2, or 3');
|
||||
}
|
||||
assertValidCatalog(catalog, reviewData);
|
||||
await writeJsonAtomic(reviewsPath, reviewData);
|
||||
return { id: body.id, rating: review.rating ?? null, review };
|
||||
}
|
||||
|
||||
if (body.action === 'update') {
|
||||
const form = typeof body.form === 'string' ? body.form.trim() : '';
|
||||
const lineage = typeof body.lineage === 'string' ? body.lineage.trim() : '';
|
||||
const targetFamily = catalog.families.find(family => family.id === body.familyId);
|
||||
if (form.length < 12 || form.length > 600 || !form.includes(',')) {
|
||||
throw new Error('Form must be 12–600 characters and include inherited structure after a comma');
|
||||
if (form.length < 40 || form.length > 360 || !form.includes(',')) {
|
||||
throw new Error('Form must be 40–360 characters and include inherited structure after a comma');
|
||||
}
|
||||
if (lineage.length < 2 || lineage.length > 160) throw new Error('Lineage must be 2–160 characters');
|
||||
if (lineage.length < 12 || lineage.length > 200) throw new Error('Lineage must be 12–200 characters');
|
||||
if (!validateTags(body.tags)) throw new Error('Exactly three structural tags are required');
|
||||
if (!validateSystem(body.system)) throw new Error('Exactly five system grammar rules of 12–180 characters are required');
|
||||
const spark = typeof body.spark === 'string' ? body.spark.trim() : '';
|
||||
if (spark.length < 80 || spark.length > 320) throw new Error('Creative spark must be 80–320 characters');
|
||||
const webLeverage = typeof body.webLeverage === 'string' ? body.webLeverage.trim() : '';
|
||||
if (webLeverage.length < 20 || webLeverage.length > 240) throw new Error('Web leverage must be 20–240 characters');
|
||||
if (!targetFamily) throw new Error('Family was not found');
|
||||
|
||||
const updated = {
|
||||
@@ -100,7 +228,19 @@ export function worldsReviewPlugin({ root = process.cwd() } = {}) {
|
||||
form,
|
||||
lineage,
|
||||
tags: body.tags.map(tag => tag.trim()),
|
||||
system: body.system.map(rule => rule.trim()),
|
||||
spark,
|
||||
webLeverage,
|
||||
};
|
||||
const existingForms = new Map();
|
||||
for (const family of catalog.families) {
|
||||
for (const concept of family.concepts) {
|
||||
if (concept.id === body.id) continue;
|
||||
existingForms.set(normalizeConceptForm(concept.form), concept.id);
|
||||
}
|
||||
}
|
||||
const entryErrors = validateConceptEntry(updated, { existingForms });
|
||||
if (entryErrors.length > 0) throw new Error(entryErrors[0]);
|
||||
if (targetFamily.id === match.family.id) {
|
||||
match.family.concepts[match.index] = updated;
|
||||
} else {
|
||||
@@ -109,8 +249,22 @@ export function worldsReviewPlugin({ root = process.cwd() } = {}) {
|
||||
targetFamily.concepts.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
catalog.catalogVersion = new Date().toISOString();
|
||||
const previousStatus = reviewData.reviews[body.id]?.status || 'pending';
|
||||
if (reviewData.reviews[body.id]) {
|
||||
delete reviewData.reviews[body.id];
|
||||
reviewData.reviews = Object.fromEntries(Object.entries(reviewData.reviews).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
if (previousStatus === 'approved') assertApprovedFloor(catalog, reviewData);
|
||||
assertValidCatalog(catalog, reviewData);
|
||||
await writeJsonAtomic(catalogPath, catalog);
|
||||
return { id: body.id, concept: updated, familyId: targetFamily.id };
|
||||
if (previousStatus !== 'pending') await writeJsonAtomic(reviewsPath, reviewData);
|
||||
return {
|
||||
id: body.id,
|
||||
concept: updated,
|
||||
familyId: targetFamily.id,
|
||||
status: 'pending',
|
||||
catalogVersion: catalog.catalogVersion,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Action is invalid');
|
||||
|
||||
Reference in New Issue
Block a user