74dc80fd3bd2a70371fa75218347ddc45a556dde
braney
  Wed Aug 5 07:28:59 2026 -0700
Docent: render a tour at print resolution, and make zoom wait for its redraw

refs #37892

scale: k (DOCENT_SCALE=k, make hires [SCALE=3]) renders the same tour with k
times the pixels, for figures that have to print -- a screen still is about
120 dpi across a journal column. Nothing is upscaled; each layer that draws is
asked for more:

* deviceScaleFactor: k with the viewport left at its 1x CSS size, so the page
lays out exactly as at 1x -- same line breaks, same jQuery-dialog width, same
tooltip placement -- and rasterizes with k times the pixels,
* pix x k so the server draws a wider browser image, with textSize stepped up
its ladder to match (3x lands on 24) so hgTracks makes the same layout
decisions in it: same tick spacing, same room for labels, same packing of
features into rows,
* zoom: 1/k on the image table, handing that wider image the 1x amount of
layout space, so one image pixel falls on one device pixel.

A scaled run is stills-only (no mp4), and DOCENT_STILLS names a different parent
so a print render lands beside the screen stills instead of over them.

Everything hgTracks reports about the image -- map-box coords, mouseOver spans,
insideX, a px: drag endpoint -- is in the pixels the SERVER drew, which is not
the displayed size once the image is scaled. Those now go through the image's
natural-to-displayed ratio (1 at 1x). Without it a named mouseover: lands k
times off and pins a different feature.

zoom: in|out waited only for #imgTbl, which the buttons never remove (they
redraw in place via ajax), so the following step could read the previous
window's map boxes and report an item "not found" that was simply not in view
yet. It now waits for the window itself to change. Pre-existing, hidden by the
dwell that FAST removes -- and every hires run is FAST. An item-not-found error
also names the current window and the items that ARE in that row, which is what
told the two cases apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index f4402c92bc3..9150f8fb1ff 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -45,55 +45,88 @@
           + `space after the colon. Did you mean "${k.replace(/:(?=\S)/, ': ')}"? `
           + `(this key is being IGNORED, so the verb may fall back to a default)`);
       }
       scan(obj[k], where);
     }
   };
   (steps || []).forEach((s, i) => {
     if (s && typeof s === 'object') { const v = Object.keys(s)[0]; scan(s[v], `step ${i + 1} (${v})`); }
   });
   if (n) console.warn(`(${n} suspicious key${n > 1 ? 's' : ''} above -- fix the missing space, or the arg is dropped)`);
 })(doc.steps);
 const HERE = path.dirname(path.resolve(SCRIPT));
 const base = path.basename(SCRIPT).replace(/\.(docent\.)?ya?ml$/i, '').replace(/\.docent$/i, '');
 const FIGDIR = path.resolve(HERE, '..');                       // figures dir beside the scripts
 const OUTMP4 = process.argv[3] || doc.mp4 || path.join(FIGDIR, base + '.mp4');
-const STILLDIR = doc.stills ? path.resolve(HERE, doc.stills) : path.join(HERE, 'stills', base);
+// Stills go to stills/<base>/. DOCENT_STILLS names a different PARENT ("stills.hires"),
+// which is how a high-resolution run keeps its figures beside the screen-resolution ones
+// instead of overwriting them.
+const STILLPARENT = process.env.DOCENT_STILLS;
+const STILLDIR = STILLPARENT ? path.resolve(HERE, STILLPARENT, base)
+  : doc.stills ? path.resolve(HERE, doc.stills) : path.join(HERE, 'stills', base);
 
 // `target:` takes a shorthand from this table, a bare `hgwdev-<user>` sandbox name
 // (expanded below), or a full https://.../cgi-bin URL. Default is genome-test, so a
 // script that forgets to say where it runs does not silently hit someone's sandbox.
 const SERVERS = {
   'rr': 'https://genome.ucsc.edu/cgi-bin',
   'genome-test': 'https://genome-test.gi.ucsc.edu/cgi-bin',
   'hgwdev': 'https://hgwdev.gi.ucsc.edu/cgi-bin',
   'hgwbeta': 'https://hgwbeta.soe.ucsc.edu/cgi-bin',
 };
 const resolveTarget = t => {
   if (!t) return SERVERS['genome-test'];
   if (SERVERS[t]) return SERVERS[t];
   if (/^hgwdev-[a-z0-9._-]+$/i.test(t)) return `https://${t}.gi.ucsc.edu/cgi-bin`;  // personal sandbox
   return t;                                                    // full URL
 };
 const SERVER = resolveTarget(doc.target).replace(/\/$/, '');
