- Add ReadingMark TipTap extension (transient, never saved to DB) that
renders the active TTS passage as <span class='reading-word'>
- Build a char→ProseMirror position map on read-start so boundary events
can pinpoint exact document positions
- Use onstart (fires on every utterance/voice) for reliable sentence-level
highlight; onboundary overrides with word-level when the voice supports it
- Auto-scroll the highlighted span into view (smooth, centred) on each update
- Strip readingWord marks from JSON alongside lintError before saving
- Guard all mark dispatches with applyingLints flag to suppress spurious
saves and lint re-checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Infinite loop (lint hammering server every 2s):
- Add applyingLints ref; set true before clearLintMarks() and
editor.view.dispatch(tr), false after
- onUpdate returns early when applyingLints is true, so applying
marks no longer reschedules a lint check or triggers a save
Popover disappearing before you can interact:
- Render via createPortal(…, document.body) — completely outside the
editor DOM, no stacking context or overflow clipping can interfere
- onMouseDown={e => e.preventDefault()} on the popover prevents the
editor from losing focus before the button click fires
- Removed setLintPopover(null) from the top of runLint — the popover
was being force-closed on every auto-check cycle
Debounce: 1.5 s → 3 s — checks after a genuine pause, not mid-word
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
useEffect(() => {...}, [runLint]) evaluated [runLint] immediately,
but runLint was declared with const on the next line — temporal dead
zone. Moved both effects to after the useCallback closes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- spellCheck={false} on EditorContent — LanguageTool's wavy marks
take over entirely, no double-squiggle confusion
- onUpdate schedules runLint() 1.5 s after the last keystroke via
lintDebounce ref; typing resets the timer so it only fires when
the writer pauses (naturally after finishing a word or sentence)
- runLintRef kept in sync with useEffect so the stale onUpdate
closure always calls the latest runLint
- Timer cleaned up on unmount
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server:
- New POST /api/lint/check proxies to LanguageTool API (public free
endpoint by default; override with LANGUAGETOOL_URL env var for
self-hosted instance)
- Returns trimmed match list: message, offset, length, replacements,
kind (spelling|grammar), ruleId
- Disables cosmetic rules that don't suit creative writing
- 20-second timeout; falls back with 502 on error
Frontend:
- LintMark: transient TipTap Mark extension (never saved to DB —
stripLintMarks() removes them from getJSON() before onChange fires)
- buildTextMap(): walks ProseMirror doc tree, builds parallel plain-
text string + position array so LanguageTool char offsets map back
to exact PM positions even across paragraphs / headings
- clearLintMarks() + runLint(): check → clear old marks → apply new
marks in one transaction, no flicker
- Click on underlined text → fixed-position popover showing the error
message + up to 6 one-click replacement buttons + Ignore / ✕
- Applying a replacement triggers an automatic re-check after 600 ms
- lintStatus: idle → checking → done → stale (when user edits)
- ABC toolbar button shows count badge when errors found, ✓ when clean
- Wavy red underline = spelling, wavy blue = grammar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Notes: per-story rich-text notes panel (right drawer) with TipTap
editor, image support, autosave, and full CRUD API
- Font picker: 15 Google Fonts selectable from a floating Aa button,
persisted to localStorage via --font-body CSS variable
- Sticky toolbar: pulled formatting bar out of overflow:hidden wrapper
so it sticks below the topbar while scrolling
- Prompts: 100 additional built-in prompts (120 total) in a shuffled
no-repeat queue; pre-fetch on page load so the AI has time to respond;
timeout raised to 45s; error logging + /api/prompts/test debug endpoint;
source badge shows whether prompt came from AI or built-in list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>