# ARCHITECTURE-JS — the page-builder editor

The JavaScript architecture of the `cms/builder` editor: **156 vanilla ES modules** under
`resources/backend/js`, bundled by Laravel Mix into `public/backend/editor.js`.

**No jQuery. No Vue. No Tailwind — in the editor.** The editor page loads Bootstrap 5.3 from the
consumer site's frontend layout; Tailwind is admin-only and is not present on the editor page,
and Vue 2 is used elsewhere in the Bongo admin, never here. The editor bundle contains no jQuery
at all. The package *does* ship jQuery — `resources/vendor/jquery.js` (v3.4.1, published as
`public/vendor/jquery.js`) and the `resources/vendor/jquery.appear.js` plugin that
`mix.scripts()` concatenates into `public/frontend/box.js` — but both belong to the published
frontend page, and neither is ever loaded by the editor.

For the PHP/Laravel side see [ARCHITECTURE.md](./ARCHITECTURE.md).

---

## Contents

- [Where do I go to add X?](#where-do-i-go-to-add-x)
- [Non-negotiables](#non-negotiables)
- [1. Overview and the two layers](#1-overview-and-the-two-layers)
- [2. Build system](#2-build-system)
- [3. Directory map](#3-directory-map)
- [4. Initialisation sequence](#4-initialisation-sequence)
- [5. The Editor object and its state](#5-the-editor-object-and-its-state)
- [6. Sub-systems](#6-sub-systems)
- [7. Event patterns and data flow](#7-event-patterns-and-data-flow)
- [8. Globals and the iframe bridge](#8-globals-and-the-iframe-bridge)
- [9. DOM structure and CSS conventions](#9-dom-structure-and-css-conventions)
- [10. Testing](#10-testing)

---

## Where do I go to add X?

Most readers want this table. Each runbook lists the exact ordered edit sites, the base class
to extend, and the traps that fail silently.

| I need to… | Read | Start from |
|---|---|---|
| Add a button to the rich-text toolbar | [docs/adding-an-rte-toolbar-button.md](docs/adding-an-rte-toolbar-button.md) | `contentbuilder/rte/buttons/` + the registry in `contentbuilder/rte.js` |
| Make a new kind of element clickable/editable (image, table, iframe…) | [docs/adding-an-element-type.md](docs/adding-an-element-type.md) | `contentbuilder/elements/types/` + `elements/Elements.js` |
| Add a tab to the element style panel | [docs/adding-an-element-style-panel.md](docs/adding-an-element-style-panel.md) | `contentbuilder/elements/panels/`, extending `PanelSection` |
| Add a drop-in content block / design | [docs/adding-a-content-block.md](docs/adding-a-content-block.md) | `src/Views/frontend/component/` (blocks), `src/Views/frontend/design/` (sections), `src/Views/frontend/layout/` (site chrome) — PHP side |
| Find out what an option does | [docs/editor-options-reference.md](docs/editor-options-reference.md) | generated from `EditorDefaults.js` + `config/ContentBuilderConfig.js` |
| Build, publish and cache-bust the bundle | [docs/building-and-releasing-editor-assets.md](docs/building-and-releasing-editor-assets.md) | `webpack.mix.js`, `package.json` |

---

## Non-negotiables

Five rules. Three fail **silently** when missed.

1. **`this.editor`, never `this.builder`.** Every sub-class holds the Editor as `this.editor`.
   There are zero `this.builder` references in the tree; do not add the first.
2. **Never set HTML by direct DOM-property assignment.** Use `DomHelper.setHtml(el, html)` or
   `DomHelper.appendHtml(parent, html)`. This is a hard project rule — every HTML write stays on
   one auditable path — and review rejects the direct assignment.
3. **Never call `getRangeAt(0)` directly.** Use `DomHelper.getCurrentNodeFromSelection()`
   (returns `null` when there is no range) or `DomHelper.textSelection()`.
4. **Undo + onChange on every persisted-content mutation** — see
   [the mutation lifecycle](#the-mutation-lifecycle-saveforundo--mutate--onchange). **Missing
   `saveForUndo()` produces no error: Ctrl-Z just skips the edit. Missing `onChange()` produces
   no error: the page is never flagged dirty and the user's work is lost on navigate-away.**
5. **Never string-interpolate user-influenced data into an HTML string that is then parsed.**
   `DomHelper.setHtml` / `appendHtml` / `insertAdjacentHTML` / `createContextualFragment` all
   parse. Validate to a strict charset first (`/^[A-Za-z0-9_-]+$/`) or build the node with DOM
   APIs and assign `.src` as a property. This was a real stored-XSS hole in the paste path.

Plus: import `makeId` from `shared/StringHelper.js` (never write a local one); IDs are
kebab-case (`editor-chrome`, `box-tool`) and builder-owned classes are kebab-case with an `is-`
prefix (`is-section-tool`, `is-modal`); method-length target is 10 lines, 15+ is an extraction
candidate.

---

## 1. Overview and the two layers

The editor attaches to one DOM element — the page wrapper — and turns everything inside it into
an editing surface:

```js
import { initEditor } from './editor.js';

const editor = initEditor(document.querySelector('.is-wrapper'), {
    apiPath: '/api/builder',
    backendAssetPath: '/backend',
    frontendAssetPath: '/frontend',
    onChange: () => { /* mark the page dirty */ },
});
```

`initEditor(element, options)` is idempotent: it stores the instance on `element._contentbox`
and returns the existing one on a second call. `new Editor(element, opts)` is the same thing
without that guard. The constructor **alerts** if `apiPath`, `frontendAssetPath` or
`backendAssetPath` is missing — that is the "Box API or Asset Path not setup correctly" dialog.

### The two layers

There is one `Editor`, but two distinct editing systems inside it, and mixing them up is the
most common source of confusion:

**Layer 1 — page / section chrome.** Owns the document outline: sections, boxes, the sidebar,
the modals. Classes: `SectionManager`, `SectionSettings`, `BoxEditor` (+ `BoxLayout`,
`BoxBackground`, `BoxAnimation`, `BoxText`, `BoxModuleCodeEditor`), `SidebarManager`,
`ModalManager`, `ModalController`. Scope selector: `opts.page` = `.is-wrapper`.

**Layer 2 — per-content-area editing.** Runs once per content area and owns rows, columns,
cells, inline text and elements. Entry point is `ContentManager.applyBehavior()`, which walks
every `opts.container` (`.is-container`), stamps `is-builder` on it, and recurses
builder → row → column. Classes: `ContentManager`, `GridEditor`/`Grid`, `RowTool`, `RowAddTool`,
`ColumnTool`, `Rte`, `Element` and the element types.

DOM nesting mirrors that split:

```
.is-wrapper                      ← layer 1: the Editor's element
 └ .is-section (opt. .is-box)    ← layer 1: SectionManager / BoxEditor
    └ .is-container.is-builder   ← layer 2: ContentManager (opts.container)
       └ row  (+ .is-row-tool, .is-rowadd-tool)
          └ column  → contentEditable, cell-active, RTE + elements
```

### Composition tree

Built from the manager instantiation in `Editor.init()`. Property names are exactly what you
type at the console on `window.editor`:

```
Editor                              resources/backend/js/editor.js
├─ util               Util          contentbuilder/util.js
├─ dom                DomHelper     shared/DomHelper.js          (the class; static methods)
├─ uo                 UndoRedo      contentbuilder/undoredo.js
├─ contentManager     ContentManager  contentbuilder/core/ContentManager.js
├─ elements           Element       contentbuilder/elements/Elements.js
│   └─ hyperlink / image / spacer / module / code / iframe / table   elements/types/*
├─ elmTool            ElementTool   contentbuilder/elements/ElementTool.js
├─ colTool            ColumnTool    contentbuilder/grid/ColumnTool.js
├─ rowtool            RowTool       contentbuilder/grid/RowTool.js   (re-assigned per row)
├─ rte                Rte           contentbuilder/rte.js
│   ├─ mainToolbar / secondaryToolbar      rte/toolbars/*
│   ├─ buttons     Map<Class, Instance>    rte/buttons/*
│   └─ dropdowns   Map<Class, Instance>    rte/dropdowns/*
├─ openai             OpenAI        contentbuilder/openai/OpenAI.js  (attached by rte.js)
├─ tooltip            Tooltip       contentbuilder/tooltip.js
├─ colorPicker        ColorPicker   contentbuilder/colorpicker.js
├─ pluginManager      PluginManager contentbuilder/plugins/PluginManager.js
├─ _assetLoader       AssetLoader   resources/backend/js/AssetLoader.js
├─ _sidebarManager    SidebarManager    contentbuilder/ui/SidebarManager.js
├─ _sectionSettings   SectionSettings   contentbuilder/sections/SectionSettings.js
├─ _sectionManager    SectionManager    contentbuilder/sections/SectionManager.js
├─ _boxEditor         BoxEditor         contentbuilder/sections/BoxEditor.js
├─ _modalManager      ModalManager      contentbuilder/ui/ModalManager.js
└─ _modalController   ModalController   contentbuilder/ui/ModalController.js
```

`GridEditor`, `SnippetPanel` and `Tabs` are constructed but **not stored** on the Editor — they
attach themselves to the DOM and live on their own listeners.

---

## 2. Build system

`webpack.mix.js` is 18 lines and declares five outputs:

| Source | Output |
|---|---|
| `resources/backend/js/editor.js` | `public/backend/editor.js` |
| `resources/backend/js/helpers.js` | `public/backend/helpers.js` |
| `resources/backend/sass/editor.scss` | `public/backend/editor.css` |
| `resources/frontend/sass/box.scss` | `public/frontend/box.css` |
| `mix.scripts([...])` concatenation | `public/frontend/box.js` |

The last one is a plain **concatenation**, not a webpack bundle:
`resources/vendor/jquery.appear.js`, `resources/vendor/skrollr.js`, `resources/vendor/aos.js`,
then `resources/frontend/js/box.js`. It runs on the **published** page (which supplies its own
jQuery), never in the editor.

npm scripts (`package.json`):

```
npm run dev    = npm run lint && mix                (development build)
npm run prod   = npm run lint && mix --production   (minified build)
npm run watch  = mix watch
npm run lint   = ten gates — see docs/building-and-releasing-editor-assets.md § 3
```

**`lint` runs as part of `dev` and `prod`** — a lint failure aborts the build. The table in
[docs/building-and-releasing-editor-assets.md](./docs/building-and-releasing-editor-assets.md) § 3
is the authority and is kept complete by `lint:docs`. In outline:
`lint:css` (`scripts/lint-css-selectors.mjs` — fails if an SCSS selector targets an ID/class
found nowhere in `resources/backend/js`, `public/backend/plugins`, `public/backend/modules/js`
or `src/Views`); `lint:js` (`eslint resources/backend/js`, config `.eslintrc.json`, `public/`
ignored); `lint:imports` (`scripts/lint-import-case.mjs` — resolves every relative import
case-sensitively, because macOS happily resolves `./contentbuilder/Rte.js` to `rte.js` and Linux
CI does not; a mis-cased import builds and runs on macOS forever and fails only on a
case-sensitive filesystem); and `lint:public` (`scripts/lint-public-js.mjs` — `node --check` over
all 41 `.js` files under `public/backend`, which webpack and ESLint never see).

### The committed bundle, and why your breakpoint lands in minified code

**`public/backend/editor.js` is committed to git, and is a single ~908 KB line of minified
output.** Set a breakpoint in dev-tools against the production bundle and you will land in
`(()=>{var e,t={933:…`, not in your module.

**Source maps are generated for development builds** (`webpack.mix.js` — `mix.sourceMaps()` guarded
by `if (! mix.inProduction())`), so `npm run dev` gives real file, line and function names. They are
deliberately NOT generated for production, because that bundle is published to the client's admin
and a map beside it would expose the full readable source. `tests/vitest/config/sourcemaps-are-dev-only.test.js`
fails in both directions. The debug loop is in
[docs/building-and-releasing-editor-assets.md](./docs/building-and-releasing-editor-assets.md) § 9 —
remember to `npm run production` before committing, or `npm run check` will fail.

Because the bundle is committed, "I changed the source" is never enough: rebuild, publish to the
consumer site, and bump the cache-bust version. Full procedure:
[docs/building-and-releasing-editor-assets.md](docs/building-and-releasing-editor-assets.md).

---

## 3. Directory map

`resources/backend/js` — 156 `.js` files. Counts are per directory, non-recursive.

| Path | Files | What lives here |
|---|--:|---|
| `.` | 4 | `editor.js` (the `Editor` class), `EditorDefaults.js`, `AssetLoader.js`, `helpers.js` (the iframe-bridge bundle) |
| `shared/` | 12 | Editor-independent helpers: `DomHelper`, `ElementHelper`, `StringHelper`, `SelectionHelper`, `HtmlSanitiser`, `HexColour`, `BrowserDetect`, `TranslationHelper`, `UtilHelper`, `ErrorReporter` (the one way to report a runtime failure — `console.error` appeared zero times before it existed), `DialogSemantics` (the ARIA every modal needs, applied from all three open paths), `UntrustedHtml` (`sanitiseUntrustedHtml` — the DOM-based scrub for content from OUTSIDE the editor: the OpenAI reply, anything a plugin fetches. **Not** `HtmlSanitiser`, which is a regex Word-noise cleaner that touches no attributes and is not a security boundary). **No file here references the editor** — import and unit-test them freely |
| `contentbuilder/` | 18 | Large single-purpose modules: `util.js`, `html.js`, `rte.js`, `undoredo.js`, `colorpicker.js`, `gradientpicker.js`, `modal.js`, `snippetpanel.js`, `saveimages.js`, `svgicons.js`, `tabs.js`, `tooltip.js`, `dropdown.js`, `draggable.js` and their `*Templates.js` |
| `contentbuilder/core/` | 6 | `ContentManager.js` — now just **applying editing behaviour** to a content area, plus fifteen forwards — and the four concerns it used to carry alongside that: `PasteHandler.js` (the editor's main untrusted input), `DocumentEventHandler.js` (the three document-level listeners and the click classification), `CellInteraction.js` (click/keyboard inside a cell, including the undo WINDOW that makes a burst of typing one undo step), `HtmlIo.js` (read out / load in). Plus `ContentInserter.js` (caret insertion). ContentManager went 1,098 -> 490 lines; every forward is guarded by `tests/vitest/core/content-manager-delegates.test.js` |
| `contentbuilder/elements/helpers/` | 6 | `SubblockSerialiser` (`[data-subblock]` round-trips), `HtmlSlotExpander` (`expandHtmlSlots` — substitutes a custom-code block's `[%HTML1%]..[%HTML20%]` from its `data-html-n` attributes; the guard that matters is that `getAttribute` returns `null` and `decodeURIComponent(null)` yields the string `"null"`), `CssUnitParser`, `StylePropertyInput`, `StyleSelectInput`, `ToolPositioner` |
| `contentbuilder/config/` | 2 | `ContentBuilderConfig.js` (second-tier defaults) and `BuilderPresets.js` (snippet categories, colour + gradient palettes) |
| `contentbuilder/grid/` | 11 | Row/column layout: `Grid`, `GridEditor`, `RowTool`, `RowAddTool`, `ColumnTool`, `QuickAdd`, `PopoverDismiss` + templates |
| `contentbuilder/rte/` | 2 | `RteComponent.js` (base of every RTE class), `RteClickedElement.js` |
| `contentbuilder/rte/buttons/` | 33 | Every toolbar button + the four Template-Method base classes |
| `contentbuilder/rte/dropdowns/` | 14 | `RteDropdown` base, `RteLazyIframeDropdown`, and twelve concrete dropdowns |
| `contentbuilder/rte/toolbars/` | 3 | `RteToolbar` base, `RteMainToolbar`, `RteSecondaryToolbar` |
| `contentbuilder/rte/actions/` | 1 | `RemoveFormattingAction.js` — behaviour shared by a button and a dropdown item |
| `contentbuilder/elements/` | 8 | `Elements.js` (dispatcher), `ElementComponent.js` (base), `ElementWithModal.js`, `ElementTool.js`, `ElementPanel.js`, `CssEditor.js` + templates |
| `contentbuilder/elements/types/` | 13 | The seven element types (`ImageElement`, `HyperlinkElement`, `TableElement`, `CodeElement`, `IframeElement`, `ModuleElement`, `SpacerElement`) + their templates |
| `contentbuilder/elements/panels/` | 12 | `PanelSection.js` (base) + the eleven style-panel tabs |
| `contentbuilder/elements/helpers/` | 5 | `StylePropertyInput`, `StyleSelectInput`, `CssUnitParser`, `SubblockSerialiser`, `ToolPositioner` |
| `contentbuilder/sections/` | 9 | Layer-1 section/box editing: `SectionManager`, `SectionSettings`, `BoxEditor` + `Box{Layout,Background,Animation,Text,ModuleCodeEditor}`, all on `SectionComponent` — the base that owns the editor reference, `_qs`/`_qsa`, `mutate()` (the saveForUndo → change → onChange bracket, previously hand-written at 32 sites) and `snapshotOnFocus()` |
| `contentbuilder/ui/` | 13 | `SidebarManager`, `ModalManager`, `ModalController`, `LinkCleaner`, `GoogleFontsHelper` + template modules. Also the five services lifted out of `util.js` in the 4.3 split: `ModalService` (show/hide/confirm), `EmptyStateService` (empty-area UI + QuickAdd placement), `ControlStateService` (the three clear-lists), `LayoutService` (row/column classes), `ContentInsertionService` (addContent / addSnippet / htmlToElement). `Util` keeps thin delegates for their public names. |
| `contentbuilder/plugins/` | 1 | `PluginManager.js` — loads `public/backend/plugins/config.js`, then each `plugin.js` |
| `contentbuilder/utils/` | 1 | `ScriptLoader.js` — script/stylesheet loading with dedup |
| `contentbuilder/openai/` | 1 | The content-assistant modal; POSTs to the builder's `/openai/generate` endpoint (credentials stay server-side) |

---

## 4. Initialisation sequence

`Editor.init()` is a list of named private steps. This is what each does, and — crucially —
what already exists by the time it runs.

| # | Step | Effect |
|--:|---|---|
| 1 | `_mergeSettings()` | `this.settings = Object.assign({}, this._defaults, this._opts)` — plain merge of `buildEditorDefaults(opts)` under the caller's options |
| 2 | `_prepareWrapperElement()` | Adds `.is-wrapper` to the element; sets `element._contentbox = this` |
| 3 | `_wrapOnRenderHook()` | Rebinds `settings.onRender` so it is always called with the Editor as `this` |
| 4 | `_ensureEditorChromeContainer()` | Appends `<div id="editor-chrome"></div>` to `document.body` if absent |
| 5 | `_bootSidebar()` | `new SidebarManager(this).init()` — builds the sidebar markup |
| 6 | `_mergeContentBuilderOptionsOntoThis()` | **`this._config = Object.assign({}, cbDefaults, cbOpts)`**, then `_installConfigAccessors()` republishes each key onto the instance — see §5 |
| 7 | `_initialiseUtilitiesAndState()` | `this.dom`; `setSnippetsPath()`, `setLanguage()`, `setLegacyOptions()`, `setUtil()` (`this.util`), `setDevice()`, `configureUndoRedo()` (`this.uo`); `this._assetLoader`; `setBuilderStuff()` (`this.builderStuff` = `#editor-chrome`); `this.contentManager` |
| 8 | `_initialiseEditorComponents()` | SVG defs, `GridEditor`, `SnippetPanel`, `ColumnTool`, `ElementTool`, `Element`, `Rte`, `Tooltip`, then `applyBehavior()` and `setLegacyOnChange()` |
| 9 | `_initialiseInteractiveSystems()` | `Tabs`, `ColorPicker`, `PluginManager.initialise()`, `registerEventHandlers()` |
| 10 | `_registerGlobalConvenienceBindings()` | Sets `window.editor` plus four bound-method globals (§8) |
| 11 | `_instantiateManagerClasses()` | `SectionSettings`, `SectionManager`, `BoxEditor` |
| 12 | `_renderEditorChromeMarkup()` | Appends the seven modal templates into `#editor-chrome` |
| 13 | `_bootModalAndSidebarHandlers()` | `ModalManager`, `ModalController.init()`, sidebar click handlers; loads the section list directly when `useSidebar` is false |
| 14 | `setup()` | → `SectionManager.setup()` — (re)builds the per-section toolbars |
| 15 | `_ensureBoxToolRendered()` | Appends `#box-tool` if absent |
| 16 | `_applyCustomValueHidden()` | Seeds `#custom-value` from `settings.customval` |
| 17 | — | `this._colorpicker = this.colorpicker()`; `_sidebarManager.bindMouseoverHandler()` |

Two ordering facts worth knowing: `#editor-chrome` exists from step 4 (steps 5–6 reach it with
`document.getElementById('editor-chrome')`, as `SidebarManager.init()` does), while
`editor.builderStuff` is only assigned in step 7 by `setBuilderStuff()` — **so only step 7
onwards may append into `editor.builderStuff`; earlier it is `undefined`**. And `applyBehavior()`
(step 8) is what makes cells editable — after injecting sections yourself, call `editor.setup()`
and `editor.contentManager.applyBehavior()` again.

### Teardown

`editor.destroy()` is the mirror image: `_flushContentBackToWrapper()` (serialises via `html()`
and deletes `element._contentbox`), `_tearDownEditorChrome()` (removes `#editor-chrome`),
`_unbindDocumentListeners()` (removes the three `document` handlers and calls
`_modalController.destroy()`), then `util.clearControls()`, `_cleanBuilderMarkup()`,
`_destroySortableObjects()` and `_removeBuilderStuffOverlay()`. **Anything you attach at
`document` level — a listener, a `MutationObserver`, a timer — must be unregistered in
`_unbindDocumentListeners()`, or it outlives the editor and fires against detached nodes.**

---

## 5. The Editor object and its state

### `editor.opts`, `editor.settings` and `editor.config` are one object — and it is NOT the editor

Read this carefully; it changed on 2026-08-05 and most people's mental model predates it.

```js
// editor.js — _mergeContentBuilderOptionsOntoThis()
this._config = Object.assign({}, cbDefaults, cbOpts);
this._installConfigAccessors();
```

The configuration lives in **its own object**, `this._config`. `opts`, `settings` and `config` are
three getters that all return it:

```js
editor.opts === editor.settings;          // true
editor.settings === editor.config;        // true
editor.opts === editor;                   // FALSE — this is the part that changed
```

Until 2026-08-05 the merge was `this.opts = Object.assign(this, cbDefaults, cbOpts)`. `Object.assign`
returns its *target*, so `opts` was the editor itself and all ~85 config keys were own properties
sitting in the same namespace as the ~230 methods. That was RC1 in the architecture assessment and
it is now undone.

**Bare reads still work, on purpose.** `window.editor` is a supported surface that consumer sites
and customer-written plugins reach directly, and some read config bare. Every key is therefore
republished onto the instance as a get/set accessor pair onto `_config`:

```js
editor.snippetPath;                       // works — accessor onto _config
editor.rowTool = 'left';                  // writes THROUGH to editor.opts.rowTool
Object.getOwnPropertyDescriptor(editor, 'rowTool').get;  // a function, not a data property
```

That shim is compatibility, not the design. Retiring it is a separate, deliberate breaking change.

**Which name to write in new code:**

- For **reads**, use `this.editor.config.someOption`. `scripts/lint-bare-config-reads.mjs` fails the
  build on a bare `this.editor.someOption`, because that spelling is the one that breaks silently if
  the accessors are ever retired.
- To **fire the change notification**, use `this.editor.settings.onChange()`. Inside an
  `ElementComponent` subclass just call `this.onChange()`.
- Do not "fix" existing `this.editor.opts.X` sites to `.config.X` as drive-by churn. Both spellings
  are live and identical; only the *bare* form is banned.

**An option can no longer shadow a method — the boot refuses it.**
`Editor._assertNoConfigShadowsMethod()` throws at start-up, at both merge points, naming the key.
There is no allowlist: `onChange` and `onRender` used to be both config keys and prototype methods,
which made those two methods permanently unreachable dead code; they were deleted, so nothing is
exempt. Guarded by `tests/vitest/core/config-shadows-method.test.js`.

### The defaults cascade trap

Defaults come from two files that merge in cascade, and `setLegacyOptions()` does
`Object.assign(this.opts, this.settings)` — so anything in `EditorDefaults.js` **overwrites** the
ContentBuilder-layer value. Keys that need a cbOpts override (`page`, `container`,
`snippetList`, `lang`, `snippetJSON`, `fontSizeClassValues`, …) must live **only** in
`config/ContentBuilderConfig.js`; `EditorDefaults.js` carries an inline NOTE listing them. After
any defaults change run `tests/browser/core/config-cascade.spec.js` — moving a key the wrong way
breaks section-delete-then-undo, silently — and run `npm run docs:options`
(`scripts/generate-options-reference.mjs`) to regenerate
[docs/editor-options-reference.md](docs/editor-options-reference.md) from the two files.

### Active-element state

The Editor also carries the current selection state as ad-hoc properties. **These are declared
nowhere** — no field manifest, no initialiser — and are set and cleared by whichever subsystem
owns them:

| Field | Set / cleared by |
|---|---|
| `activeCol` | `ContentManager.handleCellClick` and `Grid` (a duplicated row hands the pointer to its clone); cleared in `util.clearActiveCell` |
| `activeImage` | `ImageElement`, `ContentManager` |
| `activeIcon` | `Elements`, `HyperlinkElement`, `rte.js`; cleared by `util._deactivateActiveIcons` |
| `activeLink` | Set by `HyperlinkElement`; cleared in `util.js`; read by `public/backend/plugins/buttons/js/button.js` |
| `activeModule` | Set by `ModuleElement`; cleared in `util.js` |
| `activeIframe` | `IframeElement` |
| `activeSpacer` | `SpacerElement` |
| `activeCodeBlock` | `CodeElement` |
| `activeTd`, `activeTable` | `TableElement`, `util.js` |
| `activeElement`, `inspectedElement` | `ElementTool`, `ElementPanel` |
| `_activeSection`, `_activeBox` | `SectionManager`, `BoxEditor` |
| `selection` | `util.saveSelection` / `util.restoreSelection` |

**Three different clear-lists exist in `contentbuilder/ui/ControlStateService.js` and they are
*not identical*:**
`clearActiveCell()` (nulls `activeCol` only), `clearControls()` → `_clearActiveElementRefs()`
(nulls eight refs, plus `activeIcon` via `_deactivateActiveIcons()`), and `clearAfterUndoRedo()`
→ `_clearPostUndoActiveRefs()` (the same refs as `clearControls` **except** `activeImage`, which
is deliberately preserved across undo/redo). **If you add a new `activeXxx`, check all three —
otherwise expect a stale reference, and no error to tell you about it.**

### Public API

`window.editor` is the instance. Key methods: `html(bForView)`, `loadHtml(html)`, `setup()`,
`undo()`, `redo()`, `saveForUndo(checkLater)`, `onChange()`, `onRender()`, `out(text)`
(translation), `makeId()`, `addButton(btn)` (sidebar), `addPluginButton(...)`, `showModal()` /
`hideModal()`, `viewHtml(area)`, `getScope()`. `editor.dom` is `DomHelper` — use it from outside
the bundle (iframe pages, tests); inside the bundle import `DomHelper` directly.

---

## 6. Sub-systems

### `rte/` — the reference pattern

`contentbuilder/rte.js` holds the `Rte` controller. It builds two **class-keyed registries** from
declarative tables in `registerButtons()` and `registerDropdowns()`:

```js
this.buttons = new Map(
    definitions.map(([ButtonClass, ref]) => [ButtonClass, new ButtonClass(this.editor, ref)])
);
```

Keys are class references, not strings, so `identifyButton(RteBoldButton)` greps perfectly and a
typo is a `ReferenceError` rather than a silent miss. This is the best pattern in the codebase —
copy it when you build a new registry.

**Registration and placement are two separate lists.** `registerButtons()` creates the instance;
a toolbar's or dropdown's `buttons = [...]` array decides where it appears. `Rte.init()` only
calls `init()` on buttons present in `mainToolbar.buttons`, `secondaryToolbar.buttons`,
`RteMainMoreDropdown.buttons` or `RteSecondaryMoreDropdown.buttons`. **A button you register but
never place renders nowhere and is never initialised — no error, no warning.** (Several are
deliberately in that state and carry a `// Deferred:` comment.) The mirror mistake is louder but
just as easy to misread: a button **placed** in a toolbar array but never registered in
`registerButtons()` falls through `Rte.identifyButton()` to `new RteNotFoundButton(name)`, which
renders a visible `-` button with `class="rte-not-found rte-button"` and
`title="Button X not found"`. **A stray dash in the toolbar means "placed but not registered".**

Every RTE class descends from `rte/RteComponent.js`, which supplies `emit`/`listen`, show/hide,
activate/deactivate, `getElement()` (lazy `.${ref}` lookup), positioning and class helpers.
`RteButton extends RteComponent` adds `alignText(value)`.

The four Template-Method base classes — pick the one matching the behaviour, implement only the
hooks:

| Base | Hooks you implement | Ceremony it owns |
|---|---|---|
| `RteInlineStyleButton` | `execCommandName()`, `wrapperTagName()`, `toggleInlineStyle(el)` | `init()`, saveForUndo, restoreSelection, selection-vs-caret branch, persist selection, `onChange()` |
| `RteDropdownToggleButton` | `dropdownRef()`, optional `togglesActiveState()` | `init()` over all matching elements, active toggle, `emit('<ref>:toggle')` |
| `RteAlignDirectionButton` | `alignDirection()`, optional `iconSuffix()` / `title()` | `init()`, `alignText()` wiring, and the `render()` markup |
| `RteActionButton` | `executeAction(element, selectedText, hasSelection)` | The full undo / selection / `onChange()` ceremony |

`RteBoldButton` is the canonical example — three one-line hooks (`execCommandName`,
`wrapperTagName`, `toggleInlineStyle`) plus `render()`.
`RteActionButton` currently has **no subclass**: it is the base to reach for when a new button
runs one undo-tracked action on the caret element.

Dropdowns extend `rte/dropdowns/RteDropdown.js`, which owns the run-once `init()` guard (you
implement `bindButtons()`) and `runRteMutation(apply)` — the same ceremony as `RteActionButton`,
for a dropdown item. `show()` calls `init()` on **every** open, and only the `hasBeenInitialised`
flag inside the base `init()` stops the re-binding — **so never override `init()` in a dropdown
subclass. Doing so defeats the guard and re-binds every handler on each open, silently stacking
duplicate listeners.** Put your wiring in `bindButtons()`. `RteLazyIframeDropdown` adds lazy
iframe loading (font family, icons). Behaviour shared between a button and a dropdown item
belongs in `rte/actions/` as a static class — see `RemoveFormattingAction.apply(...)`.

### `elements/` — element types and the style panel

`ElementComponent` (`elements/ElementComponent.js`) is the base for everything here. It throws if
constructed without an editor and exposes `this.editor`, `get config()`, `get builderStuff()`,
`saveForUndo()`, `onChange()`, `onRender()`, `showModal()`, `hideModal()`.

`Elements.js` (class `Element`, stored as `editor.elements` — `editor.element` is the DOM
wrapper) instantiates the seven types and dispatches `click(col, e)` to them **in a fixed order**
(image must precede link). Adding a type means editing three places in that one file: the
import, the constructor, and the `click()` dispatch. **If the new type parks a reference on the
Editor (`activeSpacer`, `activeIframe`, …), that is a fourth edit site: register it in all three
`util.js` clear-lists (see [Active-element state](#active-element-state)), or the reference
survives a deselect and the next gesture acts on the wrong node.**

`IframeElement` and `ModuleElement` extend `ElementWithModal` (hooks: `toolSelector`,
`modalSelector`, `toolHtml`, `openModal`, `saveModal`, `afterSave`). The other five extend
`ElementComponent` directly — the base does not fit `TableElement` or `CodeElement`.

The style panel is `ElementPanel.js` plus eleven `panels/*` classes extending `PanelSection`,
which gives you `get panelElement()`, `get inspectedElement()` and
`bindStyleInput(inputId, unitId, cssProperty, options)` / `bindStyleSelect(...)` — those wire a
`StylePropertyInput` / `StyleSelectInput` to a CSS property on the inspected element. A new panel
must be registered in **five** places (the class file, the import, the constructor
instantiation, the `_readAllPanelStyles` array, and the tab + content `<div>` in
`ElementPanelTemplate.js`). **Miss the array and the tab renders but never populates — no
error.** Follow [docs/adding-an-element-style-panel.md](docs/adding-an-element-style-panel.md).

### `grid/`

`GridEditor` renders the floating row/column modal and binds its buttons; `Grid` holds the
operations themselves (`moveColumnPrevious`, duplicate, resize, remove — each doing
`saveForUndo()` → mutate → `onChange()`). `RowTool` and `RowAddTool` are re-rendered per row by
`ContentManager._attachRowTools`; `ColumnTool` floats over the active column. `QuickAdd` is the
insert palette. `PopoverDismiss` is the shared outside-click dismisser for `RowTool`'s and
`ColumnTool`'s "more" popovers.

### `sections/` and `ui/`

`SectionManager` adds / duplicates / removes / reorders `.is-section` nodes and rebuilds the
`.is-section-tool` toolbars in `setup()`. `SectionSettings` owns separators, height, scroll icons
and the section class list. `BoxEditor` is a thin orchestrator delegating to `BoxLayout`,
`BoxBackground`, `BoxAnimation`, `BoxText` and `BoxModuleCodeEditor`.

In `ui/`: `SidebarManager` builds the left button bar and its lazy iframe panels; `ModalManager`
handles show/hide plus the HTML-source views; `ModalController` is cross-cutting — it observes
the `active` class under `#editor-chrome`, locks page scroll, guarantees a close control, and
closes the topmost modal on `Escape`, whichever code path opened it. `LinkCleaner` strips unused
font links and duplicate content styles before `editor.html()` serialises.

### `core/`, `plugins/`, `utils/`, `shared/`

`ContentManager` is the layer-2 engine — `applyBehavior()`, the document handlers, and the cell
handlers (`handleCellClick`, `handleCellKeypress` — the debounced typing undo snapshot —
`handleCellKeydown`, `handleCellKeyup`, `handleCellPaste`). `ContentInserter` backs
`util.pasteHtmlAtCaret(html, selectPastedContent)`.

`PluginManager` loads `pluginPath + 'config.js'` (which sets `editor.settings.plugins = [...]`),
injects a placeholder button per plugin into both "more" dropdowns, then loads each
`plugins/{name}/plugin.js`, which replaces its placeholder by calling
`editor.addPluginButton(name, html, selector, callback)` / `editor.addPluginButton2(...)` — thin
wrappers over `PluginManager.addButton` / `addButton2`. Do not confuse these with
`editor.addButton(btn)`, which adds a **sidebar** button. `ScriptLoader` and the root
`AssetLoader` load scripts and styles with dedup.

`shared/` holds nine modules with **zero executable references to the editor** — pure functions
you can import and unit-test with no Editor at all: `DomHelper` (classes, attributes, ancestor
walks, selection, positioning), `ElementHelper` (`isBold`, `toggleItalic`,
`clearAllFormatting`), `StringHelper` (`makeId`, `replaceTemplateIds`), `SelectionHelper`,
`HtmlSanitiser` (`cleanHtml`, for paste — Word noise only, NOT a security boundary),
`UntrustedHtml` (`sanitiseUntrustedHtml`, for third-party HTML), `HexColour` (`lightenDarkenHex`), `BrowserDetect`,
`TranslationHelper` (`translate`). `UtilHelper.js` is an empty placeholder class.

---

## 7. Event patterns and data flow

### The RTE CustomEvent bus (RTE-internal only)

`RteComponent.emit(name, data)` dispatches a `CustomEvent` **on `window`**;
`RteComponent.listen(name, cb)` subscribes. `setListeners()`, run in the constructor,
auto-subscribes every RTE component to five ref-scoped events:

```
<ref>:toggle   <ref>:show   <ref>:hide   <ref>:activate   <ref>:deactivate
```

…and **throws** at construction if the class does not implement the matching method — so a
missing handler is a loud failure, not a silent one.

The bus has exactly one emitter in the tree: `RteDropdownToggleButton.handleClick()` fires
`'<dropdownRef>:toggle'`. Nothing outside `rte/` emits or listens. Do not treat it as a general
application event bus.

### Document-level handlers

There is no single global click handler — seven modules attach their own `document` click
listener. The two that matter:

- `ContentManager.registerEventHandlers()` binds `click`, `mousedown` and `keydown` on
  `document` (kept on the editor as `doDocumentClick` / `doDocumentMousedown` /
  `doDocumentKeydown`). The click handler classifies the target and, when the click lands
  outside the editing surface, calls `util.clearActiveCell()` + `util.clearControls()`. The
  keydown handler is where undo (Ctrl/Cmd-Z, no Shift, no Alt) and redo (Ctrl/Cmd-Shift-Z, or
  Ctrl-Y — `ctrlKey` only, not Cmd) are detected, in `_isUndoCombo` / `_isRedoCombo`.
- `Rte.init()` closes any visible dropdown whose ref is not an ancestor of the click target, and
  clears the toggle button's `.active` in lock-step.

Cell-level handlers are bound once per column by `ContentManager._bindCellInteractionsOnce`,
guarded by a `data-click` attribute: `click`, two `keydown`s, `keyup`, `paste`.

### Selection save/restore

A text selection does not survive a click on a toolbar button, so the RTE saves and restores it
around every gesture. `util.saveSelection()` stores ranges on `editor.selection` via
`SelectionHelper.captureModernRanges()`; `util.restoreSelection()` clears and re-applies them.
The base classes already do this — you only need it in a handler written outside them.

### The mutation lifecycle: saveForUndo → mutate → onChange

Every handler that mutates **persisted page content** (style, attribute, class, text, adding /
deleting / moving / cloning a node, setting HTML) must do both:

```js
this.editor.saveForUndo();          // ONCE per user gesture, BEFORE the mutation
element.style.color = colour;       // the mutation
this.editor.settings.onChange();    // AFTER the mutation
```

- **Buttons and toggles** snapshot on `click`.
- **Text inputs** snapshot on `focus`, once, so held keys do not flood the stack:
  `DomHelper.bindOnce(input, 'focus', () => this.editor.saveForUndo());`
- **Drag reorder** fires `onChange()` in `onEnd`, gated on `oldIndex !== newIndex`.

**The central `onChange` fires ONLY from `ContentManager` — `handleCellKeyup` (content-cell
`keyup`), `_processPastedContentWord` (the paste tail), `_onSortableEnd` (a drop that actually
reordered), `_handleSnippetDrop` and `loadHtml`. A content-cell *click* does NOT fire it —
`handleCellClick` fires `opts.onContentClick(e)`, which is a different callback.** It does
**not** fire for toolbar, modal or picker mouse gestures, nor for any drag handler you write
yourself outside `ContentManager`'s sortable — so colour-picker callbacks, box-tool buttons, RTE
dropdown inserts and panel inputs must each fire it themselves. `util.pasteHtmlAtCaret()` does
**not** fire it; the caller must.

**Exclusions — these must NOT snapshot:** read-only handlers, populate/refresh handlers,
open-panel and switch-tab handlers, and anything touching only editor chrome (markers, tools,
overlays). A snapshot there pollutes the undo stack with no-ops.

**Both omissions are silent.** No exception, no console message, no failing test. Missing
`saveForUndo()` ⇒ Ctrl-Z jumps past the edit. Missing `onChange()` ⇒ the page is never marked
dirty and the user's work is lost on navigate-away. This is the most commonly missed obligation
in the whole codebase.

`UndoRedo` (`contentbuilder/undoredo.js`) keeps a slot-indexed history of the serialised
container HTML (plus stylesheet links, unless `undoContainerOnly` is set), and clears the redo
region on a genuine new edit so undo → edit → redo cannot resurrect an abandoned branch.

---

## 8. Globals and the iframe bridge

`editor.js` sets these on `window`. `scriptPath`, `trans`, `applyBoxImage` and `applyTypography`
are assigned at module scope near the top of the file; `Editor` and `initEditor` at module scope
at the bottom; the rest inside `_registerGlobalConvenienceBindings()` during `init()`:

| Global | Purpose |
|---|---|
| `window.editor` | The `Editor` instance. Plugins call `editor.x()`; iframe pages call `parent.editor.x()` |
| `window.Editor`, `window.initEditor` | The class and the factory, for host pages that boot the editor |
| `window.scriptPath` | Directory of the currently-executing script, derived at load |
| `window.trans(s)` | Translation lookup against `window._txt` |
| `window.applyBoxImage(s)` | Routes to `.is-wrapper._contentbox.boxImage(s)` |
| `window.applyTypography(className, contentCss, pageCss)` | Called from `public/backend/styles/js/styles.js` |
| `window.applyLargerImage`, `window.selectFile`, `window.selectImage`, `window.imageLoaded` | Bound Editor methods. `selectImage` / `selectFile` are called by the images / files iframe pages; `imageLoaded` is reached from an inline `onload=` attribute emitted by `grid/QuickAdd.js`, so it must stay global |

`helpers.js` builds separately to `public/backend/helpers.js` and sets `window.Helpers` with
`makeId`, `replaceTemplateIds`, `translate`, `getActiveModule`, `getSettings`, `getSettingsJson`,
`setHtml`, `setSettings`, `getDocumentReadyScript`, `convertColumnsToBootstrap`. It is explicitly
a temporary bridge — when the iframes go, delete the file, its `webpack.mix.js` entry, and the
`<script>` tags in `src/Views/api/**`.

### `public/backend/**/*.js` is SOURCE, not build output

This trips people up. Only `public/backend/editor.js`, `public/backend/helpers.js`,
`public/backend/editor.css`, `public/frontend/box.js` and `public/frontend/box.css` are build
artefacts. **Everything else under `public/backend` is outside webpack, outside ESLint and
outside vitest — hand-maintained first-party source, plus two vendored libraries
(`codemirror/5.48.4/**`, `js-beautify/1.15.1/beautify-html.min.js`) that must not be edited:**

- `public/backend/modules/js/*.js` — the twelve iframe module pages (gallery-builder, slider,
  post, project, question, form-builder, menu-builder, contact-form, review, review-form,
  review-summary, collection-module)
- `public/backend/plugins/config.js` + `plugins/*/plugin.js` — the editor plugins
- `public/backend/lang/{en,fr}.js` — the `_txt` translation store
- `public/backend/{snippets,designs,images,files,icons,fonts,styles}/js/*.js` — iframe panels

The only automated check over them is `npm run lint:public`, which is `node --check` — syntax
only. A logic error there ships. Their host Blade views live in `src/Views/api/`.

Direction of travel across the iframe boundary:

- **Downwards** — iframe pages read `parent.document` and `parent._txt`, and call
  `parent.editor.activeCol`, `parent.editor.activeLink`, `parent.editor.uo.saveForUndo()`,
  `parent.editor.opts.onChange()`, `parent.editor.pasteHtmlAtCaret()`, `parent.editor.addIcon()`,
  `parent.editor.setFont()`, `parent.editor.clearFont()`, `parent.editor.dropdown()`,
  `parent.editor.getScope()`.
- **Sideways** — `Helpers.makeId()`, `Helpers.getSettingsJson()`, `Helpers.setHtml()`,
  `Helpers.setSettings()`, `Helpers.getDocumentReadyScript()`.

An iframe page that mutates page content owes the same debt as §7, across the frame boundary:
`parent.editor.uo.saveForUndo()` before, `parent.editor.opts.onChange()` after.

---

## 9. DOM structure and CSS conventions

### Containers

- **`#editor-chrome`** — created by `_ensureEditorChromeContainer()` on `document.body`. Every
  piece of editor UI (toolbars, tools, modals, pickers, panels) lives inside it, and
  `editor.builderStuff` / `util.builderStuff()` return it. It is *not* the edited content.
- **`.is-wrapper`** — the element the Editor is attached to; the edited page.
- **`.is-container.is-builder`** — a content area (`is-builder` is stamped on by
  `ContentManager`).
- Named regions: `#rte-tool`, `#box-tool`, `#sidebar-snippets`, `#sidebar-sections`,
  `#sidebar-source`, `#sidebar-typography`, `#custom-value`.

### State classes

`cell-active` (the active column), `row-active` and `row-outline` (its row), `builder-active`
(the content area), `icon-active` (a selected empty `<i>` placeholder), `elm-inspected` and
`elm-active` (element style panel), plus `active` / `deactive` on RTE components and `active` on
open modals and sidebar panels.

Adding a new transient marker class has **two** edit sites, not one:
`TRANSIENT_EDITOR_CLASSES` in `shared/ElementHelper.js` (the declared single source of truth —
`elm-active`, `elm-inspected`, `cell-active`, `row-active`, `row-outline`) and the near-duplicate
list in `html.js._cleanupCellAndRowSelection`, which strips the same five plus `sortable-chosen`,
`sortable-ghost` and `is-builder` on serialisation. **Miss both and the marker class is written
into the published page — silently, with the styling that goes with it.**

### SCSS

Entry point `resources/backend/sass/editor.scss` — everything is nested inside
`body.web-editor`, so no editor rule can leak onto a published page. It imports
`vendor/animate/index`, `contentbuilder/index` and `contentbuilder/utilities`.
`contentbuilder/index.scss` imports the partials in dependency order: tokens (`_variables`,
`_mixins`), foundation (`_reset`, `_wrapper`, `_builder`, `_icons`, `_no_grid`), then `ui/`,
`sections/`, `elements/`, `rte/`. 64 `.scss` files in total; the tree deliberately mirrors the
JS tree. Use Bootstrap 5.3 utilities first (`d-flex`, `w-100`, `p-0`, `position-absolute`);
custom utilities go in `contentbuilder/_utilities.scss`; never add inline `style="…"` to a
template string.

### The z-index scale

Tokenised in `contentbuilder/_variables.scss` — use the token, never a raw number:

| Band | Tokens |
|---|---|
| Behind the canvas | `$editor-z-behind-deep: -1000`, `$editor-z-behind: -1` |
| In-canvas content | `$editor-z-base: 1`, `$editor-z-raised: 2`, `$editor-z-tool: 10`, `$editor-z-content-top: 100`, `$editor-z-panel: 1000`, `$editor-z-section-overlay: 9999` |
| Editor chrome | `$editor-z-chrome: 10001`, `$editor-z-chrome-raised: 10002`, `$editor-z-chrome-floating: 10003`, `$editor-z-chrome-modal: 10004`, `$editor-z-chrome-modal-top: 10005` |
| Top-level overlays | `$editor-z-overlay: 100000` (snippet panel), `$editor-z-tooltip: 100005` |

The colour tokens in the same file are being adopted incrementally — a raw hex is replaced only
once the property it sits on matches the token's role. Do not do a blind value-only swap.

---

## 10. Testing

Two layers, both real. Which one you owe depends on what you touched:

| You changed | Run |
|---|---|
| Anything in `shared/`, or pure logic with a stub editor | `npx vitest run` — milliseconds, no browser, no consumer site |
| Anything the browser suite covers (editor behaviour, toolbars, panels, modals, grid) | `npm run prod` **first**, then `npx playwright test` — the suite serves this package's freshly built bundle, and needs a configured `.env.testing` |

**Unit — vitest + jsdom.** 83 `*.test.js` files under `tests/vitest/`, mirroring the source tree
(`core/`, `rte/`, `elements/`, `elements/panels/`, `elements/types/`, `grid/`, `sections/`,
`ui/`, `shared/`, `config/`, `plugins/`, `modules/`, `widgets/`, `fixes/`, `misc/`). Configured
by `vitest.config.js` with `tests/vitest/setup.js`. Milliseconds; no browser, no consumer site.
The pattern is real classes against real stub editors, with real bound events fired and real DOM
assertions — near-zero mocking. Copy the nearest existing test rather than inventing a style.

```bash
cd cms/builder
npx vitest run          # or: npm run test:unit  (npx vitest for watch mode)
```

**Browser — Playwright.** 135 `*.spec.js` files under `tests/browser/`, grouped by subsystem:
`elements/` 36, `core/` 24, `rte/` 21, `modals/` 10, `plugins/` 10, `ui/` 7, `grid/` 6,
`panels/` 6, `rows/` 5, `columns/` 4, `sections/` 4, `modules/` 1, plus `parity-capture.spec.js`.

```bash
cd cms/builder
npx playwright test          # or: npm test   (--headed to watch it)
```

These run against a **live consumer site**, configured via `.env.testing` (`TEST_BASE_URL`,
`TEST_EMAIL`, `TEST_PASSWORD` — see `.env.testing.example`); `tests/browser/global-setup.js`
writes `storageState.json`. `tests/browser/helpers/editor.js` exports `navigateToEditor(page)`,
which reaches the editor by clicking the nav link as a real user does, and route-intercepts the
CSS/JS so the suite tests **this package's freshly built** `public/backend/editor.js` rather than
the consumer site's published copy — so run `npm run prod` before the browser suite.

**Without a `.env.testing`, `TEST_BASE_URL` — and so Playwright's `baseURL` — is undefined:
`global-setup` fails fast, or, if a stale `storageState.json` is lying around, it warns, reuses
it, and every spec then fails on navigation.** That is missing configuration, not a regression.

Targeted regression nets, worth running after touching the matching subsystem:

| Spec | Run after changing |
|---|---|
| `core/config-cascade.spec.js` | `EditorDefaults.js` / `ContentBuilderConfig.js` |
| `core/shared-modules.spec.js` | anything in `shared/`, or `BuilderPresets` / `GoogleFontsHelper` |
| `core/dom-helper-rebind.spec.js` | `DomHelper.rebindEvent` / `rebindEventWithElement` |
| `core/undo-redo.spec.js`, `core/structural-undo.spec.js` | the undo lifecycle |
| `core/subblock-serialiser.spec.js` | `[data-subblock]` save/restore |
| `elements/element-with-modal.spec.js` | `ElementWithModal` |
| `modals/modal-envelope-shape.spec.js` | any `Modal` change |

**A visual or editor-JS change is not done until you have opened it in a real browser and
exercised it.** Unit tests green and the page rendering are both necessary and neither is
sufficient: click every control you touched, watch the console for errors, and confirm the edit
survives a save and reload.