+// SCALE: the same tour rendered at k times the resolution, for figures that have to print.
+// Nothing is upscaled -- a still only ever has the pixels it was drawn with -- so each layer
+// is asked to draw k times as many while the layout is left alone:
+//
+//   * deviceScaleFactor: k. The viewport keeps its 1x CSS size, so the page lays out exactly
+//     as at 1x -- same line breaks, same jQuery-dialog width, same tooltip placement -- and
+//     every bit of it is rasterized with k times the pixels. The retina case, natively.
+//   * `pix` x k, so the server draws the browser image k times as wide, with `textSize`
+//     stepped up to match so hgTracks makes the SAME layout decisions in that bigger image:
+//     same tick spacing, same room for labels, same packing of features into rows. Without
+//     the font, a wider image is a different picture rather than a bigger one.
+//   * `zoom: 1/k` on the image table (SCALE_INIT below), handing that k-times-wider image
+//     back the 1x amount of layout space. One image pixel then falls on exactly one device
+//     pixel: native resolution, no resampling anywhere in the path.
+//
+// So the still comes out k times the 1x still in each dimension, showing the same figure --
+// not a variation of it rendered in a bigger window.
+const SCALE = Math.max(1, Number(process.env.DOCENT_SCALE || doc.scale || 1));
 const [VW, VH] = doc.size || [1000, 760];
-const PIX = doc.pix || 850;
+const PIX = Math.round((doc.pix || 850) * SCALE);
+// hgTracks offers a fixed ladder of track font sizes (hgTracks/config.c); step to the one
+// closest to scaling its 8px default, so rows and labels grow with the image instead of
+// staying 8px tall in a 3x-wide picture.
+const TEXTSIZE = [6, 8, 10, 12, 14, 18, 24, 34].reduce((a, b) =>
+  Math.abs(b - 8 * SCALE) < Math.abs(a - 8 * SCALE) ? b : a);
+// What every hgTracks nav carries: the image width, plus the font to draw it with at scale.
+const IMGVARS = `pix=${PIX}` + (SCALE > 1 ? `&textSize=${TEXTSIZE}` : '');
 // FAST: iterate on the FIGURES. Everything that exists only for the video is dropped --
 // the dwells, the cursor animation, the dropdown theatrics, the screen recording and the
 // mp4 transcode. The stills are byte-for-byte what a full run produces, and a run costs
 // roughly a third as long. `fast: true` in the script, DOCENT_FAST=1, or `make FAST=1 BP1`.
-const FAST = !!(doc.fast || process.env.DOCENT_FAST);
+// A scaled run is a figure run: at 3x the video would be a 3000px-wide recording of a tour
+// nobody watches at that size, so the mp4 is skipped and only the stills are produced. Build
+// the video from an unscaled run of the same script.
+const FAST = !!(doc.fast || process.env.DOCENT_FAST || SCALE > 1);
 const PACE = FAST ? 0 : Math.round((doc.pace ?? 1.2) * 1000);   // dwell after each step
 const SHOTHOLD = FAST ? 0 : Math.round((doc.shotHold ?? 2.2) * 1000);  // extra pause at a shot
 
 // ---------- trackDb ----------
 // Docent carries NO table of per-track cart variables. Such a table encodes one snapshot
 // of trackDb and then quietly lies when trackDb changes (this file used to pin
 // `clinvarMain=dense` for every `clinvar:` step, for instance). Instead ask the server for
 // the trackDb it is driving -- hubApi /list/tracks -- and derive what a step needs:
 //
 //   * the containers above a subtrack (composite, view, superTrack) that have to be
 //     turned on with it, and what each of those takes (a superTrack wants show/hide),
 //   * whether trackDb leaves that subtrack UNSELECTED (`parent <c> off`), in which case
 //     the subtrack checkbox `<name>_sel` has to come along,
 //   * which leaf actually draws the pixels for a container name.
 //
@@ -194,30 +227,52 @@
   return p;
 }
 
 const sleep = ms => new Promise(r => setTimeout(r, ms));
 // A pause that exists only so a viewer can follow the video: skipped entirely in FAST.
 const dwell = ms => (FAST ? Promise.resolve() : sleep(ms));
 // Typing is shown on screen for the video; in FAST just put the text in the box (fill()
 // still fires the input events the autocompletes listen for).
 async function typeIn(pg, sel, text) {
   if (FAST) await pg.fill(sel, String(text));
   else await pg.type(sel, String(text), { delay: 45 });
 }
 const enc = s => encodeURIComponent(String(s));
 const state = { db: doc.db || 'hg38', position: doc.position || '', hgsid: '' };
 
