ssh git@grit.deadkey.org man

Commit 8fb5eb9 h

Pat Ambrosio · 26-09-01 10:29

Centre a linked passage instead of pinning it to the top

Changes: 3 files +101 -16

Changes (Side-by-Side)

share/jenny/layout/page.sh +41 -8

cat << _EOF_
1238 }); 1238 });
1239 1239
1240 // Mark whatever the reader arrived at, so a permalink lands on 1240 // Mark whatever the reader arrived at, so a permalink lands on
1241 // something visibly identified rather than just scrolled to.
1242 function markTarget() {
1241 // something visibly identified rather than just scrolled to, and
1242 // put it in the middle of the screen rather than against the top
1243 // edge. A passage flush with the top has no text above it, so
1244 // there is nothing to show what it follows from; centred, it
1245 // arrives with its own context either side.
1246 function showTarget(t, smooth) {
1243 document.querySelectorAll('.target-flash').forEach(function(e) { 1247 document.querySelectorAll('.target-flash').forEach(function(e) {
1244 e.classList.remove('target-flash'); 1248 e.classList.remove('target-flash');
1245 }); 1249 });
1246 if (!location.hash) return;
1247 var t = null;
1248 try { t = document.querySelector(location.hash); } catch (err) { return; }
1249 if (t) t.classList.add('target-flash');
1250 if (!t) return;
1251 t.classList.add('target-flash');
1252 t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' });
1253 }
1254 function resolveHash() {
1255 if (!location.hash) return null;
1256 try { return document.querySelector(location.hash); } catch (err) { return null; }
1257 }
1258
1259 // A click within the page: the browser would jump the target to the
1260 // top, so take the scroll over.
1261 document.addEventListener('click', function(e) {
1262 var link = e.target.closest('a[href^="#"]');
1263 if (!link) return;
1264 var id = link.getAttribute('href').slice(1);
1265 if (!id) return;
1266 var t = document.getElementById(id);
1267 if (!t) return;
1268 e.preventDefault();
1269 history.replaceState(null, '', '#' + id);
1270 showTarget(t, true);
1271 });
1272
1273 window.addEventListener('hashchange', function() {
1274 showTarget(resolveHash(), true);
1275 });
1276
1277 // Arriving from elsewhere at .../page#id: the browser has already
1278 // scrolled the target to the top by the time this runs, so it has
1279 // to be moved rather than merely marked. A frame later, once
1280 // layout has settled -- fonts and diagrams both change the height
1281 // above it and would otherwise leave the centring stale.
1282 var initial = resolveHash();
1283 if (initial) {
1284 requestAnimationFrame(function() { showTarget(initial, false); });
1250 } 1285 }
1251 window.addEventListener('hashchange', markTarget);
1252 markTarget();
1253 } 1286 }
1254 // The script tag sits in the body, so at parse time the content it 1287 // The script tag sits in the body, so at parse time the content it
1255 // needs may not exist yet -- the first version ran before any of it 1288 // needs may not exist yet -- the first version ran before any of it

share/jenny/layout/post.sh +41 -8

cat << _EOF_
1010 }); 1010 });
1011 1011
1012 // Mark whatever the reader arrived at, so a permalink lands on 1012 // Mark whatever the reader arrived at, so a permalink lands on
1013 // something visibly identified rather than just scrolled to.
1014 function markTarget() {
1013 // something visibly identified rather than just scrolled to, and
1014 // put it in the middle of the screen rather than against the top
1015 // edge. A passage flush with the top has no text above it, so
1016 // there is nothing to show what it follows from; centred, it
1017 // arrives with its own context either side.
1018 function showTarget(t, smooth) {
1015 document.querySelectorAll('.target-flash').forEach(function(e) { 1019 document.querySelectorAll('.target-flash').forEach(function(e) {
1016 e.classList.remove('target-flash'); 1020 e.classList.remove('target-flash');
1017 }); 1021 });
1018 if (!location.hash) return;
1019 var t = null;
1020 try { t = document.querySelector(location.hash); } catch (err) { return; }
1021 if (t) t.classList.add('target-flash');
1022 if (!t) return;
1023 t.classList.add('target-flash');
1024 t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' });
1025 }
1026 function resolveHash() {
1027 if (!location.hash) return null;
1028 try { return document.querySelector(location.hash); } catch (err) { return null; }
1029 }
1030
1031 // A click within the page: the browser would jump the target to the
1032 // top, so take the scroll over.
1033 document.addEventListener('click', function(e) {
1034 var link = e.target.closest('a[href^="#"]');
1035 if (!link) return;
1036 var id = link.getAttribute('href').slice(1);
1037 if (!id) return;
1038 var t = document.getElementById(id);
1039 if (!t) return;
1040 e.preventDefault();
1041 history.replaceState(null, '', '#' + id);
1042 showTarget(t, true);
1043 });
1044
1045 window.addEventListener('hashchange', function() {
1046 showTarget(resolveHash(), true);
1047 });
1048
1049 // Arriving from elsewhere at .../page#id: the browser has already
1050 // scrolled the target to the top by the time this runs, so it has
1051 // to be moved rather than merely marked. A frame later, once
1052 // layout has settled -- fonts and diagrams both change the height
1053 // above it and would otherwise leave the centring stale.
1054 var initial = resolveHash();
1055 if (initial) {
1056 requestAnimationFrame(function() { showTarget(initial, false); });
1022 } 1057 }
1023 window.addEventListener('hashchange', markTarget);
1024 markTarget();
1025 } 1058 }
1026 // The script tag sits in the body, so at parse time the content it 1059 // The script tag sits in the body, so at parse time the content it
1027 // needs may not exist yet -- the first version ran before any of it 1060 // needs may not exist yet -- the first version ran before any of it

