Bot:Gadget-figureSource.js

/* Gadget-figureSource — click-to-source provenance for archived documents.

*
* Two modes, keyed on the page:
*
* ARCHIVE PAGES (any page with a published figures sidecar,
* Data:<page>.figures.json): every located figure becomes clickable. Click →
* an anchored popover showing a CROP of the source PDF with the figure's
* stored rectangle boxed in coral, plus its tags (row label, column header,
* caption, p./t. address). The wiki row stays visible beside the popover —
* the review gesture is "does the wiki cell match the source?", so nothing
* may cover the cell being compared.
*
* Bot:PDF viewer: the full-page stage — ?doc=<archive page>&fig=<figure id>
* renders the whole PDF page with the box, prev/next paging. Deep-linkable:
* a figure's evidence is a URL.
*
* Data contract: Data:<page>.figures.json (by_id figures — see
* tools/doc_archive/output/figures.py). Boxes are PDF points, top-left
* origin; the sidecar's `loc` fields are DOM-computable (table ordinal +
* expanded grid r/c; chunk + in-order prose occurrences). The mirrored PDF
* is File:<page with "/"->"-">.pdf, served same-origin via Special:FilePath.
* NEVER guesses: a figure the sidecar could not locate gets an honest
* "no source location" popover, and a DOM cell whose text disagrees with
* the sidecar entry is skipped with a console warning.
*/

