Skip to content

Instantly share code, notes, and snippets.

@DolphinDream
Created March 22, 2026 03:12
Show Gist options
  • Select an option

  • Save DolphinDream/1b0a70b30782bc929673624ae8ea8a16 to your computer and use it in GitHub Desktop.

Select an option

Save DolphinDream/1b0a70b30782bc929673624ae8ea8a16 to your computer and use it in GitHub Desktop.
Book Browser App — Architecture Document

Book Browser Application — Architecture Document

Status: Architecture Complete, Ready for Implementation Created: 2026-03-21 Replaces: apps/cover-reviewer/ (to be retired)


Table of Contents

  1. Overview & Stack
  2. Directory Structure
  3. Backend Architecture
  4. Frontend Architecture
  5. Data Model
  6. API Design
  7. Data Flow
  8. Component Architecture
  9. UI Layout & Design
  10. Cover System
  11. Search System
  12. Keyboard Navigation
  13. Accessibility
  14. State Management
  15. AI Chat Integration Point
  16. Patterns Ported from Cover Reviewer
  17. Security
  18. Implementation Phases
  19. Open Decisions

1. Overview & Stack

What This Is

A personal book library browser — the primary interface for browsing, searching, and interacting with ~1135 books across 15+ categories. Replaces the 5400-line monolithic Cover Reviewer with a modern component-based architecture.

Why Fresh Build (Not Refactor)

The Cover Reviewer has:

  • Zero accessibility (no ARIA, no semantic HTML)
  • No responsive design (no @media queries)
  • DOM XSS via innerHTML without escaping
  • 5400-line single HTML file with interleaved CSS/JS/markup
  • Coupled to cover comparison workflow (not book browsing)

A fresh build inherits proven UX patterns (search, keyboard nav, cover chains, state persistence) while fixing all structural issues.

Stack

Layer Technology Rationale
Frontend framework Svelte 5 2-3KB runtime, single-file components, built-in reactivity, best DX for solo dev
Build tool Vite Instant HMR, proxy /api to Flask, Rollup-based production builds
CSS Tailwind CSS v4 + CSS custom properties Dark mode via class strategy, utility-first, custom theme tokens
Search Fuse.js Client-side fuzzy search, proven in cover reviewer (threshold 0.4)
Backend Flask (Python 3.13+) Keep existing backend language, fix security issues, split into routes/services
State Svelte stores + localStorage Built-in reactivity, no Redux/Pinia needed
Icons Lucide (via lucide-svelte) Consistent, lightweight icon set

Development & Production Modes

Development:
  Vite dev server (localhost:5173) → proxy /api → Flask (localhost:8901)
  HMR for instant frontend updates, Flask auto-reload for backend

Production:
  npm run build → frontend/dist/
  Flask serves dist/ as static files + /api routes on port 8901

2. Directory Structure

