From d72ac0022635d8ed86ae7c02cf786dc38d66cf8b Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Apr 2026 13:33:14 -0700 Subject: [PATCH] feat(site): replace Visual Mode section with Live Mode + interactive demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 05 is now "Live Mode" with a self-contained real-DOM animated demo loop. Browser chrome + stage + picker bar + simulated cursor, running through the full Live Mode flow: hover → outline → click → open command picker → pick "delight" → type "more playful" → draw stroke → Go → generating → three variants cycled → accept → write-to-source confirmation → reset Three distinct card variants morph in place so the cycling reads as real iteration, not a slideshow of the same card. Picker bar states (idle / configuring / generating / cycling / accepted) are driven by data-phase and matching CSS selectors. Simulated cursor animates between targets on an expo-out curve; annotations draw a scribble stroke and pop a comment tag. IntersectionObserver gates the timeline so it only plays while visible, and prefers-reduced-motion freezes on a cycling / variant 3 still. Supporting row below the frame adds the three honest limitations: frameworks supported (Vite/Next/SvelteKit/Astro/Nuxt), writes to real source via HMR, CSP-strict apps get a one-time dev-only patch. Top-nav rename "Visual Mode" → "Overlay" (points at /visual-mode which is still the overlay-focused sub-page). Sticky section-nav updated too. New file: public/js/components/live-demo.js with the timeline driver. --- public/app.js | 2 + public/css/workflow.css | 503 ++++++++++++++++++++++++++++++ public/index.html | 175 +++++++++-- public/js/components/live-demo.js | 226 ++++++++++++++ 4 files changed, 874 insertions(+), 32 deletions(-) create mode 100644 public/js/components/live-demo.js diff --git a/public/app.js b/public/app.js index 0de7d11db..3aba5332f 100644 --- a/public/app.js +++ b/public/app.js @@ -8,6 +8,7 @@ import { initScrollReveal } from "./js/utils/reveal.js"; import { initAnchorScroll, initHashTracking } from "./js/utils/scroll.js"; import { initSectionNav } from "./js/components/section-nav.js"; import { initFoundationGrid } from "./js/components/foundation-grid.js"; +import { initLiveDemo } from "./js/components/live-demo.js"; // ============================================ // STATE @@ -215,6 +216,7 @@ function init() { initFoundationGrid(); initSectionNav(); initWhyTabs(); + initLiveDemo(); loadContent(); document.body.classList.add("loaded"); diff --git a/public/css/workflow.css b/public/css/workflow.css index 390746c7f..e8ad89b7e 100644 --- a/public/css/workflow.css +++ b/public/css/workflow.css @@ -2688,3 +2688,506 @@ text-transform: uppercase; margin-top: -2px; } + +/* ============================================ + LIVE MODE — interactive demo loop + ============================================ */ + +.live-section { + padding: var(--spacing-2xl) 0; + border-top: 1px solid var(--color-mist); +} + +.live-content .section-lead { + max-width: 64ch; + margin-bottom: var(--spacing-xl); +} + +.live-demo { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.live-demo-frame { + position: relative; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 12px; + overflow: hidden; + aspect-ratio: 16 / 9; + max-width: 960px; + box-shadow: 0 20px 50px oklch(0% 0 0 / 0.08); + user-select: none; +} + +.live-demo-chrome { + display: flex; + align-items: center; + gap: 7px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-mist); + background: var(--color-paper); +} +.live-demo-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-mist); +} +.live-demo-url { + margin-left: 14px; + padding: 4px 14px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 5px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-charcoal); +} + +.live-demo-stage { + position: relative; + height: calc(100% - 42px - 60px); /* minus chrome + bar */ + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow: hidden; +} + +/* Target card */ +.live-demo-target { + position: relative; + width: min(360px, 80%); + min-height: 200px; +} +.live-demo-variant { + position: absolute; + inset: 0; + opacity: 0; + transform: translateY(8px) scale(0.99); + transition: opacity 280ms var(--ease-out), transform 280ms var(--ease-out); + pointer-events: none; +} +.live-demo-variant.is-active { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; +} +.live-demo-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 22px 24px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + height: 100%; + box-sizing: border-box; +} +.live-demo-card-kicker { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-ash); +} +.live-demo-card h3 { + font-family: var(--font-display); + font-weight: 400; + font-size: 20px; + line-height: 1.2; + color: var(--color-ink); + margin: 0; +} +.live-demo-card p { + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; + color: var(--color-charcoal); + margin: 0; +} +.live-demo-card button { + align-self: flex-start; + margin-top: auto; + font-family: var(--font-body); + font-weight: 500; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 9px 16px; + background: var(--color-ink); + color: var(--color-paper); + border: 0; + border-radius: 0; + cursor: pointer; +} + +/* Per-variant look */ +.live-demo-card--plain {} +.live-demo-card--v1 { + background: var(--color-cream); +} +.live-demo-card--v1 h3 { font-style: italic; } +.live-demo-card--v1 h3 em { color: var(--color-accent); font-style: italic; } +.live-demo-card--v2 { + background: var(--color-paper); + border: 1px dashed var(--color-accent); +} +.live-demo-card--v2 .live-demo-card-kicker { color: var(--color-accent); } +.live-demo-card--v2 h3 { font-family: var(--font-display); font-style: italic; } +.live-demo-card--v2 button { + background: var(--color-accent); +} +.live-demo-card--v3 { + background: oklch(96% 0.02 350); + border: 1px solid var(--color-accent-soft); +} +.live-demo-card--v3 h3 em { color: var(--color-accent); font-style: italic; } +.live-demo-card--v3 .live-demo-card-kicker { color: var(--color-accent-hover); } + +/* Highlight outline over the target */ +.live-demo-outline { + position: absolute; + border: 2px solid var(--color-accent); + border-radius: 8px; + pointer-events: none; + opacity: 0; + transition: opacity 200ms var(--ease-out), top 320ms var(--ease-out), left 320ms var(--ease-out), width 320ms var(--ease-out), height 320ms var(--ease-out); + box-shadow: 0 0 0 4px var(--color-accent-dim); +} +.live-demo-outline.is-visible { + opacity: 1; +} + +/* Annotations */ +.live-demo-annotations { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity 280ms var(--ease-out); + color: var(--color-accent); +} +.live-demo-annotations.is-visible { opacity: 1; } +.live-demo-stroke { + position: absolute; + width: 280px; + height: 56px; + top: 50%; + left: 50%; + transform: translate(-50%, -24px); + pointer-events: none; +} +.live-demo-stroke path { + stroke-dasharray: 1; + stroke-dashoffset: 1; +} +.live-demo-annotations.is-visible .live-demo-stroke path { + animation: liveDemoStroke 800ms var(--ease-out) forwards; +} +@keyframes liveDemoStroke { + to { stroke-dashoffset: 0; } +} +.live-demo-comment { + position: absolute; + top: 56%; + left: 50%; + transform: translate(-50%, 0); + padding: 5px 10px; + background: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-mono); + font-size: 11px; + border-radius: 4px; + white-space: nowrap; + opacity: 0; + transition: opacity 200ms var(--ease-out); +} +.live-demo-annotations.is-comment-visible .live-demo-comment { opacity: 1; } + +/* Simulated cursor */ +.live-demo-cursor { + position: absolute; + top: 0; + left: 0; + width: 18px; + height: 22px; + pointer-events: none; + opacity: 0; + transform: translate(0, 0); + transition: opacity 200ms var(--ease-out), transform 560ms var(--ease-out-quint); + z-index: 5; + filter: drop-shadow(0 2px 4px oklch(0% 0 0 / 0.2)); +} +.live-demo-cursor.is-visible { opacity: 1; } +.live-demo-cursor.is-click svg path { + transform-origin: 4px 4px; + animation: liveDemoCursorClick 220ms var(--ease-out); +} +@keyframes liveDemoCursorClick { + 0%, 100% { transform: scale(1); } + 40% { transform: scale(0.78); } +} + +/* Picker bar at the bottom */ +.live-demo-bar { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + min-height: 36px; + padding: 0 4px; + background: oklch(14% 0 0); + color: oklch(92% 0 0); + border-radius: 999px; + display: flex; + align-items: center; + gap: 2px; + font-family: var(--font-body); + font-size: 12px; + box-shadow: 0 8px 24px oklch(0% 0 0 / 0.2); + overflow: hidden; + transition: width 320ms var(--ease-out); +} +.live-demo-bar > div { + display: none; + align-items: center; + gap: 2px; + padding: 0 6px; +} +.live-demo-bar[data-phase="idle"] .live-demo-bar-idle, +.live-demo-bar[data-phase="configuring"] .live-demo-bar-config, +.live-demo-bar[data-phase="generating"] .live-demo-bar-generating, +.live-demo-bar[data-phase="cycling"] .live-demo-bar-cycle, +.live-demo-bar[data-phase="accepted"] .live-demo-bar-accepted { + display: flex; +} +.live-demo-bar-slash { + font-family: var(--font-display); + font-size: 16px; + color: var(--color-accent); + padding: 0 8px 0 10px; +} +.live-demo-bar-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + background: transparent; + border: 0; + border-radius: 8px; + color: oklch(75% 0 0); + font: inherit; + cursor: pointer; +} +.live-demo-bar-btn.is-active { + background: var(--color-accent-dim); + color: var(--color-accent); +} +.live-demo-bar-dmd { + display: inline-grid; + grid-template: repeat(2, 1fr) / repeat(2, 1fr); + width: 12px; + height: 12px; + border-radius: 3px; + overflow: hidden; +} +.live-demo-bar-dmd span:nth-child(1) { background: oklch(60% 0.25 350); } +.live-demo-bar-dmd span:nth-child(2) { background: oklch(60% 0.15 45); } +.live-demo-bar-dmd span:nth-child(3) { background: oklch(55% 0.12 250); } +.live-demo-bar-dmd span:nth-child(4) { background: oklch(30% 0 0); } + +.live-demo-bar-cmd { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--color-accent-dim); + color: var(--color-accent); + border-radius: 6px; + font-family: var(--font-mono); + font-size: 11px; +} +.live-demo-bar-cmd-label { + opacity: 0.7; + text-transform: uppercase; + font-size: 9px; + letter-spacing: 0.2em; +} +.live-demo-bar-cmd-name { + font-weight: 500; + letter-spacing: 0.05em; +} +.live-demo-bar-divider { + width: 1px; + height: 18px; + background: oklch(28% 0 0); + margin: 0 4px; +} +.live-demo-bar-input { + display: inline-flex; + align-items: center; + min-width: 140px; + font-family: var(--font-mono); + font-size: 11px; + color: oklch(85% 0 0); + padding: 0 4px; +} +.live-demo-bar-caret { + display: inline-block; + width: 6px; + height: 12px; + background: var(--color-accent); + margin-left: 2px; + animation: liveDemoCaret 1s steps(1) infinite; +} +@keyframes liveDemoCaret { + 50% { opacity: 0; } +} +.live-demo-bar-go { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 7px 12px; + background: var(--color-accent); + color: white; + border: 0; + border-radius: 6px; + cursor: pointer; + margin-left: auto; +} + +.live-demo-bar-generating { + padding: 0 14px !important; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + color: oklch(85% 0 0); +} +.live-demo-bar-shader { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid oklch(35% 0 0); + border-top-color: var(--color-accent); + margin-right: 10px; + animation: liveDemoSpin 700ms linear infinite; +} +@keyframes liveDemoSpin { + to { transform: rotate(360deg); } +} + +.live-demo-bar-counter { + font-family: var(--font-mono); + font-size: 11px; + color: oklch(92% 0 0); + padding: 0 8px; + min-width: 38px; + text-align: center; +} +.live-demo-bar-discard { + padding: 7px 10px; + background: transparent; + border: 0; + border-radius: 8px; + color: oklch(72% 0 0); + font-size: 13px; + cursor: pointer; +} +.live-demo-bar-accept { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 7px 14px; + background: var(--color-accent); + color: white; + border: 0; + border-radius: 6px; + cursor: pointer; + margin-left: 2px; +} + +.live-demo-bar-accepted { + padding: 0 14px !important; + color: oklch(80% 0.15 145); + font-family: var(--font-body); + font-size: 12px; + gap: 8px !important; +} + +/* Phase caption under the frame */ +.live-demo-caption { + display: flex; + align-items: baseline; + gap: 10px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-ash); +} +.live-demo-caption::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-accent); + animation: liveDemoPulse 1.6s var(--ease-out) infinite; +} +@keyframes liveDemoPulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} +.live-demo-caption-label { color: var(--color-ink); } + +/* Supporting row under the demo */ +.live-demo-support { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-lg); + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-mist); +} +.live-demo-support-cell { + display: flex; + flex-direction: column; + gap: 4px; +} +.live-demo-support-k { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-ash); +} +.live-demo-support-v { + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-ink); +} + +@media (max-width: 760px) { + .live-demo-frame { aspect-ratio: 4 / 5; } + .live-demo-target { width: 90%; } + .live-demo-bar { font-size: 11px; min-height: 32px; } + .live-demo-support { grid-template-columns: 1fr; gap: var(--spacing-md); } +} + +@media (prefers-reduced-motion: reduce) { + .live-demo-cursor, + .live-demo-outline, + .live-demo-variant, + .live-demo-annotations, + .live-demo-stroke path { + transition: none !important; + animation: none !important; + } + .live-demo-caption::before { animation: none; } +} diff --git a/public/index.html b/public/index.html index fbbafba89..86d26bedd 100644 --- a/public/index.html +++ b/public/index.html @@ -60,7 +60,7 @@ Home Docs Anti-Patterns - Visual Mode + Overlay @@ -76,7 +76,7 @@ 02Language 03Antidote 04Why - 05Visual + 05Live 06Install 07New 08FAQ @@ -529,42 +529,153 @@ - -
+ +
05 -