test/regressions.sh +19 -0

it "the mark is hidden until the passage is hovered or the mark focused"
1450 assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }' 1450 assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }'
1451 assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }' 1451 assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }'
1452 1452
1453 it "a linked passage is centred, not pinned to the top"
1454 # A passage flush with the top edge has no text above it, so nothing shows
1455 # what it follows from. Centred, it arrives with its own context either side.
1456 # Measured in Chrome once the scroll settles: dead centre for an in-page click,
1457 # for an inbound permalink, and for a prose cross-reference.
1458 assert_file_contains "$dg/.dist/chart.html" "block: 'center'"
1459 assert_file_contains "$dg/.dist/post/charted.html" "block: 'center'"
1460
1461 it "an inbound permalink is re-centred after the browser's own jump"
1462 # Arriving at .../page#id, the browser has already put the target at the top
1463 # by the time this runs, so it has to be moved rather than merely marked --
1464 # and a frame later, once fonts and diagrams have settled the height above it.
1465 assert_file_contains "$dg/.dist/chart.html" "requestAnimationFrame(function() { showTarget(initial, false); });"
1466
1467 it "every in-page link is centred, not just the marks"
1468 # The handler is delegated on the document, so a cross-reference written in
1469 # the prose behaves the same way as a deep-link mark.
1470 assert_file_contains "$dg/.dist/chart.html" "a[href^=\"#\"]"
1471
1453 it "following a permalink marks what was linked" 1472 it "following a permalink marks what was linked"
1454 # A reader landing mid-document needs to see which passage was meant. 1473 # A reader landing mid-document needs to see which passage was meant.
1455 assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');" 1474 assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');"

Raw

commit 8fb5eb95dc6c90ba42d93f43ff56645582d0c5a9
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Tue Sep 1 10:29:27 2026 +0800

    Centre a linked passage instead of pinning it to the top
    
    A passage scrolled flush with the top edge has nothing above it, so the
    reader sees the sentence they followed and none of what it follows from.
    Centred, it arrives with its own context either side.
    
    Two paths need it, and they are not the same problem. A click can simply
    take the scroll over before the browser jumps. An inbound permalink
    cannot: by the time this runs the browser has already put the target at
    the top, so it has to be moved rather than merely marked -- and a frame
    later, because fonts and diagrams both change the height above it and
    would otherwise leave the centring stale. Measured on a page whose
    diagrams render after load: still dead centre.
    
    The click handler is delegated on the document rather than bound to the
    marks, so a cross-reference written in the prose gets the same treatment.
    The footnote links do too: the jump to a note lands off centre only
    because the notes sit at the end of the document and there is nothing
    below them to scroll, which is the browser doing the right thing.
    
    Measured in Chrome, polling until the scroll settles rather than after a
    fixed delay -- read mid-animation the target is 530px out, which looks
    like a defect and is not.

diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index a8ad0d9..53449df 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -1238,18 +1238,51 @@ cat << _EOF_
             });
 
             // Mark whatever the reader arrived at, so a permalink lands on