apps/book-browser/
│
├── start.sh                              # Launcher script
│                                         #   --dev: starts Vite + Flask concurrently
│                                         #   (default): builds frontend, starts Flask serving dist/
│
├── backend/
│   ├── __init__.py
│   │
│   ├── app.py                            # Flask application factory
│   │                                     #   - create_app() factory function
│   │                                     #   - CORS config (localhost:8901 only)
│   │                                     #   - Blueprint registration
│   │                                     #   - Global error handlers (404, 500)
│   │                                     #   - Static file serving for production (dist/)
│   │
│   ├── config.py                         # Backend configuration
│   │                                     #   - Port, host, debug flag
│   │                                     #   - Imports from scripts/config.py:
│   │                                     #     BOOK_LIBRARY_PATH, LIBRARY_METADATA_DIR,
│   │                                     #     COVERS_DIR, INVENTORY_FILE, OFFLINE_MODE
│   │                                     #   - Derives: METADATA_DIR, COVER_REVIEWS_FILE,
│   │                                     #     COVER_INVENTORY_FILE
│   │
│   ├── models/
│   │   ├── __init__.py
│   │   └── book.py                       # BookDTO dataclass
│   │                                     #   - All merged fields from inventory + metadata + covers
│   │                                     #   - to_dict() for JSON serialization
│   │                                     #   - to_summary() for list endpoint (fewer fields)
│   │                                     #   - from_inventory_entry() class method
│   │
│   ├── services/
│   │   ├── __init__.py
│   │   │
│   │   ├── inventory_service.py          # Core data layer
│   │   │                                 #   class InventoryService:
│   │   │                                 #     - _load_inventory() → dict
│   │   │                                 #     - _load_metadata(uuid) → dict
│   │   │                                 #     - _load_cover_inventory() → dict
│   │   │                                 #     - _load_cover_reviews() → dict
│   │   │                                 #     - _merge_book(inv_entry, metadata, covers, reviews) → BookDTO
│   │   │                                 #     - get_all_books() → list[BookDTO]  (cached, mtime-checked)
│   │   │                                 #     - get_book(uuid) → BookDTO | None
│   │   │                                 #     - refresh() → force reload
│   │   │                                 #     - _cache: list[BookDTO], _mtimes: dict of file→mtime
│   │   │
│   │   ├── category_service.py           # Category tree builder
│   │   │                                 #   class CategoryService:
│   │   │                                 #     - build_tree(books: list[BookDTO]) → list[CategoryNode]
│   │   │                                 #     - CategoryNode: {name, count, path, children[]}
│   │   │                                 #     - Derives from current_path first/second segments
│   │   │                                 #     - Sorts alphabetically, includes book counts
│   │   │
│   │   ├── cover_service.py              # Cover URL resolution
│   │   │                                 #   class CoverService:
│   │   │                                 #     - resolve_best_cover(book_uuid, cover_data, review) → str|None
│   │   │                                 #     - resolve_all_covers(book_uuid, cover_data) → CoverURLs
│   │   │                                 #     - validate_cover_path(path) → bool (traversal check)
│   │   │                                 #     - CoverURLs: {extracted, google, expanded_extracted,
│   │   │                                 #       expanded_google, ps_extracted, ps_google, best}
│   │   │                                 #     - Fallback chain: review_selection → ps → expanded → google → extracted
│   │   │
│   │   └── enrichment_service.py         # LLM enrichment job management
│   │                                     #   class EnrichmentService:
│   │                                     #     - enqueue(uuid) → job_id
│   │                                     #     - get_status(job_id) → {status, progress, error, data}
│   │                                     #     - _worker() → background thread processing queue
│   │                                     #     - _jobs: dict[job_id → JobState]
│   │                                     #     - _queue: queue.Queue
│   │                                     #     - Calls enrich_book_complete.py as subprocess
│   │
│   ├── routes/
│   │   ├── __init__.py                   # register_blueprints(app) function
│   │   │
│   │   ├── books.py                      # Blueprint: books_bp, prefix=/api
│   │   │                                 #   GET  /api/books          → all books (filterable, sortable)
│   │   │                                 #   GET  /api/books/<uuid>   → single book full detail
│   │   │
│   │   ├── categories.py                 # Blueprint: categories_bp, prefix=/api
│   │   │                                 #   GET  /api/categories     → category tree with counts
│   │   │
│   │   ├── covers.py                     # Blueprint: covers_bp, prefix=/api
│   │   │                                 #   GET  /api/covers/<path>  → serve cover image file
│   │   │                                 #                              (path-validated, cached 24h)
│   │   │
│   │   ├── actions.py                    # Blueprint: actions_bp, prefix=/api
│   │   │                                 #   POST /api/actions/open-pdf    → open book PDF in Preview
│   │   │                                 #   POST /api/actions/open-finder → reveal book in Finder
│   │   │
│   │   ├── enrichment.py                 # Blueprint: enrichment_bp, prefix=/api
│   │   │                                 #   POST /api/enrich              → queue LLM enrichment
│   │   │                                 #   GET  /api/enrich/status/<id>  → poll enrichment progress
│   │   │
│   │   └── health.py                     # Blueprint: health_bp, prefix=/api
│   │                                     #   GET  /api/health    → health check + offline status
│   │                                     #   GET  /api/stats     → library statistics
│   │
│   └── middleware/
│       ├── __init__.py
│       └── security.py                   # Security utilities
│                                         #   - validate_path(path, allowed_root) → resolved_path
│                                         #     Resolves symlinks, checks prefix within allowed_root
│                                         #   - validate_uuid(uuid_str) → bool
│                                         #     Regex: ^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$
│                                         #     Also accepts 8-char prefix: ^[0-9a-f]{8}$
│                                         #   - require_json(f) → decorator
│                                         #     Rejects POST without Content-Type: application/json
│                                         #   - rate_limit setup (Flask-Limiter)
│
├── frontend/
│   ├── index.html                        # Vite entry point, mounts #app
│   ├── package.json                      # Dependencies: svelte, vite, tailwindcss, fuse.js, lucide-svelte
│   ├── vite.config.js                    # Svelte plugin, proxy /api → Flask 8901
│   ├── svelte.config.js                  # Svelte 5 config
│   ├── tailwind.config.js                # Dark mode 'class', custom surface/accent colors
│   ├── postcss.config.js                 # Tailwind + autoprefixer
│   │
│   ├── src/
│   │   ├── main.js                       # Mount App.svelte to #app
│   │   ├── App.svelte                    # Root layout component (see Component Architecture)
│   │   ├── app.css                       # Global styles + CSS custom properties (theme tokens)
│   │   │
│   │   ├── lib/
│   │   │   │
│   │   │   ├── stores/
│   │   │   │   ├── books.js              # allBooks (writable), isLoading, lastUpdated
│   │   │   │   │                         # filteredBooks (derived from allBooks + filters + search + sort)
│   │   │   │   │                         # fetchBooks() → loads from /api/books
│   │   │   │   │
│   │   │   │   ├── categories.js         # categoryTree (writable), activeCategory (writable)
│   │   │   │   │                         # fetchCategories() → loads from /api/categories
│   │   │   │   │
│   │   │   │   ├── filters.js            # activeFilters: { difficulty, enriched, pdfType,
│   │   │   │   │                         #   subjects[], yearRange }
│   │   │   │   │                         # addFilter(type, value), removeFilter(type, value)
│   │   │   │   │                         # clearFilters(), hasActiveFilters (derived)
│   │   │   │   │
│   │   │   │   ├── search.js             # searchQuery, searchResults, searchOpen
│   │   │   │   │                         # performSearch(query) → updates searchResults
│   │   │   │   │
│   │   │   │   ├── selection.js          # selectedBook (BookDTO|null), detailOpen (bool)
│   │   │   │   │                         # focusedIndex (int), selectBook(book), closeDetail()
│   │   │   │   │
│   │   │   │   ├── ui.js                 # viewMode ('grid'|'list'), sidebarOpen (bool)
│   │   │   │   │                         # settingsOpen (bool)
│   │   │   │   │
│   │   │   │   ├── settings.js           # Persisted to localStorage key 'book-browser-settings'
│   │   │   │   │                         # { gridSize, defaultSort, defaultOrder,
│   │   │   │   │                         #   showSubtitles, showAuthor, sidebarCollapsed,
│   │   │   │   │                         #   lastCategory, lastViewMode }
│   │   │   │   │                         # Auto-save on change via $effect or subscribe
│   │   │   │   │
│   │   │   │   └── notifications.js      # toasts: { id, message, type, duration }[]
│   │   │   │                             # addToast(message, type, duration), removeToast(id)
│   │   │   │
│   │   │   ├── api/
│   │   │   │   ├── client.js             # Base fetch wrapper
│   │   │   │   │                         #   - apiGet(path, params?) → JSON
│   │   │   │   │                         #   - apiPost(path, body) → JSON
│   │   │   │   │                         #   - Handles errors, returns { data, meta } or throws
│   │   │   │   │                         #   - Base URL: '' (same origin in prod, proxied in dev)
│   │   │   │   │
│   │   │   │   ├── books.js              # fetchAllBooks(params?) → BookDTO[]
│   │   │   │   │                         # fetchBook(uuid) → BookDTO
│   │   │   │   │
│   │   │   │   ├── categories.js         # fetchCategories() → CategoryNode[]
│   │   │   │   │
│   │   │   │   └── actions.js            # openPdf(uuid) → {status}
│   │   │   │                             # openFinder(uuid) → {status}
│   │   │   │                             # enrichBook(uuid) → {job_id}
│   │   │   │                             # pollEnrichStatus(jobId) → {status, progress, data}
│   │   │   │
│   │   │   ├── search/
│   │   │   │   ├── engine.js             # Fuse.js wrapper
│   │   │   │   │                         #   - buildIndex(books: BookDTO[]) → void
│   │   │   │   │                         #   - search(query: string) → SearchResult[]
│   │   │   │   │                         #   - FUSE_OPTIONS: threshold 0.4, weighted keys
│   │   │   │   │                         #   - fuseIndex: Fuse instance (module-level singleton)
│   │   │   │   │                         #   - Rebuilt when allBooks store changes
│   │   │   │   │
│   │   │   │   └── prefixes.js           # parseQuery(rawQuery) → ParsedQuery
│   │   │   │                             #   ParsedQuery: { type: 'fuzzy'|'field'|'exact',
│   │   │   │                             #     value, field?, exact? }
│   │   │   │                             #   Prefixes: title: t: author: a: year: y:
│   │   │   │                             #     category: cat: publisher: pub: domain: d:
│   │   │   │                             #     subject: s: diff: type:
│   │   │   │                             #   Quoted strings → exact phrase match
│   │   │   │
│   │   │   ├── keyboard/
│   │   │   │   └── shortcuts.js          # Keyboard shortcut registry
│   │   │   │                             #   - registerShortcut(key, handler, options)
│   │   │   │                             #   - handleKeydown(event) → global handler
│   │   │   │                             #   - buildKeyString(event) → normalized key name
│   │   │   │                             #   - SHORTCUTS: Map<string, {handler, context}>
│   │   │   │                             #   - Skips when focus is in input/textarea
│   │   │   │                             #     (except Escape which always fires)
│   │   │   │
│   │   │   ├── covers/
│   │   │   │   ├── resolver.js           # resolveCoverUrl(book) → string|null
│   │   │   │   │                         #   Uses book.covers.best (pre-resolved by backend)
│   │   │   │   │                         #   Client-side fallback chain if needed
│   │   │   │   │
│   │   │   │   └── preloader.js          # preloadCovers(books, currentIndex) → void
│   │   │   │                             #   ±1 high priority (fetchPriority='high')
│   │   │   │                             #   ±5 background (fetchPriority='low')
│   │   │   │                             #   Deduplicates via preloadCache Set
│   │   │   │
│   │   │   └── utils/
│   │   │       ├── format.js             # formatFileSize(bytes) → "14.8 GB"
│   │   │       │                         # formatDate(isoString) → "Mar 21, 2026"
│   │   │       │                         # truncate(str, maxLen) → "Foo bar..."
│   │   │       │
│   │   │       ├── persistence.js        # loadSettings() → settings object (with defaults)
│   │   │       │                         # saveSettings(settings) → void
│   │   │       │                         # SETTINGS_KEY = 'book-browser-settings'
│   │   │       │                         # SETTINGS_VERSION = 1 (schema version for migration)
│   │   │       │
│   │   │       └── accessibility.js      # trapFocus(containerEl) → cleanup function
│   │   │                                 # announceToScreenReader(message, priority?)
│   │   │                                 #   Creates/updates aria-live region
│   │   │                                 # getAriaLabel(book) → string
│   │   │
│   │   ├── components/
│   │   │   │
│   │   │   ├── layout/
│   │   │   │   ├── Sidebar.svelte        # Category tree navigation (see §8)
│   │   │   │   ├── TopBar.svelte         # Header bar with search, sort, view toggle
│   │   │   │   ├── MainContent.svelte    # Grid/list container, filter bar
│   │   │   │   └── StatusBar.svelte      # Bottom bar: counts, online/offline, loading
│   │   │   │
│   │   │   ├── books/
│   │   │   │   ├── BookGrid.svelte       # CSS grid of BookCard components
│   │   │   │   ├── BookList.svelte       # Table/list view with sortable columns
│   │   │   │   ├── BookCard.svelte       # Cover + title + author card
│   │   │   │   ├── BookListRow.svelte    # Single row in list view
│   │   │   │   └── BookDetail.svelte     # Slide-out detail panel
│   │   │   │
│   │   │   ├── search/
│   │   │   │   ├── SearchPalette.svelte  # Cmd+K command palette overlay
│   │   │   │   ├── SearchInput.svelte    # Input with prefix autocomplete hints
│   │   │   │   └── SearchResults.svelte  # Result list with match highlighting
│   │   │   │
│   │   │   ├── filters/
│   │   │   │   ├── FilterBar.svelte      # Active filter chips row
│   │   │   │   ├── FilterChip.svelte     # Individual removable filter chip
│   │   │   │   ├── SortDropdown.svelte   # Sort field + direction
│   │   │   │   └── FilterPanel.svelte    # Expandable filter controls
│   │   │   │
│   │   │   ├── covers/
│   │   │   │   ├── CoverImage.svelte     # Cover with fallback chain + skeleton loading
│   │   │   │   └── CoverPlaceholder.svelte  # Placeholder for missing covers
│   │   │   │
│   │   │   ├── detail/
│   │   │   │   ├── MetadataSection.svelte    # Grouped metadata fields
│   │   │   │   ├── SubjectTags.svelte        # Clickable subject/domain tags
│   │   │   │   ├── DifficultyBadge.svelte    # Color-coded difficulty indicator
│   │   │   │   ├── CategoryBreadcrumb.svelte # Category > Subcategory path
│   │   │   │   ├── BookActions.svelte        # Open PDF, Finder, Re-enrich buttons
│   │   │   │   └── RelatedBooks.svelte       # Related books list (from LLM data)
│   │   │   │
│   │   │   ├── ui/
│   │   │   │   ├── Toast.svelte              # Single toast notification
│   │   │   │   ├── ToastContainer.svelte     # Toast stack (fixed bottom-right)
│   │   │   │   ├── SettingsDrawer.svelte     # Settings slide-in from right
│   │   │   │   ├── LoadingSpinner.svelte     # Spinner for async operations
│   │   │   │   ├── EmptyState.svelte         # "No books found" with suggestions
│   │   │   │   └── KeyboardHint.svelte       # Shortcut help overlay (?-key)
│   │   │   │
│   │   │   └── chat/
│   │   │       └── ChatSlot.svelte           # v2 placeholder for AI chat module
│   │   │
│   │   └── actions/                          # Svelte use:directive actions
│   │       ├── clickOutside.js               # use:clickOutside={handler}
│   │       ├── focusTrap.js                  # use:focusTrap (traps Tab within element)
│   │       └── intersection.js               # use:intersection={handler} (lazy loading)
│   │
│   └── public/
│       └── favicon.svg
│
└── tests/
    ├── backend/
    │   ├── conftest.py                       # Flask test client fixture
    │   ├── test_books_route.py               # Books endpoint tests
    │   ├── test_cover_service.py             # Cover fallback chain tests
    │   └── test_security.py                  # Path traversal, UUID validation tests
    └── frontend/
        └── (placeholder for Svelte component tests)

3. Backend Architecture

3.1 Application Factory (app.py)

# Simplified structure
def create_app(config=None):
    app = Flask(__name__, static_folder=None)

    # Load config
    app.config.from_object(backend_config)
    if config:
        app.config.update(config)

    # CORS — restricted to local only
    CORS(app, origins=[
        'http://localhost:8901',
        'http://127.0.0.1:8901',
        'http://localhost:5173',   # Vite dev server
    ])

    # Initialize services (singletons on app)
    app.inventory_service = InventoryService()
    app.category_service = CategoryService()
    app.cover_service = CoverService()
    app.enrichment_service = EnrichmentService()

    # Register blueprints
    register_blueprints(app)

    # Production: serve frontend dist/
    if not app.config.get('DEV_MODE'):
        app.static_folder = str(Path(__file__).parent.parent / 'frontend' / 'dist')
        @app.route('/')
        @app.route('/<path:path>')
        def serve_frontend(path=''):
            ...  # serve index.html for SPA routing

    # Global error handlers
    @app.errorhandler(404)
    def not_found(e): return jsonify(error={'code': 'NOT_FOUND', 'message': str(e)}), 404

    @app.errorhandler(500)
    def internal(e): return jsonify(error={'code': 'INTERNAL', 'message': 'Internal error'}), 500

    return app