Visual Mode

+

Live Mode

-
-

See design issues highlighted directly on the page. No screenshots, no guesswork. Impeccable’s overlay shows you exactly what’s wrong and where.

+
+

Pick any element in the browser. Drop a comment or a stroke. Hit Go. Three production-quality variants swap in via your framework's HMR. Accept the one you want and it writes back to source.

-
-
-
- - - - Live detection overlay +
+
+
+ + + + localhost:3000
- -
-
-
- 25 deterministic checks -

No LLM needed. Pattern matching catches purple gradients, overused fonts, nested cards, low contrast, and more.

-
-
- Two ways to use it -

The Chrome extension on any site, or embedded in /impeccable critique during an AI design review.

-
- - Impeccable Chrome extension panel listing detected anti-patterns - + + +
+ Hover an element to pick. +
+
+ + +
+
+ Works on + Vite · Next.js · SvelteKit · Astro · Nuxt +
+
+ Accepts into + Your real source file, via HMR +
+
+ CSP-strict apps + One-time dev-only patch, with your consent
diff --git a/public/js/components/live-demo.js b/public/js/components/live-demo.js new file mode 100644 index 000000000..b7b93d87a --- /dev/null +++ b/public/js/components/live-demo.js @@ -0,0 +1,226 @@ +// Interactive Live Mode demo loop. Plays when the section is in view, +// pauses when out. Respects prefers-reduced-motion (freezes on a +// representative frame). +// +// Phases map to the `data-phase` attribute on the picker bar: +// idle → configuring → generating → cycling → accepted → (reset to idle) + +const PHASE = { + IDLE: 'idle', + CONFIGURING: 'configuring', + GENERATING: 'generating', + CYCLING: 'cycling', + ACCEPTED: 'accepted', +}; + +// Each step describes one action + how long to hold before the next step. +// dt = delay BEFORE this step runs (ms). Total cycle ≈ sum of all dt. +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-config', caption: 'Picked. Choose a command.' }, + { dt: 700, action: 'cursor-to-cmd' }, + { dt: 400, action: 'set-command', cmd: 'delight', caption: 'delight — add personality.' }, + { dt: 500, action: 'cursor-to-input' }, + { dt: 300, action: 'type', text: 'more playful', caption: 'Annotate. Comment. Stroke.' }, + { dt: 1400, action: 'draw-stroke' }, + { dt: 700, 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 bar = root.querySelector('[data-demo-bar]'); + const cmdName = root.querySelector('[data-demo-cmd-name]'); + 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 || !bar) return; + + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + // Position outline over target whenever target moves. + 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'; + }; + + // Move cursor to coordinates relative to the stage. + 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'; + // Re-measure outline after layout settles. + requestAnimationFrame(positionOutline); + }; + + const reset = () => { + bar.dataset.phase = PHASE.IDLE; + cursor.classList.remove('is-visible', 'is-click'); + outline.classList.remove('is-visible'); + annotations.classList.remove('is-visible', 'is-comment-visible'); + inputText.textContent = ''; + cmdName.textContent = 'delight'; + showVariant(0); + }; + + const setCaption = (text) => { + if (text) 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-config': + bar.dataset.phase = PHASE.CONFIGURING; + break; + case 'cursor-to-cmd': + moveCursor(root.querySelector('[data-demo-cmd]')); + break; + case 'set-command': + cmdName.textContent = s.cmd; + 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'); + bar.dataset.phase = PHASE.GENERATING; + break; + case 'show-variant': + if (bar.dataset.phase !== PHASE.CYCLING) bar.dataset.phase = 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); + bar.dataset.phase = 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++; + }; + + // Start/stop based on visibility. + if (reduced) { + // Freeze on the "cycling, variant 3" frame for a representative still. + bar.dataset.phase = PHASE.CYCLING; + showVariant(3); + counter.textContent = '3 / 3'; + positionOutline(); + outline.classList.add('is-visible'); + 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); + + // Also re-measure outline on window resize since positions depend on layout. + window.addEventListener('resize', () => requestAnimationFrame(positionOutline)); +}