diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d558731 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +.DS_Store +npm-debug.log* +.yarn/ +.pnp.* diff --git a/data/sampleBooks.js b/data/sampleBooks.js new file mode 100644 index 0000000..fbeb4e5 --- /dev/null +++ b/data/sampleBooks.js @@ -0,0 +1,37 @@ +export const sampleBooks = [ + { + id: "alice-wonderland", + title: "Alice's Adventures in Wonderland", + author: "Lewis Carroll", + description: "A curious girl tumbles down the rabbit hole and navigates a world of whimsical logic.", + text: `Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do. +Once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it. +So she was considering in her own mind whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies. +Suddenly a White Rabbit with pink eyes ran close by her. There was nothing so very remarkable in that. +But when the Rabbit actually took a watch out of its waistcoat-pocket, and looked at it, and then hurried on, Alice started to her feet. +She thought it very curious that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it. +Burning with curiosity, she ran across the field after it, and was just in time to see it pop down a large rabbit-hole under the hedge. +In another moment down went Alice after it, never once considering how in the world she was to get out again.` + }, + { + id: "sherlock-study-scarlet", + title: "A Study in Scarlet", + author: "Arthur Conan Doyle", + description: "Sherlock Holmes and Dr. Watson unite for the first time to solve a puzzling murder.", + text: `In the year 1878 I took my degree of Doctor of Medicine of the University of London, and proceeded to Netley to go through the course prescribed for surgeons in the army. +Having completed my studies there, I was duly attached to the Fifth Northumberland Fusiliers as Assistant Surgeon. +The regiment was stationed in India at the time, and before I could join it, the second Afghan war had broken out. +On landing at Bombay, I learned that my corps had advanced through the passes, and was already deep in the enemy's country. +I followed, however, with many other officers who were in the same situation as myself, and succeeded in reaching Candahar in safety, where I found my regiment, and at once entered upon my new duties.` + }, + { + id: "frankenstein", + title: "Frankenstein", + author: "Mary Shelley", + description: "A scientist's ambition leads to the creation of life—and an unforeseen horror.", + text: `You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings. +I arrived here yesterday, and my first task is to assure my dear sister of my welfare and increasing confidence in the success of my undertaking. +I am already far north of London, and as I walk in the streets of Petersburgh, I feel a cold northern breeze play upon my cheeks, which braces my nerves and fills me with delight. +Do you understand this feeling? This breeze, which has travelled from the regions towards which I am advancing, gives me a foretaste of those icy climes.` + } +]; diff --git a/docs/product_spec.md b/docs/product_spec.md new file mode 100644 index 0000000..a5abf8e --- /dev/null +++ b/docs/product_spec.md @@ -0,0 +1,285 @@ +# Type-to-Read Novel App — Product Spec (v0.1) + +## 1. One-liner + +A distraction-free reading app where users *type to progress* through a book, turning passive reading into active recall to improve comprehension, retention, and focus. + +--- + +## 2. Goals & Non-Goals + +**Primary goals (MVP):** + +- Improve reading focus and comprehension via type-to-advance interaction. +- Provide immediate, low-friction feedback (accuracy, WPM, stumble words). +- Support DRM-free imports (TXT/EPUB) and a curated public-domain library. + +**Secondary goals (v1.x):** + +- Vocabulary tracking + spaced repetition for "stumble words." +- Lightweight annotations (highlights, notes). +- Basic class/coach sharing: progress snapshots, not raw text. + +**Non-goals (MVP):** + +- Hosting copyrighted/DRM-protected books. +- Social network, comments, or live co-reading. +- Heavy gamification or badges beyond simple streaks/goals. + +--- + +## 3. Target Users & Use Cases + +**Personas:** + +- **Focused Reader**: wants to reduce doom-scroll attention drift. +- **Language Learner**: needs vocabulary support and pacing. +- **Educator/Coach**: assigns chapters, monitors progress trends. + +**Core scenarios:** + +1. Import a DRM-free EPUB and complete a 20-minute focused session. +2. Read in small chunks (5–15 words) and auto-advance on correct typing. +3. Review "stumble words" (frequent errors/hesitations) post-session. +4. Toggle assistive features (font, spacing, overlays, TTS per sentence). + +--- + +## 4. Success Metrics (MVP) + +- **Activation**: ≥70% of new users complete onboarding + first 10-minute session. +- **Focus proxy**: median chunk dwell time variance ↓ vs first session (learning curve). +- **Learning proxy**: ≥20% reduction in repeat errors on the same chapter within 3 sessions. +- **Retention**: D7 return rate ≥25% for first-time readers. + +--- + +## 5. Functional Requirements (MVP) + +1. **Library & Import** + - Import `.txt` and `.epub` (no DRM). Parse into chapters → paragraphs → sentences → token chunks (5–15 words, configurable). + - Built-in catalog of public-domain titles (e.g., Gutenberg mirror references only; fetch and process client-side where possible). +2. **Typing Loop** + - Show next chunk; user types; fuzzy-match tolerance based on Damerau–Levenshtein distance and word alignment. + - Auto-advance on match ≥ threshold (default 95% per-word, normalized for punctuation and case). + - Real-time indicators: per-word correctness, caret position, optional predictive greyed text (Preview Mode). +3. **Feedback & Telemetry** + - Per-session stats: WPM, accuracy, time-on-task, error heatmap per token. + - "Stumble words" captured when (i) edit distance > threshold or (ii) dwell time > X ms. + - Post-session review list with quick lookup/flashcard actions. +4. **Assistive Features** + - Fonts: System, Dyslexia-friendly (OpenDyslexic), serif/sans; adjustable size, letter/word spacing, line height. + - Color themes: light, dark, sepia; optional color overlays. + - TTS (per sentence) with playback speed and voice selection (local/browser API where available). + - Translation/dictionary inline popover (local/offline dictionaries preferred; pluggable provider). +5. **Pacing & Modes** + - **Flow Mode**: hide upcoming characters; reveal only as typed. + - **Preview Mode**: display full chunk; grey out next words until typed. + - Adjustable chunk length or target WPM; adaptive chunking based on recent error/dwell. +6. **Annotations (v1.0 minimal)** + - Highlight selection; add note; export notes per book as markdown/CSV. +7. **Privacy & Storage** + - By default, keep full text client-side (IndexedDB). Sync (optional) stores *metadata only* (progress, stats, word lists); never store copyrighted text. + +--- + +## 6. Non-Functional Requirements + +- **Performance**: typing feedback within ≤16ms frame budget; initial book parse <5s for 1MB EPUB on mid-tier laptop; service worker for offline use. +- **Security**: text remains local unless user opts in; PII-free analytics; encrypted at rest for cloud-synced metadata. +- **Accessibility (WCAG 2.2 AA)**: keyboard-only navigation, ARIA roles, sufficient contrast, font/spacing controls, screen-reader compatibility. +- **Cross-platform**: modern Chromium/Firefox/Safari; PWA install; mobile-first responsive design. + +--- + +## 7. System Architecture (MVP) + +- **Client (Web/PWA)**: React (or Svelte) UI, Web Workers for diff/align; IndexedDB for text and progress; Service Worker for offline. +- **Optional Backend**: Lightweight API for auth, settings sync, and telemetry aggregation (no copyrighted text). +- **Integrations**: TTS via Web Speech API; optional dictionary provider; EPUB.js (or custom) for parsing. + +**High-level flow:** + +1. Import/Select Book → Parse → Chunkify → Session Engine. +2. Typing Engine (worker): tokenization → fuzzy match → scoring → UI events. +3. Telemetry: per token event → session roll-up → local store → (optional) sync. + +--- + +## 8. Data Model (MVP) + +```yaml +User: + id: string + settings: + theme: enum{light,dark,sepia} + font: enum{system,serif,sans,opendyslexic} + spacing: {letter:number, word:number, line:number} + tts: {enabled:boolean, rate:number, voice:string} + privacy: {cloudSync:boolean} + +Book: + id: string + title: string + author: string + source: enum{imported,public_domain} + manifest: {chapters: ChapterRef[]} + local_path: string # IndexedDB key + +ChapterRef: + id: string + startOffset: number + endOffset: number + +Session: + id: string + userId: string + bookId: string + chapterId: string + startedAt: datetime + durationMs: number + stats: {wpm:number, accuracy:number, chunks:number} + +TokenEvent: + id: string + sessionId: string + tokenId: string + typed:string + expected:string + dtMs:number + editDistance:number + correct:boolean + +StumbleWord: + userId: string + term:string + count:int + lastSeenAt: datetime +``` + +--- + +## 9. Core Algorithms + +**Chunking** + +- Tokenize sentences → words → merge into 5–15 word windows respecting punctuation and hyphenation; adaptive shrink on recent error spikes. + +**Fuzzy Matching (Worker)** + +- Normalize: lowercase, strip punctuation (configurable), collapse whitespace. +- Align typed to expected with Damerau–Levenshtein; per-word alignment by greedy split + dynamic programming fallback. +- Score = 1 − (editDistance / max(len(expected), 1)). +- Advance when all words score ≥ threshold OR global score ≥ threshold and no *critical* words (proper nouns) below secondary threshold. + +**Stumble Detection** + +- Mark if (editDistance > τ) OR (dtMs > μ + k·σ over last N tokens) OR backspace burst > B. +- Add to user’s StumbleWord with lemmatized key. + +--- + +## 10. UX Flows (MVP) + +1. **Onboarding**: pick a sample chapter → pick font/theme → 60-sec tutorial → first session starts. +2. **Reading**: minimal chrome (progress bar, time, accuracy); ESC → quick settings; hold SPACE to peek next chunk (if in Flow Mode). +3. **Session End**: stats, stumble list, quick review (TTS or re-type), set next goal. +4. **Library**: grid of books with last progress; import button; search by title/author. + +--- + +## 11. Accessibility & Internationalization + +- RTL support; IME-friendly input (CJK); diacritics-aware matching; per-language tokenization (TinySegmenter/Intl.Segmenter where available). +- Keyboard shortcuts: Tab to advance (when eligible), Ctrl+/ help, +/- font size. + +--- + +## 12. Content & Licensing + +- Public domain sources (e.g., Gutenberg). For user imports, process locally; do not upload or cache server-side without explicit consent. +- Provide license attributions for included dictionaries or TTS voices if required. + +--- + +## 13. Analytics & Privacy + +- Collect only aggregate, non-text telemetry (session durations, error rates) unless user opts in. +- Provide local-only mode; clear data control; export/delete account functions. + +--- + +## 14. Edge Cases & Risks + +- **IME composition**: ensure no premature validation while composing characters. +- **Hyphenation/ligatures**: normalize to canonical forms; avoid punishing typographic quirks. +- **Performance**: long chapters must stream chunk data; precompute next N chunks in worker. +- **Legal**: never store copyrighted text in cloud; validate import to detect DRM and warn. + +--- + +## 15. Roadmap + +- **MVP (4–6 weeks dev)**: import + typing loop + stats + minimal assistive settings + local storage + 3 sample books. +- **v1.1**: spaced repetition for stumble words; notes export; basic TTS. +- **v1.2**: educator snapshot links; simple assignments; share progress (no text). +- **v1.3**: mobile polish, offline dictionary packs, translation gloss. + +--- + +## 16. Acceptance Criteria (MVP) + +- Can import a DRM-free `.epub` and complete a session with real-time fuzzy matching; advance occurs with ≤150ms latency on a 5-year-old laptop. +- Stats screen shows WPM, accuracy, and ≥3 most frequent stumble words. +- User can toggle font, spacing, theme, chunk length; settings persist. +- Works offline after first load; re-opens last book at exact position. +- Text never leaves device in local-only mode; telemetry opt-in is off by default. + +--- + +## 17. Interfaces (Sketch) + +**Parsing Service (client)** + +```ts +parseEpub(file: File): Promise +chunkify(bookId: string, options: {chunkSize:number; adapt:boolean}): AsyncIterable +``` + +**Typing Engine (worker)** + +```ts +initSession(bookId: string, chapterId: string): Promise +score(typed: string, expected: string, opts: ScoreOpts): ScoreResult +next(): Chunk // returns next expected chunk +``` + +**Sync API (optional backend)** + +```http +POST /v1/auth/signup | login +GET /v1/user/settings +PUT /v1/user/settings +GET /v1/books/:id/progress (metadata only) +PUT /v1/books/:id/progress +POST /v1/telemetry/sessions +``` + +--- + +## 18. Open Questions + +1. What default chunk size best balances flow vs. friction for different languages? +2. Should proper nouns be "critical words" by default? +3. How strict should punctuation be in scoring for language learners? +4. Which offline dictionary licenses fit redistribution in a PWA? +5. Is mobile haptic feedback desirable or distracting during typing? + +--- + +## 19. Glossary + +- **Chunk**: contiguous 5–15 word span presented for typing. +- **Stumble Word**: term frequently mistyped or hesitated on; eligible for review. +- **Flow Mode**: reveal only what the user correctly types; no look-ahead. +- **Preview Mode**: show full chunk but gate progress on correctness. diff --git a/index.html b/index.html new file mode 100644 index 0000000..40b2277 --- /dev/null +++ b/index.html @@ -0,0 +1,44 @@ + + + + + + Type-to-Read + + + + + + + +
+ + +
+ +
+

