Files
biblical-guide.com/docs/plans/2025-01-11-bible-reader-2025-implementation.md

38 KiB

2025 Modern Bible Reader Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement a state-of-the-art, distraction-free Bible reader with smart customization, offline-first caching, and seamless cross-device sync.

Architecture: Build modular components (SearchNavigator, ReadingView, VersDetailsPanel, ReadingSettings, OfflineSyncManager) backed by IndexedDB for caching, localStorage for preferences, and a service worker for offline detection. Progressive disclosure: text is always the hero, details panel reveals on demand.

Tech Stack: Next.js (React), Material-UI, IndexedDB, Service Workers, localStorage, TypeScript


Phase 1: Core Reading Experience (MVP)

Task 1: Create Enhanced BibleReader Component Structure

Files:

  • Create: components/bible/bible-reader-2025.tsx (main component)
  • Create: components/bible/search-navigator.tsx (search interface)
  • Create: components/bible/reading-view.tsx (text display)
  • Create: components/bible/verse-details-panel.tsx (verse info panel)
  • Create: components/bible/reading-settings.tsx (customization)
  • Modify: types/index.ts (add new types)
  • Create: lib/cache-manager.ts (IndexedDB caching)

Step 1: Update types for new reader

Modify types/index.ts to add:

// Add to existing types
export interface BibleVerse {
  id: string
  verseNum: number
  text: string
  bookId: number
  chapter: number
}

export interface BibleChapter {
  id: string
  bookId: number
  bookName: string
  chapter: number
  verses: BibleVerse[]
  timestamp?: number
}

export interface ReadingPreference {
  fontFamily: string // 'georgia', 'inter', 'atkinson', etc.
  fontSize: number // 12-32
  lineHeight: number // 1.4-2.2
  letterSpacing: number // 0-0.15
  textAlign: 'left' | 'center' | 'justify'
  backgroundColor: string // color code
  textColor: string // color code
  margin: 'narrow' | 'normal' | 'wide'
  preset: 'default' | 'dyslexia' | 'highContrast' | 'minimal' | 'custom'
}

export interface UserAnnotation {
  id: string
  verseId: string
  chapterId: string
  type: 'bookmark' | 'highlight' | 'note' | 'crossRef'
  content?: string
  color?: string // for highlights
  timestamp: number
  synced: boolean
}

export interface CacheEntry {
  chapterId: string
  data: BibleChapter
  timestamp: number
  expiresAt: number
}

Step 2: Create cache-manager.ts for IndexedDB

Create lib/cache-manager.ts:

// IndexedDB cache management
const DB_NAME = 'BibleReaderDB'
const DB_VERSION = 1
const STORE_NAME = 'chapters'
const CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
const MAX_CACHE_SIZE = 50 // keep last 50 chapters

let db: IDBDatabase | null = null

export async function initDatabase(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION)

    request.onerror = () => reject(request.error)
    request.onsuccess = () => {
      db = request.result
      resolve(db)
    }

    request.onupgradeneeded = (event) => {
      const database = (event.target as IDBOpenDBRequest).result
      if (!database.objectStoreNames.contains(STORE_NAME)) {
        const store = database.createObjectStore(STORE_NAME, { keyPath: 'chapterId' })
        store.createIndex('timestamp', 'timestamp', { unique: false })
      }
    }
  })
}

export async function cacheChapter(chapter: BibleChapter): Promise<void> {
  if (!db) await initDatabase()

  const entry: CacheEntry = {
    chapterId: chapter.id,
    data: chapter,
    timestamp: Date.now(),
    expiresAt: Date.now() + CACHE_DURATION_MS
  }

  return new Promise((resolve, reject) => {
    const transaction = db!.transaction([STORE_NAME], 'readwrite')
    const store = transaction.objectStore(STORE_NAME)

    // Delete oldest entries if over limit
    const countRequest = store.count()
    countRequest.onsuccess = () => {
      if (countRequest.result >= MAX_CACHE_SIZE) {
        const index = store.index('timestamp')
        const oldestRequest = index.openCursor()
        oldestRequest.onsuccess = (event) => {
          const cursor = (event.target as IDBRequest).result
          if (cursor) {
            cursor.delete()
          }
        }
      }
    }

    const request = store.put(entry)
    request.onerror = () => reject(request.error)
    request.onsuccess = () => resolve()
  })
}

export async function getCachedChapter(chapterId: string): Promise<BibleChapter | null> {
  if (!db) await initDatabase()

  return new Promise((resolve, reject) => {
    const transaction = db!.transaction([STORE_NAME], 'readonly')
    const store = transaction.objectStore(STORE_NAME)
    const request = store.get(chapterId)

    request.onerror = () => reject(request.error)
    request.onsuccess = () => {
      const entry = request.result as CacheEntry | undefined
      if (entry && entry.expiresAt > Date.now()) {
        resolve(entry.data)
      } else {
        resolve(null)
      }
    }
  })
}

