# 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](ui-kit.md); nothing here specifies a component. Short names as in [design-system.md](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](.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` 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()` | ~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` 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. ```ts 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](guidelines/platform.md) is currently true of calendar and editor only. ```ts 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. ```ts export type Scheme = "light" | "dark"; export interface ThemeInfo { id: T; label: string; scheme: Scheme } export interface ThemeConfig { themes: readonly ThemeInfo[]; keyPrefix: string; // "margindocs", "marginmail", ... fallback: Record; } export function createThemeStore(config: ThemeConfig): UseBoundStore>>; export function bootScript(config: ThemeConfig): 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 `