pj 738eb9c9cf Quality bugfix plan (#2)
* fix(persist): atomic+durable .margin writes

Route save_book, write_file, write_bytes through a shared atomic_write
helper: write to a temp file, fsync, then rename into place so a crash
mid-write can never leave a truncated or empty manuscript. Rotate the
prior copy to .bak for manuscripts (not exports) so one good copy
always survives.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(editor): isolate undo history per chapter

Key the editor by chapter id so each chapter gets a fresh TipTap
instance with its own history. Previously a single instance was reused
across chapters, so the content swap entered the undo stack and Ctrl+Z
after switching restored the prior chapter's doc into the current one,
which autosave then persisted.

With remount, save the cursor/scroll position on unmount (from a ref,
not the editor, which is destroyed by then) and load it on mount,
replacing the in-place chapter-switch bookkeeping.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* feat(editor): persist active chapter per book

Remember which chapter was open per book and restore it on reopen,
falling back to the first chapter when the saved id is no longer valid.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): flush pending save on navigate-away and quit

Edits are saved on an 800ms debounce, so leaving a book within that
window lost the latest changes. Flush a dirty book before openBook /
closeBook swap state (covers All books, open-another, Cmd+N), and add a
window close-request handler that saves before destroying on quit.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): guard markSaved against in-flight edits

markSaved cleared dirty unconditionally, so an edit made while a save
was in flight had its dirty flag wiped and its follow-up save dropped.
Only clear dirty when the book reference is unchanged since the save
started (immutable updates give a fresh reference per edit).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): persist new books on create

A freshly created book lived only in memory until the first edit, so
quitting beforehand lost it and it never appeared in the library. Save
it to disk on creation via a shared createAndOpenBook used by both the
Library card and the New Book menu action.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(export): correct list nesting, marks, line-start escaping, svg type

Four export-correctness fixes intermingled across the Typst and EPUB
renderers:

- H7: neutralize newlines in escaped text and apply leading-marker
  escaping (= - + / and N.) to the first text node of every block
  (paragraph, heading, list item, blockquote) and figure captions, even
  when that run carries a mark, so author text can never inject Typst
  markup or phantom headings/TOC entries.
- H9: render lists recursively so nested lists survive and multi-
  paragraph list items keep their paragraph breaks (Typst functional
  list()/enum(); EPUB nested <ul>/<ol> and multiple <p> in <li>).
- H10: render strike, underline and inline code marks in both exporters
  (Typst #strike/#underline/#raw; EPUB <s>/<u>/<code> with css).
- H8b: normalize +-bearing image subtypes so an imported svg+xml cover
  becomes cover.svg with the correct media type.

Validated the generated Typst constructs compile with typst 0.15.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* feat(export): surface typst warnings and glyph-coverage notice

Typst compile warnings were discarded. Capture them via the Warned
result and emit a pdf-warnings event (only from the export path, not
the live preview) that App shows as a non-fatal toast; the export still
succeeds. Also scan book text before reporting success and warn when it
contains scripts the embedded Latin fonts cannot render (CJK, Arabic,
Hebrew, Devanagari, Thai, emoji).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(ui): handle library-open errors

Opening a book card swallowed failures: a corrupt or unreadable file
failed silently and normalizeBook could throw on a missing chapters
array. Surface load errors as a toast, throw a clear message on
unparseable JSON, and make normalizeBook tolerant of missing chapters,
metadata and settings.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(preview): destroy pdf.js document on recompile

The preview created a new PDF document on every recompile but never
destroyed the old one, leaking the document and its worker transport.
Keep the loading task and destroy it on effect cleanup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(library): surface unreadable books

list_books silently skipped any .margin file it could not read, parse,
or that lacked an id, so a truncated manuscript just vanished. Emit a
corrupt placeholder entry instead and show it in the library as a
non-openable card with a recovery hint pointing at the .bak backup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): schema-version guard and editor content check

A book file's declared schema was never read, so a file from a newer
app version would be opened, normalized and autosaved over in the old
format. Refuse to open books whose schema is newer than supported.
Enable the editor content check so chapters with unreadable content
warn the user instead of silently loading stripped content that the
next edit would persist.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(editor): prune chapter positions on delete and guard quota

Saved cursor/scroll and active-chapter entries accumulated per book
forever and were never removed when a book was deleted. Add
clearPositions and call it from the delete flow, and guard localStorage
writes against quota or unavailable storage.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(security): validate book id paths