Type-to-Read needs a local server

+

+ If you opened this file directly from your file system and the interface did not load, + please run a static server from the project folder and visit the page at + http://localhost:3000/. For example: +

+
python -m http.server 3000
+

+ Afterwards refresh the browser. This extra step is required because modern browsers block + module scripts when they are loaded via the file:// protocol. +

+
+ +
+ +
+ + + + diff --git a/readme.txt b/readme.txt index 2e85aed..7bf2ad3 100644 --- a/readme.txt +++ b/readme.txt @@ -1 +1,32 @@ -Sample text +# Type-to-Read Novel App + +This repository contains a zero-dependency browser MVP of the Type-to-Read Novel App. It follows the product requirements captured in [`docs/product_spec.md`](docs/product_spec.md) and prioritises being runnable anywhere without a build toolchain. + +## Running the MVP + +No package installation or build step is required; everything is bundled into plain HTML, CSS, and JavaScript files. **However, you must load the app through a local web server**—modern browsers block the module-based scripts if you double-click `index.html` and open it via the `file://` protocol. + +From the repository root run: + +```bash +python -m http.server 3000 +``` + +Then visit `http://localhost:3000/` in your browser. Any similar static server tool (for example `npx serve`, `ruby -run -ehttpd .`, or `python -m http.server`) works if you prefer a different command or port. + +If you accidentally open the file directly and see a blank page, the in-page notice will remind you to start a local server. Once the page loads correctly, the app seeds the sample library automatically. You can import additional `.txt` books via **Library → Import Text**, and your progress is saved in `localStorage`, so subsequent runs will pick up where you left off—even offline. + +## Feature overview + +* Curated sample library from public-domain classics plus support for importing `.txt` files. +* Type-to-advance reader with adaptive chunking, fuzzy accuracy scoring, stumble-word surfacing, and configurable thresholds. +* Session summaries with rolling statistics (accuracy, WPM, duration) and a stumble-word leaderboard. +* Reader settings for theme, fonts (including OpenDyslexic), spacing, and chunk sizing with persistence via `localStorage`. +* Keyboard-friendly navigation and offline-capable storage—once loaded, the app and your data remain available without a network connection. + +Refer to the product specification for the broader roadmap and non-functional requirements targeted by the MVP. + +## Branch structure + +All development branches are now merged into the `main` branch. If you cloned this repository before the consolidation, make sure to `git checkout main` +and pull the latest changes. The historical `work` branch currently points to the same tip for continuity but will no longer receive updates. diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..72d837f --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,721 @@ +import { sampleBooks } from "../data/sampleBooks.js"; +import { + chunkify, + highlightDifferences, + scoreChunk, + stripPunctuation, +} from "./text.js"; +import { loadState, saveState, getDefaultState } from "./storage.js"; + +const app = document.getElementById("app"); +const fallback = document.getElementById("app-fallback"); +if (fallback) { + fallback.remove(); +} +const navButtons = Array.from(document.querySelectorAll(".nav-btn")); + +let state = initializeState(); +let session = null; + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function initializeState() { + const loaded = loadState(); + const hasLibrary = loaded.library && loaded.library.length > 0; + const allowedFonts = new Set(["sans", "serif", "opendyslexic"]); + if (!allowedFonts.has(loaded.settings.fontFamily)) { + loaded.settings.fontFamily = "sans"; + } + if (!hasLibrary) { + const seeded = sampleBooks.map((book, index) => ({ + ...book, + source: "sample", + createdAt: Date.now() - index * 1000, + lastChunk: 0, + totalChunks: 0, + lastReadAt: null, + completed: false, + })); + loaded.library = seeded; + } else { + loaded.library = loaded.library.map((book) => ({ + lastChunk: 0, + totalChunks: 0, + lastReadAt: null, + completed: false, + ...book, + })); + } + + applySettings(loaded.settings); + return loaded; +} + +function applySettings(settings) { + document.body.dataset.theme = settings.theme; + document.body.dataset.font = settings.fontFamily; + document.documentElement.style.setProperty("--reader-size", `${1.35 * settings.fontScale}rem`); + document.documentElement.style.setProperty("--reader-line", settings.lineHeight); + document.documentElement.style.setProperty("--reader-letter", `${settings.letterSpacing}em`); + document.documentElement.style.setProperty("--reader-word", `${settings.wordSpacing}em`); +} + +function setView(view) { + state.lastView = view; + saveState(state); + render(); +} + +navButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const view = btn.dataset.view; + if (view === "reader" && !state.activeBookId) { + showDialog("Choose a book from your library before starting a session."); + return; + } + setView(view); + }); +}); + +function render() { + navButtons.forEach((btn) => { + btn.setAttribute("aria-current", btn.dataset.view === state.lastView ? "page" : "false"); + }); + + switch (state.lastView) { + case "library": + renderLibrary(); + break; + case "reader": + renderReader(); + break; + case "stats": + renderStats(); + break; + case "settings": + renderSettings(); + break; + default: + state.lastView = "library"; + renderLibrary(); + } +} + +function renderLibrary() { + const books = state.library; + const activeId = state.activeBookId; + + const tiles = books + .map((book) => { + const completedChunks = Math.min(book.lastChunk || 0, book.totalChunks || 0); + const progress = book.totalChunks + ? Math.min(100, Math.round((completedChunks / book.totalChunks) * 100)) + : 0; + const progressLabel = book.completed + ? "Finished" + : book.totalChunks + ? `${progress}% read` + : "Not started"; + const actionLabel = book.completed ? "Restart" : book.id === activeId ? "Resume" : "Start"; + return ` +
+
+

${escapeHtml(book.title)}

+
${escapeHtml(book.author)}
+
+

${escapeHtml(book.description || "")}

+
${progressLabel}
+
+ + +
+
+ `; + }) + .join(""); + + app.innerHTML = ` +
+
+

Your Library

+

Import plain text books or use the curated public-domain selections.

+
+
${tiles}
+
+
+

Import a book

+
+ + + + +
+
+ `; + + const importForm = document.getElementById("import-form"); + importForm.addEventListener("submit", handleImportSubmit); + + app.querySelectorAll("[data-action='start']").forEach((btn) => + btn.addEventListener("click", () => startSession(btn.dataset.id)) + ); + + app.querySelectorAll("[data-action='remove']").forEach((btn) => + btn.addEventListener("click", () => removeBook(btn.dataset.id)) + ); + + app.querySelectorAll(".book-tile").forEach((tile) => + tile.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + startSession(tile.dataset.id); + } + }) + ); +} + +function handleImportSubmit(event) { + event.preventDefault(); + const form = event.target; + const fileInput = form.querySelector("input[type='file']"); + const file = fileInput.files[0]; + if (!file) { + showDialog("Please choose a text file to import."); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result; + if (!text || typeof text !== "string" || stripPunctuation(text).length < 10) { + showDialog("The selected file does not contain enough text."); + return; + } + const title = form.title.value.trim() || file.name.replace(/\.txt$/i, ""); + const author = form.author.value.trim() || "Unknown"; + addBook({ + id: createId(title), + title, + author, + description: "Imported text", + text, + source: "imported", + }); + fileInput.value = ""; + form.title.value = ""; + form.author.value = ""; + showDialog(`Imported "${title}" successfully.`); + }; + reader.onerror = () => { + showDialog("Failed to read the selected file."); + }; + reader.readAsText(file); +} + +function createId(text) { + const base = + stripPunctuation(text) + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || `book-${Date.now()}`; + let candidate = base; + let counter = 2; + const ids = new Set(state.library.map((book) => book.id)); + while (ids.has(candidate)) { + candidate = `${base}-${counter}`; + counter += 1; + } + return candidate; +} + +function addBook(book) { + state.library.unshift({ + ...book, + createdAt: Date.now(), + lastChunk: 0, + totalChunks: 0, + lastReadAt: null, + completed: false, + }); + saveState(state); + renderLibrary(); +} + +function removeBook(bookId) { + const index = state.library.findIndex((book) => book.id === bookId); + if (index === -1) return; + if (state.library[index].source === "sample") { + showDialog("Sample books cannot be removed."); + return; + } + state.library.splice(index, 1); + if (state.activeBookId === bookId) { + state.activeBookId = null; + session = null; + } + saveState(state); + renderLibrary(); +} + +function startSession(bookId) { + const book = state.library.find((item) => item.id === bookId); + if (!book) return; + + state.activeBookId = bookId; + const chunkSize = Math.max(3, Math.min(25, state.settings.chunkSize)); + const chunks = chunkify(book.text, chunkSize, true); + if (!chunks.length) { + showDialog("This book does not contain any readable text chunks yet."); + return; + } + const resumeIndex = book.completed + ? 0 + : Math.min(book.lastChunk || 0, Math.max(chunks.length - 1, 0)); + if (book.completed) { + book.lastChunk = 0; + book.completed = false; + } + book.totalChunks = chunks.length; + session = { + id: `session-${Date.now()}`, + bookId, + chunks, + chunkIndex: resumeIndex, + typed: "", + startedAt: Date.now(), + chunkStartedAt: Date.now(), + entries: [], + completed: false, + }; + state.lastView = "reader"; + saveState(state); + render(); +} + +function getActiveSession() { + if (!session && state.activeBookId) { + startSession(state.activeBookId); + } + return session; +} + +function renderReader() { + const activeSession = getActiveSession(); + if (!activeSession) { + app.innerHTML = ` +
+

