Implement Bible desk study layout

This commit is contained in:
2026-07-15 18:51:35 -05:00
parent 55f7014aea
commit f2b99cec3c
13 changed files with 1034 additions and 310 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased ## Unreleased
- Completed Phase 9 Bible desk layout by replacing the 50/50 reader/resource split with a resizable Bible page, verse-focused margin, and tabbed study stack.
- Added verse-anchored margin notes with markers, translation/context anchors, visible verse markers, and export metadata in JSON, Markdown, HTML, and CSV.
- Changed the HotView HMR host from loopback-only `127.0.0.1` to the detected Tailscale IPv4 address, with a `VITE_HMR_HOST` override for other private intranet addresses. - Changed the HotView HMR host from loopback-only `127.0.0.1` to the detected Tailscale IPv4 address, with a `VITE_HMR_HOST` override for other private intranet addresses.
- Updated conventions and project-state notes before the planned consolidation phase. - Updated conventions and project-state notes before the planned consolidation phase.
- Completed Phase 8 consolidation by reorganizing the roadmap around durable product tracks and documenting app architecture boundaries. - Completed Phase 8 consolidation by reorganizing the roadmap around durable product tracks and documenting app architecture boundaries.
+4
View File
@@ -80,6 +80,10 @@ Use these tracks for future planning. Pick one active implementation phase at a
Goal: make the reader, study margin, notes, search, and resource layout feel like a serious Bible study workspace. Goal: make the reader, study margin, notes, search, and resource layout feel like a serious Bible study workspace.
Current milestone:
- Phase 9: Bible desk layout and verse-anchored notes. Complete.
Near candidates: Near candidates:
- Improve search result navigation and saved searches. - Improve search result navigation and saved searches.
+4 -3
View File
@@ -9,10 +9,11 @@ Purpose: make the app feel like a serious Bible study workspace.
Current foundation: Current foundation:
- Page-first reader. - Page-first reader.
- Study margin. - Bible desk layout with a resizable Bible page, verse-focused margin, and tabbed study stack.
- Session-aware resource toggles. - Session-aware resource toggles.
- Strong's links and definitions. - Strong's links and definitions.
- Notes drawer. - Verse-anchored margin notes with markers and export metadata.
- Notebook book in the study stack.
- Timeline. - Timeline.
- Search. - Search.
- Maps and audio controls where resources exist. - Maps and audio controls where resources exist.
@@ -21,7 +22,7 @@ Good next milestones:
- Cross-reference UX after a LibreBible package exists. - Cross-reference UX after a LibreBible package exists.
- Better search result navigation and saved searches. - Better search result navigation and saved searches.
- Highlights and richer note clipping. - Highlights, richer note clipping, and image/link notebook blocks.
- Sermon/study document workflows. - Sermon/study document workflows.
- Responsive layout and accessibility pass. - Responsive layout and accessibility pass.
+12 -12
View File
@@ -78,18 +78,7 @@ Missing or immature areas include:
## Recommended Next Step ## Recommended Next Step
The next phase should be consolidation, not another feature sprint. After Phase 9, the next work should stay inside one product track at a time. The strongest near candidate is another Study Experience milestone focused on search result navigation, saved searches, richer clipping, and notebook document blocks. Cross-references should wait for a LibreBible cross-reference package.
Consolidation should turn the completed Phase 1-7 foundation into durable product tracks:
- Study experience.
- Resource ecosystem.
- Persistence and sync.
- Platform adapters.
- Public responsibility.
- Collaboration.
The goal is to preserve the platform-agnostic architecture before adding more features.
## Phase 8 Result ## Phase 8 Result
@@ -97,3 +86,14 @@ Phase 8 completed the consolidation pass. The roadmap now keeps completed founda
- [Product Tracks](product-tracks.md) - [Product Tracks](product-tracks.md)
- [App Architecture Boundaries](app-architecture-boundaries.md) - [App Architecture Boundaries](app-architecture-boundaries.md)
## Phase 9 Result
Phase 9 started the Study Experience track by changing the workspace into a Bible desk:
- The default desktop layout is now Bible page, verse-focused margin, and study stack.
- The Bible and margin columns resize, and the study stack can be pinned or collapsed into a right-edge tab bar.
- Long-form resources moved into stack books: Notebook, Dictionary, Lexicon, Atlas, Commentary, and Search.
- The middle margin now focuses on the hovered or clicked verse and keeps compact verse material there.
- Margin notes can be written directly against a verse, carry markers like `!` and `?`, and preserve book/chapter/verse plus translation context for export.
- Session exports now include margin-note anchor metadata in JSON, Markdown, HTML, and CSV.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "libre-study", "name": "libre-study",
"version": "0.1.35", "version": "0.1.36",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "libre-study", "name": "libre-study",
"version": "0.1.35", "version": "0.1.36",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "libre-study", "name": "libre-study",
"private": true, "private": true,
"version": "0.1.35", "version": "0.1.36",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.1.35" version = "0.1.36"
dependencies = [ dependencies = [
"log", "log",
"rusqlite", "rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "0.1.35" version = "0.1.36"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study", "productName": "Libre Study",
"version": "0.1.35", "version": "0.1.36",
"identifier": "org.librestudy.desktop", "identifier": "org.librestudy.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+308 -4
View File
@@ -378,7 +378,7 @@ textarea:focus-visible {
.study-desk.study, .study-desk.study,
.study-desk.notes, .study-desk.notes,
.study-desk.compare { .study-desk.compare {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 40fr) 6px minmax(0, 20fr) 6px minmax(0, 40fr);
} }
.split-resizer { .split-resizer {
@@ -519,9 +519,10 @@ textarea:focus-visible {
} }
.page-verse { .page-verse {
position: relative;
break-inside: avoid; break-inside: avoid;
margin: 0 0 9px; margin: 0 0 9px;
padding: 3px 4px; padding: 3px 4px 3px 16px;
color: #1f211e; color: #1f211e;
font-size: 18px; font-size: 18px;
line-height: 1.58; line-height: 1.58;
@@ -533,6 +534,41 @@ textarea:focus-visible {
background: #f4ecd8; background: #f4ecd8;
} }
.page-verse.previewed {
border-radius: 5px;
background: #f8f2e4;
}
.page-verse.has-margin-note::before,
.page-verse.range-anchored::before {
position: absolute;
top: 5px;
bottom: 5px;
left: 2px;
width: 7px;
border-top: 1px solid rgba(47, 111, 94, 0.45);
border-bottom: 1px solid rgba(47, 111, 94, 0.45);
border-left: 2px solid rgba(47, 111, 94, 0.48);
border-radius: 7px 0 0 7px;
content: '';
}
.verse-note-markers {
display: inline-grid;
place-items: center;
min-width: 16px;
min-height: 16px;
margin-right: 5px;
border: 1px solid rgba(47, 111, 94, 0.32);
border-radius: 50%;
background: rgba(47, 111, 94, 0.1);
color: #225446;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 10px;
font-weight: 900;
vertical-align: middle;
}
.page-verse sup { .page-verse sup {
margin-right: 5px; margin-right: 5px;
color: #8a6d2e; color: #8a6d2e;
@@ -622,7 +658,7 @@ textarea:focus-visible {
.margin-resource-grid { .margin-resource-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
gap: 8px; gap: 8px;
align-items: start; align-items: start;
} }
@@ -826,10 +862,63 @@ textarea:focus-visible {
.commentary-card, .commentary-card,
.dictionary-card, .dictionary-card,
.map-card, .map-card,
.audio-card { .audio-card,
.margin-note-card,
.compact-commentary-card,
.margin-stack-links {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.margin-note-card {
background: #fffdf8;
}
.marker-row,
.margin-stack-links div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.marker-row button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border-color: #d8cfbf;
background: #fbf8ef;
color: #225446;
font-size: 15px;
font-weight: 900;
}
.marker-row button.active {
border-color: #2f6f5e;
background: #2f6f5e;
color: #fffef8;
}
.margin-note-input {
min-height: 94px;
resize: vertical;
font-size: 13px;
}
.compact-commentary-card p {
margin: 0;
color: #3f443d;
font-size: 12px;
line-height: 1.42;
}
.compact-commentary-card button,
.margin-stack-links button {
justify-self: start;
border-color: #d8cfbf;
background: #fbf8ef;
}
.commentary-list, .commentary-list,
.dictionary-list { .dictionary-list {
display: grid; display: grid;
@@ -1083,6 +1172,221 @@ textarea:focus-visible {
gap: 14px; gap: 14px;
} }
.study-stack {
display: grid;
min-width: 0;
min-height: 0;
}
.study-stack.pinned {
grid-template-columns: 42px minmax(0, 1fr);
}
.study-stack.unpinned {
position: relative;
z-index: 18;
width: 44px;
}
.study-stack.unpinned .study-stack-panel {
position: fixed;
top: 0;
right: 44px;
width: min(560px, calc(100vw - 104px));
height: 100vh;
box-shadow: -18px 0 42px rgba(17, 20, 17, 0.24);
}
.study-stack-tabs {
display: grid;
grid-auto-rows: minmax(42px, auto);
gap: 0;
align-content: start;
min-height: 0;
border: 1px solid #d1cab9;
border-radius: 8px 0 0 8px;
background: #1f231f;
overflow: hidden;
}
.study-stack-tabs button {
width: 100%;
min-height: 42px;
padding: 5px 3px;
border: 0;
border-bottom: 1px solid #3c4239;
border-radius: 0;
background: #252b25;
color: #f6f3eb;
font-size: 10px;
line-height: 1.05;
writing-mode: vertical-rl;
text-orientation: mixed;
}
.study-stack-tabs button.active {
background: #d6a84f;
color: #141510;
}
.study-stack-tabs .pin-stack {
background: #2f6f5e;
color: #fffef8;
font-weight: 900;
}
.study-stack-panel {
min-width: 0;
min-height: 0;
overflow: auto;
border: 1px solid #d4cdbd;
border-left: 0;
border-radius: 0 8px 8px 0;
background: #fffef8;
box-shadow: 0 12px 30px rgba(44, 48, 42, 0.08);
}
.stack-book {
display: grid;
gap: 14px;
align-content: start;
min-height: 100%;
padding: 18px;
box-sizing: border-box;
}
.stack-book > header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: start;
padding-bottom: 12px;
border-bottom: 1px solid #e4e0d6;
}
.stack-book h2,
.stack-book h3,
.stack-book h4 {
margin: 0;
letter-spacing: 0;
}
.stack-book > header h2 {
color: #202124;
font-size: 24px;
line-height: 1.1;
}
.stack-book > header span:last-child {
color: #657061;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.notebook-book textarea {
min-height: 360px;
resize: vertical;
font-size: 14px;
}
.article-book article {
display: grid;
gap: 10px;
padding-bottom: 18px;
border-bottom: 1px solid #eee7d8;
}
.article-book h3 {
color: #202124;
font-size: 24px;
line-height: 1.15;
}
.article-book h4 {
color: #657061;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
.article-book p {
margin: 0;
color: #3f443d;
font-size: 15px;
line-height: 1.58;
}
.article-kicker,
.article-pronunciation {
color: #657061;
font-size: 13px;
font-weight: 800;
}
.wiki-article-list {
display: grid;
gap: 20px;
}
.wiki-article-list article section {
display: grid;
gap: 6px;
}
.stack-token-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
}
.stack-token-grid button {
display: grid;
gap: 3px;
justify-items: start;
border-color: #ded6c7;
background: #fbf8ef;
text-align: left;
}
.stack-token-grid button.active {
border-color: #2f6f5e;
background: #eef6f1;
}
.stack-token-grid span {
color: #657061;
font-weight: 600;
}
.search-result-list {
display: grid;
gap: 8px;
overflow: auto;
}
.search-result-list button {
display: grid;
gap: 4px;
justify-items: start;
padding: 10px;
border-color: #ded6c7;
background: #fbf8ef;
color: #202124;
text-align: left;
}
.search-result-list button.active {
border-color: #2f6f5e;
background: #eef6f1;
}
.search-result-list span {
color: #3f443d;
font-weight: 500;
line-height: 1.4;
}
.notes-drawer { .notes-drawer {
position: fixed; position: fixed;
top: 0; top: 0;
+647 -278
View File
File diff suppressed because it is too large Load Diff
+26 -5
View File
@@ -115,7 +115,7 @@ function encodePathSegment(path: string) {
function renderMarkdown(exportData: SessionExport) { function renderMarkdown(exportData: SessionExport) {
const session = exportData.session const session = exportData.session
const notes = exportData.notes.filter((note) => note.body.trim()) const notes = exportData.notes.filter(hasExportableNoteContent)
return [ return [
`# ${session.title}`, `# ${session.title}`,
'', '',
@@ -126,7 +126,7 @@ function renderMarkdown(exportData: SessionExport) {
'## Notes', '## Notes',
'', '',
notes.length notes.length
? notes.map((note) => note.body.trim()).join('\n\n---\n\n') ? notes.map((note) => [`### ${noteHeading(note)}`, '', note.body.trim() || markerSummary(note)].join('\n')).join('\n\n---\n\n')
: '_No notes recorded._', : '_No notes recorded._',
'', '',
'## Timeline', '## Timeline',
@@ -140,7 +140,7 @@ function renderMarkdown(exportData: SessionExport) {
function renderHtml(exportData: SessionExport) { function renderHtml(exportData: SessionExport) {
const session = exportData.session const session = exportData.session
const notes = exportData.notes.filter((note) => note.body.trim()) const notes = exportData.notes.filter(hasExportableNoteContent)
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -154,7 +154,7 @@ function renderHtml(exportData: SessionExport) {
<dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd> <dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd>
</dl> </dl>
<h2>Notes</h2> <h2>Notes</h2>
${notes.length ? notes.map((note) => `<article><pre>${escapeHtml(note.body.trim())}</pre></article>`).join('\n') : '<p>No notes recorded.</p>'} ${notes.length ? notes.map((note) => `<article><h3>${escapeHtml(noteHeading(note))}</h3><pre>${escapeHtml(note.body.trim() || markerSummary(note))}</pre></article>`).join('\n') : '<p>No notes recorded.</p>'}
<h2>Timeline</h2> <h2>Timeline</h2>
${session.timeline.length ? `<ol>${session.timeline.map((entry) => `<li><time>${escapeHtml(entry.at)}</time> ${escapeHtml(entry.label)}${entry.reference ? ` (${escapeHtml(entry.reference)})` : ''}${entry.detail ? `: ${escapeHtml(entry.detail)}` : ''}</li>`).join('')}</ol>` : '<p>No timeline events recorded.</p>'} ${session.timeline.length ? `<ol>${session.timeline.map((entry) => `<li><time>${escapeHtml(entry.at)}</time> ${escapeHtml(entry.label)}${entry.reference ? ` (${escapeHtml(entry.reference)})` : ''}${entry.detail ? `: ${escapeHtml(entry.detail)}` : ''}</li>`).join('')}</ol>` : '<p>No timeline events recorded.</p>'}
</body> </body>
@@ -163,7 +163,7 @@ function renderHtml(exportData: SessionExport) {
function renderCsv(exportData: SessionExport) { function renderCsv(exportData: SessionExport) {
const rows = [ const rows = [
['session_title', 'note_id', 'kind', 'created_at', 'updated_at', 'reference', 'translation_id', 'body'], ['session_title', 'note_id', 'kind', 'created_at', 'updated_at', 'reference', 'translation_id', 'book', 'chapter', 'start_verse', 'end_verse', 'markers', 'body'],
...exportData.notes.map((note) => [ ...exportData.notes.map((note) => [
exportData.session.title, exportData.session.title,
note.id, note.id,
@@ -172,12 +172,33 @@ function renderCsv(exportData: SessionExport) {
note.updatedAt, note.updatedAt,
note.reference ?? '', note.reference ?? '',
note.translationId ?? '', note.translationId ?? '',
note.anchor?.book ?? '',
note.anchor?.chapter != null ? String(note.anchor.chapter) : '',
note.anchor?.startVerse != null ? String(note.anchor.startVerse) : '',
note.anchor?.endVerse != null ? String(note.anchor.endVerse) : '',
note.markers?.join(' ') ?? '',
note.body, note.body,
]), ]),
] ]
return rows.map((row) => row.map(csvCell).join(',')).join('\n') return rows.map((row) => row.map(csvCell).join(',')).join('\n')
} }
function noteHeading(note: SessionNote) {
const kind = note.kind === 'margin' ? 'Margin note' : 'Notebook'
const reference = note.anchor?.reference ?? note.reference
const translation = note.translationId ? ` - ${note.translationId}` : ''
const markers = note.markers?.length ? ` [${note.markers.join(' ')}]` : ''
return `${kind}${reference ? ` - ${reference}` : ''}${translation}${markers}`
}
function markerSummary(note: SessionNote) {
return note.markers?.length ? `Markers: ${note.markers.join(' ')}` : ''
}
function hasExportableNoteContent(note: SessionNote) {
return Boolean(note.body.trim() || note.markers?.length)
}
function csvCell(value: string) { function csvCell(value: string) {
return `"${value.replace(/"/g, '""')}"` return `"${value.replace(/"/g, '""')}"`
} }
+25 -2
View File
@@ -23,14 +23,25 @@ export type TimelineEntry = {
detail?: string detail?: string
} }
export type VerseAnchor = {
book: string
chapter: number
startVerse: number
endVerse: number
reference: string
translationId: string
}
export type SessionNote = { export type SessionNote = {
id: string id: string
kind: 'draft' kind: 'draft' | 'margin'
body: string body: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
reference?: string reference?: string
translationId?: string translationId?: string
anchor?: VerseAnchor
markers?: string[]
} }
export type StudySession = { export type StudySession = {
@@ -73,9 +84,15 @@ export type StudySettings = {
showDictionaryPanel: boolean showDictionaryPanel: boolean
splitPercent: number splitPercent: number
notesPinned: boolean notesPinned: boolean
bibleColumnPercent: number
marginColumnPercent: number
studyStackPinned: boolean
activeStudyBook: StudyBook
themeMode: ThemeMode themeMode: ThemeMode
} }
export type StudyBook = 'notebook' | 'dictionary' | 'lexicon' | 'atlas' | 'commentary' | 'search'
export type WorkspaceSnapshot = { export type WorkspaceSnapshot = {
schemaVersion: number schemaVersion: number
sessions: StudySession[] sessions: StudySession[]
@@ -130,6 +147,10 @@ export function defaultStudySettings(): StudySettings {
showDictionaryPanel: true, showDictionaryPanel: true,
splitPercent: 50, splitPercent: 50,
notesPinned: false, notesPinned: false,
bibleColumnPercent: 40,
marginColumnPercent: 20,
studyStackPinned: true,
activeStudyBook: 'notebook',
themeMode: 'light', themeMode: 'light',
} }
} }
@@ -204,12 +225,14 @@ function normalizeSessionNotes(session: Partial<StudySession>): SessionNote[] {
if (Array.isArray(session.notes) && session.notes.length) { if (Array.isArray(session.notes) && session.notes.length) {
return session.notes.map((note) => ({ return session.notes.map((note) => ({
id: note.id || timelineId(), id: note.id || timelineId(),
kind: 'draft', kind: note.kind === 'margin' ? 'margin' : 'draft',
body: note.body ?? '', body: note.body ?? '',
createdAt: note.createdAt ?? session.createdAt ?? new Date().toISOString(), createdAt: note.createdAt ?? session.createdAt ?? new Date().toISOString(),
updatedAt: note.updatedAt ?? session.updatedAt ?? new Date().toISOString(), updatedAt: note.updatedAt ?? session.updatedAt ?? new Date().toISOString(),
reference: note.reference, reference: note.reference,
translationId: note.translationId ?? session.baseTranslation, translationId: note.translationId ?? session.baseTranslation,
anchor: note.anchor,
markers: Array.isArray(note.markers) ? note.markers : [],
})) }))
} }