Skip to content

Commit 73fe070

Browse files
biblenerdclaude
andcommitted
Add the synopsis comparison modal to the parallels panel
Each pericope row in the Synoptic parallels panel now carries a "compare" button (a columns glyph) that opens a modal showing the passage across the Gospels side by side, with the actual scripture text of each. This is phase 1 of the Gospel parallels browser (approach C); a shareable /synopsis/ route is a later phase. The comparison is pre-rendered at build time (a native <dialog> per pericope), so it opens instantly with no fetch and needs only a few lines of JS to open and to close on backdrop click (Esc and the close button are native). Each column header links back to that Gospel's chapter at the verse ("Read with commentary →"), and columns whose Gospel has authored notes on the pericope show a "✎ N notes" badge, so the reader can see where commentary lives and jump there. - src/lib/texts.ts: passageText(code, range) pulls a verse range as inline HTML, matching ChapterText's per-segment spacing so text reads correctly across dropped footnote markers. - src/lib/commentary.ts: notesInRange(code, range) counts overlapping notes. - src/lib/synopsis.ts: synopsisColumns(code, entry) assembles the columns (self passage first, then each parallel Gospel). - scripts/build-parallels.mjs: emit each parallel's parsed verse span (`from`) so the columns can pull its text. - src/components/Parallels.astro: compare button + per-pericope dialog. - src/styles/global.css: compare-button and modal styles (theme-aware). The panel section keeps data-pagefind-ignore, so the pre-rendered comparison text is not indexed by search. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9bcce7b commit 73fe070

8 files changed

Lines changed: 243 additions & 13 deletions

File tree

scripts/build-parallels.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Build-time Gospel-parallels pipeline: data/parallels/synopsis.json (pericopes with
22
// per-Gospel refs) → public/data/parallels/<CODE>.json (a verse→parallels index, keyed by
33
// book then chapter). Every reference is validated against the vendored text; a bad one is
4-
// reported. Proof of concept — not yet wired into `npm run data` or any page.
4+
// reported. Runs as part of `npm run data`; feeds the Synoptic parallels panel and the
5+
// synopsis comparison modal on Gospel chapter pages.
56
//
67
// node scripts/build-parallels.mjs
78

@@ -69,7 +70,13 @@ function build() {
6970
from: { sc: c.span.sc, sv: c.span.sv, ec: c.span.ec, ev: c.span.ev },
7071
parallels: cols
7172
.filter((o) => o.code !== c.code)
72-
.map((o) => ({ code: o.code, ref: o.ref, label: o.label, href: o.href })),
73+
.map((o) => ({
74+
code: o.code,
75+
ref: o.ref,
76+
label: o.label,
77+
href: o.href,
78+
from: { sc: o.span.sc, sv: o.span.sv, ec: o.span.ec, ev: o.span.ev },
79+
})),
7380
});
7481
}
7582
}

src/components/Parallels.astro

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,99 @@
11
---
22
// "Synoptic parallels" — for each pericope in this Gospel chapter that also appears in
33
// another Gospel, the passage's own range + title, then a chip linking to the parallel
4-
// passage in each other Gospel. Sits in the right column beside the reader, mirroring
5-
// <Backlinks>. Data from public/data/parallels/<CODE>.json. REQUIREMENTS.md §5.8.
4+
// passage in each other Gospel. Each row also carries a "compare" button that opens a
5+
// modal showing the pericope across the Gospels side by side (the synopsis browser).
6+
// Sits in the right column beside the reader, mirroring <Backlinks>. REQUIREMENTS.md §5.8.
67
import type { ParallelEntry } from '../lib/parallels';
8+
import { synopsisColumns } from '../lib/synopsis';
79
810
interface Props {
11+
code: string; // this chapter's Gospel (USFM code)
912
entries: ParallelEntry[];
1013
}
11-
const { entries } = Astro.props;
14+
const { code, entries } = Astro.props;
1215
const SHORT: Record<string, string> = { MAT: 'Matt', MRK: 'Mark', LUK: 'Luke', JHN: 'John' };
1316
// Refs are stored with a plain hyphen range separator; render as an en dash to match the site.
1417
const dash = (r: string) => r.replace(/-/g, '');
18+
const rows = entries.map((e) => ({ e, cols: synopsisColumns(code, e), dlg: `syn-${code}-${e.id}` }));
1519
---
1620