(function () {

   'use strict';
   var PDFJS = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
   var PDFJS_WORKER = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
   var VIEWER_PAGE = 'Bot:PDF viewer';
   var RENDER_SCALE = 1.6;          // offscreen page render (points -> px)
   var CROP_MARGIN_X = 110;         // context around the box, PDF points
   var CROP_MARGIN_Y = 16;
   var coral = (getComputedStyle(document.documentElement)
       .getPropertyValue('--ed-accent') || '#f07662').trim() || '#f07662';
   // ── zero-dependency stand-ins for mw.util / mw.loader ──────────────────
   // Deliberate: on this wiki mediawiki.util can hang at "loading" under the
   // page's heavy module soup (VE, popups, SMW), and a gadget waits FOREVER
   // for a declared dependency (found live: the gadget sat at "loaded" while
   // its dependency never resolved). This gadget therefore depends on
   // nothing beyond the startup config, which is always present before
   // gadgets execute.
   var SCRIPT = mw.config.get('wgScript');            // "/w/index.php"
   var ARTICLE = mw.config.get('wgArticlePath');      // "/wiki/$1"
   function esc(s) {
       return String(s).replace(/[&<>"']/g, function (ch) {
           return '&#' + ch.charCodeAt(0) + ';';
       });
   }
   function rawUrl(title) {
       return SCRIPT + '?title=' + encodeURIComponent(title) + '&action=raw';
   }
   function pageUrl(title, params) {
       if (!params) {
           return ARTICLE.replace('$1', encodeURIComponent(title)
               .replace(/%2F/g, '/').replace(/%3A/g, ':'));
       }
       var q = Object.keys(params).map(function (k) {
           return k + '=' + encodeURIComponent(params[k]);
       }).join('&');
       return SCRIPT + '?title=' + encodeURIComponent(title) + '&' + q;
   }
   function addStyle(css) {
       var el = document.createElement('style');
       el.textContent = css;
       document.head.appendChild(el);
   }
   function getScript(url) {
       return new Promise(function (resolve, reject) {
           var el = document.createElement('script');
           el.src = url;
           el.onload = resolve;
           el.onerror = reject;
           document.head.appendChild(el);
       });
   }
   // ── shared: pdf.js + page-canvas cache ─────────────────────────────────
   var pdfjsReady = null;
   function loadPdfJs() {
       if (!pdfjsReady) {
           pdfjsReady = getScript(PDFJS).then(function () {
               window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER;
               return window.pdfjsLib;
           });
       }
       return pdfjsReady;
   }
   function mirrorUrl(pageTitle) {
       var file = pageTitle.replace(/\//g, '-') + '.pdf';
       return pageUrl('Special:FilePath/' + file);
   }
   function PdfDoc(url) {                      // one per document, pages cached
       this.url = url;
       this.doc = null;
       this.pages = {};                        // pageno -> {canvas, viewport}
   }
   PdfDoc.prototype.page = function (n) {
       var self = this;
       return loadPdfJs().then(function (lib) {
           self.doc = self.doc || lib.getDocument(self.url).promise;
           return self.doc;
       }).then(function (doc) {
           if (self.pages[n]) { return self.pages[n]; }
           return doc.getPage(n).then(function (pg) {
               var viewport = pg.getViewport({ scale: RENDER_SCALE });
               var canvas = document.createElement('canvas');
               canvas.width = viewport.width;
               canvas.height = viewport.height;
               return pg.render({ canvasContext: canvas.getContext('2d'),
                                  viewport: viewport }).promise
                   .then(function () {
                       self.pages[n] = { canvas: canvas, viewport: viewport };
                       return self.pages[n];
                   });
           });
       });
   };
   // ── archive mode: map sidecar locators onto the DOM ────────────────────
   // Mirror of finmodel wiki_cells.expand_table: dense grid honoring
   // colspan/rowspan, so sidecar (r, c) means the same thing here.
   function expandTable(table) {
       var grid = [];                          // r -> c -> cell element
       var pending = {};                       // c -> [element, rowsLeft]
       var trs = table.querySelectorAll('tr');
       for (var r = 0; r < trs.length; r++) {
           var row = [], c = 0;
           var cells = trs[r].children;
           for (var i = 0; i < cells.length; i++) {
               while (pending[c]) {
                   row[c] = pending[c][0];
                   if (--pending[c][1] === 0) { delete pending[c]; }
                   c++;
               }
               var cell = cells[i];
               if (cell.tagName !== 'TD' && cell.tagName !== 'TH') { continue; }
               var cs = parseInt(cell.getAttribute('colspan') || '1', 10);
               var rs = parseInt(cell.getAttribute('rowspan') || '1', 10);
               for (var k = 0; k < cs; k++) {
                   row[c] = cell;
                   if (rs > 1) { pending[c] = [cell, rs - 1]; }
                   c++;
               }
           }
           while (pending[c]) {
               row[c] = pending[c][0];
               if (--pending[c][1] === 0) { delete pending[c]; }
               c++;
           }
           grid.push(row);
       }
       return grid;
   }
   function cellText(el) {
       var clone = el.cloneNode(true);
       // rendered {{{1}}} sups + hidden footnote bodies are not cell text
       clone.querySelectorAll('sup.ed-fn-ref, .ed-fn-body').forEach(function (s) {
           s.remove();
       });
       return clone.textContent.replace(/\s+/g, ' ').trim();
   }
   // The chunk a node belongs to = the nearest PRECEDING ed-chunk anchor.
   function chunkOf(node) {
       var anchors = document.querySelectorAll('span.ed-chunk[id]');
       var best = null;
       for (var i = 0; i < anchors.length; i++) {
           var pos = anchors[i].compareDocumentPosition(node);
           /* anchor precedes node */
           if (pos & Node.DOCUMENT_POSITION_FOLLOWING) { best = anchors[i]; }
       }
       if (!best) { return null; }
       var m = best.id.match(/-c(\d+)$/);
       return m ? parseInt(m[1], 10) : null;
   }
   // Wrap the chunk's prose figures IN SIDECAR ORDER with a moving cursor —
   // reproduces the builder's in-chunk ordering without re-implementing its
   // materiality rules in JS.
   function tagProse(entries, tag) {
       var byChunk = {};
       entries.forEach(function (e) {
           (byChunk[e.loc.chunk] = byChunk[e.loc.chunk] || []).push(e);
       });
       Object.keys(byChunk).forEach(function (chunk) {
           var list = byChunk[chunk].sort(function (a, b) {
               return a.loc.n - b.loc.n;
           });
           var anchor = document.querySelector(
               'span.ed-chunk[id$="-c' + chunk + '"]');
           if (!anchor) { return; }
           var root = anchor.closest('h6') || anchor;
           var walker = document.createTreeWalker(
               document.body, NodeFilter.SHOW_TEXT, null);
           walker.currentNode = root;
           var idx = 0, node;
           while (idx < list.length && (node = walker.nextNode())) {
               // .fig-src in the guard = idempotency: re-running the tagger
               // (staging-copy injection during verification) must never
               // nest a second wrapper inside an already-tagged figure
               if (node.parentElement.closest(
                       'table, .fig-card, .fig-banner, .fig-src')) { continue; }
               if (chunkOf(node) !== parseInt(chunk, 10)) { break; }
               var at, from = 0;
               while (idx < list.length &&
                      (at = node.data.indexOf(list[idx].text, from)) !== -1) {
                   var range = document.createRange();
                   range.setStart(node, at);
                   range.setEnd(node, at + list[idx].text.length);
                   var span = document.createElement('span');
                   span.className = 'fig-src';
                   range.surroundContents(span);
                   tag(span, list[idx]);
                   idx++;
                   // continue in the tail text node created by the wrap
                   node = span.nextSibling;
                   if (!node || node.nodeType !== Node.TEXT_NODE) { break; }
                   walker.currentNode = span;
                   from = 0;
               }
           }
           if (idx < list.length) {
               console.warn('[figureSource] chunk ' + chunk + ': ' +
                           (list.length - idx) + ' prose figure(s) not found');
           }
       });
   }
   function buildArchiveMode(sidecar) {
       var byEl = new Map();
       var tag = function (el, entry) {
           el.classList.add('fig-src');
           // tabindex is the LOAD-BEARING iOS detail (the footnote cue
           // template ships it too): a focusable element passes Safari's
           // "clickable" heuristic, so taps reliably synthesize click
           // events for document-delegated listeners. Without it, taps on
           // a bare td/span inside a scroll wrapper produce NOTHING.
           el.setAttribute('tabindex', '0');
           byEl.set(el, entry);
       };
       // cells — sidecar table ordinal ↔ DOM .wikitable ordinal
       var tables = Array.prototype.filter.call(
           document.querySelectorAll('#mw-content-text table.wikitable'),
           function (t) { return !t.closest('.fig-card, .fig-banner'); });
       var grids = tables.map(expandTable);
       var located = sidecar.figures.concat(sidecar.unmatched || []);
       var skipped = 0;
       located.forEach(function (entry) {
           if (entry.loc.kind !== 'cell') { return; }
           var grid = grids[entry.loc.table];
           var el = grid && grid[entry.loc.r] && grid[entry.loc.r][entry.loc.c];
           if (!el) { skipped++; return; }
           if (cellText(el).indexOf(entry.text) === -1) {
               console.warn('[figureSource] t' + entry.loc.table + ' r' +
                           entry.loc.r + 'c' + entry.loc.c +
                           ': DOM says "' + cellText(el) + '", sidecar "' +
                           entry.text + '" — skipped');
               skipped++;
               return;
           }
           tag(el, entry);
       });
       // prose
       tagProse(located.filter(function (e) {
           return e.loc.kind === 'prose';
       }), tag);
       console.info('[figureSource] tagged ' + byEl.size + ' figure(s), ' +
              skipped + ' skipped');
       return byEl;
   }
   function tagsHtml(el, entry) {
       // Address order, outer -> inner (user design): caption (which
       // table, incl. its [t. N] index) -> column header -> row label —
       // the same drill-down the sidecar's own locator uses.
       var parts = [];
       if (entry.loc.kind === 'cell') {
           var table = el.closest('table');
           var grid = expandTable(table);
           var cap = table.querySelector('caption');
           if (cap) { parts.push(cellText(cap)); }
           var headers = [];
           for (var h = 0; h < grid.length; h++) {
               var rowEls = grid[h] || [];
               var isHeader = rowEls.length &&
                   rowEls.every(function (c) { return c && c.tagName === 'TH'; });
               if (!isHeader) { break; }
               var cell = rowEls[entry.loc.c];
               var t = cell && cellText(cell);
               if (t && headers.indexOf(t) === -1) { headers.push(t); }
           }
           if (headers.length) { parts.push(headers.join(' · ')); }
           var label = grid[entry.loc.r] && grid[entry.loc.r][0];
           if (label && label !== el) { parts.push(cellText(label)); }
       } else {
           // Address for a prose figure, outer -> inner: the enclosing
           // section heading, then the CHUNK TITLE — which lives INSIDE the

// marker's own

("[c. 3; p. 1] Gross written premiums and // underlying earnings"), so it is the paragraph text minus the // .ed-chunk span. "chunk 3" appears nowhere: readers get words. var anchor = document.querySelector( 'span.ed-chunk[id$="-c' + entry.loc.chunk + '"]'); var blk = anchor && anchor.closest('p'); if (blk) { var up = blk.previousElementSibling; while (up && !/^H[1-4]$/.test(up.tagName)) { up = up.previousElementSibling; } if (up) { parts.push(up.textContent.replace(/\s+/g, ' ').trim()); } var clone = blk.cloneNode(true); clone.querySelectorAll('.ed-chunk').forEach(function (s) { s.remove(); }); var title = clone.textContent.replace(/\s+/g, ' ').trim(); if (title) { parts.push(title); } } } // one line, middle-dot separated ("Solvency II ratio (%) · FY24 · …") return parts.length  ? '

' + parts.map(esc).join(' · ') + '

'

           : ;
   }
   // ── source peek — the FOOTNOTE pattern (Common.js .ed-fn machinery) ────
   // One body-appended surface, never an in-flow element: a fixed CARD on
   // hover (desktop), a full-width top BANNER on tap (touch). Body-appended
   // means root stacking context — no sticky column or table scroll wrapper
   // can cover or clip it, the bug class the old in-flow popover had.
   // Desktop: hover = peek (card is pointer-events:none, like .ed-fn-popover,
   // so it can hold no link), click = open the viewer. Touch: tap = banner
   // (interactive, carries the open link), next tap anywhere dismisses.
   function addressOf(entry) {
       return entry.page
           ? 'p. ' + entry.page
           : (entry.reason === 'ambiguous'
               ? 'no source location (ambiguous ×' + entry.candidates + ')'
               : 'no source location (' + (entry.reason || 'unmatched') + ')');
   }
   function peekHtml(el, entry, withLink) {
       var openHref = pageUrl(VIEWER_PAGE, {
           doc: mw.config.get('wgPageName'), fig: entry.id ||  });

return '

' + (entry.rect ? '<canvas></canvas>' : ) + '

' +

           tagsHtml(el, entry) +

'

' + esc(addressOf(entry)) +
           '' + (withLink && entry.id ? '<a href="' + openHref +
'" target="_blank">open ↗</a>' : ) + '

';

   }
   function renderCrop(container, entry, pdf) {
       if (!entry.rect) { return; }
       pdf.page(entry.page).then(function (pg) {
           var s = pg.viewport.scale;
           var x0 = Math.max(0, (entry.rect[0] - CROP_MARGIN_X) * s);
           var y0 = Math.max(0, (entry.rect[1] - CROP_MARGIN_Y) * s);
           var w = Math.min(pg.canvas.width - x0,
                            (entry.rect[2] - entry.rect[0] + 2 * CROP_MARGIN_X) * s);
           var h = Math.min(pg.canvas.height - y0,
                            (entry.rect[3] - entry.rect[1] + 2 * CROP_MARGIN_Y) * s);
           var cv = container.querySelector('canvas');
           if (!cv) { return; }         // peek already dismissed
           cv.width = w; cv.height = h;
           var ctx = cv.getContext('2d');
           ctx.drawImage(pg.canvas, x0, y0, w, h, 0, 0, w, h);
           ctx.strokeStyle = coral;
           ctx.lineWidth = 2;
           ctx.strokeRect((entry.rect[0]) * s - x0 - 2,
                          (entry.rect[1]) * s - y0 - 2,
                          (entry.rect[2] - entry.rect[0]) * s + 4,
                          (entry.rect[3] - entry.rect[1]) * s + 4);
       }, function () {
           var crop = container.querySelector('.fig-crop');
           if (crop) { crop.textContent = 'mirror PDF not available'; }
       });
   }
   function attachPeek(byEl, pdf) {
       var box = null, activeCue = null;
       function close() {
           if (box) { box.remove(); box = null; }
           activeCue = null;
           document.removeEventListener('click', onAway, true);
       }
       function onAway(e) {
           // clicks INSIDE the surface never dismiss it — the open link
           // must be clickable on both surfaces
           if (box && box.contains(e.target)) { return; }
           if (!cueOf(e)) { close(); }
       }
       function cueOf(e) {
           var t = e.target;
           if (!t || !t.closest) { return null; }
           if (t.closest('.ed-fn')) { return null; }  // footnote cues win
           var el = t.closest('.fig-src');
           return el && byEl.has(el) ? el : null;
       }
       var touch = window.matchMedia &&
           window.matchMedia('(hover: none)').matches;
       if (touch) {
           // tap -> top banner (interactive: it carries the open link);
           // the next tap anywhere dismisses — .ed-fn banner behavior
           document.addEventListener('click', function (e) {
               var cue = cueOf(e);
               if (!cue) { return; }
               e.preventDefault();
               e.stopPropagation();
               close();
               var entry = byEl.get(cue);
               box = document.createElement('div');
               box.className = 'fig-banner';
               box.innerHTML = peekHtml(cue, entry, true);
               document.body.appendChild(box);
               renderCrop(box, entry, pdf);
               setTimeout(function () {
                   document.addEventListener('click', onAway, true);
               }, 0);
           }, false);
       } else {
           // Desktop: CLICK -> anchored card below the cell — the original
           // UI, which the user preferred over a hover peek (and which the
           // touch banner mirrors). The card carries the open link, so
           // unlike the footnote hover popover it is interactive.
           document.addEventListener('click', function (e) {
               if (box && box.contains(e.target)) { return; }
               var cue = cueOf(e);
               if (!cue) { return; }
               e.preventDefault();
               e.stopPropagation();
               close();
               activeCue = cue;
               var entry = byEl.get(cue);
               box = document.createElement('div');
               box.className = 'fig-card';
               box.innerHTML = peekHtml(cue, entry, true);
               document.body.appendChild(box);
               // page coordinates (absolute), so the card scrolls WITH the
               // page like the original; left clamped for narrow viewports
               var r = cue.getBoundingClientRect();
               box.style.left = Math.max(8, Math.min(r.left + window.scrollX,
                   window.scrollX + document.documentElement.clientWidth -
                   box.offsetWidth - 8)) + 'px';
               box.style.top = (r.bottom + window.scrollY + 6) + 'px';
               renderCrop(box, entry, pdf);
               setTimeout(function () {
                   document.addEventListener('click', onAway, true);
               }, 0);
           }, false);
           document.addEventListener('keydown', function (e) {
               if (e.key === 'Escape') { close(); }
           });
       }
   }
   // ── viewer mode (Bot:PDF viewer) ───────────────────────────────────────
   function runViewer() {
       var q = new URLSearchParams(location.search);
       var doc = q.get('doc'), figId = q.get('fig');
       var root = document.querySelector('#mw-content-text');
       if (!doc) {
           root.textContent = 'Missing ?doc=<archive page title>.';
           return;
       }
       // NB: this wiki serves EMPTY 200s for missing action=raw pages —
       // existence must be tested on the body, never on r.ok.
       fetch(rawUrl('Data:' + doc + '.figures.json'))
           .then(function (r) { return r.text(); })
           .then(function (t) {
               if (!t) { throw new Error('no sidecar for ' + doc); }
               return JSON.parse(t);
           })
           .then(function (sidecar) {
               var fig = null;
               sidecar.figures.forEach(function (f) {
                   if (f.id === figId) { fig = f; }
               });
               var pdf = new PdfDoc(mirrorUrl(doc));
               var page = fig ? fig.page : parseInt(q.get('page') || '1', 10);
               // The document name IS the back link (no separate "← back"),
               // and the page label sits BETWEEN prev and next (user design).
               root.innerHTML =

'

<a class="fig-doc" href="' +
                   pageUrl(doc) + '">' + esc(doc.replace(/_/g, ' ')) +
                   '</a>' +
                   '<button class="fig-prev">‹ prev</button>' +
                   '' +
'<button class="fig-next">next ›</button>

' + '

' + '<canvas></canvas>

';

               var render = function () {
                   pdf.page(page).then(function (pg) {
                       var cv = root.querySelector('canvas');
                       cv.width = pg.canvas.width;
                       cv.height = pg.canvas.height;
                       cv.getContext('2d').drawImage(pg.canvas, 0, 0);
                       root.querySelector('.fig-viewer-page').textContent =
                           ' page ' + page;
                       var box = root.querySelector('.fig-box');
                       if (fig && fig.page === page) {
                           // PERCENT coordinates: the canvas is responsive
                           // (max-width:100%), so px offsets computed at
                           // render scale would drift the moment CSS scales
                           // the bitmap — percentages track the displayed
                           // size for free.
                           var s = pg.viewport.scale;
                           var W = pg.canvas.width, H = pg.canvas.height;
                           box.style.display = 'block';
                           box.style.left = ((fig.rect[0] * s - 3) / W * 100) + '%';
                           box.style.top = ((fig.rect[1] * s - 3) / H * 100) + '%';
                           box.style.width = (((fig.rect[2] - fig.rect[0]) * s + 6) / W * 100) + '%';
                           box.style.height = (((fig.rect[3] - fig.rect[1]) * s + 6) / H * 100) + '%';
                           box.scrollIntoView({ block: 'center' });
                       } else {
                           box.style.display = 'none';
                       }
                   });
               };
               root.querySelector('.fig-prev').onclick = function () {
                   if (page > 1) { page--; render(); }
               };
               root.querySelector('.fig-next').onclick = function () {
                   page++; render();
               };
               render();
           })
           .catch(function (e) {
               root.textContent = 'Could not load: ' + e.message;
           });
   }
   // ── archive-mode bootstrap ─────────────────────────────────────────────
   function runArchive() {
       var title = mw.config.get('wgPageName').replace(/_/g, ' ');
       fetch(rawUrl('Data:' + title + '.figures.json'))
           .then(function (r) { return r.text(); })
           .then(function (t) {                 // empty 200 = no sidecar here
               var sidecar = t ? JSON.parse(t) : null;
               if (!sidecar || !sidecar.figures) { return; }
               var byEl = buildArchiveMode(sidecar);
               if (!byEl.size) { return; }
               attachPeek(byEl, new PdfDoc(mirrorUrl(title)));
           });
   }
   addStyle(
       '.fig-src{cursor:pointer}' +
       '.fig-src:hover{text-decoration:underline;' +
       'text-decoration-color:' + coral + ';text-underline-offset:3px}' +
       // full-bleed crop: the popover's own ink border frames the image
       // (no inner padding/margins); only the text block below is inset.
       // All popover text follows the house small size, footer in page ink.
       // ── peek surfaces, mirroring the footnote machinery ──
       // .fig-card = .ed-fn-popover's shape: fixed, body-appended (root
       // stacking context — never clipped by a table scroll wrapper),
       // pointer-events:none, ink frame, house shadow. Color is pinned to
       // --text-dark because body-attached elements inherit the SKIN's
       // #202122, not the content area's ink.
       // absolute (page coords) so it scrolls with the page like the
       // original popover; interactive because it carries the open link
       '.fig-card{position:absolute;z-index:2147483647;' +
       'max-width:min(420px,90vw);background:#fff;padding:0;' +
       'border:1px solid #0d0d0d;color:var(--text-dark,#0d0d0d);' +
       'box-shadow:0 4px 16px rgba(43,41,38,.18);pointer-events:auto}' +
       // full-bleed crop in both surfaces (declare border/margin — an
       // omitted property lets a stale stacked stylesheet rule survive)
       '.fig-card canvas,.fig-banner canvas{width:100%;height:auto;' +
       'display:block;border:0;margin:0}' +
       '.fig-card .fig-crop{border-bottom:1px solid #ddd}' +
       '.fig-card .fig-crop:empty,.fig-banner .fig-crop:empty{display:none}' +
       '.fig-tags{font-size:var(--ed-small,14px);line-height:1.45}' +
       '.fig-card .fig-tags{padding:8px 10px 0}' +
       '.fig-addr{display:flex;justify-content:space-between;' +
       'font-size:var(--ed-small,14px);color:inherit}' +
       '.fig-card .fig-addr{padding:6px 10px 10px}' +
       // .fig-banner = .ed-fn-banner's shape: full-width, pinned to the
       // top, near-black, safe-area padded — the touch surface, and it IS
       // interactive (carries the open link), unlike the hover card
       '.fig-banner{position:fixed;top:0;left:0;right:0;' +
       'z-index:2147483647;box-sizing:border-box;' +
       'padding:calc(0.9em + env(safe-area-inset-top)) 1.2em 0.9em;' +
       'background:rgba(20,18,16,0.92);color:#fff;' +
       'font-size:var(--ed-small,14px);line-height:1.5}' +
       '.fig-banner canvas{margin-bottom:8px}' +
       '.fig-banner .fig-addr{margin-top:4px}' +
       '.fig-banner a{color:#fff;text-decoration:underline}' +
       // one explicit size for every bar item — name, page label, buttons —
       // so nothing inherits a divergent size from the surrounding skin
       // --ed-small is THE house small size (chunk markers, tables, infobox
       // all use it) — the viewer chrome follows it by contract, not by a
       // literal that happens to match today
       '.fig-viewer-bar{display:flex;gap:14px;align-items:center;' +
       'padding:8px 0;margin-bottom:12px;' +
       'font-size:var(--ed-small,14px);border-bottom:0}' +
       '.fig-viewer-bar .fig-doc{font-weight:bold;' +
       'font-size:var(--ed-small,14px)}' +
       '.fig-controls{display:flex;gap:10px;align-items:center;' +
       'margin-left:auto}' +
       '.fig-viewer-bar button{font:inherit;' +
       'font-size:var(--ed-small,14px);background:#fff;' +
       'border:1px solid #0d0d0d;padding:2px 12px;cursor:pointer}' +
       '.fig-stage{overflow:auto;text-align:center}' +
       // the page frame: ink border so the white page reads as an OBJECT on
       // the white wiki background; position:relative so the coral box is
       // canvas-anchored (absolute children resolve against the padding box,
       // so the border adds no offset) — centering via the stage no longer
       // drifts the box on wide windows
       '.fig-page{position:relative;display:inline-block;' +
       'border:1px solid #0d0d0d;background:#fff}' +
       // responsive page: never wider than the column, so the right border
       // stays visible and no horizontal scrollbar (the house dark
       // scrollbar was the "thick dark line" under the page)
       '.fig-page canvas{display:block;max-width:100%;height:auto}' +
       '.fig-box{position:absolute;display:none;border:3px solid ' + coral +
       ';pointer-events:none}');
   function boot() {
       if (mw.config.get('wgPageName') === VIEWER_PAGE.replace(/ /g, '_')) {
           runViewer();
       } else if (mw.config.get('wgNamespaceNumber') === 0) {
           runArchive();
       }
   }
   // A dependency-free gadget can execute before the content is parsed.
   if (document.readyState === 'loading') {
       document.addEventListener('DOMContentLoaded', boot);
   } else {
       boot();
   }

}());