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

190 lines
12 KiB
Markdown

# 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.