live-demo.js

  1// Interactive Live Mode demo loop. Matches the real picker flow:
  2// - a persistent dark global bar stays at the bottom of the frame the whole time
  3// - a light contextual bar floats above the picked element during a session,
  4//   morphing between configure → generating → cycling → accepted
  5//
  6// Plays only while the section is in view. Respects prefers-reduced-motion.
  7
  8const PHASE = {
  9	HIDDEN: 'hidden',
 10	CONFIGURING: 'configuring',
 11	GENERATING: 'generating',
 12	CYCLING: 'cycling',
 13	ACCEPTED: 'accepted',
 14};
 15
 16const TIMELINE = [
 17	{ dt: 400,  action: 'cursor-show' },
 18	{ dt: 400,  action: 'cursor-to-target' },
 19	{ dt: 900,  action: 'outline-show', caption: 'Hover to pick.' },
 20	{ dt: 500,  action: 'cursor-click' },
 21	{ dt: 200,  action: 'open-ctx', caption: 'Picked. Contextual bar appears.' },
 22	{ dt: 700,  action: 'cursor-to-input' },
 23	{ dt: 300,  action: 'type', text: 'more playful', caption: 'Type a refinement, or skip.' },
 24	{ dt: 1200, action: 'draw-stroke', caption: 'Annotate on the page, if you want.' },
 25	{ dt: 900,  action: 'cursor-to-go' },
 26	{ dt: 300,  action: 'click-go', caption: 'Generating three variants…' },
 27	{ dt: 1600, action: 'show-variant', n: 1, caption: 'Variant 1 of 3.' },
 28	{ dt: 1400, action: 'show-variant', n: 2, caption: 'Variant 2 of 3.' },
 29	{ dt: 1400, action: 'show-variant', n: 3, caption: 'Variant 3 of 3.' },
 30	{ dt: 900,  action: 'cursor-to-accept' },
 31	{ dt: 300,  action: 'click-accept', caption: 'Accepted. Written to source.' },
 32	{ dt: 1800, action: 'reset', caption: 'Hover to pick.' },
 33];
 34
 35export function initLiveDemo() {
 36	const root = document.getElementById('live-demo');
 37	if (!root) return;
 38
 39	const stage = root.querySelector('.live-demo-stage');
 40	const target = root.querySelector('[data-demo-target]');
 41	const outline = root.querySelector('[data-demo-outline]');
 42	const annotations = root.querySelector('[data-demo-annotations]');
 43	const cursor = root.querySelector('[data-demo-cursor]');
 44	const ctx = root.querySelector('[data-demo-ctx]');
 45	const inputText = root.querySelector('[data-demo-input-text]');
 46	const counter = root.querySelector('[data-demo-counter]');
 47	const captionLabel = root.querySelector('[data-demo-caption-label]');
 48	const variants = Array.from(root.querySelectorAll('.live-demo-variant'));
 49
 50	if (!stage || !target || !ctx) return;
 51
 52	const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
 53
 54	// Position the outline around the target.
 55	const positionOutline = () => {
 56		const stageRect = stage.getBoundingClientRect();
 57		const targetRect = target.getBoundingClientRect();
 58		outline.style.left = (targetRect.left - stageRect.left - 4) + 'px';
 59		outline.style.top = (targetRect.top - stageRect.top - 4) + 'px';
 60		outline.style.width = (targetRect.width + 8) + 'px';
 61		outline.style.height = (targetRect.height + 8) + 'px';
 62	};
 63
 64	// Position the contextual bar below the target (or above if below would
 65	// collide with the global bar). Mirrors positionBar() in live-browser.js.
 66	const positionCtx = () => {
 67		const stageRect = stage.getBoundingClientRect();
 68		const targetRect = target.getBoundingClientRect();
 69		const ctxRect = ctx.getBoundingClientRect();
 70		const GAP = 10;
 71		const BAR_RESERVE = 60;
 72		const belowTop = targetRect.bottom - stageRect.top + GAP;
 73		const aboveTop = targetRect.top - stageRect.top - ctxRect.height - GAP;
 74		let top;
 75		if (belowTop + ctxRect.height + GAP <= stage.clientHeight - BAR_RESERVE) {
 76			top = belowTop;
 77		} else if (aboveTop >= GAP) {
 78			top = aboveTop;
 79		} else {
 80			top = stage.clientHeight - ctxRect.height - BAR_RESERVE;
 81		}
 82		ctx.style.top = top + 'px';
 83	};
 84
 85	const moveCursor = (selector, offsetX = 0, offsetY = 0) => {
 86		const stageRect = stage.getBoundingClientRect();
 87		const el = typeof selector === 'string' ? root.querySelector(selector) : selector;
 88		if (!el) return;
 89		const rect = el.getBoundingClientRect();
 90		const x = rect.left - stageRect.left + rect.width / 2 + offsetX;
 91		const y = rect.top - stageRect.top + rect.height / 2 + offsetY;
 92		cursor.style.transform = `translate(${x}px, ${y}px)`;
 93	};
 94
 95	const showVariant = (n) => {
 96		variants.forEach((v) => {
 97			const match = (n === 0 && v.dataset.variant === 'original') || v.dataset.variant === String(n);
 98			v.classList.toggle('is-active', match);
 99		});
100		counter.textContent = n + ' / 3';
101		requestAnimationFrame(() => {
102			positionOutline();
103			positionCtx();
104		});
105	};
106
107	const setCtxPhase = (phase) => {
108		ctx.dataset.phase = phase;
109		if (phase !== PHASE.HIDDEN) requestAnimationFrame(positionCtx);
110	};
111
112	const reset = () => {
113		setCtxPhase(PHASE.HIDDEN);
114		cursor.classList.remove('is-visible', 'is-click');
115		outline.classList.remove('is-visible');
116		annotations.classList.remove('is-visible', 'is-comment-visible');
117		inputText.textContent = '';
118		showVariant(0);
119	};
120
121	const setCaption = (text) => {
122		if (text && captionLabel) captionLabel.textContent = text;
123	};
124
125	const typeText = (text, duration) => new Promise((resolve) => {
126		inputText.textContent = '';
127		const per = Math.max(30, Math.floor(duration / text.length));
128		let i = 0;
129		const tick = () => {
130			if (i >= text.length) return resolve();
131			inputText.textContent += text[i++];
132			setTimeout(tick, per);
133		};
134		tick();
135	});
136
137	const step = async (s) => {
138		switch (s.action) {
139			case 'cursor-show':
140				moveCursor(target, -120, 40);
141				cursor.classList.add('is-visible');
142				break;
143			case 'cursor-to-target':
144				moveCursor(target);
145				break;
146			case 'outline-show':
147				positionOutline();
148				outline.classList.add('is-visible');
149				break;
150			case 'cursor-click':
151				cursor.classList.add('is-click');
152				setTimeout(() => cursor.classList.remove('is-click'), 260);
153				break;
154			case 'open-ctx':
155				setCtxPhase(PHASE.CONFIGURING);
156				break;
157			case 'cursor-to-input':
158				moveCursor(root.querySelector('[data-demo-input]'));
159				break;
160			case 'type':
161				await typeText(s.text, 700);
162				break;
163			case 'draw-stroke':
164				annotations.classList.add('is-visible');
165				setTimeout(() => annotations.classList.add('is-comment-visible'), 600);
166				break;
167			case 'cursor-to-go':
168				moveCursor(root.querySelector('[data-demo-go]'));
169				break;
170			case 'click-go':
171				cursor.classList.add('is-click');
172				setTimeout(() => cursor.classList.remove('is-click'), 260);
173				annotations.classList.remove('is-visible', 'is-comment-visible');
174				setCtxPhase(PHASE.GENERATING);
175				break;
176			case 'show-variant':
177				if (ctx.dataset.phase !== PHASE.CYCLING) setCtxPhase(PHASE.CYCLING);
178				showVariant(s.n);
179				break;
180			case 'cursor-to-accept':
181				moveCursor(root.querySelector('[data-demo-accept]'));
182				break;
183			case 'click-accept':
184				cursor.classList.add('is-click');
185				setTimeout(() => cursor.classList.remove('is-click'), 260);
186				setCtxPhase(PHASE.ACCEPTED);
187				outline.classList.remove('is-visible');
188				break;
189			case 'reset':
190				reset();
191				break;
192		}
193		setCaption(s.caption);
194	};
195
196	let running = false;
197	let cancelToken = 0;
198	const sleep = (ms, token) => new Promise((resolve) => setTimeout(() => resolve(token === cancelToken), ms));
199
200	const run = async () => {
201		if (running) return;
202		running = true;
203		const myToken = ++cancelToken;
204		while (running && myToken === cancelToken) {
205			reset();
206			for (const s of TIMELINE) {
207				const stillMe = await sleep(s.dt, myToken);
208				if (!stillMe || !running) return;
209				await step(s);
210			}
211		}
212	};
213
214	const stop = () => {
215		running = false;
216		cancelToken++;
217	};
218
219	if (reduced) {
220		// Freeze on a representative still: cycling, variant 3.
221		showVariant(3);
222		counter.textContent = '3 / 3';
223		positionOutline();
224		outline.classList.add('is-visible');
225		setCtxPhase(PHASE.CYCLING);
226		setCaption('Three variants. Pick the one you want.');
227		return;
228	}
229
230	const io = new IntersectionObserver((entries) => {
231		entries.forEach((e) => {
232			if (e.isIntersecting) run();
233			else stop();
234		});
235	}, { threshold: 0.35 });
236	io.observe(root);
237
238	window.addEventListener('resize', () => requestAnimationFrame(() => {
239		positionOutline();
240		positionCtx();
241	}));
242}