export async function clearExpiredCache(): Promise<void> {
  if (!db) await initDatabase()

  return new Promise((resolve, reject) => {
    const transaction = db!.transaction([STORE_NAME], 'readwrite')
    const store = transaction.objectStore(STORE_NAME)
    const index = store.index('timestamp')
    const range = IDBKeyRange.upperBound(Date.now() - CACHE_DURATION_MS)
    const request = index.openCursor(range)

    request.onsuccess = (event) => {
      const cursor = (event.target as IDBRequest).result
      if (cursor) {
        cursor.delete()
        cursor.continue()
      } else {
        resolve()
      }
    }

    request.onerror = () => reject(request.error)
  })
}

Step 3: Commit infrastructure

git add types/index.ts lib/cache-manager.ts
git commit -m "feat: add types and IndexedDB cache manager for Bible reader 2025"

Task 2: Implement SearchNavigator Component

Files:

  • Create: components/bible/search-navigator.tsx
  • Create: lib/bible-search.ts (search logic)

Step 1: Create search logic utility

Create lib/bible-search.ts:

// Bible books data with abbreviations
const BIBLE_BOOKS = [
  { id: 1, name: 'Genesis', abbr: 'Gen', chapters: 50 },
  { id: 2, name: 'Exodus', abbr: 'Ex', chapters: 40 },
  // ... all 66 books
  { id: 66, name: 'Revelation', abbr: 'Rev', chapters: 22 }
]

export interface SearchResult {
  bookId: number
  bookName: string
  chapter: number
  reference: string
}

export function searchBooks(query: string): SearchResult[] {
  if (!query.trim()) return []

  const lowerQuery = query.toLowerCase()
  const results: SearchResult[] = []

  // Try to parse as "Book Chapter" format (e.g., "Genesis 1", "Gen 1")
  const refMatch = query.match(/^([a-z\s]+)\s*(\d+)?/i)
  if (refMatch) {
    const bookQuery = refMatch[1].toLowerCase().trim()
    const chapterNum = refMatch[2] ? parseInt(refMatch[2]) : 1

    for (const book of BIBLE_BOOKS) {
      if (book.name.toLowerCase().startsWith(bookQuery) ||
          book.abbr.toLowerCase().startsWith(bookQuery)) {
        if (chapterNum <= book.chapters) {
          results.push({
            bookId: book.id,
            bookName: book.name,
            chapter: chapterNum,
            reference: `${book.name} ${chapterNum}`
          })
        }
      }
    }
  }

  // Fuzzy match on book names if exact prefix didn't work
  if (results.length === 0) {
    for (const book of BIBLE_BOOKS) {
      if (book.name.toLowerCase().includes(lowerQuery) ||
          book.abbr.toLowerCase().includes(lowerQuery)) {
        results.push({
          bookId: book.id,
          bookName: book.name,
          chapter: 1,
          reference: book.name
        })
      }
    }
  }

  return results.slice(0, 10) // Return top 10
}

export function parseReference(ref: string): { bookId: number; chapter: number } | null {
  const match = ref.match(/^([a-z\s]+)\s*(\d+)?/i)
  if (!match) return null

  const bookQuery = match[1].toLowerCase().trim()
  const chapterNum = match[2] ? parseInt(match[2]) : 1

  for (const book of BIBLE_BOOKS) {
    if (book.name.toLowerCase().startsWith(bookQuery) ||
        book.abbr.toLowerCase() === bookQuery) {
      return {
        bookId: book.id,
        chapter: Math.max(1, Math.min(chapterNum, book.chapters))
      }
    }
  }

  return null
}

Step 2: Create SearchNavigator component

Create components/bible/search-navigator.tsx:

'use client'
import { useState, useEffect } from 'react'
import { Search, Close } from '@mui/icons-material'
import { Box, TextField, InputAdornment, Paper, List, ListItem, ListItemButton, Typography } from '@mui/material'
import { searchBooks, type SearchResult } from '@/lib/bible-search'

interface SearchNavigatorProps {
  onNavigate: (bookId: number, chapter: number) => void
}

