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

30 KiB

Non-component TypeScript across the four apps

Scope: hooks, utilities, stores, the IPC layer. Non-test .ts: margin 4061 lines, margin-calendar 3811, margin-docs 16868, margin-mail 11429. Shorthand: M margin, C margin-calendar, D margin-docs, X margin-mail.

The shared package already exists

margin-shared is at /Users/pj/Workspace/projects/python/margin/shared: a file: dependency of M, D and X, exporting ., ./fonts, ./icons and two stylesheets, 299 lines of source today.

C is not wired to it at all, in TypeScript or CSS. It has no margin-shared in package.json and its src/styles/tokens.css does not @import "margin-shared/css/tokens.css" the way the other three do. That dependency line is the prerequisite for everything here.

The extraction pattern is already proven. M/src/model/fonts.ts (41 lines) and D/src/model/fonts.ts (40) are re-export shims: pull the catalogue from margin-shared/fonts, declare one app-local alias (BookFonts vs DocumentFonts) so call sites keep the app's own noun. Copy that shape.

No app uses tsconfig path aliases, so nothing needs build config beyond the dependency. All three vitest apps run environment: "node", so shared hooks need the typeof window guards D already writes, or vi.stubGlobal as in D/src/width.test.ts:24. No app has a utils/, lib/, helpers/ or hooks/ directory: everything is either a single-purpose top-level module or defined inline atop the one component that needs it.

Byte-identical today

src/escape.ts, all four. 36 lines in M, C and D, all three md5 3b1f67d691647be7d61a23a5acd96a7b. X's is 40 lines, differing only by a four-line header; strip comments and all four hash identically (9c8530a09b35e5d697bb2b95674a2e73). Signature useEscapeLayer(active: boolean, onEscape: () => void): void, a module-level stack of Escape handlers behind one lazily-bound capture-phase listener. Move verbatim, keeping X's header. 144 duplicated lines, zero risk. All three keyboard registries already defer to it by name rather than handling Escape themselves, and its const latest = useRef(onEscape); latest.current = onEscape; is an inline useLatest that falls out of the extraction for free. useMediaQuery, all four src/useMedia.ts, byte-identical (c3499ac5ee3d1a12b138d71b2cc787ed). usePhone and useTouch, C/D/X, byte-identical bodies (d565f08a..., f4285e4c...), with PHONE_QUERY = "(max-width: 640px)" and TOUCH_QUERY = "(pointer: coarse)"; the only difference between the three files is prose describing each app's layout. M has neither, only a stale useCompact on a 899px query that D deleted when it added the phone/touch pair.

src/theme.ts, M/C/X. Strip comments, normalise the key, all three hash identically (ab9c99ff29565c686028e16960274d9d); the literal M-to-C diff is one line. src/store/useTheme.ts, M and C, byte-identical (b48a3bccfadd21b9bb4efa91eef5041c, 16 lines); X's 17 differ only by extracting a set(theme) action. src/store/useToast.ts, C and D, byte-identical (dfc90357bbcfbbfe71ffb8ba0e680c00, 15 lines). M has no toast.

The src/ipc.ts preamble. Lines 1 to 41 of C's and D's are byte-identical (afde11927f75a3a81b2b459658c5b7be), doc comments included: header, isTauri, isMobileOs with its iPadOS carve-out, isDesktop, isMacDesktop, live(). So is the body of call<T> (b4259488afdd6a0f154b8e86eae1ff9c). X has the same code with two comments abridged, plus one real addition at X/src/ipc.ts:752-758: it logs a failed command to Rust before rethrowing, skipping log_note itself to avoid a loop.

src/store/useOverlays.ts, C (64 lines) and X (72). Diffed with comments stripped, the entire difference is the Overlay union and one prettier reflow of push; all seven actions are character-identical. X's header: "Ported from the calendar's store of the same name."

The keys platform block, C/D/X, not one character differing, at C:76-87, D:206-217, X:521-532. Also byte-identical across those three: the NAMED glyph map, commandMatches, isTyping, and pushContext/useKeyContext.