3.2 Service Layer — Class Diagram

┌─────────────────────────────────────────────┐
│              InventoryService               │
│─────────────────────────────────────────────│
│ - _cache: list[BookDTO]                     │
│ - _mtimes: dict[str, float]                 │
│ - _inventory_file: Path                     │
│ - _metadata_dir: Path                       │
│ - _cover_inventory_file: Path               │
│ - _cover_reviews_file: Path                 │
│─────────────────────────────────────────────│
│ + get_all_books() → list[BookDTO]           │
│ + get_book(uuid: str) → BookDTO | None      │
│ + refresh() → void                          │
│ + get_stats() → dict                        │
│ - _needs_refresh() → bool                   │
│ - _load_and_merge() → list[BookDTO]         │
│ - _load_inventory() → dict                  │
│ - _load_metadata(uuid: str) → dict | None   │
│ - _load_cover_inventory() → dict            │
│ - _load_cover_reviews() → dict              │
│ - _merge_book(inv, meta, covers, rev) → DTO │
│ - _parse_path(current_path) → (cat, sub,    │
│     title, author, ext)                     │
└────────────────────┬────────────────────────┘
                     │ depends on
         ┌───────────┴───────────┐
         ▼                       ▼
┌─────────────────────┐  ┌──────────────────────┐
│   CategoryService   │  │    CoverService      │
│─────────────────────│  │──────────────────────│
│ + build_tree(       │  │ + resolve_best(uuid, │
│     books) →        │  │     cover_data,      │
│     CategoryNode[]  │  │     review) → url    │
│ + get_flat_list(    │  │ + resolve_all(uuid,  │
│     books) →        │  │     cover_data)      │
│     str[]           │  │     → CoverURLs      │
│                     │  │ + validate_path(     │
│ CategoryNode:       │  │     path) → bool     │
│   name: str         │  │                      │
│   path: str         │  │ CoverURLs:           │
│   count: int        │  │   extracted: str?    │
│   children: Node[]  │  │   google: str?       │
│                     │  │   expanded_ext: str?  │
└─────────────────────┘  │   expanded_goo: str?  │
                         │   ps_extracted: str?   │
                         │   ps_google: str?      │
                         │   best: str?           │
                         └──────────────────────┘

┌───────────────────────────────────────┐
│          EnrichmentService            │
│───────────────────────────────────────│
│ - _jobs: dict[str, JobState]          │
│ - _queue: queue.Queue                 │
│ - _worker_thread: Thread              │
│───────────────────────────────────────│
│ + enqueue(uuid: str) → str (job_id)   │
│ + get_status(job_id) → JobState       │
│ - _worker() → runs in daemon thread   │
│ - _run_enrichment(uuid) → dict        │
│                                       │
│ JobState:                             │
│   job_id: str                         │
│   uuid: str                           │
│   status: 'queued'|'processing'|      │
│           'complete'|'error'          │
│   progress: str                       │
│   error: str | None                   │
│   enriched_data: dict | None          │
│   started_at: datetime                │
└───────────────────────────────────────┘

3.3 Service Interactions

                       HTTP Request
                            │
                            ▼
                     ┌──────────────┐
                     │   Flask App  │
                     │  (routes/)   │
                     └──────┬───────┘
                            │
              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
     ┌────────────┐  ┌───────────┐  ┌──────────┐
     │   books.py │  │ covers.py │  │actions.py│
     └─────┬──────┘  └─────┬─────┘  └────┬─────┘
           │               │              │
           ▼               ▼              │
  ┌─────────────────┐ ┌──────────┐        │
  │InventoryService │ │CoverServ.│        │
  │  get_all_books()│ │validate_ │        │
  │  get_book(uuid) │ │  path()  │        │
  └────────┬────────┘ └──────────┘        │
           │                              │
           ├──────────────────────────────┤
           │  CategoryService             │
           │  CoverService                │
           │  (called during merge)       │
           │                              │
           ▼                              ▼
  ┌──────────────┐              ┌──────────────┐
  │ inventory.json│              │ subprocess   │
  │ metadata/*.json│             │ open -R path │
  │ cover_inv.json │             │ open path    │
  │ cover_rev.json │             └──────────────┘
  └──────────────┘

3.4 Inventory Merge Strategy

On startup and when source files change (detected by mtime check on each request):

Step 1: Load inventory.json
        → Get UUID, current_path, size_bytes, added_to_repo, watermarks,
          checksum_sha256, original_checksum

Step 2: For each book, parse current_path
        → Extract category (1st segment), subcategory (2nd segment if exists)
        → Extract title (before " by "), author (after " by ")
        → Extract file extension

Step 3: For each book, load metadata/{uuid_prefix}*.json (glob on 8-char UUID prefix)
        → Get subjects, domains, difficulty_level, description, publication_year,
          publisher, edition, isbn_10, isbn_13, best_existing_category,
          ideal_category, related_books, prerequisites, target_audience,
          book_type, pdf_type, pdf_type_detail, llm_enriched_complete,
          category_reasoning, extraction_confidence

Step 4: Load cover_inventory_analysis.json
        → Key: 8-char UUID prefix
        → Get extracted_file, google_file, expanded filenames, photoshop filenames

Step 5: Load cover_reviews.json
        → Key: full UUID
        → Get preferred: {source, version}

Step 6: For each book, call cover_service.resolve_all_covers()
        → Compute all cover URLs and determine "best" using fallback chain
        → Review selection takes priority #1

Step 7: Merge all into BookDTO, cache the list
        → Store mtimes of all 4 source files
        → On next request, compare mtimes → refresh if any changed

3.5 Route → Service Mapping

Route Service Methods Called
GET /api/books inventory_service.get_all_books() → filter → sort → paginate
GET /api/books/<uuid> inventory_service.get_book(uuid)
GET /api/categories category_service.build_tree(inventory_service.get_all_books())
GET /api/covers/<path> cover_service.validate_path(path)send_from_directory()
POST /api/actions/open-pdf inventory_service.get_book(uuid)subprocess.Popen(['open', path])
POST /api/actions/open-finder inventory_service.get_book(uuid)subprocess.Popen(['open', '-R', path])
POST /api/enrich enrichment_service.enqueue(uuid)
GET /api/enrich/status/<id> enrichment_service.get_status(id)
GET /api/health inventory_service.get_stats() + OFFLINE_MODE check
GET /api/stats inventory_service.get_stats() (category distribution, cover counts, etc.)

4. Frontend Architecture

4.1 Module Dependency Graph

main.js
  └── App.svelte
        ├── stores/*           ← all stores imported by components
        │     ├── books.js     ← depends on api/books.js, search/engine.js
        │     ├── categories.js ← depends on api/categories.js
        │     ├── filters.js
        │     ├── search.js    ← depends on search/engine.js, search/prefixes.js
        │     ├── selection.js
        │     ├── ui.js
        │     ├── settings.js  ← depends on utils/persistence.js
        │     └── notifications.js
        │
        ├── api/*              ← fetch wrappers
        │     ├── client.js    ← base fetch (apiGet, apiPost)
        │     ├── books.js     ← depends on client.js
        │     ├── categories.js ← depends on client.js
        │     └── actions.js   ← depends on client.js
        │
        ├── search/*           ← search logic
        │     ├── engine.js    ← Fuse.js, exports buildIndex/search
        │     └── prefixes.js  ← exports parseQuery
        │
        ├── keyboard/*
        │     └── shortcuts.js ← exports registerShortcut, handleKeydown
        │
        ├── covers/*
        │     ├── resolver.js  ← exports resolveCoverUrl
        │     └── preloader.js ← exports preloadCovers
        │
        ├── utils/*
        │     ├── format.js    ← pure formatting functions
        │     ├── persistence.js ← localStorage read/write
        │     └── accessibility.js ← focus trap, screen reader announce
        │
        ├── actions/*          ← Svelte use: directives
        │     ├── clickOutside.js
        │     ├── focusTrap.js
        │     └── intersection.js
        │
        └── components/*       ← Svelte components (see §8)

4.2 Store Interactions

                   ┌─────────────┐
                   │ /api/books  │  (Flask endpoint)
                   └──────┬──────┘
                          │ fetch on mount + manual refresh
                          ▼
                   ┌─────────────┐
                   │  allBooks   │  (writable store)
                   └──────┬──────┘
                          │ rebuilds Fuse.js index
                          │
          ┌───────────────┼───────────────────┐
          ▼               ▼                   ▼
   ┌──────────┐   ┌──────────────┐   ┌───────────────┐
   │activeCategory│ │ activeFilters│  │ searchResults │
   │(writable)    │ │ (writable)   │  │ (writable)    │
   └──────┬───────┘ └──────┬───────┘  └───────┬───────┘
          │                │                   │
          └────────────────┼───────────────────┘
                           │ all inputs to derived store
                           ▼
                   ┌───────────────┐
                   │ filteredBooks │  (derived store)
                   │               │
                   │ Logic:        │
                   │ 1. Category   │
                   │ 2. Filters    │
                   │ 3. Search ∩   │
                   │ 4. Sort       │
                   └───────┬───────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │ BookGrid │ │ BookList │ │StatusBar │
        │(renders  │ │(renders  │ │(shows    │
        │ cards)   │ │ rows)    │ │ counts)  │
        └──────────┘ └──────────┘ └──────────┘

4.3 filteredBooks Derived Store Logic

export const filteredBooks = derived(
  [allBooks, activeCategory, activeFilters, searchResults, sortConfig],
  ([$books, $category, $filters, $search, $sort]) => {
    let result = $books;

    // 1. Category filter (path prefix match)
    if ($category) {
      result = result.filter(b =>
        b.categoryPath === $category ||
        b.categoryPath.startsWith($category + '/')
      );
    }

    // 2. Active filters
    if ($filters.difficulty)
      result = result.filter(b => b.difficulty_level === $filters.difficulty);
    if ($filters.enriched !== null)
      result = result.filter(b => b.llm_enriched === $filters.enriched);
    if ($filters.pdfType)
      result = result.filter(b => b.pdf_type === $filters.pdfType);
    if ($filters.subjects.length > 0)
      result = result.filter(b =>
        $filters.subjects.some(s => b.subjects?.includes(s))
      );
    if ($filters.yearRange)
      result = result.filter(b => {
        const y = parseInt(b.publication_year);
        return y >= $filters.yearRange.from && y <= $filters.yearRange.to;
      });

    // 3. Search intersection (if search is active)
    if ($search !== null) {
      const searchUUIDs = new Set($search.map(r => r.item?.uuid || r.uuid));
      result = result.filter(b => searchUUIDs.has(b.uuid));
    }

    // 4. Sort
    result = [...result].sort(comparator($sort.field, $sort.order));

    return result;
  }
);

5. Data Model

5.1 BookDTO (Backend → Frontend)

@dataclass
class BookDTO:
    # Identity
    uuid: str                           # Full UUID v5
    short_uuid: str                     # 8-char prefix (for cover inventory lookup)

    # Path-derived
    current_path: str                   # "Software/Graphics/Title by Author.pdf"
    category: str                       # "Software"
    subcategory: str | None             # "Graphics" or None
    category_path: str                  # "Software/Graphics" (full category path)
    title: str                          # "Title - Subtitle" (parsed from filename)
    author: str | None                  # "Author Name" (parsed from filename)
    file_extension: str                 # "pdf"

    # Inventory
    size_bytes: int
    added_to_repo: str | None           # ISO 8601 datetime
    checksum_sha256: str
    watermark_count: int                # Number of detected watermarks
    watermarks_cleaned: bool            # Whether all watermarks are cleaned

    # Enriched metadata (from per-book JSON, nullable when not enriched)
    publication_year: str | None
    publisher: str | None
    edition: str | None
    isbn_10: str | None
    isbn_13: str | None
    subjects: list[str]
    domains: list[str]
    difficulty_level: str | None        # "beginner" | "intermediate" | "advanced"
    description: str | None
    target_audience: str | None
    book_type: str | None
    best_existing_category: str | None
    ideal_category: str | None
    ideal_subcategory: str | None
    category_reasoning: str | None
    related_books: list[str]
    prerequisites: list[str]
    pdf_type: str | None                # "text" | "scanned" | "mixed"
    pdf_type_detail: str | None
    llm_enriched: bool                  # True if llm_enriched_complete
    page_count: int | None              # From pdf_metadata.pages

    # Covers (pre-resolved URLs)
    covers: CoverURLs

    def to_summary(self) -> dict:
        """For list endpoint — fewer fields, faster serialization."""
        return {
            'uuid': self.uuid,
            'title': self.title,
            'author': self.author,
            'category': self.category,
            'subcategory': self.subcategory,
            'category_path': self.category_path,
            'publication_year': self.publication_year,
            'difficulty_level': self.difficulty_level,
            'pdf_type': self.pdf_type,
            'llm_enriched': self.llm_enriched,
            'size_bytes': self.size_bytes,
            'added_to_repo': self.added_to_repo,
            'subjects': self.subjects[:5],  # Truncate for list view
            'covers': self.covers.to_dict(),
        }

    def to_dict(self) -> dict:
        """For detail endpoint — all fields."""
        return { ... }  # All fields

5.2 API Response Shapes

GET /api/books
{
  "data": [ BookDTO.to_summary(), ... ],
  "meta": {
    "total": 1135,
    "filtered": 42,
    "offline": false,
    "last_updated": "2026-03-18T21:08:35"
  }
}

GET /api/books/<uuid>
{
  "data": BookDTO.to_dict(),    // All fields including related_books, prerequisites,
                                // watermarks detail, category_reasoning, etc.
  "meta": { "offline": false }
}

GET /api/categories
{
  "data": [
    {
      "name": "Software",
      "path": "Software",
      "count": 518,
      "children": [
        { "name": "Artificial-Intelligence", "path": "Software/Artificial-Intelligence",
          "count": 80, "children": [] },
        ...
      ]
    },
    ...
  ]
}

GET /api/health
{
  "data": {
    "status": "ok",
    "offline": false,
    "library_path": "/Volumes/...",
    "book_count": 1135,
    "enriched_count": 890,
    "cover_count": 1050,
    "version": "0.3.0"
  }
}

Error responses (all endpoints):
{
  "error": {
    "code": "NOT_FOUND" | "VALIDATION" | "OFFLINE" | "INTERNAL",
    "message": "Human-readable error message"
  }
}

6. API Design

6.1 Endpoint Reference

Method Path Params / Body Response Rate Limit
GET /api/books ?category=Software&sort=title&order=asc&difficulty=advanced&enriched=true&pdf_type=text {data: BookDTO[], meta: {total, filtered, offline}} None
GET /api/books/:uuid UUID path param (full or 8-char prefix) {data: BookDTO, meta: {offline}} None
GET /api/categories None {data: CategoryNode[]} None
GET /api/covers/:path Path to cover file (relative to COVERS_DIR) Image file (JPEG) None
POST /api/actions/open-pdf {"uuid": "00101095-..."} {data: {status: "ok", title: "..."}} 30/min
POST /api/actions/open-finder {"uuid": "00101095-..."} {data: {status: "ok", title: "..."}} 30/min
POST /api/enrich {"uuid": "00101095-..."} {data: {job_id: "...", status: "queued"}} 5/min
GET /api/enrich/status/:id Job ID path param {data: {job_id, status, progress, error, enriched_data}} None
GET /api/health None {data: {status, offline, library_path, book_count, version}} None
GET /api/stats None {data: {total, enriched, covers, categories: {name: count}, ...}} None

6.2 Query Parameter Details for GET /api/books

Param Type Default Values
category string (none) Category path prefix, e.g., "Software", "Software/AI"
sort string "title" title, author, year, added, difficulty, category, size
order string "asc" asc, desc
difficulty string (none) beginner, intermediate, advanced
enriched boolean (none) true, false
pdf_type string (none) text, scanned, mixed

6.3 Cover Serving

GET /api/covers/extracted/00101095-Physically-Based-Rendering.jpg
GET /api/covers/google/00101095-Physically-Based-Rendering.jpg
GET /api/covers/expanded/extracted/00101095-Physically-Based-Rendering.jpg
GET /api/covers/extracted/photoshop/00101095-Physically-Based-Rendering.jpg

Response headers:
  Content-Type: image/jpeg
  Cache-Control: public, max-age=86400
  (ETags set automatically by send_from_directory)

7. Data Flow

7.1 Startup Sequence

1. start.sh
   ├── (--dev mode)
   │   ├── Start Flask: python backend/app.py --dev (port 8901)
   │   └── Start Vite: npm run dev (port 5173, proxy /api → 8901)
   │
   └── (production mode)
       ├── Build: cd frontend && npm run build → dist/
       └── Start Flask: python backend/app.py (port 8901, serves dist/)

2. Flask startup
   ├── create_app()
   ├── InventoryService.__init__()
   │   └── _load_and_merge()  → first load of all data
   │       ├── Load inventory.json
   │       ├── Load metadata/*.json (glob per UUID)
   │       ├── Load cover_inventory_analysis.json
   │       ├── Load cover_reviews.json
   │       ├── Merge into BookDTO list
   │       └── Cache + store mtimes
   └── EnrichmentService.__init__()
       └── Start daemon worker thread

3. Frontend mount (browser loads page)
   ├── main.js mounts App.svelte
   ├── App.svelte onMount:
   │   ├── fetchBooks() → GET /api/books → allBooks store
   │   ├── fetchCategories() → GET /api/categories → categoryTree store
   │   ├── loadSettings() → localStorage → settings store
   │   ├── Restore lastCategory → activeCategory store
   │   ├── Restore lastViewMode → viewMode store
   │   ├── buildIndex(allBooks) → Fuse.js ready
   │   └── Register keyboard shortcuts
   └── Components render reactively from stores

7.2 User Interaction Flows

Category Selection:

User clicks "Software" in Sidebar
  → activeCategory.set("Software")
  → filteredBooks re-derives (filters by category prefix)
  → BookGrid re-renders with filtered books
  → StatusBar updates count
  → settings.lastCategory updated → localStorage

Search:

User presses Cmd+K
  → searchOpen.set(true) → SearchPalette renders
  → Focus moves to SearchInput

User types "deep learning"
  → 150ms debounce
  → parseQuery("deep learning") → { type: 'fuzzy', value: 'deep learning' }
  → fuseIndex.search("deep learning") → results with scores
  → searchResults.set(results)
  → SearchResults renders with highlighted matches

User presses Enter on result
  → selectedBook.set(result.item)
  → detailOpen.set(true)
  → searchOpen.set(false)
  → BookDetail slides in from right

Open PDF:

User clicks "Open PDF" in BookDetail (or presses 'o')
  → apiPost('/api/actions/open-pdf', { uuid })
  → Flask: inventory_service.get_book(uuid) → get current_path
  → Flask: require_online() → check not offline
  → Flask: full_path = BOOK_LIBRARY_PATH / current_path
  → Flask: subprocess.Popen(['open', str(full_path)])
  → Response: { data: { status: 'ok' } }
  → addToast('Opened in Preview', 'success')

Enrichment:

User clicks "Re-enrich" in BookDetail
  → apiPost('/api/enrich', { uuid }) → { data: { job_id, status: 'queued' } }
  → BookDetail shows spinner + "Enriching..."
  → Poll: apiGet('/api/enrich/status/' + jobId) every 2s
  → Status: queued → processing → complete
  → On complete: refresh book data → update detail panel
  → addToast('Enrichment complete', 'success')

7.3 Inventory Refresh

Every 60 seconds (setInterval in App.svelte onMount):
  → apiGet('/api/health') → check last_updated
  → If changed from stored lastUpdated:
      → fetchBooks() → re-fetch full book list
      → buildIndex(newBooks) → rebuild Fuse.js
      → Update lastUpdated

Manual refresh (button in TopBar):
  → Same flow as above, triggered immediately

8. Component Architecture

8.1 Full Component Reference

App.svelte — Root Layout

┌──────────────────────────────────────────────────────────┐
│ <svelte:window on:keydown={handleKeydown} />             │
│                                                          │
│ <a class="skip-link" href="#main">Skip to content</a>   │
│                                                          │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TopBar                                               │ │
│ └──────────────────────────────────────────────────────┘ │
│                                                          │
│ ┌──────────┬───────────────────────────────────────────┐ │
│ │ Sidebar  │ MainContent                              │ │
│ │          │                                          │ │
│ │ 260px    │ flex: 1                                  │ │
│ │          │                                          │ │
│ └──────────┴───────────────────────────────────────────┘ │
│                                                          │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ StatusBar                                            │ │
│ └──────────────────────────────────────────────────────┘ │
│                                                          │
│ {#if $searchOpen} <SearchPalette /> {/if}                │
│ {#if $detailOpen} <BookDetail book={$selectedBook} /> {/if} │
│ {#if $settingsOpen} <SettingsDrawer /> {/if}             │
│ <ToastContainer />                                       │
│ {#if shortcutsVisible} <KeyboardHint /> {/if}            │
└──────────────────────────────────────────────────────────┘

Props: None (root component) Lifecycle:

  • onMount: fetch books, fetch categories, load settings, register shortcuts, start refresh interval
  • onDestroy: clear refresh interval

TopBar.svelte — Header

┌────────────────────────────────────────────────────────────┐
│ ┌──────┐  ┌────────────────────────────┐  [↻] [▤][▦] [⚙] │
│ │ Logo │  │ ⌘K Search books...         │                   │
│ └──────┘  └────────────────────────────┘                   │
└────────────────────────────────────────────────────────────┘
  Logo      Search trigger (opens palette)   Refresh  Grid/  Settings
            shows placeholder text           button   List
                                                      toggle

Props: None (reads from stores) Interactions:

  • Search pill: click or / key → searchOpen.set(true)
  • Refresh: click → fetchBooks() + fetchCategories()
  • View toggle: click → viewMode.update(m => m === 'grid' ? 'list' : 'grid')
  • Settings: click → settingsOpen.set(true)

Sidebar.svelte — Category Navigation

┌──────────────────────┐
│ LIBRARY              │
│ ──────────────────── │
│ ▸ All Books    (1135)│  ← Click: activeCategory.set(null)
│ ▾ Software      (518)│  ← Click: activeCategory.set("Software")
│   ├ AI           (80)│  ← Click: activeCategory.set("Software/Artificial-Intelligence")
│   ├ Graphics     (42)│
│   ├ Languages    (28)│
│   └ ...              │
│ ▸ Philosophy     (75)│
│ ▸ Science       (234)│
│ ▸ ...                │
│                      │
│ ──────────────────── │
│ Enriched: 890/1135   │  ← SidebarStats
│ With covers: 1050    │
└──────────────────────┘
Width: var(--sidebar-width) = 260px
Background: var(--bg-surface)

Props: None (reads $categoryTree, $activeCategory from stores) Internal state: expandedCategories: Set<string> — tracks which categories are collapsed/expanded Interactions:

  • Click category → activeCategory.set(path)
  • Click chevron → toggle expand/collapse
  • Active category: left border accent + bg-hover background
  • Counts: right-aligned, text-muted color

Recursive rendering:

{#each categories as cat}
  <CategoryNode node={cat} depth={0} />
{/each}

<!-- CategoryNode renders itself + children recursively -->

MainContent.svelte — Grid/List Container

┌──────────────────────────────────────────────────┐
│ FilterBar (if filters active)                    │
│ [Difficulty: Advanced ✕] [PDF: Text ✕] [Clear]   │
├──────────────────────────────────────────────────┤
│                                                  │
│ {#if $viewMode === 'grid'}                       │
│   <BookGrid books={$filteredBooks} />             │
│ {:else}                                          │
│   <BookList books={$filteredBooks} />             │
│ {/if}                                            │
│                                                  │
│ {#if $filteredBooks.length === 0}                 │
│   <EmptyState />                                 │
│ {/if}                                            │
│                                                  │
└──────────────────────────────────────────────────┘

Props: None (reads from stores) Announces: aria-live="polite" region: "Showing N of M books"


BookGrid.svelte — Cover Grid

┌──────────────────────────────────────────────────┐
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │          │ │          │ │          │ │      │ │
│ │  Cover   │ │  Cover   │ │  Cover   │ │ Cov  │ │
│ │          │ │          │ │          │ │      │ │
│ ├──────────┤ ├──────────┤ ├──────────┤ ├──────┤ │
│ │ Title    │ │ Title    │ │ Title    │ │Title │ │
│ │ Author   │ │ Author   │ │ Author   │ │Auth  │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────┘ │
│                                                  │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │   ...    │ │   ...    │ │   ...    │ │ ...  │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────┘ │
└──────────────────────────────────────────────────┘

CSS Grid: auto-fill, minmax(var(--card-width), 1fr)
--card-width: 200px (small), 240px (medium), 280px (large)
Gap: var(--space-lg) = 24px

Props: books: BookDTO[] ARIA: role="grid", aria-label="Books", aria-rowcount Focus: focusedIndex determines which card has tabindex="0" (others -1)


BookCard.svelte — Single Book Card

┌─────────────────────┐
│                     │
│     CoverImage      │  aspect-ratio: 0.7 (portrait)
│                     │  border-radius: var(--radius-md)
│                     │
├─────────────────────┤
│ Title (1 line)      │  font-weight: 500, truncate with ellipsis
│ Author (1 line)     │  color: var(--text-secondary), truncate
│ [Advanced] [Text]   │  optional badges (small, colored)
└─────────────────────┘

Hover: transform scale(1.02) on image, shadow grows, title brightens
Focus: 2px accent outline
Click: selectBook(book) → detailOpen.set(true)

Props: book: BookDTO, focused: boolean, index: number ARIA: role="gridcell", aria-selected, tabindex, aria-label="{title} by {author}" Interactions: Click → open detail, keyboard Enter/Space → open detail


BookList.svelte + BookListRow.svelte — Table View

┌────────────────────────────────────────────────────────────────────┐
│ [Cover] │ Title ▲          │ Author    │ Year │ Category │ Diff   │
├─────────┼──────────────────┼───────────┼──────┼──────────┼────────┤
│ [thumb] │ Deep Learning    │ Goodfellow│ 2016 │ Software │ ●●●    │
│ [thumb] │ Deep Work        │ Newport   │ 2016 │ Productiv│ ●      │
│ [thumb] │ Designing Data   │ Kleppmann │ 2017 │ Software │ ●●     │
└─────────┴──────────────────┴───────────┴──────┴──────────┴────────┘

Cover thumbnail: 40x56px (small CoverImage)
Column headers: clickable for sorting (toggles asc/desc)
Row click: selectBook(book) → detailOpen.set(true)
Row hover: bg-hover background

Props (BookList): books: BookDTO[] Props (BookListRow): book: BookDTO, selected: boolean ARIA: role="table", rows have role="row", cells have role="cell"


BookDetail.svelte — Slide-Out Detail Panel

┌────────────────────────────────────────────┐
│ [← Back]                    [Open] [⋮]    │
│                                           │
│ ┌───────────────────────────────────────┐ │
│ │                                       │ │
│ │            Large Cover                │ │
│ │            (max-height: 400px)        │ │
│ │                                       │ │
│ └───────────────────────────────────────┘ │
│                                           │
│ Title - Subtitle                          │
│ by Author Name                            │
│                                           │
│ Software > Graphics                       │ ← CategoryBreadcrumb
│                                           │
│ ─── Details ────────────────────────────  │
│ Year          2017                        │ ← MetadataSection
│ Publisher     O'Reilly                    │
│ Edition       Third Edition               │
│ Pages         450                         │
│ ISBN          978-0-12-800630-6           │
│ PDF Type      Text (Searchable)           │
│ File Size     48.3 MB                     │
│ Difficulty    [●●● Advanced]              │ ← DifficultyBadge
│                                           │
│ ─── Description ────────────────────────  │
│ A comprehensive textbook covering         │
│ photorealistic rendering and ray          │
│ tracing...                   [Read more]  │
│                                           │
│ ─── Subjects ───────────────────────────  │
│ [Ray Tracing] [Rendering] [GPU]           │ ← SubjectTags
│ [Computer Graphics] [Shading]             │
│                                           │
│ ─── Domains ────────────────────────────  │
│ [Computer Science] [Graphics]             │
│                                           │
│ ─── Related Books ──────────────────────  │
│ • Real-Time Rendering                     │ ← RelatedBooks
│ • Fundamentals of Computer Graphics       │
│                                           │
│ ─── Actions ────────────────────────────  │
│ [Open PDF] [Open in Finder] [Re-enrich]   │ ← BookActions
│                                           │
│ ─── AI Chat ────────────────────────────  │
│ [💬 Coming in v2]                         │ ← ChatSlot
└────────────────────────────────────────────┘

Width: 50vw (min: 400px, max: 700px)
Animation: slide from right, 300ms ease-out
Background: var(--bg-surface)
Shadow: var(--shadow-panel) = -4px 0 24px rgba(0,0,0,0.5)

Props: book: BookDTO, implicit onClose via store ARIA: role="dialog", aria-label="Book details: {title}", aria-modal="false" Focus: Trapped inside when open (Tab cycles through elements), Escape closes Actions:

  • Back button / Escape → detailOpen.set(false), return focus to card
  • "Open PDF" → apiPost('/api/actions/open-pdf', {uuid})
  • "Open in Finder" → apiPost('/api/actions/open-finder', {uuid})
  • "Re-enrich" → apiPost('/api/enrich', {uuid}) + poll

SearchPalette.svelte — Command Palette

┌──────────────────────────────────────────┐
│                                          │  ← Semi-transparent backdrop
│   ┌──────────────────────────────────┐   │     (click to close)
│   │ 🔍 Search books...              │   │
│   │    Tip: use author: title: year: │   │
│   ├──────────────────────────────────┤   │
│   │                                  │   │
│   │ ► Deep Learning - Adaptive...    │   │  ← Keyboard navigable
│   │   Goodfellow • Software • 2016   │   │     (up/down arrows)
│   │                                  │   │
│   │   Deep Work by Cal Newport       │   │  ← Highlighted matches
│   │   Newport • Productivity • 2016  │   │
│   │                                  │   │
│   │   Deep Thinking by Garry Kaspar..│   │
│   │   Kasparov • Science • 2017      │   │
│   │                                  │   │
│   └──────────────────────────────────┘   │
│                                          │
└──────────────────────────────────────────┘

Width: 600px, centered horizontally
Max-height: 500px (results scroll)
Top offset: ~20% from top
Animation: fade in backdrop + scale palette 0.95→1.0 (200ms)

Props: None (reads from search stores) ARIA: role="dialog", aria-modal="true", input has role="combobox", results have role="listbox" with role="option" items Focus: Input auto-focused on open, Escape closes, Enter selects active result Keyboard: Arrow Up/Down moves through results, active result has aria-activedescendant


CoverImage.svelte — Cover with Fallback

States:
  1. Loading:  [░░░░░░░░] skeleton shimmer (gray rectangle, 3:4 aspect)
  2. Loaded:   [image]    crossfade from skeleton (200ms opacity)
  3. Error:    [AA]       placeholder with title initials + book icon
                          subtle gradient background
                          category-tinted color

Props:
  book: BookDTO         — book data for cover resolution
  size: 'sm'|'md'|'lg'  — sm=40x56, md=full card, lg=detail panel

Implementation:

<script>
  export let book;
  export let size = 'md';

  let loaded = false;
  let error = false;
  let currentSrc = book.covers?.best;

  // Fallback chain (client-side, normally not needed since backend resolves)
  const fallbacks = [
    book.covers?.best,
    book.covers?.ps_extracted, book.covers?.ps_google,
    book.covers?.expanded_extracted, book.covers?.expanded_google,
    book.covers?.google, book.covers?.extracted,
  ].filter(Boolean);

  let fallbackIndex = 0;

  function handleError() {
    fallbackIndex++;
    if (fallbackIndex < fallbacks.length) {
      currentSrc = fallbacks[fallbackIndex];
    } else {
      error = true;
    }
  }
</script>

SettingsDrawer.svelte — Settings Panel

┌────────────────────────────────────┐
│ Settings                     [✕]  │
│ ─────────────────────────────     │
│                                    │
│ DISPLAY                            │
│ Grid size    [S] [●M] [L]         │
│ Show author  [✓]                   │
│ Show subtitle[✓]                   │
│                                    │
│ DEFAULTS                           │
│ Sort by      [Title        ▼]     │
│ Sort order   [A-Z ▲]              │
│                                    │
│ KEYBOARD SHORTCUTS                 │
│ ⌘K  Search                        │
│ ←→  Navigate                       │
│ i   Detail panel                   │
│ o   Open PDF                       │
│ ?   All shortcuts                  │
│                                    │
│ ABOUT                              │
│ Version 1.0.0                      │
│ Books: 1135 • Covers: 1050         │
└────────────────────────────────────┘

Width: 320px
Animation: slide from right, 300ms
Background: var(--bg-surface)

Props: None (reads/writes settings store) ARIA: role="dialog", aria-label="Settings" Interactions: Changes auto-save to localStorage via settings store subscription


8.2 Component Prop/Event Summary

Component Props Events / Store Writes
App Mounts all, registers shortcuts
TopBar searchOpen, viewMode, settingsOpen
Sidebar activeCategory
MainContent Renders filteredBooks
BookGrid books[] focusedIndex
BookCard book, focused, index selectedBook, detailOpen
BookList books[]
BookListRow book, selected selectedBook, detailOpen
BookDetail book detailOpen, toasts (via actions)
SearchPalette searchOpen, searchQuery, selectedBook, detailOpen
SearchInput searchQuery
SearchResults results[] selectedBook, detailOpen, searchOpen
FilterBar activeFilters
FilterChip label, onRemove Calls onRemove
SortDropdown sortConfig in filteredBooks
CoverImage book, size — (self-contained)
SettingsDrawer settings, settingsOpen
Toast message, type, duration Self-dismisses
ToastContainer Reads toasts store
KeyboardHint Self-contained
StatusBar Reads filteredBooks, allBooks, health

9. UI Layout & Design

9.1 Desktop Layout (1200px+)

┌────────────────────────────────────────────────────────────────┐
│ TopBar (56px)                                                  │
│ [Logo]  [⌘K Search books...]              [↻] [▤ ▦] [⚙]      │
├──────────┬─────────────────────────────────────────────────────┤
│ Sidebar  │ Main Content                                        │
│ (260px)  │                                                     │
│          │ [Difficulty: Advanced ✕]  [Clear all]               │
│ LIBRARY  │                                                     │
│ ──────── │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐       │
│ All (1135│ │        │ │        │ │        │ │        │       │
│ ▾Software│ │ Cover  │ │ Cover  │ │ Cover  │ │ Cover  │       │
│   AI  (80│ │        │ │        │ │        │ │        │       │
│   Grfx(42│ ├────────┤ ├────────┤ ├────────┤ ├────────┤       │
│   Lang(28│ │Title   │ │Title   │ │Title   │ │Title   │       │
│ ▸Philos  │ │Author  │ │Author  │ │Author  │ │Author  │       │
│ ▸Science │ └────────┘ └────────┘ └────────┘ └────────┘       │
│          │                                                     │
│ ──────── │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐       │
│ Enriched:│ │  ...   │ │  ...   │ │  ...   │ │  ...   │       │
│ 890/1135 │ └────────┘ └────────┘ └────────┘ └────────┘       │
├──────────┴─────────────────────────────────────────────────────┤
│ StatusBar (32px)                                               │
│ Showing 42 of 1135 books                    ● Online   v1.0.0 │
└────────────────────────────────────────────────────────────────┘

9.2 With Detail Panel Open

┌────────────────────────────────────────────────────────────────┐
│ TopBar                                                         │
├──────────┬─────────────────────┬──────────────────────────────-┤
│ Sidebar  │ Main Content        │ BookDetail (50vw)             │
│ (260px)  │ (compressed)        │                               │
│          │                     │ [← Back]            [Open] [⋮]│
│          │ ┌──────┐ ┌──────┐  │                               │
│          │ │      │ │      │  │ ┌───────────────────────────┐ │
│          │ │Cover │ │Cover │  │ │     Large Cover           │ │
│          │ │      │ │      │  │ │     (max-h: 400px)        │ │
│          │ ├──────┤ ├──────┤  │ └───────────────────────────┘ │
│          │ │Title │ │Title │  │                               │
│          │ └──────┘ └──────┘  │ Title - Subtitle              │
│          │                     │ by Author                     │
│          │ ┌──────┐ ┌──────┐  │                               │
│          │ │ ...  │ │ ...  │  │ Software > Graphics           │
│          │ └──────┘ └──────┘  │                               │
│          │                     │ Year: 2017  Publisher: O'R... │
│          │                     │ ...metadata...                │
├──────────┴─────────────────────┴──────────────────────────────-┤
│ StatusBar                                                      │
└────────────────────────────────────────────────────────────────┘

9.3 Tablet Layout (768px - 1199px)

┌──────────────────────────────────────────┐
│ TopBar                                    │
│ [☰] [⌘K Search...]         [↻] [▤▦] [⚙]│
├──────────────────────────────────────────┤
│ Main Content (full width)                │
│                                          │
│ ┌──────┐ ┌──────┐ ┌──────┐             │
│ │Cover │ │Cover │ │Cover │             │  3 columns
│ ├──────┤ ├──────┤ ├──────┤             │
│ │Title │ │Title │ │Title │             │
│ └──────┘ └──────┘ └──────┘             │
│                                          │
│ ┌──────┐ ┌──────┐ ┌──────┐             │
│ │ ...  │ │ ...  │ │ ...  │             │
│ └──────┘ └──────┘ └──────┘             │
├──────────────────────────────────────────┤
│ StatusBar                                │
└──────────────────────────────────────────┘

Sidebar: hidden by default, hamburger [☰] opens as overlay drawer
Detail panel: full-width overlay (not side panel)

9.4 Color System

/* Background hierarchy — 5 levels */
--bg-deep:      #0f0f14;    /* Page background */
--bg-surface:   #1e1e35;    /* Sidebar, panels */
--bg-card:      #252540;    /* Book cards, elevated surfaces */
--bg-hover:     #2a2a45;    /* Hover states */
--bg-active:    #303050;    /* Active/selected states */