No book selected

+

Select a title from your library to begin typing.

+ +
+ `; + document.getElementById("back-to-library").addEventListener("click", () => setView("library")); + return; + } + + const book = state.library.find((item) => item.id === activeSession.bookId); + const expected = activeSession.chunks[activeSession.chunkIndex] || ""; + const typed = activeSession.typed || ""; + const { accuracy } = scoreChunk(expected, typed); + const highlight = highlightDifferences(expected, typed); + const canAdvance = accuracy >= state.settings.accuracyThreshold && stripPunctuation(typed).length >= stripPunctuation(expected).length * 0.6; + const percent = (() => { + const bookProgress = state.library.find((item) => item.id === activeSession.bookId); + if (!bookProgress || !bookProgress.totalChunks) return 0; + const completedChunks = Math.min(bookProgress.lastChunk || 0, bookProgress.totalChunks); + return Math.min(100, Math.round((completedChunks / bookProgress.totalChunks) * 100)); + })(); + + const highlightHtml = highlight + .map(({ text, status }) => { + if (status === "space") return text; + return `${escapeHtml(text)}`; + }) + .join(""); + + const stumbleList = Object.entries(state.stumbleWords) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 6) + .map(([word, info]) => `${escapeHtml(word)} · ${info.count}`) + .join(" "); + + app.innerHTML = ` +
+
+
+
+

