mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Flipping to comp demotes the inspiration instead of stacking a second slot
Two defects in the same few lines, both from `enterComp` hand-building a media slot after the deal instead of reaching the shape a comp-first render serves. A code-led card carrying catalog art shows that art as its face. Flipping to comp inserted a fresh shimmer slot above the body and left the face alone, so the card rendered the inspiration full-bleed with the rendering comp stacked under it: two images of equal weight, which is the one thing the corner treatment exists to prevent. The flip now converts that slot in place, moving the art into the `figure.pip` and dropping the face label, and flipping back restores it, so a round-trip leaves the card as it was dealt. The slot it built also carried no chips, and the zoom handlers were bound per element at load, so a comp that streamed in after a flip could not be opened at all: no expand affordance, and no click handler on the art. The three lightbox handlers are now delegated, which is what makes any later-built slot work, and a converted slot keeps the chips it already had. Polling learned to stop on a slot that stays in the DOM but loses its pending state, which only happens now that a flip back can restore rather than remove. The existing toggle test covered a wireframe card, where the schematic is hidden and a fresh slot inserted; that branch was fine, which is why this went unseen. The new test drives the art-carrying card and fails on the stacking assertion without this change. Written with AI assistance (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4f08ff3bfc
commit
ec189f4536
@@ -881,6 +881,7 @@ function page() {
|
||||
<div id="ambient" aria-hidden="true"></div>
|
||||
<div id="scrim" aria-hidden="true"></div>
|
||||
<div id="lightbox" hidden><img alt=""></div>
|
||||
<template id="tpl-expand-chip">${expandChip}</template>
|
||||
${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
|
||||
<div class="bp-confirm-panel">
|
||||
<h2 id="bp-confirm-title">Flip to comp-first?</h2>
|
||||
@@ -1063,8 +1064,10 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
return true;
|
||||
};
|
||||
const tryLoad = () => {
|
||||
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||
if (!m.isConnected) { clearInterval(tick); return; }
|
||||
// A slot the user flipped back out of either leaves the DOM or, when it
|
||||
// was an inspiration face converted in place, stays and loses its
|
||||
// pending state. Either way its loop is done.
|
||||
if (!m.isConnected || !m.classList.contains('comp-pending')) { clearInterval(tick); return; }
|
||||
const probe = new Image();
|
||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||
probe.onerror = () => {
|
||||
@@ -1101,17 +1104,58 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
if (noteEl) noteEl.textContent = notes[value];
|
||||
};
|
||||
set(current);
|
||||
const INSPO_TITLE = 'Inspiration: the world this direction draws from. Your page will not look like this image.';
|
||||
// Every media slot carries the expand affordance. A slot built here after
|
||||
// the deal used to ship without one, so a comp the user waited minutes for
|
||||
// could not be opened.
|
||||
const ensureChips = (m) => {
|
||||
if (m.querySelector('.chips')) return;
|
||||
const tpl = document.getElementById('tpl-expand-chip');
|
||||
if (!tpl) return;
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'chips';
|
||||
chips.appendChild(tpl.content.cloneNode(true));
|
||||
m.appendChild(chips);
|
||||
};
|
||||
const shimmerHtml = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||
const enterComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
|
||||
const m = document.createElement('div');
|
||||
m.className = 'media comp-pending';
|
||||
m.dataset.comp = card.dataset.compSlot;
|
||||
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||
// On a code-led card the inspiration IS the face. Comp-first demotes it
|
||||
// to the corner, so convert that slot in place rather than inserting a
|
||||
// second one: two stacked images say the catalog art and the comp are
|
||||
// peers, and the whole point of the corner is that they are not.
|
||||
const inspo = front.querySelector('.media:not(.wire):not(.comp-pending)');
|
||||
let m;
|
||||
if (inspo) {
|
||||
m = inspo;
|
||||
m.dataset.compRestore = 'inspiration';
|
||||
m.dataset.comp = card.dataset.compSlot;
|
||||
m.classList.add('comp-pending');
|
||||
m.removeAttribute('title');
|
||||
m.querySelector('.media-label')?.remove();
|
||||
const art = m.querySelector(':scope > img');
|
||||
if (art) {
|
||||
const pip = document.createElement('figure');
|
||||
pip.className = 'pip';
|
||||
pip.title = INSPO_TITLE;
|
||||
const cap = document.createElement('figcaption');
|
||||
cap.textContent = 'inspiration';
|
||||
pip.append(art, cap);
|
||||
m.appendChild(pip);
|
||||
}
|
||||
m.insertAdjacentHTML('afterbegin', shimmerHtml);
|
||||
} else {
|
||||
m = document.createElement('div');
|
||||
m.className = 'media comp-pending';
|
||||
m.dataset.comp = card.dataset.compSlot;
|
||||
m.innerHTML = shimmerHtml;
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||
}
|
||||
ensureChips(m);
|
||||
pollComp(m);
|
||||
});
|
||||
};
|
||||
@@ -1120,6 +1164,25 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
const front = card.querySelector('.face.front');
|
||||
const pending = front?.querySelector('.media.comp-pending');
|
||||
if (!pending) return; // landed comps stay; they exist either way
|
||||
// A converted slot is restored, not removed: the inspiration goes back
|
||||
// to being the face, so flipping back leaves the card as it was dealt.
|
||||
if (pending.dataset.compRestore === 'inspiration') {
|
||||
pending.classList.remove('comp-pending');
|
||||
delete pending.dataset.compRestore;
|
||||
delete pending.dataset.comp;
|
||||
pending.querySelector('.shimmer')?.remove();
|
||||
pending.querySelector('img.comp')?.remove();
|
||||
const pip = pending.querySelector('.pip');
|
||||
const art = pip?.querySelector('img');
|
||||
if (art) pending.insertBefore(art, pending.firstChild);
|
||||
pip?.remove();
|
||||
const label = document.createElement('p');
|
||||
label.className = 'media-label';
|
||||
label.textContent = 'inspiration';
|
||||
pending.insertBefore(label, pending.querySelector('.chips'));
|
||||
pending.title = INSPO_TITLE;
|
||||
return;
|
||||
}
|
||||
pending.remove();
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) wireEl.hidden = false;
|
||||
@@ -1182,14 +1245,17 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
});
|
||||
|
||||
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
|
||||
// Delegated, like every other zoom target: a build-path flip builds media
|
||||
// slots after load, and a per-element listener bound at deal time never
|
||||
// reaches them. That is how a streamed-in comp ended up unopenable.
|
||||
document.addEventListener('click', (e) => {
|
||||
const p = e.target.closest?.('.pip, .inspo');
|
||||
if (!p) return;
|
||||
e.stopPropagation();
|
||||
const img = p.querySelector('img');
|
||||
if (!img) return;
|
||||
lightboxImg.src = img.getAttribute('src');
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
}));
|
||||
openLightbox(img);
|
||||
});
|
||||
|
||||
// Deck paging: arrows appear only when the deck overflows its axis, page
|
||||
// one card at a time, and follow the aspect-ratio flip between row and column.
|
||||
@@ -1239,16 +1305,23 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
// Expand: lightbox for whichever face is showing.
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
const lightboxImg = lightbox.querySelector('img');
|
||||
document.querySelectorAll('.expand').forEach(b => b.addEventListener('click', (e) => {
|
||||
// Declared, not assigned to a const, so the delegated handlers above can call
|
||||
// it wherever they sit in this file.
|
||||
function openLightbox(img) {
|
||||
lightboxImg.src = img.getAttribute('src');
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
const b = e.target.closest?.('.expand');
|
||||
if (!b) return;
|
||||
e.stopPropagation();
|
||||
const card = b.closest('.card');
|
||||
const face = card.classList.contains('flipped') ? '.face.back' : '.face.front';
|
||||
const img = card.querySelector(face + ' .media img:not([hidden])');
|
||||
if (!img || !img.getAttribute('src')) return;
|
||||
lightboxImg.src = img.getAttribute('src');
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
}));
|
||||
openLightbox(img);
|
||||
});
|
||||
// Portrait art (native / mobile-first surfaces): the slot takes the
|
||||
// image's own ratio so nothing crops, and the whole deck narrows so
|
||||
// portrait cards sit side by side. Load events don't bubble; capture.
|
||||
@@ -1266,13 +1339,13 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
// The whole image is the zoom target, not just the expand chip; the chip
|
||||
// stays as the visible affordance. Chip and PIP handlers stop propagation,
|
||||
// so this fires only for clicks on the art itself.
|
||||
document.querySelectorAll('.media').forEach(m => m.addEventListener('click', () => {
|
||||
document.addEventListener('click', (e) => {
|
||||
const m = e.target.closest?.('.media');
|
||||
if (!m) return;
|
||||
const img = m.querySelector(':scope > img:not([hidden])');
|
||||
if (!img || !img.getAttribute('src')) return;
|
||||
lightboxImg.src = img.getAttribute('src');
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
}));
|
||||
openLightbox(img);
|
||||
});
|
||||
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
|
||||
@@ -609,6 +609,78 @@ describe('new-work-e2e: serve-question decision page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The flip used to be tested only on a wireframe card, where the schematic
|
||||
// is hidden and a fresh slot inserted. A card carrying catalog art instead
|
||||
// took the other branch: the inspiration stayed the face and the comp slot
|
||||
// was inserted below it, so the card showed two stacked images. Comp-first
|
||||
// demotes the inspiration to the corner, and a flip has to reach the same
|
||||
// shape a comp-first render would have served.
|
||||
it('(f2) flipping to comp demotes an inspiration face to the corner instead of stacking a second slot', async () => {
|
||||
const cwd = makeWorkspace();
|
||||
const key = 'flipinspo';
|
||||
const hero = makeFakeImage(cwd, 'catalog inspiration', 'inspo.png');
|
||||
const payload = {
|
||||
title: 'Choose the visual world',
|
||||
buildPath: { value: 'code', toggle: true },
|
||||
options: [
|
||||
{
|
||||
id: 'assigned', label: 'The Ledger Spine', kicker: 'THE ROLL', hero,
|
||||
comp: '.impeccable/mocks/decision/assigned.webp',
|
||||
viewport: 'A ruled masthead over a dense grid.', risk: 'Reads editorial.',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { url } = await startDaemon(cwd, payload, key);
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'load' });
|
||||
|
||||
const front = '.card[data-id="assigned"] .face.front';
|
||||
// Code-led: the catalog art is the face, labeled so it never reads as a promise.
|
||||
assert.equal(await page.$$eval(`${front} .media`, (els) => els.length), 1, 'one media slot before the flip');
|
||||
assert.ok(await page.$(`${front} .media .media-label`), 'the inspiration face is labeled');
|
||||
assert.equal(await page.$(`${front} .pip`), null, 'no corner inspiration while code-led');
|
||||
|
||||
await page.click('.bp-opt[data-bp="comp"]');
|
||||
await page.waitForSelector('#bp-confirm:not([hidden])');
|
||||
await page.click('#bp-confirm [data-confirm]');
|
||||
await page.waitForSelector(`${front} .media.comp-pending`);
|
||||
|
||||
// The whole point: one slot, not two.
|
||||
assert.equal(await page.$$eval(`${front} .media`, (els) => els.length), 1,
|
||||
'the flip converts the inspiration slot rather than stacking a second one');
|
||||
assert.ok(await page.$(`${front} .media.comp-pending .pip img`), 'the inspiration moved into the corner');
|
||||
assert.equal(await page.$(`${front} .media > .media-label`), null, 'it is no longer presented as the face');
|
||||
assert.ok(await page.$(`${front} .media .chip.expand`), 'the converted slot keeps its expand affordance');
|
||||
|
||||
const flipped = await waitLoop(cwd, key, { poll: 10 });
|
||||
assert.match(flipped.out, /BUILD PATH FLIPPED: comp/);
|
||||
|
||||
// A comp that streams in must be openable. The zoom handlers used to be
|
||||
// bound once at deal time, so anything built by the flip was inert.
|
||||
mkdirSync(path.join(cwd, '.impeccable', 'mocks', 'decision'), { recursive: true });
|
||||
makeFakeImage(path.join(cwd, '.impeccable', 'mocks', 'decision'), 'ledger spine comp', 'assigned.webp');
|
||||
await page.waitForSelector(`${front} .media img.comp:not([hidden])`, { timeout: 15000 });
|
||||
await page.click(`${front} .media .chip.expand`);
|
||||
await page.waitForSelector('#lightbox:not([hidden])');
|
||||
assert.ok(
|
||||
await page.$eval('#lightbox img', (img) => Boolean(img.getAttribute('src'))),
|
||||
'the streamed-in comp opens full screen',
|
||||
);
|
||||
await page.click('#lightbox');
|
||||
|
||||
// Flipping back restores the card as it was dealt.
|
||||
await page.click('.bp-opt[data-bp="code"]');
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(await page.$$eval(`${front} .media`, (els) => els.length), 1, 'still one slot after flipping back');
|
||||
await context.close();
|
||||
} finally {
|
||||
await stopDaemon(cwd, key);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user