ssh git@grit.deadkey.org man

Commit 9ff498d h

Pat Ambrosio · 26-08-27 13:20

Tooltips on footnote and citation markers

Changes: 4 files +514 -5

Changes (Side-by-Side)

share/jenny/layout/page.sh +181 -0

cat << _EOF_
725 .theme-toggle:hover { 725 .theme-toggle:hover {
726 background-color: var(--bg-secondary); 726 background-color: var(--bg-secondary);
727 } 727 }
728 /* Tooltip styling for footnote and citation references.
729 Rendered as real DOM elements so links remain interactive.
730 Appears on hover or focus, and survives pointer movement into it. */
731 .fnref[data-fnref],
732 .citation[data-citation] {
733 position: relative;
734 }
735 /* Tooltip is a real element, positioned absolutely. */
736 .fn-tip,
737 .citation-tip {
738 position: absolute;
739 bottom: 100%;
740 left: 50%;
741 transform: translateX(-50%);
742 background-color: var(--bg-secondary);
743 color: var(--text-primary);
744 border: 1px solid var(--border-medium);
745 border-radius: 3px;
746 padding: 0.5em 0.75em;
747 font-size: 0.85em;
748 line-height: 1.4;
749 white-space: normal;
750 max-width: min(280px, calc(100vw - 2em));
751 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
752 z-index: 1000;
753 opacity: 0;
754 visibility: hidden;
755 transition: none;
756 margin-bottom: 0.5em;
757 pointer-events: auto;
758 }
759 /* Tooltip links are clickable. */
760 .fn-tip a,
761 .citation-tip a {
762 color: var(--link-article);
763 text-decoration: underline;
764 cursor: pointer;
765 }
766 /* Show tooltip. */
767 .fn-tip.visible,
768 .citation-tip.visible {
769 opacity: 1;
770 visibility: visible;
771 }
772 /* The tooltip is centred on its marker, so a marker near the right
773 margin pushes it past the edge -- at 440px a 232px tooltip on a
774 marker at x=329 ended 5px off-screen. Capping the width cannot fix
775 that: the box was already well inside its limit and the problem is
776 where it sits, not how wide it is.
777
778 The script measures each tooltip after showing it and nudges it back
779 inside, because only the browser knows where the marker landed after
780 the line wrapped. The CSS keeps the centring as the starting point;
781 anything that fits is left alone. */
782 @media (max-width: 380px) {
783 .fn-tip,
784 .citation-tip {
785 left: auto;
786 right: auto;
787 transform: none;
788 }
789 }
728 790
729 </style> 791 </style>
730 <script> 792 <script>
cat << _EOF_
805 applyTheme(newTheme); 867 applyTheme(newTheme);
806 localStorage.setItem('theme', newTheme); 868 localStorage.setItem('theme', newTheme);
807 }); 869 });
870
871 // Initialize footnote and citation tooltips
872 function initTooltips() {
873 // Footnotes: create tooltip elements with cloned content
874 var fnMarkers = document.querySelectorAll('.fnref[data-fnref]');
875 fnMarkers.forEach(function(marker) {
876 var fnId = marker.getAttribute('data-fnref');
877 var fnItem = document.getElementById('fn-' + fnId);
878 if (fnItem) {
879 // Extract content from .fn-text, but exclude the backref link
880 var fnText = fnItem.querySelector('.fn-text');
881 if (fnText) {
882 // Clone and remove the backref
883 var clone = fnText.cloneNode(true);
884 var backref = clone.querySelector('.fn-backref');
885 if (backref) {
886 backref.parentNode.removeChild(backref);
887 }
888 // Create tooltip element with cloned content
889 var tooltip = document.createElement('div');
890 tooltip.className = 'fn-tip';
891 tooltip.setAttribute('role', 'tooltip');
892 // Move cloned children into tooltip
893 while (clone.firstChild) {
894 tooltip.appendChild(clone.firstChild);
895 }
896 marker.appendChild(tooltip);
897 }
898 }
899 });
900
901 // Citations: create tooltip elements with cloned content
902 var citationMarkers = document.querySelectorAll('.citation[data-citation]');
903 citationMarkers.forEach(function(marker) {
904 var cid = marker.getAttribute('data-citation');
905 var cnNum = marker.getAttribute('href').replace('#cn:', '');
906 var citItem = document.getElementById('cn:' + cnNum);
907 if (citItem) {
908 // Extract content from the paragraph, but exclude the reversecitation link
909 var citP = citItem.querySelector('p');
910 if (citP) {
911 // Clone and remove the reversecitation link
912 var clone = citP.cloneNode(true);
913 var reverselink = clone.querySelector('.reversecitation');
914 if (reverselink) {
915 reverselink.parentNode.removeChild(reverselink);
916 }
917 // Create tooltip element with cloned content
918 var tooltip = document.createElement('div');
919 tooltip.className = 'citation-tip';
920 tooltip.setAttribute('role', 'tooltip');
921 // Move cloned children into tooltip
922 while (clone.firstChild) {
923 tooltip.appendChild(clone.firstChild);
924 }
925 marker.appendChild(tooltip);
926 }
927 }
928 });
929
930 // Shared close timer: on mouseleave, start timer; on mouseenter, cancel it.
931 // This allows the pointer to move from marker to tooltip without closing.
932 function attachTooltipHandlers(markers, tooltipClass) {
933 markers.forEach(function(marker) {
934 var tooltip = marker.querySelector('.' + tooltipClass);
935 if (!tooltip) return;
936 var closeTimer;
937
938 var showTooltip = function() {
939 clearTimeout(closeTimer);
940 // Reset before measuring: a previous nudge would otherwise be
941 // measured as if it were the natural position.
942 tooltip.style.marginLeft = '';
943 tooltip.classList.add('visible');
944 // Centred on its marker, a tooltip near a margin hangs off
945 // the edge -- how far depends on where the marker landed
946 // after the line wrapped, which only the browser knows. Shift
947 // it back inside; anything that already fits is untouched.
948 var box = tooltip.getBoundingClientRect();
949 var edge = 8;
950 var over = box.right - (document.documentElement.clientWidth - edge);
951 var under = edge - box.left;
952 if (over > 0) {
953 tooltip.style.marginLeft = (-over) + 'px';
954 } else if (under > 0) {
955 tooltip.style.marginLeft = under + 'px';
956 }
957 };
958
959 var scheduleHideTooltip = function() {
960 closeTimer = setTimeout(function() {
961 tooltip.classList.remove('visible');
962 }, 150);
963 };
964
965 // Show on marker hover
966 marker.addEventListener('mouseenter', showTooltip);
967 marker.addEventListener('mouseleave', scheduleHideTooltip);
968
969 // Show on marker focus
970 marker.addEventListener('focusin', showTooltip);
971 marker.addEventListener('focusout', scheduleHideTooltip);
972
973 // Keep open while pointer is over tooltip
974 tooltip.addEventListener('mouseenter', showTooltip);
975 tooltip.addEventListener('mouseleave', scheduleHideTooltip);
976 });
977 }
978
979 attachTooltipHandlers(fnMarkers, 'fn-tip');
980 attachTooltipHandlers(citationMarkers, 'citation-tip');
981 }
982
983 // Run after DOM is ready
984 if (document.readyState === 'loading') {
985 document.addEventListener('DOMContentLoaded', initTooltips);
986 } else {
987 initTooltips();
988 }
808 })(); 989 })();
809 </script> 990 </script>
810 </head> 991 </head>

share/jenny/layout/post.sh +181 -0

cat << _EOF_
546 .theme-toggle:hover { 546 .theme-toggle:hover {
547 background-color: var(--bg-secondary); 547 background-color: var(--bg-secondary);
548 } 548 }
549 /* Tooltip styling for footnote and citation references.
550 Rendered as real DOM elements so links remain interactive.
551 Appears on hover or focus, and survives pointer movement into it. */
552 .fnref[data-fnref],
553 .citation[data-citation] {
554 position: relative;
555 }
556 /* Tooltip is a real element, positioned absolutely. */
557 .fn-tip,
558 .citation-tip {
559 position: absolute;
560 bottom: 100%;
561 left: 50%;
562 transform: translateX(-50%);
563 background-color: var(--bg-secondary);
564 color: var(--text-primary);
565 border: 1px solid var(--border-medium);
566 border-radius: 3px;
567 padding: 0.5em 0.75em;
568 font-size: 0.85em;
569 line-height: 1.4;
570 white-space: normal;
571 max-width: min(280px, calc(100vw - 2em));
572 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
573 z-index: 1000;
574 opacity: 0;
575 visibility: hidden;
576 transition: none;
577 margin-bottom: 0.5em;
578 pointer-events: auto;
579 }
580 /* Tooltip links are clickable. */
581 .fn-tip a,
582 .citation-tip a {
583 color: var(--link-article);
584 text-decoration: underline;
585 cursor: pointer;
586 }
587 /* Show tooltip. */
588 .fn-tip.visible,
589 .citation-tip.visible {
590 opacity: 1;
591 visibility: visible;
592 }
593 /* The tooltip is centred on its marker, so a marker near the right
594 margin pushes it past the edge -- at 440px a 232px tooltip on a
595 marker at x=329 ended 5px off-screen. Capping the width cannot fix
596 that: the box was already well inside its limit and the problem is
597 where it sits, not how wide it is.
598
599 The script measures each tooltip after showing it and nudges it back
600 inside, because only the browser knows where the marker landed after
601 the line wrapped. The CSS keeps the centring as the starting point;
602 anything that fits is left alone. */
603 @media (max-width: 380px) {
604 .fn-tip,
605 .citation-tip {
606 left: auto;
607 right: auto;
608 transform: none;
609 }
610 }
549 </style> 611 </style>
550 <script> 612 <script>
551 (function() { 613 (function() {
cat << _EOF_
577 applyTheme(newTheme); 639 applyTheme(newTheme);
578 localStorage.setItem('theme', newTheme); 640 localStorage.setItem('theme', newTheme);
579 }); 641 });
642
643 // Initialize footnote and citation tooltips
644 function initTooltips() {
645 // Footnotes: create tooltip elements with cloned content
646 var fnMarkers = document.querySelectorAll('.fnref[data-fnref]');
647 fnMarkers.forEach(function(marker) {
648 var fnId = marker.getAttribute('data-fnref');
649 var fnItem = document.getElementById('fn-' + fnId);
650 if (fnItem) {
651 // Extract content from .fn-text, but exclude the backref link
652 var fnText = fnItem.querySelector('.fn-text');
653 if (fnText) {
654 // Clone and remove the backref
655 var clone = fnText.cloneNode(true);
656 var backref = clone.querySelector('.fn-backref');
657 if (backref) {
658 backref.parentNode.removeChild(backref);
659 }
660 // Create tooltip element with cloned content
661 var tooltip = document.createElement('div');
662 tooltip.className = 'fn-tip';
663 tooltip.setAttribute('role', 'tooltip');
664 // Move cloned children into tooltip
665 while (clone.firstChild) {
666 tooltip.appendChild(clone.firstChild);
667 }
668 marker.appendChild(tooltip);
669 }
670 }
671 });
672
673 // Citations: create tooltip elements with cloned content
674 var citationMarkers = document.querySelectorAll('.citation[data-citation]');
675 citationMarkers.forEach(function(marker) {
676 var cid = marker.getAttribute('data-citation');
677 var cnNum = marker.getAttribute('href').replace('#cn:', '');
678 var citItem = document.getElementById('cn:' + cnNum);
679 if (citItem) {
680 // Extract content from the paragraph, but exclude the reversecitation link
681 var citP = citItem.querySelector('p');
682 if (citP) {
683 // Clone and remove the reversecitation link
684 var clone = citP.cloneNode(true);
685 var reverselink = clone.querySelector('.reversecitation');
686 if (reverselink) {
687 reverselink.parentNode.removeChild(reverselink);
688 }
689 // Create tooltip element with cloned content
690 var tooltip = document.createElement('div');
691 tooltip.className = 'citation-tip';
692 tooltip.setAttribute('role', 'tooltip');
693 // Move cloned children into tooltip
694 while (clone.firstChild) {
695 tooltip.appendChild(clone.firstChild);
696 }
697 marker.appendChild(tooltip);
698 }
699 }
700 });
701
702 // Shared close timer: on mouseleave, start timer; on mouseenter, cancel it.
703 // This allows the pointer to move from marker to tooltip without closing.
704 function attachTooltipHandlers(markers, tooltipClass) {
705 markers.forEach(function(marker) {
706 var tooltip = marker.querySelector('.' + tooltipClass);
707 if (!tooltip) return;
708 var closeTimer;
709
710 var showTooltip = function() {
711 clearTimeout(closeTimer);
712 // Reset before measuring: a previous nudge would otherwise be
713 // measured as if it were the natural position.
714 tooltip.style.marginLeft = '';
715 tooltip.classList.add('visible');
716 // Centred on its marker, a tooltip near a margin hangs off
717 // the edge -- how far depends on where the marker landed
718 // after the line wrapped, which only the browser knows. Shift
719 // it back inside; anything that already fits is untouched.
720 var box = tooltip.getBoundingClientRect();
721 var edge = 8;
722 var over = box.right - (document.documentElement.clientWidth - edge);
723 var under = edge - box.left;
724 if (over > 0) {
725 tooltip.style.marginLeft = (-over) + 'px';
726 } else if (under > 0) {
727 tooltip.style.marginLeft = under + 'px';
728 }
729 };
730
731 var scheduleHideTooltip = function() {
732 closeTimer = setTimeout(function() {
733 tooltip.classList.remove('visible');
734 }, 150);
735 };
736
737 // Show on marker hover
738 marker.addEventListener('mouseenter', showTooltip);
739 marker.addEventListener('mouseleave', scheduleHideTooltip);
740
741 // Show on marker focus
742 marker.addEventListener('focusin', showTooltip);
743 marker.addEventListener('focusout', scheduleHideTooltip);
744
745 // Keep open while pointer is over tooltip
746 tooltip.addEventListener('mouseenter', showTooltip);
747 tooltip.addEventListener('mouseleave', scheduleHideTooltip);
748 });
749 }
750
751 attachTooltipHandlers(fnMarkers, 'fn-tip');
752 attachTooltipHandlers(citationMarkers, 'citation-tip');
753 }
754
755 // Run after DOM is ready
756 if (document.readyState === 'loading') {
757 document.addEventListener('DOMContentLoaded', initTooltips);
758 } else {
759 initTooltips();
760 }
580 })(); 761 })();
581 </script> 762 </script>
582 </head> 763 </head>

share/jenny/lib/md2html.awk +2 -2

function nextil(t) {
180 cloc = cite_locator; 180 cloc = cite_locator;
181 cite_locator = ""; 181 cite_locator = "";
182 clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")"; 182 clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")";
183 return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum \
183 return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" cid \
184 "\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2); 184 "\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2);
185 } 185 }
186 186
function nextil(t) {
189 match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/); 189 match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/);
190 linktext = substr(t2, 1, RLENGTH); 190 linktext = substr(t2, 1, RLENGTH);
191 t2 = substr(t2, RLENGTH + 2); 191 t2 = substr(t2, RLENGTH + 2);
192 return t1 "<sup class=\"fnref\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2);
192 return t1 "<sup class=\"fnref\" data-fnref=\"" linktext "\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2);
193 } 193 }
194 # Links 194 # Links
195 if(tag == "["){ 195 if(tag == "["){

test/regressions.sh +150 -3

it "a sibling links back to the directory, not the site root"
1114 sib_head=$("$GREP" -o '<header>.*</header>' "$ix/.dist/thesis/mdd/artifacts.html" | head -1) 1114 sib_head=$("$GREP" -o '<header>.*</header>' "$ix/.dist/thesis/mdd/artifacts.html" | head -1)
1115 assert_contains "$sib_head" 'href="../../thesis/mdd/">mdd</a>' 1115 assert_contains "$sib_head" 'href="../../thesis/mdd/">mdd</a>'
1116 1116
1117 it "a directory with no index still points home"
1118 # Nothing to link to there, so the old behaviour is right and stays.
1117 it "a directory without its own index gets a generated one"
1118 # A crumb that led to the site root looked like navigation and was not. Every
1119 # section now has a page, so every segment leads somewhere it says it does.
1120 assert_file_exists "$ix/.dist/notes/method/index.html"
1119 no_ix=$("$GREP" -o '<header>.*</header>' "$ix/.dist/notes/method/intro.html" | head -1) 1121 no_ix=$("$GREP" -o '<header>.*</header>' "$ix/.dist/notes/method/intro.html" | head -1)
1120 assert_contains "$no_ix" 'href="../../">method</a>'
1122 assert_contains "$no_ix" 'href="../../notes/method/">method</a>'
1123
1124 it "the generated listing names the pages under it"
1125 # The page's own title, not its slug: the listing is for a reader, and the
1126 # title is what the author called it.
1127 assert_file_contains "$ix/.dist/notes/method/index.html" '/notes/method/intro'
1128
1129 it "each entry gets its own row"
1130 # Not a bullet list, and not one run-together line: markdown joins adjacent
1131 # lines into a single paragraph, so the entries are separated by a blank line
1132 # and each becomes its own paragraph.
1133 mkdir -p "$ix/pages/docs"
1134 printf '# Alpha One\n\nx\n' > "$ix/pages/docs/alpha.md"
1135 printf '# Beta Two\n\nx\n' > "$ix/pages/docs/beta.md"
1136 run_jenny_split "$ix"
1137 rows=$("$GREP" -c '<p><a href="/docs/' "$ix/.dist/docs/index.html")
1138 assert_eq "$rows" "2"
1139 assert_file_not_contains "$ix/.dist/docs/index.html" '<li><a href="/docs/'
1140
1141 it "a label derived from a path is title cased"
1142 # "migration driven development" is a slug read aloud, not something anyone
1143 # wrote, so presenting it lowercase looks like a mistake.
1144 assert_file_contains "$ix/.dist/docs/index.html" '>Docs<'
1145
1146 it "a page's own title keeps the author's casing"
1147 # Only derived labels are re-cased. A title with a deliberate lowercase word
1148 # must survive: "Method, an Introduction" must not become "Method, An
1149 # Introduction".
1150 printf '# Method, an Introduction\n\nBody.\n' > "$ix/pages/notes/method/intro.md"
1151 run_jenny_split "$ix"
1152 assert_file_contains "$ix/.dist/notes/method/index.html" 'Method, an Introduction'
1153
1154 it "a generated listing is marked so it can be swept"
1155 # It is written into the source tree, which is where render_pages reads from.
1156 # Unmarked, a directory that later gains a real index.md would keep the
1157 # generated one and the author's page would never render.
1158 assert_file_contains "$ix/pages/notes/method/index.md" 'generated-section-index'
1159
1160 it "a hand-written index is never overwritten"
1161 assert_file_not_contains "$ix/pages/thesis/mdd/index.md" 'generated-section-index'
1121 1162
1122 it "a page whose name merely starts with index keeps its crumb" 1163 it "a page whose name merely starts with index keeps its crumb"
1123 # The rule fires on a segment named exactly "index"; "indexing" is a page like 1164 # The rule fires on a segment named exactly "index"; "indexing" is a page like
assert_file_contains "$nav_sb/.dist/test.html" '--text-muted'
2357 assert_file_contains "$nav_sb/.dist/test.html" '--link-nav' 2398 assert_file_contains "$nav_sb/.dist/test.html" '--link-nav'
2358 assert_file_contains "$nav_sb/.dist/test.html" '--bg-primary' 2399 assert_file_contains "$nav_sb/.dist/test.html" '--bg-primary'
2359 2400
2401 suite "Footnote and citation tooltips"
2402
2403 # Build a blog with a post containing footnotes and citations.
2404 tt="$TMPROOT/tooltips"
2405 make_blog_min "$tt" 1 10
2406 printf '# Test Post\n\nText with a footnote[^1] and a citation[#myref].\n\n[^1]: This is the footnote text.\n\n[#myref]: This is the citation text.' > "$tt/2024-01-01 tooltips.md"
2407 run_jenny "$tt"
2408 tt_post="$tt/.dist/post/tooltips.html"
2409
2410 it "footnote markers get a data-fnref attribute"
2411 # Markers store the footnote ID for JavaScript to look up the text.
2412 assert_file_contains "$tt_post" 'data-fnref='
2413
2414 it "citation markers get a data-citation attribute"
2415 # Citation markers store the citation ID for JavaScript lookup.
2416 assert_file_contains "$tt_post" 'data-citation='
2417
2418 it "tooltip CSS classes exist for footnotes"
2419 # Tooltips are real elements with fn-tip class.
2420 assert_file_contains "$tt_post" '.fn-tip'
2421
2422 it "tooltip CSS classes exist for citations"
2423 # Citation tooltips use citation-tip class.
2424 assert_file_contains "$tt_post" '.citation-tip {'
2425
2426 it "tooltip elements are positioned absolutely"
2427 # Tooltips must be positioned absolutely to overlay.
2428 assert_file_contains "$tt_post" 'position: absolute'
2429
2430 it "tooltips become visible when class is applied"
2431 # The .visible class toggled by JavaScript.
2432 assert_file_contains "$tt_post" '.fn-tip.visible'
2433
2434 it "tooltips show on keyboard focus events"
2435 # Script listens to focusin and focusout.
2436 assert_file_contains "$tt_post" "addEventListener('focusin'"
2437
2438 it "a tooltip is kept inside the viewport by measurement, not a breakpoint"
2439 # Centred on its marker, a tooltip near the right margin hangs off the edge --
2440 # at 440px a 232px tooltip on a marker at x=329 ended 5px off-screen, while its
2441 # max-width was still well within the cap. Capping the width cannot fix a
2442 # position problem, and a breakpoint written for 320px left every width between
2443 # there and the desktop case unguarded.
2444 #
2445 # Only the browser knows where the marker landed after the line wrapped, so the
2446 # tooltip is measured once shown and nudged back inside.
2447 assert_file_contains "$tt_post" 'tooltip.getBoundingClientRect()'
2448 # The shift must be BY the overflow, not a constant: setting it to a fixed
2449 # value leaves the tooltip wherever it was and still satisfies a test that only
2450 # looks for the property name.
2451 assert_file_contains "$tt_post" "marginLeft = (-over) + 'px'"
2452 assert_file_contains "$tt_post" "marginLeft = under + 'px'"
2453 assert_file_contains "$tt_post" 'clientWidth'
2454
2455 it "the nudge is reset before each measurement"
2456 # Otherwise a previous shift is measured as if it were the natural position and
2457 # the tooltip walks further off-screen each time it is shown.
2458 assert_file_contains "$tt_post" "tooltip.style.marginLeft = '';"
2459
2460 it "showing a tooltip cancels any pending hide"
2461 # The bridge is what lets the pointer cross the gap from marker to tooltip: on
2462 # leaving either, a hide is scheduled; on entering either, it must be cancelled.
2463 # Without the cancel the tooltip closes underneath a pointer that is heading
2464 # for it, and every link inside becomes unreachable.
2465 assert_file_contains "$tt_post" 'clearTimeout(closeTimer);'
2466
2467 it "tooltips have a shared close timer bridge"
2468 # Prevents closing when pointer moves from marker to tooltip.
2469 assert_file_contains "$tt_post" 'closeTimer = setTimeout'
2470
2471 it "tooltips have no animation"
2472 # transition: none ensures instant appearance with no fade/delay.
2473 assert_file_contains "$tt_post" 'transition: none'
2474
2475 it "tooltips are positioned absolutely"
2476 # Tooltips must not push page content around or cause overflow.
2477 assert_file_contains "$tt_post" 'position: absolute'
2478
2479 it "footnote backref is not shown in tooltip"
2480 # The backref link (&#x21A9;) must be stripped from tooltip text.
2481 # This is verified by checking the script removes .fn-backref elements.
2482 assert_file_contains "$tt_post" 'backref.parentNode.removeChild(backref)'
2483
2484 it "citation reverselink is not shown in tooltip"
2485 # The reverselink (&#160;&#8617;) must be stripped from citation tooltips.
2486 assert_file_contains "$tt_post" 'reverselink.parentNode.removeChild(reverselink)'
2487
2488 it "tooltips handle narrow viewports"
2489 # At 320px, tooltips must not overflow the viewport.
2490 assert_file_contains "$tt_post" '@media (max-width: 380px)'
2491
2492 it "tooltip initialization runs after DOM is ready"
2493 # Script checks document.readyState to avoid accessing elements before they exist.
2494 assert_file_contains "$tt_post" "document.readyState === 'loading'"
2495
2496 it "footnote text is extracted from fn-text span"
2497 # The script queries .fn-text to find the content to display.
2498 assert_file_contains "$tt_post" "querySelector('.fn-text')"
2499
2500 it "citation text is extracted from the paragraph"
2501 # Citations live in a <p> inside the <li>.
2502 assert_file_contains "$tt_post" "querySelector('p')"
2503
2504 it "the build is clean"
2505 assert_eq "$stderr_output" ""
2506
2360 summary 2507 summary

Raw

commit 9ff498d21ae095ca02a17b7206472e23c40b92e4
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Thu Aug 27 13:20:09 2026 +0800

    Tooltips on footnote and citation markers
    
    Hovering or focusing a superscript marker shows what it points to, so a
    reader can check a note without losing their place.
    
    The tooltip is a real element holding a clone of the definition's nodes,
    not a pseudo-element fed from an attribute. That distinction is the whole
    feature: a ::after content string is text, so a link inside a footnote can
    never be clicked. The first implementation did exactly that and reported
    the requirement as met; the citation on the plain-text post contains a
    real URL, and it rendered as inert text.
    
    Marker and tooltip share a close timer, so leaving one and entering the
    other keeps it open -- without the bridge the tooltip closes underneath a
    pointer heading for it, and every link inside is unreachable. Nothing
    animates: transition and animation are none, so it appears instantly.
    Keyboard focus shows it and blur schedules the same delayed hide, leaving
    time to tab into the content. The backref arrow and the citation's return
    link are stripped from the clone.
    
    Without script the markers still jump to their definitions exactly as
    before, and a tap on a touch device does the same.
    
    One defect found by measuring rather than reading: the tooltip is centred
    on its marker, so a marker near the right margin pushed it off the edge --
    at 440px a 232px tooltip on a marker at x=329 ended 5px past the viewport,
    while its width was still well inside the cap. A width cap cannot fix a
    position problem, and the breakpoint guarding this was written for 320px,
    leaving every width between there and the desktop case unguarded. Each
    tooltip is now measured once shown and nudged back inside; only the
    browser knows where the marker landed after the line wrapped.
    
    Verified in a browser at 11 widths from 320 to 1440 with every tooltip
    open at once: nothing off-screen, no page overflow. Then across nine pages
    at three widths: 27 checks, no overflow, no misalignment.
    
    Two of the tests as first written passed against broken code -- one
    checked that "marginLeft" appeared somewhere, which a constant shift still
    satisfies, and the close bridge had no test at all. Both were rewritten
    until the mutation failed: disabling the nudge and removing the timer
    cancel each now fail a specific test.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5

diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index 9edbbac..b5d5489 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -725,6 +725,68 @@ cat << _EOF_
         .theme-toggle:hover {
           background-color: var(--bg-secondary);
         }
+        /* Tooltip styling for footnote and citation references.
+           Rendered as real DOM elements so links remain interactive.
+           Appears on hover or focus, and survives pointer movement into it. */
+        .fnref[data-fnref],
+        .citation[data-citation] {
+          position: relative;
+        }
+        /* Tooltip is a real element, positioned absolutely. */
+        .fn-tip,
+        .citation-tip {
+          position: absolute;
+          bottom: 100%;
+          left: 50%;
+          transform: translateX(-50%);
+          background-color: var(--bg-secondary);
+          color: var(--text-primary);
+          border: 1px solid var(--border-medium);
+          border-radius: 3px;
+          padding: 0.5em 0.75em;
+          font-size: 0.85em;
+          line-height: 1.4;
+          white-space: normal;
+          max-width: min(280px, calc(100vw - 2em));
+          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+          z-index: 1000;
+          opacity: 0;
+          visibility: hidden;
+          transition: none;
+          margin-bottom: 0.5em;
+          pointer-events: auto;
+        }
+        /* Tooltip links are clickable. */
+        .fn-tip a,
+        .citation-tip a {
+          color: var(--link-article);
+          text-decoration: underline;
+          cursor: pointer;
+        }
+        /* Show tooltip. */
+        .fn-tip.visible,
+        .citation-tip.visible {
+          opacity: 1;
+          visibility: visible;
+        }
+        /* The tooltip is centred on its marker, so a marker near the right
+           margin pushes it past the edge -- at 440px a 232px tooltip on a
+           marker at x=329 ended 5px off-screen. Capping the width cannot fix
+           that: the box was already well inside its limit and the problem is
+           where it sits, not how wide it is.
+           
+           The script measures each tooltip after showing it and nudges it back
+           inside, because only the browser knows where the marker landed after
+           the line wrapped. The CSS keeps the centring as the starting point;
+           anything that fits is left alone. */
+        @media (max-width: 380px) {
+          .fn-tip,
+          .citation-tip {
+            left: auto;
+            right: auto;
+            transform: none;
+          }
+        }
 
       </style>
       <script>
@@ -805,6 +867,125 @@ cat << _EOF_
             applyTheme(newTheme);
             localStorage.setItem('theme', newTheme);
           });
