Contributing to Lattice
Thanks for helping make Lattice a better research companion. Bug reports, feature ideas, documentation improvements, and focused code contributions are all welcome. Everyone taking part is expected to follow the Code of Conduct.
Report a bug or suggest an improvement
Before opening an issue, please search the existing issues to see whether it has already been reported.
For a bug, include:
- the Lattice version and your macOS version;
- clear steps to reproduce the problem;
- what you expected and what happened instead;
- relevant logs or screenshots, with private project content and credentials removed.
For a feature request, describe the research workflow you want to improve and the problem you currently encounter. Explaining the use case is more useful than prescribing a particular implementation.
Open a pull request
Small bug fixes and documentation improvements can go directly to a pull request. For a substantial feature, protocol change, or large refactor, please open an issue first so the direction can be discussed before implementation.
- Fork the repository and create a branch from
main. - Keep the change focused; avoid unrelated formatting or refactors.
- Add or update tests for behavior that changes.
- Run the relevant local checks described below.
- Open a pull request that explains the problem, the chosen solution, and how you verified it. Link the related issue when there is one.
Do not bump the application version in a normal pull request. Releases are
prepared separately by maintainers; see
docs/release-process.md.
Write the commit subject for a stranger. Lattice has no CHANGELOG.md — the
release workflow builds each release's notes from the subjects of the commits in
that tag's range, so your subject line is what users read. "Fix crash when
opening a project with no root document" is a changelog entry; "fix bug" is not.
Changes that need extra care
Most of the codebase takes an ordinary pull request. One area does not, because there the tests can keep passing while the thing they protect breaks:
- The Overleaf bridge (
src-tauri/src/overleaf.rs+overleaf/,src-tauri/src/overleaf_rt.rs+overleaf_rt/,src/overleaf/). Lattice speaks Overleaf's undocumented browser protocol, including a hand-written Socket.IO 0.9 client — the legacy protocol, not the modern one every current library implements. UI and bug-fix work is fine, but a change to the wire format, the OT semantics, the permission model or persisted sync state has to arrive with a fixture that fails on the old behaviour and a rollback that does not discard local work. Readdocs/overleaf-protocol.mdfirst; it lists the specific "cleanups" that are not cleanups.
That area is not closed to contributions. It just wants an issue before a large change, so the compatibility question gets answered before the code exists.
Set up the project locally
You will need:
- Node.js and pnpm —
mise.tomlpins the versions (Node 22, the major CI runs, and pnpm 10.13.1). Installing mise and runningmise installin the repository root gets you both at the right versions, andpnpm checkneeds mise anyway. - stable Rust with
rustfmtandclippy; - the desktop build prerequisites;
- Git and a TeX distribution that provides
latexmk.
corepack enable # only if you are not using mise
pnpm install
node scripts/merge-po.mjs --install # once per clone; worktrees share it
The last line registers the merge driver .gitattributes
names for the Lingui catalogs (src/locales/*/messages.po). Without it, two
branches that each add a string conflict on the generated catalogs; with it,
git unions their entries and only stops when both sides translated the same
message differently. Run pnpm i18n:check after such a merge: it re-extracts
from the merged source, so a wrong union fails there rather than in CI.
The pre-commit file-size check
pnpm install also installs a pre-commit hook: its prepare script runs
husky, which points this clone's
core.hooksPath at .husky/_. A clone that has not run pnpm install has no
hook. The hook runs lint-staged, which passes every staged file to
scripts/check-file-size.mjs.
That check keeps each source file small enough for an agent to read in one
pass: at most 8,000 tokens, counted as bytes / 4. Tests count too. A commit
fails when it stages a source file over the budget, unless the file was already
over the budget at HEAD and did not grow. The files that were over the budget
when the check arrived may stay that size, but they cannot gain a byte, so
split one before adding to it; the error message lists the split rules.
node scripts/check-file-size.mjs --all prints every file over the budget,
largest first.
Generated and vendored files are skipped through linguist-generated and
linguist-vendored in .gitattributes, so mark a new one
there rather than adding a skip list. A file whose every split would cost more
than it saves, such as one table that only reads whole, can opt out with
@ratchet-files-ignore in a comment within its first 10 lines, followed by the
reason:
// @ratchet-files-ignore: one opcode table; split, each half reads without the other.
The check then ignores the file entirely, so use this only after looking for a seam, never to get past a refusal.
The local core.hooksPath that husky sets takes precedence over a global
git config --global core.hooksPath, so the check runs whether or not you have
one. The flip side is that hooks in your global directory stop running in this
repository.
You probably do not need to build the bundled runtimes
This is the part that trips up most new contributors.
Cargo compiles Tauri's resource manifest, which expects src-tauri/synara-runtime/ to exist, but the tests never launch the runtime.
A stub file is enough:
mkdir -p src-tauri/synara-runtime
touch src-tauri/synara-runtime/placeholder.txt
With those files in place, pnpm check, cargo test, and cargo clippy all pass.
Three of the four workflows that compile the Rust crate do exactly this — see the "Stub bundled runtime resources for compilation" step in .github/workflows/ci.yml, .github/workflows/flake-hunt.yml and .github/workflows/release-cache.yml, none of which ships a binary.
.github/workflows/release.yml is the only one that stages the real pinned runtimes, because it is the only one that packages the app.
src-tauri/synara-runtime/ is gitignored, so the stub stays out of your commits.
src-tauri/presentation-runtime/ needs no stub: its placeholder.txt is tracked, and everything else in it is gitignored.
Unless you are changing the agent surface itself, stop here — you can run the
whole gate, the frontend dev server (pnpm dev), and every test without the
Synara source.
Running the full desktop app
pnpm tauri dev and pnpm tauri build do need the pinned Synara source, and
they hard-fail with Synara source is missing at … when it is absent.
The location is not fixed: scripts/prepare-synara-sidecar.mjs reads
SYNARA_SOURCE_DIR and otherwise falls back to sourceDirectory in
scripts/synara-runtime.json, which moves
whenever the pinned branch does. Derive everything from that file rather than
hardcoding a path:
export SYNARA_SOURCE_DIR="$PWD/../synara"
git clone "$(node -p "require('./scripts/synara-runtime.json').repository")" "$SYNARA_SOURCE_DIR"
git -C "$SYNARA_SOURCE_DIR" checkout \
"$(node -p "require('./scripts/synara-runtime.json').revision")"
# bun is a devDependency of this repo (package.json allowlists its install
# script for pnpm), so no global install is needed.
bun_bin="$PWD/node_modules/.bin/bun"
(cd "$SYNARA_SOURCE_DIR" && "$bun_bin" install --frozen-lockfile)
pnpm tauri dev
Keep SYNARA_SOURCE_DIR exported in the shell you run pnpm tauri dev from —
Vite's .env.local is not visible to the build scripts. If you would rather not
set it, clone into the path that node -p "require('./scripts/synara-runtime.json').sourceDirectory" prints instead.
scripts/setup-dev.sh automates all of this — mise and
the pinned toolchain, Rust, the stub, and the Synara checkout — but it targets
Debian/Ubuntu and will not run on macOS. It installs only into the
repository, ~/.local and ~/.cargo, and prints the PATH line for you to add
rather than editing your shell profile. Read it as executable documentation of
the correct bootstrap order.
Licensed whiteboard builds also require VITE_TLDRAW_LICENSE_KEY in
.env.local; see .env.example, which documents that and every
other environment variable this repository reads — including the two credentials
(OPENALEX_API_KEY, LATTICE_FIRECRAWL_KEY) that must be exported in your
shell rather than put in .env.local, because Vite only loads .env.local for
the frontend. If you add a new one, add it there in the same commit.
Verify your change
Run a focused test while iterating, then pnpm gate:changed before pushing:
pnpm vitest run src/path/to/file.test.ts
pnpm gate:changed
pnpm gate:changed (scripts/gate-changed.mjs) runs lint, typecheck, the i18n
catalogs, and the tests that vitest related finds from what your branch
changed against origin/main, plus the invariant tests no import leads to. It
builds no Rust; CI's rust job covers a src-tauri/ change.
docs/agent-validation.md has the details.
Vitest caches transformed modules in node_modules/.experimental-vitest-cache, so a repeat focused run skips most of its startup; pnpm vitest --clearCache empties it.
The full local gate, pnpm check, is mise run check and needs mise. It runs
ten stages in parallel, skipping any whose declared sources have not
changed since the last successful run:
| Stage | What it runs |
|---|---|
i18n-check |
catalog extraction, drift check, compile, coverage guard |
lint |
Oxlint over src/ and tools/, then knip (unused files, dependencies and exports) and the benchmark-table evidence check (pnpm perf:evidence) |
test |
the Vitest suite |
build |
typecheck plus the production web bundle, its WebKit built-in check and its size budget |
literature-worker |
typecheck and test inside literature-worker/ |
open-slide-runtime |
the Open Slide presentation server's tests inside tools/open-slide-runtime/, then pnpm prepare:presentation, which stages the pruned runtime and fails unless it still serves the editor |
cargo-fmt |
cargo fmt --check |
cargo-test |
the Rust tests |
clippy |
Clippy with warnings denied |
notices |
THIRD_PARTY_NOTICES.md against the installed npm and crates trees |
That covers literature-worker/ and tools/open-slide-runtime/ too, so there is nothing extra to run for a
change in there. mise.toml is the definition; keep it and this
table in step.
The commands are the same ones CI runs, except its interaction benchmark
(pnpm perf:bench --check, or mise run perf-bench; see
docs/performance.md), which needs a local Chrome,
Playwright's WebKit and a few quiet minutes. The environments are not interchangeable either: CI runs everything from a clean checkout with nothing skipped
(Rust on macOS, the rest on Ubuntu), while pnpm check skips stages it considers fresh. If
a stage passes locally and fails in CI, suspect the freshness cache first —
mise run --force check re-runs everything.
.no-mistakes.yaml configures the no-mistakes pipeline's test and lint commands and forbids its test step from launching the desktop app.
Work in the right area
| Path | What belongs there |
|---|---|
src/ |
The desktop interface, editors, and frontend tests |
src-tauri/src/ |
Project operations, LaTeX builds, Git, papers, Overleaf, and agent supervision |
literature-worker/ |
The public literature proxy Worker and its tests |
docs/ |
Design decisions and subsystem documentation for contributors |
If you are unsure where a change belongs, open an issue with the intended user experience before investing in a large implementation.
Licensing
Lattice is licensed under the Apache License, Version 2.0 (see
LICENSE and NOTICE). By opening a pull request you
agree that your contribution is offered under those terms, as section 5 of the
license describes. There is no CLA and no copyright assignment. Releases up to
and including 0.1.341 were GPL-3.0-or-later and remain available under it.
The tldraw SDK (tldraw, @tldraw/editor, @tldraw/driver) is not covered by
the Apache License; it stays under the tldraw license, whose verbatim text is
in public/licenses/tldraw-LICENSE.md. Never commit a tldraw license key.
Trellis (@danfessler/trellis, @danfessler/trellis-react), the workspace's
panel layout, is not covered by the Apache License either: it is licensed under
its own non-commercial license (with a commercial tier), whose files are in
public/licenses/trellis-LICENSE*.md. Keep those files and the attribution
"Uses Trellis by DanFessler - github.com/DanFessler/trellis" (README, NOTICE and
the macOS About panel). Change Trellis only through its pnpm patch,
patches/@danfessler__trellis@0.2.0.patch.
When you add a dependency, adapt code from elsewhere, or bundle an asset, record
it in THIRD_PARTY_NOTICES.md. That file also lists
the attribution gaps that are known and still open; adding to that list is a
perfectly good contribution.