scmJS docs

Conventions

These are the rules everything else follows. The design notes in .claude/internals/ give the reasons, and the source comments say more.

State is Jotai, in one store. There are no context providers. Persisted settings are atomWithStorage atoms under keys starting with scmjs., listed in Preferences ▸ Storage; each key must 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. The parsed scenario that is written to disk is changed directly, not replaced, so React does not see an edit on its own. Each area has a revision counter, bumped after every edit, undo and load, that the viewport and panels subscribe to. The chrome shows some fields (map name, size, tileset) from a second set of atoms; change one side and you must change the other.

Several maps can be open. The state atoms hold only the map in front; the others are parked records that a tab switch swaps in. A new piece of per-document state must be added to the park and install lists in src/atoms/documentAtoms.ts, or it leaks from one map into the next.

Only changed sections are written. The scenario keeps the set of sections to re-encode on save, and everything else goes out byte for byte. Code that changes the scenario must mark every section it touched, or the change is silently lost on save.

Edits are invertible change lists. A brush stroke, a placement or a paste produces a list of { before, after } changes per layer, applied in one fixed order and reversed for undo. A stroke is one history entry; the depth is a preference (Preferences ▸ Editing ▸ Undo, 200 by default). The settings dialogs and the trigger editors are OK / Apply / Cancel transactions, whose history entry is the difference between the tables before and after. Resize and the tileset change sit outside the undo model and clear it.

Every write goes through the commits. The commit, undo and redo paths, the settings and trigger commits and the whole-document changes are where repainting, history and shared maps hook in. A new way of changing the scenario that bypasses them is invisible to the other people on a shared map.

Dialogs are lazy. Adding one means an id 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.

Log what is worth explaining later. The editor keeps a log of what it did, which users read in View ▸ Debug Console and copy into bug reports. Log counts and labels, never payloads, and one line per user action, never per tile or per frame. Chatty lines go behind the console's Verbose tick.

A render that throws is contained. Each dock panel, the palette, the map view and every dialog has an error boundary, with one last boundary around the whole application. A new top-level surface (a dock, a strip, a floating panel host) needs a boundary of its own.

User-visible text goes through t(). See Translations.

Plugins get the same API the editor uses. src/plugins/api.ts is the contract. A plugin's edit goes through the same transaction and history as a brush stroke. See The plugin host.