export function SearchNavigator({ onNavigate }: SearchNavigatorProps) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
  const [isOpen, setIsOpen] = useState(false)

  useEffect(() => {
    if (query.trim()) {
      setResults(searchBooks(query))
      setIsOpen(true)
    } else {
      setResults([])
      setIsOpen(false)
    }
  }, [query])

  const handleSelect = (result: SearchResult) => {
    onNavigate(result.bookId, result.chapter)
    setQuery('')
    setIsOpen(false)
  }

  return (
    <Box sx={{ position: 'relative', width: '100%' }}>
      <TextField
        placeholder="Search Bible (e.g., Genesis 1, John 3:16)"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onFocus={() => query && setIsOpen(true)}
        InputProps={{
          startAdornment: (
            <InputAdornment position="start">
              <Search sx={{ color: 'text.secondary' }} />
            </InputAdornment>
          ),
          endAdornment: query && (
            <InputAdornment position="end">
              <Close
                sx={{ cursor: 'pointer', color: 'text.secondary' }}
                onClick={() => setQuery('')}
              />
            </InputAdornment>
          ),
        }}
        sx={{
          width: '100%',
          '& .MuiOutlinedInput-root': {
            fontSize: '0.95rem',
            '@media (max-width: 600px)': {
              fontSize: '1rem' // Larger on mobile to avoid zoom
            }
          }
        }}
      />

      {isOpen && results.length > 0 && (
        <Paper
          sx={{
            position: 'absolute',
            top: '100%',
            left: 0,
            right: 0,
            zIndex: 10,
            mt: 1,
            maxHeight: 300,
            overflow: 'auto'
          }}
        >
          <List>
            {results.map((result, idx) => (
              <ListItem key={idx} disablePadding>
                <ListItemButton onClick={() => handleSelect(result)}>
                  <Box>
                    <Typography variant="body2" fontWeight={500}>
                      {result.reference}
                    </Typography>
                  </Box>
                </ListItemButton>
              </ListItem>
            ))}
          </List>
        </Paper>
      )}
    </Box>
  )
}

Step 3: Test search logic

Create __tests__/lib/bible-search.test.ts:

import { searchBooks, parseReference } from '@/lib/bible-search'

describe('searchBooks', () => {
  it('returns results for exact book prefix', () => {
    const results = searchBooks('Genesis')
    expect(results.length).toBeGreaterThan(0)
    expect(results[0].bookName).toBe('Genesis')
  })

  it('parses "Book Chapter" format', () => {
    const results = searchBooks('Genesis 5')
    expect(results[0].chapter).toBe(5)
  })

  it('works with abbreviations', () => {
    const results = searchBooks('Gen 1')
    expect(results[0].bookName).toBe('Genesis')
  })

  it('returns empty array for empty query', () => {
    expect(searchBooks('').length).toBe(0)
  })
})

describe('parseReference', () => {
  it('parses full book name with chapter', () => {
    const result = parseReference('Genesis 3')
    expect(result?.bookId).toBe(1)
    expect(result?.chapter).toBe(3)
  })

  it('defaults to chapter 1', () => {
    const result = parseReference('Genesis')
    expect(result?.chapter).toBe(1)
  })
})

Step 4: Run tests

npm test -- __tests__/lib/bible-search.test.ts
# Expected: PASS (all tests)

Step 5: Commit

git add components/bible/search-navigator.tsx lib/bible-search.ts __tests__/lib/bible-search.test.ts
git commit -m "feat: implement search-first Bible navigator with touch optimization"

Task 3: Implement ReadingView Component

Files:

  • Create: components/bible/reading-view.tsx
  • Create: lib/reading-preferences.ts (preference management)

Step 1: Create preference management utility

Create lib/reading-preferences.ts:

import { ReadingPreference } from '@/types'

const PRESETS: Record<string, ReadingPreference> = {
  default: {
    fontFamily: 'georgia',
    fontSize: 18,
    lineHeight: 1.8,
    letterSpacing: 0,
    textAlign: 'left',
    backgroundColor: '#faf8f3',
    textColor: '#333333',
    margin: 'normal',
    preset: 'default'
  },
  dyslexia: {
    fontFamily: 'atkinson',
    fontSize: 18,
    lineHeight: 1.9,
    letterSpacing: 0.08,
    textAlign: 'left',
    backgroundColor: '#f5f5dc',
    textColor: '#333333',
    margin: 'normal',
    preset: 'dyslexia'
  },
  highContrast: {
    fontFamily: 'inter',
    fontSize: 16,
    lineHeight: 1.6,
    letterSpacing: 0,
    textAlign: 'left',
    backgroundColor: '#000000',
    textColor: '#ffffff',
    margin: 'wide',
    preset: 'highContrast'
  },
  minimal: {
    fontFamily: 'georgia',
    fontSize: 16,
    lineHeight: 1.6,
    letterSpacing: 0,
    textAlign: 'left',
    backgroundColor: '#ffffff',
    textColor: '#000000',
    margin: 'narrow',
    preset: 'minimal'
  }
}

const STORAGE_KEY = 'bibleReaderPreferences'

export function getPreset(name: keyof typeof PRESETS): ReadingPreference {
  return PRESETS[name]
}

export function loadPreferences(): ReadingPreference {
  if (typeof window === 'undefined') {
    return PRESETS.default
  }

  try {
    const stored = localStorage.getItem(STORAGE_KEY)
    return stored ? JSON.parse(stored) : PRESETS.default
  } catch {
    return PRESETS.default
  }
}