const isMac = typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent ?? "");
export const PRIMARY_LABEL = isMac ? "⌘" : "Ctrl+";
export const primaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
  isMac ? e.metaKey : e.ctrlKey;
export const secondaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
  isMac ? e.ctrlKey : e.metaKey;

The three cleanest lifts

createOverlays<T>(). Nothing in the body knows what an overlay is. Each app writes export const useOverlays = createOverlays<Overlay>(); and keeps its own union. About 130 lines at zero behavioural risk. M and D have no overlay store but both have palettes and dialogs and would adopt it.

export interface OverlayState<T extends string> {
  open: T | null;
  trail: T[];
  show: (overlay: T) => void;
  push: (overlay: T) => void;
  back: () => void;
  reachedFrom: (previous: T) => void;
  toggle: (overlay: T) => void;
  close: () => void;
}
export function createOverlays<T extends string>(): UseBoundStore<StoreApi<OverlayState<T>>>;

onAppEvent. Nobody wraps Tauri's listen, and it shows. X is the exception, at X/src/App.tsx:69-77, signature onAppEvent<T>(name: string, handler: (payload: T) => void): () => void. It returns a synchronous unsubscribe, so each effect is one line, and it falls back to window.addEventListener for a CustomEvent of the same name outside Tauri, which is what lets Playwright drive the connect flow in a browser. C hand-rolls a four-.then(stop => stop()) teardown at C/src/App.tsx:52-86; D uses a third pattern, a module exporting startWorkspaceEvents(): () => void that the shell mounts (D/src/workspace.ts:366-378). The event names are already common property: menu-action in all four, auth, sync-progress and store-changed in C and X, pdf-warnings in M and D. Four apps, the same names, three subscription mechanics. Lift X's nine lines; keep D's "module exports start*()" convention on top.

useToast and notify. X's 34 lines beat the byte-identical 15 in C and D. Strict superset: an optional ToastAction { label, keycap?, run } for undo, and a seq counter bumped on every notice. That counter is a real fix. Auto-dismiss lives in the component in all three, and C's and D's dismiss effects omit a nonce from the dep array, so notifying the same string twice does not restart the countdown; the second toast inherits the remainder of the first one's timer. The dwell times also differ for no reason: 5000ms, 4200ms, 6000ms. M has no toast, only a private notify at M/src/store/useBackup.ts:45 writing into useBook's notice field.

Theme: margin-docs wins, and it is not close

D/src/theme.ts is 167 lines against 16, and every extra line earns it: a seven-palette table with a scheme per row; a real tri-state ThemeChoice = Theme | "system" with a remembered light/dark pair; storedPreference() dropping a stored id that no longer names a theme (without it, a palette retired between releases leaves the root with a data-theme no stylesheet answers, which is not one broken colour but all of them); watchSystemScheme(), the only live prefers-color-scheme subscription in any app; saved()/store() guarding localStorage behind typeof and try/catch; and theme.test.ts (172 lines), the only theme test, holding the TypeScript table and the boot script's copy of it to one table.

X fakes a tri-state at the UI layer: "System" means deleting the stored key (forgetThemeChoice(), X/src/appearance.ts:41), so it is a snapshot, not a subscription. Pick System at night and it stays dark through the morning.

export interface ThemeConfig<T extends string> {
  themes: readonly ThemeInfo<T>[];
  keyPrefix: string;
  fallback: Record<Scheme, T>;
}
export function createThemeStore<T extends string>(config: ThemeConfig<T>): ...;
export function bootScript<T extends string>(config: ThemeConfig<T>): string;

Generating the boot script from the same config is the part to insist on. All four apps hand-maintain an inline <script> in index.html re-reading the theme keys before the bundle exists, and D re-encodes its whole palette-to-scheme table there in plain JS.

The root-setting pattern, six times over

