# Site Builder — Five User-Reported Issues **Date:** 2026-08-08 **Status:** Approved design, ready for implementation planning **Repos touched:** `cloud-hosting-platform/site-builder` (`craft/`) and `cloud-hosting-platform/whp` (`web-files/`, `sql/`, `scripts/`) ## Background Five issues came in from people using the WHP site builder: 1. Elements can be dropped outside the main page; once there they cannot be selected or deleted. 2. The HTML element exposes colour options that show in the builder but do not reach the live page. 3. Users want a way to reset a page to blank. 4. The Layers tab sometimes does not show everything. 5. There is no way to report a builder problem from inside the builder. Two of these are already explained by the source: - **#2 is a render/export mismatch, not just a stray control.** `HtmlBlock`'s React render applies its `style` prop, but `HtmlBlock.toHtml()` returns only the DOMPurify-cleaned `code` and ignores `style` entirely. Every style control the panel offers is therefore dead on export. The controls themselves come from `GenericPropsEditor`, the catch-all panel that `GuidedStyles` routes HTML to via its `isUtility` branch (shared with Divider and Spacer). - **#4 is a data-model consequence.** `FeaturesGrid`, `Tabs`, `Accordion`, `PricingTable`, `Testimonials`, `Gallery`, `ContentSlider`, `NumberCounter`, `Menu`, `SocialLinks`, `Navbar` and `ContactForm` all hold their content in **array props**, not Craft child nodes, so they are genuinely leaf nodes in the Craft tree and `LayersPanel` has nothing to nest under them. `ColumnLayout` is the exception — it uses real `` children, which is why columns nest correctly and nothing else does. #1 is **not** diagnosed. Reproducing it is the first task of implementation, and the prevention fix follows the evidence rather than this document. ## Scope In scope: the five items above, across the Craft.js editor, the WHP site-builder API, one new database table, and one new root-only admin page. Out of scope: converting composite components from array props to real Craft children; any change to publish/export output beyond `HtmlBlock` no longer applying `style` in the editor; CSRF enforcement anywhere other than the one new endpoint. --- ## Item 1 — Elements dropped outside the page ### Invariant **Every node in editor state is reachable from `ROOT`.** Enforced at three independent points so no single failure strands a block. ### 1a. Prevention (drop time) A drop whose resolved target is not a `ROOT` descendant is rejected: a new block from `BlocksPanel` is never created, and a moved block returns to its origin. The mechanism is deliberately left open pending a live reproduction. Two structural candidates, both to be tested against a real editor session before anything is written: - Craft.js's `DefaultEventHandlers` retains the **last valid drop indicator** when the pointer leaves every droppable region. The gutter beside `.canvas-device-frame` (visible at tablet/mobile device widths) and the `pointer-events: none` header/footer `ZonePreview` bands are both places where the pointer is over the canvas area but over no Craft node. - `Canvas.tsx` wraps `` in a plain positioned `
`; drops landing on that wrapper resolve to no node. **The plan's first task is reproduction, and its acceptance criterion is a written description of the actual mechanism.** No fix is written before that. ### 1b. Repair (load time) `PageContext`'s deserialization path runs an orphan sweep before handing state to Craft: - Parse the serialized craft state, walk every node's parent chain. - Any node whose chain does not terminate at `ROOT` is reattached to the end of `ROOT`'s children. - The repair is logged to the console with node id and type. This is a **pure function** over serialized state — `repairOrphanNodes(serialized: string): { state: string; repaired: string[] }` — unit-testable with no browser and no Craft instance, following the precedent of `PageContext.treeToState.test.ts`. ### 1c. Recovery (always available) `LayersPanel` gains an **Unplaced** group, rendered after the `ROOT` tree, listing any node in `state.nodes` not reachable from `ROOT`. Rows behave like normal layer rows: click to select, and the existing delete paths (context menu, Delete key) then work on them. The group renders only when the set is non-empty, so ordinary sessions see no new UI. ### Error handling If `repairOrphanNodes` throws on malformed state, it returns the input unchanged and logs — a page must never fail to load because the repair pass could not parse it. --- ## Item 2 — HTML block ### 2a. Only the Edit HTML control - `GuidedStyles` gains an `isHtml` branch (`/^html$/i`) evaluated **before** `isUtility`, and `html` is removed from the `isUtility` regex so the two cannot both match. - New `panels/right/styles/HtmlStylePanel.tsx` renders the Edit HTML button and nothing else. - `HtmlCodeField` moves out of `GenericPropsEditor.tsx` into `panels/right/styles/HtmlCodeField.tsx` and is imported by `HtmlStylePanel`. `GenericPropsEditor` keeps `code` in `SKIP_PROPS` (harmless, and guards any future component carrying a `code` prop). ### 2b. Render must match export `HtmlBlock`'s render stops applying the `style` prop. It keeps the selection outline and `minHeight: 40px` — both are editor affordances, not content. This is the fix for the actual complaint. Removing the controls alone would leave already-styled blocks still rendering their dead styling in the editor and still not on the live page. Existing stored `style` props are left in place: they are already ignored by `toHtml()`, so they have no output effect, and stripping them would mean touching every saved site. ### 2c. Smarter HTML editor `ui/CodeEditor.tsx` already provides CodeMirror 6 with autocompletion, auto-closing tags and brackets, bracket matching, a fold gutter, `indentWithTab` and the one-dark theme. Nothing new is needed for tab-complete — but the component **lazy-loads CodeMirror and silently falls back to a plain `