/* Accent */
--accent:       #4ecdc4;    /* Primary accent (teal) */
--accent-hover: #5ee0d6;    /* Accent on hover */
--accent-muted: #4ecdc433;  /* 20% opacity, subtle highlights */
--accent-subtle:#4ecdc41a;  /* 10% opacity, backgrounds */

/* Text hierarchy */
--text-primary:   #e8e8ee;  /* Headings, important text */
--text-secondary: #999999;  /* Labels, metadata */
--text-muted:     #666666;  /* Disabled, placeholder */

/* Semantic */
--success:  #4ade80;        /* Green */
--warning:  #fbbf24;        /* Yellow */
--error:    #f87171;        /* Red */
--info:     #60a5fa;        /* Blue */

/* Difficulty */
--diff-beginner:     #4ade80;  /* Green */
--diff-intermediate: #fbbf24;  /* Yellow */
--diff-advanced:     #f87171;  /* Red */

/* Borders */
--border:       #ffffff12;  /* ~7% white */
--border-hover: #ffffff25;  /* ~15% white */

/* Shadows */
--shadow-card:       0 2px 8px rgba(0,0,0,0.3);
--shadow-card-hover: 0 8px 24px rgba(0,0,0,0.4);
--shadow-panel:      -4px 0 24px rgba(0,0,0,0.5);
--shadow-dropdown:   0 4px 16px rgba(0,0,0,0.5);

