diff --git a/.gitignore b/.gitignore index aaef55405..89f4881de 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,11 @@ site/public/tutorials/ site/public/visual-mode/ site/public/slop/ +# World design-system cards live in R2 (functions/worlds/cards); only the +# manifest is tracked. Generate: bun run world-cards; upload: world-cards:publish. +site/public/worlds/cards/*.webp +site/public/worlds/cards/.published.json + # Build artifacts written to site/public/ so Astro copies them to build/ site/public/_data/ site/public/_headers diff --git a/.impeccable/config.json b/.impeccable/config.json index 45ca0e911..c75d604fe 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -9,14 +9,15 @@ "site/pages/shader-lab/**", "site/scripts/demos/commands/**", "site/styles/skill-demos.css", - "site/styles/slop-kinpaku.css" + "site/styles/slop-kinpaku.css", + "site/styles/worlds-lab.css" ], "ignoreValues": [ { "rule": "bounce-easing", "value": "bounce-ball", - "createdAt": "2026-06-15T04:15:03.164Z", - "reason": "User confirmed ball bounce animation is intentional" + "reason": "User confirmed ball bounce animation is intentional", + "createdAt": "2026-06-15T04:15:03.164Z" }, { "rule": "design-system-color", @@ -24,8 +25,8 @@ "files": [ "site/styles/home-rebuild.css" ], - "createdAt": "2026-06-15T23:37:38.170Z", - "reason": "AURELIA hotel picker is intentionally a foreign boutique-hotel palette inside the live picker demo" + "reason": "AURELIA hotel picker is intentionally a foreign boutique-hotel palette inside the live picker demo", + "createdAt": "2026-06-15T23:37:38.170Z" }, { "rule": "design-system-color", @@ -33,8 +34,8 @@ "files": [ "site/styles/main.css" ], - "createdAt": "2026-06-15T23:37:38.170Z", - "reason": "Generic AI slop card intentionally uses off-system colors for the before-state comparison" + "reason": "Generic AI slop card intentionally uses off-system colors for the before-state comparison", + "createdAt": "2026-06-15T23:37:38.170Z" }, { "rule": "design-system-color", @@ -42,8 +43,8 @@ "files": [ "site/styles/home-kinpaku.css" ], - "createdAt": "2026-06-15T23:37:38.170Z", - "reason": "Homepage before-state slop demo intentionally uses off-system purple/magenta colors" + "reason": "Homepage before-state slop demo intentionally uses off-system purple/magenta colors", + "createdAt": "2026-06-15T23:37:38.170Z" }, { "rule": "design-system-color", @@ -51,8 +52,8 @@ "files": [ "site/styles/design-system.css" ], - "createdAt": "2026-06-15T23:37:38.170Z", - "reason": "Design-system comparison intentionally shows an off-system before-state" + "reason": "Design-system comparison intentionally shows an off-system before-state", + "createdAt": "2026-06-15T23:37:38.170Z" }, { "rule": "design-system-color", @@ -60,8 +61,8 @@ "files": [ "site/styles/workflow.css" ], - "createdAt": "2026-06-15T23:37:38.170Z", - "reason": "Generic slop card intentionally uses off-system purple colors for the before-state comparison" + "reason": "Generic slop card intentionally uses off-system purple colors for the before-state comparison", + "createdAt": "2026-06-15T23:37:38.170Z" }, { "rule": "design-system-font", @@ -69,8 +70,8 @@ "files": [ "site/styles/workflow.css" ], - "createdAt": "2026-06-15T23:37:38.170Z", - "reason": "Generic slop card intentionally uses Inter for the before-state comparison" + "reason": "Generic slop card intentionally uses Inter for the before-state comparison", + "createdAt": "2026-06-15T23:37:38.170Z" } ] }, diff --git a/.impeccable/surfaces/site-pages-labs-worlds-astro.md b/.impeccable/surfaces/site-pages-labs-worlds-astro.md new file mode 100644 index 000000000..ed65997f8 --- /dev/null +++ b/.impeccable/surfaces/site-pages-labs-worlds-astro.md @@ -0,0 +1,33 @@ +--- +version: 1 +slug: "site-pages-labs-worlds-astro" +primary_target: "site/pages/labs/worlds.astro" +related_targets: [] +--- + +# Surface brief: World Catalog + +## Scope + +`/worlds` is an Operate-mode catalog review surface for the systems that challenge grounded Impeccable design directions. It is public and read-only in production; under `bun run dev` it becomes a source-writing review tool for the repository owner. + +## Product strategy + +The primary user is Paul reviewing a curated, growing concept backlog. The desired outcome is a trustworthy approved challenger pool whose entries can provoke world-leading visual systems—not merely recognizable motifs, literal industry software, operational archetypes, or near-duplicate specimens. The schema-v6 catalog groups 23 families into seven deliberately different inspiration wells spanning graphic systems, canon movements, vernacular ephemera, instruments and signals, medium-native work, material worlds, and performed time. Every concept must paint an immediate creative scene, inherit concrete material or spatial laws, define reusable topology/navigation, controls/state, and responsive/motion behavior, and name a browser-native advantage distinct from its nearest catalog neighbors. Richness and semantic distinctness are universal admission bars. Approve is the primary action; reject, restore to pending, edit, search, filter, and well/family navigation are secondary. Editing any reviewed concept returns it to pending so human approval always applies to the current content. Catalog content and review decisions remain separate so automation can add candidates without self-approving them. + +## Selected direction + +Use the confirmed Neo Kinpaku world and the `/detector` developer-tool lineage. The surface is a backlog-first catalog workbench: fixed territory rail, compact queue, and a large central review stage that gives one concept enough space for careful judgment. The creative spark leads the review, with system grammar and web leverage proving that the idea can become more than art direction. Progress, status, provenance, structural tags, and keyboard actions remain visible without turning the page into a dashboard of cards. + +## Direction contract + +- **THESIS:** Turn an abstract challenger corpus into a human-governed editorial queue that rejects the tiled catalog and admin-dashboard defaults; one evocative world earns focused attention and proves it can become a generative web system. +- **OWN-WORLD:** Lacquer ground, kinpaku commitment, patina state, neutral hairlines, Albert Sans UI, compact geometry. +- **STORY:** Find pending work, picture its creative world immediately, judge its material laws together with system grammar and use of the medium, decide, and advance the trusted pool. +- **FIRST VIEWPORT:** Well/progress rail, searchable queue, and one full-height review stage with immediate keyboard-visible decisions. +- **BAR-RAISER:** Every concept must expose an unforgettable creative spark, reusable topology/navigation, controls/state, responsive/motion behavior, and specific browser-native leverage before approval. Literal dashboards, control rooms, workflows, and tacked-on spectacle fail before review. +- **FORM:** A catalog editor crossed with a contact-sheet loupe; selection collapses hundreds of entries into one precise judgment at a time. Seed key: not run—structure is inherited from the established `/detector` tool surface. + +## Open decisions + +None. diff --git a/.impeccable/surfaces/site-pages-worlds-astro.md b/.impeccable/surfaces/site-pages-worlds-astro.md deleted file mode 100644 index 11493096e..000000000 --- a/.impeccable/surfaces/site-pages-worlds-astro.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -version: 1 -slug: "site-pages-worlds-astro" -primary_target: "site/pages/worlds/index.astro" -related_targets: [] ---- - -# Surface brief: World Catalog - -## Scope - -`/worlds` is an Operate-mode catalog review surface for the concepts that challenge grounded Impeccable design directions. It is public and read-only in production; under `bun run dev` it becomes a source-writing review tool for the repository owner. - -## Product strategy - -The primary user is Paul reviewing a large, growing concept backlog. The desired outcome is a trustworthy approved challenger pool with clear progress through pending work. Approve is the primary action; reject, restore to pending, edit, search, filter, and category navigation are secondary. The page must make the 2,304-entry catalog comprehensible without rendering an overwhelming wall. Concepts remain ineligible for seeding until approved. Existing 113 entries begin approved; new and automated entries begin pending. Review decisions persist separately from catalog content so automation cannot self-approve or overwrite human judgment. - -## Selected direction - -Use the confirmed Neo Kinpaku world and the `/detector` developer-tool lineage. The surface is a backlog-first catalog workbench: fixed family rail, compact queue, and a large central review stage that gives one concept enough space for careful judgment. Progress, status, provenance, structural tags, and keyboard actions remain visible without turning the page into a dashboard of cards. - -## Direction contract - -- **UNIQUE:** Turn an abstract challenger corpus into a human-governed editorial queue. -- **NOT-TEMPLATE:** Refuse the generic analytics dashboard and equal-card catalog grid. -- **OWN-WORLD:** Lacquer ground, kinpaku commitment, patina state, neutral hairlines, Albert Sans UI, compact geometry. -- **STORY:** Find pending work, understand one form, approve or reject it, and see the trusted pool advance. -- **FIRST VIEWPORT:** Family/progress rail, searchable queue, and one full-height review stage with immediate keyboard-visible decisions. -- **FORM:** A catalog editor crossed with a contact-sheet loupe; selection collapses thousands of entries into one precise judgment at a time. Seed key: not run—structure is inherited from the established `/detector` tool surface. - -## Open decisions - -None. diff --git a/CLAUDE.md b/CLAUDE.md index c1b6fd3aa..a75c22c05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,10 @@ Hosted on Cloudflare Pages. Static assets served from `build/`, API routes handl bun run deploy # Build + deploy to Cloudflare Pages ``` +### World cards (R2, not git) + +The `/worlds` design-system card images are generated per concept (`bun run world-cards`, gpt-image-2) into `site/public/worlds/cards/`, which is gitignored except `manifest.json` (content hashes + generation stamps). Production serves them from the `impeccable-world-cards` R2 bucket via `functions/worlds/cards/[[file]].js`; the build strips local card files from `build/` (`scripts/strip-local-world-cards.mjs`) so deploys stay light. After generating or regenerating cards, run `bun run world-cards:publish` to upload changed files. The worlds page prefers local files in dev and falls back to the published URLs on clones without local generation output. + ## Social sharing image (OG card) The OG / Twitter card is generated, not hand-drawn. To regenerate after a brand or copy change: diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 4d2a0f85e..baeec04db 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -803,6 +803,9 @@ function checkBorders(tag, widths, colors, radius, opts = {}) { // text-level borders, not chips. They skip the left/right arms below. const spanBadge = tag === 'span' && !!opts.badgeLike; if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return []; + // A live status/alert region wears a colored single-edge border as a + // severity accent (toast, snackbar, callout), not as the side-tab tell. + if (opts.statusContext) return []; const findings = []; const sides = ['Top', 'Right', 'Bottom', 'Left']; @@ -2516,6 +2519,20 @@ function isTabContextElement(el) { return false; } +// Status-surface context for accent borders. On a live status/alert region +// (role=status|alert|alertdialog|log, or aria-live=polite|assertive) a colored +// single-edge border is the established severity-accent convention — a toast, +// snackbar, or callout bar — not the decorative side-tab tell. The element +// itself or a wrapping live region qualifies. This never fires from the +// CSS-only / regex scanners, which have no role information. +function isStatusContextElement(el) { + if (!el) return false; + try { + if (el.closest?.('[role="status"], [role="alert"], [role="alertdialog"], [role="log"], [aria-live="polite"], [aria-live="assertive"]')) return true; + } catch { /* selector engine differences — fall through */ } + return false; +} + function checkElementBordersDOM(el) { const tag = el.tagName.toLowerCase(); if (BORDER_SAFE_TAGS.has(tag)) return []; @@ -2531,6 +2548,7 @@ function checkElementBordersDOM(el) { const ownBg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0, { tabContext: isTabContextElement(el), + statusContext: isStatusContextElement(el), badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1), }); } @@ -3982,6 +4000,7 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) { const ownBg = parseAnyColor(style.backgroundColor); return checkBorders(tag, widths, colors, radius, { tabContext: isTabContextElement(el), + statusContext: isStatusContextElement(el), badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1), }); } diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 1cb0a9b0d..93161e169 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -30,6 +30,9 @@ function checkBorders(tag, widths, colors, radius, opts = {}) { // text-level borders, not chips. They skip the left/right arms below. const spanBadge = tag === 'span' && !!opts.badgeLike; if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return []; + // A live status/alert region wears a colored single-edge border as a + // severity accent (toast, snackbar, callout), not as the side-tab tell. + if (opts.statusContext) return []; const findings = []; const sides = ['Top', 'Right', 'Bottom', 'Left']; @@ -1743,6 +1746,20 @@ function isTabContextElement(el) { return false; } +// Status-surface context for accent borders. On a live status/alert region +// (role=status|alert|alertdialog|log, or aria-live=polite|assertive) a colored +// single-edge border is the established severity-accent convention — a toast, +// snackbar, or callout bar — not the decorative side-tab tell. The element +// itself or a wrapping live region qualifies. This never fires from the +// CSS-only / regex scanners, which have no role information. +function isStatusContextElement(el) { + if (!el) return false; + try { + if (el.closest?.('[role="status"], [role="alert"], [role="alertdialog"], [role="log"], [aria-live="polite"], [aria-live="assertive"]')) return true; + } catch { /* selector engine differences — fall through */ } + return false; +} + function checkElementBordersDOM(el) { const tag = el.tagName.toLowerCase(); if (BORDER_SAFE_TAGS.has(tag)) return []; @@ -1758,6 +1775,7 @@ function checkElementBordersDOM(el) { const ownBg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0, { tabContext: isTabContextElement(el), + statusContext: isStatusContextElement(el), badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1), }); } @@ -3209,6 +3227,7 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) { const ownBg = parseAnyColor(style.backgroundColor); return checkBorders(tag, widths, colors, radius, { tabContext: isTabContextElement(el), + statusContext: isStatusContextElement(el), badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1), }); } diff --git a/docs/DEVELOP.md b/docs/DEVELOP.md index 6727ed0df..13648e2d1 100644 --- a/docs/DEVELOP.md +++ b/docs/DEVELOP.md @@ -44,6 +44,10 @@ Your skill instructions here... ## Building +### Developer Lab URLs + +No-index visual harnesses and internal inspectors live under `/labs/`. Use a short subject noun (`/labs/live-ui`, later `/labs/detector`), not a second `-lab` suffix. Stable public references such as `/docs` and `/design-system` stay top-level. Keep legacy top-level lab routes working through redirects when a lab moves; migrate existing exceptions when that surface is next changed rather than duplicating the page. + ### Prerequisites - Bun (fast JavaScript runtime and package manager) - No external dependencies required diff --git a/docs/WORLD-CATALOG-AUTHORING.md b/docs/WORLD-CATALOG-AUTHORING.md new file mode 100644 index 000000000..c9e379be9 --- /dev/null +++ b/docs/WORLD-CATALOG-AUTHORING.md @@ -0,0 +1,51 @@ +# World catalog authoring guide + +How new concept-world rounds are authored, gated, and reviewed. Distilled from the full human sweep of 2026-07-20/21 (325 entries reviewed, 169 approved, all approved entries star-rated). The machine-readable core lives in `skill/scripts/concept-ingredients.json` under `qualityBar` (`rejectIf`, `authoringStrategy`); this guide carries the reasoning and the territory map. + +## The pipeline + +1. An authoring agent reads `qualityBar` (including `authoringStrategy`) and the review file. Three-star approvals are positive exemplars; rejection and rating notes are negative space. +2. New entries merge as `pending`. Nothing ships without human review. +3. The render gate runs before review: specimen board first, then the desktop hero generated with the board attached as binding reference (`scripts/generate-world-cards.mjs`; the images/edits path keeps both images one system). +4. The reviewer decides in `/labs/worlds`: approve or reject, star ratings on approvals (3 exceptional, 2 solid, 1 marginal), notes on anything instructive. Ratings feed challenger draws in `concept-seed.mjs` (3-star doubles odds, 1-star sits out). + +## What wins + +The winner-property test. Every candidate must be: + +- **Born-designed**: the source is a produced 2D or display artifact with an existing graphic system, not a material, mood, or place. Even atmosphere-tier winners are secretly graphic (wax-print cloth, brick-build instructions, raku surface). +- **Dense**: the tradition immediately yields palette, materials, a type voice, several component roles, and a signature state change. +- **Era-and-school specific**: "1950s Blue Note session sleeve", never "record covers". +- **New territory**: the peak artifact of a culture the catalog has not touched. Proven seams saturate fast: the 2026-07-21 depth round scored 3/12 with 0 flagships because second-tier artifacts from mined veins read as near-duplicates ("too similar to others we already have"). Breadth-first beats depth-first. +- **System-distinct**: check the candidate against approved entries at the system level (palette plus type voice), not just by name. A different artifact with the same green-phosphor system is a duplicate. + +Flagship share by tier after the full sweep: graphic 52%, interaction 55%, atmosphere 29%. Interaction display languages stay overweighted; atmosphere qualifies only through the intrinsic-pattern rule in `rejectIf`. + +## What loses + +- Translation failures: the dominant rejection ("doesn't translate to interface"). Material worlds without an intrinsic 2D pattern system never recover, and re-authoring them fails again (rework hit rate ~33%, and only for taste fixes, never translation fixes). +- Too narrow (single prop, single color), too abstract, operations archetypes, generic categories. +- Render traps: brass or metal interface chrome reads cheap (skeuomorphism itself is fine; execution is the issue), non-Latin copy drift on non-Western worlds (interface copy stays English), AI-cliche motifs (Matrix glyph rain, recording dots), dated game chrome (game-born worlds are welcome at contemporary award standard), em dashes in rendered copy. + +## Cultural care + +Skip living sacred, ceremonial, or community-owned traditions without an established commercial graphic lineage; skip highly religious material outright. Traditions enter cleanly when they already have a commercial or civic design history the way adinkra printing, thangka-informed diagram craft, hanafuda (a published card game), or azulejo (civic architecture) do. When in doubt, leave it out. + +## Territory map (unmined as of 2026-07-21) + +One candidate per territory, always the territory's canonical peak: + +- **Print and publishing**: Penguin/Pelican Marber grid, ligne claire comics, fotonovela, children's book schools (Scarry, Golden Books), Victorian trade cards, stamp design, marbled endpapers. +- **Technical and scientific illustration**: Haeckel plates, patent drawings, anatomical atlases, exploded-view manuals, Sanborn insurance maps, airline safety cards. +- **Fashion as artifact** (not draping): tartan clan registries, kimono/obi pattern grammar, sewing-pattern envelopes with tissue markings, Take Ivy catalog photography, sneaker-box and colorway-naming culture, techwear spec labels. +- **Games and decks**: tarot (commercial deck lineage), hanafuda, mahjong tiles, Tamiya model-kit box art with sprue diagrams, Game & Watch LCD language, casino chip and felt graphics. +- **Global commercial traditions**: Portuguese azulejo, heraldry, sonidero/cumbia posters, Jamaican soundsystem graphics, Tropicália, Ethiopian commercial iconography, Mayan codex facsimile publishing. +- **Fine-art movements with systems**: Vorticism, Precisionism, Le Corbusier polychromy, Barragan color walls. + +Mark territories off as rounds mine them; a mined territory moves to the saturation gate. + +## Round mechanics + +- Rounds stay small (~12) and are treated as experiments; expected hit rate is 25-40% now that the initial canonical harvest is done. Read the verdicts before the next round; every rejection note is calibration. +- Standing reviewer requests: more non-Western systems, more cassette/VHS-era systems. +- Retired families never receive new entries: machines-contraptions, food-potioncraft, memory-ruins, festivals-public-life. diff --git a/functions/worlds/cards/[[file]].js b/functions/worlds/cards/[[file]].js new file mode 100644 index 000000000..69f864443 --- /dev/null +++ b/functions/worlds/cards/[[file]].js @@ -0,0 +1,31 @@ +// Serves world design-system cards from R2 so the 100MB+ of generated WebP +// stays out of git and out of every Pages deploy. Cards are uploaded by +// `bun run world-cards:publish` (scripts/publish-world-cards.mjs); the build +// strips local card files from the static output so this route owns the path. + +const FILE_PATTERN = /^[a-z0-9-]+\.webp$|^manifest\.json$/; + +export async function onRequestGet(context) { + const parts = context.params.file; + const file = Array.isArray(parts) ? parts.join('/') : parts; + + if (!file || !FILE_PATTERN.test(file)) { + return new Response('Not found', { status: 404 }); + } + + const object = await context.env.WORLD_CARDS.get(file); + if (!object) { + return new Response('Not found', { status: 404 }); + } + + const headers = new Headers(); + headers.set('Content-Type', file.endsWith('.json') ? 'application/json; charset=utf-8' : 'image/webp'); + headers.set('ETag', object.httpEtag); + // Card URLs carry a ?v= generation stamp, so long immutable caching is safe: + // a regenerated card gets a new URL and bypasses every cache. + headers.set('Cache-Control', file.endsWith('.json') + ? 'public, max-age=0, s-maxage=3600, stale-while-revalidate=600' + : 'public, max-age=31536000, immutable'); + + return new Response(object.body, { headers }); +} diff --git a/package.json b/package.json index b873d1220..32435dff0 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "build:skills": "bun run scripts/build.js --skip-root-sync", "build:skills:release": "bun run scripts/build.js", "build:site": "npx astro build", - "build": "bun run build:skills && bun run build:site && cp -R dist build/_data/dist", + "build": "bun run build:skills && bun run build:site && cp -R dist build/_data/dist && node scripts/strip-local-world-cards.mjs", "build:release": "bun run build:skills:release && bun run build:site && cp -R dist build/_data/dist", "build:browser": "node scripts/build-browser-detector.js", "build:extension": "node scripts/build-extension.js", @@ -75,7 +75,9 @@ "release:cli": "node scripts/release.mjs cli", "release:ext": "node scripts/release.mjs extension", "screenshot": "bun run scripts/screenshot-antipatterns.js", - "og-image": "bun run scripts/generate-og-image.js" + "og-image": "bun run scripts/generate-og-image.js", + "world-cards": "node scripts/generate-world-cards.mjs", + "world-cards:publish": "node scripts/publish-world-cards.mjs" }, "dependencies": { "css-select": "^7.0.0", diff --git a/scripts/build.js b/scripts/build.js index a85ba4c28..e0c6febe0 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -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)); diff --git a/scripts/generate-world-cards.mjs b/scripts/generate-world-cards.mjs new file mode 100644 index 000000000..1c7311331 --- /dev/null +++ b/scripts/generate-world-cards.mjs @@ -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/.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); +}); diff --git a/scripts/publish-world-cards.mjs b/scripts/publish-world-cards.mjs new file mode 100644 index 000000000..153a1e664 --- /dev/null +++ b/scripts/publish-world-cards.mjs @@ -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; diff --git a/scripts/strip-local-world-cards.mjs b/scripts/strip-local-world-cards.mjs new file mode 100644 index 000000000..f21e8797c --- /dev/null +++ b/scripts/strip-local-world-cards.mjs @@ -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)'); +} diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index f7d295e37..05f5917f2 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -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', diff --git a/scripts/worlds-review-vite-plugin.mjs b/scripts/worlds-review-vite-plugin.mjs index 6c7ecdfd2..20d6e5703 100644 --- a/scripts/worlds-review-vite-plugin.mjs +++ b/scripts/worlds-review-vite-plugin.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'); diff --git a/site/components/LiveUiGallery.astro b/site/components/LiveUiGallery.astro new file mode 100644 index 000000000..f60a419a3 --- /dev/null +++ b/site/components/LiveUiGallery.astro @@ -0,0 +1,155 @@ +--- +import '../styles/live-ui-gallery.css'; +import { LIVE_COMMANDS } from '../../skill/scripts/live/vocabulary.mjs'; +import { LIVE_UI_SURFACES } from '../../skill/scripts/live/ui-core.mjs'; + +const STATE_GROUPS = [ + { + key: 'global-chrome', + label: 'Global chrome', + summary: 'Session status, page-wide tools, and steering.', + states: [ + ['global-ready', 'Ready', 'global-bottom-bar'], + ['global-disconnected', 'Agent disconnected', 'global-bottom-bar'], + ['global-foreground', 'Codex CLI fallback', 'global-bottom-bar'], + ['global-tools', 'Detect + DESIGN.md', 'global-bottom-bar'], + ['steer-expanded', 'Steer composing', 'global-bottom-bar'], + ['steer-processing', 'Steer processing', 'global-bottom-bar'], + ], + }, + { + key: 'selection', + label: 'Selection', + summary: 'Target, annotate, and insert before generation.', + states: [ + ['configure-replace', 'Configure selection', 'element-selection-chrome'], + ['action-picker', 'Action picker', 'action-picker'], + ['configure-listening', 'Voice input', 'element-selection-chrome'], + ['configure-locked', 'Apply locked', 'element-selection-chrome'], + ['annotation', 'Annotations', 'annotation-chrome'], + ['insert-placeholder', 'Insert placeholder', 'insert-mode-chrome'], + ], + }, + { + key: 'generation-review', + label: 'Generation + review', + summary: 'Progressive arrival, tuning, application, and confirmation.', + states: [ + ['generating', 'Generating', 'generating-row'], + ['generation-recovery', 'Recovery', 'generating-row'], + ['cycling-progressive', 'Variant 1 arrived', 'variant-cycling-row'], + ['cycling-second', 'Variant 2 arrived', 'variant-cycling-row'], + ['tune-open', 'Tune panel', 'variant-params-panel'], + ['applying', 'Applying variant', 'saving-confirmed-rows'], + ['confirmed', 'Variant applied', 'saving-confirmed-rows'], + ], + }, + { + key: 'copy-support', + label: 'Copy + support', + summary: 'Inline copy edits, design context, and recovery feedback.', + states: [ + ['edit-copy', 'Edit copy', 'edit-chrome'], + ['copy-pending', 'Copy edits pending', 'pending-copy-edit-dock'], + ['copy-applying', 'Copy edits applying', 'pending-copy-edit-dock'], + ['copy-attention', 'Apply needs attention', 'pending-copy-edit-dock'], + ['design-panel', 'DESIGN.md panel', 'design-system-panel'], + ['toast-error', 'Error toast', 'toasts-and-errors'], + ], + }, +]; + +// Keep the catalog honest as Live grows. CSS isolation is a structural mount +// contract rather than a visible state; every other surface must own at least +// one snapshot here or the site build fails with the missing surface name. +const STRUCTURAL_SURFACES = new Set(['css-isolation-boundary']); +const coveredSurfaces = new Set( + STATE_GROUPS.flatMap((group) => group.states.map(([, , surface]) => surface)), +); +const visualSurfaces = LIVE_UI_SURFACES.filter((surface) => !STRUCTURAL_SURFACES.has(surface.key)); +const uncoveredSurfaces = visualSurfaces.filter((surface) => !coveredSurfaces.has(surface.key)); +if (uncoveredSurfaces.length > 0) { + throw new Error(`Live UI lab is missing snapshots for: ${uncoveredSurfaces.map((surface) => surface.key).join(', ')}`); +} + +const statePayload = STATE_GROUPS.flatMap((group) => + group.states.map(([key, label, surface]) => ({ + key, + label, + surface, + groupKey: group.key, + group: group.label, + groupSummary: group.summary, + })), +); +const groupPayload = STATE_GROUPS.map((group) => ({ + key: group.key, + label: group.label, + summary: group.summary, + states: statePayload.filter((state) => state.groupKey === group.key), +})); +const commandPayload = JSON.stringify(LIVE_COMMANDS).replace(/ + + + diff --git a/site/pages/labs/live-ui/index.astro b/site/pages/labs/live-ui/index.astro new file mode 100644 index 000000000..5fa10be65 --- /dev/null +++ b/site/pages/labs/live-ui/index.astro @@ -0,0 +1,44 @@ +--- +import Base from '../../../layouts/Base.astro'; +import LiveUiGallery from '../../../components/LiveUiGallery.astro'; +import '../../../styles/live-ui-lab.css'; + +// Developer-only visual harnesses use /labs/. The old /live-lab +// route remains as a redirect so bookmarks survive the namespace cleanup. +--- + + +
+ + + IMPECCABLE + + +
+ Developer lab +

