From 248a4a699a0effa59c8385078dd6e000e99f076f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 11 Aug 2026 15:53:25 -0400 Subject: [PATCH] Retire the sketch era's wire name: the field is comp, sketch is an alias The deliverable died in #545; the word survived as the decision-page payload's field name, annotated everywhere it appeared with the same compatibility apology. The page and the skill text ship together and payloads are per-session, so the compatibility burden is one input alias, not a frozen name. serve-question.mjs: the card field, the answer key, the schema docs, the --schema example, the help text, and every internal identifier (compSrc, data-comp, .media.comp-pending, img.comp, comp-note) now say comp; a payload declaring the legacy sketch key still renders and answers identically. new-work.md and the asset producer drop their wire-name parentheticals. The unit suite covers the canonical answer key coming back from a legacy-key payload; the new-work e2e's declined-card stray comp stays declared as sketch, which doubles as alias coverage. AI-assisted (Claude Fable 5), prepared for maintainer review. Co-Authored-By: Claude Fable 5 --- skill/agents/impeccable-asset-producer.md | 2 +- skill/reference/new-work.md | 2 +- skill/scripts/serve-question.mjs | 104 +++++++++++----------- tests/new-work-e2e.test.mjs | 5 +- tests/serve-question.test.mjs | 27 +++--- 5 files changed, 72 insertions(+), 68 deletions(-) diff --git a/skill/agents/impeccable-asset-producer.md b/skill/agents/impeccable-asset-producer.md index 6af0ab8ea..d10882f34 100644 --- a/skill/agents/impeccable-asset-producer.md +++ b/skill/agents/impeccable-asset-producer.md @@ -24,7 +24,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh ## Decision Comps -When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. ## Input Contract diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 343e2634f..ef3c60170 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node {{scripts_path}}/serve-question.mjs --start --payload ` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry. -When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way, where commitment is depth, not coverage: visualize.md's self-checks, the mode readable from the image, one dominant move, the subject present as content, bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. +When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way, where commitment is depth, not coverage: visualize.md's self-checks, the mode readable from the image, one dominant move, the subject present as content, bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round. diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index 788211920..09693e2ef 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -42,14 +42,14 @@ * // raise lines under the identity row * "risk": "one line: the honest risk", // optional * "body": "fallback prose when the structured fields are absent", - * "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's - * // full-fidelity direction comp (the field - * // keeps the sketch era's wire name). May not - * // exist yet: the page shimmer-waits and + * "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's + * // full-fidelity direction comp (the legacy + * // key "sketch" is accepted as an alias). May + * // not exist yet: the page shimmer-waits and * // polls the slot until the file lands, so * // serve first and generate after * "hero": "https://... or /abs/path.webp", // optional inspiration image; - * // rides picture-in-picture when a sketch exists + * // rides picture-in-picture when a comp exists * "board": "https://... or /abs/path.webp" // optional secondary image * }, ... * ], @@ -61,7 +61,7 @@ * "canon": true, // adds the "Play it straight" standing exit; * // direction rounds only (returns {"optionId":"canon"}) * "canonCard": { ... }, // optional: the standing exit as a full card with the - * // same anatomy (label, thesis, palette, sketch, ...); + * // same anatomy (label, thesis, palette, comp, ...); * // rendered last and visually subordinate. Without it, * // canon stays a quiet footer action. * "steer": true, // adds a free-text steer field returned with any answer @@ -75,7 +75,7 @@ * // then the execution contract. * } * - * Options render as large cards: the sketch leads when present, with the + * Options render as large cards: the comp leads when present, with the * inspiration image picture-in-picture; a hero alone renders full-bleed; a * text-only direction gets its identity from the palette chips and tags. * Local image paths are served by this server; nothing is uploaded anywhere. @@ -148,7 +148,7 @@ function printAnswer(raw) { if (a.hero || a.board) { console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach."); } - if (a.sketch) { + if (a.comp) { console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.'); } if (a.optionId === 'canon') { @@ -175,17 +175,17 @@ if (hasFlag('schema')) { title: 'Choose the visual world', question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.', options: [ - { id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' }, - { id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' }, - { id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' }, + { id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], comp: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' }, + { id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', comp: '.impeccable/mocks/decision/model-pick.webp' }, + { id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', comp: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' }, { id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' }, ], reroll: { registers: ['safer', 'bolder'] }, canon: true, - canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' }, + canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' }, steer: true, }, null, 2)); - console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the card’s full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.'); + console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.'); process.exit(0); } @@ -322,10 +322,10 @@ function loadRound(json) { localImages.push(abs); return `/img/${localImages.length - 1}`; }; - // Sketches stream in after the page is served, so their slots register + // Comps stream in after the page is served, so their slots register // whether or not the file exists yet; /img answers 404 until it lands and - // the page polls the slot. Remote sketch URLs pass through untouched. - const sketchSrc = (value) => { + // the page polls the slot. Remote comp URLs pass through untouched. + const compSrc = (value) => { if (!value) return null; if (/^https?:\/\//.test(value)) return value; localImages.push(path.resolve(value)); @@ -336,7 +336,7 @@ function loadRound(json) { ...option, heroSrc: imageSrc(option.hero), boardSrc: imageSrc(option.board), - sketchSrc: sketchSrc(option.sketch), + compSrc: compSrc(option.comp ?? option.sketch), }); options = parsed.options.map(decorate); // The verdict routes rendering: full cards first, then the canon, then the @@ -374,12 +374,12 @@ function page() { // assigned card would let rendering luck outvote the weighing: users click // the colorful thing. Declined cards are thumb-only regardless; the verdict // demoted them, and a full-bleed hero would promote them right back. - const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc)); - // A declined card never renders a full media face, sketch included: even a - // declared sketch would buy back the salience the verdict took away. - const faceSketch = (option) => demoted(option) ? null : option.sketchSrc; - const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound); - const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option))); + const identityRound = !(options[0] && (options[0].compSrc || options[0].heroSrc || options[0].boardSrc)); + // A declined card never renders a full media face, comp included: even a + // declared comp would buy back the salience the verdict took away. + const faceComp = (option) => demoted(option) ? null : option.compSrc; + const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound); + const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option))); // The back exists to keep long facts off a card whose front is an image; // a card with no art has no flip chip to reach it, so it gets no back and // the full read lives on the front instead. @@ -421,7 +421,7 @@ function page() { } // The front carries only what the choice needs: thesis, identity, and the // honest risk clamped to two lines. First viewport and the case read on - // the card's back; once the sketch lands, the first viewport is a picture. + // the card's back; once the comp lands, the first viewport is a picture. // With no art there is no back, so the full read fills the room the // image would have taken. if (hasMedia(option)) { @@ -450,18 +450,18 @@ function page() { ` : ''; const details = hasBack(option) ? flipChip('Details') : ''; // Thumb-only art renders inside the body via anatomy(), never as a face, - // and a declined card's sketch slot is ignored outright. + // and a declined card's comp slot is ignored outright. if (thumbOnly(option)) return ''; - if (faceSketch(option)) { - return `
-
rendering…
- + if (faceComp(option)) { + return `
+
rendering…
+ ${inspiration}
${expandChip}${details}
`; } if (option.heroSrc || option.boardSrc) { - // Without a sketch the catalog art is the card's face; it stays a + // Without a comp the catalog art is the card's face; it stays a // labeled reference so it never reads as the promise of the build. return `
@@ -554,9 +554,9 @@ function page() { its axis with snap points and the arrows page it card by card. */ .grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; } .grid::-webkit-scrollbar { display: none; } - /* Wide enough that the sketch carries the card: at 27vw the imagery read + /* Wide enough that the comp carries the card: at 27vw the imagery read as a thumbnail above a column of copy, and the copy won the attention - contest the sketch is supposed to win. */ + contest the comp is supposed to win. */ .grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; } .nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; } .nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); } @@ -611,7 +611,7 @@ function page() { region entirely instead of reserving a blank 16:9 void. */ .face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; } .face.text-only .body { padding-top: 12px; } - /* 16/10 matches the landscape sketch frame; portrait art overrides the + /* 16/10 matches the landscape comp frame; portrait art overrides the slot with its own exact ratio at load (see the load listener), and the deck narrows so portrait cards line up side by side. */ .media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; } @@ -647,14 +647,14 @@ function page() { .body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; } /* Inspiration rides picture-in-picture: the catalog world explains where the direction comes from without promising what the build will look like. */ - /* Hovering the inspiration takes over the whole media region; the sketch is + /* Hovering the inspiration takes over the whole media region; the comp is the promise, the inspiration is a glance, so the glance must cost nothing. */ .pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); } .pip img { display: block; width: 100%; height: 100%; object-fit: cover; } .pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); } .pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; } - .sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); } - /* Catalog art standing in for a sketchless card is a reference, and says so + .comp-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); } + /* Catalog art standing in for a comp-less card is a reference, and says so on its face; the same pill later carries "artwork unavailable". */ .media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); } /* Art that never arrives collapses to the card's own palette (painted @@ -666,15 +666,15 @@ function page() { .media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; } .media.unavailable .chips { z-index: 2; } /* A stand-in is honest about being one: dimmed, labeled, and replaced by - the real sketch whenever it lands. */ - .media.stand-in img.sketch { filter: brightness(.72) saturate(.85); } + the real comp whenever it lands. */ + .media.stand-in img.comp { filter: brightness(.72) saturate(.85); } .media.stand-in .pip { display: none; } .stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); } - .media.sketching { position: relative; } - .media.sketching .shimmer { position: absolute; inset: 0; } - .media img.sketch { position: relative; z-index: 1; } + .media.comp-pending { position: relative; } + .media.comp-pending .shimmer { position: absolute; inset: 0; } + .media img.comp { position: relative; z-index: 1; } /* The generic .media img display:block would defeat [hidden] and float an - empty block over the shimmer; an unloaded sketch must truly not render. */ + empty block over the shimmer; an unloaded comp must truly not render. */ .media img[hidden] { display: none; } /* Declined challengers: the weighing demoted them, so the card is narrower and quieter, its catalog art rides as a labeled thumb in the body, and @@ -863,22 +863,22 @@ function page() { })); } - // Sketches stream in after the deal: poll each slot until the file lands, + // Comps stream in after the deal: poll each slot until the file lands, // then swap the shimmer for the image. Generation is genuinely slow and a // sequential batch puts the last card many minutes out, so patience is the // default: a slot only shows its inspiration as a stand-in when it has // waited four minutes AND nothing has landed anywhere for four minutes, the - // stand-in is labeled as such, and polling continues so the real sketch + // stand-in is labeled as such, and polling continues so the real comp // still swaps in whenever it arrives. Progress anywhere resets patience. const landTracker = { last: Date.now() }; - document.querySelectorAll('.media.sketching').forEach(m => { - const url = m.dataset.sketch; - const img = m.querySelector('img.sketch'); - const note = m.querySelector('.sketch-note'); + document.querySelectorAll('.media.comp-pending').forEach(m => { + const url = m.dataset.comp; + const img = m.querySelector('img.comp'); + const note = m.querySelector('.comp-note'); const started = Date.now(); // A live elapsed count is the difference between "working" and "frozen". const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000); - const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); }; + const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); }; const standIn = () => { const pip = m.querySelector('.pip img'); if (!pip || m.classList.contains('stand-in')) return; @@ -910,7 +910,7 @@ function page() { // slots are excluded; their polling owns the wait. const artFailed = (img) => { const m = img.closest('.media'); - if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return; + if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return; m.classList.add('unavailable'); const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean); if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')'; @@ -923,7 +923,7 @@ function page() { label.textContent = 'artwork unavailable'; m.appendChild(label); }; - document.querySelectorAll('.media:not(.sketching) > img').forEach(img => { + document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => { if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img); else img.addEventListener('error', () => artFailed(img), { once: true }); }); @@ -1129,7 +1129,7 @@ const server = http.createServer((req, res) => { ...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}), ...(followupOpen ? { followup: true } : {}), ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}), - ...(chosen?.sketch ? { sketch: chosen.sketch } : {}), + ...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}), }); if (detachedKey) { fs.mkdirSync(QUESTION_DIR, { recursive: true }); diff --git a/tests/new-work-e2e.test.mjs b/tests/new-work-e2e.test.mjs index 4a3b546a5..461180159 100644 --- a/tests/new-work-e2e.test.mjs +++ b/tests/new-work-e2e.test.mjs @@ -333,8 +333,9 @@ describe('new-work-e2e: serve-question decision page', () => { id: 'challenger-deepsea', label: 'Deep Sea Survey', verdict: 'declined', case: 'Fuses poorly: buyers do not identify with abyssal instrumentation.', kept: 'Total environmental commitment.', hero, - // A stray sketch on a declined card must not re-promote it to a - // full media face; the renderer ignores it outright. + // A stray comp on a declined card must not re-promote it to a + // full media face; the renderer ignores it outright. Declared with + // the legacy `sketch` key, which doubles as alias coverage. sketch: '.impeccable/sketches/challenger-deepsea.webp', }, { id: 'challenger-waxprint', label: 'Wax Print Market', verdict: 'competitive', hero: winnerHero }, diff --git a/tests/serve-question.test.mjs b/tests/serve-question.test.mjs index 2a8e22f2d..279cff761 100644 --- a/tests/serve-question.test.mjs +++ b/tests/serve-question.test.mjs @@ -190,9 +190,9 @@ describe('serve-question', () => { assert.equal(dead, 2, 'a truly missing process must still read as gone'); }); - it('renders anatomy, streams late sketches, and returns the chosen sketch', async () => { + it('renders anatomy, streams late comps, and returns the chosen comp', async () => { const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-')); - const sketchPath = path.join(dir, 'sketches', 'assigned.webp'); + const compPath = path.join(dir, 'comps', 'assigned.webp'); const payload = { title: 'Choose the visual world', options: [ @@ -200,7 +200,9 @@ describe('serve-question', () => { id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', thesis: 'The gig poster idea.', palette: ['#e8452c', '#f5d64c'], materials: ['letterpress'], viewport: 'Full-bleed dated bill.', risk: 'Nostalgia trap.', - sketch: sketchPath, hero: 'https://impeccable.style/worlds/cards/x-hero.webp', + // The legacy key: a payload authored against the sketch-era schema + // must keep rendering, so the lead card declares its comp as `sketch`. + sketch: compPath, hero: 'https://impeccable.style/worlds/cards/x-hero.webp', }, { id: 'challenger-1', label: 'Teletext Service', case: 'Fuses cleanly.' }, ], @@ -222,24 +224,25 @@ describe('serve-question', () => { assert.match(html, /class="tag">letterpress/); assert.match(html, /The gig poster idea\./); assert.match(html, /Fuses cleanly\./); - // The inspiration image rides picture-in-picture beside the sketch slot. + // The inspiration image rides picture-in-picture beside the comp slot. assert.match(html, /class="pip"/); - assert.match(html, /media sketching/); + assert.match(html, /media comp-pending/); // canonCard renders as a subordinate card and suppresses the footer action. assert.match(html, /card canon/); assert.match(html, /Play it straight { }); const code = await new Promise((resolve) => child.on('exit', resolve)); assert.equal(code, 0); - assert.match(read(), /"sketch":/); + assert.match(read(), /"comp":/); assert.match(read(), /CHOSEN COMP:/); }); });