diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs
index 8dc30822e..25beaa759 100644
--- a/skill/scripts/serve-question.mjs
+++ b/skill/scripts/serve-question.mjs
@@ -143,7 +143,7 @@ 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.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.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.', sketch: '.impeccable/sketches/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: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
],
reroll: true,
@@ -151,7 +151,7 @@ if (hasFlag('schema')) {
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/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. 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. 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. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
+ 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. 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. 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. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
process.exit(0);
}
@@ -321,7 +321,11 @@ function page() {
// and material tags give a text-only direction an immediate identity that
// no generation luck can distort.
const fact = (label, value, cls = '') => value ? `
${label}${esc(value)}
` : '';
- const hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
+ const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
+ // 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.
+ const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
const anatomy = (option) => {
const rows = [];
if (option.thesis) rows.push(`${esc(option.thesis)}
`);
@@ -336,7 +340,15 @@ 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.
- rows.push(fact('Risk', option.risk, 'clamp'));
+ // With no art there is no back, so the full read fills the room the
+ // image would have taken.
+ if (hasMedia(option)) {
+ rows.push(fact('Risk', option.risk, 'clamp'));
+ } else {
+ rows.push(fact('First viewport', option.viewport));
+ rows.push(fact('The case', option.case));
+ rows.push(fact('Risk', option.risk));
+ }
if (!option.thesis && option.body) rows.push(`${esc(option.body)}
`);
else if (option.body && option.thesis && !hasBack(option)) rows.push(`${esc(option.body)}
`);
return rows.join('\n ');
@@ -362,8 +374,11 @@ function page() {
`;
}
if (option.heroSrc || option.boardSrc) {
- return `
+ // Without a sketch the catalog art is the card's face; it stays a
+ // labeled reference so it never reads as the promise of the build.
+ return `
`;
}
@@ -547,6 +562,17 @@ function page() {
.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
+ 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
+ inline from its swatches) instead of sitting as a dark void wearing a
+ zoom cursor; the scrim keeps the label legible over saturated fields,
+ passes clicks through, and the flip chips stay above it. A card with no
+ palette falls back to the quiet graphite field. */
+ .media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
+ .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); }
@@ -709,6 +735,36 @@ function page() {
tryLoad();
});
+ // A declared image that never loads (missing catalog asset, offline shell)
+ // must not sit as a dark void: the slot collapses to the card's own
+ // palette, labeled honestly, and the card competes on its facts. Sketch
+ // 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;
+ 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(', ') + ')';
+ m.querySelector('.media-label')?.remove();
+ m.querySelector('.chip.expand')?.remove();
+ m.removeAttribute('title');
+ img.remove();
+ const label = document.createElement('p');
+ label.className = 'media-label';
+ label.textContent = 'artwork unavailable';
+ m.appendChild(label);
+ };
+ document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
+ if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
+ else img.addEventListener('error', () => artFailed(img), { once: true });
+ });
+ // A broken inspiration PIP just leaves; nothing depends on it.
+ document.querySelectorAll('.pip img').forEach(img => {
+ const gone = () => img.closest('.pip')?.remove();
+ if (img.complete && img.naturalWidth === 0) gone();
+ else img.addEventListener('error', gone, { once: true });
+ });
+
// Inspiration PIP opens the full catalog card in the lightbox.
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
diff --git a/tests/new-work-e2e.test.mjs b/tests/new-work-e2e.test.mjs
index 0cbde6567..327cf07d8 100644
--- a/tests/new-work-e2e.test.mjs
+++ b/tests/new-work-e2e.test.mjs
@@ -274,7 +274,10 @@ describe('new-work-e2e: serve-question decision page', () => {
const payload = {
title: 'Choose the visual world',
options: [
- { id: 'assigned', label: 'Text Only Direction', body: 'a grounded direction, no comp' },
+ {
+ id: 'assigned', label: 'Text Only Direction', body: 'a grounded direction, no comp',
+ viewport: 'A full-width ruled manifest with entries.', case: 'Grounded in the audience world.',
+ },
{ id: 'challenger-hero', label: 'Has A Card', hero },
],
reroll: true, steer: true,
@@ -288,10 +291,78 @@ describe('new-work-e2e: serve-question decision page', () => {
const textOnlyMedia = await page.$('.card[data-id="assigned"] .media');
const heroMedia = await page.$('.card[data-id="challenger-hero"] .media');
const textOnlyFace = await page.$('.card[data-id="assigned"] .face.text-only');
+ // No media means no back face to hide facts on: the full read renders
+ // on the front, where nothing else would have used the room.
+ const textOnlyBack = await page.$('.card[data-id="assigned"] .face.back');
+ const frontRead = await page.$eval('.card[data-id="assigned"] .face.front', (el) => el.textContent);
+ // Catalog art without a sketch is labeled as reference, never as the
+ // promise of the build.
+ const heroLabel = await page.$eval('.card[data-id="challenger-hero"] .media .media-label', (el) => el.textContent);
await context.close();
assert.equal(textOnlyMedia, null, 'text-only card has no .media region');
assert.ok(textOnlyFace, 'text-only card carries the .text-only face class');
assert.ok(heroMedia, 'the hero card still renders its .media region');
+ assert.equal(textOnlyBack, null, 'text-only card has no unreachable back face');
+ assert.match(frontRead, /First viewport/, 'text-only front carries the first viewport fact');
+ assert.match(frontRead, /The case/, 'text-only front carries the case fact');
+ assert.equal(heroLabel, 'inspiration', 'sketchless catalog art is labeled inspiration');
+ } 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';
+ const payload = {
+ title: 'Choose the visual world',
+ options: [
+ {
+ id: 'assigned', label: 'Broken Art Direction',
+ thesis: 'The art is gone but the direction is not.',
+ palette: ['#1a3a2e', '#f2ede2', '#c8452a'],
+ risk: 'None.',
+ viewport: 'A ruled manifest.', case: 'Back facts must stay reachable.',
+ // Port 1 refuses connections everywhere; the load fails offline
+ // and deterministically, like a retired catalog URL in the field.
+ hero: 'http://127.0.0.1:1/gone-hero.webp',
+ },
+ {
+ id: 'challenger-bare', label: 'No Palette Direction',
+ thesis: 'Broken art and no swatches to paint from.',
+ hero: 'http://127.0.0.1:1/gone-too.webp',
+ },
+ ],
+ reroll: true, steer: true,
+ };
+ const { url } = await startDaemon(cwd, payload, key);
+ try {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto(url, { waitUntil: 'load' });
+ await page.waitForSelector('.card[data-id="assigned"] .media.unavailable');
+ await page.waitForSelector('.card[data-id="challenger-bare"] .media.unavailable');
+ const imgGone = await page.$('.card[data-id="assigned"] .media img');
+ const label = await page.$eval('.card[data-id="assigned"] .media .media-label', (el) => el.textContent);
+ const field = await page.$eval('.card[data-id="assigned"] .media', (el) => el.style.background);
+ // The stale "Inspiration" tooltip goes with the art it described.
+ const title = await page.$eval('.card[data-id="assigned"] .media', (el) => el.getAttribute('title'));
+ // The scrim must not trap the flip controls: Details still flips.
+ await page.click('.card[data-id="assigned"] .chip.flip');
+ const flipped = await page.$('.card[data-id="assigned"].flipped');
+ // A palette-less card keeps the label and leans on the CSS graphite
+ // field instead of an inline gradient.
+ const bareLabel = await page.$eval('.card[data-id="challenger-bare"] .media .media-label', (el) => el.textContent);
+ const bareField = await page.$eval('.card[data-id="challenger-bare"] .media', (el) => el.style.background);
+ await context.close();
+ assert.equal(imgGone, null, 'the broken img is removed');
+ assert.equal(label, 'artwork unavailable', 'the slot says what happened');
+ assert.match(field, /linear-gradient/, 'the slot is painted from the card palette');
+ assert.equal(title, null, 'the inspiration tooltip is removed with the art');
+ assert.ok(flipped, 'the Details chip still flips the card under the scrim');
+ assert.equal(bareLabel, 'artwork unavailable', 'a palette-less slot still says what happened');
+ assert.equal(bareField, '', 'a palette-less slot takes the CSS fallback field, no inline gradient');
} finally {
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });