Commit 9ff498d h
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 (↩) 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 ( ↩) 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 (↩) 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 ( ↩) 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