This commit is contained in:
mark committed 2026-09-09 10:12:39 -04:00
1 parent ce73c76126
commit 84c07a912b
5 files changed
+44 -133

No files matched your search

+2 -1
View File
@@ -25,7 +25,8 @@
user-select: none; user-select: none;
} }
/* app.js turns these into real buttons, so they need a visible focus ring. */ /* These are real buttons -- compile_bible.py writes role and tabindex on every
one of them -- so they need a visible focus ring. */
.v:focus-visible { .v:focus-visible {
outline: 2px solid var(--bs-primary); outline: 2px solid var(--bs-primary);
outline-offset: 2px; outline-offset: 2px;
+17 -112
View File
@@ -2,13 +2,14 @@
// //
// Pages arrive whole -- composed by the service worker, or by boot.html on the // Pages arrive whole -- composed by the service worker, or by boot.html on the
// visit before there is one -- so there is nothing here that builds a page. // visit before there is one -- so there is nothing here that builds a page.
// What is left is the four things a composed document cannot do for itself: // What is left is the three things a composed document cannot do for itself:
// mark up the verse numbers as controls, put a swapped-in page where it // put a swapped-in page where it belongs before it is painted, land the
// belongs before it is painted, land the reference picker where the reader // reference picker where the reader asked to go, and tell the chrome when a
// asked to go, and tell the chrome when a worker has appeared to answer it. // worker has appeared to answer it.
// //
// Two things used to live here and no longer do, both for the same reason: // Three things used to live here and no longer do, all for the same reason:
// they were reading the page to work out something the worker already knew. // they were reading the page to work out something that was already known
// somewhere better.
// //
// The reference picker was built by reading the chapters and verses off the // The reference picker was built by reading the chapters and verses off the
// book on screen. It is markup and htmx now: sw.js renders it from // book on screen. It is markup and htmx now: sw.js renders it from
@@ -24,39 +25,18 @@
// delegated because the trigger is one of 38,000 generated verse numbers and // delegated because the trigger is one of 38,000 generated verse numbers and
// cannot carry attributes of its own. See templates/verse.njk. // cannot carry attributes of its own. See templates/verse.njk.
// //
// What is left of the dialog here is markVerses, and only because the numbers // The third was marking those numbers as controls: role, tabindex and an
// have to look and behave like controls before anyone can click one. // aria-label, set on every verse on load, after every swap and after every
// history restore -- a walk over a whole book, three times over, to arrive at
// the same three attributes every time. They are in the markup now, written
// by compile_bible.py, because nothing about them depends on anything only the
// browser knows. It costs the corpus a couple of megabytes and saves the pass.
(function () { (function () {
'use strict'; 'use strict';
// -- verse numbers ------------------------------------------------------ // -- events -------------------------------------------------------------
// Applied here rather than baked into the HTML: without this script the
// numbers are plain text, and announcing them as buttons would lie. It also
// earns Reboot's [role=button]{cursor:pointer}. Runs again after every htmx
// swap, because the new verses arrive bare.
function markVerses(root) {
var verses = (root || document).querySelectorAll('.chapter .v');
for (var i = 0; i < verses.length; i++) {
verses[i].setAttribute('role', 'button');
verses[i].setAttribute('tabindex', '0');
verses[i].setAttribute('aria-label',
'Verse ' + (verses[i].textContent || '').trim() + ', options');
}
}
// Delegated at the document, so it survives swaps and is bound only once.
// The verse numbers and the dialog's own buttons are not here: opening the
// dialog is an hx-on handler on #content, and everything inside it is
// markup that sw.js sends. This is the reset button and nothing else.
document.addEventListener('click', function (event) {
if (!event.target.closest) return;
var reset = event.target.closest('#app-reset');
if (reset) resetApp(reset);
});
document.body.addEventListener('htmx:afterSwap', function (event) { document.body.addEventListener('htmx:afterSwap', function (event) {
markVerses(event.target);
var target = event.detail.target; var target = event.detail.target;
if (!target) return; if (!target) return;
// Both selects answer into the verse list, so this is either of them. // Both selects answer into the verse list, so this is either of them.
@@ -67,10 +47,10 @@
// A history restore does not go through the request pipeline -- htmx swaps // A history restore does not go through the request pipeline -- htmx swaps
// the element straight in and fires this instead -- so the work afterSwap // 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 // does has to be repeated here or a page reached by the back button arrives
// with dead verse numbers. The picker listens for the same event itself, // scrolled to wherever the last one was. The picker listens for the same
// from the navbar, because a restore reaches nothing outside #content. // event itself, from the navbar, because a restore reaches nothing outside
// #content.
document.body.addEventListener('htmx:historyRestore', function () { document.body.addEventListener('htmx:historyRestore', function () {
markVerses();
land(); land();
showVersion(); showVersion();
}); });
@@ -195,80 +175,6 @@
el.classList.toggle('d-none', !document.querySelector('#content .index')); el.classList.toggle('d-none', !document.querySelector('#content .index'));
} }
// -- reset --------------------------------------------------------------
// Everything this app has put on the device, cleared in the order that makes
// the next load a first visit.
//
// The worker goes first, so that nothing is left answering out of a cache
// while the caches themselves are being deleted. Unregistering does not stop
// the copy already controlling this page -- an active worker keeps its
// clients until they unload -- which is what the reload at the end is for:
// the registration is gone by then, the navigation reaches server.py, and
// boot.html registers a new worker against whatever is on disk now.
//
// Then the caches, both of them, by whatever names they carry: the point of
// this button is the case where the names are not the ones this build would
// have chosen. Then the bookmark, which is the only thing here a reader
// would miss -- hence the question first.
//
// One after another rather than all at once, so that the order above is the
// order it happens in, and so that a step cannot be reading storage another
// is halfway through deleting.
//
// It still races the worker's own background download, which writes a book
// into the cache on any request that arrives while this is running -- there
// is no way to call that off from here. The reload is what settles it: the
// worker is gone by then, and the one that installs in its place opens
// caches under its own names regardless of what survived.
//
// Each step is allowed to fail on its own. A browser with no caches API, or
// storage the user has locked down, should still get everything else
// cleared and still reload.
var RESET_QUESTION =
'Clear this app from the device? The offline copy of the books and the ' +
'bookmark go with it, and the app reloads as a first visit.';
function settle(promise) {
return promise.catch(function () {});
}
function forgetWorker() {
if (!('serviceWorker' in navigator)) return Promise.resolve();
return navigator.serviceWorker.getRegistrations().then(function (regs) {
return Promise.all(regs.map(function (reg) { return reg.unregister(); }));
});
}
function forgetCaches() {
if (!window.caches) return Promise.resolve();
return caches.keys().then(function (keys) {
return Promise.all(keys.map(function (key) { return caches.delete(key); }));
});
}
// Resolved on blocked as well as on done. Blocked means another tab still
// has the database open, and there is nothing useful to do about it from
// here: this tab is about to reload, and the delete completes when the last
// one lets go.
function forgetBookmark() {
return new Promise(function (resolve) {
if (!window.indexedDB) return resolve();
var req = indexedDB.deleteDatabase('bible');
req.onsuccess = req.onerror = req.onblocked = function () { resolve(); };
});
}
function resetApp(button) {
if (!window.confirm(RESET_QUESTION)) return;
button.disabled = true;
button.textContent = 'Clearing…';
settle(forgetWorker())
.then(function () { return settle(forgetCaches()); })
.then(function () { return settle(forgetBookmark()); })
.then(function () { location.reload(); });
}
// api/bookmark has no origin server -- it is answered by the worker -- so // api/bookmark has no origin server -- it is answered by the worker -- so
// there is nothing to ask until the worker is controlling this page. // there is nothing to ask until the worker is controlling this page.
// controller, not ready: ready can resolve a tick before clients.claim() // controller, not ready: ready can resolve a tick before clients.claim()
@@ -282,7 +188,6 @@
// Deferred scripts run before DOMContentLoaded fires, so this always lands, // Deferred scripts run before DOMContentLoaded fires, so this always lands,
// and it lands after htmx has processed the shell. // and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
markVerses();
scrollToHash(); scrollToHash();
showVersion(); showVersion();
+2 -2
View File
@@ -116,8 +116,8 @@
to travel. to travel.
Enter and space go through the same door rather than a second one: Enter and space go through the same door rather than a second one:
markVerses() in app.js gives every verse role=button and tabindex, and compile_bible.py writes role=button and tabindex on every verse, and a
a button that answers the keyboard by being clicked is a button with one button that answers the keyboard by being clicked is a button with one
behaviour instead of two. #} behaviour instead of two. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt <div id="content" class="col-12 col-xl-8" hx-history-elt
hx-on:click="var verse = event.target.closest('.v[id]'); hx-on:click="var verse = event.target.closest('.v[id]');
+8 -16
View File
@@ -1,5 +1,4 @@
{# The footer under the book list: what this device is running, and the one {# The footer under the book list: what this device is running.
button that makes it forget.
Both digests are stamped into sw.js by server.py from the files on disk: Both digests are stamped into sw.js by server.py from the files on disk:
shell covers assets/, the hand-written chrome, and library covers bible/, shell covers assets/, the hand-written chrome, and library covers bible/,
@@ -12,10 +11,9 @@
the next full load -- which is the reload you would do to check it anyway. the next full load -- which is the reload you would do to check it anyway.
version is null in one case: boot.html composed this page, so there was no version is null in one case: boot.html composed this page, so there was no
worker to ask. The line is then absent, which is the honest answer -- no worker to ask. Nothing is rendered at all then, which is the honest answer
worker means no version, and the bookmark slot in the navbar is empty for -- no worker means no version, and the bookmark slot in the navbar is empty
the same reason. The button stays, because a device with no worker and a for the same reason. app.js finds no element and leaves it at that.
full cache is exactly the state worth clearing.
Rendered on every page and shown under the book list alone. Populating it Rendered on every page and shown under the book list alone. Populating it
only for the index would look like the smaller change and would not work: only for the index would look like the smaller change and would not work:
@@ -23,17 +21,11 @@
book list swaps #content without rebuilding anything around it. So the page book list swaps #content without rebuilding anything around it. So the page
decides, not the composer -- app.js looks for the index in #content after decides, not the composer -- app.js looks for the index in #content after
every swap. The body class this was rendered with cannot be asked, for the every swap. The body class this was rendered with cannot be asked, for the
same reason: it goes stale the moment a link is clicked. same reason: it goes stale the moment a link is clicked. #}
The button is a plain <button> outside any form, which is the whole of why
hx-boost on <body> leaves it alone: htmx boosts links, forms, and submits
belonging to one. resetApp() in js/app.js is what it runs. #}
<div class="app-version d-none text-center mt-5 mb-2" id="app-version">
{%- if version %} {%- if version %}
<p class="app-version-digests form-text mt-0 mb-2"> <div class="app-version d-none text-center mt-5 mb-2" id="app-version">
<p class="app-version-digests form-text m-0">
shell {{ version.shell }} &middot; library {{ version.library }} shell {{ version.shell }} &middot; library {{ version.library }}
</p> </p>
{%- endif %}
<button type="button" class="app-reset btn btn-outline-secondary btn-sm"
id="app-reset">Reset app</button>
</div> </div>
{%- endif %}
+15 -2
View File
@@ -588,8 +588,21 @@ def render_inline(nodes, book, chapter, out):
elif kind == "verse": elif kind == "verse":
# Trailing space keeps the number off the text before any CSS # Trailing space keeps the number off the text before any CSS
# lands; style .v with padding and it stays correct either way. # lands; style .v with padding and it stays correct either way.
out.append('<span class="v" id="%s">%s</span> ' % ( #
verse_id(chapter, node["label"]), esc(node["label"]))) # A control, said here rather than by a pass over the page once it
# has landed. role is what announces it and what earns Reboot's
# [role=button] pointer cursor, tabindex is what puts it in the tab
# order, and the label is what a screen reader says in place of a
# bare number. All three are true of every verse in the corpus and
# none of them depends on anything only the browser knows, so
# there is nothing here for a script to work out: app.js used to
# set them on load, after every htmx swap and after every history
# restore, walking a whole book each time. The click itself never
# needed that pass -- it is delegated at #content in base.njk.
label = esc(node["label"])
out.append('<span class="v" id="%s" role="button" tabindex="0" '
'aria-label="Verse %s, options">%s</span> '
% (verse_id(chapter, node["label"]), label, label))
elif kind == "noteref": elif kind == "noteref":
n = node["id"].replace("FN", "") n = node["id"].replace("FN", "")
out.append('<a class="noteref" id="fnref%s_%s" href="#fn%s_%s">' out.append('<a class="noteref" id="fnref%s_%s" href="#fn%s_%s">'