# Build and developer tooling across the four apps Scope: package.json, lockfiles, vite, tsconfig, index.html, justfiles, scripts, gitignore, nix, editor config, Cargo profiles, capabilities. Not CI, signing or tests, bar where build leaks in. Paths: margin `/Users/pj/Workspace/projects/python/margin`, margin-calendar `/Users/pj/Workspace/projects/python/margin-caledar`, margin-docs `/Users/pj/Workspace/projects/rust/margin-editor`, margin-mail `/Users/pj/Workspace/projects/rust/margin-mail`. Cites below are relative to those roots. ## Five findings first 1. **margin-docs cannot install on a fresh clone or on its own CI.** Its `package.json:33` asks for `"margin-shared": "file:../../python/margin/shared"` but `.github/workflows/ci.yml:19` does one checkout. The frontend job dies inside `pnpm install --frozen-lockfile` with exit 254; four of the last five runs failed (run 33308997470, 2026-08-30: `rust: success`, `frontend: failure`). margin-mail is the only app that solved it, with a second checkout of `priyanshujain/margin` into `python/margin` (`.github/workflows/ci.yml:26-31`, `release.yml:109`). 2. **The four tsconfigs are three identical files plus one that differs by two lines.** md5 of margin, margin-calendar and margin-docs `tsconfig.json` is `468c4a26...`; margin-mail differs only in `target` and `lib`. All four `tsconfig.node.json` are byte identical (`767b2e9a...`). 3. **The three justfiles are one file with the product name swapped**, plus two extra recipes and a signing block in margin-mail. margin has no justfile at all, so the "every fix ends with `just install`" rule is unenforceable there. 4. **No prettier, no eslint, no biome, no .editorconfig, no rustfmt.toml, no rust-toolchain file in any of the four.** Confirmed by search over the repo roots and by grep over each package.json. The only editor config is `margin/.vscode/extensions.json`, two recommendations, and no sibling has one. 5. **Nix exists only in margin-calendar** and is a publishing artifact, not a toolchain: it repackages the released `.deb`. Worth copying per app, but not shared build config. ## package.json ### Scripts | script | margin | calendar | docs | mail | | --- | --- | --- | --- | --- | | `dev` | `vite` | `vite` | `vite` | `vite` | | `build` | `tsc && vite build` | same | same | same | | `preview` | `vite preview` | same | same | same | | `tauri` | `tauri` | same | same | same | | `dmg` | `tauri build --bundles dmg` (`:11`) | absent | absent | absent | | `test` | absent | `vitest run` | `vitest run` | `vitest run` | | `test:watch` | absent | `vitest` | `vitest` | `vitest` | | `test:ui` | absent | `playwright test` | `playwright test` | `playwright test` | | `fonts:sync` | `node node_modules/margin-shared/bin/sync-fonts.mjs .` (`:12`) | absent | same (`:15`) | `margin-shared-fonts .` (`:15`) | | `fonts:check` | same with `--check` (`:13`) | absent | same (`:16`) | `margin-shared-fonts . --check` (`:16`) | Two drifts worth folding: margin and margin-docs invoke the font sync by path into `node_modules`, margin-mail uses the `margin-shared-fonts` bin the package already declares (`python/margin/shared/package.json:16-18`) and which is linked in all three consumers. The bin form is the correct one. margin-calendar has no font sync and vendors four files under `public/fonts` against eighteen in the others, so it is not on the shared face set. `license` also drifts: `"SEE LICENSE IN LICENSE"` in margin (`:41`) and mail (`:5`), `"MIT"` in calendar (`:5`) and docs (`:3`), matching `LicenseRef-FSL-1.1-MIT` and `MIT` respectively in the Cargo manifests. Consistent within an app, but the four are not on one licence. ### Version drift (declared spec, then what the lockfile resolved) | package | margin | calendar | docs | mail | | --- | --- | --- | --- | --- | | react, react-dom | `^19.1.0` -> 19.2.7 | `^19.1.0` -> 19.2.8 | 19.2.8 | 19.2.8 | | vite | `^7.0.4` -> 7.3.5 | 7.3.6 | 7.3.6 | 7.3.6 | | typescript | `~5.8.3` -> 5.8.3 | 5.8.3 | 5.8.3 | 5.8.3 | | @vitejs/plugin-react | `^4.6.0` -> 4.7.0 | 4.7.0 | 4.7.0 | 4.7.0 | | vitest | absent | `^3.2.4` -> 3.2.7 | 3.2.7 | 3.2.7 | | @playwright/test | absent | 1.62.1 | 1.62.1 | 1.62.1 | | zustand | `^5.0.14` -> 5.0.14 | 5.0.14 | 5.0.15 | 5.0.15 | | @types/react | 19.2.17 | 19.2.18 | 19.2.18 | 19.2.18 | | @types/react-dom | 19.2.3 | 19.2.4 | 19.2.4 | 19.2.7 | | @types/node | absent | absent | `^22.20.1` -> 22.20.1 | `^24.0.0` -> 24.13.3 | | @tauri-apps/api | `^2` -> 2.11.1 | 2.11.1 | 2.11.1 | 2.11.1 | | @tauri-apps/cli | `^2` -> 2.11.3 | 2.11.4 | 2.11.4 | 2.11.4 | | plugin-opener | 2.5.4 | 2.5.4 | 2.5.4 | 2.5.5 | | plugin-process | 2.3.1 | 2.3.1 | 2.3.1 | 2.3.1 | | plugin-updater | 2.10.1 | 2.10.1 | 2.10.1 | 2.11.0 | | plugin-dialog | `^2.7.1` -> 2.7.1 | absent | `^2` -> 2.7.2 | absent | | plugin-notification | absent | absent | absent | 2.4.0 | | plugin-os | absent | absent | absent | 2.3.2 | | tiptap | `^3.27.1` -> 3.27.1 | absent | `3.30.2` exact | `^3.31.2` -> 3.31.2 | Nothing here is a real incompatibility. Every spec except margin-docs' tiptap is a caret or tilde, so the drift is purely "when was `pnpm install` last run here": margin is the stale one, a patch behind on react and vite and two `@types` bumps behind. The one deliberate difference is margin-docs pinning tiptap exactly at 3.30.2 (`package.json:24-28`) while the others float. `@types/node` is the only genuine split: 22 in docs, 24 in mail, absent in the other two. Since `tsconfig.json` in all four sets no `types` array, the presence of `@types/node` silently changes what global names typecheck per app. No app declares a `packageManager` field, so nothing pins pnpm from the repo itself. ## The margin-shared relative path - margin: `"margin-shared": "file:./shared"` (`package.json:26`), inside its own repo, and the directory is tracked (26 files under `shared/`). - margin-docs: `"file:../../python/margin/shared"` (`package.json:33`). - margin-mail: `"file:../../python/margin/shared"` (`package.json:27`). The lockfiles record the literal relative string, with no integrity hash: `rust/margin-mail/pnpm-lock.yaml:918` is `margin-shared@file:../../python/margin/shared:` with `resolution: {directory: ../../python/margin/shared, type: directory}` and the snapshot at `:1957` is `{}`. Same shape at `rust/margin-editor/pnpm-lock.yaml:1449`. How fragile: pnpm resolves the path relative to the importer directory, so the dependency is not "the margin repo", it is "two directories up, then `python/margin/shared`". That encodes PJ's local grouping (`Workspace/projects/python`, `Workspace/projects/rust`) into a committed manifest. - **Fresh clone.** Cloning margin-mail into `~/code/margin-mail` makes the target `/Users/pj/python/margin/shared`. `pnpm install` then stops with `ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND Could not install from "..." as it does not exist.` (reproduced directly, exit non-zero, nothing installed). This is not a warning that degrades to a missing font, it is a hard install failure before any other dependency lands. - **CI.** margin-mail works only because `ci.yml:26-31` checks the sibling out at the exact path `python/margin` and runs everything with `working-directory: rust/margin-mail`. margin-docs does not do this and its frontend job has been red since the dependency landed. - **Anyone else.** A contributor must clone two repositories into a two-level layout whose folder names (`python`, `rust`) mean nothing to them and appear in no documentation. The margin repo is public, so it is possible, just undiscoverable. - **Reproducibility.** A directory dependency has no hash, so `pnpm install --frozen-lockfile` consumes whatever is in `shared/` at that moment, uncommitted edits included. The lockfile is not frozen with respect to shared code. - **Publishing.** `shared/package.json:4` is `"private": true`, so today it cannot go to a registry without a deliberate change. Three ways out, in order of how much they cost: 1. **Give shared its own repo and depend on a git tag.** Removes the path assumption entirely and gets an immutable resolution. npm and pnpm git dependencies cannot point at a subdirectory, so this means moving `shared/` out of the margin repo, which also fixes margin depending on it via `file:./shared`. 2. **Publish `margin-shared` to npm** (or a GitHub npm registry) and depend on a version. Same benefit, plus a real integrity hash in the lockfile. Costs a publish step per change to shared. 3. **Keep the relative path but make it discoverable and enforced:** an `.env`-style documented layout, a preinstall check that fails with a readable message instead of pnpm's error, and the second checkout added to margin-docs CI. This is the cheap fix and it leaves the reproducibility hole open. If a shared toolchain package is going to exist anyway, it should be delivered the same way as whatever is chosen here, and the two should not use different mechanisms. ## pnpm and workspaces All four lockfiles are `lockfileVersion: '9.0'` (line 1) with identical settings blocks (`autoInstallPeers: true`, `excludeLinksFromLockfile: false`). No `pnpm-workspace.yaml` and no `.npmrc` in any of the four. There is no workspace today and no way to create one across four git repos without either submodules or a monorepo merge. Local installs all report `packageManager: pnpm@10.12.4` in `node_modules/.modules.yaml`, which is install state rather than a committed pin; CI pins `pnpm/action-setup@v6` `version: 10` and node 26. ## vite.config.ts Ports, which are the one thing that must stay per app and are correctly staggered: | app | server.port | hmr.port | tauri devUrl | | --- | --- | --- | --- | | margin | 1420 (`:17`) | 1421 (`:23`) | `http://localhost:1420` | | calendar | 1430 (`:13`) | 1431 (`:20`) | `http://localhost:1430` | | docs | 1440 (`:23`) | 1441 (`:30`) | `http://localhost:1440` | | mail | 1450 (`:16`) | 1451 (`:22`) | `http://localhost:1450` | Everything else in the file is the same four properties: `plugins: [react()]`, `clearScreen: false`, `strictPort: true`, `host: host || false` where `host` is `process.env.TAURI_DEV_HOST` behind a `@ts-expect-error` comment in all four (`:4-5` in each), the same conditional `hmr` block, and `watch.ignored`. Real differences: - margin imports `defineConfig` from `"vite"` (`:1`) and exports an async factory, `defineConfig(async () => ({ ... }))` (`:8`), for no reason visible in the file. The other three import from `"vitest/config"` and export a plain object, because they carry a `test` block. - margin ignores `"**/website/**"` as well as src-tauri (`:29`); the others ignore only src-tauri. - margin-docs is the only one with a `build` block: `assetsInlineLimit` as a function that returns `false` for `woff2?|ttf|otf|eot` (`:19`), because the app CSP is `font-src 'self'` and a data URI font would be refused. - The `test` blocks: calendar and mail are identical (`include: ["src/**/*.test.ts"]`, `environment: "node"`); docs adds `maxWorkers: "50%"`, `testTimeout: 30_000`, `hookTimeout: 30_000`, `teardownTimeout: 30_000` (`:57-69`). Nothing in any of the four sets `define`, `envPrefix`, `resolve.alias`, `build.target`, `minify` or `sourcemap`. So there is no alias story to preserve and no env prefix convention to standardise. ## tsconfig.json and tsconfig.node.json `tsconfig.json` is identical in margin, calendar and docs. margin-mail differs in exactly two options: - `"target": "ES2022"` versus `"ES2020"` (`rust/margin-mail/tsconfig.json:3`) - `"lib": ["ES2022", "DOM", "DOM.Iterable"]` versus `["ES2020", ...]` (`:5`) Everything else matches across all four: `useDefineForClassFields`, `module: "ESNext"`, `skipLibCheck`, `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `resolveJsonModule`, `isolatedModules`, `noEmit`, `jsx: "react-jsx"`, `strict`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`, `include: ["src"]`, and a reference to `./tsconfig.node.json`. `tsconfig.node.json` is byte identical in all four: `composite`, `skipLibCheck`, `module: ESNext`, `moduleResolution: bundler`, `allowSyntheticDefaultImports`, `include: ["vite.config.ts"]`. `tests/tsconfig.json` exists in calendar, docs and mail (margin has no tests directory). Calendar and docs are byte identical. margin-mail adds `"types": ["node"]` (`:16-18`) and is written with one array element per line, which is a formatting drift nothing enforces. Nothing runs `tests/tsconfig.json`. `pnpm build` is `tsc && vite build`, and root `tsc` only sees `include: ["src"]`. No package.json script, justfile recipe or workflow step in any of the three references it. The Playwright specs are therefore type checked by nobody. None of the four sets `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax` or `paths`. ## index.html Identical structure in all four: ``, `lang="en"`, `charset=UTF-8`, a title, a `
`, and `