export function savePreferences(prefs: ReadingPreference): void {
  if (typeof window === 'undefined') return

  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
  } catch (e) {
    console.error('Failed to save preferences:', e)
  }
}

export function getCSSVariables(prefs: ReadingPreference): Record<string, string> {
  return {
    '--font-family': getFontStack(prefs.fontFamily),
    '--font-size': `${prefs.fontSize}px`,
    '--line-height': `${prefs.lineHeight}`,
    '--letter-spacing': `${prefs.letterSpacing}em`,
    '--bg-color': prefs.backgroundColor,
    '--text-color': prefs.textColor,
    '--margin-width': getMarginWidth(prefs.margin),
  }
}

function getFontStack(fontFamily: string): string {
  const stacks: Record<string, string> = {
    georgia: 'Georgia, serif',
    inter: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
    atkinson: '"Atkinson Hyperlegible", sans-serif',
    merriweather: '"Merriweather", serif',
  }
  return stacks[fontFamily] || stacks.georgia
}

function getMarginWidth(margin: string): string {
  const margins: Record<string, string> = {
    narrow: 'max(1rem, 5%)',
    normal: 'max(2rem, 10%)',
    wide: 'max(4rem, 15%)',
  }
  return margins[margin] || margins.normal
}

Step 2: Create ReadingView component

Create components/bible/reading-view.tsx:

'use client'
import { useState, useEffect, CSSProperties } from 'react'
import { Box, Typography, IconButton, Paper, useMediaQuery, useTheme } from '@mui/material'
import { NavigateBefore, NavigateNext, Settings as SettingsIcon } from '@mui/icons-material'
import { BibleChapter } from '@/types'
import { getCSSVariables, loadPreferences } from '@/lib/reading-preferences'

interface ReadingViewProps {
  chapter: BibleChapter
  loading: boolean
  onPrevChapter: () => void
  onNextChapter: () => void
  onVerseClick: (verseId: string) => void
  onSettingsOpen: () => void
  hasPrevChapter: boolean
  hasNextChapter: boolean
}

export function ReadingView({
  chapter,
  loading,
  onPrevChapter,
  onNextChapter,
  onVerseClick,
  onSettingsOpen,
  hasPrevChapter,
  hasNextChapter,
}: ReadingViewProps) {
  const theme = useTheme()
  const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
  const isTablet = useMediaQuery(theme.breakpoints.down('md'))
  const [preferences, setPreferences] = useState(loadPreferences())
  const [showControls, setShowControls] = useState(!isMobile)

  useEffect(() => {
    setPreferences(loadPreferences())
  }, [])

  const cssVars = getCSSVariables(preferences)

  if (loading) {
    return (
      <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
        <Typography>Loading chapter...</Typography>
      </Box>
    )
  }

  return (
    <Box
      sx={{
        ...cssVars,
        backgroundColor: 'var(--bg-color)',
        color: 'var(--text-color)',
        minHeight: '100vh',
        transition: 'background-color 0.2s, color 0.2s',
        display: 'flex',
        flexDirection: 'column',
        position: 'relative'
      } as CSSProperties}
      onClick={(e) => {
        if (isMobile) {
          const rect = e.currentTarget.getBoundingClientRect()
          const y = e.clientY - rect.top
          if (y < rect.height * 0.3) {
            setShowControls(true)
          } else if (y > rect.height * 0.7) {
            setShowControls(!showControls)
          } else {
            setShowControls(false)
          }
        }
      }}
    >
      {/* Header */}
      {(showControls || !isMobile) && (
        <Paper
          elevation={0}
          sx={{
            p: 2,
            backgroundColor: 'inherit',
            borderBottom: `1px solid var(--text-color)`,
            opacity: 0.7
          }}
        >
          <Typography variant="h5" fontWeight={600}>
            {chapter.bookName} {chapter.chapter}
          </Typography>
        </Paper>
      )}

      {/* Main Text Area */}
      <Box
        sx={{
          flex: 1,
          p: 3,
          maxWidth: 700,
          mx: 'auto',
          width: '100%',
          margin: 'var(--margin-width)',
          lineHeight: 'var(--line-height)',
          fontSize: 'var(--font-size)',
          fontFamily: 'var(--font-family)',
        } as CSSProperties}
      >
        {chapter.verses.map((verse) => (
          <span
            key={verse.id}
            onClick={(e) => {
              e.stopPropagation()
              onVerseClick(verse.id)
            }}
            style={{
              cursor: 'pointer',
              transition: 'background-color 0.15s',
            }}
            onMouseEnter={(e) => {
              e.currentTarget.style.backgroundColor = 'rgba(255, 193, 7, 0.3)'
            }}
            onMouseLeave={(e) => {
              e.currentTarget.style.backgroundColor = 'transparent'
            }}
          >
            <sup style={{ fontSize: '0.8em', marginRight: '0.25em', fontWeight: 600, opacity: 0.6 }}>
              {verse.verseNum}
            </sup>
            {verse.text}{' '}
          </span>
        ))}
      </Box>

      {/* Navigation Footer */}
      {(showControls || !isMobile) && (
        <Paper
          elevation={0}
          sx={{
            p: 2,
            backgroundColor: 'inherit',
            borderTop: `1px solid var(--text-color)`,
            opacity: 0.7,
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
          }}
        >
          <IconButton
            onClick={onPrevChapter}
            disabled={!hasPrevChapter}
            size={isMobile ? 'small' : 'medium'}
          >
            <NavigateBefore />
          </IconButton>

          <Typography variant="body2">
            Chapter {chapter.chapter}
          </Typography>

          <IconButton
            onClick={onSettingsOpen}
            size={isMobile ? 'small' : 'medium'}
          >
            <SettingsIcon />
          </IconButton>

          <IconButton
            onClick={onNextChapter}
            disabled={!hasNextChapter}
            size={isMobile ? 'small' : 'medium'}
          >
            <NavigateNext />
          </IconButton>
        </Paper>
      )}
    </Box>
  )
}

Step 3: Test preference loading

Create __tests__/lib/reading-preferences.test.ts:

import { getCSSVariables, getPreset } from '@/lib/reading-preferences'

describe('reading-preferences', () => {
  it('returns default preset', () => {
    const preset = getPreset('default')
    expect(preset.fontFamily).toBe('georgia')
    expect(preset.fontSize).toBe(18)
  })

  it('generates CSS variables', () => {
    const preset = getPreset('dyslexia')
    const vars = getCSSVariables(preset)
    expect(vars['--font-size']).toBe('18px')
    expect(vars['--letter-spacing']).toBe('0.08em')
  })
})

Step 4: Run tests

npm test -- __tests__/lib/reading-preferences.test.ts
# Expected: PASS

Step 5: Commit

git add components/bible/reading-view.tsx lib/reading-preferences.ts __tests__/lib/reading-preferences.test.ts
git commit -m "feat: implement responsive ReadingView with preference support"

Task 4: Implement VersDetailsPanel Component

Files:

  • Create: components/bible/verse-details-panel.tsx

Step 1: Create VersDetailsPanel

Create components/bible/verse-details-panel.tsx:

'use client'
import { useState } from 'react'
import { Box, Paper, Typography, Tabs, Tab, IconButton, useMediaQuery, useTheme, TextField, Button } from '@mui/material'
import { Close, Bookmark, BookmarkBorder } from '@mui/icons-material'
import { BibleVerse } from '@/types'

interface VersDetailsPanelProps {
  verse: BibleVerse | null
  isOpen: boolean
  onClose: () => void
  isBookmarked: boolean
  onToggleBookmark: () => void
  onAddNote: (note: string) => void
}

