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

451 lines
28 KiB
Markdown

# 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: [email protected]` 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: `<!doctype html>`, `lang="en"`, `charset=UTF-8`, a title, a
`<div id="root">`, and `<script type="module" src="/src/main.tsx">`.
- Viewport: margin, calendar and mail use `width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no, viewport-fit=cover`; docs drops `viewport-fit=cover` (`:5`).
- Favicon: `/margin-mark.png`, `/margincal-mark.png`, `/marginmail-mark.png`. margin-docs has no
`<link rel="icon">` at all and no mark in `public/`.
- No CSP meta tag and no font preload in any of the four. The real CSP is in
`src-tauri/tauri.conf.json` under `app.security.csp`, and it differs per app for good reasons:
margin and docs add `worker-src 'self' blob:`, docs adds `asset: http://asset.localhost` to
`img-src`, mail adds `frame-src 'self'`.
- Every one has an inline pre-paint script that reads localStorage and sets attributes on
`documentElement` before React boots. They share a shape (theme resolution against
`prefers-color-scheme`, then app specific attributes) but every key is app prefixed
(`margin-theme`, `margincal-theme`, `marginmail-theme`, `margindocs-theme`) and each sets
different things. margin-docs is the only one that reads keys individually rather than wrapping
the lot in one `try` (`:13-19`), which is the better version: a webview that refuses storage
still gets the theme. The other three lose everything after the first throw.
That inline script is the one genuinely shared idea in these files, and it is the hardest to share,
because it must be inline and cannot import.
## justfiles
margin has none. calendar, docs and mail have one, and the three are the same file with names
substituted; `diff` between calendar and docs is nine hunks, all of them the product name, the
process name or a comment reflow.
| recipe | calendar | docs | mail | bodies match |
| --- | --- | --- | --- | --- |
| `default` (`@just --list`) | yes | yes | yes | identical |
| `dev` (`pnpm tauri dev`) | yes | yes | yes | identical |
| `test` (`pnpm test` then `cd src-tauri && cargo test`) | yes | yes | yes | identical |
| `test-ui` (`pnpm test:ui`) | yes | yes | yes | identical, comment differs in mail |
| `guide-shots` | no | no | yes (`:29-30`) | mail only |
| `docs` (`node scripts/docs-check.mjs`) | no | no | yes (`:33-34`) | mail only |
| `build` | yes | yes | yes | same except mail's signing block |
| `install` (depends on `build`) | yes | yes | yes | identical |
| `_install-macos` | yes | yes | yes | docs omits the final `open "$dest"` |
| `_install-linux` | yes | yes | yes | identical bar names and the desktop entry |
| `uninstall` | yes | yes | yes | identical bar names |
Shared variables: `set shell := ["bash", "-euo", "pipefail", "-c"]`, `app`, `bundle :=
"src-tauri/target/release/bundle"`.
Substantive differences:
- margin-mail's `build` sources a signing env file before building
(`rust/margin-mail/justfile:46-54`): it reads
`"${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}/studio.margin.app.env"`. Note the file name is
`studio.margin.app`, which is margin's bundle identifier, not `studio.margin.mail`. If the intent
is one env file for the whole suite the name is misleading; if it is one per app, this is wrong.
- margin-docs' `_install-macos` does not `open "$dest"` at the end (compare `justfile:77` in docs
with `:78` in calendar and `:97` in mail). So `just install` starts the new build in two of the
three apps and not the third.
- Only margin-mail has a prose gate recipe.
Because the rule is that a fix ends with `just install`, the gap that matters is margin: its only
local build path is `pnpm dmg` (`package.json:11`), and nothing copies a bundle into
`/Applications`. The three justfiles already prove the body is app independent bar four names, so
margin is a paste plus a variable block away from the same command.
## scripts directories
Only two apps have one and they do unrelated things.
- `margin/scripts` is twelve files of Apple release plumbing: `apple-provision.rb`,
`apple-secrets.sh`, `appstore-compliance.rb`, `appstore-listing.rb`,
`appstore-review-detail.rb`, `appstore-screenshots.rb`, `mas-package.sh`, `mas-upload-local.sh`,
`testflight-release.rb`, `testflight-setup.rb`, `testflight-testers.rb`. This is the App Store
path and only margin is on it, so it stays where it is (and overlaps the signing agent's scope).
- `margin-mail/scripts` is one file, `docs-check.mjs`, 72 lines: no em or en dash anywhere
including in code fences, no directory tree (box glyph run or three consecutive ASCII tree
lines), no broken relative link. It walks every `.md` in the repo, skipping
`node_modules, dist, target, .git, gen, .playwright-mcp`.
`docs-check.mjs` is the clearest single candidate for sharing: no dependencies, no app specific
knowledge, and it enforces a house rule that applies to all four repos. It belongs in the shared
package with a bin, like `sync-fonts.mjs` (exposed as `margin-shared-fonts`).
## .gitignore
Common core in all four, in the same order: log patterns, `node_modules`, `dist`, `dist-ssr`,
`*.local`, the editor block (`.vscode/*` with `!.vscode/extensions.json`, `.idea`, `.DS_Store`,
`*.sw?`), `src-tauri/target/`, `.playwright-mcp/`, `/*.png`.
Differences:
- `src-tauri/gen/schemas/` is ignored in calendar (`:27`), docs (`:27`) and mail (`:21`), but not
in margin. margin therefore can commit generated schema files.
- The Xcode block (`src-tauri/gen/apple/build/`, `Externals/`, `Pods/`, `Podfile.lock`,
`xcuserdata/`) is in calendar (`:32-36`), docs (`:32-36`) and mail (`:25-29`), not margin.
- Google credentials (`/google-credentials.json`, `/client_secret_*.json`) in margin (`:30-31`),
calendar (`:39-40`) and mail (`:33-34`), not docs, which needs none.
- margin only: `target-mas/` and two App Store review contact files (`:38-42`).
- docs only: `.env`, `.env.*`, `!.env.example` (`:43-45`), the updater signing key.
- calendar only: `/result`, `/result-*` (`:47-48`), the nix build symlinks.
- mail only: `/screenshots/` (`:41`).
- margin-mail trims the editor block hardest (no `*.suo`, `*.ntvs*`, `*.njsproj`, `*.sln`).
A shared base of about twenty lines would cover everything up to `/*.png`, with five to eight app
specific lines after it. Git has no include mechanism for ignore files and `core.excludesFile` is
per machine, so this is one of the things that stays copied.
## Nix in margin-calendar
`flake.nix` is 21 lines: one input (`nixpkgs` at `nixos-unstable`, locked in `flake.lock` at rev
`3ed67ec0a4d3c7ab4ae1f04f8ee8df07bfa506a2`), an overlay and a single `x86_64-linux` package that
calls `./nix/package.nix`. `nix/package.nix` fetches the published `.deb` from the GitHub release
named in `nix/release.json` (`{"version": "0.0.5", "hash": "sha256-+bEQO..."}`), unpacks it with
`dpkg-deb -x`, relinks it with `autoPatchelfHook` and `wrapGAppsHook3` against nixpkgs' gtk3,
`webkitgtk_4_1`, `libsoup_3` and friends, writes a launcher that points `libglvnd` at nixpkgs' mesa
when `/run/opengl-driver` is absent, shims `xdg-open` to strip those variables again, and sets
`MARGIN_CALENDAR_PACKAGED_BY=nix` so the in-app updater reports rather than replaces itself.
`docs/release.md:41-81` gives the rationale: the AppImage bundles Ubuntu's GTK stack and a bundled
`libwayland-client` cannot talk to a current compositor, so on Hyprland it falls back to Xwayland.
It is binary by necessity, because the Google OAuth client is embedded at compile time from a file
that is not in the repo.
Is this the Linux distribution path, and should the others adopt it? Yes for margin-mail, same
Google client problem and same GTK and WebKit runtime; yes for margin-docs if it ships Linux, which
its justfile already builds for. margin is macOS and App Store shaped and would gain nothing.
But note what is actually shared here: almost nothing. The flake is fifteen lines of boilerplate and
`package.nix` is a hundred lines of which the app name, the deb URL, the desktop file rename, the
env variable name and the meta block are all per app, and the rest (the hooks, the buildInputs list,
the mesa launcher, the xdg-open shim) is genuinely common. If two apps adopt it, that common part
should be a function in the shared repo that each flake calls with a name, a repo and a release pin.
Below two adopters, copy it.
## Editor, formatter and linter config
Confirmed absent everywhere: prettier (no config file, no dependency, no script in any of the four
package.json files), eslint, biome, `.editorconfig`, `rustfmt.toml`, `clippy.toml`,
`rust-toolchain.toml`, `.nvmrc`.
Present: `margin/.vscode/extensions.json`, two recommendations
(`tauri-apps.tauri-vscode`, `rust-lang.rust-analyzer`). No other app has a `.vscode` directory,
though all four gitignore `.vscode/*` while un-ignoring `extensions.json`, so the intent is there.
`margin-editor/src-tauri/.cargo/config.toml` is the only cargo config: `[env] RUST_TEST_THREADS =
"1"`, for a suite that shares one on-disk git repository. App specific, stays.
The consistent formatting across all these files (two space JSON, 100 column comments, the same
comment voice) is being maintained by hand. That works while one person writes everything, and the
one place it has already slipped is `rust/margin-mail/tests/tsconfig.json`, which is the same file
as its siblings reformatted with expanded arrays.
## Cargo
No `[workspace]` section in any of the four manifests, so each `src-tauri` is its own workspace root
with its own `Cargo.lock` (margin 964 packages, calendar 565, docs 962, mail 668).
Profiles: only margin (`src-tauri/Cargo.toml:78-79`) and margin-docs (`:107-108`) set anything,
both `[profile.dev.package."*"] opt-level = 3` with near identical comments about Harper's grammar
engine being ten times slower unoptimized. No `[profile.release]` anywhere, so release builds are
cargo defaults in all four and there is no `lto`, `codegen-units` or `strip` setting to align.
Duplication worth noting: margin and margin-docs both carry `[patch.crates-io]` stubs for
`burn-cuda` and `cubecl-cpu` (`margin:70-72`, `docs:98-100`) plus a `stubs/` directory each with the
same two skeleton crates at the same versions (`burn-cuda` 0.19.1, `cubecl-cpu` 0.8.1) and nearly
the same comments. This is real shared code, kept in sync by hand, and it is tied to
`harper-core = "=2.5.0"` in both.
A shared cargo workspace across the four is not feasible. A workspace requires one filesystem root
containing all members with paths in the root manifest, which means one git repository. These are four
repositories with independent release tags and version numbers, and margin-mail has no remote
configured yet. Without merging, the one thing worth extracting is a shared crate for the harper
stubs behind a git dependency, which deletes both copied `stubs/` directories.
## Capabilities
All four have exactly `capabilities/default.json` and `capabilities/desktop.json`, both pointing at
`../gen/schemas/desktop-schema.json`, both scoped to `windows: ["main"]`, with desktop gated on
`platforms: ["macOS", "windows", "linux"]`. No `permissions/` directory in any app.
| app | default permissions | desktop permissions |
| --- | --- | --- |
| margin | `core:default`, `core:window:allow-destroy`, `core:window:allow-start-dragging`, `opener:default`, `dialog:default` | `updater:default`, `process:allow-restart` |
| calendar | `core:default`, `opener:default`, `deep-link:default` | the two window permissions, `updater:default`, `process:allow-restart` |
| docs | `core:default`, `opener:default`, `dialog:default` | the two window permissions plus `core:window:allow-toggle-maximize`, `updater:default`, `process:allow-restart` |
| mail | `core:default`, `opener:default`, `deep-link:default`, `notification:default`, `os:default` | the two window permissions, `updater:default`, `process:allow-restart` |
margin is the odd one: it puts the two window permissions in `default` rather than `desktop`, so a
mobile build would ask for them. `desktop.json` is identical in calendar and mail bar the
description, and docs differs by one line.
## Recommendation
A shared package (call it `margin-shared` extended, or a second `margin-config` delivered the same
way) should export exactly five things:
1. **`tsconfig/base.json`.** Everything currently duplicated, with `target` and `lib` at ES2022 for
all four, since ES2020 in three of them is a scaffold default nobody chose. Each app keeps a
three line `tsconfig.json` that extends it and sets `include` and `references`. Also export
`tsconfig/node.json` (byte identical in all four today) and `tsconfig/tests.json` (identical in
two of three, one `types` entry apart). Adding `noUncheckedIndexedAccess` is a separate decision
and should not ride along with the consolidation.
2. **A vite config factory**, `marginVite({ port, test })`, returning the plugin, `clearScreen`,
the full `server` block derived from one port number, and the `TAURI_DEV_HOST` handling. Ports
stay per app and are the argument. margin-docs passes its `assetsInlineLimit`, margin-docs
passes its vitest timeouts. The `@ts-expect-error process` comment disappears with it, because
the factory can own that line once.
3. **A justfile include.** `just` supports `import`, so the shared file can hold `default`, `dev`,
`test`, `test-ui`, `build`, `install`, `_install-macos`, `_install-linux` and `uninstall`
verbatim, parameterised on `app`, `binary`, `comment` and `categories`, which the app justfile
sets before importing. Adding this to margin is the change that makes `just install` a real
suite-wide rule instead of a rule three of four apps can honour. The import has to resolve to a
real path, which lands back on the same delivery question as `margin-shared`.
4. **`docs-check` as a bin.** Move `margin-mail/scripts/docs-check.mjs` into the shared package
beside `sync-fonts.mjs`, expose it as `margin-shared-docs`, and add a `docs` recipe to the shared
justfile. It has no app specific content at all.
5. **The checking story, with no eslint.** Today that is `tsc` under `pnpm build` plus `cargo
test`. Two gaps close inside the shared config rather than with a linter: `tests/tsconfig.json`
is run by nothing, so add a `typecheck` recipe covering both projects; and `pnpm fonts:check`
runs in margin-mail CI only, so it belongs in the shared `test` recipe everywhere.
What has to stay per app, and should not be abstracted:
- The dev server port and the matching `devUrl` in `tauri.conf.json`. Staggering is load bearing:
Playwright reuses whatever answers on the port, so a collision means a suite silently driving the
wrong app (`rust/margin-mail/vite.config.ts:7-9`).
- The inline pre-paint script in `index.html`. It cannot import, its storage keys are app prefixed,
and it sets different attributes per app. Copy margin-docs' per key `saved()` pattern into the
other three by hand.
- The CSP in `tauri.conf.json`, the capabilities files and `Cargo.toml` dependencies. One shared
version would be the union, which is wrong for a suite that does not reach for spare permissions.
- `.gitignore`, because git cannot include a shared file.
- The nix flake, unless and until a second app ships Linux through it.
- margin's `scripts/` App Store tooling, and margin-docs' `src-tauri/.cargo/config.toml`.
Sequencing: the `margin-shared` path problem has to be solved first, because every item above is
delivered through the same mechanism, and adding four more consumers of a broken relative path makes
the fresh clone failure four times worse instead of once. Second, add the missing checkout to
margin-docs CI, which is a red build today for a reason nobody has looked at. Third, give margin a
justfile.