Status: Architecture Complete, Ready for Implementation
Created: 2026-03-21
Replaces: apps/cover-reviewer/ (to be retired)
- Overview & Stack
- Directory Structure
- Backend Architecture
- Frontend Architecture
- Data Model
- API Design
- Data Flow
- Component Architecture
- UI Layout & Design
- Cover System
- Search System
- Keyboard Navigation
- Accessibility
- State Management
- AI Chat Integration Point
- Patterns Ported from Cover Reviewer
- Security
- Implementation Phases
- Open Decisions
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.
The Cover Reviewer has:
- Zero accessibility (no ARIA, no semantic HTML)
- No responsive design (no
@mediaqueries) - DOM XSS via
innerHTMLwithout 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.
| 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:
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
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)
# 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┌─────────────────────────────────────────────┐
│ 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 │
└───────────────────────────────────────┘
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 │ └──────────────┘
└──────────────┘
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
| 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.) |
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)
┌─────────────┐
│ /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) │
└──────────┘ └──────────┘ └──────────┘
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;
}
);@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 fieldsGET /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"
}
}
| 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 |
| 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 |
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)
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
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')
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
┌──────────────────────────────────────────────────────────┐
│ <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 intervalonDestroy: clear refresh interval
┌────────────────────────────────────────────────────────────┐
│ ┌──────┐ ┌────────────────────────────┐ [↻] [▤][▦] [⚙] │
│ │ 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)
┌──────────────────────┐
│ 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-hoverbackground - Counts: right-aligned,
text-mutedcolor
Recursive rendering:
{#each categories as cat}
<CategoryNode node={cat} depth={0} />
{/each}
<!-- CategoryNode renders itself + children recursively -->┌──────────────────────────────────────────────────┐
│ 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"
┌──────────────────────────────────────────────────┐
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │ │ │ │ │ │ │ │ │
│ │ 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)
┌─────────────────────┐
│ │
│ 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
┌────────────────────────────────────────────────────────────────────┐
│ [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"
┌────────────────────────────────────────────┐
│ [← 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
┌──────────────────────────────────────────┐
│ │ ← 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
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>┌────────────────────────────────────┐
│ 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
| 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 |
┌────────────────────────────────────────────────────────────────┐
│ 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 │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ 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 │
└────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ 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)
/* 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);--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 */| 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.
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).
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).
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
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
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
};| 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 |
| 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 |
- On page load: Focus on first book card (or search if no books)
- On detail open: Focus trapped inside panel (Tab cycles through: close button, actions, etc.)
- On detail close: Focus returns to the book card that opened it
- On search open: Focus auto-moves to search input
- On search close: Focus returns to previously focused element
- On search result select: Focus moves to the selected book card (detail opens)
- Grid arrow keys: Update
focusedIndex, scroll into view if needed - Inputs/textareas: All shortcuts suppressed except Escape
// 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);<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><!-- 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>| 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 |
- 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
| 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 |
// 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.
| 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) |
┌─────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────┘
<!-- 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.
| 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 |
- 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
| 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 |
| 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 |
Goal: Empty app runs with "Hello World" frontend and /api/health backend.
- Create
apps/book-browser/directory structure cd frontend && npm initwith svelte@5, @sveltejs/vite-plugin-svelte, vite, tailwindcss@4, fuse.js, lucide-svelte, postcss, autoprefixervite.config.js: Svelte plugin + proxy/api→http://localhost:8901tailwind.config.js: dark mode 'class', custom surface/accent colorsapp.css: CSS custom properties (full token set from §9.4)- Flask
app.py: factory, CORS, health blueprint start.sh:--devmode (concurrent Vite + Flask) vs build + serve- Verify: Svelte renders,
/api/healthreturns JSON
Goal: All API endpoints return correct data, covers resolve properly.
models/book.py: BookDTO dataclassservices/inventory_service.py: full merge logic (inventory + metadata + covers + reviews)services/category_service.py: build tree from pathsservices/cover_service.py: fallback chain with review selection prioritymiddleware/security.py: path validation, UUID validation, rate limitingroutes/books.py: GET /api/books (filter, sort), GET /api/books/:uuidroutes/categories.py: GET /api/categoriesroutes/covers.py: GET /api/covers/:path (validated, cached)routes/health.py: GET /api/health, GET /api/stats- Verify: curl all endpoints, check
covers.bestresolves review selections
Goal: Grid of books with covers renders, sidebar filters by category.
App.svelte: CSS grid layout (sidebar + main + overlays)Sidebar.svelte: recursive category treeTopBar.svelte: search trigger, view toggle, settings buttonStatusBar.svelte: book count, online/offline- Stores: books, categories, filters, settings (localStorage)
BookGrid.svelte+BookCard.svelteCoverImage.svelte: skeleton → load → crossfade → error fallback- Verify: grid renders with real covers, clicking category filters works
Goal: Click book → slide-out panel with metadata, action buttons work.
BookDetail.svelte: slide-out animation, layout- Detail sub-components: MetadataSection, SubjectTags, DifficultyBadge, CategoryBreadcrumb
BookActions.svelte: Open PDF, Open Finderroutes/actions.py: POST open-pdf, open-finder- Focus trapping, Escape to close
- Verify: click card → panel with metadata, Open PDF launches Preview
Goal: Cmd+K opens palette, fuzzy search finds books, prefixes work.
SearchPalette.svelte: modal overlay with backdropSearchInput.svelte: prefix hintsSearchResults.svelte: match highlighting, keyboard navsearch/engine.js: Fuse.js setupsearch/prefixes.js: prefix parserstores/search.js: query + results- Verify: Cmd+K → type → results appear → Enter navigates to book
Goal: Grid/list toggle, multi-field sort, filter chips.
BookList.svelte+BookListRow.svelte: table viewSortDropdown.svelte: field + directionFilterBar.svelte+FilterChip.svelteFilterPanel.svelte: subject/difficulty/pdfType/enrichment/year- View toggle binding
- Verify: toggle works, sort works, filters add/remove as chips
Goal: Full keyboard navigation, VoiceOver reads correctly.
keyboard/shortcuts.js: registry + handler- Register all shortcuts from §12.1
- ARIA landmarks, roles, live regions on all components
- Focus indicators, skip-to-content, focus trapping
prefers-reduced-motionsupport- Verify: navigate entire app with keyboard only, run axe-core
Goal: Production-ready, polished experience.
- Enrichment: route, progress UI in detail
- Toast system
- Settings drawer (grid size, sort, shortcuts reference)
- Cover preloading
- Inventory refresh (manual + 60s poll)
- Loading/empty/error states
- Responsive breakpoints (768px tablet)
- ChatSlot placeholder
- Verify: end-to-end workflow, responsive, performance, offline indicator
| # | 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 |