From c66f9a1e2a07fcee18b5766588010506c4b97d43 Mon Sep 17 00:00:00 2001 From: Mark Wissink Date: Wed, 21 Oct 2026 18:19:19 -0400 Subject: [PATCH] update borders --- assets/css/app.css | 195 +++++++++++++++++++- assets/js/render.js | 12 +- assets/ornaments/mural-311a.svg | 1 + assets/ornaments/uniform-rule-corner-bl.svg | 1 + assets/ornaments/uniform-rule-corner-br.svg | 1 + assets/ornaments/uniform-rule-corner-tl.svg | 1 + assets/ornaments/uniform-rule-corner-tr.svg | 1 + assets/sw.js | 104 ++++++++++- assets/templates/base.njk | 14 ++ assets/templates/reference.njk | 18 ++ assets/templates/sidebar.njk | 35 ++++ assets/templates/verses.njk | 4 + 12 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 assets/ornaments/mural-311a.svg create mode 100644 assets/ornaments/uniform-rule-corner-bl.svg create mode 100644 assets/ornaments/uniform-rule-corner-br.svg create mode 100644 assets/ornaments/uniform-rule-corner-tl.svg create mode 100644 assets/ornaments/uniform-rule-corner-tr.svg create mode 100644 assets/templates/reference.njk create mode 100644 assets/templates/sidebar.njk create mode 100644 assets/templates/verses.njk diff --git a/assets/css/app.css b/assets/css/app.css index d883610..914656d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -22,7 +22,11 @@ *::before, *::after { box-sizing: border-box; } +/* flow-root keeps main's top margin inside the body. Let through, it moves + the body's top edge down the page, and htmx scrolls a boosted page to that + edge, not to the very top. */ body { + display: flow-root; margin: 0; font-family: var(--font-body); font-size: 1rem; @@ -74,19 +78,200 @@ sup { a { color: var(--link); } a:hover { color: var(--link-hover); } +/* The frame's measures, shared by the sidebar and the button that opens it + below a laptop. A corner is six rules square, set turned half round from how + the piece is drawn, so its small square sits outermost. The frame runs two + rules along each side, one on the edge and one 2.5 rules in, both meeting + the corner's far side. */ +.sidebar-shell { + --rule: 3px; + --corner: calc(var(--rule) * 6); + --inner: calc(var(--rule) * 2.5); + --run: calc(100% - var(--corner) * 2); + --margin: 1.5rem; +} + +/* Over the text along the left edge, so the reading column stays centred on + the screen, with the same margin on every side. The dialog's own box has + no padding: a click that lands on it rather than on what is inside is a + click on the backdrop. */ +.sidebar { + position: fixed; + inset: var(--margin) auto var(--margin) var(--margin); + z-index: 1; + width: auto; + height: auto; + max-height: none; + margin: 0; + padding: 0; + color: var(--text); + border: 0; + background: + url("../ornaments/uniform-rule-corner-br.svg") left top / var(--corner) no-repeat, + url("../ornaments/uniform-rule-corner-bl.svg") right top / var(--corner) no-repeat, + url("../ornaments/uniform-rule-corner-tr.svg") left bottom / var(--corner) no-repeat, + url("../ornaments/uniform-rule-corner-tl.svg") right bottom / var(--corner) no-repeat, + linear-gradient(#000 0 0) var(--corner) var(--inner) / var(--run) var(--rule) no-repeat, + linear-gradient(#000 0 0) var(--corner) 0 / var(--run) var(--rule) no-repeat, + linear-gradient(#000 0 0) var(--corner) calc(100% - var(--inner)) / var(--run) var(--rule) no-repeat, + linear-gradient(#000 0 0) var(--corner) 100% / var(--run) var(--rule) no-repeat, + linear-gradient(#000 0 0) var(--inner) var(--corner) / var(--rule) var(--run) no-repeat, + linear-gradient(#000 0 0) 0 var(--corner) / var(--rule) var(--run) no-repeat, + linear-gradient(#000 0 0) calc(100% - var(--inner)) var(--corner) / var(--rule) var(--run) no-repeat, + linear-gradient(#000 0 0) 100% var(--corner) / var(--rule) var(--run) no-repeat, + var(--background); + transition: opacity .15s ease-out; +} + +/* Where the reading column has a margin beside it, the sidebar fills it, the + gap to the text the same as the margin round the rest. */ +@media (min-width: 1200px) { + .sidebar { + width: calc((100% - var(--measure)) / 2 + var(--gutter) - var(--margin) * 2); + } +} + +.sidebar-body { padding: calc(var(--corner) + 1rem); } + +.sidebar::backdrop { background: rgb(0 0 0 / .3); } + +/* Away from the top it fades out unless it is under the pointer. Focus does + not hold it: a select keeps focus after it is used, which would leave the + sidebar over the text. */ +.scrolled .sidebar:not(:modal, :hover) { opacity: 0; } + +/* Below a laptop the sidebar waits behind a button in the corner, a box in the + frame's rule. Opened, the frame grows out of it and a copy of it, which + closes the dialog, stands in for the frame's bottom right corner. */ +.sidebar-fab { + display: none; + width: 4rem; + height: 4rem; + padding: 0; + color: #000; + border: var(--rule) solid #000; + background: var(--background); + cursor: pointer; +} + +.sidebar-fab::before { + content: ""; + display: block; + width: 90%; + height: 90%; + margin: auto; + background: currentColor; + mask: url("../ornaments/mural-311a.svg") center / contain no-repeat; +} + +.sidebar-fab:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.sidebar-open { + position: fixed; + right: max(var(--margin), env(safe-area-inset-right)); + bottom: max(var(--margin), env(safe-area-inset-bottom)); + z-index: 1; + transition: opacity .15s ease-out; +} + +/* It fades out away from the top of the page but can still be tapped. Only a + real pointer brings it back on hover: a touch screen keeps :hover on + whatever was last tapped, which would leave it showing. */ +.scrolled .sidebar-open:not(:focus-visible) { opacity: 0; } + +@media (hover: hover) { + .scrolled .sidebar-open:hover { opacity: 1; } +} + +.sidebar-close { + position: absolute; + right: 0; + bottom: 0; +} + +@media (max-width: 767.98px) { + .sidebar-shell { --margin: 1rem; } +} + +/* Without a margin beside the reading column to hold the sidebar, it waits + behind the button. Opened, it fills the screen less the margin all round, + clear of a notch or home indicator. */ +@media (max-width: 1199.98px) { + .sidebar:not(:modal) { display: none; } + .sidebar-open, + .sidebar:modal .sidebar-close { display: block; } + + .sidebar:modal { + inset: max(var(--margin), env(safe-area-inset-top)) + max(var(--margin), env(safe-area-inset-right)) + max(var(--margin), env(safe-area-inset-bottom)) + max(var(--margin), env(safe-area-inset-left)); + width: auto; + max-width: none; + } +} + +.reference { + display: flex; + align-items: center; + gap: .25rem; +} + +.reference-book { + font-weight: 700; + margin-right: .25rem; +} + +/* A plain box in a hairline, square like the frame, with a small black + triangle in place of the system's arrow. */ +.reference select { + appearance: none; + padding: .25rem 1.75rem .25rem .5rem; + font: inherit; + color: #000; + border: 1px solid #000; + border-radius: 0; + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M0 0h10L5 6z'/%3E%3C/svg%3E") + right .5rem center / .625rem no-repeat, + var(--background); + cursor: pointer; +} + +.reference select:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + /* One reading column: the full width less a gutter on anything narrower than - a laptop, and a measure that grows a step with the screen beyond that. */ + a laptop, and a measure that grows a step with the screen beyond that. The + sidebar sizes itself from the same two values. */ +:root { --gutter: .75rem; } + main { margin: 1.5rem auto; - padding: 0 .75rem; + padding: 0 var(--gutter); } @media (min-width: 1200px) { - main { max-width: 760px; } + :root { --measure: 760px; } + main { max-width: var(--measure); } } @media (min-width: 1400px) { - main { max-width: 880px; } + :root { --measure: 880px; } } /* Only A-Z and a-z: the compiler puts a drop cap on a chapter only when it @@ -196,7 +381,7 @@ main { gap: .4em; font-size: 2rem; font-weight: 400; - color: var(--text-muted); + color: var(--text); margin: 5rem 0 .75rem; } diff --git a/assets/js/render.js b/assets/js/render.js index e0cab0b..a66da22 100644 --- a/assets/js/render.js +++ b/assets/js/render.js @@ -11,6 +11,9 @@ 'page.njk', 'book.njk', 'index.njk', + 'sidebar.njk', + 'reference.njk', + 'verses.njk', 'version.njk', 'notice.njk' ]; @@ -57,11 +60,16 @@ return { render: render, - compose: function (fragment) { + compose: function (fragment, chrome) { var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim(); var content = fragment.replace(TITLE_RE, ''); var name = (TEMPLATE_RE.exec(content) || ['', 'page'])[1] + '.njk'; - return render(name, { title: title, content: content }); + return render(name, { + title: title, + content: content, + sidebar: chrome.sidebar, + reference: chrome.reference + }); }, notice: function (heading, text) { diff --git a/assets/ornaments/mural-311a.svg b/assets/ornaments/mural-311a.svg new file mode 100644 index 0000000..8a1ab10 --- /dev/null +++ b/assets/ornaments/mural-311a.svg @@ -0,0 +1 @@ + diff --git a/assets/ornaments/uniform-rule-corner-bl.svg b/assets/ornaments/uniform-rule-corner-bl.svg new file mode 100644 index 0000000..516962f --- /dev/null +++ b/assets/ornaments/uniform-rule-corner-bl.svg @@ -0,0 +1 @@ + diff --git a/assets/ornaments/uniform-rule-corner-br.svg b/assets/ornaments/uniform-rule-corner-br.svg new file mode 100644 index 0000000..05deb0e --- /dev/null +++ b/assets/ornaments/uniform-rule-corner-br.svg @@ -0,0 +1 @@ + diff --git a/assets/ornaments/uniform-rule-corner-tl.svg b/assets/ornaments/uniform-rule-corner-tl.svg new file mode 100644 index 0000000..692bde0 --- /dev/null +++ b/assets/ornaments/uniform-rule-corner-tl.svg @@ -0,0 +1 @@ + diff --git a/assets/ornaments/uniform-rule-corner-tr.svg b/assets/ornaments/uniform-rule-corner-tr.svg new file mode 100644 index 0000000..d58898a --- /dev/null +++ b/assets/ornaments/uniform-rule-corner-tr.svg @@ -0,0 +1 @@ + diff --git a/assets/sw.js b/assets/sw.js index fa6feb7..1c717a9 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -24,6 +24,11 @@ const PRECACHE = [ './css/app.css', './fonts/DayLeafInitials.woff2', './ornaments/group-l-49.svg', + './ornaments/mural-311a.svg', + './ornaments/uniform-rule-corner-tl.svg', + './ornaments/uniform-rule-corner-tr.svg', + './ornaments/uniform-rule-corner-bl.svg', + './ornaments/uniform-rule-corner-br.svg', './js/htmx.min.js' ].concat(TEMPLATE_URLS); @@ -123,6 +128,71 @@ const view = createRenderer(readTemplate); const router = new Router(new URL('./', location.href).pathname); +const VERSES_URL = './verses.json'; + +let verses = null; + +function readVerses() { + if (!verses) { + verses = readText(pageRequest(VERSES_URL)) + .then(JSON.parse) + .catch((err) => { verses = null; throw err; }); + } + return verses; +} + +function parseAnchor(id) { + const chapter = /^ch(\d+)$/.exec(id || ''); + if (chapter) return { chapter: +chapter[1], verse: null }; + const verse = /^v(\d+)_[0-9-]+$/.exec(id || ''); + if (verse) return { chapter: +verse[1], verse: id }; + return null; +} + +function verseLabels(chapters, number) { + const entry = chapters[number - 1]; + if (typeof entry !== 'number') return entry || []; + const out = []; + for (let i = 1; i <= entry; i++) out.push(String(i)); + return out; +} + +function referenceFor(map, code, anchor) { + const chapters = map && Object.prototype.hasOwnProperty.call(map, code) + ? map[code] : null; + if (!chapters || !chapters.length) return null; + + if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) { + return null; + } + + const at = parseAnchor(anchor) || { chapter: 1, verse: null }; + const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1; + const verse = at.chapter === number ? at.verse : null; + + return { + book: code, + chapter: 'ch' + number, + chapters: chapters.map((_, i) => ({ id: 'ch' + (i + 1), label: String(i + 1) })), + verse: verse, + verses: verseLabels(chapters, number) + .map((label) => ({ id: 'v' + number + '_' + label, label: label })) + }; +} + +function bookOf(url) { + return url ? router.relative(url).slice(1) : ''; +} + +async function referenceOf(code, anchor) { + if (!code) return null; + try { + return referenceFor(await readVerses(), code, anchor); + } catch (err) { + return null; + } +} + const OFFLINE_NOTICE = ['Not available offline', 'This page has not been opened on this device, so there is no copy stored ' + 'here. Reconnect and try again, or open one you have read before.']; @@ -143,9 +213,13 @@ async function servePage(path, code, call) { if (!code) { fragment += '\n' + await view.render('version.njk', { version: VERSIONS }); } - if (!call.navigate && !call.restore) return fragment; + const chrome = { sidebar: !!code, reference: await referenceOf(code, null) }; + if (!call.navigate && !call.restore) { + return fragment + '\n' + + await view.render('sidebar.njk', Object.assign({ oob: true }, chrome)); + } try { - return await view.compose(fragment); + return await view.compose(fragment, chrome); } catch (err) { console.error('compose failed: ' + err.message); return fragment; @@ -156,6 +230,32 @@ router.get('/', (call) => servePage('./', '', call)); router.get('/:page', (call) => servePage('./' + call.params.page, call.params.page, call)); +router.get('/api/reference', async (call) => { + const params = call.url.searchParams; + const page = call.currentUrl(); + const moved = params.has('at') ? params.get('at') : null; + + const code = bookOf(page); + const reference = await referenceOf( + code, moved === null ? (page && page.hash.slice(1)) : moved); + + if (moved === null) { + return view.render('sidebar.njk', + { sidebar: !!code, reference: reference, settled: true }); + } + if (!reference) return new Response('', { status: 204 }); + + const target = reference.verse || reference.chapter; + return new Response(await view.render('verses.njk', { reference: reference }), { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'HX-Reswap': 'innerHTML settle:0 show:#' + target + ':top', + 'HX-Replace-Url': page.pathname + page.search + '#' + target + } + }); +}); + let filling = null; let complete = false; diff --git a/assets/templates/base.njk b/assets/templates/base.njk index 81afe16..7899393 100644 --- a/assets/templates/base.njk +++ b/assets/templates/base.njk @@ -21,9 +21,23 @@ +{% include "sidebar.njk" %} +
{% block content %}{% endblock %}
+ + diff --git a/assets/templates/reference.njk b/assets/templates/reference.njk new file mode 100644 index 0000000..0e41c40 --- /dev/null +++ b/assets/templates/reference.njk @@ -0,0 +1,18 @@ +
+ {{ reference.book }} + + + : + + +
diff --git a/assets/templates/sidebar.njk b/assets/templates/sidebar.njk new file mode 100644 index 0000000..c902095 --- /dev/null +++ b/assets/templates/sidebar.njk @@ -0,0 +1,35 @@ +{# Only the /:page routes have one. The element is always there, hidden on the + others, so that a boosted link in either direction has something to swap + out of band, and so a history restore can ask for the right one. #} + diff --git a/assets/templates/verses.njk b/assets/templates/verses.njk new file mode 100644 index 0000000..6a33762 --- /dev/null +++ b/assets/templates/verses.njk @@ -0,0 +1,4 @@ +{% for verse in reference.verses -%} + +{% endfor -%}