diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index 09693e2ef..225025bd6 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -444,8 +444,9 @@ function page() { option.body && option.thesis ? `

${esc(option.body)}

` : '', ].filter(Boolean).join('\n '); const media = (option) => { - const inspiration = option.heroSrc ? `
- + const inspirationSrc = option.heroSrc || option.boardSrc; + const inspiration = inspirationSrc ? `
+
inspiration
` : ''; const details = hasBack(option) ? flipChip('Details') : ''; @@ -453,10 +454,12 @@ function page() { // and a declined card's comp slot is ignored outright. if (thumbOnly(option)) return ''; if (faceComp(option)) { + const textOnlyFacts = backFacts(option); return `
rendering…
${inspiration} +
${expandChip}${details}
`; } @@ -879,24 +882,47 @@ function page() { // 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('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); }; - const standIn = () => { + const fallback = () => { const pip = m.querySelector('.pip img'); - if (!pip || m.classList.contains('stand-in')) return; - img.src = pip.getAttribute('src'); img.hidden = false; - m.classList.add('stand-in'); - m.querySelector('.shimmer')?.remove(); - clearInterval(tick); - const label = document.createElement('p'); - label.className = 'stand-in-label'; - label.textContent = 'inspiration · comp pending'; - m.appendChild(label); + if (pip) { + if (m.classList.contains('stand-in')) return false; + img.src = pip.getAttribute('src'); img.hidden = false; + m.classList.add('stand-in'); + m.querySelector('.shimmer')?.remove(); + clearInterval(tick); + const label = document.createElement('p'); + label.className = 'stand-in-label'; + label.textContent = 'inspiration · comp pending'; + m.appendChild(label); + return false; + } + + // No comp and no inspiration is the text-only card the payload would + // have rendered without a comp declaration. Bring the complete read + // forward before removing the now-unreachable back face. + const card = m.closest('.card'); + const front = card?.querySelector('.face.front'); + const body = front?.querySelector('.body'); + const back = card?.querySelector('.face.back'); + const textOnlyFacts = m.querySelector('template.text-only-facts'); + const choose = body?.querySelector(':scope > button.choose'); + if (body && textOnlyFacts && choose) { + [...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove()); + choose.before(textOnlyFacts.content.cloneNode(true)); + } + card?.classList.remove('flipped'); + front?.classList.add('text-only'); + back?.remove(); + settle(); + m.remove(); + return true; }; const tryLoad = () => { const probe = new Image(); probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); }; probe.onerror = () => { const quiet = Date.now() - landTracker.last > 240000; - if (Date.now() - started > 240000 && quiet) standIn(); + if (Date.now() - started > 240000 && quiet && fallback()) return; setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500); }; probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now(); diff --git a/tests/new-work-e2e.test.mjs b/tests/new-work-e2e.test.mjs index 461180159..54de1043f 100644 --- a/tests/new-work-e2e.test.mjs +++ b/tests/new-work-e2e.test.mjs @@ -315,6 +315,85 @@ describe('new-work-e2e: serve-question decision page', () => { } }); + it('(e1) a missing comp without inspiration settles into a complete text-only card', async () => { + const cwd = makeWorkspace(); + const key = 'missing-comp'; + const board = makeFakeImage(cwd, 'board fallback', 'board-fallback.png'); + const payload = { + title: 'Choose the visual world', + options: [ + { + id: 'assigned', label: 'Shotengai Chalkboard', kicker: 'THE ROLL', + thesis: 'A hand-lettered neighborhood noticeboard.', + body: 'Program notes explain how the board changes through the day.', + palette: ['#18251d', '#f2e8cb'], materials: ['chalk', 'painted timber'], + viewport: 'The daily program fills a ruled community board.', + case: 'The audience already reads this visual language.', + risk: 'Can become nostalgic if the type loses discipline.', + comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'missing.webp'), + }, + { + id: 'kept-only', label: 'Kept Line Only', + kept: 'Keep the hand-painted timetable as the organizing device.', + comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'kept-missing.webp'), + }, + { + id: 'body-only', label: 'Body Line Only', + thesis: 'A compact neighborhood notice.', + body: 'Body copy must remain present exactly once.', + comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'body-missing.webp'), + }, + { + id: 'board-only', label: 'Board Fallback', + board, + comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'board-missing.webp'), + }, + ], + reroll: true, + steer: true, + }; + const { url } = await startDaemon(cwd, payload, key); + try { + const context = await browser.newContext(); + await context.addInitScript(() => { + const realNow = Date.now.bind(Date); + let offset = 0; + Date.now = () => realNow() + offset; + window.__advanceDecisionClock = (ms) => { offset += ms; }; + }); + const page = await context.newPage(); + await page.goto(url, { waitUntil: 'load' }); + await page.waitForSelector('.card[data-id="assigned"] .media.comp-pending'); + + await page.evaluate(() => window.__advanceDecisionClock(241000)); + await page.waitForFunction(() => ['assigned', 'kept-only', 'body-only'].every((id) => + !document.querySelector(`.card[data-id="${id}"] .media`)), null, { timeout: 6000 }); + await page.waitForSelector('.card[data-id="board-only"] .media.stand-in'); + + const faceClass = await page.getAttribute('.card[data-id="assigned"] .face.front', 'class'); + const back = await page.$('.card[data-id="assigned"] .face.back'); + const frontRead = await page.$eval('.card[data-id="assigned"] .face.front', (el) => el.textContent); + const keptOnlyRead = await page.$eval('.card[data-id="kept-only"] .face.front', (el) => el.textContent); + const bodyOnlyRead = await page.$eval('.card[data-id="body-only"] .face.front', (el) => el.textContent); + const boardFallbackLabel = await page.$eval('.card[data-id="board-only"] .stand-in-label', (el) => el.textContent); + await context.close(); + + assert.match(faceClass, /text-only/, 'the settled card uses the text-only layout'); + assert.equal(back, null, 'the settled card has no unreachable back face'); + assert.match(frontRead, /First viewport/, 'the full first-viewport fact moves to the front'); + assert.match(frontRead, /The case/, 'the full case moves to the front'); + assert.match(frontRead, /Risk/, 'the risk remains readable after the media collapses'); + assert.match(frontRead, /Program notes explain/, 'thesis-linked body prose moves off the removed back face'); + assert.match(keptOnlyRead, /Keep the hand-painted timetable/, 'kept-only content survives without a back face'); + assert.equal(bodyOnlyRead.match(/Body copy must remain present exactly once\./g)?.length, 1, + 'body prose already on a no-back front is not duplicated'); + assert.equal(boardFallbackLabel, 'inspiration · comp pending', 'board-only art remains as the labeled fallback'); + } finally { + await stopDaemon(cwd, key); + rmSync(cwd, { recursive: true, force: true }); + } + }); + it('(e2) verdicts route the deck: declined cards demote, reorder to the end, and stay adoptable', async () => { const cwd = makeWorkspace(); const key = 'verdicts';