Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-08-14 04:12:14 +00:00
parent 19ed691810
commit 103343d746
32 changed files with 2320 additions and 608 deletions
@@ -109,7 +109,11 @@ Before loading new-work or resuming shape/build, verify that PRODUCT.md exists a
## Step 5: Record workflow defaults
When image generation is available (context.mjs reports it) and no `buildPath` is recorded yet, ask once how new surfaces should be built, stated as the trade it is: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
When image generation is available and no `buildPath` is recorded yet, ask once how new surfaces should be built. Availability means a harness-native image tool or the API fallback that context.mjs reports as `IMAGE_GEN_AVAILABLE`, and the first of those leaves no trace in the boot output: context.mjs only sees the key, so a silent boot on a harness that generates images is not evidence there is nothing to ask about. This is its own question, never a clause riding inside another one. The stack round asks what to build with; this asks how the building starts, and an answer to the first carries no consent about the second. State the trade in the question the user actually reads, because the two names mean nothing to someone meeting them for the first time: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster).
Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. Write only the value the user chose. A recommendation you made is not an answer you received, and a value taken from silence is a standing default nobody set: it then rides every future round in the project, which is the opposite of asking once. When the question goes unanswered, record nothing and say in one line which path this session is taking and that it is not stored. That path is comp-first, the default new-work applies wherever image generation exists and nothing is recorded; name it rather than choosing a quieter one, because a silent default invented here is the same failure as a value written without an answer. Unset is a working state, not a gap: the decision page's toggle governs each session, and new-work's one-time offer records the answer the first time the user flips it. The config is the only place this lives. It is a workflow setting, not product truth, so it never joins `## Stack` or any other PRODUCT.md section, where a second copy would outlive the setting and steer rounds nobody could trace back to it.
A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
Then configure live mode when useful: skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
@@ -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>
@@ -1019,6 +1020,12 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
const landTracker = { last: Date.now() };
const pollComp = (m) => {
const url = m.dataset.comp;
// A converted slot is the SAME node across flip cycles, so a probe from an
// earlier cycle can still be in flight when the next one starts. Without a
// generation stamp its late onload settles the new slot: shimmer stripped,
// pending state cleared, comp still hidden, live poll stopped.
const generation = String(Number(m.dataset.pollGen || 0));
const current = () => String(Number(m.dataset.pollGen || 0)) === generation;
const img = m.querySelector('img.comp');
const note = m.querySelector('.comp-note');
const started = Date.now();
@@ -1063,11 +1070,25 @@ ${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') || !current()) { clearInterval(tick); return; }
const probe = new Image();
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
probe.onload = () => {
// A stale generation also ends this run's clock. tryLoad clears it on
// re-entry, and an in-flight probe that finishes stale schedules no
// re-entry, so returning without clearing ran the interval for the rest
// of the page's life.
if (!current()) { clearInterval(tick); return; }
landTracker.last = Date.now();
const target = m.querySelector('img.comp') || img;
target.src = probe.src;
target.hidden = false;
settle();
};
probe.onerror = () => {
if (!current()) { clearInterval(tick); return; }
const quiet = Date.now() - landTracker.last > 240000;
if (Date.now() - started > 240000 && quiet && fallback()) return;
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
@@ -1101,17 +1122,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&hellip;</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&hellip;</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 +1182,34 @@ ${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') {
// Everything the pending state added has to leave, presentation
// included: a slot that reached stand-in kept its "inspiration comp
// pending" label beside a fresh one, and a slot whose art had failed
// came back still marked unavailable.
pending.classList.remove('comp-pending', 'stand-in');
delete pending.dataset.compRestore;
delete pending.dataset.comp;
pending.dataset.pollGen = String(Number(pending.dataset.pollGen || 0) + 1);
pending.querySelector('.shimmer')?.remove();
pending.querySelector('img.comp')?.remove();
pending.querySelector('.stand-in-label')?.remove();
pending.querySelectorAll('.media-label').forEach((el) => el.remove());
const pip = pending.querySelector('.pip');
const art = pip?.querySelector('img');
if (art) pending.insertBefore(art, pending.firstChild);
pip?.remove();
// No art means the image failed to load before the flip, so hand the
// slot back to the same honest treatment rather than calling it art.
const label = document.createElement('p');
label.className = 'media-label';
label.textContent = art ? 'inspiration' : 'artwork unavailable';
pending.insertBefore(label, pending.querySelector('.chips'));
if (art) pending.title = INSPO_TITLE; else pending.classList.add('unavailable');
return;
}
pending.remove();
const wireEl = front.querySelector('.media.wire');
if (wireEl) wireEl.hidden = false;
@@ -1181,15 +1271,42 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
else img.addEventListener('error', gone, { once: true });
});
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
const img = p.querySelector('img');
if (!img) return;
lightboxImg.src = img.getAttribute('src');
lightbox.hidden = false;
requestAnimationFrame(() => lightbox.classList.add('open'));
}));
// Every zoom target resolves in ONE delegated listener, in priority order.
// Delegation is what lets a slot built by a build-path flip work at all, and
// a single listener is what keeps the targets from fighting: stopPropagation
// ends bubbling, not other listeners on the same target, so split across two
// handlers a click on the corner inspiration opened the inspiration and then
// the comp overwrote it in the lightbox. Per-element handlers elsewhere (the
// flip chip, the raise cycler) still stop bubbling before the event lands
// here, so they keep their own behavior.
document.addEventListener('click', (e) => {
const target = e.target;
if (!target || !target.closest) return;
// The corner inspiration wins over the slot it sits inside.
const pip = target.closest('.pip, .inspo');
if (pip) {
const art = pip.querySelector('img');
if (art) openLightbox(art);
return;
}
const chip = target.closest('.chip');
if (chip) {
// Only expand zooms. Any other chip owns its click and must not fall
// through to the media underneath it.
if (!chip.classList.contains('expand')) return;
const card = chip.closest('.card');
const face = card && card.classList.contains('flipped') ? '.face.back' : '.face.front';
const shown = card && card.querySelector(face + ' .media img:not([hidden])');
if (shown && shown.getAttribute('src')) openLightbox(shown);
return;
}
// The whole image is the zoom target, not just the chip; the chip stays as
// the visible affordance.
const media = target.closest('.media');
if (!media) return;
const art = media.querySelector(':scope > img:not([hidden])');
if (art && art.getAttribute('src')) openLightbox(art);
});
// 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 +1356,13 @@ ${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) => {
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;
// 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'));
}));
}
// 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.
@@ -1262,17 +1376,6 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
document.querySelector('.grid')?.classList.add('portrait-media');
}
}, true);
// 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', () => {
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'));
}));
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(); });