9.5 Typography

--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;

/* Scale */
Page title:     1.5rem,  weight 600, line-height 1.3
Section header: 1.125rem, weight 600, line-height 1.3, letter-spacing 0.5px
Body:           0.9375rem, weight 400, line-height 1.6
Label:          0.8125rem, weight 500, line-height 1.4, letter-spacing 0.5px
Caption:        0.75rem,  weight 400, line-height 1.4

/* On dark backgrounds: slightly increased line-height + letter-spacing for readability */

9.6 Animation Specs

Animation Duration Easing Details
Card hover 150ms ease-out Image scale(1.02), shadow grows, title brightens
Detail panel open 300ms ease-out translateX(100% → 0), backdrop opacity 0→0.5
Detail panel close 200ms ease-in translateX(0 → 100%), backdrop opacity 0.5→0
Search palette open 200ms ease-out opacity 0→1, scale 0.95→1.0
Filter chip add 150ms ease-out opacity 0→1, translateY(-4px→0)
Toast appear 200ms ease-out translateY(20px→0), opacity 0→1
Toast dismiss 150ms ease-in opacity 1→0
Sidebar category expand 200ms ease-out height 0→auto (using grid technique)
Skeleton shimmer 1.5s linear Background gradient sweep (infinite loop)
Cover load 200ms ease-out opacity 0→1 (crossfade from skeleton)

