Developer guide#
Everything a new person on the team needs to go from a clean machine to a merged change. Read Architecture and the Coding guide alongside it; this one is the procedure.
What you are working on#
Ohm is a browser built on Chromium (Electron today, a Chromium fork later) with a scripting language built in: Lua 5.4 with LaTeX math, compiled line-for-line to Lua and run in a worker thread. Around that sit projects, prompts, experiments, curves, graphs, specs and document annotation — the parts that make it a research tool rather than a browser with a macro language.
Two constraints shape almost every decision:
- The engine is replaceable. Only
packages/engine-electronmay importelectron. - Files are the source of truth. Everything the user makes is a plain file in a folder they own.
Setting up#
You need Node 22 or newer and git. Nothing else — no Rust, no Python, no C++ toolchain unless you rebuild llama.cpp from source, which nobody does.
git clone <repo> && cd ohm
npm install
npm run check # ~40 seconds; everything should pass on a clean checkout
npm run dev # Vite for the UI, esbuild watching the main process
The installer workspace is separate and has its own dependencies. You only need it when you are packaging:
cd installers && npm install && npm run assets
On Windows, build.bat does the whole thing; on macOS and Linux, ./build.sh.
Platform notes#
| Platform | Notes |
|---|---|
| Windows | ELECTRON_ENABLE_LOGGING=1 or you see no console output from the main process. Git Bash works; PowerShell mangles $ and backslashes in arguments. |
| macOS | Building the DMG needs macOS (hdiutil, codesign). Unsigned builds run locally and warn elsewhere. |
| Linux | AppImage and .deb need Linux tooling; --targets tar.gz works from any host. |
The map#
| Path | Owns |
|---|---|
apps/desktop |
the composition root: picks the engine, resolves folders, wires core |
apps/ui |
React + Monaco renderer, served from ohm-app://, engine-agnostic |
packages/engine-api |
the interfaces a browser engine must provide |
packages/engine-electron |
the Electron implementation — the only import 'electron' |
packages/protocol |
every method and event between UI and core, as types |
packages/core |
tabs, scripts, projects, prompts, experiments, settings, models, AI |
packages/ohm-lang |
splitter → lexer → parser → Lua codegen, and the MathML renderer |
packages/ohm-runtime |
wasmoon in a worker thread, the cooperative scheduler, the matrix prelude |
packages/curves |
piecewise curves and the safe expression language |
packages/graph |
graph components and the graph→Ohm compiler |
packages/docview |
PDF, Word, spreadsheet and slide parsing |
packages/annotate |
capture modes, anchoring and drift detection |
scripts/ |
the checks and generators: engine boundary, specs, docs, screenshots |
docs/ |
the documentation, compiled into the app's Help tab |
installers/ |
Windows, macOS and Linux packaging |
The daily loop#
npm run dev
- The UI hot-reloads. Save a
.tsxand the window updates. - The main process rebuilds and Electron restarts. You lose open tabs; that is normal.
- The worker is rebuilt with the main process, so a language change needs the restart.
Useful while you work:
npx vitest run packages/core/test/app-config.test.ts # one test file
npx vitest # watch mode
npm run typecheck # just the compiler
OHM_DEVTOOLS=1 npm run dev # DevTools for the UI
Run a throwaway instance that cannot touch your own data:
Ohm.exe --data-dir /tmp/ohm/data --profile-dir /tmp/ohm/profile --scripts-dir /tmp/ohm/scripts
Adding a feature, end to end#
The worked example is the one in the repository: moving Ohm's folders. It touched every layer, in this order.
1. Decide where the logic lives, and keep it out of Electron.
packages/core/src/app-config.ts takes argv, env and defaults and returns paths. It
imports node:fs and node:path and nothing else, which is why it is testable and why the
engine check stays green.
2. Add the protocol.
'config.get': [void, AppDirsState] and friends in packages/protocol/src/index.ts, with
the interfaces beside them. Now CoreHandlers demands a handler and the UI gets types.
3. Add an engine capability if you need one.
Restarting is something only the engine can do, so BrowserEngine.restart() went into
engine-api and app.relaunch() + app.exit(0) into engine-electron.
4. Handle it in core.
Small handlers that delegate. Validate the input (checkDirKey), and refuse impossible
requests with a sentence the user can act on.
5. Wire the composition root.
apps/desktop/src/main.ts. Order matters here more than anywhere: userData and
sessionData must be set before Chromium opens them, so the config is read synchronously
at module scope, before whenReady.
6. Build the UI.
A component in apps/ui/src/components, a section in SettingsPane, styles scoped to your
own classes in styles.css.
7. Test the logic, not the plumbing.
23 tests for precedence, parsing, corrupt files and refusals. Two of them failed first and
were right to fail — the flag/env order was wrong, and restartRequired was claiming a
restart would help when a flag would survive it.
8. Then verify it for real.
Tests do not prove that Chromium actually wrote its profile where you asked. Drive the app
over CDP (--remote-debugging-port), or run it by hand and look.
9. Document and specify.
docs/reference/environment.md, docs/guide/storage.md, a section in
docs/specs/src/12-storage.spec.mjs, then npm run specs, npm run docs, npm run shots
if a screen changed.
Shorter recipes#
| To add… | Do this |
|---|---|
| A function scripts can call | a method on ScriptHost, then the table in docs/reference/script-api.md and the editor's completion data |
An operator (\Foo_{\Psi}) |
the operator table in packages/ohm-lang, codegen, a host implementation, the guide and a compile test |
| A settings page | a component beside LibrarySettings.tsx, a Section in SettingsPane.tsx, a row in SECTION_LABELS |
| A project item type | ProjectService.listItems kinds, an editor view, the file-format reference |
| A document viewer | a parser in packages/docview, a view in apps/ui/src/views, a case in DocumentViewer.tsx |
Debugging#
| Symptom | Where to look |
|---|---|
| Nothing in the console on Windows | ELECTRON_ENABLE_LOGGING=1 |
Works in npm run dev, not in npm start |
the production CSP, injected at build time |
| A script hangs | Shift+F5 stops the worker; the scheduler is cooperative, so a tight Lua loop never yields |
| A page action does nothing | the CDP automation is attached per tab — check the tab is not discarded |
| Wrong line in an error | compilation is line-for-line; if it drifted, the codegen emitted a newline it should not have |
| The UI cannot see a file | the renderer has no filesystem. It needs a core method or an ohm-file:// URL |
To drive a running instance from outside — which is how the screenshot and verification scripts work:
Ohm.exe --remote-debugging-port=9333
Then connect to http://127.0.0.1:9333/json/list, find the target whose URL contains
ohm-app, and talk CDP to it. scripts/capture-docs.mjs is a complete worked example.
Before you hand it over#
A review here asks these questions:
-
npm run checkpasses. - Nothing outside
engine-electronimportselectron. - New behaviour has a test, and the test would fail without the change.
- Errors say what happened and what to do about it.
- Secrets stay in core; nothing new is sent to the renderer that should not be.
- Paths from outside are contained; deletes go to the trash; writes are atomic.
- Comments explain decisions, not syntax, and none of them are now false.
- Docs, specs and screenshots updated for anything a user can see.
- No new dependency without a sentence justifying it.
- You ran it, not just the tests.
Building and releasing#
./build.sh all # macOS, Linux: check, build, package
build.bat all # Windows
Or by hand — see Build and package for the detail:
npm run build # app only
cd installers
npm run build:win # NSIS wizard + portable
npm run build:mac # DMG + zip (on macOS)
npm run build:linux # AppImage + deb (on Linux)
npm run build:linux -- --targets tar.gz # works from any host
npm run build:mac -- --validate # check the config from anywhere
Then actually install the result and run it. The end-to-end checklist is in Build and package; at minimum, install, launch, run a script, open a page, and uninstall.
Long work, across sessions#
plans/ holds work that spans more than a sitting. One markdown file per step, with its
status in the frontmatter, so the plan diffs in git and merges like code.
npm run plan # where everything stands
node scripts/plan.mjs next # the next step, in full
node scripts/plan.mjs done 05-ocr "tessdata was the slow part"
Every step is self-contained on purpose: what to change, how to verify it, what done means.
Someone who has never seen the discussion that produced the plan should be able to read one
file and do the work. When you stop mid-step, leave what you learned in its Notes — that
is what makes the next start cheap. npm run check validates the plans, so a step that
depends on something that does not exist, or is marked done while its dependencies are not,
fails the build.
When you are stuck#
- Read the spec for the area:
docs/specs/*.spec.mdare written to be rebuildable from, and they record the decisions and the known gaps. - Search the tests. Most non-obvious behaviour has one, named in plain English.
- Check the traps table in the Coding guide. Several hours of this project are already recorded there.