From 23bc2d0dc15cab67aae36410988a0bcc16677693 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 11:25:19 -0400 Subject: [PATCH] Frontend tail: keyboard access, idempotence, input edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gallery.js: math/score focus overlays are keyboard-activatable (role=button, tabindex, Enter/Space) and focus return on close lands on a focusable trigger (AUDIT §5.7) - annotations.js: marks are focusable; Enter/Space pins the tooltip with focus moved to its Delete button, Escape dismisses — the delete affordance is finally reachable without a mouse (§5.7) - transclude.js: nested transclusions resolve (depth-capped at 3, with ancestor-chain cycle rejection rendering the existing error style); collapse.js reinit is idempotent via data-collapse-bound (§5.7) - copy.js excludes the button label from code-less
 copies;
  score-reader.js stops rewriting plain loads to ?p=1; search-filters
  treats non-numeric threshold input as inactive instead of a
  match-everything >=0 filter; selection-popup no longer re-summons
  the toolbar while typing capitals in the annotation picker (§5.8)

Co-Authored-By: Claude Fable 5 
---
 static/js/annotations.js     | 39 ++++++++++++++++++++++++++++++++++++
 static/js/collapse.js        |  6 ++++++
 static/js/copy.js            | 15 +++++++++++---
 static/js/gallery.js         | 21 ++++++++++++++++---
 static/js/score-reader.js    |  6 +++++-
 static/js/search-filters.js  |  7 ++++++-
 static/js/selection-popup.js |  9 +++++++++
 static/js/transclude.js      | 29 +++++++++++++++++++++++++--
 8 files changed, 122 insertions(+), 10 deletions(-)

diff --git a/static/js/annotations.js b/static/js/annotations.js
index 0baebae..9abac29 100644
--- a/static/js/annotations.js
+++ b/static/js/annotations.js
@@ -12,6 +12,8 @@
     var STORAGE_KEY = 'site-annotations';
     var tooltip     = null;
     var tooltipTimer = null;
