# Migration The order the work happens in, and what proves each step. Every phase leaves all four apps building and shippable; nothing here is a long-lived branch. ## Preconditions None of this starts until these are true. They are not precautions, they are the things that make repo surgery safe. **Commit and push the two dirty trees.** Margin Docs has 123 uncommitted files. Margin Mail has 123 uncommitted files on top of a single scaffold commit. Moving files between repos and rewriting manifests across that is how work gets lost. **Give Margin Mail a git remote.** It has none. That blocks the `uses:` reference for a reusable workflow, blocks the sibling checkout other apps need, and blocks the shared repo's CI from building it. Everything in [release.md](release.md) waits on this one command. **Get Margin Docs' CI green.** Six of its last seven runs failed in `pnpm install --frozen-lockfile` with `ENOENT: no such file or directory, scandir '/Users/runner/work/python/margin/shared'`, because its workflow does a single checkout and its manifest points at a sibling repository. Margin Mail already solved this with a second checkout and it was never carried back. Do that, as a stopgap, so main is green before anything moves. Phase 1 deletes the stopgap. ## Fix before, or fix along the way The audits turned up 30 defects. Most ride along with the extraction that touches them, but some have to be settled first, because extracting a module means choosing which version wins and a bug you have not decided about gets chosen by accident. Fix first, because they block a phase or because they are shipping wrong today: | Defect | Where | Why first | |---|---|---| | Margin Mail cannot compile for mobile | `lib.rs:203` has `mobile_entry_point` on `attach_account` rather than `run()` at `:250`; the three helpers called at `:307`, `:310`, `:314` are defined nowhere | The Rust extraction touches `lib.rs` in all four apps. Fixing it afterwards means fixing it twice. All three helpers exist in Margin Calendar and were meant to be ported | | Margin's Google refresh token is in plaintext | `gdrive.rs:71`, `:153-157`, in `backup.json` | Same OAuth client and same grant as the two apps that seal theirs, so it sets the suite's real security level. `margin-secrets` should land on a repo that has already stopped doing this | | Margin's PDF exports every heading at weight 400 | `pdf.rs:9-20`, `:57-62` load variable fonts, which Typst lays out at the default instance | The extraction has to take Margin Docs' static cuts. Decide that before the crate exists, not during | | Margin's Typst escaper is `JSON.stringify` | `src/export/typst.ts:36-38` | Typst copies an unrecognised escape to the page verbatim, so `\b`, `\f` and braceless `\uXXXX` reach the PDF as visible backslashes. The busiest call site is every inline code span | | Two apps ship placeholder updater pubkeys | Margin Docs and Margin Mail | Neither can ship a verifiable direct-download update. The shared release pipeline should not be built around a config that has never worked | | No workflow sets `max-parallel: 1` | `margin:91`, `margin-caledar:85`, `margin-mail:85` set only `fail-fast: false` | tauri-action merges `latest.json` read-modify-write across platforms. The constraint was written down once and lost, which is the failure this whole exercise is about | | Margin Calendar has no rate limit handling | A 429 becomes `ApiError::Other` at `google/api.rs:191-197`; the outbox counts it as a real attempt and five retire the write permanently at `push.rs:29`, `:375-381` | A user's write is silently dropped. `margin-http` fixes it, but the data loss is live now | Everything else rides along and is listed in the document that owns it: the missing transactions and the settings struct with 25 fields and one `serde(default)` in [rust-crates.md](rust-crates.md); the theme key declared twice, the updater with no package-manager guard, and the keyboard normalisation that makes `cmd+F` and `cmd+f` collide in [hooks.md](hooks.md); the unclamped row menu and the menu with no escape layer in [ui-kit.md](ui-kit.md); the two justfile bugs and the unchecked TypeScript projects in [toolchain.md](toolchain.md). One of them is a fix rather than a saving and should be called out: three apps ship modals with `role="dialog"` and `aria-modal="true"` and no focus trap behind them. Only Margin has `useFocusTrap`. That arrives with `Sheet`. ## Phase 1: the shared repo exists and the build is not broken Create `margin-shared` as its own repository, MIT licensed. Move `margin/shared` into it intact: tokens, fonts, icons, the font binaries and `sync-fonts`. Tag `v0.1.0`. Repoint Margin, Margin Docs and Margin Mail off the relative path, and add the dependency to Margin Calendar, which has never had it. Nothing else changes in this phase. No new tokens, no new components, no reconciliation. The point is to move the existing thing to a place where a fresh clone works, and to prove the consumption mechanism before anything depends on it. **Proves it worked:** clone each of the four repos into an empty directory, `pnpm install`, `pnpm build`. All four succeed. Today two of them cannot. Then Margin Docs' CI goes green with the stopgap second checkout removed. This phase also forces the licence decision. Margin is FSL-1.1-MIT, Margin Mail will be, Margin Calendar and Margin Docs are MIT. The shared repo is MIT so all four can consume it. Make that deliberately rather than discovering it mid-extraction. ## Phase 2: the design system `@margin/tokens` and `@margin/fonts`, per [design-system.md](design-system.md). Margin Calendar is 49 of 52 token values identical to the shared set already, so joining costs two imports and a script pair. Promote the corrections two apps found independently, starting with `--ink-faint`, which Calendar and Mail both moved to `#6e675b` light and `#8e8677` dark for the same contrast reason while Margin and Docs kept the failing `#9b9484`. Promote the 19 tokens that exist in two or three apps and not in shared. Collapse the base layer, which is already one file in four byte-identical copies. **Proves it worked:** a screenshot of the same screen in each app before and after, and a lint over each app's CSS for colour literals outside the token layer. Margin has 15 hex and 17 `rgba()` literals today; Calendar has one; Docs and Mail have none. The lint is what stops the drift resuming, so it lands in this phase rather than later. ## Phase 3: the toolchain `@margin/config`, per [toolchain.md](toolchain.md). The base tsconfig three apps already share byte for byte, the Vite factory the four configs differ from only by port, the shared justfile recipes, and the prose checker that currently exists in one repo. Two things gate later phases and belong here. `@margin/ui` cannot ship source-only TSX unless each app's tsconfig and Vite config compile TSX out of `node_modules`, and none does today. And the checking gate has two holes: nothing type checks the Playwright specs, and nothing builds `tsconfig.node.json`, so `vite.config.ts` is unchecked in all four and fails in two. **Proves it worked:** one recipe name in every app runs the whole gate, and it is red in the places the audits say it should be red today. ## Phase 4: the UI kit `@margin/ui`, in the ranked order in [ui-kit.md](ui-kit.md): sheets and confirmation first, then list navigation, the export preview, the primitives, and so on down. The standing note in `shared/src/icons.ts:11-13` that says the `Icon` component is deliberately not shared has to be reopened in the file, with the new reasoning, rather than quietly contradicted. Do not do this before Phase 2. A component kit on four different token sets is a component kit that looks different in four apps. **Proves it worked:** Margin Mail's `Kit.tsx` renders every primitive in every state in both palettes, and it keeps working after each promotion. That page is the regression test and it already exists. ## Phase 5: hooks and IPC `@margin/hooks` and `@margin/ipc`, per [hooks.md](hooks.md). Start with the six things that are byte identical today and verified by hash, because they drop in with no behaviour change. Order by confidence, not by line count. The winners are not all in one app, so no single repo is the source: Docs wins the theme and the updater, Mail wins the keyboard registry and the toast and the `call` wrapper, Margin wins the focus trap and relative time, Calendar wins clamp and contributes the packaged-by guard. ## Phase 6: the test harness `@margin/test`, per [testing.md](testing.md). The three Playwright configs are one file with the port swapped. The invoke stub is the thing worth sharing and three apps solved events three different ways, with the correct one currently living in `tests/` where it cannot be reused. This is the highest-value item in the whole consolidation for day-to-day work, because it is what lets a change be verified without touching the running dev server, which [guidelines/working-together.md](guidelines/working-together.md) forbids. Margin comes last here and is new work rather than migration: it has no tests of any kind and no `src/ipc.ts` seam to plug a fixture into. ## Phase 7: the Rust crates Per [rust-crates.md](rust-crates.md), then [accounts.md](accounts.md), then [typesetting.md](typesetting.md). Consumed as cargo git dependencies pinned to a tag, because a path dependency across checkouts fails on a fresh clone in exactly the way the npm one already does. Order within the phase: `margin-log` first and alone, because it is about 100 lines, it is the only logging in the suite, and every later phase debugs better with it. Then the Tauri shell, where 73 distinct lines of `lib.rs` are verbatim identical in all four files. Then SQLite, done for the two defects it fixes rather than the volume. Then secrets, Google and HTTP together, since 1,552 of Margin Calendar's 1,675 non-test OAuth lines exist verbatim in Margin Mail. Then typesetting, grammar and the macOS integrations. Close the version drift in the same pass: rusqlite 0.37 against 0.40, reqwest 0.12 against 0.13, chacha20poly1305 0.10 against 0.11, fontdb 0.23 against 0.24. Note what is deliberately not built: no shared settings crate, no shared error crate, no shared filesystem crate, no shared async crate, and no shared sync engine. Each refusal has evidence behind it in its own document, and the sync engine refusal is the most important one in the plan. ## Phase 8: release Per [release.md](release.md). The `prepare` job is 81 lines in two apps and differs by one line. No repo has the good version of the pipeline: every good idea lives in exactly one repo and one app has no CI workflow at all. This is late deliberately. A reusable workflow is only worth building once the four repos agree about what a build is, which is what phases 1 through 7 settle. **Proves it worked:** each app's own workflow file is under 20 lines, and a release of each app produces a signed, notarised, verifiable artifact. ## Phase 9: names Per [naming.md](naming.md). Last, because a rename during extraction is a rename of a moving target, and because two of the renames are free only once nothing points at the old paths. The bundle identifiers stay frozen. Changing one orphans the app data directory, the sealed-secret service name, the OAuth redirect scheme registered with Google, the macOS notification settings deep link, the App Store record and the Homebrew cask, and breaks the update path for every existing install. ## What "done" looks like A fresh clone of any of the five repos installs and builds with no sibling checkout. The same button exists once. A token changed in one place changes in four apps. An icon is aligned by two CSS rules rather than by a nudge at each call site. A failure is written to a log file in every app rather than one. A change can be verified in a browser against fixtures without touching the running dev server. And the rules the four apps are built by are in `guidelines/` in a repository, rather than in memory files on one machine.