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;
}
/* 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 {
outline: 2px solid var(--bs-primary);
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
// 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:
// mark up the verse numbers as controls, put a swapped-in page where it
// belongs before it is painted, land the reference picker where the reader
// asked to go, and tell the chrome when a worker has appeared to answer it.
// What is left is the three things a composed document cannot do for itself:
// put a swapped-in page where it belongs before it is painted, land the
// reference picker where the reader asked to go, and tell the chrome when a
// worker has appeared to answer it.
//
// Two things used to live here and no longer do, both for the same reason:
// they were reading the page to work out something the worker already knew.
// Three things used to live here and no longer do, all for the same reason:
// 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
// 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
// cannot carry attributes of its own. See templates/verse.njk.
//
// What is left of the dialog here is markVerses, and only because the numbers
// have to look and behave like controls before anyone can click one.
// The third was marking those numbers as controls: role, tabindex and an
// 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 () {
'use strict';
// -- verse numbers ------------------------------------------------------
// 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);
});
// -- events -------------------------------------------------------------
document.body.addEventListener('htmx:afterSwap', function (event) {
markVerses(event.target);
var target = event.detail.target;
if (!target) return;
// 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
// 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. The picker listens for the same event itself,
// from the navbar, because a restore reaches nothing outside #content.
// scrolled to wherever the last one was. The picker listens for the same
// event itself, from the navbar, because a restore reaches nothing outside
// #content.
document.body.addEventListener('htmx:historyRestore', function () {
markVerses();
land();
showVersion();
});
@@ -195,80 +175,6 @@
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
// there is nothing to ask until the worker is controlling this page.
// 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,
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () {
markVerses();
scrollToHash();
showVersion();
+2 -2
View File
@@ -116,8 +116,8 @@
to travel.
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
a button that answers the keyboard by being clicked is a button with one
compile_bible.py writes role=button and tabindex on every verse, and a
button that answers the keyboard by being clicked is a button with one
behaviour instead of two. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt
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
button that makes it forget.
{# The footer under the book list: what this device is running.
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/,
@@ -12,10 +11,9 @@
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
worker to ask. The line is then absent, which is the honest answer -- no
worker means no version, and the bookmark slot in the navbar is empty for
the same reason. The button stays, because a device with no worker and a
full cache is exactly the state worth clearing.
worker to ask. Nothing is rendered at all then, which is the honest answer
-- no worker means no version, and the bookmark slot in the navbar is empty
for the same reason. app.js finds no element and leaves it at that.
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:
@@ -23,17 +21,11 @@
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
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.
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">
same reason: it goes stale the moment a link is clicked. #}
{%- 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 }}
</p>
{%- endif %}
<button type="button" class="app-reset btn btn-outline-secondary btn-sm"
id="app-reset">Reset app</button>
</div>
{%- endif %}