+    var tooltipPinned = false; /* keyboard-opened: blur must not dismiss */
+    var tooltipMark   = null;  /* mark that opened the tooltip, for focus return */
 
     /* ------------------------------------------------------------------
        Storage
@@ -148,6 +150,18 @@
 
         tooltip.addEventListener('mouseenter', function () { clearTimeout(tooltipTimer); });
         tooltip.addEventListener('mouseleave', function () { hideTooltip(false); });
+
+        /* Keyboard flow: Escape closes a pinned tooltip and returns focus
+           to its mark; tabbing out of the tooltip dismisses it. */
+        tooltip.addEventListener('keydown', function (e) {
+            if (e.key === 'Escape') {
+                hideTooltip(true);
+                if (tooltipMark) tooltipMark.focus();
+            }
+        });
+        tooltip.addEventListener('focusout', function (e) {
+            if (!tooltip.contains(e.relatedTarget)) hideTooltip(false);
+        });
     }
 
     /* Defer to the shared utility (loaded synchronously from
@@ -159,6 +173,8 @@
 
     function showTooltip(mark, ann) {
         clearTimeout(tooltipTimer);
+        tooltipPinned = false;
+        tooltipMark   = mark;
 
         var note    = ann.note    || '';
         var created = ann.created ? new Date(ann.created).toLocaleDateString() : '';
@@ -197,6 +213,7 @@
 
     function hideTooltip(immediate) {
         clearTimeout(tooltipTimer);
+        tooltipPinned = false;
         if (immediate) {
             if (tooltip) tooltip.classList.remove('is-visible');
         } else {
@@ -212,6 +229,28 @@
             showTooltip(mark, ann);
         });
         mark.addEventListener('mouseleave', function () { hideTooltip(false); });
+
+        /* Keyboard: focus mirrors hover; Enter/Space pins the tooltip and
+           moves focus to its Delete button; Escape dismisses. */
+        mark.setAttribute('tabindex', '0');
+        mark.addEventListener('focus', function () {
+            clearTimeout(tooltipTimer);
+            showTooltip(mark, ann);
+        });
+        mark.addEventListener('blur', function () {
+            if (!tooltipPinned) hideTooltip(false);
+        });
+        mark.addEventListener('keydown', function (e) {
+            if (e.key === 'Enter' || e.key === ' ') {
+                e.preventDefault();
+                showTooltip(mark, ann);
+                tooltipPinned = true;
+                var del = tooltip.querySelector('.ann-tooltip-delete');
+                if (del) del.focus();
+            } else if (e.key === 'Escape') {
+                hideTooltip(true);
+            }
+        });
     }
 
     /* ------------------------------------------------------------------
diff --git a/static/js/collapse.js b/static/js/collapse.js
index a7f4630..43f6d3d 100644
--- a/static/js/collapse.js
+++ b/static/js/collapse.js
@@ -16,6 +16,11 @@
     var store  = window.lnUtils && window.lnUtils.safeStorage;
 
     function initHeading(heading) {
+        // Idempotence guard: reinitCollapse may be called more than once on
+        // the same container — never re-wrap a section or stack toggle
+        // buttons (matches the popups.js/sidenotes.js convention).
+        if (heading.dataset.collapseBound === '1') return;
+
         var level   = parseInt(heading.tagName[1], 10);
         var content = [];
         var node    = heading.nextElementSibling;
@@ -28,6 +33,7 @@
             node = node.nextElementSibling;
         }
         if (!content.length) return;
+        heading.dataset.collapseBound = '1';
 
         // Wrap collected nodes in a .section-body div.
         var wrapper   = document.createElement('div');
diff --git a/static/js/copy.js b/static/js/copy.js
index fb2f6e3..b9c9f26 100644
--- a/static/js/copy.js
+++ b/static/js/copy.js
@@ -17,9 +17,18 @@
         btn.setAttribute('aria-label', 'Copy code to clipboard');
 
         btn.addEventListener('click', function () {
-            var text = pre.querySelector('code')
-                ? pre.querySelector('code').innerText
-                : pre.innerText;
+            var code = pre.querySelector('code');
+            var text;
+            if (code) {
+                text = code.innerText;
+            } else {
+                /* Code-less 
: clone and strip the injected button so
+                   its label is not copied along with the content. */
+                var clone = pre.cloneNode(true);
+                var cloneBtn = clone.querySelector('.copy-btn');
+                if (cloneBtn) cloneBtn.remove();
+                text = clone.innerText;
+            }
 
             navigator.clipboard.writeText(text).then(function () {
                 btn.textContent = 'copied';
diff --git a/static/js/gallery.js b/static/js/gallery.js
index cd8aa64..5e53c0c 100644
--- a/static/js/gallery.js
+++ b/static/js/gallery.js
@@ -88,6 +88,21 @@
         return exhibit.dataset.exhibitCaption || '';
     }
 
+    /* Make an exhibit wrapper keyboard-operable: role=button, tabindex,
+       and Enter/Space sharing the click path. closeOverlay()'s focus
+       return relies on the wrapper being focusable. */
+    function bindActivation(el, activate) {
+        el.setAttribute('role', 'button');
+        el.setAttribute('tabindex', '0');
+        el.addEventListener('click', activate);
+        el.addEventListener('keydown', function (e) {
+            if (e.key === 'Enter' || e.key === ' ') {
+                e.preventDefault();
+                activate();
+            }
+        });
+    }
+
     function discoverFocusableMath(markdownBody) {
         markdownBody.querySelectorAll('.katex-display').forEach(function (katexEl) {
             var source    = getSource(katexEl);
@@ -118,8 +133,8 @@
             };
             focusables.push(entry);
 
-            /* Click anywhere on the wrapper opens the overlay */
-            wrapper.addEventListener('click', function () {
+            /* Click or Enter/Space anywhere on the wrapper opens the overlay */
+            bindActivation(wrapper, function () {
                 openOverlay(focusables.indexOf(entry));
             });
         });
@@ -151,7 +166,7 @@
             };
             focusables.push(entry);
 
-            figEl.addEventListener('click', function () {
+            bindActivation(figEl, function () {
                 openOverlay(focusables.indexOf(entry));
             });
         });
diff --git a/static/js/score-reader.js b/static/js/score-reader.js
index d65557f..bb95cca 100644
--- a/static/js/score-reader.js
+++ b/static/js/score-reader.js
@@ -23,6 +23,9 @@
 
     /* Read ?p= from the query string for deep linking. */
     var qs = new URLSearchParams(window.location.search);
+    /* Keep the canonical URL clean on plain loads: only sync ?p= back to
+       the URL when one was already present or the user navigates. */
+    var syncUrl = qs.has('p');
     var initPage = parseInt(qs.get('p'), 10);
     if (!isNaN(initPage) && initPage >= 1 && initPage <= pageCount) {
         currentPage = initPage;
@@ -47,7 +50,7 @@
 
         /* Replace URL so the page is bookmarkable at the current position.
            The back button still returns to the landing page. */
-        history.replaceState(null, '', '?p=' + currentPage);
+        if (syncUrl) history.replaceState(null, '', '?p=' + currentPage);
 
         /* Preload the adjacent pages for smooth turning. */
         if (currentPage > 1)         new Image().src = pages[currentPage - 2];
@@ -132,4 +135,5 @@
     ------------------------------------------------------------------ */
 
     navigate(currentPage);
+    syncUrl = true; /* any later navigate() is a user action — sync from here on */
 }());
diff --git a/static/js/search-filters.js b/static/js/search-filters.js
index 38e5625..83d03a7 100644
--- a/static/js/search-filters.js
+++ b/static/js/search-filters.js
@@ -273,7 +273,12 @@
             if (!el) return;
             el.addEventListener('input', function () {
                 var v = el.value.trim();
-                state[field] = v !== '' ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : null;
+                var n = parseInt(v, 10);
+                /* Non-numeric input deactivates the filter (null) rather
+                   than coercing to an always-matching >= 0 threshold. */
+                state[field] = (v !== '' && !isNaN(n))
+                    ? Math.max(0, Math.min(100, n))
+                    : null;
                 loadMeta().then(applyFilters);
             });
         });