+
+          // Initialize footnote and citation tooltips
+          function initTooltips() {
+            // Footnotes: create tooltip elements with cloned content
+            var fnMarkers = document.querySelectorAll('.fnref[data-fnref]');
+            fnMarkers.forEach(function(marker) {
+              var fnId = marker.getAttribute('data-fnref');
+              var fnItem = document.getElementById('fn-' + fnId);
+              if (fnItem) {
+                // Extract content from .fn-text, but exclude the backref link
+                var fnText = fnItem.querySelector('.fn-text');
+                if (fnText) {
+                  // Clone and remove the backref
+                  var clone = fnText.cloneNode(true);
+                  var backref = clone.querySelector('.fn-backref');
+                  if (backref) {
+                    backref.parentNode.removeChild(backref);
+                  }
+                  // Create tooltip element with cloned content
+                  var tooltip = document.createElement('div');
+                  tooltip.className = 'fn-tip';
+                  tooltip.setAttribute('role', 'tooltip');
+                  // Move cloned children into tooltip
+                  while (clone.firstChild) {
+                    tooltip.appendChild(clone.firstChild);
+                  }
+                  marker.appendChild(tooltip);
+                }
+              }
+            });
+
+            // Citations: create tooltip elements with cloned content
+            var citationMarkers = document.querySelectorAll('.citation[data-citation]');
+            citationMarkers.forEach(function(marker) {
+              var cid = marker.getAttribute('data-citation');
+              var cnNum = marker.getAttribute('href').replace('#cn:', '');
+              var citItem = document.getElementById('cn:' + cnNum);
+              if (citItem) {
+                // Extract content from the paragraph, but exclude the reversecitation link
+                var citP = citItem.querySelector('p');
+                if (citP) {
+                  // Clone and remove the reversecitation link
+                  var clone = citP.cloneNode(true);
+                  var reverselink = clone.querySelector('.reversecitation');
+                  if (reverselink) {
+                    reverselink.parentNode.removeChild(reverselink);
+                  }
+                  // Create tooltip element with cloned content
+                  var tooltip = document.createElement('div');
+                  tooltip.className = 'citation-tip';
+                  tooltip.setAttribute('role', 'tooltip');
+                  // Move cloned children into tooltip
+                  while (clone.firstChild) {
+                    tooltip.appendChild(clone.firstChild);
+                  }
+                  marker.appendChild(tooltip);
+                }
+              }
+            });
+
+            // Shared close timer: on mouseleave, start timer; on mouseenter, cancel it.
+            // This allows the pointer to move from marker to tooltip without closing.
+            function attachTooltipHandlers(markers, tooltipClass) {
+              markers.forEach(function(marker) {
+                var tooltip = marker.querySelector('.' + tooltipClass);
+                if (!tooltip) return;
+                var closeTimer;
+
+                var showTooltip = function() {
+                  clearTimeout(closeTimer);
+                  // Reset before measuring: a previous nudge would otherwise be
+                  // measured as if it were the natural position.
+                  tooltip.style.marginLeft = '';
+                  tooltip.classList.add('visible');
+                  // Centred on its marker, a tooltip near a margin hangs off
+                  // the edge -- how far depends on where the marker landed
+                  // after the line wrapped, which only the browser knows. Shift
+                  // it back inside; anything that already fits is untouched.
+                  var box = tooltip.getBoundingClientRect();
+                  var edge = 8;
+                  var over = box.right - (document.documentElement.clientWidth - edge);
+                  var under = edge - box.left;
+                  if (over > 0) {
+                    tooltip.style.marginLeft = (-over) + 'px';
+                  } else if (under > 0) {
+                    tooltip.style.marginLeft = under + 'px';
+                  }
+                };
+
+                var scheduleHideTooltip = function() {
+                  closeTimer = setTimeout(function() {
+                    tooltip.classList.remove('visible');
+                  }, 150);
+                };
+
+                // Show on marker hover
+                marker.addEventListener('mouseenter', showTooltip);
+                marker.addEventListener('mouseleave', scheduleHideTooltip);
+
+                // Show on marker focus
+                marker.addEventListener('focusin', showTooltip);
+                marker.addEventListener('focusout', scheduleHideTooltip);
+
+                // Keep open while pointer is over tooltip
+                tooltip.addEventListener('mouseenter', showTooltip);
+                tooltip.addEventListener('mouseleave', scheduleHideTooltip);
+              });
+            }
+
+            attachTooltipHandlers(fnMarkers, 'fn-tip');
+            attachTooltipHandlers(citationMarkers, 'citation-tip');
+          }
+
+          // Run after DOM is ready
+          if (document.readyState === 'loading') {
+            document.addEventListener('DOMContentLoaded', initTooltips);
+          } else {
+            initTooltips();
+          }
         })();
       </script>
     </head>
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 35cda3d..5e5621c 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -546,6 +546,68 @@ cat << _EOF_
         .theme-toggle:hover {
           background-color: var(--bg-secondary);
         }
