790667d0f7ee6965244ed31318aa9459e82fda73
braney
  Mon Aug 3 16:50:48 2026 -0700
Docent: add hideKids, and make a named mouseover wait for that item's own tooltip.

hideKids is a container "visibility" meaning hide everything under it, so a child
named alongside it is left the only one drawn. A superTrack needs it: unlike a
composite, its own mode does not reach its children, so each comes up at its own
trackDb visibility and {varsInPubs: show} draws all eight of its members however
much an earlier "hide: all" hid. The expansion skips any child the step names
itself and is sent in a round of its own AFTER the rest, since a subtrack hide
travelling in the same request as its container can be dropped by the cart
(#37953). It works on a composite or view too, where it deselects (_sel=0).

The mouseover fix: tooltips are mouseenter-driven with a 500ms show delay and a
500ms hide grace (hg/js/utils.js addMouseover), so while the cursor glides in it
crosses other items and one of THEIR tooltips is often still on screen when it
arrives. Waiting for "some tooltip is visible" therefore recorded a neighbour's
text -- an Alignment Differences mismatch pinned as the adjacent aligned block's
"identical". That only bit pin:, because the dwell which follows let the right
tooltip replace the wrong one before any shot: -- so a scenario's figures and its
mp4 disagreed with each other, and FAST (a single cursor jump, never entering the
neighbour) read correctly while the full run did not. A named mouseover now waits
for the item's own text, taken from its map box and rendered the way the tooltip
renders it (innerHTML then textContent: the attribute holds markup and undecoded
entities such as ★). Comparison is whitespace-insensitive on a distinctive
prefix. A positional hover has no expected text, so it settles instead.

DOCENT_ROWS=1 now also reports mouseovers -- which map boxes matched the name,
where the cursor went, the tooltip expected from the box and the one that came
up -- and warns when the item's own text never appears. Verified over all ten
named-item mouseovers in the Current Protocols quickLift scenarios: no warnings,
every tooltip correct.

refs #37892

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 a2fd5ba8914..f4402c92bc3 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -516,47 +516,63 @@
     if (!img || !row) throw new Error(`track "${t}" not shown (need #img_data_${key} + #tr_${key})`);
     return { key, img, row };
   }
   // 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 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') || '';
+        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;
-        cands.push({ cx, cy, href, inBand: cy >= band.top - 1 && cy <= band.bot + 1 });
+        // 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);
-      return (inBand[0] || cands[0]) || null;
+      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) return { x: area.cx, y: area.cy, href: area.href };
+    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 };
   }
   // 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.
