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

39 KiB

The shared hooks: @margin/hooks and @margin/ipc

Scope: hooks, utilities, the theme, the keyboard registry, the escape stack, the IPC wrapper, the updater and the zustand conventions. The React components that consume all of it are ui-kit.md; nothing here specifies a component. Short names as in design-system.md: margin = python/margin, calendar = python/margin-caledar, editor = rust/margin-editor, mail = rust/margin-mail, under /Users/pj/Workspace/projects. Line numbers and hashes are as of 2026-09-06; the audit behind this is .research/frontend-utils.md.

One missing dependency line, and a template that already works

Calendar is not wired to the shared package at all, in TypeScript or in CSS. There is no margin-shared in python/margin-caledar/package.json, where margin has it at line 26 (file:./shared), editor at 33 and mail at 27 (file:../../python/margin/shared), and python/margin-caledar/src/styles/tokens.css does not import the shared tokens the way python/margin/src/styles/tokens.css:3, rust/margin-editor/src/styles/tokens.css:5 and rust/margin-mail/src/styles/tokens.css:6 do. Every extraction below is blocked on that one line, because a hook calendar cannot import is a hook that gets copied instead. Nothing else is needed for build config: no app uses tsconfig path aliases, so a dependency plus Vite's own resolution is the whole mechanism.

The migration shape does not need inventing either. python/margin/src/model/fonts.ts (41 lines) and its twin rust/margin-editor/src/model/fonts.ts (40) are already re-export shims: they pull the catalogue from margin-shared/fonts, declare one app-local alias so call sites keep the app's own noun (BookFonts in margin, DocumentFonts in editor), and carry a header saying why the catalogue is not theirs. Two apps, one module, in production today. Every module below follows it: the app's file stays where it is and becomes a re-export, no call site moves, and the shim is deleted later only if it holds no alias worth keeping.

Byte-identical today, verified by hash

Compared with md5, not by reading them. Nothing in this section is a judgement call.

Module Apps Lines Hash Note
src/escape.ts margin, calendar, editor 36 each 3b1f67d691647be7d61a23a5acd96a7b whole file, identical
src/escape.ts mail 40 6d9abcfb42b082961110af9a33647f19 four-line header only
src/escape.ts all four 148 9c8530a09b35e5d697bb2b95674a2e73 comments stripped
useMediaQuery all four 13 each c3499ac5ee3d1a12b138d71b2cc787ed the function, not the file
usePhone calendar, editor, mail 17 each d565f08a457563fd8ef874a6435a2bbd
useTouch calendar, editor, mail 17 each f4285e4c...
src/theme.ts margin, calendar, mail 16/16/21 ab9c99ff29565c686028e16960274d9d comments stripped, key normalised
src/store/useTheme.ts margin, calendar 16 each b48a3bccfadd21b9bb4efa91eef5041c mail's 17 add a set(theme)
src/store/useToast.ts calendar, editor 15 each dfc90357bbcfbbfe71ffb8ba0e680c00
src/ipc.ts lines 1 to 41 calendar, editor 41 each afde11927f75a3a81b2b459658c5b7be doc comments included
call<T> body calendar, editor 8 each b4259488afdd6a0f154b8e86eae1ff9c
the platform block calendar keys/bindings.ts:76-87, editor :206-217, mail :521-532 12 each identical isMac, PRIMARY_LABEL, primaryHeld, secondaryHeld

src/store/useOverlays.ts is the near miss worth naming: calendar's 64 lines and mail's 72 differ only in the Overlay union and one reflow of push, and all seven actions are character-identical. Mail's header already says "Ported from the calendar's store of the same name."

The plan is ordered by confidence and mechanical safety, not by line count. The theme is the largest single win and it is ninth, because it changes behaviour in three apps. The keyboard registry is the highest-value item here and it is twelfth, because it needs two type parameters that do not exist yet. What goes first is what can move without anybody having to think.