Bigger than theme. Read a boot attribute first because index.html already applied it, fall back to localStorage, apply to the root, write back. src/theme.ts in all four on data-theme; X/src/pane.ts (20 lines) on data-no-pane, its header saying it is "exactly the shape src/theme.ts uses"; D/src/width.ts (52) on data-width; M/src/width.ts (28) doing the same job through a --measure custom property; M/src/panes.ts on --pane-sidebar/--pane-dock with its own private clamp; X/src/appearance.ts (43) on --font-ui, --font-heading and --body-size. The rule these obey is stated three times in three files: a store may not touch the DOM, and a layout fact the stylesheet needs on first paint has to be an attribute, not a class on a component. Write it down once. Key naming is already strict: every key in every app is <slug>-<setting>, slugs margin-, margincal-, margindocs-, marginmail-. Counted: 8 keys in M, 6 in C, 17 in D, 9 in X. That prefix is the only per-app parameter a storage helper needs.

export function makeStorage(prefix: string): {
  readString(key: string, fallback: string | null): string | null;
  readJson<T>(key: string, fallback: T): T;
  write(key: string, value: string): void;
  remove(key: string): void;
};
export function rootSetting<T extends string>(opts: {
  attribute: string; key: string; values: readonly T[]; fallback: T;
}): { initial(): T; apply(value: T): void };

D has written the guarded accessor pair nine times inside one app: theme.ts:105, store/useUpdate.ts:64,73,85, store/useProofing.ts:102,111, store/useDocumentFonts.ts:58,77, workspace.ts:60,70, width.ts:33, with the catch comment ("A webview with storage denied still X, it just forgets between launches") repeated four times with only the verb swapped. Adopting it also fixes unguarded reads in M/theme.ts:8, C/time.ts:16, C/store/useCalendarView.ts:13, X/theme.ts:13, X/pane.ts:14 and D/Outline.tsx:30; several run during module initialisation and would take the whole app down on a throw in a storage-denied webview, and C's is already why components/overlayModel.test.ts:20-31 stubs a global.

Do not reach for zustand's persist middleware, nor a useLocalStorage hook: the boot script reads these keys before any bundle exists, so the shapes must stay plain and hand-chosen. X/pane.ts:1-8 and X/appearance.ts:1-7 both document the constraint. width.ts itself should not be shared either. M has four named widths on a CSS variable, D five on an attribute plus command wiring; only the persistence overlaps, and rootSetting covers it.

The IPC layer

Uniform in three of four and worth stating as the target: a frozen src/ipc.ts holding DTOs mirroring src-tauri/src/dto.rs plus call<T>, and thin per-domain modules in src/api/ doing nothing but naming a command, e.g. export const labelsList = (accountId: string | null) => call<LabelInfo[]>("labels_list", { accountId });

C has 6 api modules and 41 lines, D 9 and 211, X 16 and 386. X's ipc.ts is 760 lines because it has the most DTOs, not because it is structured differently. Only X states the rule explicitly ("Nothing outside src/api may call call directly").

M is the outlier. No src/api, invoke imported directly in 7 files and called at 8 sites. Its 43-line ipc.ts has no isTauri, no live(), no isMacDesktop and no dev-fixture branch, which is why it has no browser dev harness either. And a real bug: M/src/ipc.ts:3 names its constant isDesktop but computes what C/D/X call isTauri. The same identifier means the opposite thing in different apps, and in M it gates runWritingTool, listSystemFonts and gdriveListBackups. M is desktop-only today so it does not bite yet.

Shareable: the whole preamble plus call<T> as a factory, since the dynamic import("./dev/mockIpc") path is necessarily per-app. X's version wins, a strict superset of the byte-identical C and D; the log_note recursion guard is the detail nobody would re-derive. DTOs stay per-app. C/src/ipc/ exists and is empty; delete it.

The dev harness

C, D and X each have src/dev/fixture.ts and src/dev/mockIpc.ts: 134/222 lines, 696/344, 1769/1572. Every mockCall is (command: string, args?: Record<string, unknown>) => Promise<T>, a switch on command ending in the same byte-identical dev mock has no handler for ${command} throw, but the bodies are entirely app-specific. A createMockIpc(handlers) supplying the dispatch and default throw is marginal; the real shared pieces are the mock branch inside call<T> and X's onAppEvent fallback, both covered above. M has no dev harness and cannot be driven in a browser.

