4265f36510c9498df6d7b0a8ff9e5251350c133c
braney
  Sun Sep 20 14:34:09 2026 -0700
docent: read a graph's tooltip, and noTip: for a bare number, refs #37892

hgTracks has two tooltips and their ids differ only in the case of one letter.
hg/js/utils.js hangs an ITEM's tooltip off `#mouseoverContainer`: the text of a
map box's title, an rsID or a gene name.  A WIGGLE reports itself the other way.
The `mouseOver` module in hg/js/hgTracks.js writes the value under the cursor
into `#mouseOverText`, read from the per-pixel spans hgTracks leaves in a trash
.json.  A track drawn as a coverage graph has no map boxes at all, so its numbers
exist only in the second one, and `tip:` on such a track read an empty string and
then waited out its timeout.  Every reader of the live tooltip now takes whichever
is up: `tip:`, the `mouseover:` waits, `pinShot:` and the shot clip.

Two more from the same mistake.  `mouseover: {value: ...}` is documented for
wiggles and had never matched anything on any track: it looked in
`window.mapData.spans[...]` for a member called `value`, which is hg/js/mouseOver.js,
an older copy of the module the page does not load, and the member there is `v`
as well.  It now reads `mouseOver.items[...]` and places the cursor from
`td_data_<track>`, which is what hgTracks.js itself compares the cursor against.
And asking by value now skips the map boxes entirely, because a track's own center
label is titled "Click to alter the display density of <track>": a probe for the
value 3 hovered the center label of a track called rm38253stairs before any span
was looked at.

`noTip:` is new.  `tip:` is a substring test, which is right for an item, whose
tooltip is markup and whose tail can render differently from the title it came
from.  It is wrong for a graph, whose tooltip is the number and nothing else:
`tip: "1"` is also satisfied by "1.5" and by "13", and a mean computed with the
wrong divisor is exactly the fraction that would slip through.  Naming the other
digits and the decimal point leaves one number.  This is the prefix trap #38279
hit with two messages sharing a head, in a place with no closing paren to carry
the delimiter.

diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index 819389c47e5..bfd84537ada 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -281,30 +281,57 @@
 // is written as an inline style, so the rule has to be !important to win. `.__pinnedTip` is
 // a recorded tooltip re-injected by pinShot() (which strips the id, keeps the class).
 const SCALE_INIT = ({ k, tipPx }) => {
   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; }`
       + `\n#mouseoverContainer, #mouseOverText, .tooltip, .__pinnedTip { font-size: ${tipPx}px !important; }`;
     (document.head || document.documentElement).appendChild(s);
   };
   if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add);
   else add();
 };
 
+// hgTracks has TWO tooltips and their ids differ only in the case of one letter, so a check
+// that reads one is blind to the other. hg/js/utils.js hangs an ITEM's tooltip off
+// `#mouseoverContainer`: the text of a map box's title, an rsID or a gene name. A WIGGLE
+// reports itself the other way -- the `mouseOver` module in hg/js/hgTracks.js writes the
+// value under the cursor into `#mouseOverText`, read out of the per-pixel spans hgTracks
+// leaves in a trash .json (`div.mouseOverData[jsonUrl=...]`). A track drawn as a coverage
+// graph has no map boxes at all, so its numbers exist ONLY in the second one, and a `tip:`
+// that looked at the first read an empty string and then waited out its timeout.
+//
+// hg/js/mouseOver.js is an older copy of that module and is NOT what the page runs: it
+// wants a `#mouseOverContainer` and a `.wigMouseOver` that hgTracks no longer writes. Do
+// not take it for the live code, as this did.
+//
+// Item tooltip first, so a page showing both answers the way it always did.
+const TIP_INIT = () => {
+  window.__docentTip = () => {
+    for (const id of ['mouseoverContainer', 'mouseOverText']) {
+      const e = document.getElementById(id);
+      if (!e || !e.offsetWidth) continue;
+      const st = getComputedStyle(e);
+      if (st.display === 'none' || st.visibility === 'hidden') continue;
+      return e;
+    }
+    return null;
+  };
+};
+
 // ---------- animated cursor (same technique as the walkthrough-video skill's record.js) ----------
 // The glyph and its box are shared with pinShot(), which draws a STATIC copy at every
 // pinned mouseover so a combined figure shows where each tooltip was raised from. Keep
 // them one definition: a pinned cursor that did not match the animated one would read as
 // a different pointer rather than as the same tour paused.
 const CURSOR_BOX = '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));';
 const CURSOR_SVG = '<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>';
 const CURSOR_INIT = ({ box, svg }) => {
   const add = () => {
     if (document.getElementById('__cur')) return;
     const c = document.createElement('div');
     c.id = '__cur';
     c.style.cssText = box;
     c.innerHTML = svg;
@@ -357,30 +384,31 @@
 // makes them cheap to look at, and cheap to diff when the derivation is changed.
 const DERIVE = !!process.env.DOCENT_DERIVE;
 
 const T_START = Date.now();
 (async () => {
   // Before the browser: nothing in the derivation touches the page, and the point is to
   // not pay for one. (The helpers below are function declarations, so they are hoisted.)
   if (DERIVE) { await deriveMain(); return; }
   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: SCALE,
     ...(FAST ? {} : { recordVideo: { dir: path.join(HERE, '.vid_' + base), size: { width: VW, height: VH } } }),
   });
   if (SCALE > 1) await ctx.addInitScript(SCALE_INIT, SCALE_ARGS);
+  await ctx.addInitScript(TIP_INIT);
   await ctx.addInitScript(CURSOR_INIT, { box: CURSOR_BOX, svg: CURSOR_SVG });
   await ctx.addInitScript(() => { try { localStorage.setItem('hgTracks_hideTutorial', '1'); } catch (e) {} });
   const page = await ctx.newPage();
   const T_REC = Date.now();        // the recorder starts with the page; see FLASH below
   const shotSecs = [];             // when each shot: was taken, seconds into the recording
   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) {}
@@ -649,33 +677,32 @@
     return null;
   }
   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 = document.getElementById('mouseoverContainer');
