Files
pbakaus_impeccable/public/js/components/live-demo.js
T
Paul Bakaus 32b81aebcd refine(site): live mode support list copy pass + drop phase caption + border
- 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.
2026-04-22 14:12:52 -07:00

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();
}));
}