Keyboard: three registries, one design, no chords

C, D and X each have src/keys with bindings.ts, keymap.ts, commands.ts, menu.ts. No file is byte-identical, but the scaffolding is one module forked three ways and several blocks inside are identical (listed above).

X's registry should win. Three reasons that matter.

Its resolve() layers contexts instead of replacing them (X/keys/keymap.ts:94-109). A screener or focus frame overrides only the keys it declares and leaves the base view keymap live underneath; only overlay and editor shadow wholesale, via an explicit SHADOWS_VIEW list. C and D fall from the top frame straight to global, which kills the base keymap the moment any non-overlay frame is pushed. X subsumes them; they cannot express X.

Its normalizeCombo treats shift as a first-class modifier, so Cmd+A and Cmd+Shift+A are distinct. D preserves case instead (cmd+F vs cmd+f), which works for letters on a US layout and breaks for punctuation that only exists shifted. C's lowercases behind a modifier, so cmd+F and cmd+f collide silently. That is a live bug.

Its bindings.ts does not import commands.ts, so the table, the sheet and the palette are testable without booting stores or Tauri; in C and D the dependency runs bindings to commands to every store. Its commands.ts is 75 lines with zero store imports: a Map<CommandId, Handler[]>, registerCommands returning its own teardown, last-registered wins, so a screen takes over a verb on mount and hands it back. C's and D's are static tables reaching into the whole app (224 and 433 lines). D is halfway there with its onCommand fan-out, worth keeping alongside the stack for cases needing multiple listeners.

Take menu.ts from D instead: the same three lines everywhere, but only D exports its id list and tests that every menu id names a real command.

No app supports chords. All three carry the same header line: "Nothing is chorded and nothing is modal. Two keys never combine into a third meaning." No pending prefix, no timeout, no sequence buffer. A g i binding is new work; X's Map<string, Binding[]> index and single comboOf extend to it most cleanly.

M has no registry and should adopt one. Four unrelated mechanisms: an if/else if chain on a capture-phase window listener at M/components/EditorView.tsx:167-192, a second competing window listener for Cmd+K in M/editor/FloatingToolbar.tsx:57-70, TipTap extension shortcuts in four files, and two hand-rolled menu-action chains in App.tsx:25-35 and EditorView.tsx:199-206 unaware of each other.

M has no platform detection at all: grep navigator. over its src returns two clipboard calls. The five e.metaKey tests in EditorView.tsx are macOS-only and silently dead on Linux and Windows; primaryHeld fixes that for free. The blocker is that M has no palette and no shortcut sheet, so the "generated, never maintained" payoff has nowhere to land yet.

Stays per-app: BINDINGS (20 rows, 20, ~70), the CommandId union, GROUPS, MENU_IDS, the command implementations, and app-specific KeyContext members. KeyContext has to become a type parameter and SHADOWS_VIEW an app-supplied list. The two isMac regexes differ by design and confusingly little: keys/bindings.ts uses /mac|iphone|ipad/i, ipc.ts uses /mac/i gated on isDesktop, both correct for their purpose. One margin-shared/platform should export both, plus isTauri, isDesktop, isMacDesktop, live, PRIMARY_LABEL, primaryHeld and secondaryHeld, with an injectable user agent so the non-Mac branch is finally testable; no test exercises it today.

Stores: the conventions are already uniform

All 42 stores checked. Every one uses the plain create<State>((set, get) => ({ ... })). Not one uses curried create<T>()(...). No middleware anywhere: zero hits for persist, subscribeWithSelector, immer, devtools, useShallow or zustand/shallow in any app. Nothing is imported from zustand except create.

Selectors are uniformly inline, (s) => s.field, one hook call per field rather than one destructured object. No exported selector functions exist in any store directory in any app. That is why no shallow comparator is needed: every subscription is to a primitive or a stable reference. About 620 inline selector sites and 676 getState() calls.

