Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more

Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

+30 -1
View File
@@ -1,3 +1,5 @@
import { labelFor as outlineLabel } from './outlineLabels.js';
// ---------------------------------------------------------------------------
// Study constants
// ---------------------------------------------------------------------------
@@ -187,6 +189,11 @@ export function buildExportHtml(project) {
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
.greek th { background: #f8fafc; }
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.5rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.3rem 0; line-height: 1.5; }
.outline .lbl { display: inline-block; min-width: 2rem; font-weight: 700; }
.outline .ref { color: #64748b; font-size: 0.9em; }
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
@media print {
body { padding: 0; }
@@ -197,6 +204,11 @@ export function buildExportHtml(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
// The outline, if one was written: nested lists, labelled I. / A. / 1. like on the page
const outlineHtml = (project.outline ?? []).length > 0
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineListHtml(project.outline, 0)}</section>`
: '';
const chunksHtml = chapters.map((ch, chapterIndex) => {
const chapterHeader = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
const chunkSections = ch.chunks.map((chunk) => {
@@ -284,12 +296,18 @@ export function buildExportHtml(project) {
<div class="page">
<h1>${escHtml(project.title)}</h1>
<p>${escHtml(project.translation)}</p>
${outlineHtml}
${chunksHtml}
</div>
</body>
</html>`;
}
// <ul> of outline points, each with its label (I., A., 1. …) and passage
function outlineListHtml(nodes, depth) {
return `<ul>${nodes.map((n, i) => `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`).join('')}</ul>`;
}
export function wordTableHtml(rows) {
return `
<table>
@@ -341,7 +359,18 @@ export function renderVerseContent(content) {
return content;
}
if (Array.isArray(content)) {
return content.map(renderVerseContent).join('');
// A footnote marker ({ noteId }) often sits where the source had a space ("light,” [note] and there
// was light"), which would run the words together. Put the space back when nothing else supplies one.
let out = '';
let afterNote = false;
for (const part of content) {
const piece = renderVerseContent(part);
if (piece === '' && part && typeof part === 'object' && 'noteId' in part) { afterNote = true; continue; }
if (afterNote && out && piece && !/\s$/.test(out) && /^[\p{L}\p{N}“‘"'(]/u.test(piece)) out += ' ';
afterNote = false;
out += piece;
}
return out;
}
if (content && typeof content === 'object') {
if (content.lineBreak) {