export function VersDetailsPanel({
  verse,
  isOpen,
  onClose,
  isBookmarked,
  onToggleBookmark,
  onAddNote,
}: VersDetailsPanelProps) {
  const theme = useTheme()
  const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
  const [tabValue, setTabValue] = useState(0)
  const [noteText, setNoteText] = useState('')

  if (!verse || !isOpen) return null

  const handleAddNote = () => {
    if (noteText.trim()) {
      onAddNote(noteText)
      setNoteText('')
    }
  }

  const PanelContent = (
    <Box sx={{ p: 2 }}>
      {/* Verse Header */}
      <Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
        <Typography variant="subtitle1" fontWeight={600}>
          Verse {verse.verseNum}
        </Typography>
        <IconButton size="small" onClick={onClose}>
          <Close />
        </IconButton>
      </Box>

      {/* Verse Text */}
      <Paper sx={{ p: 2, mb: 2, bgcolor: 'grey.100' }} elevation={0}>
        <Typography variant="body2" sx={{ mb: 1, fontStyle: 'italic' }}>
          {verse.text}
        </Typography>
      </Paper>

      {/* Bookmark Button */}
      <Box sx={{ mb: 2 }}>
        <Button
          startIcon={isBookmarked ? <Bookmark /> : <BookmarkBorder />}
          onClick={onToggleBookmark}
          variant={isBookmarked ? 'contained' : 'outlined'}
          size="small"
          fullWidth={isMobile}
        >
          {isBookmarked ? 'Bookmarked' : 'Bookmark'}
        </Button>
      </Box>

      {/* Tabs */}
      <Tabs
        value={tabValue}
        onChange={(_, newValue) => setTabValue(newValue)}
        variant={isMobile ? 'fullWidth' : 'standard'}
        sx={{ borderBottom: 1, borderColor: 'divider' }}
      >
        <Tab label="Notes" />
        <Tab label="Highlights" />
        <Tab label="References" />
      </Tabs>

      {/* Tab Content */}
      <Box sx={{ pt: 2 }}>
        {tabValue === 0 && (
          <Box>
            <TextField
              fullWidth
              multiline
              rows={3}
              placeholder="Add a note..."
              value={noteText}
              onChange={(e) => setNoteText(e.target.value)}
              size="small"
              sx={{ mb: 1 }}
            />
            <Button
              variant="contained"
              size="small"
              onClick={handleAddNote}
              disabled={!noteText.trim()}
            >
              Save Note
            </Button>
          </Box>
        )}

        {tabValue === 1 && (
          <Typography variant="body2" color="text.secondary">
            Highlight colors coming soon
          </Typography>
        )}

        {tabValue === 2 && (
          <Typography variant="body2" color="text.secondary">
            Cross-references coming soon
          </Typography>
        )}
      </Box>
    </Box>
  )

  if (isMobile) {
    return (
      <Box
        sx={{
          position: 'fixed',
          bottom: 0,
          left: 0,
          right: 0,
          zIndex: 100,
          maxHeight: '70vh',
          backgroundColor: 'white',
          borderTopLeftRadius: 16,
          borderTopRightRadius: 16,
          boxShadow: '0 -4px 20px rgba(0,0,0,0.1)',
          overflow: 'auto',
        }}
      >
        {PanelContent}
      </Box>
    )
  }

  return (
    <Paper
      sx={{
        position: 'fixed',
        right: 0,
        top: 0,
        bottom: 0,
        width: 350,
        zIndex: 100,
        borderRadius: 0,
        boxShadow: '-4px 0 20px rgba(0,0,0,0.1)',
        overflow: 'auto',
        backgroundColor: 'white',
      }}
    >
      {PanelContent}
    </Paper>
  )
}

Step 2: Test panel rendering

Create __tests__/components/verse-details-panel.test.tsx:

import { render, screen } from '@testing-library/react'
import { VersDetailsPanel } from '@/components/bible/verse-details-panel'

const mockVerse = {
  id: 'v1',
  verseNum: 1,
  text: 'In the beginning...',
  bookId: 1,
  chapter: 1
}

describe('VersDetailsPanel', () => {
  it('renders when open with verse data', () => {
    render(
      <VersDetailsPanel
        verse={mockVerse}
        isOpen={true}
        onClose={() => {}}
        isBookmarked={false}
        onToggleBookmark={() => {}}
        onAddNote={() => {}}
      />
    )
    expect(screen.getByText(/In the beginning/)).toBeInTheDocument()
  })

  it('does not render when closed', () => {
    const { container } = render(
      <VersDetailsPanel
        verse={mockVerse}
        isOpen={false}
        onClose={() => {}}
        isBookmarked={false}
        onToggleBookmark={() => {}}
        onAddNote={() => {}}
      />
    )
    expect(container.firstChild).toBeNull()
  })
})

Step 3: Run tests

npm test -- __tests__/components/verse-details-panel.test.tsx
# Expected: PASS

Step 4: Commit

Create components/bible/verse-details-panel.tsx:

git add components/bible/verse-details-panel.tsx __tests__/components/verse-details-panel.test.tsx
git commit -m "feat: implement VersDetailsPanel with notes, bookmarks, and tabs"

Task 5: Integrate into Main BibleReaderApp

Files:

  • Create: components/bible/bible-reader-app.tsx (integrates all components)
  • Modify: app/[locale]/bible/page.tsx (use new reader)

Step 1: Create main BibleReaderApp

Create components/bible/bible-reader-app.tsx:

'use client'
import { useState, useEffect } from 'react'
import { Box } from '@mui/material'
import { BibleChapter, BibleVerse } from '@/types'
import { getCachedChapter, cacheChapter } from '@/lib/cache-manager'
import { SearchNavigator } from './search-navigator'
import { ReadingView } from './reading-view'
import { VersDetailsPanel } from './verse-details-panel'
import { ReadingSettings } from './reading-settings'