+// ---------- SCALE: give the k-times-wider browser image the 1x amount of layout space ----------
+// hgTracks sizes the image table in the HTML it writes, so shrinking the <img> elements alone
+// would leave the table 3x wider than its own contents. Zoom the table: the zoom reaches the
+// images inside it, and its box goes back to the width the 1x page gives it -- 854 CSS px for
+// a pix=850 run, whatever k is -- so the rest of the page is laid out exactly as at 1x while
+// the image keeps all k times its pixels (one per device pixel at deviceScaleFactor: k).
+//
+// Installed for the whole run rather than at the shutter: the tour's own geometry -- a drag
+// across the image, a mouseover on a feature -- then works in the same coordinates a 1x run
+// works in, and needs no scale arithmetic of its own.
+const SCALE_INIT = (k) => {
+  const add = () => {
+    if (document.getElementById('__scale')) return;
+    const s = document.createElement('style');
+    s.id = '__scale';
+    s.textContent = `#imgTbl, #chromIdeoImg, img[src*="hgtIdeo"] { zoom: ${1 / k} !important; }`;
+    (document.head || document.documentElement).appendChild(s);
+  };
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add);
+  else add();
+};
+
 // ---------- animated cursor (same technique as the walkthrough-video skill's record.js) ----------
 const CURSOR_INIT = () => {
   const add = () => {
     if (document.getElementById('__cur')) return;
     const c = document.createElement('div');
     c.id = '__cur';
     c.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;width:24px;height:24px;margin-left:-3px;margin-top:-2px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.4));';
     c.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24"><path d="M3 2 L3 19 L7.5 14.5 L10.5 21.5 L13.5 20.2 L10.6 13.5 L17 13.5 Z" fill="#111" stroke="#fff" stroke-width="1.3"/></svg>';
     document.documentElement.appendChild(c);
     const place = (x, y) => { c.style.transform = `translate(${x}px,${y}px)`; };
     place(120, 120);
     document.addEventListener('mousemove', e => place(e.clientX, e.clientY), true);
     document.addEventListener('mousedown', e => {
       const r = document.createElement('div');
       r.className = '__ripple';
@@ -231,33 +286,34 @@
   else add();
 };
 
 function absurl(u) {
   if (/^https?:/.test(u)) return u;
   if (u.startsWith('/cgi-bin/')) return SERVER.replace(/\/cgi-bin$/, '') + u;
   if (u.startsWith('/')) return SERVER.replace(/\/cgi-bin$/, '') + u;
   return SERVER + '/' + u;
 }
 
 const T_START = Date.now();
 (async () => {
   fs.mkdirSync(STILLDIR, { recursive: true });
   const browser = await chromium.launch({ headless: true, args: ['--force-color-profile=srgb'] });
   const ctx = await browser.newContext({
-    viewport: { width: VW, height: VH }, deviceScaleFactor: 1,
+    viewport: { width: VW, height: VH }, deviceScaleFactor: SCALE,
     ...(FAST ? {} : { recordVideo: { dir: path.join(HERE, '.vid_' + base), size: { width: VW, height: VH } } }),
   });
+  if (SCALE > 1) await ctx.addInitScript(SCALE_INIT, SCALE);
   await ctx.addInitScript(CURSOR_INIT);
   await ctx.addInitScript(() => { try { localStorage.setItem('hgTracks_hideTutorial', '1'); } catch (e) {} });
   const page = await ctx.newPage();
   const cur = { x: 120, y: 120 };
   const pinnedTips = [];   // recorded mouseover tooltips for the next pinShot (per view)
 
   async function captureState() {
     try {
       const u = new URL(page.url());
       const h = u.searchParams.get('hgsid'); if (h) state.hgsid = h;
       const db = u.searchParams.get('db'); if (db) state.db = db;
       const p = u.searchParams.get('position'); if (p) state.position = p;
     } catch (e) {}
     // An interactive zoom / drag-select reload stores the new window in the CART, not the
     // URL, so read the live position straight from hgTracks when we're on a tracks page --
@@ -502,97 +558,138 @@
     const cands = [t, ...await tdbLeaves(t)];
     let key = null;
     for (const c of cands) {
       key = await page.evaluate(k => {
         if (document.getElementById('img_data_' + k)) return k;
         const el = [...document.querySelectorAll('[id^="img_data_"]')]
           .find(e => e.id === 'img_data_' + k || e.id.endsWith('_' + k));
         return el ? el.id.replace('img_data_', '') : null;
       }, c);
       if (key) { if (c !== t) console.log(`track ${t}: drawn by "${c}"`); break; }
     }
     if (!key) throw new Error(`track "${t}" not shown (no #img_data_ for ${cands.join(', ')})`);
     const img = await page.locator(`#img_data_${key}`).first().boundingBox({ timeout: 8000 }).catch(() => null);
     const row = await page.locator(`#imgTbl tr#tr_${key}`).first().boundingBox({ timeout: 8000 }).catch(() => null);
     if (!img || !row) throw new Error(`track "${t}" not shown (need #img_data_${key} + #tr_${key})`);
-    return { key, img, row };
+    // Everything hgTracks reports about the image -- map-box coords, mouseOver spans,
+    // insideX -- is in the pixels of the image the SERVER drew, which is not the size the
+    // page shows it at when the image is scaled (SCALE). imgPx is that ratio, so those
+    // numbers can be turned into page coordinates: 1 normally, 1/SCALE for a print render.
+    const imgPx = await page.evaluate(k => {
+      const im = document.getElementById('img_data_' + k);
+      return im && im.naturalWidth ? im.getBoundingClientRect().width / im.naturalWidth : 1;
+    }, key);
+    return { key, img, row, imgPx: imgPx || 1 };
   }
   // Resolve a NAMED item to a point {x,y} + its map-box HREF (the item's hgc link). We
   // pick the map <area> whose href(&i=<name>)/title carries the name AND whose box sits in
   // this track's own ROW band (so stacked items on other rows don't win); fall back to the
   // JSON mouseOver spans (wig/dense tracks, no per-item href).
   async function itemXY(t, want, titleOnly) {
-    const { key, img, row } = await trackBox(t);
+    const { key, img, row, imgPx } = await trackBox(t);
     const band = { top: row.y, bot: row.y + row.height };
     const area = await page.evaluate(({ want, titleOnly, band, imgBox }) => {
       const areas = [...document.querySelectorAll('map[name^="map_"] area')];
       const cands = [];
       for (const a of areas) {
         const href = a.getAttribute('href') || '';
         const title = a.getAttribute('title') || a.getAttribute('data-tooltip')
                    || a.getAttribute('mouseoverText') || '';
         const hay = titleOnly ? title : (href + ' ' + title);
         if (!hay.includes(want)) continue;
         const c = (a.getAttribute('coords') || '').split(',').map(Number);
         if (c.length < 4) continue;
         // origin = the image this map is attached to (fall back to the data image box)
         const m = a.closest('map'), nm = m && m.getAttribute('name');
         const im = nm && document.querySelector(`img[usemap="#${nm}"]`);
         const r = im ? im.getBoundingClientRect() : null;
         const ox = r ? r.left : imgBox.x, oy = r ? r.top : imgBox.y;
-        const cx = ox + (c[0] + c[2]) / 2, cy = oy + (c[1] + c[3]) / 2;
+        // coords are in the drawn image's own pixels; s converts them to page pixels
+        const s = (r && im.naturalWidth) ? r.width / im.naturalWidth : 1;
+        const cx = ox + s * (c[0] + c[2]) / 2, cy = oy + s * (c[1] + c[3]) / 2;
         // The tooltip's own text, so the hover can wait for THIS item's tooltip rather than
         // for any tooltip at all (see mouseover()). Rendered exactly the way the tooltip
         // renders it -- innerHTML then textContent -- because the attribute holds markup AND
         // undecoded entities (`<b>`, `&#9733;`) that getAttribute hands back literally.
         const tmp = document.createElement('div');
         tmp.innerHTML = title;
         const tip = (tmp.textContent || '').replace(/\s+/g, ' ').trim();
         cands.push({ cx, cy, href, tip, inBand: cy >= band.top - 1 && cy <= band.bot + 1 });
       }
       const inBand = cands.filter(h => h.inBand);
       const pick = (inBand[0] || cands[0]) || null;
       return pick && { ...pick, n: cands.length, nBand: inBand.length,
                        all: cands.map(c => Math.round(c.cx)) };
     }, { want: String(want), titleOnly: !!titleOnly, band, imgBox: img });
     if (area) {
       if (process.env.DOCENT_ROWS)
         console.log(`  item "${want}" in ${t}: ${area.n} map box(es) match (${area.nBand} in row), `
                   + `centers x=[${area.all}] -> hovering (${Math.round(area.cx)},${Math.round(area.cy)})`
                   + (area.tip ? `, expecting tip "${area.tip.slice(0, 40)}"` : ''));
       return { x: area.cx, y: area.cy, href: area.href, tip: area.tip };
     }
     const span = await page.evaluate(({ keys, want }) => {
       const md = window.mapData; if (!md || !md.spans) return null;
       for (const k of keys) {
         const arr = md.spans[k]; if (!arr) continue;
         const s = arr.find(r => String(r.value || '').includes(want));
         if (s) return { x1: s.x1, x2: s.x2 };
       }
       return null;
     }, { keys: [key, t], want: String(want) });
-    if (!span) throw new Error(`item "${want}" not found in track "${t}" (searched map-box areas + mouseOver spans)`);
-    return { x: img.x + (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null };
+    if (!span) {
+      // Say WHAT is there instead. An item name that has gone missing is usually a track
+      // whose items depend on the pixel width -- a print render (SCALE) draws a wider image,
+      // so features hgTracks merged into one box at screen width come apart into several
+      // with names of their own -- and the fix is to pick from the names that do exist.
+      const near = await page.evaluate(({ band }) => {
+        const out = [];
+        for (const a of document.querySelectorAll('map[name^="map_"] area')) {
+          const c = (a.getAttribute('coords') || '').split(',').map(Number);
+          if (c.length < 4) continue;
+          const m = a.closest('map'), nm = m && m.getAttribute('name');
+          const im = nm && document.querySelector(`img[usemap="#${nm}"]`);
+          if (!im) continue;
+          const r = im.getBoundingClientRect();
+          const s = im.naturalWidth ? r.width / im.naturalWidth : 1;
+          const cy = r.top + s * (c[1] + c[3]) / 2;
+          if (cy < band.top - 1 || cy > band.bot + 1) continue;
+          const i = (a.getAttribute('href') || '').match(/[?&]i=([^&]+)/);
+          if (i) out.push(decodeURIComponent(i[1]));
+        }
+        return out;
+      }, { band }).catch(() => []);
+      const win = await page.evaluate(() => {
+        try { return `${hgTracks.chromName}:${hgTracks.winStart}-${hgTracks.winEnd}`; }
+        catch (_) { return '?'; }
+      }).catch(() => '?');
+      const show = process.env.DOCENT_ROWS ? near : near.slice(0, 12);
+      throw new Error(`item "${want}" not found in track "${t}" (searched map-box areas + `
+        + `mouseOver spans). Window ${win}.${near.length ? ` In that row: ${show.join(', ')}`
+          + `${show.length < near.length ? `, ... (${near.length} total; DOCENT_ROWS=1 for all)` : ''}`
+          : ''}`);
+    }
+    return { x: img.x + imgPx * (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null };
   }
   // POSITIONAL point: at:/frac:/x: -> x, y forced to the track row's middle. The grey
   // side-label strip (insideX) is baked into the image's left, so a fraction/coord maps
   // across [img.x+insideX, img.x+img.width], not the whole image width.
   async function posXY(t, o) {
-    const { img, row } = await trackBox(t);
-    const insideX = await page.evaluate(() => { try { return hgTracks.insideX || 0; } catch (_) { return 0; } });
+    const { img, row, imgPx } = await trackBox(t);
+    const insideX = imgPx * await page.evaluate(() => { try { return hgTracks.insideX || 0; } catch (_) { return 0; } });
     let x;
-    if (o.x != null) x = img.x + insideX + Number(o.x);
+    if (o.x != null) x = img.x + insideX + imgPx * Number(o.x);
     else {
       const frac = (o.frac != null) ? Number(o.frac)
         : (o.at != null) ? await page.evaluate(at => {
             try { const s = hgTracks.winStart, e = hgTracks.winEnd;
               const c = +String(at).replace(/.*:/, '').replace(/,/g, '');
               return Math.max(0, Math.min(1, (c - s) / (e - s))); } catch (_) { return 0.5; }
           }, o.at)
         : 0.5;
       x = img.x + insideX + frac * (img.width - insideX);
     }
     return { x, y: row.y + row.height / 2 };
   }
   // Hover an item to raise its mouseover tooltip (real mousemove -> the browser's own
   // tooltip). Two ways to place the cursor:
   //   IDENTITY  `item:` / `title:` / `value:` -> name the item (lands on the right ROW).
@@ -707,31 +804,32 @@
     const t = await page.evaluate(({ x, y }) => {
       const c = document.getElementById('mouseoverContainer');
       if (!c || !c.offsetWidth) return null;
       const im = document.getElementById('imgTbl');
       const ir = im ? im.getBoundingClientRect() : { left: 0, top: 0 };
       return { dx: x - ir.left + 8, dy: y - ir.top + 8, html: c.outerHTML };
     }, { x, y });
     if (t) pinnedTips.push(t);
   }
   // Render every recorded tooltip open at once in a still, WITHOUT touching the recorded
   // page (so the mp4 is unaffected): reload the current view on a throwaway page that
   // shares the session cookie (cart), inject the recorded tooltips, screenshot, discard.
   async function pinShot(name) {
     if (!pinnedTips.length) { console.warn(`pinShot ${name}: no pinned mouseovers recorded (set pin: true / pinMouseovers: true)`); return; }
     const url = page.url();
-    const ctx2 = await browser.newContext({ viewport: { width: VW, height: VH }, deviceScaleFactor: 1 });
+    const ctx2 = await browser.newContext({ viewport: { width: VW, height: VH }, deviceScaleFactor: SCALE });
+    if (SCALE > 1) await ctx2.addInitScript(SCALE_INIT, SCALE);
     await ctx2.addCookies(await ctx.cookies());
     const pg2 = await ctx2.newPage();
     await pg2.goto(url, { waitUntil: 'load' });
     await pg2.waitForSelector('#imgTbl', { timeout: 8000 }).catch(() => {});
     await pg2.evaluate((tips) => {
       window.scrollTo(0, 0);
       const im = document.getElementById('imgTbl');
       const ir = im ? im.getBoundingClientRect() : { left: 0, top: 0 };
       for (const t of tips) {
         const wrap = document.createElement('div');
         wrap.innerHTML = t.html;
         const el = wrap.firstElementChild; if (!el) continue;
         el.removeAttribute('id');
         el.classList.add('__pinnedTip');
         el.style.position = 'fixed';
@@ -775,34 +873,40 @@
       const m = String(o.range).match(/^\s*(.+):([\d,]+)\s*-\s*([\d,]+)\s*$/);
       if (!m) throw new Error(`drag: range "${o.range}" is not chrom:start-end`);
       o = Object.assign({}, o, { from: `${m[1]}:${m[2]}`, to: `${m[1]}:${m[3]}` });
     }
     const img = await page.locator('img[id^="img_data_"]').first().boundingBox({ timeout: 8000 }).catch(() => null);
     const tbl = await page.locator('#imgTbl').first().boundingBox({ timeout: 8000 }).catch(() => null);
     if (!img || !tbl) throw new Error('drag: track image not shown (need #imgTbl)');
     const coordFrac = at => page.evaluate(a => {
       try { const s = hgTracks.winStart, e = hgTracks.winEnd;
         const c = +String(a).replace(/.*:/, '').replace(/,/g, '');
         return Math.max(0, Math.min(1, (c - s) / (e - s))); } catch (_) { return null; }
     }, at);
     // The grey side-label strip is baked into the LEFT of every full-width track
     // image, so the genomic data area starts insideX px in — fractions/coords map
     // across [img.x+insideX, img.x+img.width], not the whole image width.
-    const insideX = await page.evaluate(() => { try { return hgTracks.insideX || 0; } catch (_) { return 0; } });
+    // insideX and any px: endpoint are in the drawn image's pixels; imgPx converts them to
+    // page pixels (1 normally, 1/SCALE when the image is scaled for print).
+    const imgPx = await page.evaluate(() => {
+      const im = document.querySelector('img[id^="img_data_"]');
+      return im && im.naturalWidth ? im.getBoundingClientRect().width / im.naturalWidth : 1;
+    }) || 1;
+    const insideX = imgPx * await page.evaluate(() => { try { return hgTracks.insideX || 0; } catch (_) { return 0; } });
     const dataLeft = img.x + insideX, dataW = Math.max(1, img.width - insideX);
     const endX = async (px, fr, coord) => {
-      if (px != null) return img.x + Number(px);
+      if (px != null) return img.x + imgPx * Number(px);
       const f = (fr != null) ? Number(fr) : (coord != null ? await coordFrac(coord) : null);
       if (f == null) throw new Error('drag: need endpoints as coord (from:/to:), frac (fromFrac:/toFrac:) or px (fromX:/toX:)');
       return dataLeft + f * dataW;
     };
     const x1 = await endX(o.fromX, o.fromFrac, o.from);
     const x2 = await endX(o.toX, o.toFrac, o.to);
     // y band for the drawn selection box. Default: span the FULL track image (like a
     // real shift+drag, which highlights every track top-to-bottom). A named `track:`
     // narrows the band to that one row instead. The CURSOR, however, sweeps near the
     // TOP of the image (over the ruler) — a real drag is horizontal and the highlight
     // fills downward on its own; sending the cursor to the vertical center would make
     // it dive through the tracks first.
     let y = tbl.y + Math.min(90, tbl.height / 2);
     let y1 = 0, y2 = tbl.height;
     if (o.track) {
@@ -986,31 +1090,31 @@
     await clickGlide(sel);
     await page.waitForSelector('#imgTbl').catch(() => {});
     await captureState();
     return true;
   }
 
   function norm(step) {
     if (typeof step === 'string') { const [v, ...r] = step.trim().split(/\s+/); return { verb: v, arg: r.length ? r.join(' ') : true }; }
     const k = Object.keys(step)[0]; return { verb: k, arg: step[k] };
   }
   async function run({ verb, arg }) {
     switch (verb) {
       case 'gateway': await nav(`/cgi-bin/hgGateway?db=${state.db}`); break;
       case 'go':
         if (arg === true || arg === '' || arg == null) { await clickGlide('.jwGoButtonContainer'); await page.waitForSelector('#imgTbl'); }
-        else { await nav(`/cgi-bin/hgTracks?db=${state.db}&position=${enc(arg)}&pix=${PIX}`); }
+        else { await nav(`/cgi-bin/hgTracks?db=${state.db}&position=${enc(arg)}&${IMGVARS}`); }
         await captureState(); break;
       case 'goShow': {
         // DEMONSTRATE the position change through the UI (vs. `go:` which navs straight to
         // the new position): glide to the position box, clear it, type on screen, then let
         // the page take it from there -- "Search" (#goButton) on hgTracks, the arrow
         // (.jwGoButtonContainer) on hgGateway, so one verb covers either page.
         //
         // Takes a POSITION or a GENE NAME (or any search term the box accepts: HGVS, an
         // accession, ...). A gene name goes through the browser's own suggestion menu, the
         // way a user does it: wait for the menu, then click the matching item -- hgTracks'
         // handler sets the position from that item and submits, so we land on the gene
         // instead of the search-results page. `pick:` chooses among the suggestions when
         // the term is ambiguous (substring of the menu row); default is an exact symbol
         // match, else the first row.
         //
@@ -1123,46 +1227,46 @@
             k = n.parent;
           }
           if (!rounds.has(d)) rounds.set(d, []);
           rounds.get(d).push(e);
         }
         if (kidHides.length) rounds.set(Number.MAX_SAFE_INTEGER, kidHides);
         for (const d of [...rounds.keys()].sort((a, b) => a - b)) {
           const vars = new Map();
           for (const [name, mode] of rounds.get(d))
             // A derived variable never overrides one the step names itself, whatever the
             // order: `{clinvar: pack, clinvarCnv: hide}` keeps clinvar=pack.
             for (const [k, v] of await visVars(name, mode))
               if (k === name || !named.has(k)) vars.set(k, v);
           const parts = [...vars].map(([k, v]) => `${k}=${v}`);
           console.log('track:', parts.join(' '));   // what trackDb turned the step into
-          await nav(`/cgi-bin/hgTracks?db=${state.db}&position=${enc(state.position)}&${parts.join('&')}&pix=${PIX}`);
+          await nav(`/cgi-bin/hgTracks?db=${state.db}&position=${enc(state.position)}&${parts.join('&')}&${IMGVARS}`);
         }
         break;
       }
       case 'convert': await convert(arg); break;
       case 'hub': {
         // Attach a track hub by URL: hgTracks?hubUrl=... connects the hub and makes its
         // tracks available at their hub-declared visibility. Follow with `track:` to turn
         // specific ones on. Accepts a bare URL or {url:, db:, position:}.
         const o = (typeof arg === 'string') ? { url: arg } : (arg || {});
         if (!o.url) { console.warn('hub: no url given'); break; }
         const db = o.db || state.db;
         const pos = o.position != null ? o.position : state.position;
         const parts = [`db=${db}`, `hubUrl=${enc(o.url)}`];
         if (pos) parts.push(`position=${enc(pos)}`);
-        parts.push(`pix=${PIX}`);
+        parts.push(IMGVARS);
         await nav(`/cgi-bin/hgTracks?${parts.join('&')}`);
         await page.waitForSelector('#imgTbl').catch(() => {});
         break;
       }
       case 'addHub': {
         // DEMONSTRATE attaching a hub through the UI (vs. `hub:` which just navs):
         // My Data -> Track Hubs (hgHubConnect), the Connected Hubs tab, paste the URL,
         // click Add Hub. The cursor glides and the URL is typed visibly. Accepts a bare
         // URL or {url:, db:, shot:}.
         const o = (typeof arg === 'string') ? { url: arg } : (arg || {});
         if (!o.url) { console.warn('addHub: no url given'); break; }
         const db = o.db || state.db;
         await nav(`/cgi-bin/hgHubConnect?db=${db}`);
         await clickGlide('a[href="#unlistedHubs"]');       // Connected Hubs tab reveals the URL box
         await page.waitForSelector('#hubUrl', { state: 'visible', timeout: 8000 });
@@ -1241,31 +1345,48 @@
         // The manage page appears on success; click through to the browser (a data error
         // re-shows the add page instead, so guard on the button being present).
         const goSel = (o.goto === 'current') ? '#submitGoBack' : '#submit';
         if (await page.locator(goSel).count()) {
           await clickGlide(goSel);
           await page.waitForSelector('#imgTbl').catch(() => {});
           await captureState();
         } else {
           console.warn('addCustomTrack: submit did not reach the manage page (data error?)');
         }
         if (o.shot) { await shot(o.shot); return; }
         break;
       }
       case 'drag': await drag(arg); break;
       case 'open': if (arg === 'lift') { await clickGlide('main a[href*="hgTracks"]'); await page.waitForSelector('#imgTbl'); await captureState(); } break;
-      case 'zoom': { const btn = (arg === 'in') ? '#hgt\\.in2' : '#hgt\\.out2'; await clickGlide(btn); await page.waitForSelector('#imgTbl'); break; }
+      case 'zoom': {
+        const btn = (arg === 'in') ? '#hgt\\.in2' : '#hgt\\.out2';
+        const was = await page.evaluate(() => {
+          try { return `${hgTracks.winStart}-${hgTracks.winEnd}`; } catch (_) { return ''; }
+        });
+        await clickGlide(btn);
+        await page.waitForSelector('#imgTbl');
+        // The zoom buttons redraw the image in place (ajax), so #imgTbl never went away and
+        // waiting for it proves nothing: the next step can read the OLD view's map boxes and
+        // report an item "not found" that simply is not in view yet. Wait for the window to
+        // change instead. Until FAST there was always a dwell here hiding this.
+        if (was) await page.waitForFunction(
+            w => { try { return `${hgTracks.winStart}-${hgTracks.winEnd}` !== w; } catch (_) { return false; } },
+            was, { timeout: 15000 })
+          .catch(() => console.warn(`zoom ${arg}: window still ${was} after 15s`));
+        await captureState();
+        break;
+      }
       case 'shot': await shot(arg); return;                       // shot supplies its own dwell
       case 'pinShot': await pinShot(arg); break;                  // combined figure, off the mp4 timeline
       case 'mouseover': await mouseover(arg); return;             // supplies its own dwell (o.hold)
       // escape hatches
       case 'goto': await nav(arg); break;
       case 'click':
         if (arg && typeof arg === 'object' && arg.track) {
           // Click a NAMED track item -> follow its map-box link (e.g. the hgc detail page).
           const it = await itemXY(arg.track, arg.item ?? arg.title ?? arg.value,
                                   arg.title != null && arg.item == null && arg.value == null);
           await glide(it.x, it.y); await sleep(200);
           // A raw click on the data area is swallowed by hgTracks' drag-select handler, so
           // follow the item's own map-box link (the hgc detail page) directly.
           if (it.href) await nav(it.href);
           else { await page.mouse.click(it.x, it.y); await page.waitForLoadState('load').catch(() => {}); await captureState(); }
@@ -1277,30 +1398,33 @@
           // (a no-op if the click didn't navigate).
           await page.evaluate(s => document.querySelectorAll(s).forEach(e => e.removeAttribute('target')), arg).catch(() => {});
           await clickGlide(arg);
           await page.waitForLoadState('load').catch(() => {});
           await captureState();
         }
         break;
       case 'hover': await glideTo(arg); await page.hover(arg); break;
       case 'wait': await page.waitForSelector(arg, { timeout: 15000 }); break;
       case 'sleep': await sleep(Number(arg)); return;
       default: console.warn('unknown verb:', verb);
     }
     await sleep(PACE);
   }
 
+  if (SCALE > 1)
+    console.log(`scale: ${SCALE}x -- pix=${PIX}, textSize=${TEXTSIZE}, dpr=${SCALE} at ${VW}x${VH}, `
+                + `stills only (no mp4) -> ${STILLDIR}`);
   if (doc.reset) await page.goto(absurl('/cgi-bin/cartReset?skipLs=1'), { waitUntil: 'domcontentloaded' });
   const steps = doc.steps || [];
   const timing = [];
   for (let i = 0; i < steps.length; i++) {
     const s = norm(steps[i]);
     const t0 = Date.now();
     try { await run(s); }
     catch (e) { console.error(`step ${i + 1} (${s.verb}) failed:`, e.message); await ctx.close(); await browser.close(); process.exit(1); }
     timing.push({ n: i + 1, verb: s.verb, ms: Date.now() - t0 });
   }
 
   await page.waitForTimeout(300);
   await ctx.close();
   await browser.close();