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.