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 ``;
}
@@ -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';