Files
margin/simplify/typesetting.md
T
2026-10-03 22:48:49 +05:30

27 KiB

Typesetting, fonts and proofing

The plan for margin-typeset, margin-mac and the font packages, from the measurements in .research/typesetting-and-text.md, with names from repo-layout.md.

One file that was copied, and only one copy was maintained

The problem is not that Margin and Margin Docs both compile Typst. It is that Docs' Typst stack is Margin's, forked when Docs was scaffolded, and every fix since has landed on one side. Docs is ahead on all six Rust pairs and four of the six frontend pairs, and in three places Margin does not merely lag: it ships a defect Docs found, wrote a paragraph about, and fixed. The shared crate is the only mechanism that would have carried those three fixes back.

Overlap below is "identical in order": an LCS over code lines, comments and blanks stripped, as a percentage of the smaller file. Numbers from the audit.

Pair Margin Margin Docs Identical % of smaller Ahead
src-tauri/src/pdf.rs 129 (116 code) 455 (275) 37 31% Docs
src-tauri/src/fonts.rs 47 (43) 182 (113) 17 39% Docs
src-tauri/src/macspell.rs 73 (68) 162 (82) 49 72% Docs
src-tauri/src/writingtools.rs 81 (68) 146 (83) 42 61% Docs
proofing.rs vs grammar.rs 275 (239) 128 (58) 33 56% Docs
stubs/* (4 files) 37 37 all 100% neither
src/export/typst.ts 421 (335) 808 (486) 9 2% neither
src/components/ExportPreview.tsx 336 (307) 448 (342) 200 65% Docs
src/components/ProofPopover.tsx 76 (68) 292 (184) 29 42% Docs
src/editor/proofing.ts 139 642 (344) 17 13% Docs
src/editor/search.ts 233 (217) 264 (235) 206 94% neither
src/editor/paste.ts (70) (163) 51 72% Docs
src/escape.ts 36 36 36 100% neither

What the drift has cost, all of it paid by Margin, all of it fixed in Docs already:

  • The three bugs below: every heading and bold run in a Margin PDF exports at weight 400, \b and \f and \u001b reach the page as visible backslash text, and a system family that is a .ttc is read and shipped to the compiler four times.
  • An inverted Harper span panics: chars[start..end] at margin/src-tauri/src/proofing.rs:171-173 clamps start and end independently, where Docs clamps end first then start against end (grammar.rs:84-85). An NSRange carrying NSNotFound wraps or panics the same way, because margin/src-tauri/src/macspell.rs:53 adds before it clamps and Docs uses saturating_add at macspell.rs:107.
  • Harper's "French spaces" lints draw an underline over two spaces that cannot be clicked; Docs drops whitespace-only lints at grammar.rs:96-98.
  • Roughly 1.5M of pdf.js sits in Margin's main bundle whether or not the export panel is opened, because ExportPreview.tsx:18 sets GlobalWorkerOptions.workerSrc at module scope. Docs dynamic-imports it in margin-editor/src/pdfjs.ts, 27 lines.
  • Margin names no monospace and no math family, so a code block gets whatever Typst defaults to and a formula on a machine without New Computer Modern Math is a hard compile error, not a warning. Docs' fonts.rs:19-42 and pdf.rs:165-194 exist for exactly that.

Both trees resolve the same 960-package graph to the same versions by hand, twice: harper-core 2.5.0, typst 0.14.2, fontdb 0.23.0, burn-cuda 0.19.1, cubecl-cpu 0.8.1.

The three bugs

All three verified in this pass, against the source and the dependency sources.

1. JSON.stringify is not a Typst escaper. Confirmed. margin/src/export/typst.ts:36-38 is function str(value) { return JSON.stringify(value) }. A Typst string literal resolves exactly \\, \", \n, \r, \t and \u{...}; every other escape falls through the _ => out.push_str(s.from(start)) arm at typst-syntax-0.14.2/src/ast.rs:1250-1270 and is copied to the page as literal text. I checked what JSON.stringify emits in node: \b for U+0008, \f for U+000C, and \uXXXX (no braces) for every other C0 control and for a lone surrogate. None of those five forms is Typst syntax, so each lands in the PDF as the backslash sequence itself: an escape character between two letters arrives as the eight characters a\u001bb, and there is no compile error. The busiest call site is typst.ts:52, #raw(${str(node.text ?? "")}), every inline code span in the book; then the href at :60, the title and author at :136 and :374, and figure paths at :83 and :89.

One correction to the audit: JSON.stringify does not escape U+2028 or U+2029, it passes them through raw. Separate and smaller, and esc() at typst.ts:26-28 already folds them to a space on the markup path. Fixed by the extraction only if the escaper is shared where it runs, which is TypeScript; see section 4.

2. Variable fonts export at one weight. Confirmed. margin/src-tauri/src/pdf.rs:9-20 are eleven include_bytes! of public/fonts/*-VF.ttf, and pdf.rs:57-62 pushes the Literata and Hanken variable files into the engine. Typst has no variable axis: it lays out at the default instance and warns that it did. So a Margin PDF has no typographic hierarchy at all, headings at the same weight as the body. Docs cut nine static instances into margin-editor/src-tauri/fonts/ (1.6M plus PROVENANCE.md) and loads those at pdf.rs:32-42, with the reason at pdf.rs:24-31, and it documents the residual caveat for the other four families at pdf.rs:44-51: EB Garamond, Lora, Source Serif and Fraunces are still loaded as variable files and still export flat, which Docs judged not worth eight more cuts. Margin has never noticed either half. This is the one bug the extraction fixes on its own, because the fix is an asset the crate carries.

3. Faces deduplicated by face id, not by file. Confirmed. margin/src-tauri/src/fonts.rs:30 and :39: seen.insert(id) over fontdb::ID, which is per face. db.with_face_data hands back the whole file, so a .ttc holding regular, italic, bold and bold italic has four ids, passes the seen check four times, and the same multi-megabyte collection is copied into the font list four times. On macOS almost every system family is a .ttc. Docs keys on the source path (margin-editor/src-tauri/src/fonts.rs:55-61, used at :88) and reads it once. Fixed by the extraction: there is one loader and it is Docs'.

The installed() guard: the audit's correction is right. Confirmed. margin-editor/src-tauri/src/fonts.rs:63-67 is checked at :88 right after a successful db.query, and the comment at :84-86 says fontdb returns a closest match rather than nothing. It does not. Database::query at fontdb-0.23.0/src/lib.rs:661-678 builds its candidate list with face.families.iter().any(|family| family.0 == name), exact string equality, and returns None when the list is empty. The guard cannot fire, and since it compares case-insensitively it is strictly weaker than the filter that already ran. It is live and correct at :139, asked about db.faces() directly. Keep the function, delete the call at :88 and the comment. Same code in fontdb 0.24 (lib.rs:663-681), which matters because Margin Mail is on 0.24.

The harper patch trap

The strongest argument for a shared crate here is four lines that fail silently. Both apps pin harper-core = { version = "=2.5.0", features = ["concurrent"] } (margin/src-tauri/Cargo.toml:38, margin-editor/src-tauri/Cargo.toml:57). Harper pulls burn, which declares optional, disabled burn-cuda and cubecl-cpu backends. Both are off, and cargo resolves them anyway, so both repos replace them with do-nothing crates:

[patch.crates-io]
burn-cuda = { path = "stubs/burn-cuda" }
cubecl-cpu = { path = "stubs/cubecl-cpu" }

margin/src-tauri/Cargo.toml:70-72 and margin-editor/src-tauri/Cargo.toml:98-100. The four stub files are code-identical across the two repos, only the prose comments differ, and both repos also carry [profile.dev.package."*"] opt-level = 3 (Margin :76-79, Docs :105-108) because Harper's burn-ndarray POS tagger is roughly ten times slower unoptimized.

The stub versions, burn-cuda 0.19.1 and cubecl-cpu 0.8.1, and the feature lists that have to satisfy burn's burn-cuda?/... references (std, doc, fusion, autotune, autotune-checks) are facts about harper-core 2.5.0's exact transitive graph. Bump harper and they may not match. Cargo treats a [patch] entry that matches nothing as a warning, not an error, so what silently returns is the real cubecl-cpu: several hundred crates and an LLVM/MLIR toolchain back in the build. Nothing fails; the symptom is a build that got slow, noticed weeks later. No test in either repo asserts the patch still applies, and no justfile checks.

The check that should exist is cheap: when a patch applies, the package's entry in Cargo.lock has no source and no checksum. Both lockfiles show exactly that today (margin/src-tauri/Cargo.lock:659-661 for burn-cuda, :1650-1651 for cubecl-cpu; Docs at :1596-1597), where fontdb at :2699-2702 carries both. So the test is a lockfile parse, no network and no cargo invocation:

for name in ["burn-cuda", "cubecl-cpu"] {
    let entry = lock_entry(include_str!("../../Cargo.lock"), name);
    assert!(entry.source.is_none(), "{name} came from the registry: the [patch] went stale");
}

One constraint decides where this lives: cargo only honours [patch] from the top-level workspace manifest and ignores it in dependencies, and [profile] is the same. So a shared crate cannot carry the patch block, and copying stub directories into every app defeats the point. The workable shape is a git-sourced patch, which needs no registry:

[patch.crates-io]
burn-cuda = { git = "https://github.com/priyanshujain/margin-shared", tag = "v0.3.0" }
cubecl-cpu = { git = "https://github.com/priyanshujain/margin-shared", tag = "v0.3.0" }

with packages literally named burn-cuda and cubecl-cpu in the shared repo under crates/stubs/. Each app keeps four lines it can copy and cannot get wrong, one repo owns the versions and feature lists, and the lockfile test ships as a snippet each app's gate runs.

Repo-layout.md has no crate for Harper and needs one. The audit called it margin-grammar and that name should stand: it owns build_harper (Margin proofing.rs:98-103, Docs grammar.rs:41-47, the same five lines including set_rule_enabled("SpellCheck", false), because the system checker does spelling better), collect_grammar with Docs' clamp order and whitespace-lint drop, GrammarIssue, and the stub packages and lockfile test above.

margin-typeset

Owns everything that knows about the Typst engine and nothing that knows what document is being written.

  • The engine and its world: font bytes in, source and images in, PDF bytes and warnings out.
  • The font resolver: the fontdb database, the installed-family list, the four-style loader keyed on source path, the fallback collection, and the bundled faces including the nine cuts (section 5).
  • Diagnostic formatting: margin/src-tauri/src/pdf.rs:113-129 and margin-editor/src-tauri/src/pdf.rs:439-455 are the same sixteen lines, character for character, differing only in the name.
  • Warning aggregation with kind and count, Docs' note at pdf.rs:201-215 and PdfWarning at dto.rs:216-222, so forty unparseable formulas are one toast with a number on it.
  • Image handling: inline base64, the root-guarded filesystem read, and the one-pixel placeholder per format so an unreadable image is a gap and not a failed export (Docs pdf.rs:122-163).
  • PDF output options, the &Default::default() both apps pass to typst_pdf::pdf today, so metadata and PDF/A get decided once; and error mapping, TypstAsLibError and &[SourceDiagnostic] to one String or a Vec<Warning>.

Does not own each app's preamble or converter: 2% overlap, 9 lines of 335, and those nine are the image extension sniffer. Margin writes a book (trim sizes at typst.ts:16-21, title page, chapter and part openers, a TOC querying <chap> metadata at :130-239); Docs writes an A4 document (callouts, task lists, tables, code surfaces, mermaid, mitex math at :206-327).

pub struct Faces { pub bundled: Vec<String>, pub system: Vec<String> }
pub struct Fallbacks { pub fonts: Vec<Vec<u8>>, pub monospace: Vec<String>, pub math: Vec<String> }
pub struct ImageInput { pub path: String, pub data: Option<String> }
pub struct Warning { pub kind: &'static str, pub message: String, pub count: u32 }

pub struct Job<'a> {
    pub source: String,
    pub images: &'a [ImageInput],
    /// Directories a file-backed image may be read from. Empty means inline bytes only.
    pub roots: &'a [String],
    pub faces: Faces,
    pub options: typst_pdf::PdfOptions<'a>,
}

/// `Err` is the formatted diagnostics; warnings never fail an export.
pub fn compile(job: Job<'_>) -> Result<(Vec<u8>, Vec<Warning>), String>;

/// Typst source naming the monospace and math families this machine has, to concatenate ahead of
/// the caller's own preamble. Only what was found, because Typst warns once per family it missed.
pub fn fallback_preamble(fallbacks: &Fallbacks) -> String;

/// A Typst string literal: `\\ \" \n \r \t \u{...}` and nothing else.
pub fn escape_string(value: &str) -> String;

pub mod fonts {
    pub fn system_families() -> Vec<String>;          // the eight-line list, three copies today
    pub fn faces_for(family: &str) -> Vec<Vec<u8>>;   // four styles, deduplicated by source path
    pub fn fallbacks() -> Fallbacks;
    pub fn bundled(id: &str) -> &'static [&'static [u8]];
}

The pdf feature is on by default and gates typst, typst-as-lib and typst-pdf; fonts is always compiled. Margin Mail takes default-features = false and gets eight lines of fontdb without dragging the Typst compiler into a mail client.

On the escaper: escape_string has a real Rust consumer, font_list at Docs' pdf.rs:165-169, which writes format!("\"{f}\"") with no escaping at all, safe only because the two family lists are consts. But the converters build their Typst source in TypeScript, so the escaper that fixes bug 1 has to be TypeScript too. Both sides ship the same rule against one table of test vectors in the shared repo (the C0 controls, a lone surrogate, U+2028, a quote, a backslash), so they cannot drift the way the two str implementations already did.

Fonts: one catalogue, four lists, no Rust half

margin/shared/src/fonts.ts is 233 lines and is already the single source of truth for the six bundled families: the FontRef encoding, the pairings, fontsUsed. All three UI apps import it, including Margin Mail at src/screens/Settings.tsx:12. Its own header states the problem it exists to solve: a face named there is a @font-face in shared/css/fonts.css, a file in shared/fonts/, and a family a Typst preamble names, and four lists in two repos cannot be kept honest by hand.

Three of the four are wired together by shared/bin/sync-fonts.mjs, which every app already runs as fonts:sync and fonts:check. The fourth never got done: the Typst family names are re-declared as include_bytes! lists in margin/src-tauri/src/pdf.rs:9-35 and margin-editor/src-tauri/src/pdf.rs:32-88, and nothing checks either against the catalogue. The installed-family list is written three times, margin/src-tauri/src/fonts.rs:11-18, margin-editor/src-tauri/src/fonts.rs:159-168 (byte-identical) and margin-mail/src-tauri/src/settings.rs:179-189 (same algorithm, system_db() inlined). Mail is on fontdb 0.24 and the others on 0.23; the two crates' public signatures diff clean, so one pin at 0.24 is mechanical.

The bytes are on disk four times at 6.8M each, plus 1.6M of cuts: 28.8M of repo for six families.

  • @margin/fonts owns the catalogue in TypeScript, the @font-face block, the variable binaries, their OFL files and sync-fonts. The catalogue stays the source of truth: it is what a human edits.
  • margin-typeset owns the Rust half: the nine static instances and their PROVENANCE.md, the variable files for the other four families, the id-to-bytes mapping and the Typst family names.
  • The join is generated, not written. sync-fonts --rust emits the crate's catalogue module from fonts.ts, and fonts:check fails when it is stale, exactly as it already fails when public/fonts is stale. Four lists become one list and three generated views, and the failure mode moves from "renders in one app and falls back to Georgia in the other" to a red CI job.

The static instances go in the crate and not in @margin/fonts because they are useless to a browser, and their provenance note is the regeneration instruction, which belongs beside the files.

margin-mac

One cfg(target_os = "macos") boundary for the three AppKit and Foundation integrations.

Writing Tools. The AppKit menu walk is the cleanest extraction in the audit: it takes a MainThreadMarker and a title and knows nothing about either app. submenu_named and edit_menu are 21 lines byte-identical, margin/src-tauri/src/writingtools.rs:10-30 against margin-editor/src-tauri/src/writingtools.rs:23-43, and writing_tools_menu is the same one-liner (Margin :32-34, Docs :45-47, differing by pub). Take Docs' availability probe with it, the SUBMENU_SEEN atomic at :13-14 and :92-105 and writing_available at :106-120, so an app can say the machine has no Apple Intelligence instead of offering a dead button. Docs' perform returns three distinct messages (:58-73); Margin's returns () and no-ops when the row is missing (:51-61).

The key equivalents stay in the apps and are not a drift to resolve. Margin puts Shift+Option+F and Shift+Option+R on Apple's own Proofread and Rewrite rows (writingtools.rs:8, 36-49). Docs refuses, and writingtools.rs:85-91 says why: AppKit performs a key equivalent by firing the menu item directly, so a chord on the system's row reaches Writing Tools without passing the selection guard in src/editor/writing.ts. Margin has no such guard so it is not wrong today, but the crate should expose label_shortcuts(rows: &[(&str, &str)]) and let each app decide which rows it labels.

While there, margin/src-tauri/Cargo.toml:57 asks for objc2-app-kit's NSWritingToolsCoordinator feature and nothing in Margin uses it. Drop it.

NSSpellChecker. The closest pair in either Rust tree, 49 lines identical in order, 72% of the smaller. utf16_to_codepoint is byte-identical, 12 lines: margin/macspell.rs:14-25 against margin-editor/macspell.rs:54-65. check is the same function either side of three differences: Docs' named constants (MAX_SUGGESTIONS, NO_DOCUMENT at :40-45) against Margin's inline 5 and 0; Docs' saturating_add clamp at :107 against Margin's overflow at :53; and Margin's in-process custom word set at :55-57, a real product difference, since Docs learns into the system (learn and unlearn at :150-162, absent from Margin). The crate takes an optional filter closure and both fit. One SpellIssue (margin-editor/src-tauri/src/dto.rs:179-185) replaces Margin's local MacIssue (macspell.rs:7-12, the same four fields).

Notifications. Margin Mail's src-tauri/src/notify/macos.rs, 286 lines, is the only working system notification code in the suite and belongs here: the UNUserNotificationCenter delegate, permission (:107), request (:178), post (:205), open_settings (:257), and the settings read at :120-162 that tells "denied" apart from "Show previews: Never". Its header at :3-14 says why it exists: tauri-plugin-notification posts through notify-rust and mac-notification-sys, which use NSUserNotificationCenter, deprecated since 10.14. On macOS 26 that API still answers, the delegate is told the notification was delivered and nothing appears; the app never shows up under System Settings and the permission question is never asked. The module also carries the two conditions of the working path: a bundled app with a real signature, and a request before the first post.

Correction to repo-layout.md: the other three apps do not post through the broken plugin, they do not post at all. Neither tauri-plugin-notification nor @tauri-apps/plugin-notification appears in Margin's, Docs' or Calendar's manifests, and Calendar's notify at App.tsx:27 is a toast helper from src/store/useToast. What is true is that the plugin is what any of them would reach for the first time they want a banner, and it silently does nothing. Mail keeps it for the non-macOS path only (notify.rs:484-486), and the crate should keep that shape.

Frontend

Worth sharing, in order of ratio:

  • src/editor/search.ts, 206 of 217 code lines identical, 94%, the highest pair in either repo. Docs' header at search.ts:6-7 says it was ported from Margin's and the only change of substance is the name of the last command; the rest of the diff is trailing commas and a return type. Outside the typesetting brief, and the most obviously extractable file in the suite.
  • ExportPreview.tsx, 200 of 307, 65%. Share the frame-and-zoom shell, not the panel: the Frame interface, measureEditorPane against measurePane (Margin :40-47, Docs :64-71, identical but for the name), the zoom constants and step, the ctrl-wheel and gesture accumulator (Margin :75-129, Docs :117-180, one identifier apart), the pdf.js render loop with RenderTask cancellation, and the ResizeObserver on .editor-pane. Start from Docs': StrictMode-safe compile-once refs (:113-135), lazy pages via AHEAD, the overlay key context.
  • margin-editor/src/pdfjs.ts, 27 lines, dynamic-imports pdf.js and its worker and caches the promise. Copy it into Margin whether or not anything else here is shared.
  • ProofPopover.tsx, 29 of 68, 42%. Both portal to document.body, clamp to the window, humanise Harper's CamelCase kind the same way (Margin :6-8 humanize, Docs :57-63 readableKind) and render "" as "Remove" (Margin :58, Docs :70). A @margin/ui primitive with the action row as a prop, since "Remember" and "Learn Spelling" write to different places.
  • src/escape.ts, 36 lines byte-identical in three repos, and src/editor/paste.ts at 72%.

Not worth it:

  • The markdown and remark stack, margin-editor/src/markdown, 2737 non-test lines plus roughly 8400 of tests written against src/model/schema.ts as a frozen contract, and EPUB, margin/src-tauri/src/epub.rs, 88 lines of zip plumbing with one consumer. See the last table.
  • src/editor/extensions.ts: twelve lines identical and they are the StarterKit import and the configure skeleton, since Docs switches StarterKit's schema off entirely at :128-147. Same for the TipTap pins (Margin ^3.27.1, Docs 3.30.2, Mail ^3.31.2).
  • src/proofing.ts and src/editor/proofing.ts, 13% and 0%. The same offset-mapping problem solved twice in incompatible shapes: Margin flattens the document to one string with a Segment table (proofing.ts:39-73), Docs maps per block inside 8000-character batches (proofing.ts:154-251). Docs' is better, but converging them is a rewrite rather than an extraction.

Per app, in order

Shared repo, first. crates/stubs/burn-cuda and crates/stubs/cubecl-cpu with the versions and feature lists as they stand. margin-grammar with the harper pin, build_harper, Docs' collect_grammar, GrammarIssue and the lockfile test. margin-mac with the menu walk, the availability probe, check/learn/unlearn, SpellIssue and Mail's notification module. margin-typeset with Docs' fonts.rs (minus the dead installed() call at :88 and its comment), Docs' compile path, the diagnostic formatter, the warning aggregator, image handling, and the nine static instances with their provenance. @margin/fonts as shared/src/fonts.ts, shared/css and shared/fonts moved intact plus sync-fonts --rust; @margin/typeset for str, sanitize, the extension sniffer and the shared escape vectors.

Margin Docs, second, because it is the donor and its diff should be a deletion. Delete fonts.rs, macspell.rs, writingtools.rs, grammar.rs and stubs/; keep spell.rs as the platform shim over margin-mac; reduce pdf.rs to the mitex two-pass and its own preamble, calling margin_typeset::compile. Rewrite the patch block to the git form and add the lockfile test. If Docs does not build cleanly on the crates, the crates are wrong, and finding that out here costs one repo rather than two.

Margin, third, and this is where the user-visible fixes land. Take margin-typeset and the nine static instances and headings stop exporting at 400; take the source-path dedup and a system family stops being read four times; take margin-grammar and the inverted-span panic and the whitespace underlines go; take margin-mac and the NSNotFound overflow goes, with the custom dictionary staying as a filter passed in. Replace str at export/typst.ts:36-38 with @margin/typeset's and backslash sequences stop appearing in exported code spans. Copy pdfjs.ts, add the fallback preamble so code blocks and formulas have a face, drop the unused feature at Cargo.toml:57.

Margin Mail, last and smallest. Delete system_fonts from settings.rs:179-189 and call margin_typeset::fonts::system_families() with default-features = false. Move notify/macos.rs into margin-mac; the 601-line test file stays in Mail, because what it tests is which arrivals are worth announcing, not how a banner is posted. Nothing else in Mail touches this stack.

Margin Calendar. Nothing: no typst, no fontdb, no harper, no NSSpellChecker. It joins only if it gains reminders, at which point it takes margin-mac for the notification path.

What stays per app, and why

Stays Where Measured reason
The Typst converter and preamble src/export/typst.ts in both 9 of 335 lines shared, 2%. A book with trim sizes and a generated TOC against an A4 document with callouts and mitex.
Trim size, margins, running heads each preamble Product decisions. Margin has four print trim sizes at typst.ts:16-21; Docs has A4.
The mitex two-pass and its vendored wasm Docs pdf.rs:84-113, 344-379 Margin has no math. Adding it to the crate would put a wasm binary in three apps that cannot use it.
The custom dictionary file Margin proofing.rs A deliberate product difference: Margin keeps its own, Docs learns into the system. The crate takes a filter, not a policy.
The Writing Tools key equivalents each app's writingtools.rs caller Docs' refusal is guarded by src/editor/writing.ts, which Margin does not have. Same code, different correct answer.
The markdown stack Docs src/markdown 2737 lines against a frozen schema contract. No other app has markdown.
EPUB Margin src-tauri/src/epub.rs 88 lines, one consumer, no second producer or reader in the suite.
The proofing driver and offset mapping both src/editor/proofing.ts 13% overlap. Two incompatible shapes; converging them is a rewrite, and it is not a precondition for any crate here.
Notification content and arrival rules Mail notify.rs 585 lines of "which of these is worth saying out loud", which is entirely Mail's product. Only the 286-line posting layer moves.
TipTap versions all three Docs is pinned exactly for the markdown contract; Mail's editor is 95 lines.