-            // something visibly identified rather than just scrolled to.
-            function markTarget() {
+            // something visibly identified rather than just scrolled to, and
+            // put it in the middle of the screen rather than against the top
+            // edge. A passage flush with the top has no text above it, so
+            // there is nothing to show what it follows from; centred, it
+            // arrives with its own context either side.
+            function showTarget(t, smooth) {
               document.querySelectorAll('.target-flash').forEach(function(e) {
                 e.classList.remove('target-flash');
               });
-              if (!location.hash) return;
-              var t = null;
-              try { t = document.querySelector(location.hash); } catch (err) { return; }
-              if (t) t.classList.add('target-flash');
+              if (!t) return;
+              t.classList.add('target-flash');
+              t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' });
+            }
+            function resolveHash() {
+              if (!location.hash) return null;
+              try { return document.querySelector(location.hash); } catch (err) { return null; }
+            }
+
+            // A click within the page: the browser would jump the target to the
+            // top, so take the scroll over.
+            document.addEventListener('click', function(e) {
+              var link = e.target.closest('a[href^="#"]');
+              if (!link) return;
+              var id = link.getAttribute('href').slice(1);
+              if (!id) return;
+              var t = document.getElementById(id);
+              if (!t) return;
+              e.preventDefault();
+              history.replaceState(null, '', '#' + id);
+              showTarget(t, true);
+            });
+
+            window.addEventListener('hashchange', function() {
+              showTarget(resolveHash(), true);
+            });
+
+            // Arriving from elsewhere at .../page#id: the browser has already
+            // scrolled the target to the top by the time this runs, so it has
+            // to be moved rather than merely marked. A frame later, once
+            // layout has settled -- fonts and diagrams both change the height
+            // above it and would otherwise leave the centring stale.
+            var initial = resolveHash();
+            if (initial) {
+              requestAnimationFrame(function() { showTarget(initial, false); });
             }
-            window.addEventListener('hashchange', markTarget);
-            markTarget();
           }
           // The script tag sits in the body, so at parse time the content it
           // needs may not exist yet -- the first version ran before any of it
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 0785ce5..59e6308 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -1010,18 +1010,51 @@ cat << _EOF_
             });
 
             // Mark whatever the reader arrived at, so a permalink lands on
-            // something visibly identified rather than just scrolled to.
-            function markTarget() {
+            // something visibly identified rather than just scrolled to, and
+            // put it in the middle of the screen rather than against the top
+            // edge. A passage flush with the top has no text above it, so
+            // there is nothing to show what it follows from; centred, it
+            // arrives with its own context either side.
+            function showTarget(t, smooth) {
               document.querySelectorAll('.target-flash').forEach(function(e) {
                 e.classList.remove('target-flash');
               });
-              if (!location.hash) return;
-              var t = null;
-              try { t = document.querySelector(location.hash); } catch (err) { return; }
-              if (t) t.classList.add('target-flash');
+              if (!t) return;
+              t.classList.add('target-flash');
+              t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' });
+            }
+            function resolveHash() {
+              if (!location.hash) return null;
+              try { return document.querySelector(location.hash); } catch (err) { return null; }
+            }
+
+            // A click within the page: the browser would jump the target to the
+            // top, so take the scroll over.
+            document.addEventListener('click', function(e) {
+              var link = e.target.closest('a[href^="#"]');
+              if (!link) return;
+              var id = link.getAttribute('href').slice(1);
+              if (!id) return;
+              var t = document.getElementById(id);
+              if (!t) return;
+              e.preventDefault();
+              history.replaceState(null, '', '#' + id);
+              showTarget(t, true);
+            });
+
+            window.addEventListener('hashchange', function() {
+              showTarget(resolveHash(), true);
+            });
+
+            // Arriving from elsewhere at .../page#id: the browser has already
+            // scrolled the target to the top by the time this runs, so it has
+            // to be moved rather than merely marked. A frame later, once
+            // layout has settled -- fonts and diagrams both change the height
+            // above it and would otherwise leave the centring stale.
+            var initial = resolveHash();
+            if (initial) {
+              requestAnimationFrame(function() { showTarget(initial, false); });
             }
-            window.addEventListener('hashchange', markTarget);
-            markTarget();
           }
           // The script tag sits in the body, so at parse time the content it
           // needs may not exist yet -- the first version ran before any of it
diff --git a/test/regressions.sh b/test/regressions.sh
index 416e45e..131759f 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1450,6 +1450,25 @@ it "the mark is hidden until the passage is hovered or the mark focused"
 assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }'
 assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }'
 
+it "a linked passage is centred, not pinned to the top"
+# A passage flush with the top edge has no text above it, so nothing shows
+# what it follows from. Centred, it arrives with its own context either side.
+# Measured in Chrome once the scroll settles: dead centre for an in-page click,
+# for an inbound permalink, and for a prose cross-reference.
+assert_file_contains "$dg/.dist/chart.html" "block: 'center'"
+assert_file_contains "$dg/.dist/post/charted.html" "block: 'center'"
+
+it "an inbound permalink is re-centred after the browser's own jump"
+# Arriving at .../page#id, the browser has already put the target at the top
+# by the time this runs, so it has to be moved rather than merely marked --
+# and a frame later, once fonts and diagrams have settled the height above it.
+assert_file_contains "$dg/.dist/chart.html" "requestAnimationFrame(function() { showTarget(initial, false); });"
+
+it "every in-page link is centred, not just the marks"
+# The handler is delegated on the document, so a cross-reference written in
+# the prose behaves the same way as a deep-link mark.
+assert_file_contains "$dg/.dist/chart.html" "a[href^=\"#\"]"
+
 it "following a permalink marks what was linked"
 # A reader landing mid-document needs to see which passage was meant.
 assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');"