From ce73c7612633bdecc50dcaaef4d27e01dfbf23ba Mon Sep 17 00:00:00 2001 From: Mark Wissink Date: Wed, 9 Sep 2026 09:08:19 -0400 Subject: [PATCH] Toast --- assets/js/render.js | 2 +- assets/sw.js | 67 ++++++++++++++++++++++++++++++------- assets/templates/base.njk | 41 ++++++++++++++++++----- assets/templates/status.njk | 5 --- assets/templates/toast.njk | 42 +++++++++++++++++++++++ assets/templates/verse.njk | 39 +++++++++++++++------ 6 files changed, 158 insertions(+), 38 deletions(-) delete mode 100644 assets/templates/status.njk create mode 100644 assets/templates/toast.njk diff --git a/assets/js/render.js b/assets/js/render.js index 9a9ba3e..3d07404 100644 --- a/assets/js/render.js +++ b/assets/js/render.js @@ -39,7 +39,7 @@ 'verses.njk', 'verse.njk', 'version.njk', - 'status.njk', + 'toast.njk', 'notice.njk' ]; diff --git a/assets/sw.js b/assets/sw.js index 5f600a3..edacb5f 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -13,6 +13,7 @@ // POST /api/bookmark set the bookmark, and return the slot // GET /api/reference the navbar's chapter and verse picker // GET /api/verse the dialog for one verse +// GET /api/toast one confirmation, by name // // Anything with a dot in its last segment is a file, not a route, and is // served from the cache or the network without going near the router. Pages @@ -436,8 +437,8 @@ async function servePage(path, code, call) { if (!call.navigate && !call.restore) { // The picker is in the navbar and every boosted link targets #content, so // a swap never reaches it. It rides along out of band instead, the way a - // bookmark write carries the modal's status line: one response, both - // halves of the page. Sent even when it is null -- that is the hidden + // bookmark write carries its toast: one response, both halves of the + // page. Sent even when it is null -- that is the hidden // state, and arriving at the book list has to put the picker away. return fragment + '\n' + await view.render('reference.njk', { reference: reference, oob: true }); @@ -578,20 +579,60 @@ router.post('/api/bookmark', async (call) => { await withStore('readwrite', (s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY)); // Only on the way out of a write that committed. base.njk closes the - // dialog on this, and a dialog that closed itself on a failure would take - // the line explaining the failure with it. - return new Response(await bookmarkFragment('Bookmark set.'), { + // dialog on this, and there is nothing left in the dialog worth keeping + // open for: the confirmation rides out of band into the toast container, + // which is not inside it. + return new Response(await bookmarkFragment('bookmarked'), { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', - 'HX-Trigger-After-Settle': 'bookmark-saved' + 'HX-Trigger-After-Settle': 'verse-done' } }); } catch (err) { - return bookmarkFragment('Could not save: storage unavailable.'); + // No trigger, so the dialog stays where it is. The toast says what + // happened whether or not it does, but a dialog that closed on a failed + // write would look exactly like one that closed on a successful one. + return bookmarkFragment('bookmark-refused'); } }); +// ------------------------------------------------------------------ toasts + +// Everything the app says back to a reader who has just done something. Named +// rather than passed as text: the page asks for 'copied', not for a sentence, +// so the wording is here with the rest of the app's words and an attribute in +// a template never has to spell one out. bad is the ones that did not happen. +const TOASTS = { + 'copied': { text: 'Link copied.' }, + 'copy-refused': { text: 'Copy the link above — this browser would not ' + + 'take it.', bad: true }, + 'bookmarked': { text: 'Bookmark set.' }, + 'bookmark-refused': { text: 'Could not save the bookmark: storage ' + + 'unavailable.', bad: true } +}; + +// oob is how it travels: on its own to the toast container, which is what the +// copy button asks for, or out of band on an answer that is already going +// somewhere else, which is what a bookmark write sends. +function toastFragment(say, oob) { + const toast = TOASTS[say]; + return view.render('toast.njk', { text: toast.text, bad: toast.bad, oob: oob }); +} + +// One toast, by name. +// +// say=copied which one, from the table above. +// +// 204 for a name that is not in it, on the same reasoning api/verse 204s for a +// verse that is not there: htmx swaps nothing, so a typo appends nothing, +// rather than an empty toast sliding up to say nothing at all. +router.get('/api/toast', (call) => { + const say = call.url.searchParams.get('say'); + if (!TOASTS[say]) return new Response('', { status: 204 }); + return toastFragment(say, false); +}); + // ----------------------------------------------------------------- library // The background download of the whole corpus. @@ -693,13 +734,13 @@ function getBookmark() { .then((mark) => mark || DEFAULT_BOOKMARK); } -async function bookmarkFragment(status) { +async function bookmarkFragment(say) { let html = await view.render('bookmark.njk', { mark: await getBookmark() }); - if (status) { - // Out of band, so one response updates both the navbar slot and the - // modal's status line without the modal having to ask separately. Same - // template the shell uses, so the two cannot drift apart. - html += '\n' + await view.render('status.njk', { text: status, oob: true }); + if (say) { + // Out of band, so one response updates the navbar slot and raises the + // toast without the page having to ask for the second one. Same template + // and same table api/toast answers from, so the two cannot drift apart. + html += '\n' + await toastFragment(say, true); } return html; } diff --git a/assets/templates/base.njk b/assets/templates/base.njk index 047e3e3..01fa049 100644 --- a/assets/templates/base.njk +++ b/assets/templates/base.njk @@ -147,23 +147,46 @@ after-swap and not after-settle: settling is the 20ms attribute pass and this dialog has nothing to settle. Only the modal-content swap can reach - here -- the bookmark write targets the navbar slot, and the status line it - sends back rides in out of band, which fires its own event and not this one. + here -- the bookmark write targets the navbar slot, and the toast it sends + back rides in out of band, which fires its own event and not this one. - bookmark-saved comes from sw.js once the write has committed, and only then: - a dialog that closed itself on a failed write would take the message - explaining the failure with it. #} + verse-done is the dialog saying it has finished its job, and it arrives + from both of the things in it: sw.js sends it as a trigger header on a + bookmark write that committed, and the copy button dispatches it when the + clipboard has actually taken the link. Neither sends it when the action + failed -- the field in the dialog is the fallback for a copy that was + refused, and there is nothing to take down the dialog for when there is + nothing to celebrate. + + Closed at once rather than after a pause. The pause was there to let the + status line inside be read before the dialog took it away; the line is a + toast now, outside and above all of this, so it outlives the dialog and + there is nothing left to wait for. #} +{# Where confirmations land. Empty in the page and filled a toast at a time: + api/toast appends one when the copy button asks for it, and a bookmark + write sends one out of band on its own answer. templates/toast.njk is what + lands here, and each one shows and then removes itself, so this is empty + again a few seconds later. + + Fixed, and outside #content, for the same reason the dialog is: it has to + survive the swap under it. It also has to survive the dialog -- a copy + confirmed by a toast inside the modal would go when the modal did. + + Bottom right, and last in the document, so it covers the text rather than + moving it, and so nothing in the reading order comes after it. It sits + above the modal without being asked to: Bootstrap gives .toast a z-index of + 1090 against the modal's 1055, and this container takes none of its own, + which is what leaves that comparison to the toast. #} +
+ diff --git a/assets/templates/status.njk b/assets/templates/status.njk deleted file mode 100644 index 30326bc..0000000 --- a/assets/templates/status.njk +++ /dev/null @@ -1,5 +0,0 @@ -{# The modal's status line. One template for both paths: the shell renders it - empty, and the bookmark API re-renders it with oob set so a single response - updates the header slot and this line together. #} -

{{ text }}

diff --git a/assets/templates/toast.njk b/assets/templates/toast.njk new file mode 100644 index 0000000..c0df7c5 --- /dev/null +++ b/assets/templates/toast.njk @@ -0,0 +1,42 @@ +{# One confirmation: something the reader just did, said once and then gone. + + Bootstrap's custom-content shape -- a flex row of body and close button, + with no toast-header above it. The header carries a title, an icon and a + timestamp, which is what a notification arriving from elsewhere needs; this + is the answer to a button pressed a second ago, and the sentence is the + whole of it. + + Context: text, bad (it did not happen), oob. + + Two ways in, the same element either way. api/toast renders it alone and + htmx appends it to the container; the bookmark write renders it with oob + set and it rides along on the answer that updates the navbar slot -- one + response, both halves of the page, the way the modal's status line used to. + The wrapper is how an out-of-band element with no id of its own says where + it goes: htmx appends the wrapper's children to the named container and + drops the wrapper itself. + + It shows itself on arrival and removes itself once it has gone. hx-on::load + is htmx's own event, fired on content it has just inserted and processed, + so it covers both ways in -- and it is the same move base.njk makes to open + the dialog: the arrival of the content is the signal to show it. Bootstrap + owns the timer, the fade, the close button and Escape from there. Removing + the element on hidden.bs.toast is what keeps the container from filling up + with dead markup: a toast that has been read is not history, and nothing + here ever looks at it again. #} +{% if oob %}
{% endif %} + +{% if oob %}
{% endif %} diff --git a/assets/templates/verse.njk b/assets/templates/verse.njk index 5cb3dd0..43b7df1 100644 --- a/assets/templates/verse.njk +++ b/assets/templates/verse.njk @@ -28,7 +28,6 @@ - {% include "status.njk" %}