Two conventions worth writing down because they are universal and currently retyped everywhere: if (!live()) return; as the first line of every backend-touching action, about 40 places; and error: String(e) in state, never e.message.

The async action shape repeats about 100 times: optimistic set, try, replace with the server's answer, catch, roll back and a Could not ... toast. Counts: M 1, C 10, D 33, X 53. I would not abstract it: the bodies are five lines and each message is bespoke. Share the Phase union and a describe(e) helper only.

useSearch, useAccounts and useSync look shareable by name and are not. The three

useSearch stores do three different jobs. useAccounts in C and X shares a real idea, an OAuth consent promise bridged over a Tauri event with the resolver stashed in state, but it is on its third hand-copy (M/store/useBackup.ts:70 to C to X) and is ~60% app-specific. Share the bridge, not the store, plus openAuthUrl/copyAuthUrl, character-identical in both and pure utility.

export function deferred<T>(): { promise: Promise<T>; settle: (value: T) => void };
export function latestOnly<A extends unknown[], R>(fn: (...a: A) => Promise<R>): (...a: A) => Promise<R | undefined>;

latestOnly covers the stale-response race both search stores solve differently: D with module-level monotonic counters (useSearch.ts:20-21), X by re-comparing the query string (useSearch.ts:108). D's is more general; X's breaks if two callers share a query.

X's useSync is the best sync store, because ~160 of its 210 lines are pure exported functions over SyncStatus[] rather than store code, with a 9.6KB test behind them. C keeps the same dedupe logic as an effect-local closure variable in App.tsx.

Cross-cutting utilities

Confirmed absent from all four: any debounce or throttle utility; deep equality; a safeParse/tryParse wrapper; class-name joining (no cx, no clsx, no classNames, and no such dependency); measureText or any text-measurement helper; Intl.RelativeTimeFormat; Intl.PluralRules; navigator.userAgentData; nanoid/uuid or any id counter; and useLocalStorage, useInterval, useRaf, useEvent, useLatest, useMountedRef or an exported sleep. No truncation helper; truncation is done in CSS.

The class-name negative is a deliberate architectural choice, not an oversight: all four apps style off data attributes, so no app ever concatenates class names. Do not introduce cx. debounce. Written longhand at 15 sites across all four. The React-effect variant is the same five lines in at least six places and is worth one useDebounced<T>(value, ms); D's QuickOpen.tsx:88 and FindInFiles.tsx:70 are already identical and name the constant DEBOUNCE_MS. Leave the class-field and module-level timers alone; their cancel/flush semantics are the point.

clamp: 6 definitions plus 12 inline sites, zero shared. C/grid/fit.ts:89 and C/components/QuickCreateModel.ts:147 are the identical one-liner duplicated within C:

const clamp = (n: number, lo: number, hi: number) => (n < lo ? lo : n > hi ? hi : n);

C/components/EventDetailsModel.ts:45 should win: it is the only one guarding an inverted range, which matters because half the call sites pass length - 1 as the high bound and that goes negative on an empty list. QuickCreateModel.ts:156,163,171 already works around exactly this at every call. Add a clampIndex(i, length) for the ten index sites. M/ExportPreview.tsx:92 and D/ExportPreview.tsx:143 are the byte-identical zoom clamp.

Byte size: four implementations, three spellings. D/UpdateDialog.tsx:21 says kB, D/FileViewer.tsx:43 says bytes and KB with one decimal, X/screens/format.ts:52 says B/KB/MB with rounding, X/screens/Settings.tsx:940 adds GB and adaptive precision. 1536 bytes renders as "1.5 KB", "2 kB" and "2 KB" in one product family. X's space() wins: the only one reaching GB, and its mb < 10 ? toFixed(1) : Math.round(mb) rule actually solves what D's UpdateDialog comment states ("so the number under the bar stops twitching"), by significant figures rather than fixed decimals.

