storyshot 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/record.mjs CHANGED
@@ -3,12 +3,12 @@
3
3
  import { chromium } from 'playwright';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { describePage } from './inspect.mjs';
6
+ import { clickableKeys, describePage } from './inspect.mjs';
7
7
  import { detectLogin, loginAdvice, sessionContext } from './auth.mjs';
8
8
  import { certError } from './config.mjs';
9
9
 
10
10
  // Injected into every page: fake cursor, click ripple, smooth in-page anchor scrolling.
11
- function pageScript({ scrollOffset }) {
11
+ export function pageScript({ scrollOffset }) {
12
12
  const install = () => {
13
13
  if (document.getElementById('__demo_cursor')) return;
14
14
  const c = document.createElement('div');
@@ -55,19 +55,60 @@ function pageScript({ scrollOffset }) {
55
55
  for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) if (scrollable(p)) return p;
56
56
  return docEl();
57
57
  };
58
+ // `behavior: 'instant'` on every frame: with `scroll-behavior: smooth` in the page's CSS (GitHub has it),
59
+ // plain scrollTop writes each start a smooth scroll of their own, so the page lags behind and is still
60
+ // moving after the step that scrolled it is over
58
61
  window.__demoScrollBy = (dy, ms = 1200, sc = mainScroller()) => new Promise((done) => {
59
62
  const start = sc.scrollTop, t0 = performance.now();
60
63
  const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
61
64
  const step = (now) => {
62
- const k = Math.min(1, (now - t0) / ms);
63
- sc.scrollTop = start + dy * ease(k);
65
+ const k = ms > 0 ? Math.min(1, Math.max(0, (now - t0) / ms)) : 1;
66
+ sc.scrollTo({ top: start + dy * ease(k), behavior: 'instant' });
64
67
  if (k < 1) requestAnimationFrame(step); else done();
65
68
  };
66
69
  requestAnimationFrame(step);
67
70
  });
71
+ // How far down a header will cover the window once `el` is scrolled to the top: bars fixed at the top,
72
+ // and sticky ones that stick inside a box holding `el` (GitHub's README bar sticks within the README).
73
+ const headerCover = (el) => {
74
+ let cover = 0;
75
+ for (const h of document.querySelectorAll('body *')) {
76
+ if (h.getAttribute('id')?.startsWith('__demo') || h.contains(el)) continue; // not h.id: a form with an <input name=id> has that input as its .id
77
+ const cs = getComputedStyle(h);
78
+ if (cs.position !== 'fixed' && cs.position !== 'sticky') continue;
79
+ const r = h.getBoundingClientRect();
80
+ if (!r.height || r.height > innerHeight * 0.3 || r.width < innerWidth * 0.3 || cs.visibility === 'hidden' || cs.opacity === '0') continue;
81
+ if (cs.position === 'fixed' && r.top <= 1) cover = Math.max(cover, r.bottom);
82
+ const stick = parseFloat(cs.top);
83
+ if (cs.position === 'sticky' && !Number.isNaN(stick) && stick < innerHeight * 0.3 && h.parentElement?.contains(el)) cover = Math.max(cover, stick + r.height);
84
+ }
85
+ return cover;
86
+ };
87
+ // the element's top lands `scrollOffset` px down, or just below a header that would cover it there
68
88
  window.__demoScrollTo = (el, ms) => {
69
- const sc = scrollerFor(el), top = sc === docEl() ? 0 : sc.getBoundingClientRect().top;
70
- return window.__demoScrollBy(el.getBoundingClientRect().top - top - scrollOffset, ms, sc);
89
+ const sc = scrollerFor(el), docs = sc === docEl(), top = docs ? 0 : sc.getBoundingClientRect().top;
90
+ const offset = docs ? Math.max(scrollOffset, headerCover(el) + 12) : scrollOffset;
91
+ return window.__demoScrollBy(el.getBoundingClientRect().top - top - offset, ms, sc);
92
+ };
93
+ // resolves once nothing has scrolled for a few frames (the page's own smooth scrolling included)
94
+ window.__demoScrollIdle = (max = 2000) => new Promise((done) => {
95
+ const sc = mainScroller(), pos = () => `${scrollX},${scrollY},${sc.scrollTop}`;
96
+ const end = performance.now() + max;
97
+ let last = pos(), still = 0;
98
+ const check = () => {
99
+ const now = pos();
100
+ still = now === last ? still + 1 : 0; last = now;
101
+ if (still >= 3 || performance.now() > end) done(); else requestAnimationFrame(check);
102
+ };
103
+ requestAnimationFrame(check);
104
+ });
105
+ // a slider's value and range: an <input type=range>, or an ARIA slider (Desmos: [role=slider] on its knob)
106
+ window.__demoSliderValue = (el) => {
107
+ const r = el.type === 'range' ? el : el.querySelector?.('input[type=range]');
108
+ if (r) return { now: Number(r.value), lo: Number(r.min || 0), hi: Number(r.max || 100), native: r };
109
+ const aria = el.hasAttribute('aria-valuenow') ? el : el.querySelector?.('[aria-valuenow]');
110
+ const now = Number(aria?.getAttribute('aria-valuenow'));
111
+ return aria && Number.isFinite(now) ? { now, lo: Number(aria.getAttribute('aria-valuemin') ?? 0), hi: Number(aria.getAttribute('aria-valuemax') ?? 100) } : null;
71
112
  };
72
113
  // Tutorial callouts: spotlight a target, show a pressed key combo.
73
114
  window.__demoSpot = (r) => {
@@ -86,24 +127,64 @@ function pageScript({ scrollOffset }) {
86
127
  Object.assign(el.style, { left: `${r.x - pad}px`, top: `${r.y - pad}px`, width: `${r.width + 2 * pad}px`, height: `${r.height + 2 * pad}px` });
87
128
  requestAnimationFrame(() => { el.style.opacity = '1'; });
88
129
  };
89
- window.__demoSpotOff = () => {
130
+ window.__demoSpotOff = (instant) => {
90
131
  const el = document.getElementById('__demo_spot');
91
- if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }
132
+ if (el && instant) el.remove();
133
+ else if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }
92
134
  };
93
- window.__demoKeys = (label) => {
135
+ // the key badge sits just below the cursor (above it near the bottom), where the action is: a zoom on
136
+ // that spot keeps it in the frame. Returns its box, so captions keep off it.
137
+ window.__demoKeys = (label, x = innerWidth / 2, y = innerHeight * 0.7) => {
94
138
  const el = document.createElement('div');
95
139
  el.textContent = label;
96
140
  Object.assign(el.style, {
97
- position: 'fixed', left: '50%', bottom: '22%', transform: 'translateX(-50%)', zIndex: '2147483646', pointerEvents: 'none',
141
+ position: 'fixed', left: '0', top: '0', zIndex: '2147483646', pointerEvents: 'none', whiteSpace: 'nowrap',
98
142
  font: '600 30px -apple-system, "Segoe UI", sans-serif', color: '#fff', background: 'rgba(15,23,42,.88)',
99
143
  padding: '14px 26px', borderRadius: '14px', boxShadow: '0 10px 30px rgba(0,0,0,.3)', letterSpacing: '.04em',
100
144
  });
101
145
  document.documentElement.appendChild(el);
102
- el.animate([{ opacity: 0, transform: 'translateX(-50%) scale(.9)' }, { opacity: 1, transform: 'translateX(-50%) scale(1)', offset: 0.15 },
146
+ const { width: w, height: h } = el.getBoundingClientRect();
147
+ const left = Math.max(16, Math.min(innerWidth - w - 16, x - w / 2));
148
+ const top = y + 48 + h < innerHeight - 16 ? y + 48 : Math.max(16, y - 40 - h);
149
+ Object.assign(el.style, { left: `${left}px`, top: `${top}px` });
150
+ el.animate([{ opacity: 0, transform: 'scale(.9)' }, { opacity: 1, transform: 'scale(1)', offset: 0.15 },
103
151
  { opacity: 1, offset: 0.8 }, { opacity: 0 }], { duration: 1400, easing: 'ease-out' }).onfinish = () => el.remove();
152
+ return { x: left, y: top, width: w, height: h };
104
153
  };
105
154
  // `mark`: a highlighter sweeps over a phrase (a text Range, even across inline elements) or an
106
155
  // element, line by line. Marks follow scrolling (positions are re-read every frame).
156
+ // What of a rect is really on screen: cut to the viewport and to every ancestor of `from` that clips
157
+ // its overflow (a panel scrolled past it, a collapsed section). scrollable: false cuts only to the
158
+ // clippers that can't scroll (what scrolling could never bring into view). null when nothing is left.
159
+ window.__demoClip = (q, from, { scrollable = true } = {}) => {
160
+ let { left, top, right, bottom } = q;
161
+ for (let a = from; a && a !== document.documentElement && a !== document.body; a = a.parentElement) {
162
+ const s = getComputedStyle(a);
163
+ const clips = s.overflowX !== 'visible' || s.overflowY !== 'visible', scrolls = /auto|scroll/.test(s.overflowX + s.overflowY);
164
+ if (clips && (scrollable || !scrolls)) {
165
+ const b = a.getBoundingClientRect();
166
+ left = Math.max(left, b.left); top = Math.max(top, b.top); right = Math.min(right, b.right); bottom = Math.min(bottom, b.bottom);
167
+ }
168
+ if (s.position === 'fixed') break; // a fixed element isn't clipped by what's around it
169
+ }
170
+ if (scrollable) { left = Math.max(left, 0); top = Math.max(top, 0); right = Math.min(right, innerWidth); bottom = Math.min(bottom, innerHeight); }
171
+ return right - left > 1 && bottom - top > 1 ? { left, top, right, bottom } : null;
172
+ };
173
+ // how a text range or element shows: 'shown' (on screen, nothing over it), 'away' (scrolling brings it
174
+ // into view), 'covered' (on screen, under something else) or 'hidden' (invisible, clipped, collapsed)
175
+ window.__demoShows = (src) => {
176
+ const el = src instanceof Range ? (src.commonAncestorContainer.nodeType === 1 ? src.commonAncestorContainer : src.commonAncestorContainer.parentElement) : src;
177
+ if (!el || getComputedStyle(el).visibility !== 'visible') return 'hidden';
178
+ for (let a = el; a; a = a.parentElement) if (+getComputedStyle(a).opacity < 0.05) return 'hidden';
179
+ const rects = [...(src instanceof Range ? src.getClientRects() : [src.getBoundingClientRect()])].filter((q) => q.width > 1 && q.height > 1);
180
+ const from = src instanceof Range ? el : el.parentElement;
181
+ if (!rects.some((q) => window.__demoClip(q, from, { scrollable: false }))) return 'hidden';
182
+ const seen = rects.map((q) => window.__demoClip(q, from)).filter(Boolean);
183
+ if (!seen.length) return 'away';
184
+ const q = seen.sort((a, b) => (b.right - b.left) * (b.bottom - b.top) - (a.right - a.left) * (a.bottom - a.top))[0];
185
+ const hit = document.elementFromPoint((q.left + q.right) / 2, (q.top + q.bottom) / 2);
186
+ return !hit || el.contains(hit) || hit.contains(el) && getComputedStyle(el).pointerEvents === 'none' || (!(src instanceof Range) && hit.contains(el)) ? 'shown' : 'covered';
187
+ };
107
188
  window.__demoFind = (phrase) => {
108
189
  const norm = (s) => s.replace(/\s+/g, ' ');
109
190
  const want = norm(phrase).trim();
@@ -126,27 +207,29 @@ function pageScript({ scrollOffset }) {
126
207
  const r = document.createRange();
127
208
  const [sn, so] = map[at], [en, eo] = map[at + needle.length - 1];
128
209
  r.setStart(sn, so); r.setEnd(en, eo + 1);
129
- const rects = [...r.getClientRects()].filter((q) => q.width > 1 && q.height > 1);
130
- if (rects.length) ranges.push(r);
210
+ // text the viewer can't see (hidden, clipped, collapsed, or a page's old view kept in the DOM) doesn't count
211
+ const shows = window.__demoShows(r);
212
+ if (shows !== 'hidden') ranges.push({ r, shows });
131
213
  }
132
214
  if (ranges.length) break;
133
215
  }
134
- const inView = (r) => { const q = r.getBoundingClientRect(); return q.top >= 0 && q.bottom <= innerHeight; };
135
- const r = ranges.find(inView) ?? ranges[0];
136
- if (!r) return null;
137
- window.__demoFound = r;
138
- return { inView: inView(r), count: ranges.length };
216
+ // on screen first, then what a scroll brings into view, then what something covers right now
217
+ const found = ['shown', 'away', 'covered'].map((s) => ranges.find((x) => x.shows === s)).find(Boolean);
218
+ if (!found) return null;
219
+ window.__demoFound = found.r;
220
+ return { inView: found.shows === 'shown', count: ranges.length };
139
221
  };
140
222
  window.__demoMark = (what, { ms = 700, color, box = false }) => {
141
223
  const layer = document.getElementById('__demo_marks') ?? document.documentElement.appendChild(Object.assign(document.createElement('div'), { id: '__demo_marks' }));
142
224
  // multiply on the layer itself (its children are isolated inside it), so text under a mark stays readable
143
225
  Object.assign(layer.style, { position: 'fixed', inset: '0', zIndex: '2147483644', pointerEvents: 'none', transition: 'opacity .3s ease', mixBlendMode: 'multiply' });
144
226
  const src = what === 'found' ? window.__demoFound : window.__demoMarkEl;
227
+ const clipFrom = what === 'found' ? (src.commonAncestorContainer.nodeType === 1 ? src.commonAncestorContainer : src.commonAncestorContainer.parentElement) : src.parentElement;
145
228
  const rectsOf = () => {
146
- const all = what === 'found' ? [...src.getClientRects()] : [src.getBoundingClientRect()];
229
+ const all = (what === 'found' ? [...src.getClientRects()] : [src.getBoundingClientRect()]).filter((q) => q.width > 1 && q.height > 1);
147
230
  // one rect per line: merge the pieces of inline elements that sit on the same line
148
231
  const lines = [];
149
- for (const q of all.filter((q) => q.width > 1 && q.height > 1)) {
232
+ for (const q of all) {
150
233
  const l = lines.find((l) => Math.abs(l.top - q.top) < q.height * 0.5);
151
234
  if (l) { l.left = Math.min(l.left, q.left); l.right = Math.max(l.right, q.right); l.top = Math.min(l.top, q.top); l.bottom = Math.max(l.bottom, q.bottom); }
152
235
  else lines.push({ left: q.left, right: q.right, top: q.top, bottom: q.bottom });
@@ -167,17 +250,21 @@ function pageScript({ scrollOffset }) {
167
250
  delay += d;
168
251
  return el;
169
252
  });
253
+ // each line drawn only where it's on screen: text scrolled out of its panel (or out from under a
254
+ // header) loses its mark there instead of leaving it floating over whatever is now in that place
170
255
  const place = () => {
171
256
  if (!els[0]?.isConnected) return;
172
- rectsOf().forEach((q, i) => {
173
- const el = els[i]; if (!el) return;
174
- const pad = box ? 6 : 2;
175
- Object.assign(el.style, { left: `${q.left - pad}px`, top: `${q.top - (box ? pad : 0)}px`, width: `${q.right - q.left + 2 * pad}px`, height: `${q.bottom - q.top + (box ? 2 * pad : 0)}px` });
257
+ const now = rectsOf();
258
+ els.forEach((el, i) => {
259
+ const q = now[i] && window.__demoClip(now[i], clipFrom), pad = box ? 6 : 2;
260
+ if (!q) { el.style.display = 'none'; return; }
261
+ Object.assign(el.style, { display: '', left: `${q.left - pad}px`, top: `${q.top - (box ? pad : 0)}px`, width: `${q.right - q.left + 2 * pad}px`, height: `${q.bottom - q.top + (box ? 2 * pad : 0)}px` });
176
262
  });
177
263
  requestAnimationFrame(place);
178
264
  };
179
265
  place();
180
- const u = first.reduce((a, q) => ({ x: Math.min(a.x, q.left), y: Math.min(a.y, q.top), r: Math.max(a.r, q.right), b: Math.max(a.b, q.bottom) }), { x: 1e9, y: 1e9, r: -1e9, b: -1e9 });
266
+ const shown = first.map((q) => window.__demoClip(q, clipFrom)).filter(Boolean);
267
+ const u = (shown.length ? shown : first).reduce((a, q) => ({ x: Math.min(a.x, q.left), y: Math.min(a.y, q.top), r: Math.max(a.r, q.right), b: Math.max(a.b, q.bottom) }), { x: 1e9, y: 1e9, r: -1e9, b: -1e9 });
181
268
  return { x: u.x, y: u.y, width: u.r - u.x, height: u.b - u.y };
182
269
  };
183
270
  // `note`: a short label with an arrow pointing at an element, placed on the side with the most room.
@@ -348,6 +435,8 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
348
435
  });
349
436
  const sleep = (ms) => page.waitForTimeout(ms);
350
437
  const now = () => Date.now() / 1000;
438
+ const warned = []; // also printed by inspect --after, whose log is silent
439
+ const warn = (m) => { warned.push(m); log(` ⚠ ${m}`); };
351
440
 
352
441
  // --- cursor ---------------------------------------------------------------------
353
442
  let cur = { x: VW * 0.62, y: VH * 0.55 };
@@ -403,7 +492,13 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
403
492
  const l = locate(page, spec);
404
493
  await waiting(() => l.waitFor({ state: 'visible', timeout }));
405
494
  await l.scrollIntoViewIfNeeded();
406
- const b = await l.boundingBox();
495
+ let b = await l.boundingBox();
496
+ if (b && (b.x + b.width <= 0 || b.y + b.height <= 0)) {
497
+ // parked off screen, the way styled radios and checkboxes hide the real input: go to its label
498
+ const label = (await l.evaluateHandle((el) => [...(el.labels ?? [])].find((x) => { const r = x.getBoundingClientRect(); return r.width > 2 && r.height > 2 && r.right > 0; }) ?? null).catch(() => null))?.asElement();
499
+ if (label) { await label.scrollIntoViewIfNeeded().catch(() => {}); b = (await label.boundingBox()) ?? b; }
500
+ else warn(`${acting}: the target is off screen (at x=${Math.round(b.x)}, y=${Math.round(b.y)}) — the page hides it there; target what it shows instead (inspect lists it)`);
501
+ }
407
502
  boxes.set(keyOf(spec), b);
408
503
  marks.push({ t: now(), box: b });
409
504
  const o = opt.offset ?? { x: 0.5, y: 0.5 };
@@ -424,7 +519,11 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
424
519
  await sleep(280);
425
520
  return true;
426
521
  }
427
- const spotOff = () => page.evaluate(() => window.__demoSpotOff()).catch(() => {});
522
+ const spotOff = (instant) => page.evaluate((i) => window.__demoSpotOff(i), !!instant).catch(() => {});
523
+ const keys = async (label) => {
524
+ const box = await page.evaluate(([l, x, y]) => window.__demoKeys(l, x, y), [label, cur.x, cur.y]).catch(() => null);
525
+ if (box) marks.push({ t: now(), box });
526
+ };
428
527
  const keyLabel = (k) => k.split('+').map((p) => ({ Meta: '⌘', ControlOrMeta: '⌘', Control: 'Ctrl', Shift: '⇧', Alt: '⌥', Enter: '↵ Enter', Escape: 'Esc', Backspace: '⌫', Tab: '⇥ Tab' })[p] ?? (p.length === 1 ? p.toUpperCase() : p)).join(' ');
429
528
 
430
529
  async function runAction(action) {
@@ -443,6 +542,16 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
443
542
  const p = await pointOf(spec, opt);
444
543
  const lit = await spot(p.box, hl);
445
544
  await moveTo(p.x, p.y, opt.ms ?? cfg.pace.cursor);
545
+ if (!isPoint(spec)) {
546
+ const over = await locate(page, spec).evaluate((el, [x, y]) => {
547
+ const hit = document.elementFromPoint(x, y);
548
+ const label = el.closest('label') ?? [...(el.labels ?? [])].find((l) => l.contains(hit));
549
+ if (!hit || el.contains(hit) || hit.contains(el) || (label && label.contains(hit)) || el.getRootNode() !== document || hit.closest('[id^=__demo]')) return null;
550
+ const text = (hit.innerText ?? '').replace(/\s+/g, ' ').trim().slice(0, 50);
551
+ return hit.tagName.toLowerCase() + [...hit.classList].slice(0, 2).map((c) => `.${c}`).join('') + (text ? ` "${text}"` : '');
552
+ }, [p.x, p.y]).catch(() => null);
553
+ if (over) warn(`${acting}: ${over} is on top of the click target, so the click lands on it — close or hide it first (in setup: click its close button, or hide: <its css>)`);
554
+ }
446
555
  await sleep(lit ? 350 + cfg.pace.beat / 3 : 120);
447
556
  if (kind === 'dblclick') { clicks.push(now()); await page.mouse.dblclick(p.x, p.y); } else await press();
448
557
  if (lit) { await sleep(150); await spotOff(); }
@@ -463,7 +572,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
463
572
  }
464
573
  case 'dismiss': {
465
574
  const l = locate(page, spec);
466
- if (await l.isVisible().catch(() => false)) { await l.click(); await sleep(400); }
575
+ if (await l.isVisible().catch(() => false)) { await l.click({ timeout: 10000 }); await sleep(400); }
467
576
  break;
468
577
  }
469
578
  case 'waitFor': await waiting(() => locate(page, spec).waitFor({ state: 'visible', timeout: opt.timeout ?? 180000 })); await settle(); await nudge(); break;
@@ -473,6 +582,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
473
582
  case 'scrollTo': {
474
583
  const h = await locate(page, spec).elementHandle({ timeout: opt.timeout ?? 15000 });
475
584
  await page.evaluate(([el, ms]) => window.__demoScrollTo(el, ms), [h, opt.ms ?? 1200]);
585
+ // where it landed: a zoom on the same target frames it there, not where it was before the scroll
586
+ const b = await h.boundingBox().catch(() => null);
587
+ if (b) boxes.set(keyOf(spec), b);
476
588
  break;
477
589
  }
478
590
  case 'scroll': await page.evaluate(([dy, ms]) => window.__demoScrollBy(dy, ms), [value.y ?? value, value.ms ?? 1000]); break;
@@ -558,7 +670,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
558
670
  document.addEventListener('paste', (e) => { window.__demoPasted = !!e.clipboardData?.getData('text'); }, { capture: true, once: true });
559
671
  return true;
560
672
  }, () => false), text).catch(() => false);
561
- if (!fast) await page.evaluate((l) => window.__demoKeys(l), process.platform === 'darwin' ? '⌘ V' : 'Ctrl V').catch(() => {});
673
+ if (!fast) await keys(process.platform === 'darwin' ? '⌘ V' : 'Ctrl V');
562
674
  if (copied) await page.keyboard.press('ControlOrMeta+V');
563
675
  await sleep(200);
564
676
  const pasted = copied && await page.evaluate(() => window.__demoPasted === true).catch(() => false);
@@ -570,25 +682,28 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
570
682
  }
571
683
  case 'slide': {
572
684
  // slide: { target: <slider>, to: 0.2 } — drag a slider to a position along its track (0 = start,
573
- // 1 = end), found from the element instead of pixel positions that break when the layout moves
574
- if (!value?.target || typeof value?.to !== 'number') throw new Error('slide needs { target: <slider>, to: 0..1 }');
685
+ // 1 = end), or { target, value: 3 } — to a value, for sliders that report their range; found from
686
+ // the element instead of pixel positions that break when the layout moves
687
+ if (!value?.target || (typeof value?.to !== 'number' && typeof value?.value !== 'number')) throw new Error('slide needs { target: <slider>, to: 0..1 } or { target: <slider>, value: <number> }');
575
688
  const p = await pointOf(value.target, value);
576
- const b = p.box;
577
- const from = await locate(page, value.target).evaluate((el) => {
578
- const r = el.type === 'range' ? el : el.querySelector?.('input[type=range]');
579
- if (r) return (Number(r.value) - Number(r.min || 0)) / ((Number(r.max || 100) - Number(r.min || 0)) || 1);
580
- const aria = el.hasAttribute('aria-valuenow') ? el : el.querySelector?.('[aria-valuenow]');
581
- if (aria) {
582
- const now = Number(aria.getAttribute('aria-valuenow')), lo = Number(aria.getAttribute('aria-valuemin') ?? 0), hi = Number(aria.getAttribute('aria-valuemax') ?? 100);
583
- if (Number.isFinite(now)) return (now - lo) / ((hi - lo) || 1);
689
+ const s = await locate(page, value.target).evaluate((el) => {
690
+ // a knob (Desmos's [role=slider] is its 38 px thumb): slide along its track, the nearest wide parent
691
+ let track = el;
692
+ const w = el.getBoundingClientRect().width;
693
+ for (let a = el.parentElement, i = 0; w < 60 && a && i < 4; a = a.parentElement, i++) if (a.getBoundingClientRect().width >= 3 * w) { track = a; break; }
694
+ const t = track.getBoundingClientRect(), v = window.__demoSliderValue(el);
695
+ let from = v ? (v.now - v.lo) / ((v.hi - v.lo) || 1) : null;
696
+ if (from == null) {
697
+ // custom sliders: start where the knob is drawn, so the value doesn't jump first
698
+ const knob = [...track.querySelectorAll('[role=slider], [class*=thumb], [class*=handle], [class*=knob]')]
699
+ .map((k) => k.getBoundingClientRect()).find((k) => k.width > 0 && k.width < t.width * 0.5);
700
+ from = knob ? (knob.x + knob.width / 2 - t.x) / (t.width || 1) : 0.5;
584
701
  }
585
- // custom sliders (e.g. Desmos): start where the knob is drawn, so the value doesn't jump first
586
- const t = el.getBoundingClientRect();
587
- const knob = [...el.querySelectorAll('[role=slider], [class*=thumb], [class*=handle], [class*=knob]')]
588
- .map((k) => k.getBoundingClientRect()).find((k) => k.width > 0 && k.width < t.width * 0.5);
589
- return knob ? (knob.x + knob.width / 2 - t.x) / (t.width || 1) : 0.5;
590
- }).catch(() => 0.5);
591
- const x0 = b.x + b.width * from, x1 = b.x + b.width * Math.max(0, Math.min(1, value.to)), y = b.y + b.height / 2;
702
+ return { box: { x: t.x, y: t.y, width: t.width, height: t.height }, from, range: v && { lo: v.lo, hi: v.hi } };
703
+ }).catch(() => ({ box: p.box, from: 0.5, range: null }));
704
+ if (typeof value.value === 'number' && !s.range) throw new Error('slide value: this slider doesn\'t report its range (no aria-valuemin/max, not an <input type=range>) — use to: 0..1');
705
+ const to = typeof value.value === 'number' ? (value.value - s.range.lo) / ((s.range.hi - s.range.lo) || 1) : value.to;
706
+ const b = s.box, x0 = b.x + b.width * s.from, x1 = b.x + b.width * Math.max(0, Math.min(1, to)), y = b.y + b.height / 2;
592
707
  await moveTo(x0, y, value.ms ?? cfg.pace.cursor);
593
708
  await sleep(120);
594
709
  clicks.push(now());
@@ -596,6 +711,19 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
596
711
  await moveTo(x1, y, value.dragMs ?? 900);
597
712
  await sleep(120);
598
713
  await page.mouse.up();
714
+ if (typeof value.value === 'number') {
715
+ // a native range's thumb doesn't reach the ends of its box, so the drag can land a step off: set it
716
+ const got = await locate(page, value.target).evaluate((el, want) => {
717
+ const v = window.__demoSliderValue(el), r = v?.native;
718
+ if (r && v.now !== want) {
719
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(r, String(want));
720
+ r.dispatchEvent(new Event('input', { bubbles: true })); r.dispatchEvent(new Event('change', { bubbles: true }));
721
+ return Number(r.value);
722
+ }
723
+ return v?.now;
724
+ }, value.value).catch(() => null);
725
+ if (got != null && Math.abs(got - value.value) > 1e-9) warn(`${acting}: slide stopped at ${got}, not ${value.value} — the slider snaps to its step`);
726
+ }
599
727
  await settle();
600
728
  await beat();
601
729
  break;
@@ -608,11 +736,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
608
736
  let box = null;
609
737
  const phraseOnly = v.text && Object.keys(v).every((k) => ['text', 'exact'].includes(k));
610
738
  if (phraseOnly) {
739
+ await page.evaluate(() => window.__demoScrollIdle()).catch(() => {}); // judge "in view" where the page ends up
611
740
  const found = await page.evaluate((t) => window.__demoFind(t), v.text);
612
- if (found && !found.inView) {
613
- await page.evaluate((ms) => window.__demoScrollTo(window.__demoFound.startContainer.parentElement, ms), fast ? 0 : 900);
614
- await sleep(fast ? 50 : 950);
615
- }
741
+ if (found && !found.inView) await page.evaluate((ms) => window.__demoScrollTo(window.__demoFound.startContainer.parentElement, ms), fast ? 0 : 900);
616
742
  if (found) box = await page.evaluate(([ms, color, b]) => window.__demoMark('found', { ms, color, box: b }), [ms, color, opt.box ?? false]);
617
743
  }
618
744
  if (!box) {
@@ -637,8 +763,10 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
637
763
  await l.evaluate((el) => { window.__demoMarkEl = el; });
638
764
  const box = await page.evaluate(([t, o]) => window.__demoNote(t, o), [String(value.text), { side: value.side, ink: cfg.theme.ink, accent: cfg.theme.accent }]);
639
765
  marks.push({ t: now(), box });
640
- // reading time: about 3 words per second, at least a second
641
- const read = fast ? 0 : value.ms ?? Math.max(1000, String(value.text).split(/\s+/).length * 330);
766
+ // reading time: about 3 words a second, or 5 Chinese/Japanese/Korean characters; at least a second
767
+ const t = String(value.text), cjk = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g;
768
+ const words = t.replace(cjk, ' ').split(/\s+/).filter(Boolean).length, chars = (t.match(cjk) ?? []).length;
769
+ const read = fast ? 0 : value.ms ?? Math.max(1000, words * 330 + chars * 200);
642
770
  holds += read / 1000;
643
771
  await sleep(read);
644
772
  break;
@@ -647,7 +775,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
647
775
  const key = typeof value === 'object' ? value.key : String(value);
648
776
  // on-screen key badge: shortcuts and special keys only (not plain characters), when highlighting
649
777
  const show = value?.show ?? (hl && (key.includes('+') || key.length > 1));
650
- if (show && !fast) await page.evaluate((l) => window.__demoKeys(l), keyLabel(key)).catch(() => {});
778
+ if (show && !fast) await keys(keyLabel(key));
651
779
  await page.keyboard.press(key);
652
780
  await sleep(200);
653
781
  if (key === 'Enter') { await settle(); await beat(); }
@@ -659,7 +787,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
659
787
 
660
788
  // --- setup (not recorded) -----------------------------------------------------------
661
789
  // Run a list of actions; on failure, say exactly which one and save a screenshot.
790
+ let acting = '';
662
791
  async function runAll(actions, where) {
792
+ acting = where;
663
793
  for (const [j, a] of actions.entries()) {
664
794
  try {
665
795
  // watchdog: an action that hangs (a frozen page, a stalled browser) fails with a clear message
@@ -690,10 +820,14 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
690
820
  const lw = await detectLogin(page).catch(() => ({})), loginWall = lw.wall ? lw.reason : null;
691
821
  if (desc) fs.writeFileSync(pageFile, desc.text);
692
822
  await browser.close().catch(() => {});
823
+ // Playwright's call log says when another element takes the click (a modal, a banner, an overlay)
824
+ const blocker = e.message.replace(/\u001b\[\d+m/g, '').match(/\n\s*-\s*(?:<[^\n]*?> from )?(<[^\n]*?>)(?: subtree)? intercepts pointer events/)?.[1];
693
825
  const err = new Error(`${where}, action ${j + 1} (${kind}: ${JSON.stringify(value)}) failed: ` +
694
- `${e.message.split('\n')[0]}${matches}\n page: ${page.url()}\n screenshot of the moment it failed: ${shot}` +
826
+ `${e.message.split('\n')[0]}${matches}` +
827
+ (blocker ? `\n another element is on top of it and takes the click: ${blocker.slice(0, 200)} — close it first (click its button, or hide: it in setup)` : '') +
828
+ `\n page: ${page.url()}\n screenshot of the moment it failed: ${shot}` +
695
829
  (loginWall ? `\n ⚠ the page is asking to log in (${loginWall}) — ${cfg.storageState ? 'the saved session has probably expired; run login again' : loginAdvice(cfg.url)}` : '') +
696
- (desc ? `\n targets on the page at that moment: ${pageFile}` : ''));
830
+ (desc ? (fast ? `\n\nThe page at that moment:\n${desc.text}` : `\n targets on the page at that moment: ${pageFile}`) : ''));
697
831
  err.screenshot = shot;
698
832
  err.pageDescription = desc?.text;
699
833
  throw err;
@@ -716,8 +850,11 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
716
850
  await runAll(cfg.setup, 'setup');
717
851
  for (const [card, file] of [[cfg.endCard, 'logo.png'], [cfg.intro, 'intro-logo.png']]) {
718
852
  if (!card?.logo || card.logoFile) continue;
719
- await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 })
720
- .catch((e) => log(`warning: could not capture logo (${e.message.split('\n')[0]})`));
853
+ await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 }).catch(async (e) => {
854
+ const all = locateAll(page, card.logo), n = await all.count().catch(() => 0), v = await all.filter({ visible: true }).count().catch(() => 0);
855
+ const why = !n ? 'nothing on the first page matches it' : !v ? `it matches ${n} element(s), none of them visible` : e.message.split('\n')[0];
856
+ log(`warning: could not capture the ${card === cfg.intro ? 'intro' : 'end card'} logo ${JSON.stringify(card.logo)}: ${why} — an inline <svg> logo is { css: "header a svg" } or similar, not an img; or give a logo file`);
857
+ });
721
858
  }
722
859
  // where the intro's logo sits on the first page: a HyperFrames launch intro lands its logo there
723
860
  const introLogo = cfg.intro?.logo && !cfg.intro.logoFile
@@ -757,18 +894,21 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
757
894
 
758
895
  if (fast) {
759
896
  const n = Math.min(inspectAfter, cfg.steps.length);
897
+ let before = null;
760
898
  for (const [i, s] of cfg.steps.slice(0, n).entries()) {
761
899
  stepHighlight = false;
762
- // like the recording: marks and notes end with their step (the last step's stay in the screenshot)
763
- if (i > 0) await page.evaluate(() => { window.__demoUnmark(); window.__demoUnnote(); }).catch(() => {});
900
+ if (i === n - 1) before = await clickableKeys(page);
901
+ // like the recording: marks, notes and spotlights end with their step (the last step's stay in the screenshot)
902
+ if (i > 0) await page.evaluate(() => { window.__demoUnmark(true); window.__demoUnnote(true); window.__demoSpotOff(true); }).catch(() => {});
764
903
  await runAll(s.actions, `step ${i + 1}`);
904
+ await page.evaluate(() => window.__demoScrollIdle()).catch(() => {});
765
905
  }
766
906
  await sleep(800);
767
907
  const shot = path.join(cfg.work, `inspect-after-${n}.jpg`);
768
908
  await page.screenshot({ path: shot, quality: 80, scale: 'css' });
769
- const desc = await describePage(page, cfg.viewport);
909
+ const desc = await describePage(page, cfg.viewport, { before });
770
910
  await browser.close();
771
- return { text: `State after ${n ? `step ${n}` : 'setup'} of ${cfg.name}:\n${desc.text}\n\nScreenshot: ${shot}`, screenshot: shot };
911
+ return { text: `State after ${n ? `step ${n}` : 'setup'} of ${cfg.name}:\n${warned.map((w) => `⚠ ${w}\n`).join('')}${desc.text}\n\nScreenshot: ${shot}`, screenshot: shot };
772
912
  }
773
913
 
774
914
  // --- recording ----------------------------------------------------------------------
@@ -804,10 +944,14 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
804
944
  const zoomTarget = s.zoom?.target && !isPoint(s.zoom.target);
805
945
  const startBox = zoomTarget ? await boxNow() : null;
806
946
  await runAll(s.actions, `step ${i + 1}`);
947
+ // a scroll still running (the page's own smooth scrolling) ends in this step, not in the next one's first shot
948
+ await page.evaluate(() => window.__demoScrollIdle()).catch(() => {});
807
949
  const end = now(); // before clearing: a step that is held for its narration keeps its marks
808
- await spotOff();
809
- // docs clips start where the step starts: clear at once, so no fading callout leaks into the next clip
810
- await page.evaluate((i) => { window.__demoUnmark(i); window.__demoUnnote(i); }, cfg.clips.length > 0).catch(() => {});
950
+ // docs clips start where the step starts, and a hidden step is cut: clear at once, so no fading
951
+ // callout leaks into the next shot
952
+ const instant = cfg.clips.length > 0 || !!s.hidden;
953
+ await spotOff(instant);
954
+ await page.evaluate((i) => { window.__demoUnmark(i); window.__demoUnnote(i); }, instant).catch(() => {});
811
955
  let zoomBox = null;
812
956
  if (s.zoom?.target) {
813
957
  if (isPoint(s.zoom.target)) {