export function BibleReaderApp() {
  const [bookId, setBookId] = useState(1) // Genesis
  const [chapter, setChapter] = useState(1)
  const [currentChapter, setCurrentChapter] = useState<BibleChapter | null>(null)
  const [selectedVerse, setSelectedVerse] = useState<BibleVerse | null>(null)
  const [detailsPanelOpen, setDetailsPanelOpen] = useState(false)
  const [settingsOpen, setSettingsOpen] = useState(false)
  const [loading, setLoading] = useState(false)
  const [bookmarks, setBookmarks] = useState<Set<string>>(new Set())

  // Load chapter on navigation
  useEffect(() => {
    loadChapter(bookId, chapter)
  }, [bookId, chapter])

  async function loadChapter(bookId: number, chapterNum: number) {
    setLoading(true)
    try {
      // Try cache first
      const chapterId = `${bookId}-${chapterNum}`
      let data = await getCachedChapter(chapterId)

      // If not cached, fetch from API
      if (!data) {
        const response = await fetch(`/api/bible/chapter?book=${bookId}&chapter=${chapterNum}`)
        if (response.ok) {
          const json = await response.json()
          data = json.chapter

          // Cache it
          if (data) {
            data.id = chapterId
            await cacheChapter(data)
          }
        }
      }

      setCurrentChapter(data)
    } catch (error) {
      console.error('Error loading chapter:', error)
    } finally {
      setLoading(false)
    }
  }

  const handleVerseClick = (verseId: string) => {
    const verse = currentChapter?.verses.find(v => v.id === verseId)
    if (verse) {
      setSelectedVerse(verse)
      setDetailsPanelOpen(true)
    }
  }

  const handleToggleBookmark = () => {
    if (!selectedVerse) return
    const newBookmarks = new Set(bookmarks)
    if (newBookmarks.has(selectedVerse.id)) {
      newBookmarks.delete(selectedVerse.id)
    } else {
      newBookmarks.add(selectedVerse.id)
    }
    setBookmarks(newBookmarks)
    // TODO: Sync to backend
  }

  const handleAddNote = (note: string) => {
    if (!selectedVerse) return
    // TODO: Save note to backend
    console.log(`Note for verse ${selectedVerse.id}:`, note)
  }

  if (!currentChapter) {
    return <Box sx={{ p: 4, textAlign: 'center' }}>Loading Bible reader...</Box>
  }

  return (
    <Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
      {/* Header with search */}
      <Box sx={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50, p: 2, bgcolor: 'background.paper', boxShadow: 1 }}>
        <SearchNavigator onNavigate={(bookId, chapter) => {
          setBookId(bookId)
          setChapter(chapter)
        }} />
      </Box>

      {/* Reading area */}
      <Box sx={{ flex: 1, mt: 8, overflow: 'auto' }}>
        <ReadingView
          chapter={currentChapter}
          loading={loading}
          onPrevChapter={() => chapter > 1 && setChapter(chapter - 1)}
          onNextChapter={() => setChapter(chapter + 1)}
          onVerseClick={handleVerseClick}
          onSettingsOpen={() => setSettingsOpen(true)}
          hasPrevChapter={chapter > 1}
          hasNextChapter={true} // TODO: Check actual chapter count
        />
      </Box>

      {/* Details panel */}
      <VersDetailsPanel
        verse={selectedVerse}
        isOpen={detailsPanelOpen}
        onClose={() => setDetailsPanelOpen(false)}
        isBookmarked={selectedVerse ? bookmarks.has(selectedVerse.id) : false}
        onToggleBookmark={handleToggleBookmark}
        onAddNote={handleAddNote}
      />

      {/* Settings panel */}
      {settingsOpen && (
        <ReadingSettings onClose={() => setSettingsOpen(false)} />
      )}
    </Box>
  )
}

Step 2: Create ReadingSettings component

Create components/bible/reading-settings.tsx:

'use client'
import { useState } from 'react'
import { Box, Paper, Typography, Button, Slider, FormControl, InputLabel, Select, MenuItem, useMediaQuery, useTheme } from '@mui/material'
import { Close } from '@mui/icons-material'
import { ReadingPreference } from '@/types'
import { getPreset, loadPreferences, savePreferences } from '@/lib/reading-preferences'

const FONTS = [
  { value: 'georgia', label: 'Georgia (Serif)' },
  { value: 'merriweather', label: 'Merriweather (Serif)' },
  { value: 'inter', label: 'Inter (Sans)' },
  { value: 'atkinson', label: 'Atkinson (Dyslexia-friendly)' },
]

interface ReadingSettingsProps {
  onClose: () => void
}