diff --git a/static/js/selection-popup.js b/static/js/selection-popup.js
index 5ab064b..e8c62ff 100644
--- a/static/js/selection-popup.js
+++ b/static/js/selection-popup.js
@@ -88,6 +88,15 @@
     }
 
     function onKeyUp(e) {
+        /* Typing capitals in the annotation picker's note input (or any
+           other editable field) releases Shift — don't re-summon the
+           toolbar over the UI the user is typing into. */
+        var t = e.target;
+        if (t && t.nodeType === Node.ELEMENT_NODE) {
+            if (popup.contains(t)) return;
+            if (picker && picker.contains(t)) return;
+            if (t.isContentEditable || t.closest('input, textarea')) return;
+        }
         if (e.shiftKey || e.key === 'End' || e.key === 'Home') {
             clearTimeout(showTimer);
             showTimer = setTimeout(tryShow, SHOW_DELAY);
diff --git a/static/js/transclude.js b/static/js/transclude.js
index 8aa4882..6895dc7 100644
--- a/static/js/transclude.js
+++ b/static/js/transclude.js
@@ -108,11 +108,26 @@
         }
     }
 
-    function loadTransclusion(el) {
+    /* Nested transclusion limits: ancestors carries the chain of srcs
+     * currently being expanded (cycle guard — a self-transcluding page
+     * must not loop), and MAX_DEPTH caps pathological nesting. */
+    var MAX_DEPTH = 3;
+
+    function loadTransclusion(el, depth, ancestors) {
+        depth     = depth     || 0;
+        ancestors = ancestors || [];
+
         var src     = el.dataset.src;
         var section = el.dataset.section || null;
         if (!src) return;
 
+        if (depth >= MAX_DEPTH || ancestors.indexOf(src) !== -1) {
+            el.classList.add('transclude--error');
+            el.textContent = '[transclusion omitted (cycle or depth limit): '
+                + src + (section ? '#' + section : '') + ']';
+            return;
+        }
+
         el.classList.add('transclude--loading');
 
         fetchPage(src)
@@ -138,6 +153,14 @@
                 el.classList.replace('transclude--loading', 'transclude--loaded');
                 el.appendChild(wrapper);
 
+                /* The fetched page may itself contain transclusion
+                   placeholders — process them too, extending the
+                   ancestor chain for cycle/depth guarding. */
+                var chain = ancestors.concat(src);
+                wrapper.querySelectorAll('div.transclude').forEach(function (nested) {
+                    loadTransclusion(nested, depth + 1, chain);
+                });
+
                 reinitFragment(el);
             })
             .catch(function (err) {
@@ -147,6 +170,8 @@
     }
 
     document.addEventListener('DOMContentLoaded', function () {
-        document.querySelectorAll('div.transclude').forEach(loadTransclusion);
+        document.querySelectorAll('div.transclude').forEach(function (el) {
+            loadTransclusion(el);
+        });
     });
 }());