1721
{entries.length > 0 && (
1822
<section class="parallels ui" data-pagefind-ignore>
1923
<div class="col-label pl-head">Synoptic parallels</div>
2024
<ul>
21-
{entries.map((e) => (
25+
{rows.map(({ e, cols, dlg }) => (
2226
<li>
23-
<span class="pl-title"><span class="pl-self">{dash(e.ref)}</span> {e.title}</span>
24-
<div class="pl-par">
25-
{e.parallels.map((p) => (
26-
<span class="pl-chip"><span class="pl-code">{SHORT[p.code] ?? p.code}</span>{' '}<a href={p.href}>{dash(p.ref)}</a></span>
27-
))}
27+
<div class="pl-main">
28+
<span class="pl-title"><span class="pl-self">{dash(e.ref)}</span> {e.title}</span>
29+
<div class="pl-par">
30+
{e.parallels.map((p) => (
31+
<span class="pl-chip"><span class="pl-code">{SHORT[p.code] ?? p.code}</span>{' '}<a href={p.href}>{dash(p.ref)}</a></span>
32+
))}
33+
</div>
2834
</div>
35+
<button
36+
type="button"
37+
class="pl-cmp"
38+
data-syn={dlg}
39+
aria-haspopup="dialog"
40+
title="Compare across the Gospels"
41+
aria-label={`Compare "${e.title}" across the Gospels`}
42+
>
43+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
44+
<rect x="3" y="4" width="5" height="16" rx="1.2" />
45+
<rect x="9.5" y="4" width="5" height="16" rx="1.2" />
46+
<rect x="16" y="4" width="5" height="16" rx="1.2" />
47+
</svg>
48+
</button>
49+
50+
<dialog class="syn-modal" id={dlg} aria-labelledby={`${dlg}-t`}>
51+
<form method="dialog" class="syn-mhead">
52+
<div>
53+
<h2 class="syn-mtitle" id={`${dlg}-t`}>{e.title}</h2>
54+
<span class="syn-mref">{cols.length} Gospels · read side by side</span>
55+
</div>
56+
<button class="syn-x" value="close" aria-label="Close">✕</button>
57+
</form>
58+
<div class="syn-scroll">
59+
<div class="syn-cols" style={`--n:${cols.length}`}>
60+
{cols.map((c) => (
61+
<div class={`syn-col${c.self ? ' self' : ''}`}>
62+
<div class="syn-chead">
63+
<div class="syn-cg">{c.name} <span class="syn-cref">{dash(c.ref)}</span>{c.self && <span class="syn-here"> · this chapter</span>}</div>
64+
<a class="syn-back" href={c.href}>Read with commentary →</a>
65+
{c.notes > 0 && <span class="syn-cnotes">✎ {c.notes} note{c.notes === 1 ? '' : 's'}</span>}
66+
</div>
67+
<div class="syn-ctext">
68+
{c.verses.length === 0
69+
? <span class="syn-empty">—</span>
70+
: c.verses.map((vv) => (
71+
<><span class="syn-vn">{vv.v}</span><Fragment set:html={vv.html} />{' '}</>
72+
))}
73+
</div>
74+
</div>
75+
))}
76+
</div>
77+
</div>
78+
<div class="syn-mfoot"><kbd>Esc</kbd> or click outside to close</div>
79+
</dialog>
2980
</li>
3081
))}
3182
</ul>
3283
</section>
3384
)}
85+
86+
<script>
87+
// Open a pericope's synopsis dialog; close on backdrop click (native form/Esc handle the rest).
88+
document.querySelectorAll<HTMLButtonElement>('.pl-cmp[data-syn]').forEach((btn) => {
89+
btn.addEventListener('click', () => {
90+
const d = document.getElementById(btn.dataset.syn!) as HTMLDialogElement | null;
91+
if (d?.showModal) d.showModal();
92+
});
93+
});
94+
document.querySelectorAll<HTMLDialogElement>('dialog.syn-modal').forEach((d) => {
95+
d.addEventListener('click', (e) => {
96+
if (e.target === d) d.close(); // click landed on the backdrop, not the content
97+
});
98+
});
99+
</script>

src/lib/commentary.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ export function notesForChapter(code: string, chapter: number): Note[] {
4040
.sort((a, b) => startIn(a) - startIn(b) || b.span - a.span);
4141
}
4242

43+
/** How many authored (non-book) notes overlap a verse range — for the synopsis "✎ notes"
44+
* badge, telling the reader which Gospel columns carry commentary on the pericope. */
45+
export function notesInRange(
46+
code: string,
47+
range: { sc: number; sv: number; ec: number; ev: number },
48+
): number {
49+
const lo = range.sc * 1000 + range.sv;
50+
const hi = range.ec * 1000 + range.ev;
51+
return loadNotes(code).filter(
52+
(n) =>
53+
n.anchor.type !== 'book' &&
54+
n.anchor.sc * 1000 + n.anchor.sv <= hi &&
55+
n.anchor.ec * 1000 + n.anchor.ev >= lo,
56+
).length;
57+
}
58+
4359
/** A note elsewhere that references a verse in some chapter ("referenced elsewhere"). */
4460
export interface Backlink {
4561
tv: number; // target verse in this chapter

src/lib/parallels.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import path from 'node:path';
55

66
const DIR = path.join(process.cwd(), 'public/data/parallels');
77

8+
import type { VerseRange } from './texts';
9+
810
export interface ParallelRef {
911
code: string; // USFM code of the parallel Gospel (MAT/MRK/LUK/JHN)
1012
ref: string; // reference within that Gospel, e.g. "3:1-6"
1113
label: string; // full label, e.g. "Matthew 3:1-6"
1214
href: string; // link to the passage, e.g. "/nt/matthew/3#v1"
15+
from: VerseRange; // parsed verse span, for pulling the passage text
1316
}
1417

1518
export interface ParallelEntry {

src/lib/synopsis.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Build-time assembly of the synopsis comparison columns for a pericope: the chapter's own
2+
// passage plus each parallel Gospel, each with its verse text and a count of the commentary
3+
// notes that cover it. Feeds the comparison modal launched from the Synoptic parallels panel.
4+
import { bookByCode } from './canon';
5+
import { passageText, type PassageVerse, type VerseRange } from './texts';
6+
import { notesInRange } from './commentary';
7+
import type { ParallelEntry } from './parallels';
8+
9+
export interface SynopsisColumn {
10+
code: string; // USFM code
11+
name: string; // Gospel name (Matthew/Mark/Luke/John)
12+
ref: string; // reference within that Gospel, e.g. "3:1-6"
13+
href: string; // link to the passage (chapter page, verse anchor)
14+
self: boolean; // true for the chapter the reader is on
15+
verses: PassageVerse[]; // the passage text, one entry per verse
16+
notes: number; // authored notes overlapping the passage (for the ✎ badge)
17+
}
18+
19+
const selfHref = (code: string, from: VerseRange): string => {
20+
const b = bookByCode(code);
21+
return b ? `/${b.testament}/${b.slug}/${from.sc}#v${from.sv}` : '#';
22+
};
23+
24+
/** Columns for a pericope: the chapter's own passage first, then each parallel Gospel. */
25+
export function synopsisColumns(selfCode: string, entry: ParallelEntry): SynopsisColumn[] {
26+
const col = (code: string, ref: string, href: string, from: VerseRange, self: boolean): SynopsisColumn => ({
27+
code,
28+
name: bookByCode(code)?.name ?? code,
29+
ref,
30+
href,
31+
self,
32+
verses: passageText(code, from),
33+
notes: notesInRange(code, from),
34+
});
35+
return [
36+
col(selfCode, entry.ref, selfHref(selfCode, entry.from), entry.from, true),
37+
...entry.parallels.map((p) => col(p.code, p.ref, p.href, p.from, false)),
38+
];
39+
}

src/lib/texts.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,59 @@ export function loadBook(code: string): BookText | null {
9595
return JSON.parse(fs.readFileSync(p, 'utf8'));
9696
}
9797

98+
/** A verse-numbered range within a book, e.g. { sc:1, sv:2, ec:1, ev:6 }. */
99+
export interface VerseRange {
100+
sc: number;
101+
sv: number;
102+
ec: number;
103+
ev: number;
104+
}
105+
/** One rendered verse of a passage. */
106+
export interface PassageVerse {
107+
v: number; // verse number
108+
html: string; // inline HTML (italic runs preserved, footnote markers dropped)
109+
}
110+
111+
const vkey = (c: number, v: number) => c * 1000 + v;
112+
113+
/**
114+
* The text of a verse range, one entry per verse, as inline HTML. Walks the book's block
115+
* model tracking the running verse number and collects the text segments that fall inside
116+
* [sc:sv … ec:ev]. Footnote markers are dropped; italic (translator-addition) runs are kept.
117+
* Used to render synopsis comparison columns at build time.
118+
*/
119+
export function passageText(code: string, range: VerseRange): PassageVerse[] {
120+
const book = loadBook(code);
121+
if (!book) return [];
122+
const lo = vkey(range.sc, range.sv);
123+
const hi = vkey(range.ec, range.ev);
124+
const order: number[] = [];
125+
const acc = new Map<number, string>();
126+
for (const chap of book.chapters) {
127+
if (chap.number < range.sc || chap.number > range.ec) continue;
128+
let cur: number | null = null;
129+
for (const blk of chap.blocks) {
130+
if (!blk.segments) continue;
131+
for (const seg of blk.segments) {
132+
if (isVerse(seg)) {
133+
cur = seg.n;
134+
} else if (!isFootnote(seg) && cur != null) {
135+
const k = vkey(chap.number, cur);
136+
if (k < lo || k > hi) continue;
137+
if (!acc.has(k)) {
138+
acc.set(k, '');
139+
order.push(k);
140+
}
141+
// Trailing space per segment mirrors ChapterText, so text reads correctly across
142+
// dropped footnote markers (e.g. "Jesus" + \f + "Christ" → "Jesus Christ").
143+
acc.set(k, acc.get(k)! + inlineHtml((seg as TextSeg).t) + ' ');
144+
}
145+
}
146+
}
147+
}
148+
return order.map((k) => ({ v: k % 1000, html: (acc.get(k) ?? '').replace(/\s+/g, ' ').trim() }));
149+
}
150+
98151
export interface Intro {
99152
title: string;
100153
html: string;

src/pages/[testament]/[book]/[chapter].astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ const credit =
128128
<div class="note-body" set:html={n.html} />
129129
</article>
130130
))}
131-
<Parallels entries={parallels} />
131+
<Parallels code={book.code} entries={parallels} />
132132
<Backlinks groups={back.groups} bookNotes={back.bookNotes} total={back.total} />
133133
</section>
134134
</div>

src/styles/global.css

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,8 +599,10 @@ ol ol, ol ul, ul ul, ul ol { padding-left: 1.25em; }
599599
font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-soft);
600600
}
601601
.parallels ul { list-style: none; margin: 8px 0 0; padding: 0; }
602-
.parallels li { padding: 9px 0; border-bottom: 1px dotted var(--rule); }
602+
.parallels li { padding: 9px 0; border-bottom: 1px dotted var(--rule);
603+
display: flex; gap: 8px; align-items: flex-start; }
603604
.parallels li:last-child { border-bottom: 0; }
605+
.parallels .pl-main { flex: 1; min-width: 0; }
604606
.parallels .pl-title { font-size: 13.5px; color: var(--ink); }
605607
.parallels .pl-self { color: var(--accent); font-weight: 700; margin-right: 5px; }
606608
.parallels .pl-par { margin-top: 5px; }
@@ -609,6 +611,50 @@ ol ol, ol ul, ul ul, ul ol { padding-left: 1.25em; }
609611
.parallels .pl-code { color: var(--gold-ink); font-weight: 700; font-size: 11px; }
610612
.parallels .pl-chip a { color: var(--link); text-decoration: none; }
611613
.parallels .pl-chip a:hover { text-decoration: underline; color: var(--accent); }
614+
/* compare button — opens the synopsis modal for this pericope */
615+
.parallels .pl-cmp { flex: none; margin-top: 1px; width: 26px; height: 26px;
616+
display: inline-flex; align-items: center; justify-content: center;
617+
border: 1px solid var(--rule); border-radius: 6px; background: var(--bg); color: var(--accent);
618+
cursor: pointer; transition: background .12s, border-color .12s; }
619+
.parallels .pl-cmp:hover { background: var(--accent-soft); border-color: var(--accent); }
620+
.parallels .pl-cmp svg { width: 15px; height: 15px; display: block; }
621+
622+
/* ---- synopsis comparison modal (launched from a parallels row) ---- */
623+
dialog.syn-modal { width: min(1000px, 94vw); max-height: 88vh; padding: 0;
624+
border: 1px solid var(--rule); border-radius: 14px; background: var(--panel); color: var(--ink);
625+
box-shadow: 0 24px 70px rgba(0, 0, 0, .35); font-family: var(--sans); }
626+
dialog.syn-modal::backdrop { background: rgba(20, 17, 12, .55); }
627+
.syn-modal .syn-mhead { position: sticky; top: 0; z-index: 1; background: var(--panel);
628+
border-bottom: 1px solid var(--rule); border-radius: 14px 14px 0 0;
629+
display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; }
630+
.syn-modal .syn-mtitle { margin: 0; font-size: 16px; color: var(--accent); font-family: var(--serif); }
631+
.syn-modal .syn-mref { font-size: 12px; color: var(--ink-soft); }
632+
.syn-modal .syn-mhead > div { flex: 1; }
633+
.syn-modal .syn-x { flex: none; border: 1px solid var(--rule); background: var(--bg); border-radius: 7px;
634+
width: 30px; height: 30px; cursor: pointer; color: var(--ink-soft); font-size: 15px; line-height: 1; }
635+
.syn-modal .syn-x:hover { border-color: var(--accent); color: var(--accent); }
636+
.syn-modal .syn-scroll { overflow: auto; max-height: calc(88vh - 96px); }
637+
.syn-modal .syn-cols { display: grid; grid-template-columns: repeat(var(--n), minmax(230px, 1fr));
638+
gap: 1px; background: var(--rule); }
639+
.syn-modal .syn-col { background: var(--panel); padding: 12px 15px 16px; min-width: 0; }
640+
.syn-modal .syn-col.self { background: var(--stripe); }
641+
.syn-modal .syn-chead { border-bottom: 1px solid var(--rule); padding-bottom: 8px; margin-bottom: 9px; }
642+
.syn-modal .syn-cg { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--accent); }
643+
.syn-modal .syn-cref { color: var(--ink-soft); font-weight: 400; }
644+
.syn-modal .syn-here { color: var(--ink-soft); font-weight: 400; text-transform: none; letter-spacing: 0; }
645+
.syn-modal .syn-back { display: inline-block; margin-top: 5px; font-size: 12px; font-weight: 600;
646+
text-decoration: none; color: var(--link); }
647+
.syn-modal .syn-back:hover { text-decoration: underline; }
648+
.syn-modal .syn-cnotes { display: inline-block; margin-left: 8px; font-size: 10.5px; font-weight: 700;
649+
color: var(--gold-ink); background: var(--gold-soft); border: 1px solid var(--gold-edge); border-radius: 4px; padding: 0 5px; }
650+
.syn-modal .syn-ctext { font-family: var(--serif); font-size: 15px; line-height: 1.6; color: var(--ink); }
651+
.syn-modal .syn-vn { font-size: .62em; color: var(--accent); font-weight: 700; vertical-align: super; margin-right: 2px; }
652+
.syn-modal .syn-empty { color: var(--ink-soft); font-style: italic; }
653+
.syn-modal .syn-mfoot { border-top: 1px solid var(--rule); padding: 9px 16px; font-size: 12px; color: var(--ink-soft); }
654+
.syn-modal kbd { font-family: var(--sans); font-size: 11px; border: 1px solid var(--rule); border-radius: 4px; padding: 0 5px; background: var(--bg); }
655+
@media (max-width: 560px) {
656+
.syn-modal .syn-cols { grid-template-columns: repeat(var(--n), minmax(200px, 1fr)); }
657+
}
612658
.note-body blockquote {
613659
margin: 12px 0; padding: 10px 16px; border-left: 3px solid var(--gold);
614660
background: var(--gold-soft); border-radius: 0 6px 6px 0; color: var(--ink-soft);

0 commit comments

Comments
 (0)