${escapeHtml(book.title)}

+
${escapeHtml(book.author)}
+
+
Progress: ${percent}%
+
+
+
${highlightHtml}
+ + +
+ + + +
+ ${ + stumbleList + ? `

Recent stumble words

${stumbleList}
` + : "" + } +
+ `; + + const typeArea = document.getElementById("type-area"); + typeArea.focus(); + typeArea.selectionStart = typeArea.value.length; + typeArea.selectionEnd = typeArea.value.length; + + typeArea.addEventListener("input", () => { + activeSession.typed = typeArea.value; + renderReader(); + }); + + document.getElementById("advance-btn").addEventListener("click", () => { + if (!canAdvance) return; + advanceChunk(activeSession, { accuracy }); + }); + + document.getElementById("finish-btn").addEventListener("click", () => finishSession(false)); + document.getElementById("reset-input").addEventListener("click", () => { + activeSession.typed = ""; + activeSession.chunkStartedAt = Date.now(); + renderReader(); + }); +} + +function advanceChunk(activeSession, { accuracy }) { + const expected = activeSession.chunks[activeSession.chunkIndex] || ""; + const typed = activeSession.typed; + const now = Date.now(); + const duration = now - activeSession.chunkStartedAt; + const tokenCount = stripPunctuation(expected).split(" ").filter(Boolean).length; + const wpm = duration > 0 ? Math.round((tokenCount / (duration / 1000 / 60)) * 100) / 100 : 0; + + const entry = { + chunkIndex: activeSession.chunkIndex, + expected, + typed, + accuracy, + duration, + wpm, + }; + activeSession.entries.push(entry); + + if (accuracy < state.settings.accuracyThreshold) { + registerStumbles(expected, typed); + } + + activeSession.chunkIndex += 1; + activeSession.typed = ""; + activeSession.chunkStartedAt = now; + + if (activeSession.chunkIndex >= activeSession.chunks.length) { + finishSession(true); + } else { + updateProgress(activeSession); + renderReader(); + } +} + +function registerStumbles(expected, typed) { + const expectedWords = expected.split(/\s+/); + const typedWords = typed.split(/\s+/); + expectedWords.forEach((word, index) => { + const expectedClean = stripPunctuation(word); + if (!expectedClean) return; + const typedClean = stripPunctuation(typedWords[index] || ""); + if (expectedClean !== typedClean) { + const record = state.stumbleWords[expectedClean] || { count: 0, lastSeenAt: null }; + record.count += 1; + record.lastSeenAt = Date.now(); + state.stumbleWords[expectedClean] = record; + } + }); +} + +function updateProgress(activeSession) { + const book = state.library.find((item) => item.id === activeSession.bookId); + if (!book) return; + book.totalChunks = activeSession.chunks.length; + const nextIndex = Math.min(activeSession.chunkIndex, activeSession.chunks.length); + book.lastChunk = nextIndex; + book.completed = nextIndex >= activeSession.chunks.length && activeSession.chunks.length > 0; + book.lastReadAt = Date.now(); + saveState(state); +} + +function finishSession(completedAll) { + if (!session) return; + const activeSession = session; + const now = Date.now(); + const durationMs = now - activeSession.startedAt; + const averageAccuracy = + activeSession.entries.reduce((sum, item) => sum + item.accuracy, 0) / + (activeSession.entries.length || 1); + const averageWpm = + activeSession.entries.reduce((sum, item) => sum + item.wpm, 0) / + (activeSession.entries.length || 1); + + const summary = { + id: activeSession.id, + bookId: activeSession.bookId, + startedAt: activeSession.startedAt, + durationMs, + accuracy: Number(averageAccuracy.toFixed(3)), + wpm: Number(averageWpm.toFixed(1)), + chunks: activeSession.entries.length, + completedAll, + }; + + state.sessions.unshift(summary); + updateProgress(activeSession); + session = null; + + saveState(state); + showDialog("Session saved. Great job!", () => { + setView("stats"); + }); +} + +function renderStats() { + const totals = state.sessions.reduce( + (acc, session) => { + acc.totalSessions += 1; + acc.totalDuration += session.durationMs; + acc.totalChunks += session.chunks; + acc.avgAccuracy += session.accuracy; + acc.avgWpm += session.wpm; + return acc; + }, + { totalSessions: 0, totalDuration: 0, totalChunks: 0, avgAccuracy: 0, avgWpm: 0 } + ); + + const averageAccuracy = totals.totalSessions ? totals.avgAccuracy / totals.totalSessions : 0; + const averageWpm = totals.totalSessions ? totals.avgWpm / totals.totalSessions : 0; + const averageDuration = totals.totalSessions ? totals.totalDuration / totals.totalSessions : 0; + + const formatDuration = (ms) => { + const minutes = Math.floor(ms / 60000); + const seconds = Math.round((ms % 60000) / 1000); + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; + }; + + const rows = state.sessions + .slice(0, 12) + .map((session) => { + const book = state.library.find((item) => item.id === session.bookId); + const title = book ? book.title : "Unknown"; + return ` + + ${new Date(session.startedAt).toLocaleString()} + ${escapeHtml(title)} + ${Math.round(session.accuracy * 100)}% + ${session.wpm} + ${session.chunks} + ${formatDuration(session.durationMs)} + ${session.completedAll ? "Yes" : "Partial"} + + `; + }) + .join(""); + + const stumbleEntries = Object.entries(state.stumbleWords) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 10) + .map(([word, info]) => `${escapeHtml(word)} · ${info.count}`) + .join(" "); + + app.innerHTML = ` +
+

