Trash icon (🗑) appears in the per-image controls when an image is
selected, after the alignment buttons. Calls TipTap's deleteNode()
to remove the node from the document. Turns red on hover.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>
Without this the browser caches index.html, which contains references
to the current build's JS bundle filename. After a redeploy the new
bundle has a different hash, but the browser still loads the old
index.html → requests the old bundle → may 404 or load stale code.
Symptom: Ctrl+Shift+R required after every update to see the story.
index.html: Cache-Control no-cache/no-store/must-revalidate
JS/CSS/font/image assets: max-age=31536000, immutable (safe because
Vite includes a content hash in every filename)
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>
- Add erikvl87/languagetool service on the internal network
(port 8010, English only by default — change langsToLoad to add more)
- 256 MB min / 512 MB max heap; healthcheck waits up to ~3 min for
first startup while Java initialises
- Server LANGUAGETOOL_URL hardwired to http://languagetool:8010/v2 —
no character limits, no rate limits, fully private
- Remove 40 000-char cap from lint route (not needed self-hosted)
- Bump fetch timeout to 30 s for cold-start requests
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>
- ⚙ button opens a settings popover above the toolbar with:
- Voice dropdown: lists all voices the browser exposes, with ☁
suffix for remote/cloud voices vs local ones
- Speed slider: 0.5x–2.0x in 0.1 steps, labelled live
- On first load, auto-selects the first English non-eSpeak voice
(helps Linux/Firefox users who only have eSpeak installed by default
and haven't yet set a preference)
- Voice URI and rate persist in localStorage (sw-tts-voice, sw-tts-rate)
- Voice and rate applied per-utterance via voiceURIRef / ttsRateRef
so changes take effect on the next sentence without restarting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Hourly PASSIVE WAL checkpoint prevents unbounded WAL growth and
ensures all writes are merged into the main .db file regularly.
Previously the WAL was never checkpointed — all data was accumulating
in stories.db-wal with no protection if that file was lost.
- Daily backup using better-sqlite3 .backup() writes a safe online
snapshot to data/backups/stories-YYYY-MM-DD.db on startup and
every 24 h; keeps last 7 days, pruning older ones automatically.
- busy_timeout = 5000 so concurrent requests wait briefly rather
than failing with SQLITE_BUSY.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Typewriter mode (⌨ toolbar button):
- Registers TipTap update/selectionUpdate listeners via editor.on()
- On each change, uses coordsAtPos + window.scrollBy to keep cursor
at 50% of viewport height — instant, no scroll-lag
- Adds 50vh padding-bottom so the last line can reach screen centre
- Toggles .typewriter class on <html>; cleans up on unmount
Alignment buttons:
- Replaced L/C/R text labels with inline SVG stacked-line icons
(left, centre, right, justify) — standard text-editor appearance
- Added Justify as a fourth option
Highlight readability:
- Added color: #111 !important to .editor-body .ProseMirror mark
so highlighted text is always dark-on-light regardless of theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 🔊 button at far-right of toolbar; toggles to ⏹ while reading
- Extracts plain text from cursor position to end of document via
editor.state.doc.textBetween — works with cursor or selection
- Splits into sentence chunks to work around Chrome's ~15s utterance
cutoff bug; each chunk's onend fires the next
- isReadingRef guards the closure so Stop cancels mid-sentence cleanly
- Gentle pulse animation on the active button (reading-pulse keyframe)
- No server required — uses built-in browser speechSynthesis
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Refill a queue of 5 AI prompts in the background on page load
- Track in-flight fetches with pendingFetches ref so we never over-request
- fetchPrompt() is now synchronous: pops from queue or instantly returns a
shuffled built-in (LOCAL_PROMPTS, 12 entries, no-repeat cycling)
- Source badge replaced with a small emoji in the top-right corner of the
popover: 🤖 for AI prompts, 📚 for built-ins
- Removed promptLoading state and spinner entirely
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The server at ai.binarygnome.com is Open WebUI, not a bare Ollama
instance. Open WebUI proxies Ollama at /ollama/v1/... rather than /v1/...
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The server at ai.binarygnome.com returns 405 on POST /api/generate,
which means it speaks the OpenAI-compatible API rather than the native
Ollama format. Switch to /v1/chat/completions with messages[] payload
and data.choices[0].message.content response parsing.
Also add optional OLLAMA_API_KEY env var for servers that require a
Bearer token.
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>
Wrap button labels in spans so the existing hide-on-mobile rule fires.
Hide all topbar text labels and save status on small screens, leaving
only icons. Add overflow-x: hidden to html as a safety net.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Login now uses username instead of email
- DB migration renames email -> username on existing databases
- Users can change their own password from the Stories page
- Admin can reset any user's password from the admin panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
network_mode: bridge uses Docker's default bridge which has no
service-name DNS. An explicit named network gets its own resolver
so nginx can resolve "server" by name. Also adds a healthcheck so
app waits until server is actually ready before nginx starts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The resolver 127.0.0.11 directive caused ECONNREFUSED errors in this
network setup. Direct proxy_pass resolves server at startup which is
sufficient since depends_on ensures server is running first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>