Relative time: three hand-rolled ladders, no two agreeing. M/src/time.ts:1 is the only standalone module; M/src/backup.ts:61 is a second copy in the same app with the prefix baked into every branch; D/components/Settings.tsx:45 is a third, inline, with "Checked" hardcoded throughout. They diverge in behaviour, not just text: M floors, D rounds; M's cutover is 7 days, D's 30. D's rounding makes 89 seconds "just now" and 91 seconds "2 minutes ago". M's wins: the only one guarding clock skew with Math.max(0, now - ts), and flooring is right for "how long ago". Callers compose their own prefix, deleting backup.ts:61 and reducing Settings.tsx:45 to one line. X has no "X ago" at all; rowTime/messageTime (screens/format.ts:34-49) go straight to calendar-day buckets and are the only implementation counting by calendar day via local midnight rather than elapsed hours, which is the difference between "Yesterday" being right and being off by a few hours every evening. Keep both.

useClock is the missing half. C/src/useClock.ts (useTick<T>, useMinuteTick, useHourStart) re-schedules against wall-clock rather than a setInterval and treats visibilitychange and focus as ticks, because a timer's deadline is measured in time the machine spent awake. C is its only consumer today, but M's relativeTime callers both take a now prop that has to come from somewhere, and X's row formatters default to Date.now() at call time and go stale on a window left open overnight. Ship it beside relativeTime.

Intl.DateTimeFormat: no shared factory. The same { day: "numeric", month: "short" } formatter is constructed in 6 X files and the hourCycle: "h23" clock in 3. M and D use bare toLocaleDateString(). A memoised dateFormat(options) collapses the lot; X's cached-module-constant style is the right pattern.

C's src/time.ts (136 lines) avoids Intl entirely with hand-written day and month arrays, a deliberate product decision for a calendar grid that should stay. Its startOfDay, addDays, isSameDay, toDateOnly, parseDateOnly are reusable date maths and should move; startOfDay is already written three times across C and X (C/time.ts:23, X/screens/format.ts:20, X/SnoozePicker.tsx:72).

Focus management: one app has it, three do not. python/margin/src/focus.ts (56 lines) is a proper trap: a FOCUSABLE selector, Tab wrapping both directions, opener restore on teardown, and a module-level focusTrapped() so the global key handler can stand down. Five call sites in M.

export function useFocusTrap(ref: RefObject<HTMLElement | null>, active = true): void;
export function focusTrapped(): boolean;

C has autofocus only, no trap and no restore (components/overlayShell.tsx:51-56). D and X have neither: both ship role="dialog" aria-modal="true" markup with imperative .focus() on mount and no trap behind it. This is the one item where sharing fixes an accessibility gap in three apps rather than removing duplication. The wrinkle: M's focusTrapped() and the other apps' context stack are two mechanisms for one idea. Either the trap pushes an overlay frame, or the shared keymap takes a "something external owns the keyboard" predicate.

Scroll position restore: near-identical in M and D, and D says so. python/margin/src/editor/positions.ts (64 lines) and rust/margin-editor/src/editor/positions.ts (52) share an identical { from, to, scroll } interface, the same guarded readAll/write, and the same load/save API. D's header names the relationship: M keys by book then chapter, D by absolute path, "and that path is the whole key". D adds an LRU trim at LIMIT = 200; M's map grows unbounded. D wins, with a flat string key M composes as ${bookId}/${chapterId}.

scrollIntoView is raw at 8 sites, never wrapped; M/editor/search.ts:98 and D/editor/search.ts:111 are byte-identical. Worth extracting alongside it is D/components/Outline.tsx:79-89, the only implementation avoiding scrollIntoView scrolling every ancestor, already duplicated once inside D at editor/linkPicker.ts:572.

JSON. 10 JSON.parse sites, 8 guarded inline, 2 not: M/library.ts:31 and M/project.ts:19 parse a file read off disk with no catch, so a truncated or hand-edited project file throws unhandled. Fold the guarded ones into readJson<T>(key, fallback); the file reads want parseJson<T>(text): T | null. D's const parsed: unknown = JSON.parse(...) then narrow is the right discipline; M and X cast straight to the target type. plural is the same function under different names in M/store/useBackup.ts:56 and D/linkRewrite.ts:694, plus 10 open-coded ${n === 1 ? "" : "s"} sites including two byte-identical ones in X/screens/ReadingPane.tsx:235,502. M's name wins.

ResizeObserver: 13 raw instantiations, no hook. M/ExportPreview.tsx:64 and D/ExportPreview.tsx:104 are byte-identical, as are M:238/D:339 and the IntersectionObserver at M:286/D:395. Any shared useResize must carry the null guard X/screens/MessageBody.tsx:207 documents and the others lack: "ResizeObserver.observe(null) throws hard enough to take the screen with it". getBoundingClientRect has 32 inline sites, all positioning rather than text metrics; the popover-placement clamp is duplicated across four apps but the anchoring rules differ meaningfully, so it is the lowest-confidence item here. Id generation is M only, crypto.randomUUID() raw at 8 app-domain sites; C, D and X take ids from Rust, so there is nothing to share.

Updates: four apps, four answers

M has src/updater.ts (111 lines) plus store/useUpdater.ts (34), a direct vs appstore channel split, and it holds the live Update resource handle in the zustand store. C has keys/updates.ts (41), toast-only, "Ported from margin's src/updater.ts, minus its progress dialog"; it is the only one of the four with a packagedBy() guard, so a nix or homebrew install is told to update through its package manager instead of self-updating. X has no update module at all: checkForUpdates is inline at X/src/App.tsx:98-118, with a second copy in screens/Settings.tsx.

D has src/update.ts (171) plus store/useUpdate.ts (129), the best of the four: named phase transitions rather than a generic set(partial); total: number | null so a missing content-length draws an indeterminate bar instead of 0% forever; version and notes as plain strings so no Rust handle sits in the store; a launch delay and 24-hour interval for automatic checks; a flush of the pending save before relaunch(); and a discriminator for "this dev build has no updater plugin" so that reads as a sentence about the build. Take D's store and driver, fold in C's packagedBy guard as an option.

Bugs found, worth fixing regardless of any extraction

  • marginmail-theme is declared as two constants in two files, X/src/theme.ts:8 and X/src/appearance.ts:14.
  • X's checkForUpdates lacks C's packagedBy() guard, so a package-manager-installed build will try to self-update over a path it does not own.
  • C's normalizeCombo lowercases the key behind a modifier, so cmd+F and cmd+f are one binding.
  • M/src/ipc.ts:3 names isDesktop what the other three call isTauri, and it gates three IPC calls.
  • M/library.ts:31 and M/project.ts:19 parse a file off disk with no catch.

Ranked

  1. escape.ts. ~144 lines, byte-identical in four apps.
  2. createOverlays<T>(). ~130 lines, code-identical today.
  3. platform.ts (both halves). ~50 lines, byte-identical in three, fixes two M bugs.
  4. onAppEvent. ~60 lines, better teardown, unlocks browser testing for two apps.
  5. useMedia.ts. ~120 lines, byte-identical bodies in three apps.
  6. useToast plus notify. ~60 lines, strict superset, fixes a timer bug in two.
  7. makeStorage and rootSetting. ~120 lines, deletes nine copies in D, fixes six unguarded reads.
  8. call<T> factory plus the ipc.ts preamble. ~50 lines.
  9. Theme, from D, with a generated boot script. ~250 lines, needs a per-app table.
  10. Small utilities: clamp, fileSize, relativeTime plus useClock, plural, positions.ts, useDebounced, dateFormat, latestOnly, deferred. Collectively ~250 lines and several user-visible inconsistencies.
  11. useFocusTrap. Not a saving; an accessibility fix for three apps.
  12. The keyboard registry, from X. Largest and highest-value, but needs KeyContext parameterised and SHADOWS_VIEW injected, and M needs a palette before it benefits.
  13. The update store and driver. Four designs to reconcile; do it last.

Items 1 to 6 are mechanically identical across apps today and drop into the existing package with no behaviour change. The one prerequisite is adding margin-shared to margin-calendar.