load/save/delete_book built paths from the raw id, and ids round-trip
from file JSON, so a crafted id could escape the library directory.
Route all three through a book_path helper that rejects anything but
[A-Za-z0-9_-].

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* chore(security): restrictive content security policy

security.csp was null (no CSP). Add a restrictive policy as
defense-in-depth: self-only by default, data:/blob: images for cover
and figure URLs, inline styles for React/inline style attributes, and
blob: workers for pdf.js. Needs verification against a production build
(covers, preview, export, IPC).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): preserve link, underline, strike and code marks

EPUB import only mapped bold and italic; a, u, s/del/strike and code
elements lost their formatting (kept as plain text). Map them to the
matching marks, consistent with what the exporters now render.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): keep inline svg and unresolved image alt text

Inline svg elements were dropped entirely and an img whose source could
not be resolved was discarded. Serialize a standalone svg to a
data:image/svg+xml figure, and fall back to an unresolved image's alt
text as a paragraph instead of losing it.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): keep table rows together

Tables were flattened to one paragraph per cell, losing row grouping.
Join each row's cells into a single paragraph so the row structure
survives (the model has no table node).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): import the cover image

The EPUB cover was discarded and replaced with the default generated
cover. Locate the cover via the manifest cover-image property or the
meta name="cover" reference, resolve it to a data URI, and import it as
an image cover.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): validate ISBN with checksum

The importer accepted any 9-13 digit run as the ISBN, so a UUID or
arbitrary number became a bogus ISBN. Prefer scheme-tagged identifiers
and accept only values that pass the ISBN-10/13 length and checksum
test, leaving it empty otherwise.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): isolate bad chapters and bound nesting depth

A deeply nested document could overflow the stack and abort the entire
import. Bound block recursion depth and wrap each chapter build in a
try/catch so one unreadable chapter is skipped instead of failing the
whole import.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* changes
2026-06-22 13:44:30 -04:00
2026-06-20 23:46:10 -04:00
2026-06-22 13:44:30 -04:00
2026-06-22 13:44:30 -04:00
2026-06-22 13:03:11 -04:00
fix
2026-06-20 19:07:57 -04:00
2026-06-21 14:37:02 -04:00
2026-06-22 16:50:04 +00:00
2026-06-22 09:56:20 -04:00
2026-06-20 13:09:40 -04:00
2026-06-22 11:56:31 -04:00

margin

An offline desktop app for writing books and exporting them to print-ready PDF and reflowable EPUB — a focused, free alternative to InDesign for text-first books (novels, non-fiction).

Stack

  • Tauri 2 (Rust shell) — offline by construction; the entire export pipeline runs locally, nothing hits the network.
  • React + TipTap — the semantic writing surface.
  • Typst (embedded as a Rust crate) — print-ready PDF with real book typography.
  • Built-in EPUB3 generator + zip packager.

One semantic document feeds three renderers — the editor, the PDF, and the EPUB — from a single design-token theme (Quiet Press: Literata for the page, Hanken Grotesk for the chrome). Both fonts are bundled.

Develop

pnpm install
pnpm tauri dev     # the real desktop app: live PDF dock, file dialogs, PDF/EPUB export
pnpm dev           # browser-only UI preview (Tauri APIs are inert)

The first tauri dev build is slow because it compiles Typst.

Using it

  • A book saves as a single .margin file (JSON). Open / Save with ⌘O / ⌘S.
  • Click the title in the top bar for Book setup (title, author, ISBN, trim size, language).
  • Insert images from the toolbar and choose a placement (inline / full-width / full-page / float).
  • Export to PDF or EPUB from the titlebar menu.

Layout

  • src/editor/ — TipTap editor, extensions, figure block, floating toolbar.
  • src/components/ — app shell, preview dock, book-setup panel, pdf.js renderer.
  • src/export/ — typst.ts (book → Typst source), epub.ts (book → EPUB3 files), exporters.ts.
  • src/store/, src/model/ — document model and state.
  • src-tauri/src/ — pdf.rs (Typst compile), epub.rs (zip packager), project.rs (file IO).
S
Description
No description provided
Readme MIT
8.1 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 46.6%
CSS 13.7%
Rust 12%
Astro 11.2%
HTML 8.4%
Other 8.1%