9cdd7a15f40c41874620a7d2bc907d77489f7746
braney
  Sat Sep 26 17:43:32 2026 -0700
docent: setVis:, rightClick:, and drag: then: none / dialog: auto, refs #37892, #38035, #38071, #38087

setVis: sets a track's visibility with its own dropdown under the image. The change
event fires and the page's handler runs, with no navigation. track: asks for the mode in
a URL, so it cannot see what that handler does to the page.

rightClick: opens the track right-click menu and picks an entry by its text. An exact
match wins over a substring. A miss fails the run and lists the entries on offer.

drag: then: none leaves the drag-select dialog open for later click: steps. dialog: auto
hands the selection to dragSelect.selectEnd(), so the page decides between the dialog
and a straight zoom instead of the verb forcing the dialog on.

A track name ending in * now matches a row id by prefix, for custom tracks. shot: also
captures an open right-click menu.

diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index bece0ad1c9f..7659a4ba4ed 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -685,61 +685,68 @@
   async function shot(name) {
     const p = path.join(STILLDIR, name + '.png');
     // Drop any click ripple still fading: it reads as a red blob over whatever was just
     // clicked. It belongs to the video, not to a figure.
     await page.evaluate(() => document.querySelectorAll('.__ripple').forEach(e => e.remove())).catch(() => {});
     // If a mouseover tooltip is currently up, capture the image + tooltip together
     // (the tooltip is appended to <body>, so an #imgTbl element shot would clip it).
     const clip = await page.evaluate(() => {
       const im = document.getElementById('imgTbl');
       if (!im) return null;
       // Floating overlays to capture together with the image: the mouseover tooltip,
       // and any visible jQuery-UI dialog (e.g. the drag-select "Zoom In / Highlight" box).
       const overlays = [];
       const tip = window.__docentTip && window.__docentTip();
       if (tip) overlays.push(tip);
-      for (const d of document.querySelectorAll('.ui-dialog')) if (d.offsetWidth > 0) overlays.push(d);
+      for (const d of document.querySelectorAll('.ui-dialog, .context-menu')) if (d.offsetWidth > 0) overlays.push(d);
       if (!overlays.length) return null;
       const a = im.getBoundingClientRect();
       let x = a.left, y = a.top, x2 = a.right, y2 = a.bottom;
       for (const o of overlays) { const r = o.getBoundingClientRect(); x = Math.min(x, r.left); y = Math.min(y, r.top); x2 = Math.max(x2, r.right); y2 = Math.max(y2, r.bottom); }
       return { x: Math.max(0, x - 4), y: Math.max(0, y - 4), width: (x2 - x) + 8, height: (y2 - y) + 8 };
     });
     if (clip) {
       await page.screenshot({ path: p, clip });
     } else if (await page.locator('#imgTbl').count()) {
       await page.locator('#imgTbl').screenshot({ path: p });
     } else {
       // Not a tracks page (hgc detail page, an external page a link led to, ...). An element
       // shot of <main> would run the whole scrolling page -- thousands of pixels tall and
       // unusable as a figure. Capture the viewport only, i.e. the top of the page.
       await page.screenshot({ path: p });
     }
     shotSecs.push((Date.now() - T_REC) / 1000);
     console.log('SHOT', p);
     await sleep(SHOTHOLD);
   }
   // Resolve a track name to its DOM key + data-image and row bounding boxes. On a
   // quickLift/Convert target the tracks come from a hub, so ids gain a dynamic
   // `hub_<n>_` prefix -- match by suffix so the YAML can just say `track: quickLiftChain`.
   // A container draws nothing itself, so if the name given is one, fall through to the
   // leaves trackDb lists under it and take the first that is actually on the page.
   async function trackBox(t) {
     const cands = [t, ...await tdbLeaves(t)];
     let key = null;
     for (const c of cands) {
       key = await page.evaluate(k => {
+        // A trailing * matches by prefix, for a custom track, whose id carries a random
+        // number no script can know in advance (ct_rm38087_723 is ct_rm38087_*).
+        if (k.endsWith('*')) {
+          const pre = [...document.querySelectorAll('[id^="img_data_"]')]
+            .find(e => e.id.startsWith('img_data_' + k.slice(0, -1)));
+          return pre ? pre.id.replace('img_data_', '') : null;
+        }
         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})`);
     // 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.
@@ -1767,31 +1774,37 @@
     }
     const msg = bad.join('; ') + `\n  drawn: ${seen.rows.join(', ') || '(none)'}`;
     if (o.warn) console.warn('EXPECT (warning only):', msg);
     else throw new Error(msg);
   }
   // Shift+drag across the track image to open the browser's own drag-select dialog
   // ("Zoom In / Single Highlight / ..."), then act on it. The usual form gives one
   // genomic region and zooms:  drag: chr7:155,806,100-155,806,557
   // Any other action needs the map form, which is also how you pass shot:/track:
   //   drag: {range: "chr7:155,806,100-155,806,557", then: highlight}
   // Endpoints that are not genomic coords use a fraction
   // across the view (fromFrac:/toFrac:) or a raw pixel (fromX:/toX:) instead.
   // Optional `track:` picks the row the drag runs over (y); default is the middle of
   // the image. `shot:` captures the open dialog (e.g. the Figure 1A drag-select box).
   // `then:` = zoom (default, clicks Zoom In) | highlight (Single Highlight) | cancel
-  // (Escape, leaves the view unchanged).
+  // (Escape, leaves the view unchanged) | none (leaves the dialog OPEN, so later steps can
+  // tick its checkbox or press one of its other buttons with click:).
+  // `dialog: auto` stops the verb forcing the dialog on. By default it arms
+  // hgTracks.enableHighlightingDialog and opens the dialog itself, so a figure always gets
+  // one. With auto the selection is handed to the page's own dragSelect.selectEnd(), which
+  // opens the dialog only if the user has not switched it off and otherwise zooms straight
+  // away, so a script can see what the "Don't show this again" checkbox did (#38071).
   async function drag(o) {
     // A bare string is the region; `range:` is the same thing with room for other
     // keys. Both expand to the from:/to: endpoints the rest of this function uses.
     if (typeof o === 'string') o = { range: o };
     o = o || {};
     if (o.range != null) {
       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;
@@ -1832,65 +1845,103 @@
     // The selected genomic range: from coord endpoints we already have it; otherwise
     // derive it from the view fractions below (inside the browser).
     const coordNum = v => +String(v).replace(/.*:/, '').replace(/,/g, '');
     let posStr = null;
     if (typeof o.from === 'string' && o.from.includes(':') && o.to != null) {
       const chrom = o.from.split(':')[0], a = coordNum(o.from), b = coordNum(o.to);
       posStr = `${chrom}:${Math.min(a, b)}-${Math.max(a, b)}`;
     }
     // Visible cursor sweep across the selection (no button-down — a real drag would
     // just PAN the image). The drag-select band is grown UNDER the cursor as it moves,
     // so it reads like a genuine shift+drag rather than popping in at the end; the
     // dialog is then raised via the browser's own dragSelect entry point, the same way
     // highlight_shot.js does it.
     await glide(x1, y); await sleep(200);
     // Arm the highlighting dialog and open a zero-width selection at the start point.
-    await page.evaluate(({ ix1, y1, y2 }) => {
+    const auto = o.dialog === 'auto';
+    if (o.dialog != null && !auto) throw new Error(`drag: dialog: takes only "auto", not "${o.dialog}"`);
+    await page.evaluate(({ ix1, y1, y2, auto }) => {
       try {
-        hgTracks.enableHighlightingDialog = true;
+        if (!auto) hgTracks.enableHighlightingDialog = true;
         dragSelect.startTime = Date.now();
         $(imageV2.imgTbl).imgAreaSelect({ x1: ix1, y1, x2: ix1, y2, show: true });
       } catch (_) {}
-    }, { ix1: x1 - tbl.x, y1, y2 });
+    }, { ix1: x1 - tbl.x, y1, y2, auto });
     // Sweep to the end, widening the band to the cursor's x at every step.
     const dsteps = Math.max(10, Math.round(Math.abs(x2 - x1) / 9));
     for (let i = 1; i <= dsteps; i++) {
       const px = x1 + (x2 - x1) * i / dsteps;
       await page.mouse.move(px, y);
       await page.evaluate(({ ix1, ix2, y1, y2 }) => {
         try { $(imageV2.imgTbl).imgAreaSelect({ x1: Math.min(ix1, ix2), y1, x2: Math.max(ix1, ix2), y2, show: true }); } catch (_) {}
       }, { ix1: x1 - tbl.x, ix2: px - tbl.x, y1, y2 });
       await sleep(15);
     }
     cur.x = x2; cur.y = y;
     await sleep(200);
+    if (auto) {
+      // Let the page decide: the same call imgAreaSelect makes when the mouse is released,
+      // with an event that carries no modifier keys. It either opens the dialog or zooms.
+      const before = await page.evaluate(() => { try { return hgTracks.winStart + '-' + hgTracks.winEnd; } catch (e) { return ''; } });
+      const was = page.url();
+      const r = await page.evaluate(({ ix1, ix2, y1, y2, ey }) => {
+        try {
+          const tb = imageV2.imgTbl[0], ofs = jQuery(tb).offset();
+          const ev = { altKey: false, metaKey: false, ctrlKey: false, shiftKey: true,
+                       pageX: ofs.left + ix2, pageY: ofs.top + ey };
+          const selection = { x1: Math.min(ix1, ix2), x2: Math.max(ix1, ix2), y1, y2,
+                              width: Math.abs(ix2 - ix1), height: y2 - y1, event: ev };
+          dragSelect.selectEnd(tb, selection, ev);
+          return true;
+        } catch (e) { return String((e && e.message) || e); }
+      }, { ix1: x1 - tbl.x, ix2: x2 - tbl.x, y1, y2, ey: y - tbl.y });
+      if (r !== true) throw new Error('drag: dragSelect.selectEnd failed (' + r + ')');
+      const how = await Promise.race([
+        page.waitForSelector('#dragSelectDialog:visible', { timeout: 10000 }).then(() => 'dialog'),
+        page.waitForFunction(prev => { try { return (hgTracks.winStart + '-' + hgTracks.winEnd) !== prev; } catch (e) { return false; } },
+                             before, { timeout: 10000 }).then(() => 'zoom'),
+        page.waitForFunction(u => location.href !== u, was, { timeout: 10000 }).then(() => 'zoom'),
+      ]).catch(() => null);
+      if (!how) throw new Error('drag: dialog: auto -- neither the dialog nor a zoom followed the selection');
+      if (how === 'zoom') {
+        console.log('drag: no dialog, the page zoomed straight away');
+        await page.waitForSelector('#imgTbl'); await sleep(400);
+        await captureState();
+        return;
+      }
+      console.log('drag: the page opened the drag-select dialog');
+    } else {
     // Band is fully drawn — now raise the Drag-and-select dialog.
     const res = await page.evaluate(({ f1, f2, posStr }) => {
       try {
         let pos = posStr;
         if (!pos) { const s = hgTracks.winStart, e = hgTracks.winEnd;
           const c1 = Math.round(s + (e - s) * f1), c2 = Math.round(s + (e - s) * f2);
           pos = hgTracks.chromName + ':' + (Math.min(c1, c2) + 1) + '-' + Math.max(c1, c2); }
         dragSelect.selectionEndDialog(pos);
         return true;
       } catch (e) { return String((e && e.message) || e); }
     }, { f1: (x1 - dataLeft) / dataW, f2: (x2 - dataLeft) / dataW, posStr });
     const up = await page.waitForSelector('#dragSelectDialog:visible', { timeout: 4000 }).then(() => true).catch(() => false);
     if (!up) throw new Error('drag: drag-select dialog did not open' + (res === true ? '' : ' (' + res + ')'));
+    }
     await sleep(400);
     if (o.shot) await shot(o.shot);
     const act = o.then || 'zoom';
+    if (!['zoom', 'highlight', 'cancel', 'none'].includes(act))
+      throw new Error(`drag: then: takes zoom, highlight, cancel or none, not "${act}"`);
+    if (act === 'none') return;
     if (act === 'cancel') { await page.keyboard.press('Escape'); }
     else {
       const label = (act === 'highlight') ? 'Single Highlight' : 'Zoom In';
       const sel = `.ui-dialog-buttonset button:has-text("${label}")`;
       // "Zoom In" either full-submits the form OR does an AJAX in-place update
       // (imageV2.inPlaceUpdate) that never fires a page load -- so don't wait on load;
       // wait for the live hgTracks window to actually change from its pre-click value.
       const before = await page.evaluate(() => { try { return hgTracks.winStart + '-' + hgTracks.winEnd; } catch (e) { return ''; } });
       await clickGlide(sel);
       if (act !== 'highlight') {
         await page.waitForFunction(prev => {
           try { return (hgTracks.winStart + '-' + hgTracks.winEnd) !== prev; } catch (e) { return false; }
         }, before, { timeout: 8000 }).catch(() => {});
         await page.waitForSelector('#imgTbl'); await sleep(400);
       }
@@ -2497,30 +2548,109 @@
             await page.waitForSelector('#imgTbl', { timeout: 20000 }).catch(() => {});
             await captureState();
           }
           if (arg.shot) { await shot(arg.shot); return; }
         } else {
           // Plain selector click. Strip target=_blank first so an external link (e.g. a
           // dbSNP id -> NIH) navigates in THIS tab instead of a popup we can't screenshot,
           // then wait out the navigation so a following shot captures the destination page
           // (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 'rightClick': {
+        // Open hgTracks' right-click menu over a track and, with pick:, choose an entry by a
+        // substring of its text. Addressed like click: -- item:/title: by identity, or
+        // at:/frac:/x: for a bare point on the row. The menu is the only way in to what it
+        // offers (Delete Custom Track, Hide all other tracks, Move to top ...), and each
+        // entry runs in place, so the verb waits out the page's ajax rather than a load.
+        // With no pick: the menu is left open, for a shot: of it or an expect: on its text.
+        if (!arg || typeof arg !== 'object' || !arg.track)
+          throw new Error('rightClick: takes {track: <name>, item:|title:|at:|frac:|x:, pick: <menu text>}');
+        const named = arg.item ?? arg.title;
+        const it = (named != null)
+          ? await itemXY(arg.track, named, arg.title != null && arg.item == null, false)
+          : await posXY(arg.track, arg);
+        await glide(it.x, it.y); await sleep(200);
+        await page.mouse.click(it.x, it.y, { button: 'right' });
+        const menuUp = await page.waitForSelector('.context-menu:visible', { timeout: 8000 }).then(() => true).catch(() => false);
+        if (!menuUp) throw new Error(`rightClick: no menu came up over ${arg.track}`);
+        await sleep(300);
+        if (arg.pick == null) {
+          if (arg.shot) { await shot(arg.shot); return; }
+          break;
+        }
+        const offered = await page.$$eval('.context-menu-item', els => els
+          .filter(e => e.offsetWidth > 0).map(e => (e.innerText || e.textContent || '').trim()));
+        // An exact entry wins over a substring, so pick: Hide is the visibility entry and
+        // not "Hide all other tracks".
+        const want = String(arg.pick).trim().toLowerCase();
+        let i = offered.findIndex(t => t.toLowerCase() === want);
+        if (i < 0) i = offered.findIndex(t => t.toLowerCase().includes(want));
+        if (i < 0) throw new Error(`rightClick: no menu entry "${arg.pick}"; the menu offered: ${offered.join(' | ')}`);
+        console.log('rightClick:', arg.track, '->', offered[i]);
+        if (arg.shot) await shot(arg.shot);
+        const entry = page.locator('.context-menu-item:visible').nth(i);
+        const box = await entry.boundingBox();
+        if (box) { await glide(box.x + Math.min(40, box.width / 2), box.y + box.height / 2); await sleep(200); }
+        const was = page.url();
+        await entry.click();
+        // An entry answers with an in-place redraw, an hgc/hgTrackUi dialog, or a full
+        // navigation (View image, Configure). Wait for the page to go quiet either way.
+        await page.waitForFunction(u => location.href !== u || !window.jQuery || jQuery.active === 0,
+                                   was, { timeout: 20000 }).catch(() => {});
+        await page.waitForLoadState('load').catch(() => {});
+        await page.waitForSelector('#imgTbl', { timeout: 20000 }).catch(() => {});
+        await sleep(300);
+        await captureState();
+        break;
+      }
+      case 'setVis': {
+        // Set a track's visibility with its own dropdown under the image, IN PLACE: the
+        // select changes and its change event fires, and the page's handler does the rest,
+        // with no navigation. `track:` is the other half: it asks for the mode in a URL and
+        // draws a fresh page, so it can never see what the dropdown's handler does to the
+        // page it is on -- the Visible Tracks list keeping a hidden track (#38035), say.
+        // {<track>: <mode>, ...} in order; `in: visible` uses the copy in the Visible Tracks
+        // group, `in: group` the one in the track's own group (a visible track has both).
+        if (!arg || typeof arg !== 'object') throw new Error('setVis: takes {<track>: <mode>}');
+        const where = arg.in;
+        if (where != null && where !== 'visible' && where !== 'group')
+          throw new Error(`setVis: in: takes visible or group, not "${where}"`);
+        for (const [name, mode] of Object.entries(arg)) {
+          if (name === 'in' || name === 'shot') continue;
+          const own = `select[name="${name}"]`;
+          const sel = (where === 'visible') ? `[id^="visible-"] ${own}`
+                    : (where === 'group') ? `${own}:not([id^="visible-"] ${own})` : own;
+          const opts = await page.locator(sel).first()
+            .evaluate(el => [...el.options].map(o => o.value)).catch(() => null);
+          if (!opts) throw new Error(`setVis: no visibility dropdown for ${name}` +
+                                     (where ? ` in the ${where === 'visible' ? 'Visible Tracks' : 'its own'} group` : '') + ' on this page');
+          if (!opts.includes(String(mode))) throw new Error(`setVis: ${name} offers ${opts.join('/')}, not ${mode}`);
+          console.log('setVis:', `${name}=${mode}`, where ? `(${where})` : '');
+          await openSelectVisible(sel, String(mode), 6, true);
+        }
+        // The handler posts the new value to the cart and may redraw; let that finish.
+        await page.waitForFunction(() => !window.jQuery || jQuery.active === 0, null, { timeout: 20000 }).catch(() => {});
+        await sleep(300);
+        await captureState();
+        if (arg.shot) { await shot(arg.shot); return; }
+        break;
+      }
       case 'hover': await glideTo(arg); await page.hover(arg); break;
       case 'fill': {
         // Type into an arbitrary form field: {<selector>: <text>}, one or more pairs, in order.
         // The named verbs type into the boxes they own (position, hub URL, hub search); this is
         // for any other box, such as the session name on the Sessions page save card (#38311).
         // The field is emptied first, so a value left from an earlier step cannot run into it.
         if (!arg || typeof arg !== 'object') throw new Error('fill: takes {<selector>: <text>}');
         for (const [sel, text] of Object.entries(arg)) {
           await glideTo(sel);
           await page.fill(sel, '');
           await typeIn(page, sel, text);
         }
         break;
       }
       case 'wait': {