Live UI states

+
+ +

Every visible Live surface, clustered by workflow and host context.

+ Live overview +
+ +
+ +
+ diff --git a/site/pages/labs/stagings.astro b/site/pages/labs/stagings.astro new file mode 100644 index 000000000..cc8450f50 --- /dev/null +++ b/site/pages/labs/stagings.astro @@ -0,0 +1,455 @@ +--- +import fs from 'node:fs'; +import path from 'node:path'; +import Base from '../../layouts/Base.astro'; +import '../../styles/sub-pages.css'; +import '../../styles/worlds-lab.css'; +import { compositionContentHash } from '../../../skill/scripts/lib/composition-catalog.mjs'; + +const ROOT = process.cwd(); +const catalog = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill', 'scripts', 'composition-ingredients.json'), 'utf8')); +const reviewData = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill', 'scripts', 'composition-reviews.json'), 'utf8')); +const reviews = reviewData.reviews || {}; +const CARD_DIR = path.join(ROOT, 'site', 'public', 'worlds', 'cards'); +const cardManifest = (() => { + try { return JSON.parse(fs.readFileSync(path.join(CARD_DIR, 'manifest.json'), 'utf8')); } catch { return {}; } +})(); +const PROD_CARDS_BASE = 'https://impeccable.style/worlds/cards'; +const stagingCardFor = composition => { + const row = cardManifest[composition.id]; + const localExists = fs.existsSync(path.join(CARD_DIR, `${composition.id}.webp`)); + if (!localExists && !row) return null; + const generated = Date.parse(row?.generatedAt || 0) || 0; + const base = localExists ? '/worlds/cards' : PROD_CARDS_BASE; + return { + src: `${base}/${composition.id}.webp?v=${generated.toString(36)}`, + stale: row?.hash !== compositionContentHash(composition), + }; +}; +const familiesById = new Map(catalog.families.map(family => [family.id, family])); +const compositions = (catalog.compositions || []).map(composition => ({ + ...composition, + familyLabel: familiesById.get(composition.familyId)?.label || composition.familyId, + status: reviews[composition.id]?.status || 'pending', + review: reviews[composition.id] || null, + card: stagingCardFor(composition), +})); +const counts = { + total: compositions.length, + approved: compositions.filter(item => item.status === 'approved').length, + pending: compositions.filter(item => item.status === 'pending').length, + rejected: compositions.filter(item => item.status === 'rejected').length, +}; +const initial = compositions.find(item => item.status === 'pending') || compositions[0] || null; +const pageData = JSON.stringify({ canWrite: import.meta.env.DEV, compositions }).replace(/ + +
+ + +
+
+ +
+ +
+ {import.meta.env.DEV ? 'Source writing active' : 'Read-only catalog'} + {import.meta.env.DEV ? 'Changes persist to skill/scripts' : 'Run bun run dev to review'} +
+
+
+ +
+ + +
+
+
+ {initial?.familyLabel} + + {initial?.id} +
+
+ Staging 001 / {counts.total.toLocaleString()} + {initial?.status || 'pending'} +
+
+ +
+
+

{initial?.surface} staging

+

{initial?.form}

+
+

Spark

+ {initial?.spark} +
+
+ {(initial?.tags || []).map(tag => {tag})} +
+
+
+

Lineage

+ {initial?.lineage} +
+ + +
+
+
+ +
+
+

Staging grammar

+
    + {(initial?.grammar || []).map(rule =>
  1. {rule}
  2. )} +
+
+
+

Web leverage

+ {initial?.webLeverage} +
+
+
+
+ +
+
+ +

+
+
+ + + +
+
+
+
+
+
+ + + + + + diff --git a/site/pages/worlds/index.astro b/site/pages/labs/worlds.astro similarity index 55% rename from site/pages/worlds/index.astro rename to site/pages/labs/worlds.astro index 51f4de690..33823fb64 100644 --- a/site/pages/worlds/index.astro +++ b/site/pages/labs/worlds.astro @@ -4,23 +4,59 @@ import path from 'node:path'; import Base from '../../layouts/Base.astro'; import '../../styles/sub-pages.css'; import '../../styles/worlds-lab.css'; +import { conceptContentHash } from '../../../skill/scripts/lib/concept-catalog.mjs'; const ROOT = process.cwd(); const catalog = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill', 'scripts', 'concept-ingredients.json'), 'utf8')); const reviewData = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill', 'scripts', 'concept-reviews.json'), 'utf8')); const reviews = reviewData.reviews || {}; +const CARD_DIR = path.join(ROOT, 'site', 'public', 'worlds', 'cards'); +const cardManifest = (() => { + try { return JSON.parse(fs.readFileSync(path.join(CARD_DIR, 'manifest.json'), 'utf8')); } catch { return {}; } +})(); +// Cards live in R2 in production (functions/worlds/cards). A local generation +// output is preferred when present; a clone without local cards falls back to +// the published set so the lab still shows them. +const PROD_CARDS_BASE = 'https://impeccable.style/worlds/cards'; +const cardFor = concept => { + const row = cardManifest[concept.id]; + const localExists = fs.existsSync(path.join(CARD_DIR, `${concept.id}.webp`)); + if (!localExists && !row) return null; + // Cards are regenerated under the same filename, so the URL carries the + // generation timestamp: browsers and CDNs refetch exactly when a card changes. + const generated = Date.parse(row?.generatedAt || 0) || 0; + const base = localExists ? '/worlds/cards' : PROD_CARDS_BASE; + const heroLocal = fs.existsSync(path.join(CARD_DIR, `${concept.id}-hero.webp`)); + const heroGenerated = Date.parse(row?.heroGeneratedAt || 0) || 0; + return { + src: `${base}/${concept.id}.webp?v=${generated.toString(36)}`, + heroSrc: (heroLocal || row?.heroGeneratedAt) + ? `${heroLocal ? '/worlds/cards' : PROD_CARDS_BASE}/${concept.id}-hero.webp?v=${heroGenerated.toString(36)}` + : null, + stale: row?.hash !== conceptContentHash(concept), + }; +}; +const wellsById = new Map((catalog.wells || []).map(well => [well.id, well])); const families = catalog.families.map(family => ({ id: family.id, label: family.label, description: family.description, + wellId: family.well, + wellLabel: wellsById.get(family.well)?.label || '', concepts: family.concepts.map(concept => ({ ...concept, + card: cardFor(concept), familyId: family.id, familyLabel: family.label, + wellId: family.well, + wellLabel: wellsById.get(family.well)?.label || '', status: reviews[concept.id]?.status || 'pending', review: reviews[concept.id] || null, })), })); +const wellGroups = (catalog.wells || []) + .map(well => ({ well, families: families.filter(family => family.wellId === well.id) })) + .filter(group => group.families.length > 0); const concepts = families.flatMap(family => family.concepts); const initialConcept = concepts.find(concept => concept.status === 'pending') || concepts[0]; const counts = { @@ -37,7 +73,7 @@ const pageData = JSON.stringify({ }).replace(/ + World Catalog + +
Human reviewed @@ -76,36 +117,45 @@ const pageData = JSON.stringify({
-
-
Approved
{counts.approved}
-
Backlog
{counts.pending}
-
Rejected
{counts.rejected}
-
Total
{counts.total}
-
-
- -
- - - - +
+ + + + + +