# Item Size Risk
1 escape.ts ~148 none, identical in four
2 createOverlays<T>() ~130 none, identical in two
3 platform.ts, both halves ~50 none in three; fixes two margin bugs
4 onAppEvent ~60 low; unlocks browser tests for two apps
5 media and layout hooks ~190 low
6 useToast and notify ~60 low; strict superset, fixes a timer bug in two
7 makeStorage, rootSetting ~120 low; deletes nine copies in editor, fixes six unguarded reads
8 call<T> and the ipc.ts preamble ~50 low
9 the theme, from editor ~250 medium; needs a per-app table and a generated boot script
10 small utilities ~250 medium; several user-visible inconsistencies get picked
11 useFocusTrap ~56 medium; a fix, not a saving
12 the keyboard registry, from mail ~900 high; needs KeyContext parameterised
13 the update store and driver ~490 high; four designs to reconcile

Items 1 to 8 are mechanically identical across apps today and land with no behaviour change.

The modules

The winners are spread across all four apps, and that is the point: the temptation is to name one app the reference implementation and take everything from it, which would ship three regressions. Editor wins the theme and storage, mail the registry, the toast and the IPC wrapper, margin the focus trap and relative time, calendar the media hooks, the date maths and the updater's package-manager guard.

The escape stack, from any of the four

A module-level stack of Escape handlers behind one lazily bound capture-phase listener, so Escape unwinds layers in the order they went up instead of every component thinking it owns the key. Moves verbatim, keeping mail's four-line header, the only copy that explains itself.

export function useEscapeLayer(active: boolean, onEscape: () => void): void;

The app supplies nothing. All three keyboard registries already defer to it by name rather than handling Escape themselves, so extracting it does not touch them. Its const latest = useRef(onEscape); latest.current = onEscape; is an inline useLatest that falls out for free; export it when a second caller appears, not before.

Media and layout, from calendar

Calendar's src/useMedia.ts wins over editor's identical file only because calendar has to adopt the package anyway. Mail has no useCompact, and margin's is the bare one-liner useMediaQuery("(max-width: 899px)") at python/margin/src/useMedia.ts:15 without the data-compact write, so the responsive layout described in guidelines/platform.md is currently true of calendar and editor only.

export function useMediaQuery(query: string): boolean;
export function useCompact(): boolean;   // also writes data-compact on the root
export function usePhone(): boolean;
export function useTouch(): boolean;
export const PHONE_QUERY = "(max-width: 640px)";
export const TOUCH_QUERY = "(pointer: coarse)";

The app supplies its index.html boot script's copy of the same two queries, which calendar's comment at useMedia.ts:27 already flags as needing to stay in step. Same problem as the theme, same answer: generate the boot script from the constants.

The theme, from editor

rust/margin-editor/src/theme.ts is 167 lines against margin's and calendar's 16 and mail's 21, and every extra line earns it. A seven-palette table with a scheme per row (:35), so light and dark are properties of a theme rather than the only two themes. A real tri-state ThemeChoice = Theme | "system" (:20) with a remembered light/dark pair under margindocs-theme-light and margindocs-theme-dark (:60, :61), so choosing System does not forget which dark theme you liked. storedPreference() (:137) dropping a stored id that no longer names a theme: without it a palette retired between releases leaves the root carrying a data-theme no stylesheet answers, which is not one broken colour, it is all of them. watchSystemScheme() (:164), the only live prefers-color-scheme subscription in any of the four. saved() and store() (:105, :115) guarding localStorage behind typeof and try/catch. And theme.test.ts, 172 lines, the only theme test in the family, holding the TypeScript table and the boot script's copy of it to one table.

Mail fakes the tri-state at the UI layer: "System" deletes the stored key (forgetThemeChoice(), rust/margin-mail/src/appearance.ts:41). That is a snapshot, not a subscription. Pick System at night and mail stays dark through the morning.

export type Scheme = "light" | "dark";
export interface ThemeInfo<T extends string> { id: T; label: string; scheme: Scheme }
export interface ThemeConfig<T extends string> {
  themes: readonly ThemeInfo<T>[];
  keyPrefix: string;                       // "margindocs", "marginmail", ...
  fallback: Record<Scheme, T>;
}
export function createThemeStore<T extends string>(config: ThemeConfig<T>): UseBoundStore<StoreApi<ThemeState<T>>>;
export function bootScript<T extends string>(config: ThemeConfig<T>): string;

The app supplies the table, the key prefix and the fallback pair. 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 editor re-encodes its whole palette-to-scheme table there in plain JavaScript.

Storage and root settings, from editor

Bigger than the theme, and what makes the theme extraction tractable. Read a boot attribute first because index.html already applied it, fall back to localStorage, apply to the root, write back. Six copies: src/theme.ts in all four on data-theme; rust/margin-mail/src/pane.ts (20 lines) on data-no-pane, whose header says it is "exactly the shape src/theme.ts uses"; rust/margin-editor/src/width.ts (52) on data-width; python/margin/src/width.ts (28) doing the same job through a --measure custom property; python/margin/src/panes.ts (47) on --pane-sidebar and --pane-dock with its own private clamp; rust/margin-mail/src/appearance.ts (43) on --font-ui, --font-heading and --body-size.

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 };

The prefix is the only per-app parameter: every key in every app is already <slug>-<setting>, slugs margin-, margincal-, margindocs-, marginmail-, 8 keys in margin, 6 in calendar, 17 in editor, 9 in mail. Editor wins because it 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 same catch comment four times, the verb swapped each time. The rule all six obey is stated in three files and should be written once: a store may not touch the DOM, and a layout fact the stylesheet needs on first paint has to be an attribute on the root, not a class on a component.

Do not reach for zustand's persist middleware or a useLocalStorage hook. The boot script reads these keys before any bundle exists, so the stored shapes stay plain and hand-chosen; rust/margin-mail/src/pane.ts:1-8 and appearance.ts:1-7 both document the constraint. width.ts itself is not shared: margin has four named widths on a CSS variable, editor five on an attribute plus command wiring, and only the persistence overlaps.

The keyboard registry, from mail

Three reasons, all of them things calendar and editor cannot express.

resolve() layers contexts instead of replacing them (rust/margin-mail/src/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, through an explicit SHADOWS_VIEW list at keymap.ts:94. Calendar and editor fall from the top frame straight to global, which kills the base keymap the moment any non-overlay frame is pushed. Mail's comment at keymap.ts:90-92 records that it was written that way once, and that this is the bug the comment exists to prevent.

normalizeCombo (keys/bindings.ts:545) carries shift in the prefix alongside cmd, ctrl and alt, and handles the two combos a naive split breaks, a key of + and a key of space. Calendar's and editor's filter shift out entirely.

bindings.ts does not import commands.ts, so the table, the sheet and the palette are testable without booting a store or Tauri; in calendar and editor the dependency runs bindings to commands to every store, which is why editor's commands.ts is 433 lines and calendar's 224. Mail's is 75 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 on unmount. Editor's onCommand fan-out is worth keeping alongside the stack for cases needing several listeners, and menu.ts comes from editor, the same three lines everywhere but the only one exporting its id list and testing that every menu id names a real command.

export function createKeymap<C extends string, K extends string>(opts: {
  bindings: readonly Binding<C, K>[];
  shadowsView: readonly K[];
  run: (command: C) => boolean;
}): { attach(): () => void; pushContext(context: K): () => void; useKeyContext(context: K, active?: boolean): void };
export function createCommands<C extends string>(): {
  register(handlers: Partial<Record<C, Handler>>): () => void;
  run(command: C): boolean;
};
export const PRIMARY_LABEL: string;
export const primaryHeld: (e: { metaKey: boolean; ctrlKey: boolean }) => boolean;
export const secondaryHeld: (e: { metaKey: boolean; ctrlKey: boolean }) => boolean;
export function normalizeCombo(combo: string): string;
export function comboLabel(combo: string): string;

The app supplies BINDINGS (20 rows in calendar, 20 in editor, about 70 in mail), the CommandId union, GROUPS, MENU_IDS, the command implementations and its own KeyContext members. KeyContext becoming a type parameter and SHADOWS_VIEW an app-supplied list is the whole cost of the extraction.

No app supports chords. All three registries 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, so a g i binding is new work, and mail's Map<string, Binding[]> index with its single comboOf extends to it most cleanly.

Margin has no registry and four unrelated mechanisms instead: an if/else if chain on a capture phase window listener at python/margin/src/components/EditorView.tsx:167-192, a second competing window listener for Cmd+K at src/editor/FloatingToolbar.tsx:57-70, TipTap extension shortcuts in four files, and two hand-rolled menu-action chains at App.tsx:25-35 and EditorView.tsx:199-206 unaware of each other. It also has no platform detection at all, so its five e.metaKey tests are macOS-only and silently dead on Linux and Windows; primaryHeld fixes that for free. The blocker on the rest is that margin has no palette and no shortcut sheet, so the generated sheet, which is most of the payoff, has nowhere to land. Adopt platform.ts now and the registry when the palette exists.

The two isMac regexes differ by design and confusingly little: keys/bindings.ts uses /mac|iphone|ipad/i because a Mac keyboard layout is what it asks about, ipc.ts uses /mac/i gated on isDesktop because a title bar inset is what it asks about. Both are correct. One platform module exports both, with an injectable user agent so the non-Mac branch is finally testable; no test exercises it today in any app.

The toast, from mail

Mail's 34 lines are a strict superset of the byte-identical 15 in calendar and editor: an optional ToastAction { label, keycap?, run } for undo, and a seq counter bumped on every notice.

export interface ToastAction { label: string; keycap?: string; run: () => void }
export const useToast: UseBoundStore<StoreApi<ToastState>>;
export function notify(message: string, action?: ToastAction): void;
export const DISMISS_MS: number;

The counter is a real fix. Auto-dismiss lives in the component in all three, and calendar's effect deps are [message, dismiss] (python/margin-caledar/src/components/Toast.tsx:14) where mail's are [message, seq, dismiss] (rust/margin-mail/src/screens/Toasts.tsx:23), so in calendar and editor notifying the same string twice does not restart the countdown and the second toast inherits the remainder of the first one's timer. The dwell times differ for no reason: 4200ms (rust/margin-editor/src/components/Toast.tsx:4), 5000ms (calendar :4), 6000ms (mail Toasts.tsx:6). Pick one in the package and let an app override it only with a reason in a comment. Margin has no toast, only a private notify at src/store/useBackup.ts:45 writing into useBook's notice field; it adopts the store when it takes the primitive from ui-kit.md.

The focus trap, from margin

python/margin/src/focus.ts, 56 lines, is the only implementation in the family and the winner by default: a FOCUSABLE selector filtered for hidden and detached nodes, Tab wrapping both directions, opener restore on teardown, and a module-level counter behind focusTrapped() so the global key handler can stand down.

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

Twelve call sites across ten files in margin today, plus two focusTrapped() reads at components/EditorView.tsx:125,168. The app supplies a ref and, for popovers, the open flag. What it fixes elsewhere is its own section below.

The updater, from editor, with calendar's guard

Four apps, four answers. Editor's src/update.ts (171 lines) plus src/store/useUpdate.ts (129) wins: named phase transitions (begin, offer, progress, installing, failed, dismiss) rather than a generic set(partial); total: number | null so a missing content length draws an indeterminate bar instead of nought percent forever; version and notes as plain strings so no Rust resource handle sits in the store, which is the mistake python/margin/src/store/useUpdater.ts makes; a launch delay and a 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 rather than an error. Fold in calendar's packagedBy() guard and updateHint() from python/margin-caledar/src/keys/updates.ts:13-33: calendar is the only one of the four that tells a nix or homebrew install to update through its package manager.

export function createUpdater(opts: {
  appName: string;
  packagedBy?: () => Promise<string | null>;
  launchDelayMs?: number;
  intervalMs?: number;
  beforeRelaunch?: () => Promise<void>;
}): { store: UseBoundStore<StoreApi<UpdateState>>; check(manual: boolean): Promise<void>; install(): Promise<void> };

The app supplies its name for the copy, its packagedBy command if it has one, and its beforeRelaunch flush. Mail has no update module at all: checkForUpdates is inline at rust/margin-mail/src/App.tsx:98-118, with a second differently shaped copy at src/screens/Settings.tsx:2478-2499.

onAppEvent, from mail

Nobody wraps Tauri's listen, and it shows. Mail is the exception, at rust/margin-mail/src/App.tsx:69-77.

export function 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 mail's connect flow in a browser. Calendar hand-rolls a four-.then(stop => stop()) teardown at python/margin-caledar/src/App.tsx:52-86; editor uses a third pattern, a module exporting startWorkspaceEvents(): () => void that the shell mounts (rust/margin-editor/src/workspace.ts:366-378). The names are already common property: menu-action in all four, auth, sync-progress and store-changed in calendar and mail, pdf-warnings in margin and editor. Lift mail's nine lines, keep editor's "a module exports start*()" convention on top.

The small utilities

Individually trivial, collectively about 250 lines and several user-visible inconsistencies. Each app keeps its own call sites; only the implementation moves.

Helper Winner Why that one
clamp, clampIndex calendar/components/EventDetailsModel.ts:45 six definitions and twelve inline sites today; only this one guards an inverted range, and half the callers pass length - 1, which goes negative on an empty list
fileSize mail/screens/format.ts:52 four implementations, three spellings; 1536 bytes renders as "1.5 KB", "2 kB" and "2 KB" in one product family
relativeTime margin/src/time.ts:1 the only one guarding clock skew with Math.max(0, now - ts), and flooring, which is right for "how long ago"; editor's rounding makes 89 seconds "just now" and 91 seconds "2 minutes ago"
useTick, useMinuteTick, useHourStart calendar/src/useClock.ts re-schedules against the wall clock and reads visibilitychange and focus as ticks, because a timer's deadline is measured in time the machine spent awake
rowTime, messageTime mail/screens/format.ts:34-49 kept beside relativeTime, not merged into it: the only one bucketing by local midnight, which is the difference between "Yesterday" being right and being wrong every evening
plural margin/store/useBackup.ts:56 same function under a different name at editor/linkRewrite.ts:694, plus ten open-coded sites
positions.ts editor/src/editor/positions.ts scroll and selection restore; adds the LIMIT = 200 LRU trim margin's unbounded map lacks, on a flat string key margin composes as ${bookId}/${chapterId}
useDebounced<T>(value, ms) new, from six identical effects the class-field and module-level timers stay put, their cancel and flush semantics are the point
dateFormat(options) mail, cached module constants the same { day: "numeric", month: "short" } formatter is constructed in six mail files
latestOnly, deferred new; editor's counter, calendar's promise bridge covers the stale-response race the two search stores solve differently
parseJson<T>(text): T | null new for text off disk; readJson on the storage helper covers the eight guarded localStorage parses
useResize needs mail/screens/MessageBody.tsx:207's null guard thirteen raw ResizeObserver instantiations, no hook, and only that one comment says why the guard is there
scrollIntoViewIfNeeded editor/components/Outline.tsx:79-89 the only one that does not scroll every ancestor, already duplicated once inside editor
date maths calendar/src/time.ts startOfDay, addDays, isSameDay, toDateOnly, parseDateOnly; startOfDay alone is written three times across calendar and mail

Calendar's time.ts avoids Intl entirely with hand-written day and month arrays. That is a deliberate product decision for the grid and it stays in calendar; only the date maths moves.

The zustand conventions

Nothing to extract, and worth writing into the package README because it is currently retyped in every store. All 42 stores across the four apps use plain create<State>((set, get) => ({ ... })); not one uses the curried create<T>()(...). No middleware anywhere: zero hits for persist, subscribeWithSelector, immer, devtools, useShallow or zustand/shallow, and nothing is imported from zustand except create. Selectors are uniformly inline, (s) => s.field, one hook call per field rather than one destructured object, which is why no shallow comparator is needed: every subscription is to a primitive or a stable reference.

Two conventions are universal and belong in the docs rather than in code: if (!live()) return; as the first line of every backend-touching action (31 uses in mail, 6 in calendar, 1 in editor, 0 in margin, which has no live() to call), and error: String(e) in state, never e.message (11, 6, 56, 21). The async action shape repeats about 97 times (margin 1, calendar 10, editor 33, mail 53): optimistic set, try, replace with the server's answer, catch, roll back and a "Could not ..." toast. Do not abstract it. The bodies are five lines and every message is bespoke. Share the Phase union and describe(e), nothing more.

Three stores look shareable by name and are not. The three useSearch stores do three different jobs. useAccounts in calendar and mail shares one 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 (margin/store/useBackup.ts:70 to calendar to mail) and is about 60% app-specific: share the bridge (deferred) and openAuthUrl/copyAuthUrl, character-identical in both and pure utility. Mail's useSync is the best sync store because roughly 160 of its 210 lines are pure exported functions over SyncStatus[] with a 9.6KB test behind them, but the store around them is mail's.

@margin/ipc

Three of the four apps already agree on the target and it is worth stating as the target: a frozen src/ipc.ts holding the DTOs that mirror src-tauri/src/dto.rs, plus call<T>, with thin per-domain modules in src/api/ that do nothing but name a command. Calendar has 6 api modules over a 41-line preamble, editor 9, mail 16; mail's ipc.ts is 760 lines because it has the most DTOs, not because it is structured differently. Only mail states the rule out loud, at ipc.ts:4: "Nothing outside src/api may call call directly." That sentence goes in the package README. The DTOs stay per-app permanently; they mirror one Rust file per app and there is nothing shared about them.

export const isTauri: boolean;                       // "__TAURI_INTERNALS__" in window
export const isDesktop: boolean;                     // isTauri and not a mobile OS
export const isMacDesktop: boolean;                  // isDesktop and a Mac user agent
export const live: () => boolean;                    // isTauri or import.meta.env.DEV
export type Phase = "idle" | "fetching" | "error";
export function describe(e: unknown): string;
export function createCall(opts: { mock?: () => Promise<MockIpc> }): <T>(command: string, args?: Record<string, unknown>) => Promise<T>;

The three booleans are not interchangeable, and the doc comments calendar wrote at src/ipc.ts:7-33 say why: core:window:* sits in the desktop-only capability, so on a phone those commands are refused rather than absent, and a Tauri event fires on mobile too.

The naming disagreement. python/margin/src/ipc.ts:3 declares export const isDesktop and computes exactly what the other three call isTauri. The same identifier means the opposite thing in two of the four apps, and in margin it gates runWritingTool, listSystemFonts and gdriveListBackups. guidelines/platform.md already records the confusion in its own words. isTauri wins because it says what it tests. Margin renames on adoption, and those three calls take isTauri, not the mobile-safe isDesktop they never meant.

The phase union. guidelines/errors-and-feedback.md requires a string phase union on every handler that waits, with data-phase or data-busy on the control and a present-tense label. Phase is the base union and an app widens it with domain members rather than inventing a parallel one; editor's six-member UpdatePhase at store/useUpdate.ts:26-33 is the worked example, and its comment about why there is no "done" member is the reasoning to copy. The primitives in ui-kit.md consume Phase directly, which is what stops an app forgetting the busy state.

The logging rule. Every IPC failure has to reach the app's log file on disk, and a single transient failure must never toast. call is where that is enforced, and mail is the only app enforcing it, at rust/margin-mail/src/ipc.ts:752-758: the rejection is written to Rust with log_note before being rethrown, and the write is skipped when the command is log_note itself, which is the recursion guard nobody would re-derive. Those eight lines are why mail's call wins outright over the byte-identical calendar and editor versions. call logs and rethrows; it never swallows and it never notifies. The caller decides whether a failure is worth a sentence.

The error shape. Today every rejection crosses the boundary as a string and the convention is error: String(e) in state, which is why describe(e) is the whole shared surface for now. When the margin-ipc crate in rust-crates.md lands its serde error type, the TypeScript mirror and an isCommandError guard belong here and describe narrows through it. Do not invent the union before the Rust side has one.

The dev fixture stub. The mock branch inside call<T> is shared; the fixtures behind it are not. Calendar, editor and mail each have src/dev/fixture.ts and src/dev/mockIpc.ts at 222/134, 344/696 and 1572/1769 lines, every mockCall a switch on command ending in the same byte-identical dev mock has no handler for ${command} throw, with entirely app-specific bodies. createCall takes the app's loader as opts.mock and keeps the import.meta.env.DEV && !isTauri gate that compiles the branch out of a production bundle. The harness itself, createMockIpc(handlers) and the fixture loader, is testing.md's and @margin/test's; it is named here only so the seam is unambiguous. Margin has no dev harness and cannot be driven in a browser at all, which is what adopting createCall unlocks for it.

While in there: python/margin-caledar/src/ipc/ exists and is empty. Delete it.

The five bugs found on the way

Bug Where What breaks, and for whom When
One key, two constants mail/src/theme.ts:8 and mail/src/appearance.ts:14 both declare marginmail-theme a rename in one file silently stops the other reading the value, and appearance is the writer. Mail only before: the theme extraction touches both files
Self-update over a package manager mail/src/App.tsx:98-118 and mail/screens/Settings.tsx:2478-2499; mail calls packagedBy() at Settings.tsx:2475 and uses the answer only to print it at :2516 a nix or homebrew install downloads and writes over a path it does not own. Mail, on any install that is not the DMG before: it is a write into another program's files
Two chords, one binding calendar/keys/bindings.ts:90-96: line 94 filters shift out of the modifier prefix, line 95 lowercases the key Cmd+Shift+F and Cmd+F normalise to the same string, so one silently wins and the other is dead. Calendar; editor's :224-230 preserves case instead, which works for letters on a US layout and breaks for punctuation that only exists shifted rides along: mail's normalizeCombo is the fix
The IPC gate misnamed margin/src/ipc.ts:3 isDesktop computes "is Tauri" and gates three IPC calls. Margin is desktop-only today so it does not bite yet, and it bites the day it ships to a phone rides along: the rename is the adoption
Unguarded disk parses margin/src/library.ts:31 and margin/src/project.ts:19 JSON.parse on a file read off disk, cast straight to Book. library.ts has a try and rethrows a readable sentence; project.ts:19 has neither, so opening a truncated or hand-edited project file throws unhandled out of openBook. Margin before: it is four lines

The two disk parses are the tail of a wider pattern the storage helper closes: unguarded localStorage reads at margin/theme.ts:8, calendar/time.ts:16, calendar/store/useCalendarView.ts:13, mail/theme.ts:13, mail/pane.ts:14 and editor/components/Outline.tsx:30. Several run during module initialisation, so in a webview with storage denied they take the whole app down on a throw, and calendar's is already why components/overlayModel.test.ts:20-31 has to stub a global.

The accessibility gap

The one item here that is a fix rather than a saving.

Margin is the only app with a focus trap, and margin ships no role="dialog" and no aria-modal at all. The three apps that do declare modal dialogs have nothing behind the declaration: calendar in components/overlayShell.tsx and palette/CommandPalette.tsx, editor in eight files (ConflictDialog, Settings, Palette, ConfirmDialog, ExportPreview, DocumentSetup, Shortcuts, UpdateDialog), mail in ui/Palette.tsx and ui/Sheet.tsx. Calendar has autofocus and no restore (overlayShell.tsx:51-56); editor and mail have an imperative .focus() on mount and no trap. aria-modal="true" is a promise to assistive technology that the rest of the page is inert, and in those twelve components it is not true: Tab walks straight out of the dialog into the list behind it, and closing the dialog drops focus on body.

useFocusTrap moving into @margin/hooks is what makes the promise true, and it lands in all three apps at once through the Dialog and Sheet primitives in ui-kit.md rather than through twelve separate edits.

One wrinkle to settle first. Margin's module-level focusTrapped() and the other apps' keyboard context stack are two mechanisms for one idea, "something in front owns the keyboard". Either the trap pushes an overlay frame onto the keymap stack when it engages, which is the smaller change and makes margin's two focusTrapped() reads disappear, or the shared keymap takes an injectable "external owner" predicate. Prefer the first: one mechanism, and it is the one three apps already have.

What the audit looked for and did not find

Confirmed absent from all four: any debounce or throttle utility; deep equality; a safeParse or tryParse wrapper; class-name joining; 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. There is no truncation helper because truncation is done in CSS.

Most of those are gaps. Some are decisions, and filling them would be a regression.

No cx, and none should be added. There is no cx, no clsx, no classNames and no such dependency in any of the four apps, and not one call site concatenates class names. That is not an oversight, it is the architecture: all four style off data attributes, so a component's variant state is data-phase, data-open, data-compact or data-selected, read by the stylesheet, while the class name stays constant. A shared cx would work against that directly. It would make conditional classes cheap, conditional classes would start appearing beside the attributes, and one component's styling would then live in two mechanisms at once. The right shared helper for variant state is a props-to-attributes mapping in @margin/ui, not a string joiner here.

Also deliberately not shared: width.ts, where only the persistence overlaps and rootSetting covers it; calendar's Intl-free day and month arrays; the mockIpc bodies, which are per-app by definition; the popover placement clamp, duplicated across four apps but with genuinely different anchoring rules, which makes it the lowest confidence item in the audit; and id generation, which is margin-only because calendar, editor and mail all take ids from Rust.

Per app, in order

calendar. Add "margin-shared" to package.json and import the shared tokens in src/styles/tokens.css; nothing else starts until this lands. Delete the empty src/ipc/ directory. Shim escape.ts, useMedia.ts, store/useOverlays.ts and store/useToast.ts. Adopt platform.ts and the shared call<T>. Fix normalizeCombo by adopting mail's registry, which is also where its context stack stops flattening to global. Give up keys/updates.ts to the shared updater but keep packagedBy and updateHint as the option every app now gets. Move its date maths and useClock into the package and re-export; its EventDetailsModel.ts clamp becomes the shared one.

editor. Shim escape.ts and useMedia.ts. Adopt platform.ts, the shared call<T> (gaining the log_note write it does not have), createOverlays, and mail's toast store with its seq, which fixes its dwell timer. Hand over theme.ts, theme.test.ts, menu.ts, positions.ts, components/Outline.tsx:79-89 and the update store and driver, and take back nine call sites' worth of guarded storage as makeStorage. Replace keys/ with the shared registry parameterised on its KeyContext, keeping its onCommand fan-out. Its eight aria-modal dialogs get the trap through @margin/ui.

mail. Fix the duplicate marginmail-theme constant and add the packagedBy guard first, before either extraction touches those files. Hand over escape.ts (header included), call<T>, onAppEvent, the toast store, the keyboard registry, store/useOverlays.ts, screens/format.ts's space() and its date formatters. Adopt editor's theme, which turns its fake System into a real subscription and deletes forgetThemeChoice; adopt rootSetting for pane.ts and appearance.ts; adopt the shared updater and delete both inline copies of checkForUpdates. Adopt useCompact, which it does not have.

margin. Guard project.ts:19 and tidy library.ts:31 onto parseJson first. Add the shared call<T>, renaming isDesktop to isTauri at the three call sites, and gain a log file entry for every IPC failure for the first time. Hand over focus.ts, time.ts's relativeTime and its plural, and delete the second relative-time copy at backup.ts:61. Shim escape.ts; replace its bare useCompact with calendar's, which writes data-compact. Adopt platform.ts, which makes its five e.metaKey tests work off a Mac. Adopt createOverlays and the toast store when it takes the primitives. Defer the keyboard registry until it has a palette and a shortcut sheet for the generated output to land in; until then the four competing mechanisms stay, which is the one place in this plan where an app knowingly keeps the worse thing.