# 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` (`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`. ```ts 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()`.** Nothing in the body knows what an overlay is. Each app writes `export const useOverlays = createOverlays();` 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. ```ts export interface OverlayState { 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(): UseBoundStore>>; ``` **`onAppEvent`.** Nobody wraps Tauri's `listen`, and it shows. X is the exception, at `X/src/App.tsx:69-77`, signature `onAppEvent(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. ```ts export interface ThemeConfig { themes: readonly ThemeInfo[]; keyPrefix: string; fallback: Record; } export function createThemeStore(config: ThemeConfig): ...; export function bootScript(config: ThemeConfig): string; ``` Generating the boot script from the same config is the part to insist on. All four apps hand-maintain an inline `