Coding guide#
How code is written in this repository. Architecture explains what the pieces are; this explains what a change should look like.
The short version: the reader is the point. Someone will open your file in two years knowing nothing. Write for them.
The rules a machine enforces#
npm run check is the contract. It runs five things, and all five are quick:
| Step | Fails when |
|---|---|
check:engine |
anything outside packages/engine-electron imports electron |
check:specs |
docs/specs/*.spec.{json,md} disagree with docs/specs/src |
check:docs |
the compiled help site is stale |
typecheck |
tsc --noEmit complains anywhere |
test |
any vitest test fails |
Run it before you hand anything over. If a rule is wrong, change the rule and say why — do not route around it.
TypeScript#
- Strict, with
noUncheckedIndexedAccess.array[0]isT | undefined. Handle it; don't reach for!unless you have just proved the index exists on the line above. - No
any. Data arriving from disk, the network or the protocol isunknownand gets narrowed at the boundary, once, where the shape is documented. - Named exports only, re-exported from each package's
src/index.ts. Import across packages by alias (@ohm/core), never by relative path into another package. import typefor types, so the emitted bundle has no phantom dependency.- Interfaces for data, discriminated unions for variants. Anchors, spec nodes and
library entries all carry a
kind; switch on it and let the compiler find the case you forgot. - Functions over classes, except where something genuinely owns state and a lifecycle
(
TabManager,SettingsService,ScriptService).
Comments#
A comment earns its place by recording something the code cannot say: a decision, a trap, a constraint from outside. Not by restating the next line.
// EBUSY on Windows means the browser is open and holding its SQLite files.
report.locked.push(label);
// A flag is the most explicit thing the user can say, so it outranks a variable, which
// outranks the config file.
Every file opens with a block that says what it is for and what is surprising about it. Every exported function or type gets a line saying what it is for — and when there is a reason behind the signature, the reason, not a restatement of the parameters.
Delete comments that have become false. A wrong comment is worse than none.
Naming#
- Files are
kebab-case.ts; React components arePascalCase.tsx. - Types are nouns (
ResolvedDir), functions are verbs (resolveDirs), predicates read as questions (supportsBlocks). - No abbreviations that a newcomer would have to look up.
destination, notdst. - Protocol methods are
area.verb:scripts.run,config.setDir,profiles.import.
Errors#
Every error message is read by a person who is trying to get something done.
throw new Error('That is the profile folder Ohm is using right now. Choose a different folder for the profile first, then import into it.');
- A sentence, capitalised, saying what happened and what to do next.
- Never a bare code, never a stack trace in the UI. The settings pane strips Electron's
Error invoking remote method '…'prefix; keep the rest readable. - Validate at the boundary and refuse early. A bad folder is rejected when it is chosen, not at the next start when it would stop the app from working.
Async#
- No floating promises. Prefix a deliberate fire-and-forget with
void. - Clean up in
finally— a temp file, a probe, a worker, a render task. - Anything that can be cancelled should be. The document viewers keep their pdf.js render tasks and cancel them on unmount, because two renders on one canvas throws.
The UI#
apps/ui is engine-agnostic. It knows the HostBridge and nothing else.
- The renderer never touches the filesystem, never spawns anything and never imports
electron. It asks core. - State comes from core: call once on mount, then subscribe to the matching event
(
settings.changed,tabs.changed,projects.changed). There is no state library and no client-side merge — core emits complete snapshots. - Function components. Hooks at the top, no conditional hooks.
useCallbackfor anything passed to a child that memoises. - The page is a real Chromium view layered over the UI, so "hiding" it means reporting a zero-sized rectangle, never unmounting.
- Styles live in
apps/ui/src/styles.csswith the existing custom properties. Scope new rules to a class you added;.doc-toolbar button.ononce repainted an unrelated view blue-on-blue because it was not scoped to>.
Adding a protocol method#
The order matters, because each step makes the next one type-check:
packages/protocol/src/index.ts— add'area.verb': [Params, Result]to the methods map, and any new interface beside it.packages/core/src/core.ts— add the handler.CoreHandlersis keyed by the methods map, so a missing handler is a compile error.- UI —
host.call('area.verb', params), fully typed. - If it needs something only the engine can do, add it to
@ohm/engine-apifirst and implement it in@ohm/engine-electron.BrowserEngine.restart()was added exactly this way. docs/reference/protocol.md— one row.
Storage#
- Files are the source of truth. Bundles are copies; the browser mirror is a backup.
- Atomic writes: temp file then rename. An interrupted save must not leave half a document.
- Delete means trash.
.ohm/trash/<timestamp>-<name>; a misclick in a file tree is never final. - Never overwrite on import. A name collision gets
(2). - Contain every path. Resolve against the root and refuse anything that escapes it —
in
ScriptFiles, inProjectServiceand in each custom scheme handler.
Security#
These are not preferences:
- Secrets never reach the renderer. The UI learns whether a key is set, never its value.
- Remote endpoints are off by default, and the master switch is checked before the endpoint is resolved, so "off" means no request is even attempted.
- Anything copied from elsewhere on the user's machine is explained: the Chrome profile import deliberately leaves passwords and extensions behind, and says so in the result.
- A script is untrusted input. It gets the host API, not the filesystem.
Tests#
vitest, in packages/*/test, named after the behaviour:
it('refuses to write into the profile Ohm is running from', async () => { … });
- Real filesystem, temp folders.
mkdtempinbeforeEach,rminafterEach. Mocks offstest the mock. - Fakes for the engine, not for our own code.
tab-manager.test.tsimplements a whole secondEngineTab/EngineWindow, which is also the proof that the engine seam is real. - One behaviour per test, and prefer a test that would have caught a bug you actually hit. Half the regression tests here are named after a real mistake.
- When a test fails because the expectation was wrong, fix the expectation and say so in
a comment if the behaviour is subtle.
restartRequiredis false while a flag is in force, and the test says why.
Documentation is part of the change#
A feature is not finished when it works.
| Change | Also update |
|---|---|
| Anything a user can see | the relevant docs/guide/*.md |
| A protocol method | docs/reference/protocol.md |
| A flag, variable or path | docs/reference/environment.md |
| Any behaviour worth rebuilding from | docs/specs/src/*.spec.mjs, then npm run specs |
| A screen that changed shape | npm run shots |
npm run check fails when the compiled docs or the generated specs are stale, so this is
not a matter of remembering.
Traps this codebase has already hit#
Each of these cost real time once. They are all encoded in a test or a comment now.
| Trap | What to do |
|---|---|
Backticks inside a Lua string in a TS String.raw template end the template |
no backticks in MATRIX_PRELUDE |
node -e and heredocs mangle \, $ and ${ on Windows |
write files with an editor tool, not through the shell |
| pdf.js text layers collapse to zero width | set --scale-round-x/y: 1px on the text layer |
| Two pdf.js renders on one canvas throw | keep the task, cancel it on unmount |
| Electron quits after an offscreen capture | app.on('window-all-closed', () => {}) in capture scripts |
| Electron logs nothing on Windows | ELECTRON_ENABLE_LOGGING=1 |
| A UI automation click that silently does nothing | hash each screenshot; identical shots fail the run |
app.setPath('userData') after whenReady |
resolve folders synchronously, at module scope |
electron-builder EBUSY on win-unpacked |
close the shell sitting in it, or build with --out |
| A generic class name in our global CSS | Monaco names its own elements main, body, action, tree, empty, context-view. Prefix ours; apps/ui/test/css-collisions.test.ts enforces it |
A UI bug that the DOM and textContent both say is fine |
take a screenshot. Twice now the data was perfect and the pixels were not |
Dependencies#
The app has four runtime dependencies that matter (electron, wasmoon,
node-llama-cpp, marked for the docs build) and that is deliberate. Before adding one,
say in the pull request what it does that fifty lines here could not. The installer has to
ship every transitive dependency: 91 packages today, most of them llama.cpp's.