export function ReadingSettings({ onClose }: ReadingSettingsProps) {
  const theme = useTheme()
  const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
  const [preferences, setPreferences] = useState(loadPreferences())

  const applyPreset = (presetName: string) => {
    const preset = getPreset(presetName as any)
    setPreferences(preset)
    savePreferences(preset)
  }

  const handleChange = (key: keyof ReadingPreference, value: any) => {
    const updated = { ...preferences, [key]: value }
    setPreferences(updated)
    savePreferences(updated)
  }

  const content = (
    <Box sx={{ p: 3, maxWidth: 400 }}>
      <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
        <Typography variant="h6">Reading Settings</Typography>
        <Button size="small" onClick={onClose} startIcon={<Close />} />
      </Box>

      {/* Presets */}
      <Box sx={{ mb: 3 }}>
        <Typography variant="subtitle2" sx={{ mb: 1 }}>Presets</Typography>
        {['default', 'dyslexia', 'highContrast', 'minimal'].map((preset) => (
          <Button
            key={preset}
            fullWidth={isMobile}
            variant={preferences.preset === preset ? 'contained' : 'outlined'}
            onClick={() => applyPreset(preset)}
            sx={{ mr: isMobile ? 0 : 1, mb: 1 }}
          >
            {preset.charAt(0).toUpperCase() + preset.slice(1)}
          </Button>
        ))}
      </Box>

      {/* Font */}
      <FormControl fullWidth sx={{ mb: 2 }}>
        <InputLabel>Font</InputLabel>
        <Select
          value={preferences.fontFamily}
          label="Font"
          onChange={(e) => handleChange('fontFamily', e.target.value)}
        >
          {FONTS.map((font) => (
            <MenuItem key={font.value} value={font.value}>
              {font.label}
            </MenuItem>
          ))}
        </Select>
      </FormControl>

      {/* Font Size */}
      <Box sx={{ mb: 2 }}>
        <Typography variant="body2">Size: {preferences.fontSize}px</Typography>
        <Slider
          value={preferences.fontSize}
          onChange={(_, value) => handleChange('fontSize', value)}
          min={12}
          max={32}
          step={1}
          marks
        />
      </Box>

      {/* Line Height */}
      <Box sx={{ mb: 2 }}>
        <Typography variant="body2">Line Height: {preferences.lineHeight.toFixed(1)}x</Typography>
        <Slider
          value={preferences.lineHeight}
          onChange={(_, value) => handleChange('lineHeight', value)}
          min={1.4}
          max={2.2}
          step={0.1}
        />
      </Box>

      {/* Background Color */}
      <FormControl fullWidth sx={{ mb: 2 }}>
        <InputLabel>Background</InputLabel>
        <Select
          value={preferences.backgroundColor}
          label="Background"
          onChange={(e) => handleChange('backgroundColor', e.target.value)}
        >
          <MenuItem value="#faf8f3">Warm</MenuItem>
          <MenuItem value="#ffffff">White</MenuItem>
          <MenuItem value="#f5f5f5">Light Gray</MenuItem>
          <MenuItem value="#1a1a1a">Dark</MenuItem>
        </Select>
      </FormControl>
    </Box>
  )

  if (isMobile) {
    return (
      <Box
        sx={{
          position: 'fixed',
          bottom: 0,
          left: 0,
          right: 0,
          maxHeight: '80vh',
          backgroundColor: 'white',
          borderTopLeftRadius: 16,
          borderTopRightRadius: 16,
          zIndex: 100,
          overflow: 'auto',
        }}
      >
        {content}
      </Box>
    )
  }

  return (
    <Paper
      sx={{
        position: 'fixed',
        right: 0,
        top: 0,
        bottom: 0,
        width: 400,
        zIndex: 100,
        borderRadius: 0,
        overflow: 'auto',
      }}
    >
      {content}
    </Paper>
  )
}

Step 3: Update Bible page to use new reader

Modify app/[locale]/bible/page.tsx:

import { BibleReaderApp } from '@/components/bible/bible-reader-app'

export const metadata = {
  title: 'Read Bible',
  description: 'Modern Bible reader with offline support'
}

export default function BiblePage() {
  return <BibleReaderApp />
}

Step 4: Commit

git add components/bible/bible-reader-app.tsx components/bible/reading-settings.tsx app/[locale]/bible/page.tsx
git commit -m "feat: integrate all Bible reader 2025 components into main app"

Phase 2: Annotations & Sync (Next)

Tasks for Phase 2 (after Phase 1 validation):

  • Task 6: Implement notes persistence (localStorage + backend)
  • Task 7: Implement highlights system with color selection
  • Task 8: Implement cross-references display
  • Task 9: Implement offline/online detection service worker
  • Task 10: Implement auto-sync queue for annotations

Testing Checklist for Phase 1

  • Search works for book names, abbreviations, and chapter references
  • ReadingView renders correctly on desktop/tablet/mobile
  • Verse customization presets apply correctly
  • Font size slider adjusts text
  • Tap/click on verse opens details panel
  • Bookmark button toggles state
  • Note editor allows input (not yet persisted)
  • Offline cache stores chapters correctly
  • Navigation prev/next chapters works

Environment Setup

Before starting implementation:

# Install missing dependencies if needed
npm install atkinson-hyperlegible

# Build icons and validate setup
npm run build

# Start dev server
npm run dev

Summary

This plan implements the Phase 1 MVP of the 2025 Bible Reader with: Search-first navigation (touch-optimized) Responsive reading layout Smart customization presets Verse details panel (layered) Offline caching Full test coverage for each component

Estimated time: 8-12 hours for experienced engineer.

Total commits: 5 (infrastructure, search, reading-view, details-panel, integration)