All animations respect @media (prefers-reduced-motion: reduce) → instant state changes.


10. Cover System

10.1 Fallback Chain

Priority 1: User-selected cover (from cover_reviews.json)
            ├── source=extracted, version=photoshop → covers/extracted/photoshop/{filename}
            ├── source=extracted, version=expanded  → covers/expanded/extracted/{uuid}-{title}.jpg
            ├── source=extracted, version=original  → covers/extracted/{uuid}-{title}.jpg
            ├── source=google,    version=photoshop → covers/google/photoshop/{filename}
            ├── source=google,    version=expanded  → covers/expanded/google/{uuid}-{title}.jpg
            └── source=google,    version=original  → covers/google/{uuid}-{title}.jpg

Priority 2: Photoshop-processed (either source, if exists)
Priority 3: AI-expanded (either source, if exists)
Priority 4: Google Books cover (higher quality, proper aspect)
Priority 5: Extracted from PDF (first page, may include headers/text)
Priority 6: Generated placeholder (SVG with initials + category color)

The backend resolves this chain in cover_service.py and returns covers.best in the API response. The frontend CoverImage.svelte uses covers.best directly and only walks the client-side fallback chain if the best URL fails to load (onerror).

10.2 Preloading Strategy

Current view renders N visible cards.

On scroll or navigation:
  ±1 from focused card → <link rel="preload" as="image" fetchpriority="high">
  ±5 from focused card → new Image() background fetch (fetchPriority="low")

Deduplication: Set<string> tracks preloaded URLs (never re-fetch same URL).

10.3 Placeholder Design

For books with no cover at all:

┌─────────────────────┐
│                     │
│       ╔═══╗        │  Book icon (subtle, 20% opacity)
│       ║   ║        │
│       ╚═══╝        │
│                     │
│        PBR          │  Title initials (large, 30% opacity)
│                     │
│                     │
└─────────────────────┘

Background: subtle gradient using category-based tint
  Software → blue-tinted dark
  Philosophy → purple-tinted dark
  Science → green-tinted dark

11. Search System

11.1 Architecture

User types in SearchInput
  │
  ├── 150ms debounce
  │
  ▼
parseQuery(rawQuery)
  │
  ├── Starts with prefix? (title: author: year: etc.)
  │   → { type: 'field', field: 'title', value: 'deep', exact: false }
  │
  ├── Wrapped in "quotes"?
  │   → { type: 'exact', value: 'deep learning' }
  │
  └── Otherwise
      → { type: 'fuzzy', value: 'deep learning' }

      │
      ▼
Search execution:
  ├── fuzzy:  fuseIndex.search(value) → results with scores + match ranges
  ├── exact:  allBooks.filter(b => someField.includes(value))
  └── field:  allBooks.filter(b => b[field].includes(value))
              (or === for exact fields like year, difficulty, pdf_type)

      │
      ▼
searchResults.set(results)
  │
  ▼
SearchResults.svelte renders with highlighting

11.2 Fuse.js Configuration

const FUSE_OPTIONS = {
  threshold: 0.4,          // 0=exact, 1=anything. 0.4 is a good balance.
  distance: 100,           // Max distance from expected location
  minMatchCharLength: 2,   // Ignore single-char matches
  ignoreLocation: true,    // Don't penalize matches far from start
  keys: [
    { name: 'title',           weight: 2.0 },
    { name: 'author',          weight: 1.5 },
    { name: 'category',        weight: 1.0 },
    { name: 'subcategory',     weight: 0.8 },
    { name: 'subjects',        weight: 1.0 },  // Array field
    { name: 'domains',         weight: 0.8 },  // Array field
    { name: 'publisher',       weight: 0.5 },
    { name: 'description',     weight: 0.3 },
  ],
  includeScore: true,       // For ranking
  includeMatches: true,     // For highlighting
};

11.3 Prefix Reference

Prefix Short Field Match Type
title: t: title fuzzy (includes)
author: a: author fuzzy (includes)
year: y: publication_year exact
category: cat: category fuzzy (includes)
publisher: pub: publisher fuzzy (includes)
domain: d: domains[] fuzzy (includes in array)
subject: s: subjects[] fuzzy (includes in array)
diff: difficulty_level exact
type: pdf_type exact

12. Keyboard Navigation

12.1 Shortcut Map

