scmJS docs

How the editor is put together

These are the conventions that everything else follows. Each is explained in more depth in CLAUDE.md, and the source comments say why.

State is Jotai, and there is one store. No context providers, no Redux. Persisted settings are atomWithStorage atoms under keys starting with scmjs., listed in Preferences ▸ Storage; every such key has to be registered in the reset table in src/atoms/preferencesAtoms.ts, and a test fails when one is not.

The scenario is mutated in place. scenarioAtom holds the parsed Scenario that is written to disk, and edits change it directly rather than replacing it. React therefore does not see an edit on its own: each area has a revision counter (terrainRevisionAtom and its siblings) that is bumped after every edit, undo and load, and the viewport and panels subscribe to those. A second set of atoms mirrors the fields the chrome displays (map name, size, tileset); change one side and you have to change the other.

Only changed sections are written. Scenario.dirty is the set of section names to re-encode on save; everything else goes out byte for byte. Any code that mutates the scenario must mark every section it touched, or the change is silently lost on save. file-formats.md has the rest.

Edits are invertible change lists. A brush, a placement or a paste produces a list of { before, after } changes per layer, applied by one function in one fixed order and reversed for undo. A stroke is one history entry (200 levels). The settings dialogs, the trigger editors, resize, and the tileset change are transactions outside the undo model, as in StarEdit: each is its own OK / Apply / Cancel.

Dialogs are lazy. Adding one means a DialogId in src/components/dialogs/ids.ts and an entry in the registry in DialogHost.tsx, which loads each dialog module on first use. Nothing on the startup path may import a dialog module statically, or Vite folds it back into the main chunk.

Anything worth explaining later is logged. The editor keeps a ring buffer of what it did — maps opened and saved, plugins started and stopped, where the game data resolved, and every uncaught error and rejected promise — which the user reads in View ▸ Debug Console and copies, with a header describing the build, into a bug report. It records whether the console is open or not, because the failures worth reporting do not repeat on request. Two rules for anything that writes to it: log counts and labels, never payloads (an entry holding an edit's change list would pin every cell record it touched for as long as the ring holds the line), and log at the granularity of user intent, never the inner loop — one line per stroke, nothing on the paint or pointer path. Warnings and errors are mirrored to the browser's console as before. The chatty tier — every plugin API call, every edit — is behind the console's Verbose tick and off by default, and code on a hot path checks that flag before it builds a message.

Plugins get the same API the editor uses. src/plugins/api.ts is the contract; the host builds it over the store with no React and no atoms exposed. A plugin's edit goes through the same transaction and history as a brush stroke, and a builder that throws is rolled back through the same change lists. See The plugin host.