# Harper grammar-check integration

Status: **desktop SHIPPED v1.2.168 (2026-07-22); Android deferred.**
What shipped diverges from the original sketch in one big way: the linter
runs **in the daemon**, not in the compose page — harper.js works in Node,
and the 18 MB WASM engine has no business in the page load path. The client
(`client/compose/harper-lint.ts`) sends plain-text blocks over IPC
(`grammarLint`), paints dotted-blue squiggle overlays from the returned
spans, and shows a suggestion popup on squiggle click. One generic
implementation covers Quill, tiptap, AND TinyMCE (all contenteditable DOM;
TinyMCE's editable body reached via `nativeEditor.getBody()`, overlay parked
on the iframe's `<html>` so nothing ever lands inside the editable subtree).
Settings → "Grammar check (local)", default ON (`ui.grammarCheck` +
`mailx-grammar-check` localStorage; boot-snapshot carries it to popouts).
Quoted reply chains (blockquotes) are skipped. Spelling lints are skipped
(native WebView2 spellcheck owns spelling). Windows note: harper's Node
loader mangles drive letters (`fs.readFile(new URL(u).pathname)` →
`Y:\Y:\...`); the daemon sidesteps it by handing the engine a
`data:application/wasm;base64,` URI.

**Remaining — Android** (bridge has no `grammarLint`; the client goes
silently dark there): host a `LocalLinter` in android-bootstrap, lazy-loaded
post-boot; ship harper.js dist in the APK assets like sql.js; ArrayBuffer
instantiate for the file:// wasm fetch. ~+18 MB APK. Also still open:
per-rule LintConfig, harper user-dictionary merge with userdict.jsonc,
ignore-this-lint memory.

## What Harper is

- Offline, Rust-based grammar checker from Automattic. Apache-2.0, free.
- Catches grammar, awkward phrasing, capitalization, spelling — the mechanical
  layer above spellcheck, below meaning-level AI rewrite.
- `harper.js` npm package: ESM wrapping the engine compiled to WASM. Runs
  in-browser (WebView2, Android WebView) and Node. `WorkerLinter` (off-thread,
  preferred) and `LocalLinter`. Lint returns spans + suggestions; suggestions
  are applicable programmatically. Sub-10 ms lint passes; ~1/50th of
  LanguageTool's memory.
- English only (dialect-aware: US/UK/CA/AU/IN). API flagged "early access /
  not yet stable" — pin the version, expect churn on upgrade.
- Docs: https://writewithharper.com/docs/harperjs/introduction

## Why it fits rmfmail

- Local-first like the app; drafts never leave the machine. No API key, no
  Ollama, no feature gate needed.
- Matches Bob's standing rule: AI assists with **proofreading, not drafting**
  — and this layer doesn't even need the AI providers.
- Fills the gap between native WebView2 spellcheck (spelling only) and the
  on-demand AI Proofread (whole-text rewrite, cloud/Ollama): live squiggles
  as you type.
- Could subsume the bundled nspell + dictionary-en machinery (harper does
  spelling too) — one engine, less code. Fold `userdict.jsonc` into Harper's
  user-dictionary API (it has one — verify shape at build time).

## Architecture sketch

One shared module (`client/compose/harper-lint.ts`), per-editor adapters as
thin as possible:

1. **Linting**: `WorkerLinter` singleton in the compose page. Debounced
   (~500 ms after idle, reuse the draft-checkpoint debounce cadence). Feed it
   PLAIN TEXT extracted per block (paragraph-level granularity keeps lint
   cheap and mapping simple); harper.js documents contenteditable-oriented
   extraction incl. soft breaks.
2. **Rendering — prefer the OVERLAY approach over per-editor decorations**:
   absolutely-positioned underline elements computed from `Range.getClientRects()`
   over the editor's DOM, in a pointer-events:none layer above the editor.
   ONE renderer serves Quill, tiptap, AND TinyMCE (TinyMCE = same-origin
   iframe: overlay goes inside its doc, or map rects to the host). Editor
   decorations (Quill formats / tiptap decorations / TinyMCE annotations)
   mutate editor state, fight undo stacks, and need three implementations —
   the overlay touches nothing. Re-paint on scroll/resize/input (cheap:
   rects only for on-screen spans).
3. **Span→DOM mapping**: lint per text node / per block so span offsets map
   directly into that node's Range. Avoid whole-document offset arithmetic —
   rich text invalidates global offsets constantly.
4. **Suggestions UI**: reuse the msger native context-menu plumbing
   (contextMenuItems + `__msgerContextCommand`) — right-click on a squiggle
   inserts Harper's suggestions above the native spellcheck entries. Applying
   a fix goes through the EDITOR's own API (Quill deleteText/insertText,
   tiptap transaction, TinyMCE `editor.selection` + `insertContent`) so undo
   history stays coherent — apply is the only per-editor code besides text
   extraction.
5. **Settings**: View→Settings toggle "Grammar check (Harper, local)" —
   default ON once stable (it's private and cheap); per-rule config later
   (harper has lint-rule toggles).

## Platform notes

- **Desktop WebView2 / msger**: WASM served via the msger custom protocol —
  verify `application/wasm` content-type or use `WebAssembly.instantiate`
  with ArrayBuffer (avoid `instantiateStreaming` MIME dependency).
- **Android WebView**: `file:///android_asset` fetch of .wasm can be finicky —
  same ArrayBuffer fallback; bundle the .wasm as a base64 asset if needed.
  harper.js worker mode inside Android WebView needs a check — fall back to
  `LocalLinter` on the main thread there (lints are ms-scale; acceptable).
- **Bundling**: esbuild (`bin/build-bundles.mjs`) — mark the .wasm as an
  external asset copied next to the bundle, not inlined, unless base64
  inlining proves simpler for the msger protocol.

## Phasing + estimates (Bob-calibrated, hours not weeks)

1. **P1 — TinyMCE first** (since that's what Bob uses): WorkerLinter + overlay
   renderer + context-menu apply, TinyMCE adapter. ~1 day.
2. **P2 — Quill + tiptap adapters** on the same renderer: ~½ day each,
   mostly text-extraction + apply-fix glue.
3. **P3 — retire nspell path**, merge userdict.jsonc into Harper dictionary;
   decide whether native WebView2 spellcheck stays on (probably yes — free,
   and its suggestion menu already works) with Harper owning grammar only,
   to avoid double-squiggles on spelling errors: configure Harper with
   spelling lints OFF while native spellcheck is on.
4. **P4 — Android**: same bundle; verify WASM load + LocalLinter fallback.

## Open questions for build time

- harper.js API stability — re-check docs/changelog before starting
  (https://github.com/Automattic/harper/releases).
- Does Harper's dialect setting matter for Bob? (Default American English.)
- Interaction with AI ghost-text autocomplete overlay (both paint over the
  editor — z-order and pointer-events need a look).