@@ -574,64 +590,116 @@
           }, 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).
   //   POSITION  `at:` (genomic coord) / `frac:` (0..1) / `x:` (raw px) -> a point.
   async function mouseover(o) {
     if (typeof o === 'string') o = { track: o };
     o = o || {};
     const t = o.track; if (!t) throw new Error('mouseover: needs a track');
     const want = o.item ?? o.title ?? o.value;         // identity mode if any is set
-    const { x, y } = (want != null)
+    const spot = (want != null)
       ? await itemXY(t, want, o.title != null && o.item == null && o.value == null)
       : await posXY(t, o);
-    // Raise a FRESH tooltip for THIS item. Two waits, both on the tooltip's actual state
-    // rather than on a duration: park over the grey side-label strip (no items there) until
-    // the previous tooltip is GONE, then hover the item until a tooltip is up whose content
-    // differs from the one we just dismissed. Sleeping instead only looks right -- with the
-    // dwells trimmed (FAST) a back-to-back pinned mouseover captured the PREVIOUS item's
-    // text, and even at full pace that was a race waiting to be lost.
+    const { x, y } = spot;
+    // Raise a FRESH tooltip for THIS item, and be sure it IS this item's. The browser shows
+    // a tooltip on MOUSEENTER after a 500ms delay and hides it 500ms after mouseleave
+    // (hg/js/utils.js addMouseover), so while the cursor glides in it crosses other items
+    // and any of THEIR tooltips can still be on screen when we arrive -- an Alignment
+    // Differences item recorded as "identical" (the neighbouring aligned block, lingering in
+    // its grace period) when the item under the cursor reads "mismatch C->T". Waiting for
+    // "some tooltip is visible" is therefore not enough: when we know the item's own text
+    // (from its map box) we wait for exactly that.
     const tipHtml = () => page.evaluate(() => {
       const c = document.getElementById('mouseoverContainer');
       if (!c || !c.offsetWidth) return null;
       const st = getComputedStyle(c);
       return (st.display === 'none' || st.visibility === 'hidden') ? null : c.innerHTML;
     });
     const prevTip = await tipHtml();
     await page.mouse.move(2, y); cur.x = 2; cur.y = y;
     if (prevTip) await page.waitForFunction(() => {
       const c = document.getElementById('mouseoverContainer');
       if (!c || !c.offsetWidth) return true;
       const st = getComputedStyle(c);
       return st.display === 'none' || st.visibility === 'hidden';
     }, null, { timeout: 2000 }).catch(() => {});
     await sleep(60);
     await glide(x, y);
     // small jiggle so the mousemove handler definitely fires and positions the tooltip
     await page.mouse.move(x + 1, y); await sleep(60); await page.mouse.move(x, y);
+    const shown = () => page.evaluate(() => {
+      const c = document.getElementById('mouseoverContainer');
+      if (!c || !c.offsetWidth) return null;
+      const st = getComputedStyle(c);
+      if (st.display === 'none' || st.visibility === 'hidden') return null;
+      return (c.textContent || '').replace(/\s+/g, ' ').trim();
+    });
+    const wantTip = spot.tip || null;
+    if (wantTip) {
+      // The item's own tooltip, or nothing. A neighbour's tooltip lingering from the glide
+      // fails this test, so we keep waiting until the 500ms show timer fires for OUR item.
+      // Compared with ALL whitespace removed: the title's markup (`<b>rsID</b>: ...`, `<br>`)
+      // leaves no whitespace at all in textContent, so any tag-to-space normalisation would
+      // never match and the wait would just burn its timeout on a tooltip that was right.
+      await page.waitForFunction(w => {
+        const c = document.getElementById('mouseoverContainer');
+        if (!c || !c.offsetWidth) return false;
+        const st = getComputedStyle(c);
+        if (st.display === 'none' || st.visibility === 'hidden') return false;
+        const flat = z => z.replace(/\s+/g, '');
+        // A distinctive PREFIX, not the whole string: the head of a mouseOver carries the
+        // item's identity (its name/HGVS), while the tail can render differently from the
+        // title it came from (entities, stars, a max-width span). Short tips match whole.
+        return flat(c.textContent || '').includes(flat(w).slice(0, 60));
+      }, wantTip, { timeout: 4000 }).catch(() => {});
+      const flat = z => (z || '').replace(/\s+/g, '');
+      if (process.env.DOCENT_ROWS && !flat(await shown()).includes(flat(wantTip).slice(0, 60)))
+        console.warn(`  WARNING: mouseover ${t} "${want}": tooltip never showed its own text\n`
+                   + `      want: ${JSON.stringify(flat(wantTip).slice(0, 70))}\n`
+                   + `      got : ${JSON.stringify(flat(await shown()).slice(0, 70))}`);
+    } else {
       await page.waitForFunction(prev => {
         const c = document.getElementById('mouseoverContainer');
         if (!c || !c.offsetWidth) return false;
         const st = getComputedStyle(c);
         if (st.display === 'none' || st.visibility === 'hidden') return false;
         return prev == null || c.innerHTML !== prev;
       }, prevTip, { timeout: 3000 }).catch(() => {});
+    }
+    // A POSITIONAL hover has no expected text to wait for, so the best it can do is let the
+    // tooltip settle: the content stops changing once the cursor is parked, so sample until
+    // two reads agree. (A pinned positional hover therefore still records whatever is under
+    // the point -- `frac: 0.5` can land between two features and report the block they sit
+    // in. Name the item when the figure depends on which tooltip it is.)
+    if (!wantTip) {
+      let settled = await tipHtml();
+      for (let i = 0; i < 15; i++) {
+        await sleep(80);
+        const now = await tipHtml();
+        if (now && now === settled) break;
+        settled = now;
+      }
+    }
+    if (process.env.DOCENT_ROWS)
+      console.log(`  tip at (${Math.round(x)},${Math.round(y)}): `
+        + JSON.stringify(((await shown()) || '').slice(0, 90)));
     // Optionally RECORD this tooltip so a later `pinShot:` can show several mouseovers
     // open together in one figure. We only record (position + the tooltip's own HTML)
     // here -- nothing is injected into the recorded page, so the mp4 still shows just the
     // transient native tooltip. `pin:` on the step overrides the document-level
     // `pinMouseovers:` default. Records accumulate within a view and are cleared on nav.
     const pin = (o.pin != null) ? o.pin : (doc.pinMouseovers === true);
     if (pin) await recordTip(x, y);
     if (!FAST) await sleep(o.hold != null ? Number(o.hold) * 1000 : SHOTHOLD);
     if (o.shot) await shot(o.shot);
   }
   // Grab the live mouseover tooltip's HTML and anchor it at the ITEM's coordinate (x,y
   // that mouseover just hovered), expressed RELATIVE TO the track image (#imgTbl). The
   // browser parks its own tooltip at a near-fixed spot, so two tips would stack; anchoring
   // to the item keeps each pinned tooltip on its own feature (and robust to the throwaway
   // page's image sitting at a different offset).