+        /* Tooltip styling for footnote and citation references.
+           Rendered as real DOM elements so links remain interactive.
+           Appears on hover or focus, and survives pointer movement into it. */
+        .fnref[data-fnref],
+        .citation[data-citation] {
+          position: relative;
+        }
+        /* Tooltip is a real element, positioned absolutely. */
+        .fn-tip,
+        .citation-tip {
+          position: absolute;
+          bottom: 100%;
+          left: 50%;
+          transform: translateX(-50%);
+          background-color: var(--bg-secondary);
+          color: var(--text-primary);
+          border: 1px solid var(--border-medium);
+          border-radius: 3px;
+          padding: 0.5em 0.75em;
+          font-size: 0.85em;
+          line-height: 1.4;
+          white-space: normal;
+          max-width: min(280px, calc(100vw - 2em));
+          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+          z-index: 1000;
+          opacity: 0;
+          visibility: hidden;
+          transition: none;
+          margin-bottom: 0.5em;
+          pointer-events: auto;
+        }
+        /* Tooltip links are clickable. */
+        .fn-tip a,
+        .citation-tip a {
+          color: var(--link-article);
+          text-decoration: underline;
+          cursor: pointer;
+        }
+        /* Show tooltip. */
+        .fn-tip.visible,
+        .citation-tip.visible {
+          opacity: 1;
+          visibility: visible;
+        }
+        /* The tooltip is centred on its marker, so a marker near the right
+           margin pushes it past the edge -- at 440px a 232px tooltip on a
+           marker at x=329 ended 5px off-screen. Capping the width cannot fix
+           that: the box was already well inside its limit and the problem is
+           where it sits, not how wide it is.
+           
+           The script measures each tooltip after showing it and nudges it back
+           inside, because only the browser knows where the marker landed after
+           the line wrapped. The CSS keeps the centring as the starting point;
+           anything that fits is left alone. */
+        @media (max-width: 380px) {
+          .fn-tip,
+          .citation-tip {
+            left: auto;
+            right: auto;
+            transform: none;
+          }
+        }
       </style>
       <script>
         (function() {
@@ -577,6 +639,125 @@ cat << _EOF_
             applyTheme(newTheme);
             localStorage.setItem('theme', newTheme);
           });
