Thorin

Enter password to continue

Skip to content

Readr — Deep Technical Profile

Table of Contents

  1. Project Overview
  2. Architecture
  3. The Reader Engine
  4. Server & API
  5. Sync Engine
  6. E-ink & Supernote Integration
  7. Technical Tradeoffs & Decisions
  8. Major Bugs & Debugging Stories
  9. AI Agent Involvement
  10. Development Timeline
  11. 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

PackagePurpose
apps/mobileReact Native + Expo Web (58 TS files)
apps/serverHono API (47 TS files, 35 endpoints)
packages/sharedTypes, constants, Zod validators
packages/sync-engineLWW merge, tombstone sets, queue dedup
services/tts-workerPython 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:

  1. ReaderScreen resolves a book source (local file:// path preferred, server presigned URL as fallback)
  2. getReaderHtml() generates an HTML document with bundled fonts, polyfills (Chromium 96 compat for Supernote), and the reader bundle
  3. reader.ts (1183 lines, runs inside WebView) fetches the EPUB via XHR, opens it with foliate, configures pagination, and handles all in-book interactions
  4. A typed postMessage bridge (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:

  1. Stub phase: Uses byte-based location.total (font-invariant) until measurement completes
  2. Background measurement: Hidden <foliate-view> iterates every EPUB section, awaits font loading, polls renderer.pages until stable, records per-section page counts
  3. 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.

  1. Registration: Client generates a random token (16-256 chars), POSTs to /api/register. Token stored in users.token.
  2. Every request: Authorization: Bearer &lt;token&gt;. Server seeks on unique index.
  3. Email (optional): Resend-powered OTP for account recovery and login. Entirely disabled when env vars unset.

4.2 Database Schema (14 tables)

TableKey Design
usersUUID PK (decoupled from token), optional email
filesContent-addressable by SHA-256. Shared across users. refCount tracks references.
booksPer-user reference to a file, with optional title/author overrides
reading_progressPer (bookId, userId, deviceId) — multi-device support
bookmarks/highlights/notesSoft-delete via deletedAt tombstone (required for sync)
sync_logAppend-only mutation log, indexed by (userId, timestamp), 90-day retention
tts_jobs + tts_audio_chunksBatch TTS job tracking with per-chapter audio
collections + book_collectionsUser collections with M:N join
reading_sessionsDuration, 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 TypeStrategyTie-Breaking
Reading progressLWW (Last-Writer-Wins)Server wins on timestamp tie
Annotations (bookmarks, highlights, notes)Tombstone set mergeDeletions 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 (deletedAt set). 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
  • disableRects define forbidden zones so the kernel only draws in the canvas area
  • BALL_PEN pen 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 StylusOnlyView that 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 drawing
  • setPart() — Partial refresh for idle canvas
  • setFull() — Full GC16 refresh to clear ghost residue

7. Technical Tradeoffs & Decisions

7.1 WebView File Access (The Canonical Solution)

Android WebView with source=&#123;&#123; html &#125;&#125; 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=&#123;&#123; uri &#125;&#125;, 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:

  1. CDN <script type="module"> — blocked by CORS on null origin
  2. Base64 injection via injectedJavaScriptBeforeContentLoaded — crashed renderer on large books
  3. fetch() to file:// — blocked
  4. XHR to file:// from about: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

MetricValue
JSONL session files7 (main) + 2 (worktrees)
Total session data~289 MB
Subagent files162 across all sessions
Memory files5 (WebView patterns, workflow, Olares deploy/env)
Date rangeFeb 24 – Apr 12, 2026
Largest session92.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-auth with 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
DateKey Achievement
Feb 23Architecture spec + scaffold start
Feb 24Entire monorepo scaffolded in ~2 hours (Phases 1-6)
Apr 8Development resumes. Auth rewrite, reader settings, Docker deployment
Apr 9Reader UX obsession: themes, page counting, fonts, progress bar. PR #1 (email recovery)
Apr 10foliate bundling, security audit (UUID migration), architecture doc rewrite. PRs #2, #3
Apr 11Web consolidation (PR #4), compose stack split (PR #14), offline mode (PR #16), web parity sprint
Apr 12Kernel handwriting for Supernote, self-hosted dictionary, canvas fixes

11. Key Files Reference

Reader Engine

FileLinesPurpose
mobile/webview-src/reader.ts1183EPUB reader JS (runs in WebView)
mobile/components/reader/pdf-html.ts511PDF reader (inline JS in WebView)
mobile/app/reader/[bookId].tsx~1380Main reader screen (RN side)
mobile/components/notes/HandwritingCanvas.tsx~400Skia + kernel drawing
mobile/lib/epd-mode.ts~60Supernote EPD waveform control
mobile/lib/handwrite-service.ts~100Kernel handwriting binder

Server

FileLinesPurpose
server/src/db/schema.ts~40014-table Drizzle schema
server/src/routes/sync.ts~200Pull/push sync with LWW + set merge
server/src/routes/books.ts~250Upload, dedup, metadata extraction
server/src/services/book-processor.ts~200EPUB/PDF metadata + cover extraction

Sync Engine

FileLinesPurpose
sync-engine/src/lww.ts~30Last-Writer-Wins merge
sync-engine/src/set.ts~80Tombstone set merge (permanent deletes)
sync-engine/src/queue.ts~40Offline dedup queue

Shared

FileLinesPurpose
shared/src/types.ts~200All domain types
shared/src/validators.ts~30022 Zod schemas for API validation
shared/src/constants.ts~50Highlight colors, TTS engines, limits