@@ -995,60 +1063,81 @@
           const navDone = page.waitForNavigation({ waitUntil: 'load', timeout: 30000 }).catch(() => {});
           await clickGlide((await page.locator('#goButton').count()) ? '#goButton' : '.jwGoButtonContainer');
           await navDone;
         }
         // A unique hit lands on the track image; a term with no suggestion and several
         // matches lands on the search-results page instead, which has no #imgTbl -- that's
         // legal, the script can `click` a result from there.
         await page.waitForSelector('#imgTbl', { timeout: 15000 }).catch(() => {});
         await captureState();
         await page.mouse.move(cur.x, cur.y);                              // re-show the cursor overlay
         if (o.shot) { await shot(o.shot); return; }
         break;
       }
       case 'hide': if (arg === 'all' || arg === true) { await clickGlide('#hgt\\.hideAll'); await page.waitForSelector('#imgTbl'); } break;
       case 'track': {
-        const entries = Object.entries(arg);
-        const named = new Set(entries.map(([n]) => n));          // what the author spelled out
+        let entries = Object.entries(arg);
+        const isKidHide = ([, mode]) => String(mode).toLowerCase() === 'hidekids';
+        // What the author spelled out AS A VISIBILITY. A `hideKids` container is deliberately
+        // NOT in here: it names no mode of its own, so it must not suppress the container
+        // variable derived from a child below it (`pubtator: pack` is what turns varsInPubs on).
+        const named = new Set(entries.filter(e => !isKidHide(e)).map(([n]) => n));
         const idx = await tdbIndex(state.db);
+        // `hideKids` is not a visibility -- it is "hide everything under this container", so
+        // that a child named alongside it is left the only one drawn. A superTrack needs it:
+        // unlike a composite, its own mode does NOT reach its children, so every child comes
+        // up at its own trackDb visibility and an earlier `hide: all` does not stick
+        // (`{varsInPubs: show}` alone draws all eight of its members). The expansion skips any
+        // child the step names itself, and runs in a round of its own AFTER the rest, because
+        // a subtrack hide travelling in the same request as its container can be dropped by
+        // the cart (#37953) -- so the container goes on first and the hides follow.
+        const kidHides = [];
+        for (const e of entries.filter(isKidHide)) {
+          const leaves = (await tdbLeaves(e[0])).filter(k => !named.has(k));
+          if (!leaves.length)
+            console.warn(`track ${e[0]}: hideKids -- trackDb gives it no children to hide`);
+          for (const k of leaves) kidHides.push([k, 'hide']);
+        }
+        entries = entries.filter(e => !isKidHide(e));
         // Visible gesture first: drive the real track-controls dropdowns so the mouse is
         // seen turning the tracks on. State is still applied by the nav()s below (which
         // carry the container/checkbox vars too), so these opens are non-committing.
         if (doc.trackAnim !== false)
           for (const [name, mode] of entries) {
             const csel = await ctrlSelect(name);
             if (csel) await openSelectVisible(csel, mode, 6, false);
           }
         // hgTracks RESHAPES a composite when its container visibility changes, and that wipes
         // per-subtrack overrides arriving in the same request (`clinvar=pack&clinvarCnv=hide`
         // leaves clinvarCnv_sel=1 and the CNV row still drawn). So a step that names both a
         // composite and something under it is applied in rounds -- container first, then the
         // deviations -- which is exactly what writing them as two steps does. superTracks
         // don't reshape, so they don't force a round.
         const rounds = new Map();
         for (const e of entries) {
           let d = 0;
           for (let k = e[0]; ;) {
             const n = idx && idx.get(k);
             if (!n || !n.parent) break;
             const p = idx.get(n.parent);
             if (named.has(n.parent) && !(p && p.superTrack)) d++;
             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}`);
         }
         break;
       }
       case 'convert': await convert(arg); break;
       case 'hub': {