mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Build-path toggle moves to the header; a code-to-comp flip confirms first
The toggle sits top-left under the brand instead of in the footer bar, and flipping to comp-first now opens a confirm dialog before anything renders, since the flip starts billed, minutes-long generation; flipping back stays free and immediate. The dialog lives at the document root so it never loses the stacking fight with the deck. The schema blob also states harder that toggle: true may only be offered when image generation exists. Written with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1d5e05785b
commit
76b9aaf021
@@ -199,7 +199,7 @@ if (hasFlag('schema')) {
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp 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 comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. 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. The pick card is a kicker convention, not a field: kicker "IMPECCABLE’S PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. 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. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/settings.json, or a PRODUCT.md standing commitment as fallback) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. Offer the toggle only when image generation exists. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp 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 comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. 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. The pick card is a kicker convention, not a field: kicker "IMPECCABLE’S PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. 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. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/settings.json, or a PRODUCT.md standing commitment as fallback) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -820,16 +820,31 @@ function page() {
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
|
||||
control, its trade stated in one line that changes with the selection.
|
||||
The default comes from the payload (settings); flipping binds this
|
||||
session only, and the agent learns about a code-to-comp flip live. */
|
||||
#build-path { display: flex; flex-direction: column; gap: 4px; }
|
||||
control in the header, top-left under the brand, its trade stated in
|
||||
one line that changes with the selection. The default comes from the
|
||||
payload (settings); flipping binds this session only, and the agent
|
||||
learns about a code-to-comp flip live. Rendered only when the payload
|
||||
offers it, which the agent does only when image generation exists. */
|
||||
#build-path { display: flex; flex-direction: column; gap: 4px; margin: 1rem 0 0.2rem; }
|
||||
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; align-self: flex-start; }
|
||||
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
|
||||
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
|
||||
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
|
||||
.bp-opt:not(.active):hover { color: var(--ks-text); }
|
||||
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
|
||||
/* Flipping to comp starts billed, minutes-long generation, so it asks
|
||||
first; flipping back is free and never does. */
|
||||
#bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.72); opacity: 0; transition: opacity .2s ease; }
|
||||
#bp-confirm[hidden] { display: none; }
|
||||
#bp-confirm.open { opacity: 1; }
|
||||
.bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.55); }
|
||||
.bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
|
||||
.bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
|
||||
.bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
|
||||
.bp-confirm-go { background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
|
||||
.bp-confirm-stay { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font: inherit; padding: 9px 18px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
@@ -858,11 +873,28 @@ function page() {
|
||||
<div id="ambient" aria-hidden="true"></div>
|
||||
<div id="scrim" aria-hidden="true"></div>
|
||||
<div id="lightbox" hidden><img alt=""></div>
|
||||
${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>
|
||||
<p>The agent starts rendering a comp for every open card right away, about a minute or two per card on your image provider, and the images land on the cards as they finish. This flip binds this session only.</p>
|
||||
<div class="bp-confirm-actions">
|
||||
<button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
|
||||
<button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<header>
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>
|
||||
<span class="wordmark">Impeccable</span>
|
||||
</div>
|
||||
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||
</div>
|
||||
<p class="bp-note" data-bp-note></p>
|
||||
</div>` : ''}
|
||||
</header>
|
||||
<main>
|
||||
<div class="stage">
|
||||
@@ -882,13 +914,6 @@ function page() {
|
||||
</main>
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||
</div>
|
||||
<p class="bp-note" data-bp-note></p>
|
||||
</div>` : ''}
|
||||
${(() => {
|
||||
if (!payload.reroll) return '';
|
||||
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||
@@ -1068,12 +1093,28 @@ function page() {
|
||||
else if (!front.querySelector('.media')) front.classList.add('text-only');
|
||||
});
|
||||
};
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||
const value = b.dataset.bp;
|
||||
if (value === current) return;
|
||||
const apply = (value) => {
|
||||
set(value);
|
||||
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||
if (value === 'comp') enterComp(); else exitComp();
|
||||
};
|
||||
// Flipping to comp starts real generation, so it confirms first; the
|
||||
// flip back is free and applies immediately.
|
||||
const confirm = document.getElementById('bp-confirm');
|
||||
const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
|
||||
confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
|
||||
confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
|
||||
confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||
const value = b.dataset.bp;
|
||||
if (value === current) return;
|
||||
if (value === 'comp') {
|
||||
confirm.hidden = false;
|
||||
requestAnimationFrame(() => confirm.classList.add('open'));
|
||||
return;
|
||||
}
|
||||
apply(value);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -494,6 +494,9 @@ describe('new-work-e2e: serve-question decision page', () => {
|
||||
assert.equal(await page.$('.media.comp-pending'), null, 'code-led serves no shimmer');
|
||||
assert.ok(await page.$('.media.wire'), 'code-led serves the wireframe');
|
||||
await page.click('.bp-opt[data-bp="comp"]');
|
||||
await page.waitForSelector('#bp-confirm:not([hidden])');
|
||||
assert.equal(await page.$('.media.comp-pending'), null, 'nothing renders before the flip is confirmed');
|
||||
await page.click('#bp-confirm [data-confirm]');
|
||||
await page.waitForSelector('.media.comp-pending');
|
||||
const flipped = await waitLoop(cwd, key, { poll: 10 });
|
||||
assert.equal(flipped.code, 0, flipped.out);
|
||||
|
||||
Reference in New Issue
Block a user