diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index e6328ba86..89c764ebb 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -36,7 +36,7 @@ Keep the visual system fixed. Derive five to seven materially different structur `node {{scripts_path}}/concept-seed.mjs --scope surface --mode ` -The script assigns which structure gets built; your top-ranked structure is what every run would ship, so the dice come from outside. Never run the script for a local extension or a precisely specified narrow request; shape those directly. +The script deals three of your structures to the table; the dice decide which three reach the user, so the ranking rut stays broken while the user still holds a real choice. Present the three dealt structures on the decision page as full cards of equal salience, the dealt lead carrying kicker THE ROLL, with steer and re-roll; the user locks one in. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation available and no standing code-led preference, each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving in reading order under the comp discipline in [visualize.md](visualize.md); anchor each of these comps on the established identity by passing a captured screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) beside a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character, because a prose paraphrase of a design system drifts where a pixel reference does not. Without image generation, or under a standing code-led preference, each card instead carries a `wireframe` layout schematic (see `serve-question.mjs --schema`) that the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly. ### Create or replace the visual world diff --git a/skill/reference/visualize.md b/skill/reference/visualize.md index 244ce58c4..3ea1fd693 100644 --- a/skill/reference/visualize.md +++ b/skill/reference/visualize.md @@ -1,12 +1,12 @@ # Visualize: Direction Comps & Asset Production -Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it. +Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card’s comp is the approved comp, so record the approval and continue at After approval; generate nothing new. The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed. ## Generate three compositional options -Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here. +Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool’s input image, or `generate-image.mjs --ref`); the prompt then leads with the new surface’s structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page’s own content does not, so a banner, hero, or card lifted verbatim from the reference is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here. - A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally. - The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions exist to hold; the world dresses the frame and never displaces what the frame exists to show. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject: a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere, so regenerate with the subject's content named region by region. diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index 5b8e6a67a..1006e965b 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -338,6 +338,20 @@ export function renderConceptSeed({ }; const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`; const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount + // Surface scope deals a hand of three grounded structures: one card is not + // a choice, and the full ranked list would hand selection back to the + // model's taste. The dice pick all three; the primary index leads. The + // no-lineup rule stays direction-only, where it was written for worlds. + const dealtIndices = [buildIndex]; + for (let draw = 0; scope === 'surface' && dealtIndices.length < Math.min(3, candidateCount); draw += 1) { + const idx = 1 + Math.floor(unit(`${indexSalt}:deal-${draw}`) * candidateCount); + if (!dealtIndices.includes(idx)) dealtIndices.push(idx); + if (draw > 64) { // hash repeats cannot stall the deal + for (let fill = 1; dealtIndices.length < Math.min(3, candidateCount); fill += 1) { + if (!dealtIndices.includes(fill)) dealtIndices.push(fill); + } + } + } // Local catalog first (private repo, evals, tests), then the roll API, // then a degraded assignment-only seed. The assigned index is pure local @@ -411,12 +425,22 @@ export function renderConceptSeed({ on named factual grounds, when the assignment cannot carry the product's truth or task; taste is never grounds.` : `After ordering the task's grounded structural candidates by resonance, - build candidate ${buildIndex} of your own grounded list; the assignment never - points at a challenger. The assignment is the roll, not a suggestion. - In an attended run, present the assigned structure and offer re-roll; never - present a ranked lineup to choose from. Re-roll yourself only when the - assignment fails audience identification or product clarity on named - factual grounds.`; + deal candidates ${dealtIndices.join(', ')} of your own grounded list to the + table; index ${buildIndex} leads, and the deal never points at a challenger. + The deal is the roll, not a suggestion: the dice decide which structures + reach the user, so the ranking rut stays broken while the user still gets a + real choice, and the full ranked list stays yours. In an attended run, + present the three dealt structures as full cards of equal salience, the + lead carrying kicker THE ROLL, with steer and re-roll, and let the user + lock one in; the world is already settled, so this choice is composition. + Visualize every dealt card: with image generation available and no + standing code-led preference, declare a comp per card and generate after + serving, lead first; otherwise author each card's wireframe field (see + serve-question --schema) and the page draws the schematic. Locking a card + approves its comp: a surface round that put three visualized structures on + the table replaces the three-option comp round in visualize.md. Re-roll + yourself only when every dealt structure fails audience identification or + product clarity on named factual grounds.`; const challengerInstruction = scope === 'direction' ? `Fuse each challenger before judging it: the challenger supplies the form @@ -490,7 +514,7 @@ assigned index is suspended this round and the user picks; seed key ${key}. ` : ''; return `${degradedHeader} -${degradedRegister}ASSIGNED INDEX: ${buildIndex} +${degradedRegister}${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`} ${promotedInstruction} The assignment exists to refuse the model's ranking rut, never to outrank the user or the brief. Never expose assignment metadata in user-facing labels. @@ -514,8 +538,11 @@ channel: when a browser can open, present the direction on the decision page the no-browser fallback. ${authorityInstruction} A user- or brief-pinned decision beats the roll, always. -ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate -${buildIndex} of your own grounded list; seed key ${key}. +${scope === 'direction' + ? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate +${buildIndex} of your own grounded list; seed key ${key}.` + : `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index +${buildIndex} leads. Present all three dealt structures; seed key ${key}.`} `; } @@ -595,7 +622,7 @@ rivals to your habitual layout, and keep only what makes this product clearer.${ or IMPECCABLE_NO_TELEMETRY is set.\n` : ''; const assignedBlock = register === null - ? `ASSIGNED INDEX: ${buildIndex} + ? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`} ${promotedInstruction} The assignment exists to refuse the model's ranking rut, never to outrank the user or the brief. Never expose assignment metadata in user-facing labels.` @@ -621,8 +648,11 @@ craft bar, the finish level and commitment the build is expected to reach, never as a mockup to copy; your surface serves this product, not that render. `; const restated = register === null - ? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate + ? (scope === 'direction' + ? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate ${buildIndex} of your own grounded list; seed key ${key}.` + : `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index +${buildIndex} leads. Present all three dealt structures; seed key ${key}.`) : `REGISTER (restated for truncated readers): ${register}, user-requested; the assigned index is suspended this round; seed key ${key}.`; return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision) diff --git a/skill/scripts/generate-image.mjs b/skill/scripts/generate-image.mjs index ab03ef768..813b2e467 100644 --- a/skill/scripts/generate-image.mjs +++ b/skill/scripts/generate-image.mjs @@ -10,6 +10,11 @@ * * node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium] * node generate-image.mjs --prompt-file prompt.txt --out mock.png + * node generate-image.mjs --prompt "..." --out mock.png --ref screenshot.png [--ref more.png] + * + * --ref anchors generation on input image(s) via the edits endpoint: pass a + * captured screenshot of a representative existing page when comping a new + * surface for an established world, so the identity comes from the real UI. */ import fs from 'node:fs'; import zlib from 'node:zlib'; @@ -212,12 +217,44 @@ if (!prompt || !out) { } const size = arg('size', '1536x1024'); const quality = arg('quality', 'medium'); +// Reference images (--ref, repeatable): route through the edits endpoint, +// which accepts input images. This is how a comp for an established world +// inherits the real UI's identity from a captured screenshot instead of a +// prose paraphrase of it; the prompt then describes the NEW surface and the +// reference carries palette, type, and component character. +const refs = (() => { + const found = []; + for (let i = 0; i < process.argv.length; i += 1) { + if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]); + } + return found; +})(); -const response = await fetch('https://api.openai.com/v1/images/generations', { - method: 'POST', - headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' }, - body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }), -}); +let response; +if (refs.length) { + const form = new FormData(); + form.append('model', 'gpt-image-2'); + form.append('prompt', prompt); + form.append('size', size); + form.append('quality', quality); + form.append('n', '1'); + for (const ref of refs) { + const bytes = fs.readFileSync(ref); + const type = ref.endsWith('.png') ? 'image/png' : ref.endsWith('.webp') ? 'image/webp' : 'image/jpeg'; + form.append('image[]', new Blob([bytes], { type }), ref.split('/').pop()); + } + response = await fetch('https://api.openai.com/v1/images/edits', { + method: 'POST', + headers: { Authorization: `Bearer ${key}` }, + body: form, + }); +} else { + response = await fetch('https://api.openai.com/v1/images/generations', { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }), + }); +} if (!response.ok) { console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`); process.exit(1); @@ -235,6 +272,6 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64')); try { const { spawnSync } = await import('node:child_process'); spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' }); - fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2)); + fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2)); } catch { /* embedding is best-effort */ } console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`); diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index 3363e117f..2cc9fa3f1 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -185,7 +185,7 @@ if (hasFlag('schema')) { 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/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 "IMPECCABLE’S 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.'); + 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 "IMPECCABLE’S 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. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. 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); } @@ -472,13 +472,36 @@ function page() { } return ''; }; + // Wireframe media: a code-led card's layout schematic, authored as grid + // regions in the payload and drawn by the page; boxes and labels, no art. + // It fills the media slot only when the card has no imagery, and it never + // counts toward salience or earns a card back: the full read stays on the + // front, exactly like a text-only card. + const wire = (option) => { + const frame = option.wireframe; + if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return ''; + const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12; + const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10; + const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`; + const cells = frame.regions.slice(0, 12).map((region) => { + const x = Number(region.x) || 0; + const y = Number(region.y) || 0; + const w = Math.max(Number(region.w) || 1, 0.5); + const h = Math.max(Number(region.h) || 1, 0.5); + return `
${esc(region.label || '')}
`; + }).join(''); + return ``; + }; const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this'; const cards = options.map((option, index) => `
-
+
${option.kicker ? `${esc(option.kicker)}` : demoted(option) ? 'Declined' : option.isCanon ? 'The standing door' : ''} - ${media(option)} + ${media(option) || wire(option)}
${option.lineage ? `

${esc(option.lineage)}

` : ''}

