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}