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] is T | 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 is unknown and 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 type for 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 are PascalCase.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, not dst.
  • 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. useCallback for 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.css with the existing custom properties. Scope new rules to a class you added; .doc-toolbar button.on once 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:

  1. packages/protocol/src/index.ts — add 'area.verb': [Params, Result] to the methods map, and any new interface beside it.
  2. packages/core/src/core.ts — add the handler. CoreHandlers is keyed by the methods map, so a missing handler is a compile error.
  3. UI — host.call('area.verb', params), fully typed.
  4. If it needs something only the engine can do, add it to @ohm/engine-api first and implement it in @ohm/engine-electron. BrowserEngine.restart() was added exactly this way.
  5. 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, in ProjectService and 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. mkdtemp in beforeEach, rm in afterEach. Mocks of fs test the mock.
  • Fakes for the engine, not for our own code. tab-manager.test.ts implements a whole second EngineTab/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. restartRequired is 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.