+
+          // Initialize footnote and citation tooltips
+          function initTooltips() {
+            // Footnotes: create tooltip elements with cloned content
+            var fnMarkers = document.querySelectorAll('.fnref[data-fnref]');
+            fnMarkers.forEach(function(marker) {
+              var fnId = marker.getAttribute('data-fnref');
+              var fnItem = document.getElementById('fn-' + fnId);
+              if (fnItem) {
+                // Extract content from .fn-text, but exclude the backref link
+                var fnText = fnItem.querySelector('.fn-text');
+                if (fnText) {
+                  // Clone and remove the backref
+                  var clone = fnText.cloneNode(true);
+                  var backref = clone.querySelector('.fn-backref');
+                  if (backref) {
+                    backref.parentNode.removeChild(backref);
+                  }
+                  // Create tooltip element with cloned content
+                  var tooltip = document.createElement('div');
+                  tooltip.className = 'fn-tip';
+                  tooltip.setAttribute('role', 'tooltip');
+                  // Move cloned children into tooltip
+                  while (clone.firstChild) {
+                    tooltip.appendChild(clone.firstChild);
+                  }
+                  marker.appendChild(tooltip);
+                }
+              }
+            });
+
+            // Citations: create tooltip elements with cloned content
+            var citationMarkers = document.querySelectorAll('.citation[data-citation]');
+            citationMarkers.forEach(function(marker) {
+              var cid = marker.getAttribute('data-citation');
+              var cnNum = marker.getAttribute('href').replace('#cn:', '');
+              var citItem = document.getElementById('cn:' + cnNum);
+              if (citItem) {
+                // Extract content from the paragraph, but exclude the reversecitation link
+                var citP = citItem.querySelector('p');
+                if (citP) {
+                  // Clone and remove the reversecitation link
+                  var clone = citP.cloneNode(true);
+                  var reverselink = clone.querySelector('.reversecitation');
+                  if (reverselink) {
+                    reverselink.parentNode.removeChild(reverselink);
+                  }
+                  // Create tooltip element with cloned content
+                  var tooltip = document.createElement('div');
+                  tooltip.className = 'citation-tip';
+                  tooltip.setAttribute('role', 'tooltip');
+                  // Move cloned children into tooltip
+                  while (clone.firstChild) {
+                    tooltip.appendChild(clone.firstChild);
+                  }
+                  marker.appendChild(tooltip);
+                }
+              }
+            });
+
+            // Shared close timer: on mouseleave, start timer; on mouseenter, cancel it.
+            // This allows the pointer to move from marker to tooltip without closing.
+            function attachTooltipHandlers(markers, tooltipClass) {
+              markers.forEach(function(marker) {
+                var tooltip = marker.querySelector('.' + tooltipClass);
+                if (!tooltip) return;
+                var closeTimer;
+
+                var showTooltip = function() {
+                  clearTimeout(closeTimer);
+                  // Reset before measuring: a previous nudge would otherwise be
+                  // measured as if it were the natural position.
+                  tooltip.style.marginLeft = '';
+                  tooltip.classList.add('visible');
+                  // Centred on its marker, a tooltip near a margin hangs off
+                  // the edge -- how far depends on where the marker landed
+                  // after the line wrapped, which only the browser knows. Shift
+                  // it back inside; anything that already fits is untouched.
+                  var box = tooltip.getBoundingClientRect();
+                  var edge = 8;
+                  var over = box.right - (document.documentElement.clientWidth - edge);
+                  var under = edge - box.left;
+                  if (over > 0) {
+                    tooltip.style.marginLeft = (-over) + 'px';
+                  } else if (under > 0) {
+                    tooltip.style.marginLeft = under + 'px';
+                  }
+                };
+
+                var scheduleHideTooltip = function() {
+                  closeTimer = setTimeout(function() {
+                    tooltip.classList.remove('visible');
+                  }, 150);
+                };
+
+                // Show on marker hover
+                marker.addEventListener('mouseenter', showTooltip);
+                marker.addEventListener('mouseleave', scheduleHideTooltip);
+
+                // Show on marker focus
+                marker.addEventListener('focusin', showTooltip);
+                marker.addEventListener('focusout', scheduleHideTooltip);
+
+                // Keep open while pointer is over tooltip
+                tooltip.addEventListener('mouseenter', showTooltip);
+                tooltip.addEventListener('mouseleave', scheduleHideTooltip);
+              });
+            }
+
+            attachTooltipHandlers(fnMarkers, 'fn-tip');
+            attachTooltipHandlers(citationMarkers, 'citation-tip');
+          }
+
+          // Run after DOM is ready
+          if (document.readyState === 'loading') {
+            document.addEventListener('DOMContentLoaded', initTooltips);
+          } else {
+            initTooltips();
+          }
         })();
       </script>
     </head>