Reading Stats

+
+
+
Total sessions
+
${totals.totalSessions}
+
+
+
Avg accuracy
+
${Math.round(averageAccuracy * 100)}%
+
+
+
Avg WPM
+
${Math.round(averageWpm)}
+
+
+
Avg session length
+
${formatDuration(averageDuration)}
+
+
+
+
+

Recent sessions

+ ${ + rows + ? `
${rows}
WhenBookAccuracyWPMChunksDurationComplete
` + : "

No sessions yet. Start typing to see your progress.

" + } +
+
+

Frequent stumble words

+ ${stumbleEntries || "

Great work! No stumble words recorded yet.

"} +
+ `; +} + +function renderSettings() { + const settings = state.settings; + app.innerHTML = ` +
+

Reader Settings

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+ `; + + const form = document.getElementById("settings-form"); + form.addEventListener("submit", (event) => { + event.preventDefault(); + const formData = new FormData(form); + const updated = { + theme: formData.get("theme"), + fontFamily: formData.get("fontFamily"), + fontScale: Number(formData.get("fontScale")), + lineHeight: Number(formData.get("lineHeight")), + letterSpacing: Number(formData.get("letterSpacing")), + wordSpacing: Number(formData.get("wordSpacing")), + chunkSize: Number(formData.get("chunkSize")), + accuracyThreshold: Number(formData.get("accuracyThreshold")), + mode: formData.get("mode"), + }; + state.settings = { ...state.settings, ...updated }; + applySettings(state.settings); + saveState(state); + showDialog("Settings saved."); + }); + + document.getElementById("reset-settings").addEventListener("click", () => { + state.settings = getDefaultState().settings; + applySettings(state.settings); + saveState(state); + renderSettings(); + showDialog("Settings restored to defaults."); + }); +} + +function showDialog(message, onDismiss) { + const root = document.getElementById("dialog-root"); + const safeMessage = escapeHtml(message); + root.innerHTML = ` +
+

${safeMessage}

+
+ +
+
+ `; + let timer = null; + const dismiss = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + root.innerHTML = ""; + if (typeof onDismiss === "function") { + onDismiss(); + } + }; + document.getElementById("dialog-ok").addEventListener("click", dismiss); + timer = setTimeout(dismiss, 4000); +} + +window.addEventListener("beforeunload", () => { + saveState(state); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && session) { + finishSession(false); + } +}); + +render(); diff --git a/scripts/storage.js b/scripts/storage.js new file mode 100644 index 0000000..86b0d14 --- /dev/null +++ b/scripts/storage.js @@ -0,0 +1,63 @@ +const STORAGE_KEY = "type-to-read-state-v1"; + +const clone = (value) => { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +}; + +const defaultState = { + settings: { + theme: "light", + fontFamily: "sans", + fontScale: 1, + letterSpacing: 0.02, + wordSpacing: 0.08, + lineHeight: 1.8, + mode: "flow", + chunkSize: 9, + accuracyThreshold: 0.92, + }, + library: [], + sessions: [], + stumbleWords: {}, + activeBookId: null, + lastView: "library", +}; + +export function loadState() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + return clone(defaultState); + } + const parsed = JSON.parse(stored); + return { + ...clone(defaultState), + ...parsed, + settings: { ...defaultState.settings, ...(parsed.settings || {}) }, + stumbleWords: parsed.stumbleWords || {}, + }; + } catch (error) { + console.warn("Failed to parse saved state", error); + return clone(defaultState); + } +} + +export function saveState(state) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.warn("Failed to persist state", error); + } +} + +export function resetState() { + localStorage.removeItem(STORAGE_KEY); + return clone(defaultState); +} + +export function getDefaultState() { + return clone(defaultState); +} diff --git a/scripts/text.js b/scripts/text.js new file mode 100644 index 0000000..5a3dbb8 --- /dev/null +++ b/scripts/text.js @@ -0,0 +1,177 @@ +export function normalizeWhitespace(text) { + return text.replace(/\s+/g, " ").trim(); +} + +export function sentenceSplit(text) { + return text + .replace(/([.!?])\s+/g, "$1|") + .split("|") + .map((sentence) => sentence.trim()) + .filter(Boolean); +} + +export function tokenize(sentence) { + return sentence + .replace(/[\n\r]+/g, " ") + .split(/\s+/) + .map((token) => token.trim()) + .filter(Boolean); +} + +export function chunkify(text, desiredSize, adaptive = true) { + const sentences = sentenceSplit(text); + const chunks = []; + let carry = []; + + const pushChunk = (tokens) => { + if (!tokens.length) return; + chunks.push(tokens.join(" ")); + }; + + for (const sentence of sentences) { + const tokens = tokenize(sentence); + let cursor = 0; + while (cursor < tokens.length) { + const remaining = tokens.length - cursor; + const sliceSize = Math.min( + desiredSize, + remaining < Math.ceil(desiredSize / 2) && carry.length + ? desiredSize - carry.length + : desiredSize + ); + const piece = tokens.slice(cursor, cursor + sliceSize); + carry.push(...piece); + if (carry.length >= desiredSize) { + pushChunk(carry.splice(0)); + } + cursor += sliceSize; + } + } + + if (carry.length) { + pushChunk(carry); + } + + if (!adaptive || chunks.length < 2) { + return chunks; + } + + const averageLength = + chunks.reduce((sum, chunk) => sum + tokenize(chunk).length, 0) / chunks.length; + + if (averageLength < desiredSize * 0.75) { + return mergeSmallChunks(chunks, desiredSize); + } + + return chunks; +} + +function mergeSmallChunks(chunks, desiredSize) { + const merged = []; + let buffer = []; + + for (const chunk of chunks) { + const tokens = tokenize(chunk); + if (buffer.length + tokens.length <= desiredSize + Math.floor(desiredSize / 2)) { + buffer = buffer.concat(tokens); + continue; + } + if (buffer.length) { + merged.push(buffer.join(" ")); + } + buffer = tokens; + } + + if (buffer.length) { + merged.push(buffer.join(" ")); + } + + return merged; +} + +export function stripPunctuation(input) { + return input + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/["'.,!?;:()\-\[\]{}]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +export function damerauLevenshtein(a, b) { + const lenA = a.length; + const lenB = b.length; + const maxDist = lenA + lenB; + const dist = Array.from({ length: lenA + 2 }, () => new Array(lenB + 2).fill(0)); + const da = new Map(); + + dist[0][0] = maxDist; + + for (let i = 0; i <= lenA; i += 1) { + dist[i + 1][0] = maxDist; + dist[i + 1][1] = i; + } + + for (let j = 0; j <= lenB; j += 1) { + dist[0][j + 1] = maxDist; + dist[1][j + 1] = j; + } + + for (let i = 1; i <= lenA; i += 1) { + let db = 0; + for (let j = 1; j <= lenB; j += 1) { + const i1 = da.get(b[j - 1]) || 0; + const j1 = db; + let cost = 1; + if (a[i - 1] === b[j - 1]) { + cost = 0; + db = j; + } + dist[i + 1][j + 1] = Math.min( + dist[i][j] + cost, + dist[i + 1][j] + 1, + dist[i][j + 1] + 1, + dist[i1][j1] + (i - i1 - 1) + 1 + (j - j1 - 1) + ); + } + da.set(a[i - 1], i); + } + + return dist[lenA + 1][lenB + 1]; +} + +export function scoreChunk(expected, typed) { + const cleanExpected = stripPunctuation(expected); + const cleanTyped = stripPunctuation(typed); + if (!cleanExpected) { + return { accuracy: 1, distance: 0 }; + } + const distance = damerauLevenshtein(cleanExpected, cleanTyped); + const accuracy = Math.max(0, 1 - distance / Math.max(cleanExpected.length, 1)); + return { accuracy, distance }; +} + +export function highlightDifferences(expected, typed) { + const expTokens = expected.split(/(\s+)/); + const typedTokens = typed.split(/(\s+)/); + const result = []; + + for (let i = 0; i < expTokens.length; i += 1) { + const token = expTokens[i]; + if (/^\s+$/.test(token)) { + result.push({ text: token, status: "space" }); + continue; + } + const typedToken = typedTokens[i] || ""; + if (stripPunctuation(token) === stripPunctuation(typedToken)) { + result.push({ text: token, status: "correct" }); + } else if (stripPunctuation(typedToken)) { + result.push({ text: token, status: "error" }); + } else { + result.push({ text: token, status: "future" }); + } + } + + return result; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..74a2714 --- /dev/null +++ b/styles.css @@ -0,0 +1,346 @@ +:root { + --bg: #f6f8fb; + --surface: #ffffff; + --text: #1f2933; + --text-muted: #52606d; + --accent: #5465ff; + --accent-dark: #1f3fff; + --border: rgba(31, 41, 51, 0.08); + --radius-lg: 18px; + --radius-md: 12px; + --radius-sm: 8px; + color-scheme: light; +} + +[data-theme="dark"] { + --bg: #0f172a; + --surface: #111b2f; + --text: #f8fafc; + --text-muted: #cbd5f5; + --accent: #7c9bff; + --accent-dark: #b8ccff; + --border: rgba(148, 163, 184, 0.16); + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: var(--font-body, "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + line-height: 1.5; +} + +.fallback { + max-width: 42rem; + margin: 4rem auto; + padding: 2rem; + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.9); + color: #1f2933; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.12); + font-family: var(--font-body, "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); +} + +[data-theme="dark"] .fallback { + background: rgba(17, 27, 47, 0.9); + color: #e2e8f0; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.5); +} + +.fallback pre { + margin: 1rem 0; + padding: 0.75rem 1rem; + background: #0f172a; + color: #f8fafc; + border-radius: var(--radius-md); + overflow-x: auto; +} + +.fallback code { + font-family: "JetBrains Mono", "Fira Code", "Source Code Pro", ui-monospace, SFMono-Regular, Menlo, + Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +[data-font="serif"] { + --font-body: "Literata", "Georgia", "Times New Roman", serif; +} + +[data-font="sans"] { + --font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +[data-font="opendyslexic"] { + --font-body: "OpenDyslexic", "Atkinson Hyperlegible", "Arial", sans-serif; +} + +button { + font: inherit; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem clamp(1.5rem, 4vw, 3rem); + background: var(--surface); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); + position: sticky; + top: 0; + z-index: 10; +} + +.logo { + font-weight: 700; + font-size: 1.25rem; +} + +.main-nav { + display: flex; + gap: 0.5rem; +} + +.nav-btn { + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +.nav-btn[aria-current="page"], +.nav-btn:hover { + color: var(--accent-dark); + background: rgba(84, 101, 255, 0.1); +} + +.app-shell { + padding: clamp(1.5rem, 4vw, 3.5rem); + max-width: 1200px; + margin: 0 auto; + display: grid; + gap: 1.5rem; +} + +.card { + background: var(--surface); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06); + padding: clamp(1.25rem, 3vw, 2rem); +} + +.card h2 { + margin-top: 0; + margin-bottom: 0.75rem; +} + +.grid { + display: grid; + gap: 1rem; +} + +.grid.two { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.book-tile { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + cursor: pointer; + background: rgba(84, 101, 255, 0.05); + transition: transform 0.2s ease; +} + +.book-tile:hover, +.book-tile:focus-visible { + transform: translateY(-2px); + outline: none; + box-shadow: 0 12px 28px rgba(84, 101, 255, 0.18); +} + +.book-meta { + color: var(--text-muted); + font-size: 0.85rem; +} + +.primary-btn, +.secondary-btn { + padding: 0.75rem 1.2rem; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + font-weight: 600; + transition: transform 0.2s ease; +} + +.primary-btn { + background: var(--accent); + color: white; +} + +.primary-btn:hover { + transform: translateY(-1px); + background: var(--accent-dark); +} + +.secondary-btn { + background: rgba(82, 96, 109, 0.12); + color: var(--text); +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.form-row label { + display: flex; + flex-direction: column; + font-weight: 500; + gap: 0.3rem; +} + +input[type="range"] { + width: min(320px, 100%); +} + +textarea, +input[type="text"], +select { + padding: 0.6rem 0.7rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + font: inherit; + width: 100%; + background: var(--surface); + color: inherit; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +.stat-card { + padding: 1rem; + border-radius: var(--radius-md); + background: rgba(84, 101, 255, 0.1); + border: 1px solid rgba(84, 101, 255, 0.2); +} + +.stat-value { + font-size: 1.4rem; + font-weight: 700; +} + +.reader-shell { + display: grid; + gap: 1rem; +} + +.chunk-preview { + font-size: var(--reader-size, 1.4rem); + line-height: var(--reader-line, 1.8); + letter-spacing: var(--reader-letter, 0.02em); + word-spacing: var(--reader-word, 0.08em); + padding: 1.5rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + background: rgba(17, 27, 47, 0.04); + min-height: 180px; +} + +.chunk-preview[data-mode="flow"] span.future { + color: rgba(82, 96, 109, 0.35); +} + +.chunk-preview[data-mode="preview"] span.future { + color: rgba(82, 96, 109, 0.5); +} + +.chunk-preview span.correct { + color: #15803d; +} + +.chunk-preview span.error { + color: #dc2626; +} + +.type-input { + padding: 1rem; + border-radius: var(--radius-md); + border: 2px solid rgba(84, 101, 255, 0.35); + font-size: 1.1rem; + font-family: inherit; + letter-spacing: inherit; + line-height: inherit; + min-height: 120px; + resize: vertical; +} + +.feedback-bar { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.95rem; + color: var(--text-muted); +} + +.feedback-bar strong { + color: var(--accent-dark); +} + +.session-table { + width: 100%; + border-collapse: collapse; +} + +.session-table th, +.session-table td { + padding: 0.6rem 0.8rem; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.stumble-tag { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.6rem; + border-radius: 999px; + background: rgba(220, 38, 38, 0.12); + color: #b91c1c; + font-size: 0.85rem; +} + +@media (max-width: 720px) { + .app-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .main-nav { + width: 100%; + justify-content: space-between; + } + + .chunk-preview { + font-size: calc(var(--reader-size, 1.3rem) * 0.92); + } +}