${esc(option.label)}

@@ -710,6 +733,15 @@ function page() { .kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); } .card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; } .card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); } + /* Wireframe media: the code-led schematic. Quiet boxes in the card's own + chrome; uniform salience across cards by construction, so it needs no + parity rules. */ + .media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); } + .wire-field { position: absolute; inset: 12px 12px 26px; } + .wire-region { position: absolute; border: 1px solid oklch(78% 0 0 / 0.26); border-radius: 3px; background: oklch(78% 0 0 / 0.05); display: flex; align-items: center; justify-content: center; overflow: hidden; } + .wire-region span { font-family: var(--ks-mono); font-size: .55rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; } + .wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); } + .wire-region.accent span { color: var(--ks-kinpaku-rich); } /* Thumb-scale inspiration: present, labeled, zoomable, and incapable of outshouting a text-only assigned card. */ .inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); } diff --git a/tests/new-work-e2e.test.mjs b/tests/new-work-e2e.test.mjs index 24a62ea7e..9cb82ed47 100644 --- a/tests/new-work-e2e.test.mjs +++ b/tests/new-work-e2e.test.mjs @@ -471,6 +471,46 @@ describe('new-work-e2e: serve-question decision page', () => { } }); + it('(g) a wireframe card draws its schematic in the media slot and keeps its full read on the front', async () => { + const cwd = makeWorkspace(); + const key = 'wire'; + const payload = { + title: 'Choose the structure', + options: [ + { + id: 'assigned', label: 'The Ledger Spine', kicker: 'THE ROLL', + viewport: 'An alphabetical spine with a sticky category rail.', + wireframe: { cols: 12, rows: 10, regions: [ + { label: 'category rail', x: 0, y: 0, w: 3, h: 10, accent: true }, + { label: 'ledger rows', x: 3, y: 0, w: 9, h: 10 }, + ] }, + }, + { id: 'alt', label: 'The Switchboard', wireframe: { regions: [{ label: 'board', x: 0, y: 0, w: 12, h: 10 }] } }, + ], + reroll: true, + steer: true, + }; + await startDaemon(cwd, payload, key); + const state = JSON.parse(readFileSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`), 'utf8')); + try { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(state.url, { waitUntil: 'load' }); + const regions = await page.$$eval('.card[data-id="assigned"] .wire-region', (els) => els.length); + const accent = await page.$('.card[data-id="assigned"] .wire-region.accent'); + const back = await page.$('.card[data-id="assigned"] .face.back'); + const front = await page.$eval('.card[data-id="assigned"] .face.front', (el) => el.textContent); + await context.close(); + assert.equal(regions, 2, 'both wireframe regions render'); + assert.ok(accent, 'the accent region carries its class'); + assert.equal(back, null, 'a wireframe card has no back; the full read stays on the front'); + assert.match(front, /alphabetical spine/, 'the front keeps the full read beside the schematic'); + } finally { + await stopDaemon(cwd, key); + rmSync(cwd, { recursive: true, force: true }); + } + }); + it('(f) a hero that never loads collapses to a labeled palette field, not a dark void', async () => { const cwd = makeWorkspace(); const key = 'brokenart';