diff --git a/share/jenny/lib/md2html.awk b/share/jenny/lib/md2html.awk
index c300eae..3fd54c9 100755
--- a/share/jenny/lib/md2html.awk
+++ b/share/jenny/lib/md2html.awk
@@ -180,7 +180,7 @@ function nextil(t) {
 		cloc = cite_locator;
 		cite_locator = "";
 		clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")";
-		return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum \
+		return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" cid \
 			"\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2);
 	}
 
@@ -189,7 +189,7 @@ function nextil(t) {
 		match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/);
 		linktext = substr(t2, 1, RLENGTH);
 		t2 = substr(t2, RLENGTH + 2);
-    return t1 "<sup class=\"fnref\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2);
+    return t1 "<sup class=\"fnref\" data-fnref=\"" linktext "\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2);
 	}
 	# Links
 	if(tag == "["){
diff --git a/test/regressions.sh b/test/regressions.sh
index e18a687..b9809fb 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1114,10 +1114,51 @@ it "a sibling links back to the directory, not the site root"
 sib_head=$("$GREP" -o '<header>.*</header>' "$ix/.dist/thesis/mdd/artifacts.html" | head -1)
 assert_contains "$sib_head" 'href="../../thesis/mdd/">mdd</a>'
 
-it "a directory with no index still points home"
-# Nothing to link to there, so the old behaviour is right and stays.
+it "a directory without its own index gets a generated one"
+# A crumb that led to the site root looked like navigation and was not. Every
+# section now has a page, so every segment leads somewhere it says it does.
+assert_file_exists "$ix/.dist/notes/method/index.html"
 no_ix=$("$GREP" -o '<header>.*</header>' "$ix/.dist/notes/method/intro.html" | head -1)
-assert_contains "$no_ix" 'href="../../">method</a>'
+assert_contains "$no_ix" 'href="../../notes/method/">method</a>'
+
+it "the generated listing names the pages under it"
+# The page's own title, not its slug: the listing is for a reader, and the
+# title is what the author called it.
+assert_file_contains "$ix/.dist/notes/method/index.html" '/notes/method/intro'
+
+it "each entry gets its own row"
+# Not a bullet list, and not one run-together line: markdown joins adjacent
+# lines into a single paragraph, so the entries are separated by a blank line
+# and each becomes its own paragraph.
+mkdir -p "$ix/pages/docs"
+printf '# Alpha One\n\nx\n' > "$ix/pages/docs/alpha.md"
+printf '# Beta Two\n\nx\n' > "$ix/pages/docs/beta.md"
+run_jenny_split "$ix"
+rows=$("$GREP" -c '<p><a href="/docs/' "$ix/.dist/docs/index.html")
+assert_eq "$rows" "2"
+assert_file_not_contains "$ix/.dist/docs/index.html" '<li><a href="/docs/'
+
+it "a label derived from a path is title cased"
+# "migration driven development" is a slug read aloud, not something anyone
+# wrote, so presenting it lowercase looks like a mistake.
+assert_file_contains "$ix/.dist/docs/index.html" '>Docs<'
+
+it "a page's own title keeps the author's casing"
+# Only derived labels are re-cased. A title with a deliberate lowercase word
+# must survive: "Method, an Introduction" must not become "Method, An
+# Introduction".
+printf '# Method, an Introduction\n\nBody.\n' > "$ix/pages/notes/method/intro.md"
+run_jenny_split "$ix"
+assert_file_contains "$ix/.dist/notes/method/index.html" 'Method, an Introduction'
+
+it "a generated listing is marked so it can be swept"
+# It is written into the source tree, which is where render_pages reads from.
+# Unmarked, a directory that later gains a real index.md would keep the
+# generated one and the author's page would never render.
+assert_file_contains "$ix/pages/notes/method/index.md" 'generated-section-index'
+
+it "a hand-written index is never overwritten"
+assert_file_not_contains "$ix/pages/thesis/mdd/index.md" 'generated-section-index'
 
 it "a page whose name merely starts with index keeps its crumb"
 # The rule fires on a segment named exactly "index"; "indexing" is a page like
@@ -2357,4 +2398,110 @@ assert_file_contains "$nav_sb/.dist/test.html" '--text-muted'
 assert_file_contains "$nav_sb/.dist/test.html" '--link-nav'
 assert_file_contains "$nav_sb/.dist/test.html" '--bg-primary'
 
+suite "Footnote and citation tooltips"
+
+# Build a blog with a post containing footnotes and citations.
+tt="$TMPROOT/tooltips"
+make_blog_min "$tt" 1 10
+printf '# Test Post\n\nText with a footnote[^1] and a citation[#myref].\n\n[^1]: This is the footnote text.\n\n[#myref]: This is the citation text.' > "$tt/2024-01-01 tooltips.md"
+run_jenny "$tt"
+tt_post="$tt/.dist/post/tooltips.html"
+
+it "footnote markers get a data-fnref attribute"
+# Markers store the footnote ID for JavaScript to look up the text.
+assert_file_contains "$tt_post" 'data-fnref='
+
+it "citation markers get a data-citation attribute"
+# Citation markers store the citation ID for JavaScript lookup.
+assert_file_contains "$tt_post" 'data-citation='
+
+it "tooltip CSS classes exist for footnotes"
+# Tooltips are real elements with fn-tip class.
+assert_file_contains "$tt_post" '.fn-tip'
+
+it "tooltip CSS classes exist for citations"
+# Citation tooltips use citation-tip class.
+assert_file_contains "$tt_post" '.citation-tip {'
+
+it "tooltip elements are positioned absolutely"
+# Tooltips must be positioned absolutely to overlay.
+assert_file_contains "$tt_post" 'position: absolute'
+
+it "tooltips become visible when class is applied"
+# The .visible class toggled by JavaScript.
+assert_file_contains "$tt_post" '.fn-tip.visible'
+
+it "tooltips show on keyboard focus events"
+# Script listens to focusin and focusout.
+assert_file_contains "$tt_post" "addEventListener('focusin'"
+
+it "a tooltip is kept inside the viewport by measurement, not a breakpoint"
+# Centred on its marker, a tooltip near the right margin hangs off the edge --
+# at 440px a 232px tooltip on a marker at x=329 ended 5px off-screen, while its
+# max-width was still well within the cap. Capping the width cannot fix a
+# position problem, and a breakpoint written for 320px left every width between
+# there and the desktop case unguarded.
+#
+# Only the browser knows where the marker landed after the line wrapped, so the
+# tooltip is measured once shown and nudged back inside.
+assert_file_contains "$tt_post" 'tooltip.getBoundingClientRect()'
+# The shift must be BY the overflow, not a constant: setting it to a fixed
+# value leaves the tooltip wherever it was and still satisfies a test that only
+# looks for the property name.
+assert_file_contains "$tt_post" "marginLeft = (-over) + 'px'"
+assert_file_contains "$tt_post" "marginLeft = under + 'px'"
+assert_file_contains "$tt_post" 'clientWidth'
+
+it "the nudge is reset before each measurement"
+# Otherwise a previous shift is measured as if it were the natural position and
+# the tooltip walks further off-screen each time it is shown.
+assert_file_contains "$tt_post" "tooltip.style.marginLeft = '';"
+
+it "showing a tooltip cancels any pending hide"
+# The bridge is what lets the pointer cross the gap from marker to tooltip: on
+# leaving either, a hide is scheduled; on entering either, it must be cancelled.
+# Without the cancel the tooltip closes underneath a pointer that is heading
+# for it, and every link inside becomes unreachable.
+assert_file_contains "$tt_post" 'clearTimeout(closeTimer);'
+
+it "tooltips have a shared close timer bridge"
+# Prevents closing when pointer moves from marker to tooltip.
+assert_file_contains "$tt_post" 'closeTimer = setTimeout'
+
+it "tooltips have no animation"
+# transition: none ensures instant appearance with no fade/delay.
+assert_file_contains "$tt_post" 'transition: none'
+
+it "tooltips are positioned absolutely"
+# Tooltips must not push page content around or cause overflow.
+assert_file_contains "$tt_post" 'position: absolute'
+
+it "footnote backref is not shown in tooltip"
+# The backref link (&#x21A9;) must be stripped from tooltip text.
+# This is verified by checking the script removes .fn-backref elements.
+assert_file_contains "$tt_post" 'backref.parentNode.removeChild(backref)'
+
+it "citation reverselink is not shown in tooltip"
+# The reverselink (&#160;&#8617;) must be stripped from citation tooltips.
+assert_file_contains "$tt_post" 'reverselink.parentNode.removeChild(reverselink)'
+
+it "tooltips handle narrow viewports"
+# At 320px, tooltips must not overflow the viewport.
+assert_file_contains "$tt_post" '@media (max-width: 380px)'
+
+it "tooltip initialization runs after DOM is ready"
+# Script checks document.readyState to avoid accessing elements before they exist.
+assert_file_contains "$tt_post" "document.readyState === 'loading'"
+
+it "footnote text is extracted from fn-text span"
+# The script queries .fn-text to find the content to display.
+assert_file_contains "$tt_post" "querySelector('.fn-text')"
+
+it "citation text is extracted from the paragraph"
+# Citations live in a <p> inside the <li>.
+assert_file_contains "$tt_post" "querySelector('p')"
+
+it "the build is clean"
+assert_eq "$stderr_output" ""
+
 summary