Key Context Action Notes
/ Global Open search palette Also Cmd+K / Ctrl+K
Escape Search open Close search
Escape Detail open Close detail panel Returns focus to card
Escape Settings open Close settings
or j Grid/List Next book Wraps at end
or k Grid/List Previous book Wraps at start
Grid Next row Based on visible columns
Grid Previous row
Enter Book focused Open detail panel
Space Book focused Open detail panel
o Book focused Open PDF in Preview Shows toast
f Book focused Open in Finder Shows toast
i Book focused Toggle detail panel
g Global Toggle grid/list
[ Global Toggle sidebar
? Global Shortcuts overlay
Home Grid/List First book
End Grid/List Last book

12.2 Focus Management Rules

  1. On page load: Focus on first book card (or search if no books)
  2. On detail open: Focus trapped inside panel (Tab cycles through: close button, actions, etc.)
  3. On detail close: Focus returns to the book card that opened it
  4. On search open: Focus auto-moves to search input
  5. On search close: Focus returns to previously focused element
  6. On search result select: Focus moves to the selected book card (detail opens)
  7. Grid arrow keys: Update focusedIndex, scroll into view if needed
  8. Inputs/textareas: All shortcuts suppressed except Escape

12.3 Shortcut Registration

// In App.svelte onMount:
registerShortcut('/', () => searchOpen.set(true));
registerShortcut('meta+k', () => searchOpen.set(true));
registerShortcut('escape', handleEscape);  // Layered: search > detail > settings
registerShortcut('arrowright', () => navigateBook(1));
registerShortcut('j', () => navigateBook(1));
registerShortcut('arrowleft', () => navigateBook(-1));
registerShortcut('k', () => navigateBook(-1));
registerShortcut('enter', openFocusedBook);
registerShortcut('o', openFocusedBookPdf);
registerShortcut('f', openFocusedBookFinder);
registerShortcut('i', toggleDetail);
registerShortcut('g', toggleViewMode);
registerShortcut('[', toggleSidebar);
registerShortcut('?', toggleShortcutsOverlay);

13. Accessibility

13.1 ARIA Landmarks

<body class="dark">
  <a class="sr-only focus:visible" href="#main">Skip to content</a>
  <header role="banner">             <!-- TopBar -->
  <nav role="navigation"             <!-- Sidebar -->
       aria-label="Library categories">
  <main id="main" role="main"        <!-- MainContent -->
        aria-label="Book library">
  <aside role="complementary"        <!-- BookDetail (when open) -->
         aria-label="Book details">
  <footer role="contentinfo">        <!-- StatusBar -->
</body>

13.2 Live Regions

<!-- Filter/search result count (updates on filter change) -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
  Showing {$filteredBooks.length} of {$allBooks.length} books
</div>

<!-- Toast notifications -->
<div aria-live="assertive" aria-atomic="true" class="sr-only">
  {latestToast.message}
</div>

13.3 Component ARIA Patterns

Component ARIA Pattern Key Attributes
BookGrid Grid role="grid", cells have role="gridcell", aria-rowcount
Sidebar Tree role="tree", items have role="treeitem", aria-expanded
SearchPalette Combobox Input: role="combobox", aria-expanded, aria-controls, aria-activedescendant
SearchResults Listbox role="listbox", items have role="option", aria-selected
BookDetail Dialog role="dialog", aria-label, focus trap
SettingsDrawer Dialog role="dialog", aria-label, focus trap
FilterChip role="button", aria-label="Remove {filter} filter"
DifficultyBadge aria-label="Difficulty: advanced" (not just color)
CoverImage Image alt="{title} cover" or alt="" + aria-hidden if decorative

13.4 Keyboard Accessibility Checklist

  • All interactive elements focusable via Tab
  • Visible focus indicators (2px accent outline, 2px offset)
  • Skip-to-content link as first focusable element
  • Focus trapped in modals/dialogs
  • Focus restored when overlays close
  • Grid navigation with arrow keys
  • Escape closes active overlay
  • All shortcut keys documented in ? overlay
  • @media (prefers-reduced-motion: reduce) honored

14. State Management

14.1 Store Categories

Category Stores Lifecycle Persistence
Data allBooks, categoryTree App lifetime API (refetched on mount)
Derived filteredBooks, hasActiveFilters Reactive None (computed)
Navigation activeCategory, selectedBook, focusedIndex Session lastCategory via settings
Filters activeFilters, searchQuery, searchResults Session None
UI viewMode, sidebarOpen, settingsOpen, searchOpen, detailOpen Session lastViewMode via settings
Preferences settings Permanent localStorage
Transient toasts, isLoading, lastUpdated Momentary None

14.2 localStorage Schema

// Key: 'book-browser-settings'
// Value:
{
  "_version": 1,           // Schema version (for future migrations)
  "gridSize": "medium",    // "small" | "medium" | "large"
  "defaultSort": "title",  // Sort field
  "defaultOrder": "asc",   // "asc" | "desc"
  "showSubtitles": true,
  "showAuthor": true,
  "sidebarCollapsed": false,
  "lastCategory": "Software",  // Restored on mount
  "lastViewMode": "grid"       // Restored on mount
}

Migration strategy: if _version < current, apply migrations in order.

14.3 Svelte 5 Runes vs Classic Stores

Use Case Approach
Global shared state (books, categories, filters, settings) Classic Svelte stores (writable, derived)
Component-local state (hover state, expanded, loading) Svelte 5 $state rune
Side effects (localStorage sync, API polling) Svelte 5 $effect rune
Computed values (filtered lists, counts) Svelte 5 $derived rune (component) or derived store (global)

15. AI Chat Integration Point

15.1 Architecture (v2 — Not Built in MVP)

┌─────────────────────────────────────────────────────┐
│  Book Browser (Host App)                             │
│                                                      │
│  BookDetail.svelte                                   │
│  └── ChatSlot.svelte                                 │
│      │                                               │
│      │ Props:                                        │
│      │   contextProvider: () => {                    │
│      │     totalBooks, categories, currentBook       │
│      │   }                                           │
│      │   onBookReference: (uuid) => {                │
│      │     selectedBook.set(findBook(uuid))          │
│      │     detailOpen.set(true)                      │
│      │   }                                           │
│      │                                               │
│      └── <chat-module>  (future: Web Component       │
│            OR Svelte component)                      │
│                                                      │
│  Backend:                                            │
│  └── routes/chat.py  (future)                        │
│      └── POST /api/chat/message                      │
│          - Receives message + history                 │
│          - Injects library context (server-side)     │
│          - Defines tools: search_books, get_details  │
│          - Streams response via SSE                  │
└─────────────────────────────────────────────────────┘

15.2 MVP Placeholder

<!-- ChatSlot.svelte -->
<div class="chat-slot">
  <button class="chat-placeholder" title="AI Chat — Coming in v2">
    💬
  </button>
</div>

Small icon in detail panel footer. Click shows tooltip "Coming in v2". No functionality yet, but validates layout allocation.


16. Patterns Ported from Cover Reviewer

16.1 What We Keep

Pattern Cover Reviewer Implementation Book Browser Adaptation
Fuse.js search threshold 0.4, weighted keys, 150ms debounce Same config, add description + subjects keys
Field prefixes title: author: year: category: publisher: domain: Same + add subject: diff: type: short aliases
Cover fallback user-selection → auto-expanded → auto-PS → original Review selection → PS → expanded → Google → extracted → placeholder
Image preloading <link rel="preload"> ±1, new Image() ±5 Same strategy in preloader.js
State persistence localStorage JSON blob + server-side file localStorage only (settings store), API-based data
Keyboard nav Arrow keys, /, Escape, i, f Expanded set with j/k vim-style, ?, g, [, o
Toast notifications Bottom-right, auto-dismiss 2s, fade animation Same pattern, component-based
Counter/jump N/M click-to-edit for direct jump StatusBar shows count, search is the jump mechanism
Category filter Dropdown with path-prefix matching Sidebar tree with recursive expand/collapse
Settings drawer 320px slide-in from right, auto-save Same pattern, component-based
Inventory watcher 15s poll on mtime 60s poll + manual refresh button
Dual persistence localStorage + server POST localStorage for settings, API for data
Race condition guard Check currentBookUuid before applying async result Same pattern in CoverImage (check book hasn't changed during load)
Empty/error states "No books match filters" in title, toast for errors EmptyState component with suggestions, toast for errors

16.2 What We Skip

  • Cover comparison (side-by-side, overlay modes)
  • AI cover expansion generation workflow
  • Photoshop integration (AppleScript, JSX workflow)
  • Cover reviews/selections editing (read-only now — use existing selections)
  • Problem flagging per cover
  • Progress bar for "N reviewed"
  • Export reviews JSON
  • Target aspect frame visualization

17. Security

17.1 Fixes from Hardening Report

Issue Fix
Path traversal in /api/covers/<path> validate_path(): resolve, check starts with COVERS_DIR
Path traversal in open-pdf/open-finder Lookup by UUID only, never accept raw paths
DOM XSS via innerHTML Svelte's template syntax auto-escapes by default. No {@html} without sanitization.
Wildcard CORS Restrict to localhost:8901 and localhost:5173 (dev)
AppleScript injection Not applicable (no Photoshop integration)
Debug mode app.run(debug=False) enforced. DEV_MODE via env var only.
Unbounded job dict EnrichmentService: auto-cleanup jobs older than 1 hour

17.2 New Security Measures

Measure Implementation
UUID validation Regex check on all UUID params (middleware decorator)
Content-Type enforcement POST endpoints reject without application/json
Rate limiting Flask-Limiter: 5/min on enrichment, 30/min on actions
Error masking Internal errors → generic message; stack traces only in server log
No path in API Open-pdf/open-finder accept UUID only, resolve path server-side

18. Implementation Phases

Phase 0: Scaffolding

Goal: Empty app runs with "Hello World" frontend and /api/health backend.

  1. Create apps/book-browser/ directory structure
  2. cd frontend && npm init with svelte@5, @sveltejs/vite-plugin-svelte, vite, tailwindcss@4, fuse.js, lucide-svelte, postcss, autoprefixer
  3. vite.config.js: Svelte plugin + proxy /apihttp://localhost:8901
  4. tailwind.config.js: dark mode 'class', custom surface/accent colors
  5. app.css: CSS custom properties (full token set from §9.4)
  6. Flask app.py: factory, CORS, health blueprint
  7. start.sh: --dev mode (concurrent Vite + Flask) vs build + serve
  8. Verify: Svelte renders, /api/health returns JSON

Phase 1: Data Pipeline

Goal: All API endpoints return correct data, covers resolve properly.

  1. models/book.py: BookDTO dataclass
  2. services/inventory_service.py: full merge logic (inventory + metadata + covers + reviews)
  3. services/category_service.py: build tree from paths
  4. services/cover_service.py: fallback chain with review selection priority
  5. middleware/security.py: path validation, UUID validation, rate limiting
  6. routes/books.py: GET /api/books (filter, sort), GET /api/books/:uuid
  7. routes/categories.py: GET /api/categories
  8. routes/covers.py: GET /api/covers/:path (validated, cached)
  9. routes/health.py: GET /api/health, GET /api/stats
  10. Verify: curl all endpoints, check covers.best resolves review selections

Phase 2: Core UI Shell

Goal: Grid of books with covers renders, sidebar filters by category.

  1. App.svelte: CSS grid layout (sidebar + main + overlays)
  2. Sidebar.svelte: recursive category tree
  3. TopBar.svelte: search trigger, view toggle, settings button
  4. StatusBar.svelte: book count, online/offline
  5. Stores: books, categories, filters, settings (localStorage)
  6. BookGrid.svelte + BookCard.svelte
  7. CoverImage.svelte: skeleton → load → crossfade → error fallback
  8. Verify: grid renders with real covers, clicking category filters works

Phase 3: Detail Panel

Goal: Click book → slide-out panel with metadata, action buttons work.

  1. BookDetail.svelte: slide-out animation, layout
  2. Detail sub-components: MetadataSection, SubjectTags, DifficultyBadge, CategoryBreadcrumb
  3. BookActions.svelte: Open PDF, Open Finder
  4. routes/actions.py: POST open-pdf, open-finder
  5. Focus trapping, Escape to close
  6. Verify: click card → panel with metadata, Open PDF launches Preview

Phase 4: Search

Goal: Cmd+K opens palette, fuzzy search finds books, prefixes work.

  1. SearchPalette.svelte: modal overlay with backdrop
  2. SearchInput.svelte: prefix hints
  3. SearchResults.svelte: match highlighting, keyboard nav
  4. search/engine.js: Fuse.js setup
  5. search/prefixes.js: prefix parser
  6. stores/search.js: query + results
  7. Verify: Cmd+K → type → results appear → Enter navigates to book

Phase 5: List View + Sort + Filters

Goal: Grid/list toggle, multi-field sort, filter chips.

  1. BookList.svelte + BookListRow.svelte: table view
  2. SortDropdown.svelte: field + direction
  3. FilterBar.svelte + FilterChip.svelte
  4. FilterPanel.svelte: subject/difficulty/pdfType/enrichment/year
  5. View toggle binding
  6. Verify: toggle works, sort works, filters add/remove as chips

Phase 6: Keyboard + Accessibility

Goal: Full keyboard navigation, VoiceOver reads correctly.

  1. keyboard/shortcuts.js: registry + handler
  2. Register all shortcuts from §12.1
  3. ARIA landmarks, roles, live regions on all components
  4. Focus indicators, skip-to-content, focus trapping
  5. prefers-reduced-motion support
  6. Verify: navigate entire app with keyboard only, run axe-core

Phase 7: Polish

Goal: Production-ready, polished experience.

  1. Enrichment: route, progress UI in detail
  2. Toast system
  3. Settings drawer (grid size, sort, shortcuts reference)
  4. Cover preloading
  5. Inventory refresh (manual + 60s poll)
  6. Loading/empty/error states
  7. Responsive breakpoints (768px tablet)
  8. ChatSlot placeholder
  9. Verify: end-to-end workflow, responsive, performance, offline indicator

19. Open Decisions

# Decision Options Recommendation
1 Port 8901 (replace cover reviewer) or new port 8901 — this replaces the cover reviewer
2 Inventory refresh Poll (60s) + manual, or manual only Both — poll is cheap, manual gives control
3 Virtual scrolling Add now or defer Defer — test with real data first, likely fine without
4 Chat placeholder Show "Coming v2" icon or hide Show icon — validates layout, sets expectation
5 Svelte 5 approach Runes for local, stores for global (hybrid) Yes — best of both worlds
6 Cover reviews in browser Read-only (use existing) or allow editing Read-only — reviewing is done, this is for browsing
7 TypeScript Use .ts or plain .js User preference — .js is simpler, .ts adds safety
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment