mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +03:00
- Drop the black rule above the support list (it read as a hard hat
over the column).
- Drop the phase-caption strip below the frame (the demo already
carries its own state visually; the narrated loop underneath was
noise).
- Rewrite the four support cells with stronger POV:
- Start: the actual commands, /impeccable live and stop.
- Your agent, your code: emphasises that it's the same AI you've
been working with, reading your real project context, generating
real edits.
- Accept = write: canvas-like iteration, production output. The
point almost no other AI design tool can claim.
- Skill-native: no external tools or IDE extensions, works across
every harness the skill targets.
- Drop CSP-patch bullet (edge case, not worth homepage real estate).
- Drop the "Accepts into ... via HMR" framing — accepts work because
of the skill loop, not because of HMR per se.
243 lines
7.8 KiB
JavaScript
243 lines
7.8 KiB
JavaScript
// Interactive Live Mode demo loop. Matches the real picker flow:
|
|
// - a persistent dark global bar stays at the bottom of the frame the whole time
|
|
// - a light contextual bar floats above the picked element during a session,
|
|
// morphing between configure → generating → cycling → accepted
|
|
//
|
|
// Plays only while the section is in view. Respects prefers-reduced-motion.
|
|
|
|
const PHASE = {
|
|
HIDDEN: 'hidden',
|
|
CONFIGURING: 'configuring',
|
|
GENERATING: 'generating',
|
|
CYCLING: 'cycling',
|
|
ACCEPTED: 'accepted',
|
|
};
|
|
|
|
const TIMELINE = [
|
|
{ dt: 400, action: 'cursor-show' },
|
|
{ dt: 400, action: 'cursor-to-target' },
|
|
{ dt: 900, action: 'outline-show', caption: 'Hover to pick.' },
|
|
{ dt: 500, action: 'cursor-click' },
|
|
{ dt: 200, action: 'open-ctx', caption: 'Picked. Contextual bar appears.' },
|
|
{ dt: 700, action: 'cursor-to-input' },
|
|
{ dt: 300, action: 'type', text: 'more playful', caption: 'Type a refinement, or skip.' },
|
|
{ dt: 1200, action: 'draw-stroke', caption: 'Annotate on the page, if you want.' },
|
|
{ dt: 900, action: 'cursor-to-go' },
|
|
{ dt: 300, action: 'click-go', caption: 'Generating three variants…' },
|
|
{ dt: 1600, action: 'show-variant', n: 1, caption: 'Variant 1 of 3.' },
|
|
{ dt: 1400, action: 'show-variant', n: 2, caption: 'Variant 2 of 3.' },
|
|
{ dt: 1400, action: 'show-variant', n: 3, caption: 'Variant 3 of 3.' },
|
|
{ dt: 900, action: 'cursor-to-accept' },
|
|
{ dt: 300, action: 'click-accept', caption: 'Accepted. Written to source.' },
|
|
{ dt: 1800, action: 'reset', caption: 'Hover to pick.' },
|
|
];
|
|
|
|
export function initLiveDemo() {
|
|
const root = document.getElementById('live-demo');
|
|
if (!root) return;
|
|
|
|
const stage = root.querySelector('.live-demo-stage');
|
|
const target = root.querySelector('[data-demo-target]');
|
|
const outline = root.querySelector('[data-demo-outline]');
|
|
const annotations = root.querySelector('[data-demo-annotations]');
|
|
const cursor = root.querySelector('[data-demo-cursor]');
|
|
const ctx = root.querySelector('[data-demo-ctx]');
|
|
const inputText = root.querySelector('[data-demo-input-text]');
|
|
const counter = root.querySelector('[data-demo-counter]');
|
|
const captionLabel = root.querySelector('[data-demo-caption-label]');
|
|
const variants = Array.from(root.querySelectorAll('.live-demo-variant'));
|
|
|
|
if (!stage || !target || !ctx) return;
|
|
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
// Position the outline around the target.
|
|
const positionOutline = () => {
|
|
const stageRect = stage.getBoundingClientRect();
|
|
const targetRect = target.getBoundingClientRect();
|
|
outline.style.left = (targetRect.left - stageRect.left - 4) + 'px';
|
|
outline.style.top = (targetRect.top - stageRect.top - 4) + 'px';
|
|
outline.style.width = (targetRect.width + 8) + 'px';
|
|
outline.style.height = (targetRect.height + 8) + 'px';
|
|
};
|
|
|
|
// Position the contextual bar below the target (or above if below would
|
|
// collide with the global bar). Mirrors positionBar() in live-browser.js.
|
|
const positionCtx = () => {
|
|
const stageRect = stage.getBoundingClientRect();
|
|
const targetRect = target.getBoundingClientRect();
|
|
const ctxRect = ctx.getBoundingClientRect();
|
|
const GAP = 10;
|
|
const BAR_RESERVE = 60;
|
|
const belowTop = targetRect.bottom - stageRect.top + GAP;
|
|
const aboveTop = targetRect.top - stageRect.top - ctxRect.height - GAP;
|
|
let top;
|
|
if (belowTop + ctxRect.height + GAP <= stage.clientHeight - BAR_RESERVE) {
|
|
top = belowTop;
|
|
} else if (aboveTop >= GAP) {
|
|
top = aboveTop;
|
|
} else {
|
|
top = stage.clientHeight - ctxRect.height - BAR_RESERVE;
|
|
}
|
|
ctx.style.top = top + 'px';
|
|
};
|
|
|
|
const moveCursor = (selector, offsetX = 0, offsetY = 0) => {
|
|
const stageRect = stage.getBoundingClientRect();
|
|
const el = typeof selector === 'string' ? root.querySelector(selector) : selector;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const x = rect.left - stageRect.left + rect.width / 2 + offsetX;
|
|
const y = rect.top - stageRect.top + rect.height / 2 + offsetY;
|
|
cursor.style.transform = `translate(${x}px, ${y}px)`;
|
|
};
|
|
|
|
const showVariant = (n) => {
|
|
variants.forEach((v) => {
|
|
const match = (n === 0 && v.dataset.variant === 'original') || v.dataset.variant === String(n);
|
|
v.classList.toggle('is-active', match);
|
|
});
|
|
counter.textContent = n + ' / 3';
|
|
requestAnimationFrame(() => {
|
|
positionOutline();
|
|
positionCtx();
|
|
});
|
|
};
|
|
|
|
const setCtxPhase = (phase) => {
|
|
ctx.dataset.phase = phase;
|
|
if (phase !== PHASE.HIDDEN) requestAnimationFrame(positionCtx);
|
|
};
|
|
|
|
const reset = () => {
|
|
setCtxPhase(PHASE.HIDDEN);
|
|
cursor.classList.remove('is-visible', 'is-click');
|
|
outline.classList.remove('is-visible');
|
|
annotations.classList.remove('is-visible', 'is-comment-visible');
|
|
inputText.textContent = '';
|
|
showVariant(0);
|
|
};
|
|
|
|
const setCaption = (text) => {
|
|
if (text && captionLabel) captionLabel.textContent = text;
|
|
};
|
|
|
|
const typeText = (text, duration) => new Promise((resolve) => {
|
|
inputText.textContent = '';
|
|
const per = Math.max(30, Math.floor(duration / text.length));
|
|
let i = 0;
|
|
const tick = () => {
|
|
if (i >= text.length) return resolve();
|
|
inputText.textContent += text[i++];
|
|
setTimeout(tick, per);
|
|
};
|
|
tick();
|
|
});
|
|
|
|
const step = async (s) => {
|
|
switch (s.action) {
|
|
case 'cursor-show':
|
|
moveCursor(target, -120, 40);
|
|
cursor.classList.add('is-visible');
|
|
break;
|
|
case 'cursor-to-target':
|
|
moveCursor(target);
|
|
break;
|
|
case 'outline-show':
|
|
positionOutline();
|
|
outline.classList.add('is-visible');
|
|
break;
|
|
case 'cursor-click':
|
|
cursor.classList.add('is-click');
|
|
setTimeout(() => cursor.classList.remove('is-click'), 260);
|
|
break;
|
|
case 'open-ctx':
|
|
setCtxPhase(PHASE.CONFIGURING);
|
|
break;
|
|
case 'cursor-to-input':
|
|
moveCursor(root.querySelector('[data-demo-input]'));
|
|
break;
|
|
case 'type':
|
|
await typeText(s.text, 700);
|
|
break;
|
|
case 'draw-stroke':
|
|
annotations.classList.add('is-visible');
|
|
setTimeout(() => annotations.classList.add('is-comment-visible'), 600);
|
|
break;
|
|
case 'cursor-to-go':
|
|
moveCursor(root.querySelector('[data-demo-go]'));
|
|
break;
|
|
case 'click-go':
|
|
cursor.classList.add('is-click');
|
|
setTimeout(() => cursor.classList.remove('is-click'), 260);
|
|
annotations.classList.remove('is-visible', 'is-comment-visible');
|
|
setCtxPhase(PHASE.GENERATING);
|
|
break;
|
|
case 'show-variant':
|
|
if (ctx.dataset.phase !== PHASE.CYCLING) setCtxPhase(PHASE.CYCLING);
|
|
showVariant(s.n);
|
|
break;
|
|
case 'cursor-to-accept':
|
|
moveCursor(root.querySelector('[data-demo-accept]'));
|
|
break;
|
|
case 'click-accept':
|
|
cursor.classList.add('is-click');
|
|
setTimeout(() => cursor.classList.remove('is-click'), 260);
|
|
setCtxPhase(PHASE.ACCEPTED);
|
|
outline.classList.remove('is-visible');
|
|
break;
|
|
case 'reset':
|
|
reset();
|
|
break;
|
|
}
|
|
setCaption(s.caption);
|
|
};
|
|
|
|
let running = false;
|
|
let cancelToken = 0;
|
|
const sleep = (ms, token) => new Promise((resolve) => setTimeout(() => resolve(token === cancelToken), ms));
|
|
|
|
const run = async () => {
|
|
if (running) return;
|
|
running = true;
|
|
const myToken = ++cancelToken;
|
|
while (running && myToken === cancelToken) {
|
|
reset();
|
|
for (const s of TIMELINE) {
|
|
const stillMe = await sleep(s.dt, myToken);
|
|
if (!stillMe || !running) return;
|
|
await step(s);
|
|
}
|
|
}
|
|
};
|
|
|
|
const stop = () => {
|
|
running = false;
|
|
cancelToken++;
|
|
};
|
|
|
|
if (reduced) {
|
|
// Freeze on a representative still: cycling, variant 3.
|
|
showVariant(3);
|
|
counter.textContent = '3 / 3';
|
|
positionOutline();
|
|
outline.classList.add('is-visible');
|
|
setCtxPhase(PHASE.CYCLING);
|
|
setCaption('Three variants. Pick the one you want.');
|
|
return;
|
|
}
|
|
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach((e) => {
|
|
if (e.isIntersecting) run();
|
|
else stop();
|
|
});
|
|
}, { threshold: 0.35 });
|
|
io.observe(root);
|
|
|
|
window.addEventListener('resize', () => requestAnimationFrame(() => {
|
|
positionOutline();
|
|
positionCtx();
|
|
}));
|
|
}
|