-      if (tip && tip.offsetWidth > 0 &&
-          getComputedStyle(tip).display !== 'none' && getComputedStyle(tip).visibility !== 'hidden') overlays.push(tip);
+      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);
       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.
@@ -725,34 +752,41 @@
   // message below decide it. hgTracks names each map after the track it draws
   // (map_data_<key>, map_center_<key>), so the test is exact. A y-band test is not: a
   // packed row stacks items above and below its middle, and on a quickLift target the band
   // drops boxes that really are in the row. The band is kept only to choose between
   // several boxes OF THIS TRACK that carry the same name.
   //
   // A box belonging to another track is never picked, however well it matches. That
   // fallback used to be here -- `inBand[0] || cands[0]` -- and it answered with a
   // neighbouring track's item, silently, whenever this track had none of its own. A hub
   // track that declares more bigBed fields than its file has draws no items at all
   // (#38310), and a probe for one reported the item of a native track that happens to use
   // the same item names, so three tracks that draw nothing were recorded as drawing.
   // rm35920 read hg38's native `ultras` for years the same way. An answer that is wrong
   // but reads as a pass is worse than a failure, so this now returns nothing and lets the
   // error below say where the name really was.
-  async function itemXY(t, want, titleOnly) {
+  //
+  // `spanOnly` says the caller asked by VALUE, which only a wiggle has, so the map-box scan
+  // is skipped entirely. It has to be: a track's own center label carries the title "Click
+  // to alter the display density of <track>", and a track whose name contains the wanted
+  // digits matches that box before any span is looked at. A probe for the value 3 on a
+  // track called rm38253stairs hovered the center label and was told so only because the
+  // tooltip that came up disagreed with the one the box promised.
+  async function itemXY(t, want, titleOnly, spanOnly) {
     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, key }) => {
+    const area = spanOnly ? null : await page.evaluate(({ want, titleOnly, band, imgBox, key }) => {
       // Does this map hold the pixels of the track we were asked about?
       const isMine = m => {
         const nm = (m && m.getAttribute('name')) || '';
         return nm === `map_${key}` || nm.endsWith(`_${key}`);
       };
       // When NOTHING on the page can be attributed to this key, hgTracks named the map
       // something we do not recognise. Only then does geometry get to decide, which is
       // what this did for every track before.
       const anyMine = [...document.querySelectorAll('map[name^="map_"]')].some(isMine);
       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') || '';
@@ -786,36 +820,46 @@
       const elsewhere = [...new Set(cands.filter(h => !h.mine)
                                          .map(h => h.map.replace(/^map_(data_|center_)?/, ''))
                                          .filter(Boolean))];
       return { pick, n: cands.length, nPool: pool.length, anyMine, elsewhere,
                all: pool.map(c => Math.round(c.cx)) };
     }, { want: String(want), titleOnly: !!titleOnly, band, imgBox: img, key });
     if (area && area.pick) {
       const p = area.pick;
       if (process.env.DOCENT_ROWS)
         console.log(`  item "${want}" in ${t}: ${area.n} map box(es) match, ${area.nPool} of them `
                   + `${area.anyMine ? "this track's" : 'in the row (no map names this track)'}, `
                   + `centers x=[${area.all}] -> hovering (${Math.round(p.cx)},${Math.round(p.cy)})`
                   + (p.tip ? `, expecting tip "${p.tip.slice(0, 40)}"` : ''));
       return { x: p.cx, y: p.cy, href: p.href, tip: p.tip };
     }
+    // A WIGGLE has no per-item map box at all: its values live in the spans the `mouseOver`
+    // module in hg/js/hgTracks.js fetches from a trash .json, one run of pixels per value.
+    // They are `mouseOver.items[<track>]`, each `{x1, x2, v}`, and x1/x2 are offsets from
+    // the left edge of `td_data_<track>` in the pixels the SERVER drew -- which is what
+    // hgTracks.js itself compares the cursor against. This read used to go to
+    // `window.mapData.spans[...]` for a member called `value`; that is hg/js/mouseOver.js,
+    // an older copy of the module the page does not load, and the member there is `v` as
+    // well, so `value:` had never matched anything on any track.
     const span = await page.evaluate(({ keys, want }) => {
-      const md = window.mapData; if (!md || !md.spans) return null;
+      const mo = window.mouseOver; if (!mo || !mo.items) 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 };
+        const arr = mo.items[k]; if (!arr) continue;
+        const s = arr.find(r => String(r.v).includes(want));
+        if (!s) continue;
+        const td = document.getElementById('td_data_' + k);
+        return { x1: s.x1, x2: s.x2, tdLeft: td ? td.getBoundingClientRect().left : null };
       }
       return null;
     }, { keys: [key, t], want: String(want) });
     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.
       // Pick the row's areas by MAP NAME, not by geometry. hgTracks names each map after
       // the track it belongs to (map_data_<key>, map_center_<key>), so this is exact --
       // whereas the y-band test this used to do dropped every item box on a quickLift
       // target and left the message saying the row held three things when it held
       // twenty-eight. The band is still the tie-break when a map cannot be attributed.
       const near = await page.evaluate(({ key }) => {
         const out = [];
@@ -860,31 +904,32 @@
       const alsoIn = other.length
         ? `. That name IS on this page, in ${other.slice(0, 6).join(', ')}`
           + `${other.length > 6 ? `, ... (${other.length} tracks)` : ''}`
           + ` -- another track's box is never used for this one`
         : '';
       // The BOX count matters as much as the names. A type-bigBed-3 row has plenty of
       // items and no way to name any of them -- 26 boxes collapsing to two distinct
       // titles -- and the next thing to try there is a positional `click: {frac: ...}`,
       // not a different name. Saying only "in that row: two titles" hides that.
       throw new Error(`item "${want}" not found in track "${t}" (searched map-box areas + `
         + `mouseOver spans). Window ${win}. ${near.boxes} map box(es) in that row`
         + `${names.length ? `, addressable as: ${show.join(', ')}`
           + `${show.length < names.length ? `, ... (${names.length} distinct; DOCENT_ROWS=1 for all)` : ''}`
           : ' and none of them carries a name or a title'}${alsoIn}`);
     }
-    return { x: img.x + imgPx * (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null };
+    const left = (span.tdLeft != null) ? span.tdLeft : img.x;
+    return { x: left + imgPx * (span.x1 + span.x2) / 2, y: row.y + row.height / 2, href: null };
   }
   // A POSITIONAL point plus the hgc link of the map box nearest it. `click:` needs this
   // because some tracks have no item that can be named at all: every subtrack of GIAB
   // Problematic Regions is `type bigBed 3`, so its hgc hrefs carry an EMPTY `i=` and every
   // box's title is "Start of Exon (1/1)" -- neither `item:` nor `title:` can pick one, and
   // a raw mouse click on the data area is swallowed by hgTracks' drag-select handler. So
   // place the point the way posXY does and follow the box under (or nearest) it.
   //
   // Nearest rather than strictly containing, because y is the row's middle and a packed
   // row stacks its items above and below that line.
   async function areaXY(t, o) {
     const { key } = await trackBox(t);
     const { x, y } = await posXY(t, o);
     const hit = await page.evaluate(({ x, y, key }) => {
       const maps = [...document.querySelectorAll('map[name^="map_"]')].filter(m => {
@@ -1143,95 +1188,83 @@
     if (m) return [1, 2, 3].map(i => parseInt(m[i], 16));
     m = /^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/.exec(s);
     if (m) { const v3 = [1, 2, 3].map(i => Number(m[i])); return v3.every(x => x <= 255) ? v3 : null; }
     return null;
   }
   // 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 spot = (want != null)
-      ? await itemXY(t, want, o.title != null && o.item == null && o.value == null)
+      ? await itemXY(t, want, o.title != null && o.item == null && o.value == null,
+                     o.value != null && o.item == null && o.title == null)
       : await posXY(t, o);
     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 c = window.__docentTip();
+      return c ? c.innerHTML : null;
     });
     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(() => {});
+    if (prevTip) await page.waitForFunction(() => !window.__docentTip(),
+      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 c = window.__docentTip();
+      return c ? (c.textContent || '').replace(/\s+/g, ' ').trim() : null;
     });
     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 c = window.__docentTip();
+        if (!c) 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;
+        const c = window.__docentTip();
+        if (!c) 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;
@@ -1247,32 +1280,32 @@
     // `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).
   // cx/cy is the HOVER POINT itself (also image-relative), kept alongside the tooltip's
   // own offset so pinShot() can draw a cursor exactly where the tip was raised from.
   async function recordTip(x, y) {
     const t = await page.evaluate(({ x, y }) => {
-      const c = document.getElementById('mouseoverContainer');
-      if (!c || !c.offsetWidth) return null;
+      const c = window.__docentTip();
+      if (!c) return null;
       const im = document.getElementById('imgTbl');
       const ir = im ? im.getBoundingClientRect() : { left: 0, top: 0 };
       return { cx: x - ir.left, cy: y - ir.top,
                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.
   // Bare string is the still's name; the map form adds `cursors:` (default true) to draw
   // a static pointer at every pinned hover point, so a combined figure says which feature
   // each tooltip came off rather than leaving the reader to infer it from the anchor.
   async function pinShot(arg) {
     const o = (arg && typeof arg === 'object') ? arg : { name: arg };
@@ -1561,33 +1594,32 @@
   // #38251's icon left the blue bar and slid across the menu items at narrow widths with
   // every selector still matching. See boxCheck().
   //
   // `color:` is the one check that reads the IMAGE rather than the page, because a bug about
   // color changes nothing else: same rows, same height, same items, same tooltips. It names
   // the color the row is mostly drawn in (`is:`) or the one it must not be (`not:`), and
   // `part: label` asks about the center label instead of the items. See rowColors().
   //
   // `warn: true` downgrades a failure to a warning, for a check worth logging but not worth
   // stopping a build over.
   async function expectState(arg) {
     const o = (arg && typeof arg === 'object') ? arg : { text: arg };
     const url = page.url();
     const seen = await page.evaluate(() => {
       const im = document.getElementById('imgTbl');
-      const tip = document.getElementById('mouseoverContainer');
-      const up = tip && tip.offsetWidth > 0 && getComputedStyle(tip).display !== 'none'
-        && getComputedStyle(tip).visibility !== 'hidden';
+      const tip = window.__docentTip && window.__docentTip();
+      const up = !!tip;
       return {
         rows: [...document.querySelectorAll('[id^="img_data_"]')].map(e => e.id.replace('img_data_', '')),
         cssHeight: im ? im.getBoundingClientRect().height : 0,
         tip: up ? tip.innerText.trim() : '',
         text: document.body ? document.body.innerText : '',
       };
     });
     // The still is a screenshot of #imgTbl, so its height in PIXELS is the CSS height times
     // the device pixel ratio -- which is what someone means by "7,581 px tall", and what a
     // print run makes k times bigger.
     const height = Math.round(seen.cssHeight * SCALE);
     // A hub track's row id carries a per-run hub_<n>_ prefix (a quickLift target's rows all
     // do), so match the plain name by suffix, the way `mouseover:` resolves a track.
     const drawn = w => seen.rows.some(r => r === w || r.endsWith('_' + w));
     const list = v => v == null ? [] : (Array.isArray(v) ? v : [v]).map(String);
@@ -1612,30 +1644,40 @@
       for (let i = 1; i < seq.length; i++)
         if (seq[i].at < seq[i - 1].at) {
           bad.push(`rows out of order: ${seq[i - 1].w} should be drawn above ${seq[i].w}`);
           break;
         }
     }
     const banned = list(o.noRows).filter(w => drawn(w));
     if (banned.length) bad.push(`rows that should not be drawn: ${banned.join(', ')}`);
     if (o.height != null) {
       const why = cmpSize(height, o.height, 'image');
       if (why) bad.push(why);
     }
     if (o.tip != null && !seen.tip.includes(String(o.tip)))
       bad.push(seen.tip ? `tooltip says "${seen.tip}", wanted "${o.tip}"`
                         : `no tooltip is up, wanted "${o.tip}"`);
+    // `tip:` is a SUBSTRING test, because an item's tooltip is markup and its tail renders
+    // differently from the title it came from. That is right for a name and wrong for a
+    // number: a coverage graph's tooltip is the value and nothing else, so `tip: "1"` is
+    // also satisfied by "1.5" and by "13" -- the prefix trap that #38279 hit with two
+    // messages sharing a head. `noTip:` is the delimiter this check otherwise has no way to
+    // carry: state the digits and the decimal point the value must NOT contain and only one
+    // number is left. One string or a LIST, like every other no-check here.
+    for (const no of list(o.noTip))
+      if (seen.tip.includes(String(no)))
+        bad.push(`tooltip says "${seen.tip}", which must not contain "${no}"`);
     // text:/noText: take one string or a LIST of them, the way has:/noHas: do. They have to:
     // a list handed to a check that stringifies its argument fails OPEN -- ["a", "b"] becomes
     // "a,b", which no page contains, so the check passes on anything and passes silently.
     // Six scripts in one batch were written that way and all six looked green.
     for (const want of list(o.text))
       if (!seen.text.includes(want)) bad.push(`page does not contain "${want}"`);
     for (const want of list(o.noText))
       if (seen.text.includes(want)) bad.push(`page contains "${want}"`);
     if (o.url != null && !url.includes(String(o.url)))
       bad.push(`url is "${url}", wanted it to contain "${o.url}"`);
     if (o.noUrl != null && url.includes(String(o.noUrl)))
       bad.push(`url contains "${o.noUrl}": ${url}`);
     for (const sel of list(o.has)) {
       const n = await page.locator(sel).count().catch(() => -1);
       if (n === 0) bad.push(`nothing matches "${sel}"`);
@@ -2340,31 +2382,32 @@
           // Named (item:/title:/value:) picks the item by identity; positional
           // (at:/frac:/x:) takes the box nearest that point, for a track whose items
           // cannot be named at all.
           //
           // `raw: true` presses the mouse where a user would press it and lets the page do
           // whatever it does, instead of following the link. That is a different gesture,
           // not a slower way to reach the same page: an item click on most tracks is
           // answered by an ajax DIALOG, and the dialog is where a whole class of bug lives
           // (hgTracks hanging on the SECOND click of the same item, #36805). Following the
           // href never opens a dialog, so it can never see one. With raw: and no item name
           // it is a bare point on the row -- the ruler carries no hgc map boxes at all, and
           // a click on it is the gesture in #27113.
           const named = arg.item ?? arg.title ?? arg.value;
           const it = (named != null)
             ? await itemXY(arg.track, named,
-                           arg.title != null && arg.item == null && arg.value == null)
+                           arg.title != null && arg.item == null && arg.value == null,
+                           arg.value != null && arg.item == null && arg.title == null)
             : (arg.raw ? await posXY(arg.track, arg) : await areaXY(arg.track, arg));
           await glide(it.x, it.y); await sleep(200);
           // A raw click on the data area is swallowed by hgTracks' drag-select handler, so
           // by default follow the item's own map-box link (the hgc detail page) directly.
           if (it.href && !arg.raw) await nav(it.href);
           else {
             const was = page.url();
             await page.mouse.click(it.x, it.y);
             // Answered in one of three ways depending on what was clicked: a navigation, an
             // ajax dialog, or a new image swapped in place. Wait for whichever arrives
             // rather than picking one, and never on a fixed sleep, which would flake on a
             // slow hgc and waste the time on a fast one.
             await Promise.race([
               page.waitForSelector('.ui-dialog:visible', { timeout: 20000 }),
               page.waitForFunction(u => location.href !== u, was, { timeout: 20000 }),