a0adfc55d38c2fbdf34f3f2667052dbdb64db576
braney
  Thu Sep 10 14:39:03 2026 -0700
docent: montage label gutter now fits the widest label

`montage:` reserved a fixed gutter of 1.5 em for the panel label. That fits the
one-letter auto labels (A, B, C) and nothing else. A word label -- "virtChrom",
"quickLift" -- overflowed the gutter and painted over the left edge of its own
panel. The panels were all placed at the same x, but the figure read as if they
were misaligned, because the text ran into the first column of pixels of one
panel and not the other.

The gutter is now measured. Each label is laid out at width 0, its scrollWidth
is read, and the gutter is the widest of those plus a 0.4 em pad, with the old
1.5 em kept as the floor so short labels still line up the way they did.
white-space:nowrap stops a two-word label from wrapping into a taller row and
shifting its panel down.

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 cc0bedd6c42..2b122948334 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -1242,33 +1242,41 @@
         row.style.cssText = 'display:flex;align-items:flex-start;';
         const lab = document.createElement('div');
         lab.className = '__figLabel';
         lab.textContent = p.label;
         const img = document.createElement('img');
         img.src = p.data; img.style.display = 'block';
         row.appendChild(lab); row.appendChild(img);
         fig.appendChild(row);
         return { lab, img };
       });
       return Promise.all(rows.map(r => r.img.decode().catch(() => {}))).then(() => {
         // Label size follows the panels' own resolution, so a 3x montage gets 3x lettering
         // rather than a caption that shrinks to nothing next to a 2500px panel.
         const maxW = Math.max(...rows.map(r => r.img.naturalWidth));
         const fs = labelSize != null ? labelSize : Math.max(11, Math.round(maxW / 55));
+        // The gutter has to fit the WIDEST label, not a fixed two characters. A one-letter
+        // auto label fits anything, but a word label ("virtChrom") overflows a fixed gutter
+        // and paints over the left edge of its own panel -- which reads as the panels being
+        // misaligned, even though every panel is placed at the same x.
+        const style = w => `flex:0 0 ${w}px;font-weight:bold;font-size:${fs}px;`
+          + `line-height:1;color:#111;white-space:nowrap;`;
+        for (const r of rows) r.lab.style.cssText = style(0);
+        const gutter = Math.max(Math.round(fs * 1.5),
+                                ...rows.map(r => r.lab.scrollWidth + Math.round(fs * 0.4)));
         for (const r of rows) {
-          r.lab.style.cssText = `flex:0 0 ${Math.round(fs * 1.5)}px;font-weight:bold;`
-            + `font-size:${fs}px;line-height:1;color:#111;`;
+          r.lab.style.cssText = style(gutter);
           r.img.style.width = r.img.naturalWidth + 'px';   // natural size, never stretched
         }
         return fs;
       });
     }, { panels, dir, gap, labelSize: o.labelSize != null ? Number(o.labelSize) : null });
     const p = path.join(STILLDIR, name + '.png');
     await pg3.locator('#__fig').screenshot({ path: p });
     await ctx3.close();
     console.log('SHOT', p, `(montage: ${panels.length} panels, label ${labelSize}px)`);
   }
   // `shot:` writes a picture of the view; `session:` writes the view ITSELF, as a settings
   // file anyone can load into their own browser. hgSession's save-to-a-local-file path
   // (doSaveLocal, hg/hgSession/hgSession.c) needs no wiki login, so a plain GET returns the
   // whole cart -- every track's visibility, the attached hubs, the custom tracks, the window.
   //