This commit is contained in:
mark committed 2026-09-04 09:43:19 -04:00
1 parent 2fea308b38
commit f5fd8e632d
4 files changed
+91 -18

No files matched your search

+54 -9
View File
@@ -121,26 +121,71 @@
}
});
// Close the modal once a bookmark write has come back and swapped, leaving
// the status line up long enough to be read.
document.body.addEventListener('htmx:afterSwap', function (event) {
markVerses(event.target);
if (event.detail.target && event.detail.target.id === 'bookmark') {
setTimeout(hideModal, 900);
}
var target = event.detail.target;
if (!target) return;
// Close the modal once a bookmark write has come back and swapped, leaving
// the status line up long enough to be read.
if (target.id === 'bookmark') return setTimeout(hideModal, 900);
if (target.id === 'content') land();
});
// A history restore does not go through the request pipeline -- htmx swaps
// the element straight in and fires this instead -- so the work afterSwap
// does has to be repeated here or a page reached by the back button arrives
// with dead verse numbers.
document.body.addEventListener('htmx:historyRestore', function () {
markVerses();
land();
});
// -- startup ------------------------------------------------------------
function hashTarget() {
if (!location.hash) return null;
return document.getElementById(decodeURIComponent(location.hash.slice(1)));
}
// Every scroll here is a correction, not a gesture: the page is meant to
// arrive already in position. So they all say instant.
//
// Saying nothing would not mean instant. The default is "auto", which means
// "whatever scroll-behavior computes to" -- and Bootstrap's Reboot sets
// :root { scroll-behavior: smooth }, so the default animates. Bootstrap
// wraps that in prefers-reduced-motion, which is the right call for the
// chapter nav's own anchors; these are not that.
function jumpTo(el) {
el.scrollIntoView({ block: 'start', behavior: 'instant' });
}
// A worker-composed page is an ordinary document and the browser scrolls to
// the anchor itself. One composed by boot.html is not: it replaced a
// document the browser had already finished with, so the fragment identifier
// has been spent. Repeating it costs nothing in the first case and is what
// makes a link to GEN.html#v1_1 land on the verse in the second.
// makes a link to GEN#v1_1 land on the verse in the second.
function scrollToHash() {
if (!location.hash) return;
var el = document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (el) el.scrollIntoView();
var el = hashTarget();
if (el) jumpTo(el);
}
// Where a swapped-in page should sit before it is ever seen.
//
// htmx does this itself -- scrolling to the anchor a boosted href named, or
// to the top of the target when it named none -- but from the settle
// callback, which is a setTimeout of defaultSettleDelay, 20ms. The browser
// paints the new content at the old scroll offset first, so the jump is
// visible every time. Here it runs in the same task as the swap, before any
// paint, so the page simply appears where it belongs. htmx's own scroll
// still follows and finds nothing left to do.
//
// htmx has already pushed the new URL by this point -- history is updated
// before the swap, not after -- so location.hash is the anchor that was
// asked for.
function land() {
var el = hashTarget();
if (el) jumpTo(el);
else window.scrollTo({ top: 0, behavior: 'instant' });
}
// api/bookmark has no origin server -- it is answered by the worker -- so
+5
View File
@@ -32,6 +32,11 @@
// htmx stamps every request it makes; a navigation never carries it.
this.htmx = this.request.headers.get('HX-Request') === 'true';
this.navigate = this.request.mode === 'navigate';
// Going back or forward. htmx asks for the page again and expects a whole
// document back, the same as a navigation -- it takes the history element
// out of the response itself.
this.restore =
this.request.headers.get('HX-History-Restore-Request') === 'true';
}
Call.prototype.form = function () {
+4 -4
View File
@@ -257,12 +257,12 @@ async function fragmentFor(path, event) {
return res.ok ? res.text() : view.notice(...MISSING_NOTICE);
}
// One handler for both callers. htmx is swapping into a shell that is already
// on screen and wants the fragment; a navigation has no shell yet and gets the
// fragment composed into one.
// One handler for every caller. htmx swapping a link into a shell already on
// screen wants the bare fragment; a navigation has no shell yet, and a history
// restore is rebuilding one, so both get the fragment composed into a document.
async function servePage(path, call) {
const fragment = await fragmentFor(path, call.event);
if (!call.navigate) return fragment;
if (!call.navigate && !call.restore) return fragment;
try {
return await view.compose(fragment, await getBookmark());
} catch (err) {
+28 -5
View File
@@ -19,10 +19,22 @@
scripture; Bootstrap styles the chrome. #}
<link rel="stylesheet" href="css/app.css">
{# History entries are whole books, and htmx keeps its history cache in
localStorage -- a couple of them would blow the quota. Turned off: going
back re-requests the fragment, which the worker answers from its cache. #}
localStorage -- a couple of them would blow the quota. Turned off, so every
back or forward is a cache miss, and refreshOnHistoryMiss decides what a
miss does.
False on purpose. True makes htmx call location.reload(true) -- a forced
reload, which is exactly the kind of navigation a browser takes past the
service worker, so it reaches server.py and comes back as boot.html: the
spinner flashes by and then reloads into the real page. False makes htmx
fetch the page over XHR instead, which the worker answers itself, from its
own cache when offline. No navigation, no boot page, nothing to flash.
That request carries HX-History-Restore-Request, and the worker answers it
with a whole composed document; htmx lifts the element marked
hx-history-elt out of the response and puts it back in the page. #}
<meta name="htmx-config"
content='{"historyCacheSize":0,"refreshOnHistoryMiss":true}'>
content='{"historyCacheSize":0,"refreshOnHistoryMiss":false}'>
{# The bundle carries Popper, which the navbar collapse and the modal do not
need -- but a dropdown or tooltip added later would, and this is one file
in the precache either way. Deferred scripts run in order, so `bootstrap`
@@ -40,9 +52,15 @@
<meta name="apple-mobile-web-app-title" content="Bible">
</head>
{# Boosted links swap only the fragment, so the navbar, the modal and the
scripts above survive navigation and never reload. #}
scripts above survive navigation and never reload.
No show: modifier, deliberately. htmx adds show:top itself for a boosted
link, but skips it when the href carries a fragment identifier and scrolls
to that anchor after the swap instead. Spelling show:top out here overrides
that decision for every link, which leaves a cross-reference like ISA#v61_6
sitting at the top of Isaiah. #}
<body class="{% block body_class %}{% endblock %} bg-body-tertiary"
hx-boost="true" hx-target="#content" hx-swap="innerHTML show:top">
hx-boost="true" hx-target="#content" hx-swap="innerHTML">
<nav class="site-header navbar navbar-expand-lg bg-dark sticky-top"
data-bs-theme="dark">
@@ -68,7 +86,12 @@
the full width of a desktop window. #}
<main class="container-xl my-4">
<div class="row justify-content-center">
{# The swap target. hx-target on <body> names this id, so every boosted
link lands here -- remove it and a click does nothing at all while a
full navigation still works. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt>
{% block content %}{% endblock %}
</div>
</div>
</main>