From 1045c6ca98ba46d6e7fdeda78ae04065ba386044 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 3 Aug 2026 18:45:32 -0700 Subject: [PATCH] Gracefully handle the no-image decision page (#502) * Gracefully handle the no-image decision page Tested the new-work path without image generation and fixed what broke: - A text-only card's back face (First viewport, The case) was unreachable: the Details flip chip only rendered inside the media block. Cards with no imagery now render their full read on the front and skip the back face. - A hero/board that fails to load (retired catalog URL, offline shell) sat as a dark void with a zoom cursor. The slot now collapses to a field painted from the card's own palette with an "artwork unavailable" pill; broken inspiration PIPs remove themselves. - Sketchless catalog art rendered unlabeled as the card's face, reading as the promise of the build. It now carries the same "inspiration" label and hover title the PIP uses. - The --schema example pointed at catalog URLs that 404 (missing family prefix); updated to the real asset paths and noted the text-only front behavior in the schema prose. Extends e2e test (e) with the front-read and label assertions and adds test (f) for the broken-image fallback. AI-assisted (Claude Code). Co-Authored-By: Claude Code * fix: address PR review bot findings - cursor[bot]: the unavailable-art scrim painted over the flip chips and swallowed their clicks; it now passes pointer events through and the chips render above it. - Copilot: a palette-less card whose art failed still read as a dark void and kept the stale Inspiration tooltip; the slot now falls back to the graphite field in CSS and the tooltip is removed with the art. Test (f) now covers both: a broken card with back facts must still flip via Details, and a palette-less broken card gets the labeled fallback. AI-assisted (Claude Code). Co-Authored-By: Claude Code --------- Co-authored-by: Claude Code --- skill/scripts/serve-question.mjs | 66 ++++++++++++++++++++++++++--- tests/new-work-e2e.test.mjs | 73 +++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 6 deletions(-) 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 `
+

inspiration

${expandChip}${details}
`; } @@ -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 });