Appearance
Readr — Deep Technical Profile
Table of Contents
- Project Overview
- Architecture
- The Reader Engine
- Server & API
- Sync Engine
- E-ink & Supernote Integration
- Technical Tradeoffs & Decisions
- Major Bugs & Debugging Stories
- AI Agent Involvement
- Development Timeline
- Key Files Reference
1. Project Overview
A self-hosted, cross-platform e-book reader targeting the Supernote A5X e-ink tablet, with cloud sync, offline support, annotations (typed + handwritten), TTS, and a web client. Built as a monorepo with React Native (Expo SDK 54), Hono API server, and a Python TTS worker.
By the numbers:
- 222 commits, 8 PRs (7 merged, 1 open), built over Feb 23 – Apr 12, 2026
- ~92% AI co-authored (204/222 commits have Claude co-author tags)
- ~289 MB of Claude Code session data (the largest AI involvement of any project)
- 119+ source files across TypeScript (mobile + server) and Python (TTS)
- 14-table PostgreSQL schema with content-addressable book storage
- CRDT-lite sync with LWW for progress + tombstone sets for annotations
- 53 API endpoints across 10 route files
- Dual rendering: foliate-js for EPUB, pdf.js for PDF, both in WebView
- 17 bundled Google Fonts, 108K-word offline dictionary
- Kernel-level handwriting integration for Supernote (bypasses SurfaceFlinger, ~20ms latency)
2. Architecture
┌─────────────────────────────────────────────────────────────┐
│ React Native (Expo SDK 54) │
│ ├── Library (grid/list, search, sort, filter, upload) │
│ ├── Reader (WebView: foliate-js for EPUB, pdf.js for PDF) │
│ ├── Notes (Skia canvas / Supernote kernel drawing) │
│ ├── Stats (reading sessions, streaks, daily chart) │
│ └── Settings (e-ink mode, offline cache, auth) │
└──────────────┬──────────────────────────────────────────────┘
│ SQLite (local-first) REST API
│ + sync queue │
┌──────────────▼──────────────────────────────▼───────────────┐
│ Hono API Server (Node.js 22) │
│ ├── Auth (bearer token, email OTP via Resend) │
│ ├── Books (upload, dedup by SHA-256, metadata extraction) │
│ ├── Sync (pull/push with LWW + tombstone set merge) │
│ ├── Annotations (bookmarks, highlights, notes) │
│ ├── TTS (BullMQ queue → Python worker) │
│ └── Stats, Collections, Export │
└──────────────┬──────────────────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────────────────┐
│ PostgreSQL 16 │ Redis 7 │ MinIO/R2 (S3) │ TTS Worker │
└─────────────────────────────────────────────────────────────┘Monorepo Structure
| Package | Purpose |
|---|---|
apps/mobile | React Native + Expo Web (58 TS files) |
apps/server | Hono API (47 TS files, 35 endpoints) |
packages/shared | Types, constants, Zod validators |
packages/sync-engine | LWW merge, tombstone sets, queue dedup |
services/tts-worker | Python FastAPI + BullMQ TTS (Chatterbox/Kokoro) |
deploy/ | Docker Compose (infra + app stacks), Caddy, tunnel configs |
Deployment Topology
Two-stack Docker Compose (split to prevent MinIO bouncing during app redeploys):
- Infra stack: PostgreSQL 16, Redis 7, MinIO (optional, for self-hosted S3)
- App stack: Hono API, Caddy (optional, for TLS)
- GPU overlay: TTS worker with NVIDIA passthrough
Deployed on an Olares home server via rsync + docker compose up -d --build. Cloudflare Tunnel for HTTPS without opening ports.
3. The Reader Engine
3.1 EPUB Rendering (foliate-js in WebView)
The reader uses foliate-js (bundled as an IIFE via esbuild — zero CDN dependencies) running inside a react-native-webview. The pipeline:
ReaderScreenresolves a book source (localfile://path preferred, server presigned URL as fallback)getReaderHtml()generates an HTML document with bundled fonts, polyfills (Chromium 96 compat for Supernote), and the reader bundlereader.ts(1183 lines, runs inside WebView) fetches the EPUB via XHR, opens it with foliate, configures pagination, and handles all in-book interactions- A typed
postMessagebridge (26 message types) connects the WebView to React Native
Critical constraint: Android WebView with inline HTML has about:blank origin, blocking all fetch() and XHR to file:// URLs. Solution: Write reader HTML to a local cache file, load via source={{ uri: filePath }}, then use XHR (not fetch) to read book files. This was the hardest-won technical discovery in the project.
3.2 PDF Rendering (pdf.js in WebView)
Self-contained HTML with inline JS using pdf.js. Renders all pages as <canvas> elements with transparent text overlay for selection. Annotation overlays (highlights, notes) are positioned via percentage-based <div> elements. Scroll tracking computes page number from scrollTop / scrollHeight.
3.3 Annotation System
- Highlights: 5 colors (yellow, green, blue, pink, purple). EPUB uses foliate's SVG overlayer, PDF uses DOM overlays. On e-ink, all colors become solid black outlines.
- Bookmarks: Toggle via header icon, stored with CFI + percentage.
- Typed notes: Modal text editor anchored to CFI positions.
- Handwritten notes: Dual-path drawing system — Skia (LCD devices) or Supernote kernel (e-ink, see Section 6).
- All annotations stored in SQLite locally, synced to server via the sync engine.
3.4 Page Numbering (The Hardest Sub-Problem)
Two-phase system because foliate-js's page counts depend on font, margin, and viewport:
- Stub phase: Uses byte-based
location.total(font-invariant) until measurement completes - Background measurement: Hidden
<foliate-view>iterates every EPUB section, awaits font loading, pollsrenderer.pagesuntil stable, records per-section page counts - Live refinement: Current section's live page count overrides the measured value when they diverge
This went through 8+ consecutive commits of iteration before stabilizing.
3.5 TTS
Page-by-page read-aloud using expo-speech. Text split on sentence boundaries (Android has a 4000-char buffer limit). Auto-advances pages on completion. Rate control 0.5×–2.0×.
3.6 Dictionary
Offline: 27 JSON files (~108K words, ~9MB) bundled via Metro. Fuzzy matching with bounded Levenshtein distance, inflection stripping (-ies, -es, -s, -ing, -ed, -ly), case variant generation.
Online: Server-side dictionary endpoint backed by Wiktionary + WordNet (~1.4M words). 3s timeout, falls back to offline.
4. Server & API
4.1 Auth Model
No passwords, no OAuth. The bearer token IS the user identity.
- Registration: Client generates a random token (16-256 chars), POSTs to
/api/register. Token stored inusers.token. - Every request:
Authorization: Bearer <token>. Server seeks on unique index. - Email (optional): Resend-powered OTP for account recovery and login. Entirely disabled when env vars unset.
4.2 Database Schema (14 tables)
| Table | Key Design |
|---|---|
users | UUID PK (decoupled from token), optional email |
files | Content-addressable by SHA-256. Shared across users. refCount tracks references. |
books | Per-user reference to a file, with optional title/author overrides |
reading_progress | Per (bookId, userId, deviceId) — multi-device support |
bookmarks/highlights/notes | Soft-delete via deletedAt tombstone (required for sync) |
sync_log | Append-only mutation log, indexed by (userId, timestamp), 90-day retention |
tts_jobs + tts_audio_chunks | Batch TTS job tracking with per-chapter audio |
collections + book_collections | User collections with M:N join |
reading_sessions | Duration, pages, progress delta for stats |
4.3 Book Upload Pipeline
Upload multipart → validate format/size/quota → SHA-256 hash → if existing file: reuse + increment refCount → if new: upload to S3, extract metadata (EPUB: JSZip + XML parser; PDF: pdf-parse + pdftoppm), upload cover (normalized to 600×900 JPEG via sharp) → insert files + books rows → update storage_used_mb.
4.4 TTS Pipeline
Hono server enqueues per-chapter BullMQ jobs → Python worker (BRPOP on Redis, speaking BullMQ's protocol directly) → loads Chatterbox or Kokoro model → generates audio → encodes to Opus → uploads to S3 → updates progress in PostgreSQL. Also supports real-time streaming via Kokoro proxy.
5. Sync Engine
5.1 Two-Strategy CRDT-Lite
| Data Type | Strategy | Tie-Breaking |
|---|---|---|
| Reading progress | LWW (Last-Writer-Wins) | Server wins on timestamp tie |
| Annotations (bookmarks, highlights, notes) | Tombstone set merge | Deletions are permanent (no resurrection) |
5.2 Tombstone Set Merge Rules
- Create: Insert if entity doesn't exist. Skip if tombstoned (deleted entities cannot be resurrected — permanent).
- Update: LWW within living entities (newer timestamp wins). Skip if tombstoned.
- Delete: Soft-delete (
deletedAtset). Permanent — once deleted, no create or update can revive it.
5.3 Client-Side Queue
Changes queued in SQLite sync_queue. Before push, deduplicateQueue() keeps only the latest timestamp per entityType:entityId — if the user updated progress 5 times offline, only the final state is pushed. Auto-sync on app open and network reconnect. Debounced opportunistic push (800ms) on every write.
6. E-ink & Supernote Integration
6.1 Device Detection
DisplayContext.tsx checks NativeModules.PlatformConstants for Ratta/Supernote, ONYX/BOOX, Kobo, Kindle. Sets global isEink: true.
6.2 E-ink Adaptations
- All animations disabled, transitions set to
none - High contrast: black text, solid colors (no transparency)
- Larger tap targets: 64px minimum (vs 48px on LCD)
- Paginated scroll instead of smooth
- A2 refresh mode for faster screen updates
- Highlights become solid black outlines instead of translucent colors
- Loading indicators become static text instead of spinners
6.3 Kernel-Level Handwriting (Supernote-Specific)
The most technically ambitious feature. The Supernote A5X has a dedicated kernel-level handwriting pipeline:
Problem: React Native → Skia rendering has 200-400ms stroke-to-pixel latency on e-ink. Strokes also cause severe ghosting that survives 10+ screen refreshes.
Solution: HandwriteService activates the vendor binder (android.demo.IMyService) that drives a kernel-level rasterizer. EMR digitizer samples go directly to the EPD framebuffer, bypassing SurfaceFlinger entirely. Latency drops to ~20ms.
Implementation:
- Skia
<Canvas>replaced with a static<Image>snapshot when kernel mode active disableRectsdefine forbidden zones so the kernel only draws in the canvas areaBALL_PENpen type with 3 size indices {200, 400, 600}- Eraser = white color on same pen type
captureCanvas()screenshots the framebuffer region for persistence- Palm rejection via native
StylusOnlyViewthat filters non-stylus touch events
EPD waveform control (epd-mode.ts): Direct access to Rockchip's private View.requestEpdMode():
setA2()— Fast 1-bit waveform (~120ms) for active drawingsetPart()— Partial refresh for idle canvassetFull()— Full GC16 refresh to clear ghost residue
7. Technical Tradeoffs & Decisions
7.1 WebView File Access (The Canonical Solution)
Android WebView with source={{ html }} gets about:blank origin → fetch() and XHR to file:// both blocked. Tried: CDN imports (CORS), base64 injection (crashed renderer on large books), various fetch approaches.
Final solution: Write HTML to local file, load via source={{ uri }}, use XHR (not fetch) for file:// reads. This works because XHR has legacy file:// support that fetch() doesn't.
7.2 Bearer Token Auth (No Passwords)
Hunter directed replacing the initial better-auth library with a simpler custom scheme. The bearer token is self-generated by the client, stored in the users.token column (separate from the UUID primary key). No sessions, no OAuth, no password hashing. Email recovery is optional, disabled when Resend env vars aren't set.
7.3 Content-Addressable Book Storage
Books are deduped by SHA-256 hash. The files table holds one row per unique file, shared across all users. The books table is per-user, referencing files with optional title/author overrides. refCount tracks references; when it hits 0, S3 objects are cleaned up.
7.4 Two-Stack Docker Compose
Hunter directed splitting the single compose file after discovering that MinIO bouncing during app redeploys caused Cloudflare to cache 502s for book downloads. Infra stack (PostgreSQL, Redis, MinIO) is long-lived; app stack (API, Caddy) is rebuilt on every push.
7.5 Expo Web Over Standalone Web App
The separate apps/web Vite dashboard was deleted (PR #4, -2639 lines) in favor of Expo Web from apps/mobile. This makes the mobile app the single source of truth for all UI, at the cost of some web-specific features being stubbed.
7.6 Offline-First with Optimistic Auth
Auth check on app boot is optimistic: reads token from SecureStore, sets isAuthenticated: true immediately, then probes the server in the background. Only signs out on explicit 401/403 — network errors keep the user signed in. This enables full offline usage.
8. Major Bugs & Debugging Stories
8.1 The WebView Rendering Saga
The biggest technical challenge. Progression through 4 failed approaches before finding the solution:
- CDN
<script type="module">— blocked by CORS on null origin - Base64 injection via
injectedJavaScriptBeforeContentLoaded— crashed renderer on large books fetch()tofile://— blocked- XHR to
file://fromabout:blank— also blocked
Solution: Write HTML to local file → load via uri → XHR works from file:// origin.
8.2 Foliate Margin Control
Hunter directed margin customization but foliate-js's internal CSS variables (--_max-inline-size, --_max-block-size) resisted override attempts. After multiple failures, Hunter said: "I'm gonna get codex to fix it, you've lost my trust." Codex also struggled. Root cause: foliate's gap attribute expects a CSS percentage, while max-inline-size expects px with units.
8.3 Drawing Canvas Ghosting
Skia-rendered strokes on e-ink caused severe ghosting that survived 10+ screen refreshes and caused "warping" where strokes changed shape before settling. Led to the kernel-level drawing integration (Section 6.3).
8.4 Page Number Estimation
8+ consecutive commits wrestling with accurate page counts from foliate-js. Progression: fraction math → CSS column counts → section byte-size extrapolation → precompute via hidden <foliate-view> → lock counts after precompute.
8.5 Cloudflare 403 on Book Covers
Covers from books.hunterchen.ca returned 403 in browser but 200 from curl. AI methodically tested headers, CORS, Vary. Root cause: Cloudflare Hotlink Protection blocking cross-origin image requests. MinIO was never reached.
8.6 Nested Duplicate Files
Found apps/mobile/apps/mobile/apps/mobile/... three levels deep — 70 duplicate files (34 fonts, 26 JS modules). Cleaned up and added to .gitignore.
9. AI Agent Involvement
9.1 Session Data
| Metric | Value |
|---|---|
| JSONL session files | 7 (main) + 2 (worktrees) |
| Total session data | ~289 MB |
| Subagent files | 162 across all sessions |
| Memory files | 5 (WebView patterns, workflow, Olares deploy/env) |
| Date range | Feb 24 – Apr 12, 2026 |
| Largest session | 92.6 MB, 84 subagents (reader rendering, WebView, e-ink) |
9.2 Hunter's Direction
Hunter wrote the 1,166-line architecture spec upfront, made all infrastructure/security decisions, tested on real Supernote hardware, and corrected the AI repeatedly:
- WebView file access: Hunter identified the working approach after the AI exhausted 4 failed strategies
- Auth architecture: Hunter directed replacing
better-authwith bearer tokens - E-ink drawing: Hunter specified kernel-level integration after Skia ghosting, directed "the only time skia/react should be used to render strokes is in preview mode and when opening a previously created one"
- UI quality: "that's fugly as hell, do more thinking on it", "might be one of the most retarded selection menus i've seen in my life", "still looks a little sloppy tbh"
- Process enforcement: "always typecheck before pushing", "research before guessing", "don't go in circles" — all documented in memory files
- Trust boundary: After repeated margin debugging failures, Hunter brought in Codex for a second opinion, then returned when it also struggled
9.3 Workflow Patterns
- Overnight autonomous cycles: Hunter directed "do all of it. you can work through the night. repeatedly audit yourself. just on a cycle."
- Multiple review passes: "Nah we're gonna keep doing passes. Be really careful this time." — pushed for 5+ review passes on PRs
- Worktree-based PR development: 7 active git worktrees, including 2 created by Claude agents for parallel PR work
- Memory-driven continuity: 5 persistent memory files capturing hard-won WebView lessons, workflow rules, and deployment knowledge
10. Development Timeline
The project tells a story in three acts:
Act 1: Scaffold Sprint (Feb 23-24)
A single late-night session scaffolded the entire application in ~2 hours. Phases 1-6 completed: monorepo, server, mobile app, web dashboard, reader, sync engine, TTS, collections, stats. Spec-driven generation — Hunter wrote the architecture doc, Claude executed phase by phase.
Act 2: Hardware Reality (Apr 8-10, after 43-day gap)
Development resumed with fundamentally different character. Instead of generating scaffolding, the work became about making existing code work on real hardware:
- Auth system replaced (better-auth → bearer tokens)
- Reader went through dozens of iterations for page counting, themes, progress
- All external network dependencies eliminated (fonts, foliate-js, pdf.js bundled locally)
- Deployed to Olares, tested on Supernote
Act 3: Polish & Platform Parity (Apr 11-12)
- Standalone web app deleted, consolidated into Expo Web (PR #4)
- Docker Compose split into infra/app stacks (PR #14)
- Offline mode for mobile (PR #16)
- Web parity sprint (PR #15, 9 tracked issues, still open)
- Kernel-level Supernote handwriting integration
- Self-hosted dictionary endpoint
| Date | Key Achievement |
|---|---|
| Feb 23 | Architecture spec + scaffold start |
| Feb 24 | Entire monorepo scaffolded in ~2 hours (Phases 1-6) |
| Apr 8 | Development resumes. Auth rewrite, reader settings, Docker deployment |
| Apr 9 | Reader UX obsession: themes, page counting, fonts, progress bar. PR #1 (email recovery) |
| Apr 10 | foliate bundling, security audit (UUID migration), architecture doc rewrite. PRs #2, #3 |
| Apr 11 | Web consolidation (PR #4), compose stack split (PR #14), offline mode (PR #16), web parity sprint |
| Apr 12 | Kernel handwriting for Supernote, self-hosted dictionary, canvas fixes |
11. Key Files Reference
Reader Engine
| File | Lines | Purpose |
|---|---|---|
mobile/webview-src/reader.ts | 1183 | EPUB reader JS (runs in WebView) |
mobile/components/reader/pdf-html.ts | 511 | PDF reader (inline JS in WebView) |
mobile/app/reader/[bookId].tsx | ~1380 | Main reader screen (RN side) |
mobile/components/notes/HandwritingCanvas.tsx | ~400 | Skia + kernel drawing |
mobile/lib/epd-mode.ts | ~60 | Supernote EPD waveform control |
mobile/lib/handwrite-service.ts | ~100 | Kernel handwriting binder |
Server
| File | Lines | Purpose |
|---|---|---|
server/src/db/schema.ts | ~400 | 14-table Drizzle schema |
server/src/routes/sync.ts | ~200 | Pull/push sync with LWW + set merge |
server/src/routes/books.ts | ~250 | Upload, dedup, metadata extraction |
server/src/services/book-processor.ts | ~200 | EPUB/PDF metadata + cover extraction |
Sync Engine
| File | Lines | Purpose |
|---|---|---|
sync-engine/src/lww.ts | ~30 | Last-Writer-Wins merge |
sync-engine/src/set.ts | ~80 | Tombstone set merge (permanent deletes) |
sync-engine/src/queue.ts | ~40 | Offline dedup queue |
Shared
| File | Lines | Purpose |
|---|---|---|
shared/src/types.ts | ~200 | All domain types |
shared/src/validators.ts | ~300 | 22 Zod schemas for API validation |
shared/src/constants.ts | ~50 | Highlight colors, TTS engines, limits |