mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-04 20:17:03 +00:00
some more fixes
This commit is contained in:
1 parent
7a3c04f170
commit
2b86a407ba
63 files changed
+12172
-44
No files matched your search
@@ -0,0 +1,558 @@
|
||||
# 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<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.
|
||||
|
||||
```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<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`.
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```ts
|
||||
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](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.
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```ts
|
||||
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`.
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```ts
|
||||
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](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](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](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](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](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](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.
|
||||
Reference in new issue
Block a user