Files
site-builder/docs/superpowers/plans/2026-08-08-site-builder-user-reported-issues.md
T

4245 lines
152 KiB
Markdown
Raw Normal View History

# Site Builder — Five User-Reported Issues Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix five reported site-builder problems — elements stranded outside the page, dead colour controls on the HTML block, no way to blank a page, an incomplete Layers tree, and no in-builder way to report a bug.
**Architecture:** Editor work lands in the Craft.js app at `/workspace/site-builder/craft/`. Every non-trivial behaviour is first extracted as a **pure function** in `src/utils/` or a small registry module, unit-tested with no React and no Craft instance, then wired into a panel. Reporting adds one endpoint to the existing WHP site-builder API, one MySQL table, and one root-only admin page in `/workspace/whp/`.
**Tech Stack:** Vite 6, React 18, TypeScript 5 (strict), @craftjs/core 0.2.x, CodeMirror 6 (lazy-loaded), vitest + jsdom, PHP 8 + PDO/MySQL, Bootstrap 5 (admin page).
## Global Constraints
- **Design spec:** `docs/superpowers/specs/2026-08-08-site-builder-user-reported-issues-design.md`. Read it before starting.
- **Active source is `/workspace/site-builder/craft/`.** Never edit `/workspace/site-builder/` top level — that is the dead GrapesJS builder.
- **No `@/` alias in tests.** `vitest.config.ts` defines no `resolve.alias`. Use relative imports in all source and test files.
- **No `@testing-library/react`.** This repo renders with `createRoot` + `act` from `react-dom/test-utils`. For a component needing only `useNode`, mock `@craftjs/core` (pattern: `src/components/media/ImageBlock.render.test.tsx`). For anything needing a real editor, use `renderEditorHarness()` from `src/test-utils/editorHarness.tsx`.
- **Run tests with:** `cd /workspace/site-builder/craft && npx vitest run <path>`.
- **New dependencies: none.** The HTML formatter is hand-written; do not add `js-beautify`, `prettier`, `html2canvas`, or an Emmet package.
- **Every component prop change must honour both sides:** the React `render` *and* the static `.toHtml(props, childrenHtml)`. `HtmlBlock` is the exception this plan creates deliberately (Task 1).
- **PHP shell/CLI rule:** verify anything path-sensitive through the web path, not bare `php -r` — CLI has an empty `open_basedir`.
- **SQL migrations are idempotent** (`CREATE TABLE IF NOT EXISTS`), go in `sql/migrations/staging/`, and never hand-create versioned directories.
- **Commit after every task.** Co-author trailer: `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
---
## File Structure
**Created in `/workspace/site-builder/craft/`:**
| File | Responsibility |
|---|---|
| `src/utils/orphan-repair.ts` | Pure: find nodes unreachable from `ROOT` in serialized craft state; reattach them. |
| `src/utils/orphan-repair.test.ts` | Tests for the above. |
| `src/utils/format-html.ts` | Pure: indent-only HTML prettifier. |
| `src/utils/format-html.test.ts` | Tests for the above. |
| `src/utils/console-buffer.ts` | Global ring buffer of the last 20 console/window errors. |
| `src/utils/console-buffer.test.ts` | Tests for the above. |
| `src/utils/build-stamp.ts` | Safe accessor for the `__EDITOR_BUILD__` compile-time define. |
| `src/utils/report-payload.ts` | Pure: assemble + size-cap an issue-report payload. |
| `src/utils/report-payload.test.ts` | Tests for the above. |
| `src/panels/left/layers-virtual-rows.ts` | Registry + pure derivation of virtual child rows for array-prop composites. |
| `src/panels/left/layers-virtual-rows.test.ts` | Tests for the above. |
| `src/panels/left/LayerFocusContext.tsx` | Context carrying "scroll array item N of node X into view". |
| `src/panels/right/styles/HtmlCodeField.tsx` | The Edit HTML button + modal (moved out of `GenericPropsEditor`). |
| `src/panels/right/styles/HtmlToolbar.tsx` | Insert/colour/format toolbar for the Edit HTML modal. |
| `src/panels/right/styles/HtmlStylePanel.tsx` | HTML block's only style panel — the Edit HTML control alone. |
| `src/panels/right/styles/HtmlStylePanel.test.tsx` | Asserts no colour/style controls render. |
| `src/panels/topbar/ReportIssueModal.tsx` | Report-an-issue form + submit. |
| `src/panels/topbar/ReportIssueModal.test.tsx` | Tests for the above. |
**Modified in `craft/`:** `src/components/basic/HtmlBlock.tsx`, `src/panels/right/GuidedStyles.tsx`, `src/panels/right/styles/GenericPropsEditor.tsx`, `src/panels/right/styles/index.ts`, `src/ui/CodeEditor.tsx`, `src/state/PageContext.tsx`, `src/panels/left/LayersPanel.tsx`, `src/panels/left/PagesPanel.tsx`, `src/panels/right/SiteDesignPanel.tsx`, `src/panels/topbar/TopBar.tsx`, `src/panels/topbar/TopBarOverflowMenu.tsx`, `src/main.tsx`, `vite.config.ts`, `src/styles/editor.css`.
**Created in `/workspace/whp/`:** `sql/migrations/staging/create-site-builder-reports.sql`, `scripts/test-site-builder-report-validation.php`, `web-files/pages/site-builder-reports.php`.
**Modified in `/workspace/whp/`:** `web-files/api/site-builder.php`, `web-files/includes/` sidebar, `web-files/libs/permission_manager.php`, `web-files/index.php` (page registration), `DOCS_FOR_AGENTS/DATABASE_SCHEMA.md`.
---
## Phase 1 — HTML block (spec item 2)
### Task 1: HtmlBlock stops applying `style` in the editor
The reported symptom is that colours show in the builder and never reach the live page. `toHtml()` already ignores `style`; the render does not. Fix the render so both sides agree.
**Files:**
- Modify: `src/components/basic/HtmlBlock.tsx:65-78`
- Test: `src/components/basic/HtmlBlock.render.test.tsx` (create)
- Test: `src/components/basic/HtmlBlock.toHtml.test.ts` (extend)
**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `HtmlBlock` render no longer reads `props.style`. `HtmlBlockProps` keeps its `style?: CSSProperties` field so stored state stays loadable.
- [ ] **Step 1: Write the failing render test**
Create `src/components/basic/HtmlBlock.render.test.tsx`:
```tsx
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('@craftjs/core', () => ({
useNode: (collect?: (node: any) => any) => {
const node = { events: { selected: false } };
return {
connectors: { connect: (el: any) => el, drag: (el: any) => el },
actions: { setProp: vi.fn() },
...(collect ? collect(node) : {}),
};
},
}));
import { HtmlBlock } from './HtmlBlock';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
describe('HtmlBlock render ignores the style prop (matches toHtml)', () => {
test('a stored backgroundColor/color/padding is NOT applied to the wrapper', () => {
render(
React.createElement(HtmlBlock as any, {
code: '<p>hello</p>',
style: { backgroundColor: 'rgb(255, 0, 0)', color: 'rgb(0, 0, 255)', padding: '40px' },
}),
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.backgroundColor).toBe('');
expect(wrapper.style.color).toBe('');
expect(wrapper.style.padding).toBe('');
});
test('the editor affordances survive: minHeight is kept, content still renders', () => {
render(React.createElement(HtmlBlock as any, { code: '<p>hello</p>', style: {} }));
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.minHeight).toBe('40px');
expect(wrapper.innerHTML).toContain('hello');
});
});
```
- [ ] **Step 2: Run it and verify it fails**
Run: `npx vitest run src/components/basic/HtmlBlock.render.test.tsx`
Expected: FAIL — the first test reports `backgroundColor` as `rgb(255, 0, 0)` because the current render spreads `...style`.
- [ ] **Step 3: Make the render ignore `style`**
In `src/components/basic/HtmlBlock.tsx`, replace the component body's `React.createElement` call:
```tsx
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
const clean = useMemo(() => purifyHtml(code), [code]);
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
// The `style` prop is deliberately NOT applied. `toHtml()` emits only the
// purified `code`, so anything styled here would show in the editor and
// vanish on the live page -- the exact bug this block was reported for.
// Wrapper styling belongs in the user's own markup (see the Edit HTML
// toolbar's colour control). `style` stays on the props interface so
// already-saved sites keep deserializing cleanly.
return React.createElement('div', {
ref: setRef,
style: {
minHeight: '40px',
outline: selected ? '2px solid #3b82f6' : 'none',
},
dangerouslySetInnerHTML: { __html: clean },
});
};
```
- [ ] **Step 4: Add the matching export assertion**
Append to `src/components/basic/HtmlBlock.toHtml.test.ts`:
```ts
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
const out = (HtmlBlock as any).toHtml(
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
'',
);
expect(out.html).toBe('<p>hi</p>');
expect(out.html).not.toContain('background');
expect(out.html).not.toContain('40px');
});
```
- [ ] **Step 5: Run both files and verify they pass**
Run: `npx vitest run src/components/basic/HtmlBlock`
Expected: PASS, all tests in `HtmlBlock.test.ts`, `HtmlBlock.toHtml.test.ts`, `HtmlBlock.render.test.tsx`.
- [ ] **Step 6: Commit**
```bash
cd /workspace/site-builder
git add craft/src/components/basic/HtmlBlock.tsx craft/src/components/basic/HtmlBlock.render.test.tsx craft/src/components/basic/HtmlBlock.toHtml.test.ts
git commit -m "fix(site-builder): HtmlBlock render stops applying style so editor matches published output
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Move `HtmlCodeField` into its own file
Pure extraction, no behaviour change. Isolating it first keeps Task 3 (the panel) and Task 6 (the toolbar) from both editing `GenericPropsEditor.tsx`.
**Files:**
- Create: `src/panels/right/styles/HtmlCodeField.tsx`
- Modify: `src/panels/right/styles/GenericPropsEditor.tsx:1-118`
**Interfaces:**
- Produces: `export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }>` — renders a `CollapsibleSection` titled "HTML Code" containing an "Edit HTML" button that opens a `Modal` with a `CodeEditor`.
- [ ] **Step 1: Create the new file**
Create `src/panels/right/styles/HtmlCodeField.tsx` with the exact `HtmlCodeField` component currently living at `GenericPropsEditor.tsx:28-92`, plus its imports:
```tsx
import React, { useState } from 'react';
import { CollapsibleSection, sectionGap } from './shared';
import { Modal } from '../../../ui/Modal';
import { CodeEditor } from '../../../ui/CodeEditor';
/* "Edit HTML" modal for the HtmlBlock `code` prop. `code` is raw HTML
(potentially many lines, embedded <style>/<script>), so it gets a
dedicated syntax-highlighted CodeEditor in a modal rather than the
generic single-line/textarea string-prop rendering. */
export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
const [open, setOpen] = useState(false);
return (
<CollapsibleSection title="HTML Code">
<div style={sectionGap}>
<button
onClick={() => setOpen(true)}
style={{
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
borderRadius: 6, cursor: 'pointer',
}}
>
<i className="fa fa-code" /> Edit HTML
</button>
</div>
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
<div
style={{
background: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
<button
onClick={() => setOpen(false)}
style={{
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
}}
>
<i className="fa fa-times" />
</button>
</div>
<div style={{ padding: 16 }}>
<CodeEditor value={value} onChange={onChange} language="html" height={420} />
</div>
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => setOpen(false)}
style={{
padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
}}
>
Done
</button>
</div>
</div>
</Modal>
</CollapsibleSection>
);
};
```
- [ ] **Step 2: Delete the old copy and import instead**
In `src/panels/right/styles/GenericPropsEditor.tsx`: delete lines 2392 (the comment block and the local `HtmlCodeField`), drop the now-unused `useState`, `Modal` and `CodeEditor` imports, and add:
```tsx
import { HtmlCodeField } from './HtmlCodeField';
```
Leave the `hasCodeProp` branch in the JSX exactly as-is — it still renders `<HtmlCodeField .../>`.
- [ ] **Step 3: Typecheck**
Run: `npx tsc --noEmit`
Expected: no errors. (An "unused import" error means step 2's import cleanup was incomplete.)
- [ ] **Step 4: Run the full suite to prove nothing regressed**
Run: `npx vitest run`
Expected: PASS, same test count as before the change.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/right/styles/HtmlCodeField.tsx craft/src/panels/right/styles/GenericPropsEditor.tsx
git commit -m "refactor(site-builder): extract HtmlCodeField out of GenericPropsEditor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: HTML blocks get their own panel with only the Edit HTML control
**Files:**
- Create: `src/panels/right/styles/HtmlStylePanel.tsx`
- Create: `src/panels/right/styles/HtmlStylePanel.test.tsx`
- Modify: `src/panels/right/styles/index.ts`
- Modify: `src/panels/right/GuidedStyles.tsx:79` and `:161-167`
**Interfaces:**
- Consumes: `HtmlCodeField` (Task 2), `useNodeProp` from `./shared`.
- Produces: `export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<string, any> }>`, re-exported from `./styles/index.ts`.
- [ ] **Step 1: Write the failing test**
Create `src/panels/right/styles/HtmlStylePanel.test.tsx`:
```tsx
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: vi.fn() }, query: {} }),
}));
import { HtmlStylePanel } from './HtmlStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
describe('HtmlStylePanel', () => {
test('renders the Edit HTML control', () => {
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
expect(container.textContent).toContain('Edit HTML');
});
test('renders NO colour or style controls (they never reach the live page)', () => {
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
expect(container.querySelector('input[type="color"]')).toBeNull();
expect(container.textContent).not.toContain('Background');
expect(container.textContent).not.toContain('Text Color');
expect(container.textContent).not.toContain('Padding');
expect(container.textContent).not.toContain('Border Radius');
});
});
```
- [ ] **Step 2: Run it and verify it fails**
Run: `npx vitest run src/panels/right/styles/HtmlStylePanel.test.tsx`
Expected: FAIL — `Cannot find module './HtmlStylePanel'`.
- [ ] **Step 3: Create the panel**
Create `src/panels/right/styles/HtmlStylePanel.tsx`:
```tsx
import React from 'react';
import { useNodeProp } from './shared';
import { HtmlCodeField } from './HtmlCodeField';
/**
* The HTML block's entire style panel.
*
* Deliberately ONLY the code editor. `HtmlBlock.toHtml()` emits nothing but
* the purified `code`, so every colour/padding/alignment control the generic
* editor used to offer here was dead on the published page. Styling an HTML
* block is done inside the user's own markup.
*/
export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<string, any> }> = ({
selectedId,
nodeProps,
}) => {
const { setProp: setPropValue } = useNodeProp(selectedId);
return (
<>
<HtmlCodeField
value={typeof nodeProps.code === 'string' ? nodeProps.code : ''}
onChange={(v) => setPropValue('code', v)}
/>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
Style this block inside your own markup a wrapper set here would show
in the editor but not on the published page.
</p>
</>
);
};
```
- [ ] **Step 4: Export it and route HTML to it**
In `src/panels/right/styles/index.ts` add:
```ts
export { HtmlStylePanel } from './HtmlStylePanel';
```
In `src/panels/right/GuidedStyles.tsx`:
1. Add `HtmlStylePanel` to the import list from `'./styles'`.
2. Change the utility classifier (line 79) so HTML no longer matches it, and add an HTML classifier:
```tsx
const isHtml = /^html$/i.test(typeName);
// Utility types that need minimal controls. HTML is deliberately NOT here
// -- it gets HtmlStylePanel, which shows the code editor and nothing else.
const isUtility = /^divider$|^spacer$/i.test(typeName);
```
3. Give it an icon in the badge chain, before `isUtility`:
```tsx
: isHtml ? 'fa-code'
: isUtility ? 'fa-ellipsis-h'
```
4. Render it, immediately above the UTILITY block:
```tsx
{/* HTML -- code editor only */}
{isHtml && <HtmlStylePanel selectedId={selected} nodeProps={nodeProps} />}
```
5. Add `&& !isHtml` to the FALLBACK condition so an HTML block never renders two panels:
```tsx
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
)}
```
- [ ] **Step 5: Run tests and typecheck**
Run: `npx vitest run src/panels/right/styles/HtmlStylePanel.test.tsx && npx tsc --noEmit`
Expected: PASS, no type errors.
- [ ] **Step 6: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/right/styles/HtmlStylePanel.tsx craft/src/panels/right/styles/HtmlStylePanel.test.tsx craft/src/panels/right/styles/index.ts craft/src/panels/right/GuidedStyles.tsx
git commit -m "feat(site-builder): HTML blocks get a code-only style panel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: `formatHtml` — the indent-only prettifier
**Files:**
- Create: `src/utils/format-html.ts`
- Create: `src/utils/format-html.test.ts`
**Interfaces:**
- Produces: `export function formatHtml(src: string): string` — re-indents block-level tags onto their own lines with two-space nesting; leaves inline tags and `<pre>` contents untouched; idempotent.
- [ ] **Step 1: Write the failing tests**
Create `src/utils/format-html.test.ts`:
```ts
import { describe, test, expect } from 'vitest';
import { formatHtml } from './format-html';
describe('formatHtml', () => {
test('indents nested block elements two spaces per level', () => {
expect(formatHtml('<div><section><p>hi</p></section></div>')).toBe(
'<div>\n <section>\n <p>hi</p>\n </section>\n</div>',
);
});
test('leaves inline tags on the same line as their text', () => {
expect(formatHtml('<p>hello <strong>world</strong> now</p>')).toBe(
'<p>hello <strong>world</strong> now</p>',
);
});
test('void elements do not open an indent level', () => {
expect(formatHtml('<div><img src="a.png"><br><p>x</p></div>')).toBe(
'<div>\n <img src="a.png">\n <br>\n <p>x</p>\n</div>',
);
});
test('preserves <pre> contents verbatim', () => {
const src = '<div><pre> keep\n this</pre></div>';
expect(formatHtml(src)).toBe('<div>\n <pre> keep\n this</pre>\n</div>');
});
test('is idempotent', () => {
const once = formatHtml('<div><section><p>hi</p></section></div>');
expect(formatHtml(once)).toBe(once);
});
test('empty and whitespace-only input round-trip to an empty string', () => {
expect(formatHtml('')).toBe('');
expect(formatHtml(' \n ')).toBe('');
});
test('unbalanced markup never produces negative indent', () => {
expect(formatHtml('</div><p>x</p>')).toBe('</div>\n<p>x</p>');
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/utils/format-html.test.ts`
Expected: FAIL — `Cannot find module './format-html'`.
- [ ] **Step 3: Implement it**
Create `src/utils/format-html.ts`:
```ts
/**
* Indent-only HTML prettifier for the Edit HTML modal's Format button.
*
* Deliberately small and dependency-free: it re-indents BLOCK-level tags onto
* their own lines and nests them two spaces per level. It does not reflow
* text, reorder attributes, or normalise quoting -- a formatter that rewrites
* user markup is a formatter people stop trusting.
*
* Inline tags (<strong>, <a>, <span>, ...) are left exactly where they sit,
* and <pre> contents are copied through verbatim.
*/
const BLOCK_TAGS = new Set([
'html', 'head', 'body', 'div', 'section', 'article', 'aside', 'header', 'footer',
'main', 'nav', 'form', 'fieldset', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'figure', 'figcaption', 'pre', 'hr', 'br', 'img', 'iframe', 'video',
'audio', 'source', 'canvas', 'script', 'style', 'select', 'option', 'textarea',
]);
const VOID_TAGS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
]);
const INDENT = ' ';
interface Token {
/** Raw text of the token, already trimmed of surrounding whitespace. */
text: string;
/** Lowercased tag name, or '' for a text run. */
tag: string;
kind: 'open' | 'close' | 'void' | 'text' | 'verbatim';
}
/** Split source into tags and text runs, treating <pre>...</pre> as one atom. */
function tokenize(src: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < src.length) {
const lt = src.indexOf('<', i);
if (lt === -1) {
const text = src.slice(i).trim();
if (text) tokens.push({ text, tag: '', kind: 'text' });
break;
}
if (lt > i) {
const text = src.slice(i, lt).trim();
if (text) tokens.push({ text, tag: '', kind: 'text' });
}
const gt = src.indexOf('>', lt);
if (gt === -1) {
// Unterminated '<' -- emit the remainder as text rather than looping.
const text = src.slice(lt).trim();
if (text) tokens.push({ text, tag: '', kind: 'text' });
break;
}
const raw = src.slice(lt, gt + 1);
const nameMatch = /^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw);
const tag = nameMatch ? nameMatch[1].toLowerCase() : '';
// <pre> swallows everything up to its closing tag, untouched.
if (tag === 'pre' && !raw.startsWith('</')) {
const closeIdx = src.toLowerCase().indexOf('</pre>', gt);
if (closeIdx !== -1) {
const end = closeIdx + '</pre>'.length;
tokens.push({ text: src.slice(lt, end), tag: 'pre', kind: 'verbatim' });
i = end;
continue;
}
}
const isClose = raw.startsWith('</');
const selfClosing = /\/>\s*$/.test(raw);
const kind: Token['kind'] = isClose
? 'close'
: selfClosing || VOID_TAGS.has(tag)
? 'void'
: 'open';
tokens.push({ text: raw, tag, kind });
i = gt + 1;
}
return tokens;
}
export function formatHtml(src: string): string {
if (!src || !src.trim()) return '';
const tokens = tokenize(src);
const lines: string[] = [];
let depth = 0;
/** Buffer for content that must stay on one line (inline runs). */
let inline = '';
const flushInline = (): void => {
if (!inline) return;
lines.push(INDENT.repeat(depth) + inline);
inline = '';
};
for (const token of tokens) {
const isBlock = token.tag !== '' && BLOCK_TAGS.has(token.tag);
if (!isBlock) {
// Inline tag or text -- accumulate onto the current line.
inline += token.text;
continue;
}
if (token.kind === 'close') {
// A block close directly after inline content belongs on that line.
if (inline) {
lines.push(INDENT.repeat(depth) + inline + token.text);
inline = '';
} else {
depth = Math.max(0, depth - 1);
lines.push(INDENT.repeat(depth) + token.text);
}
continue;
}
flushInline();
if (token.kind === 'verbatim' || token.kind === 'void') {
lines.push(INDENT.repeat(depth) + token.text);
continue;
}
// kind === 'open'
lines.push(INDENT.repeat(depth) + token.text);
depth += 1;
}
flushInline();
return lines.join('\n');
}
```
- [ ] **Step 4: Run and verify pass**
Run: `npx vitest run src/utils/format-html.test.ts`
Expected: PASS, 7 tests.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/utils/format-html.ts craft/src/utils/format-html.test.ts
git commit -m "feat(site-builder): add dependency-free formatHtml prettifier
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: `CodeEditor` gains an imperative handle
The toolbar needs to insert text at the caret. `CodeEditor` currently owns its CodeMirror view privately and can fall back to a `<textarea>`, so the handle must work in both modes.
**Files:**
- Modify: `src/ui/CodeEditor.tsx` (whole component — add `forwardRef`)
- Test: `src/ui/CodeEditor.test.tsx` (extend)
**Interfaces:**
- Produces:
```ts
export interface CodeEditorHandle {
/** Replace the current selection (or insert at the caret) with `text`.
* Emits onChange. If `caretOffset` is given, the caret lands that many
* characters after the insertion start instead of at its end. */
insertAtCursor(text: string, caretOffset?: number): void;
/** Current document text. */
getValue(): string;
}
```
`CodeEditor` becomes `React.forwardRef<CodeEditorHandle, CodeEditorProps>`.
- [ ] **Step 1: Write the failing test**
Append to `src/ui/CodeEditor.test.tsx`:
```tsx
describe('CodeEditor imperative handle (textarea fallback mode)', () => {
test('insertAtCursor replaces the selection and emits onChange', async () => {
const onChange = vi.fn();
const ref = React.createRef<CodeEditorHandle>();
render(<CodeEditor ref={ref} value="<div></div>" onChange={onChange} />);
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
expect(ta).not.toBeNull();
ta.selectionStart = 5;
ta.selectionEnd = 5;
act(() => {
ref.current!.insertAtCursor('<p></p>');
});
expect(onChange).toHaveBeenCalledWith('<div><p></p></div>');
});
test('getValue returns the current document', () => {
const ref = React.createRef<CodeEditorHandle>();
render(<CodeEditor ref={ref} value="<span>x</span>" onChange={vi.fn()} />);
expect(ref.current!.getValue()).toBe('<span>x</span>');
});
test('caretOffset positions the caret inside the inserted snippet', () => {
const ref = React.createRef<CodeEditorHandle>();
render(<CodeEditor ref={ref} value="" onChange={vi.fn()} />);
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
act(() => {
ref.current!.insertAtCursor('<p></p>', 3);
});
expect(ta.selectionStart).toBe(3);
});
});
```
Add `CodeEditorHandle` to the file's import from `./CodeEditor`.
Note: in jsdom the dynamic `import('@codemirror/...')` chain resolves but the view does not mount cleanly, so `status` stays non-`ready` and the fallback textarea is what renders. That is exactly the mode these tests target; CodeMirror mode is covered by the manual canary check in Task 21.
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/ui/CodeEditor.test.tsx`
Expected: FAIL — `CodeEditorHandle` is not exported and `ref` is not accepted.
- [ ] **Step 3: Convert the component to `forwardRef`**
In `src/ui/CodeEditor.tsx`:
1. Extend the React import: `import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';`
2. Add the handle interface next to `CodeEditorProps`:
```ts
export interface CodeEditorHandle {
insertAtCursor(text: string, caretOffset?: number): void;
getValue(): string;
}
```
3. Add a textarea ref alongside the existing refs:
```ts
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
```
4. Change the declaration line from `export const CodeEditor: React.FC<CodeEditorProps> = ({` to:
```tsx
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor({
```
and change the closing `};` of the component to `});`, with the props destructuring keeping its existing defaults and gaining the second `ref` parameter:
```tsx
}: CodeEditorProps, ref) {
```
5. Register the handle just before the `showFallback` line:
```tsx
useImperativeHandle(ref, (): CodeEditorHandle => ({
getValue: () => {
const view = viewRef.current;
if (view) return view.state.doc.toString();
return textareaRef.current?.value ?? value;
},
insertAtCursor: (text: string, caretOffset?: number) => {
const view = viewRef.current;
if (view) {
const { from, to } = view.state.selection.main;
const caret = from + (caretOffset ?? text.length);
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret },
});
view.focus();
return;
}
// Textarea fallback: CodeMirror never mounted (still loading, or its
// lazy chunks failed). The toolbar must keep working either way.
const ta = textareaRef.current;
if (!ta) return;
const from = ta.selectionStart ?? ta.value.length;
const to = ta.selectionEnd ?? from;
const next = ta.value.slice(0, from) + text + ta.value.slice(to);
const caret = from + (caretOffset ?? text.length);
lastEmittedRef.current = next;
onChangeRef.current(next);
ta.value = next;
ta.selectionStart = caret;
ta.selectionEnd = caret;
ta.focus();
},
}), [value]);
```
6. Attach `ref={textareaRef}` to the fallback `<textarea>`.
- [ ] **Step 4: Run tests and typecheck**
Run: `npx vitest run src/ui/CodeEditor.test.tsx && npx tsc --noEmit`
Expected: PASS. Existing `CodeEditor` tests and `HeadCodeModal.test.tsx` must still pass — `forwardRef` is backwards compatible for callers that pass no ref.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/ui/CodeEditor.tsx craft/src/ui/CodeEditor.test.tsx
git commit -m "feat(site-builder): expose insertAtCursor/getValue handle on CodeEditor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Edit HTML toolbar
**Files:**
- Create: `src/panels/right/styles/HtmlToolbar.tsx`
- Create: `src/panels/right/styles/HtmlToolbar.test.tsx`
- Modify: `src/panels/right/styles/HtmlCodeField.tsx`
**Interfaces:**
- Consumes: `CodeEditorHandle` (Task 5), `formatHtml` (Task 4).
- Produces: `export const HtmlToolbar: React.FC<{ editorRef: React.RefObject<CodeEditorHandle>; onFormat: () => void }>` and `export const SNIPPETS: { label: string; icon: string; title: string; text: string; caret: number }[]`.
- [ ] **Step 1: Write the failing test**
Create `src/panels/right/styles/HtmlToolbar.test.tsx`:
```tsx
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { HtmlToolbar, SNIPPETS } from './HtmlToolbar';
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function fakeHandle() {
return { insertAtCursor: vi.fn(), getValue: vi.fn(() => '') } as unknown as CodeEditorHandle;
}
describe('HtmlToolbar', () => {
test('every snippet button inserts its snippet with its caret offset', () => {
const handle = fakeHandle();
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
for (const snippet of SNIPPETS) {
const btn = container.querySelector(`[data-snippet="${snippet.label}"]`) as HTMLButtonElement;
expect(btn, `missing button for ${snippet.label}`).not.toBeNull();
act(() => { btn.click(); });
expect(handle.insertAtCursor).toHaveBeenCalledWith(snippet.text, snippet.caret);
}
});
test('the colour input inserts a style attribute at the caret', () => {
const handle = fakeHandle();
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
const colour = container.querySelector('input[type="color"]') as HTMLInputElement;
colour.value = '#ff8800';
act(() => { colour.dispatchEvent(new Event('input', { bubbles: true })); });
expect(handle.insertAtCursor).toHaveBeenCalledWith(' style="color: #ff8800"');
});
test('Format calls onFormat', () => {
const onFormat = vi.fn();
const ref = { current: fakeHandle() } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={onFormat} />);
const btn = container.querySelector('[data-action="format"]') as HTMLButtonElement;
act(() => { btn.click(); });
expect(onFormat).toHaveBeenCalledTimes(1);
});
test('a null editor ref is a no-op, not a crash', () => {
const ref = { current: null } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
const btn = container.querySelector('[data-snippet="div"]') as HTMLButtonElement;
expect(() => act(() => { btn.click(); })).not.toThrow();
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/panels/right/styles/HtmlToolbar.test.tsx`
Expected: FAIL — `Cannot find module './HtmlToolbar'`.
- [ ] **Step 3: Implement the toolbar**
Create `src/panels/right/styles/HtmlToolbar.tsx`:
```tsx
import React from 'react';
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
/**
* Snippet buttons for the Edit HTML modal. `caret` is the offset from the
* start of the inserted text where the caret should land -- i.e. between the
* open and close tags, so the next keystroke types content rather than
* landing after the closing tag.
*/
export const SNIPPETS: { label: string; icon: string; title: string; text: string; caret: number }[] = [
{ label: 'div', icon: 'fa-square-o', title: 'Insert a div', text: '<div></div>', caret: 5 },
{ label: 'section', icon: 'fa-window-maximize', title: 'Insert a section', text: '<section></section>', caret: 9 },
{ label: 'h2', icon: 'fa-header', title: 'Insert a heading', text: '<h2></h2>', caret: 4 },
{ label: 'p', icon: 'fa-paragraph', title: 'Insert a paragraph', text: '<p></p>', caret: 3 },
{ label: 'a', icon: 'fa-link', title: 'Insert a link', text: '<a href="#"></a>', caret: 12 },
{ label: 'ul', icon: 'fa-list-ul', title: 'Insert a list', text: '<ul>\n <li></li>\n</ul>', caret: 11 },
{ label: 'img', icon: 'fa-image', title: 'Insert an image', text: '<img src="" alt="">', caret: 10 },
];
const btnStyle: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 28, height: 28, padding: '0 7px',
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 5,
fontSize: 11, cursor: 'pointer',
};
export const HtmlToolbar: React.FC<{
editorRef: React.RefObject<CodeEditorHandle>;
onFormat: () => void;
}> = ({ editorRef, onFormat }) => {
const insert = (text: string, caret?: number): void => {
// The ref is null until CodeEditor mounts; clicking early must no-op.
editorRef.current?.insertAtCursor(text, caret);
};
return (
<div
role="toolbar"
aria-label="HTML editing tools"
style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10, alignItems: 'center' }}
>
{SNIPPETS.map((s) => (
<button
key={s.label}
type="button"
data-snippet={s.label}
title={s.title}
aria-label={s.title}
style={btnStyle}
onClick={() => insert(s.text, s.caret)}
>
<i className={`fa ${s.icon}`} aria-hidden="true" />
</button>
))}
<span style={{ width: 1, height: 20, background: '#3f3f46', margin: '0 3px' }} aria-hidden="true" />
<label
title="Insert a colour style attribute at the cursor"
style={{ ...btnStyle, padding: 0, overflow: 'hidden', position: 'relative' }}
>
<input
type="color"
aria-label="Insert colour"
defaultValue="#3b82f6"
onInput={(e) => insert(` style="color: ${(e.target as HTMLInputElement).value}"`)}
style={{ width: 40, height: 34, border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
/>
</label>
<button
type="button"
data-action="format"
title="Re-indent the markup"
style={{ ...btnStyle, marginLeft: 'auto', fontWeight: 600, gap: 5 }}
onClick={onFormat}
>
<i className="fa fa-indent" aria-hidden="true" /> Format
</button>
</div>
);
};
```
Note the colour handler passes **one** argument, so the caret lands after the inserted attribute — matching the test's `toHaveBeenCalledWith(' style="color: #ff8800"')`.
- [ ] **Step 4: Wire it into the modal**
In `src/panels/right/styles/HtmlCodeField.tsx`:
1. Extend imports:
```tsx
import React, { useRef, useState } from 'react';
import { CodeEditor, type CodeEditorHandle } from '../../../ui/CodeEditor';
import { HtmlToolbar } from './HtmlToolbar';
import { formatHtml } from '../../../utils/format-html';
```
2. Inside the component, above the `return`:
```tsx
const editorRef = useRef<CodeEditorHandle>(null);
const handleFormat = (): void => {
const current = editorRef.current?.getValue() ?? value;
onChange(formatHtml(current));
};
```
3. Replace the modal body `<div style={{ padding: 16 }}>…</div>` with:
```tsx
<div style={{ padding: 16 }}>
<HtmlToolbar editorRef={editorRef} onFormat={handleFormat} />
<CodeEditor ref={editorRef} value={value} onChange={onChange} language="html" height={420} />
</div>
```
`CodeEditor`'s existing value-sync effect picks up the reformatted string because `formatHtml` output differs from `lastEmittedRef.current`.
- [ ] **Step 5: Run the tests and typecheck**
Run: `npx vitest run src/panels/right/styles/ src/ui/ && npx tsc --noEmit`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/right/styles/HtmlToolbar.tsx craft/src/panels/right/styles/HtmlToolbar.test.tsx craft/src/panels/right/styles/HtmlCodeField.tsx
git commit -m "feat(site-builder): add insert/colour/format toolbar to the Edit HTML modal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
## Phase 2 — Tree integrity and the Layers tab (spec items 1 and 4)
### Task 7: `orphan-repair` — pure detection and reattachment
**Files:**
- Create: `src/utils/orphan-repair.ts`
- Create: `src/utils/orphan-repair.test.ts`
**Interfaces:**
- Produces:
```ts
/** Ids of nodes present in `nodes` whose parent chain does not reach 'ROOT'. */
export function findUnreachableNodeIds(nodes: Record<string, any>): string[];
/** Reattach every unreachable node to the end of ROOT.nodes. Returns the
* possibly-rewritten serialized state and the ids that were moved. Never
* throws: malformed input comes back unchanged with an empty `repaired`. */
export function repairOrphanNodes(serialized: string): { state: string; repaired: string[] };
```
- [ ] **Step 1: Write the failing tests**
Create `src/utils/orphan-repair.test.ts`:
```ts
import { describe, test, expect } from 'vitest';
import { findUnreachableNodeIds, repairOrphanNodes } from './orphan-repair';
/** Minimal Craft-shaped node. */
function node(over: Record<string, any> = {}) {
return {
type: { resolvedName: 'Container' },
isCanvas: false,
props: {},
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
parent: 'ROOT',
...over,
};
}
const healthy = JSON.stringify({
ROOT: node({ isCanvas: true, parent: null, nodes: ['a'] }),
a: node({ parent: 'ROOT' }),
});
describe('findUnreachableNodeIds', () => {
test('a healthy tree has no unreachable nodes', () => {
expect(findUnreachableNodeIds(JSON.parse(healthy))).toEqual([]);
});
test('a node ROOT does not list is unreachable even when its parent says ROOT', () => {
const nodes = JSON.parse(healthy);
nodes.stray = node({ parent: 'ROOT', displayName: 'HTML' });
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
});
test('a node whose parent no longer exists is unreachable', () => {
const nodes = JSON.parse(healthy);
nodes.stray = node({ parent: 'ghost' });
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
});
test('children of an unreachable node are also unreachable', () => {
const nodes = JSON.parse(healthy);
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
nodes.strayChild = node({ parent: 'stray' });
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['stray', 'strayChild']);
});
test('linkedNodes children count as reachable', () => {
const nodes = JSON.parse(healthy);
nodes.ROOT.linkedNodes = { inner: 'linked' };
nodes.linked = node({ parent: 'ROOT' });
expect(findUnreachableNodeIds(nodes)).toEqual([]);
});
test('a cycle among orphans terminates instead of hanging', () => {
const nodes = JSON.parse(healthy);
nodes.x = node({ parent: 'y', nodes: ['y'] });
nodes.y = node({ parent: 'x', nodes: ['x'] });
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['x', 'y']);
});
});
describe('repairOrphanNodes', () => {
test('a healthy tree is returned byte-identical with nothing repaired', () => {
const out = repairOrphanNodes(healthy);
expect(out.repaired).toEqual([]);
expect(out.state).toBe(healthy);
});
test('an orphan is appended to the end of ROOT.nodes and reparented', () => {
const nodes = JSON.parse(healthy);
nodes.stray = node({ parent: 'ghost', displayName: 'HTML' });
const out = repairOrphanNodes(JSON.stringify(nodes));
expect(out.repaired).toEqual(['stray']);
const parsed = JSON.parse(out.state);
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
expect(parsed.stray.parent).toBe('ROOT');
});
test('only the top of an orphan subtree is reattached; its children ride along', () => {
const nodes = JSON.parse(healthy);
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
nodes.strayChild = node({ parent: 'stray' });
const out = repairOrphanNodes(JSON.stringify(nodes));
expect(out.repaired).toEqual(['stray']);
const parsed = JSON.parse(out.state);
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
expect(parsed.strayChild.parent).toBe('stray');
});
test('malformed JSON comes back unchanged rather than throwing', () => {
const out = repairOrphanNodes('{not json');
expect(out.state).toBe('{not json');
expect(out.repaired).toEqual([]);
});
test('state with no ROOT comes back unchanged', () => {
const orphanOnly = JSON.stringify({ a: node({ parent: null }) });
const out = repairOrphanNodes(orphanOnly);
expect(out.state).toBe(orphanOnly);
expect(out.repaired).toEqual([]);
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/utils/orphan-repair.test.ts`
Expected: FAIL — `Cannot find module './orphan-repair'`.
- [ ] **Step 3: Implement it**
Create `src/utils/orphan-repair.ts`:
```ts
/**
* Editor-state integrity: every node must be reachable from ROOT.
*
* A node that exists in `SerializedNodes` but appears in no parent's `nodes`
* or `linkedNodes` list is invisible to the Layers tree AND to Craft's own
* selection machinery -- it renders somewhere on the canvas but can't be
* selected or deleted, which is exactly the "dropped outside the page"
* report. Reachability is computed from the PARENT'S child lists, not from
* each node's own `parent` pointer: a stale `parent: 'ROOT'` on a node ROOT
* never lists is precisely the broken case we're looking for.
*
* Pure functions over serialized state -- no React, no Craft instance.
*/
const ROOT_ID = 'ROOT';
function childIdsOf(node: any): string[] {
const nodes: string[] = Array.isArray(node?.nodes) ? node.nodes : [];
const linked: string[] = node?.linkedNodes ? Object.values(node.linkedNodes) : [];
return [...nodes, ...linked];
}
export function findUnreachableNodeIds(nodes: Record<string, any>): string[] {
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) return [];
const reachable = new Set<string>([ROOT_ID]);
const queue: string[] = [ROOT_ID];
while (queue.length > 0) {
const id = queue.shift()!;
for (const childId of childIdsOf(nodes[id])) {
// The `reachable` guard also terminates on a cycle among real nodes.
if (typeof childId === 'string' && nodes[childId] && !reachable.has(childId)) {
reachable.add(childId);
queue.push(childId);
}
}
}
return Object.keys(nodes).filter((id) => !reachable.has(id));
}
export function repairOrphanNodes(serialized: string): { state: string; repaired: string[] } {
let nodes: Record<string, any>;
try {
nodes = JSON.parse(serialized);
} catch {
// A page must never fail to load because the repair pass couldn't parse
// it -- hand the original string straight back to deserialize().
return { state: serialized, repaired: [] };
}
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) {
return { state: serialized, repaired: [] };
}
const unreachable = findUnreachableNodeIds(nodes);
if (unreachable.length === 0) return { state: serialized, repaired: [] };
// Reattach only the TOP of each orphan subtree. An orphan whose parent is
// itself an orphan keeps its existing parent and rides along.
const orphanSet = new Set(unreachable);
const tops = unreachable.filter((id) => {
const parent = nodes[id]?.parent;
return !(typeof parent === 'string' && orphanSet.has(parent));
});
if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = [];
for (const id of tops) {
nodes[id].parent = ROOT_ID;
nodes[ROOT_ID].nodes.push(id);
}
return { state: JSON.stringify(nodes), repaired: tops };
}
```
- [ ] **Step 4: Run and verify pass**
Run: `npx vitest run src/utils/orphan-repair.test.ts`
Expected: PASS, 12 tests.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/utils/orphan-repair.ts craft/src/utils/orphan-repair.test.ts
git commit -m "feat(site-builder): add pure orphan-node detection and repair
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 8: Run the repair on every page load
**Files:**
- Modify: `src/state/PageContext.tsx:84-85` (export `EMPTY_CANVAS`) and `:372-388` (`loadState`)
- Test: `src/state/PageContext.orphan-repair.test.ts` (create)
**Interfaces:**
- Consumes: `repairOrphanNodes` (Task 7).
- Produces: `export const EMPTY_CANVAS` from `PageContext.tsx` — consumed by Tasks 12 and 13.
- [ ] **Step 1: Write the failing integration test**
Create `src/state/PageContext.orphan-repair.test.ts`:
```ts
import { describe, test, expect } from 'vitest';
import { renderEditorHarness } from '../test-utils/editorHarness';
import { repairOrphanNodes } from '../utils/orphan-repair';
import { EMPTY_CANVAS } from './PageContext';
describe('EMPTY_CANVAS is exported and loadable', () => {
test('deserializing EMPTY_CANVAS gives a ROOT with no children', () => {
const harness = renderEditorHarness({ initialState: EMPTY_CANVAS });
const nodes = JSON.parse(harness.getSerialized());
expect(nodes.ROOT.nodes).toEqual([]);
harness.unmount();
});
});
describe('repaired state survives a real Craft deserialize', () => {
test('an orphaned HTML block becomes a selectable child of ROOT', () => {
const broken = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: {}, tag: 'div' },
displayName: 'Container',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
},
stray: {
type: { resolvedName: 'HtmlBlock' },
isCanvas: false,
props: { code: '<p>stranded</p>', style: {} },
displayName: 'HTML',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
},
});
const { state, repaired } = repairOrphanNodes(broken);
expect(repaired).toEqual(['stray']);
const harness = renderEditorHarness({ initialState: state });
const nodes = JSON.parse(harness.getSerialized());
expect(nodes.ROOT.nodes).toContain('stray');
expect(harness.container.textContent).toContain('stranded');
harness.unmount();
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/state/PageContext.orphan-repair.test.ts`
Expected: FAIL — `EMPTY_CANVAS` is not exported from `./PageContext`.
- [ ] **Step 3: Export `EMPTY_CANVAS` and repair inside `loadState`**
In `src/state/PageContext.tsx`:
1. Change `const EMPTY_CANVAS =` (line 84) to `export const EMPTY_CANVAS =`.
2. Add the import at the top: `import { repairOrphanNodes } from '../utils/orphan-repair';`
3. Replace the `loadState` body:
```tsx
/** Load a craft state into the Frame.
*
* Every state goes through `repairOrphanNodes` first: a node that no
* parent lists is invisible to Layers and unselectable on the canvas, so
* it can neither be moved nor deleted. Reattaching it to the end of ROOT
* makes it an ordinary child the user can select and delete. Cheap
* (single JSON round-trip) and a no-op -- returning the identical string
* -- for the overwhelmingly common healthy case. */
const loadState = useCallback(
(craftState: string | null, fallback: string) => {
setTimeout(() => {
const source = craftState || fallback;
const { state, repaired } = repairOrphanNodes(source);
if (repaired.length > 0) {
console.warn(
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
repaired.join(', '),
);
}
try {
actions.deserialize(state);
} catch (e) {
console.error('Failed to deserialize state:', e);
try {
actions.deserialize(fallback);
} catch (_e2) {
// give up
}
}
}, 0);
},
[actions],
);
```
- [ ] **Step 4: Run tests and typecheck**
Run: `npx vitest run src/state/ && npx tsc --noEmit`
Expected: PASS — including the pre-existing `PageContext.*.test.tsx` files.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/state/PageContext.tsx craft/src/state/PageContext.orphan-repair.test.ts
git commit -m "fix(site-builder): reattach unreachable nodes when loading a page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 9: `layers-virtual-rows` — derive child rows from array props
Composite blocks hold their content in array props, not Craft child nodes, so the Layers tree shows them as leaves. This module turns those arrays into displayable rows.
**Files:**
- Create: `src/panels/left/layers-virtual-rows.ts`
- Create: `src/panels/left/layers-virtual-rows.test.ts`
**Interfaces:**
- Produces:
```ts
export interface VirtualRow { index: number; label: string }
export interface VirtualChildSpec { prop: string; label: string; fallback: string }
export const VIRTUAL_CHILD_PROPS: Record<string, VirtualChildSpec>;
export function deriveVirtualRows(displayName: string, props: Record<string, any>): VirtualRow[];
```
- [ ] **Step 1: Verify the registry against the real components**
Before writing code, confirm each prop name and label field against the component's `craft.props` defaults:
```bash
cd /workspace/site-builder/craft/src/components
for f in sections/FeaturesGrid.tsx sections/Tabs.tsx sections/Accordion.tsx \
sections/PricingTable.tsx sections/Testimonials.tsx sections/Gallery.tsx \
sections/ContentSlider.tsx sections/NumberCounter.tsx \
basic/Menu.tsx basic/SocialLinks.tsx basic/Navbar.tsx forms/ContactForm.tsx; do
echo "=== $f ==="
sed -n '/craft = {/,/^};/p' "$f" | sed -n '/props: {/,/^ },/p' | head -30
done
```
Record the actual array prop name and the field that best names an item. **If a name differs from the table in Step 3, the component wins — update the registry, not the component.**
- [ ] **Step 2: Write the failing tests**
Create `src/panels/left/layers-virtual-rows.test.ts`:
```ts
import { describe, test, expect } from 'vitest';
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
describe('deriveVirtualRows', () => {
test('returns one row per item, labelled by the registered field', () => {
const rows = deriveVirtualRows('Features Grid', {
features: [{ title: 'Fast' }, { title: 'Secure' }],
});
expect(rows).toEqual([
{ index: 0, label: 'Fast' },
{ index: 1, label: 'Secure' },
]);
});
test('falls back to "<Fallback> N" when the label field is missing or blank', () => {
const rows = deriveVirtualRows('Features Grid', {
features: [{ title: '' }, { description: 'no title key' }],
});
expect(rows).toEqual([
{ index: 0, label: 'Feature 1' },
{ index: 1, label: 'Feature 2' },
]);
});
test('trims and truncates a long label to 40 characters with an ellipsis', () => {
const long = 'x'.repeat(60);
const rows = deriveVirtualRows('Features Grid', { features: [{ title: ` ${long} ` }] });
expect(rows[0].label).toHaveLength(41);
expect(rows[0].label.endsWith('…')).toBe(true);
});
test('an unregistered component yields no rows', () => {
expect(deriveVirtualRows('Heading', { text: 'hi' })).toEqual([]);
});
test('a missing or non-array prop yields no rows instead of throwing', () => {
expect(deriveVirtualRows('Tabs', {})).toEqual([]);
expect(deriveVirtualRows('Tabs', { tabs: 'not an array' })).toEqual([]);
expect(deriveVirtualRows('Tabs', { tabs: null })).toEqual([]);
});
test('a non-object item still gets a fallback label', () => {
expect(deriveVirtualRows('Menu', { links: ['raw string'] })).toEqual([
{ index: 0, label: 'Link 1' },
]);
});
test('every registry entry has a non-empty prop, label and fallback', () => {
for (const [name, spec] of Object.entries(VIRTUAL_CHILD_PROPS)) {
expect(spec.prop, `${name}.prop`).toBeTruthy();
expect(spec.label, `${name}.label`).toBeTruthy();
expect(spec.fallback, `${name}.fallback`).toBeTruthy();
}
});
});
```
- [ ] **Step 3: Implement it**
Create `src/panels/left/layers-virtual-rows.ts`:
```ts
/**
* Virtual Layers rows for components whose content lives in ARRAY PROPS
* rather than Craft child nodes.
*
* FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery,
* ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm all
* render their items from a prop array, so Craft sees them as leaf nodes and
* the Layers tree showed nothing underneath them -- the "Layers doesn't show
* everything" report. These rows are display-only: they are not Craft nodes,
* cannot be dragged, and selecting one selects the PARENT node (plus asks the
* array editor to scroll that item into view -- see LayerFocusContext).
*
* ColumnLayout is deliberately absent: it uses real `<Element canvas>`
* children, which LayersPanel already nests correctly.
*/
export interface VirtualRow {
index: number;
label: string;
}
export interface VirtualChildSpec {
/** Name of the array prop holding the items. */
prop: string;
/** Per-item field used as the row label. */
label: string;
/** Used as "<fallback> <n>" when the label field is missing or blank. */
fallback: string;
}
/** Keyed by the component's craft `displayName` -- the same string
* `LayerNode` already resolves and shows as the row text. */
export const VIRTUAL_CHILD_PROPS: Record<string, VirtualChildSpec> = {
'Features Grid': { prop: 'features', label: 'title', fallback: 'Feature' },
Tabs: { prop: 'tabs', label: 'title', fallback: 'Tab' },
Accordion: { prop: 'items', label: 'title', fallback: 'Item' },
'Pricing Table': { prop: 'plans', label: 'name', fallback: 'Plan' },
Testimonials: { prop: 'testimonials', label: 'name', fallback: 'Testimonial' },
Gallery: { prop: 'images', label: 'alt', fallback: 'Image' },
'Content Slider': { prop: 'slides', label: 'title', fallback: 'Slide' },
'Number Counter': { prop: 'counters', label: 'label', fallback: 'Counter' },
Menu: { prop: 'links', label: 'text', fallback: 'Link' },
'Social Links': { prop: 'links', label: 'platform', fallback: 'Link' },
Navbar: { prop: 'links', label: 'text', fallback: 'Link' },
'Contact Form': { prop: 'fields', label: 'label', fallback: 'Field' },
};
const MAX_LABEL = 40;
export function deriveVirtualRows(displayName: string, props: Record<string, any>): VirtualRow[] {
const spec = VIRTUAL_CHILD_PROPS[displayName];
if (!spec) return [];
const items = props?.[spec.prop];
if (!Array.isArray(items)) return [];
return items.map((item, index) => {
const raw = item && typeof item === 'object' ? item[spec.label] : undefined;
const text = typeof raw === 'string' ? raw.trim() : '';
if (!text) return { index, label: `${spec.fallback} ${index + 1}` };
const label = text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL)}…` : text;
return { index, label };
});
}
```
- [ ] **Step 4: Run and verify pass**
Run: `npx vitest run src/panels/left/layers-virtual-rows.test.ts`
Expected: PASS, 7 tests.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/left/layers-virtual-rows.ts craft/src/panels/left/layers-virtual-rows.test.ts
git commit -m "feat(site-builder): derive Layers rows from array-prop composites
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 10: `LayerFocusContext` — "scroll array item N into view"
**Files:**
- Create: `src/panels/left/LayerFocusContext.tsx`
- Create: `src/panels/left/LayerFocusContext.test.tsx`
- Modify: `src/editor/EditorShell.tsx` (wrap the panels in the provider)
**Interfaces:**
- Produces:
```ts
export interface LayerFocusRequest { nodeId: string; prop: string; index: number; nonce: number }
export interface LayerFocusValue {
focus: LayerFocusRequest | null;
requestFocus(nodeId: string, prop: string, index: number): void;
}
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }>;
export function useLayerFocus(): LayerFocusValue;
```
`nonce` increments on every request so re-clicking the *same* row re-triggers consumers.
- [ ] **Step 1: Write the failing test**
Create `src/panels/left/LayerFocusContext.test.tsx`:
```tsx
import { describe, test, expect } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { LayerFocusProvider, useLayerFocus } from './LayerFocusContext';
let container: HTMLDivElement;
let root: Root;
const Probe: React.FC = () => {
const { focus, requestFocus } = useLayerFocus();
return (
<div>
<button onClick={() => requestFocus('n1', 'features', 2)}>request</button>
<span data-testid="state">{focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'}</span>
</div>
);
};
function render() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<LayerFocusProvider><Probe /></LayerFocusProvider>);
});
}
describe('LayerFocusContext', () => {
test('starts with no focus request', () => {
render();
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('none');
});
test('requestFocus publishes the target', () => {
render();
act(() => { (container.querySelector('button') as HTMLButtonElement).click(); });
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:1');
});
test('repeating the same request bumps the nonce so consumers re-fire', () => {
render();
const btn = container.querySelector('button') as HTMLButtonElement;
act(() => { btn.click(); });
act(() => { btn.click(); });
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:2');
});
test('useLayerFocus outside a provider is inert rather than a crash', () => {
container = document.createElement('div');
document.body.appendChild(container);
expect(() => {
act(() => {
root = createRoot(container);
root.render(<Probe />);
});
}).not.toThrow();
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/panels/left/LayerFocusContext.test.tsx`
Expected: FAIL — `Cannot find module './LayerFocusContext'`.
- [ ] **Step 3: Implement it**
Create `src/panels/left/LayerFocusContext.tsx`:
```tsx
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
/**
* Carries "the user clicked a virtual Layers row -- scroll that array item's
* card into view" from the Layers panel (left) to the array editors (right).
*
* Deliberately a request, not a command: consumers that don't implement
* scrolling simply ignore it. Selecting the parent node always happens in
* LayersPanel itself, so a click is useful even with no consumer at all.
*/
export interface LayerFocusRequest {
nodeId: string;
prop: string;
index: number;
/** Bumped on every request so an identical repeat still re-fires effects. */
nonce: number;
}
export interface LayerFocusValue {
focus: LayerFocusRequest | null;
requestFocus(nodeId: string, prop: string, index: number): void;
}
const LayerFocusContext = createContext<LayerFocusValue>({
focus: null,
requestFocus: () => {},
});
export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext);
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [focus, setFocus] = useState<LayerFocusRequest | null>(null);
const nonceRef = useRef(0);
const requestFocus = useCallback((nodeId: string, prop: string, index: number) => {
nonceRef.current += 1;
setFocus({ nodeId, prop, index, nonce: nonceRef.current });
}, []);
const value = useMemo(() => ({ focus, requestFocus }), [focus, requestFocus]);
return <LayerFocusContext.Provider value={value}>{children}</LayerFocusContext.Provider>;
};
```
- [ ] **Step 4: Mount the provider**
In `src/editor/EditorShell.tsx`, import `LayerFocusProvider` and wrap the element that already contains both the left and right panels (the outermost editor layout element in the component's `return`). Both panels must be inside it — the left panel produces requests, the right consumes them.
- [ ] **Step 5: Run tests and typecheck**
Run: `npx vitest run src/panels/left/LayerFocusContext.test.tsx && npx tsc --noEmit`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/left/LayerFocusContext.tsx craft/src/panels/left/LayerFocusContext.test.tsx craft/src/editor/EditorShell.tsx
git commit -m "feat(site-builder): add LayerFocusContext for layers-to-array-editor focus
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 11: LayersPanel shows everything — virtual rows, Unplaced group, scrolling
**Files:**
- Modify: `src/panels/left/LayersPanel.tsx`
- Modify: `src/styles/editor.css` (append the new row styles)
- Test: `src/panels/left/LayersPanel.test.tsx` (create)
**Interfaces:**
- Consumes: `deriveVirtualRows` / `VIRTUAL_CHILD_PROPS` (Task 9), `useLayerFocus` (Task 10), `findUnreachableNodeIds` (Task 7).
- Produces: no new exports.
- [ ] **Step 1: Write the failing test**
Create `src/panels/left/LayersPanel.test.tsx`:
```tsx
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { LayersPanel } from './LayersPanel';
import { LayerFocusProvider } from './LayerFocusContext';
function stateWith(extra: Record<string, any>, rootChildren: string[]) {
return JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true,
props: { style: {}, tag: 'div' }, displayName: 'Container',
custom: {}, hidden: false, nodes: rootChildren, linkedNodes: {}, parent: null,
},
...extra,
});
}
const featuresNode = {
type: { resolvedName: 'FeaturesGrid' }, isCanvas: false,
props: { features: [{ title: 'Fast' }, { title: 'Secure' }] },
displayName: 'Features Grid',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
describe('LayersPanel', () => {
test('shows virtual rows for a Features Grid array prop', () => {
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.textContent).toContain('Features Grid');
expect(harness.container.textContent).toContain('Fast');
expect(harness.container.textContent).toContain('Secure');
harness.unmount();
});
test('virtual rows are not rendered for a component with no registry entry', () => {
const heading = {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Title', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
const harness = renderEditorHarness({ initialState: stateWith({ h1: heading }, ['h1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.querySelectorAll('.layer-virtual-row')).toHaveLength(0);
harness.unmount();
});
test('the Unplaced group does not render for a healthy tree', () => {
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.textContent).not.toContain('Unplaced');
harness.unmount();
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/panels/left/LayersPanel.test.tsx`
Expected: FAIL — the first test finds no "Fast"/"Secure" text.
- [ ] **Step 3: Add virtual rows to `LayerNode`**
In `src/panels/left/LayersPanel.tsx`:
1. Extend the imports:
```tsx
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
import { useLayerFocus } from './LayerFocusContext';
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
```
2. Add this component above `LayerNode`:
```tsx
/**
* A display-only row for one item of a composite's array prop (a Features
* Grid feature, a Tabs tab, ...). Not a Craft node: it can't be dragged or
* deleted. Clicking it selects the PARENT and asks the array editor to
* scroll that item's card into view.
*/
const VirtualRowNode: React.FC<{
parentId: string;
prop: string;
index: number;
label: string;
depth: number;
onActivate: () => void;
}> = ({ index, label, depth, onActivate }) => (
<div
{...clickableProps(onActivate)}
className="layer-virtual-row"
title={label}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
paddingLeft: `${8 + depth * 16}px`,
fontSize: 11,
color: 'var(--color-text-dim)',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'none',
}}
>
<span style={{ marginRight: 6, fontSize: 8, flexShrink: 0 }} aria-hidden="true">&#9642;</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
</div>
);
```
3. Inside `LayerNode`, after `const allChildren = [...]`, add:
```tsx
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
```
4. Change the disclosure-triangle condition from `allChildren.length > 0` to `allChildren.length + virtualRows.length > 0` (both the visible and the transparent branch use the same condition).
5. Render the virtual rows immediately before the real-children map:
```tsx
{virtualSpec && virtualRows.map((row) => (
<VirtualRowNode
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
parentId={nodeId}
prop={virtualSpec.prop}
index={row.index}
label={row.label}
depth={depth + 1}
onActivate={() => {
actions.selectNode(nodeId);
requestFocus(nodeId, virtualSpec.prop, row.index);
}}
/>
))}
```
6. Pull `requestFocus` from the context at the top of `LayerNode`:
```tsx
const { requestFocus } = useLayerFocus();
```
- [ ] **Step 4: Add the Unplaced group and a scroll container**
Replace the `LayersPanel` component at the bottom of the file:
```tsx
export const LayersPanel: React.FC = () => {
const { nodeIds, unplacedIds } = useEditor((state) => {
const serializable: Record<string, any> = {};
for (const [id, n] of Object.entries(state.nodes)) {
serializable[id] = {
nodes: n.data.nodes || [],
linkedNodes: n.data.linkedNodes || {},
};
}
return {
nodeIds: Object.keys(state.nodes),
unplacedIds: findUnreachableNodeIds(serializable),
};
});
const hasRoot = nodeIds.includes('ROOT');
if (!hasRoot) {
return <div className="panel-placeholder">No content on canvas</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
<div
style={{
padding: '8px 12px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
Component Tree
</div>
{/* Own scroll container: a deep or long tree must stay fully reachable
regardless of how the parent tab panel is sized. */}
<div className="layers-tree-scroll" style={{ overflowY: 'auto', flex: 1, minHeight: 0 }}>
<LayerNode nodeId="ROOT" depth={0} />
{/* Unplaced: nodes no parent lists. `repairOrphanNodes` reattaches
these on load, so this should stay empty -- it exists so an
element stranded mid-session is still selectable and deletable
rather than invisible. */}
{unplacedIds.length > 0 && (
<>
<div
style={{
padding: '8px 12px',
marginTop: 8,
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-warning, #f59e0b)',
borderTop: '1px solid var(--color-border)',
}}
title="These elements are not attached to the page. Select one to delete it."
>
Unplaced ({unplacedIds.length})
</div>
{unplacedIds.map((id) => (
<LayerNode key={id} nodeId={id} depth={1} />
))}
</>
)}
</div>
</div>
);
};
```
- [ ] **Step 5: Add the hover style**
Append to `src/styles/editor.css`:
```css
/* Layers: display-only rows derived from a composite's array props
(Features Grid features, Tabs tabs, ...). Dimmer than real node rows and
with no disclosure column, so they don't read as draggable Craft nodes. */
.layer-virtual-row:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
```
- [ ] **Step 6: Run tests and typecheck**
Run: `npx vitest run src/panels/left/ && npx tsc --noEmit`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/left/LayersPanel.tsx craft/src/panels/left/LayersPanel.test.tsx craft/src/styles/editor.css
git commit -m "feat(site-builder): Layers shows array-prop items, unplaced nodes, and scrolls
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 12: Array editors honour a focus request
**Files:**
- Modify: `src/panels/right/styles/ArrayItemFields.tsx`
- Modify: `src/panels/right/styles/FeaturesEditor.tsx`
**Interfaces:**
- Consumes: `useLayerFocus` (Task 10).
- Produces: each array item card carries `data-array-item="<prop>:<index>"`.
- [ ] **Step 1: Tag each item card**
In both files, find the element wrapping one item's fields and add the attribute — in `ArrayItemFields.tsx` use the editor's own `propKey`, in `FeaturesEditor.tsx` the literal `features`:
```tsx
<div data-array-item={`${propKey}:${index}`} ...existing props>
```
- [ ] **Step 2: Scroll on request**
Add to each editor component body:
```tsx
const { focus } = useLayerFocus();
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!focus || focus.prop !== propKey) return;
const card = rootRef.current?.querySelector(`[data-array-item="${propKey}:${focus.index}"]`);
card?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// `focus.nonce` is in the dep list so clicking the SAME row twice
// re-scrolls (the request object is otherwise identical).
}, [focus?.nonce, focus?.prop, focus?.index, propKey]);
```
Attach `ref={rootRef}` to the editor's outermost element. In `FeaturesEditor.tsx` substitute the literal `'features'` for `propKey`.
- [ ] **Step 3: Typecheck and run the suite**
Run: `npx tsc --noEmit && npx vitest run`
Expected: PASS. This step has no new test of its own — `scrollIntoView` is not implemented in jsdom, and the behaviour it guards is cosmetic. Task 10's tests cover the request side; the consumer side is verified manually in Task 21.
- [ ] **Step 4: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/right/styles/ArrayItemFields.tsx craft/src/panels/right/styles/FeaturesEditor.tsx
git commit -m "feat(site-builder): array editors scroll to the item picked in Layers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 13: Reproduce and prevent the off-canvas drop
**This task is an investigation before it is a fix.** Tasks 8 and 11 already make a stranded element recoverable; this one stops it happening. Do not write a fix before Step 3 produces a reproduction — a guessed fix here is unverifiable.
**Files:**
- Modify (after diagnosis): most likely `src/editor/Canvas.tsx` and/or `src/styles/editor.css`; possibly `src/App.tsx` if a custom `DefaultEventHandlers` subclass is needed.
- Test: `src/editor/Canvas.drop-guard.test.tsx` (create, shape depends on the diagnosis)
**Interfaces:**
- Consumes: `findUnreachableNodeIds` (Task 7) as the assertion helper.
- Produces: nothing other tasks depend on.
- [ ] **Step 1: Build and serve the editor locally**
```bash
cd /workspace/site-builder/craft && npm run dev
```
Open `http://localhost:5173`. Standalone mode (no `WHP_CONFIG`) is fine — this is a pure drag-and-drop question with no backend involvement.
- [ ] **Step 2: Attempt the reported reproduction**
Drag an **HTML** block from the Blocks panel and release it in each of these places, checking after every attempt. Switch the device switcher to **Tablet** and **Mobile** first — those narrow `.canvas-device-frame` and expose the side gutters, which is where a pointer is most easily "over the canvas" but over no Craft node:
1. The grey gutter to the left/right of the device frame.
2. The header `ZonePreview` band at the top (`pointer-events: none`).
3. The footer `ZonePreview` band at the bottom.
4. Below the footer band, inside `.editor-canvas` but past all content.
5. Over the left or right panel, then back and release outside the frame.
After each drop, in the browser console:
```js
const nodes = JSON.parse(window.__craftQuery ? window.__craftQuery.serialize() : '{}');
```
If no query is exposed globally, read the Layers panel instead: a stranded node now appears under **Unplaced** (Task 11), which is itself the detector.
- [ ] **Step 3: Write down the mechanism**
Record in the commit message and in a comment at the fix site: which drop location reproduces it, what the resulting node's `parent` is, and whether `ROOT.nodes` lists it. **Acceptance criterion for this step is a written mechanism, not a fix.**
If **no** location reproduces it after all five, stop here and report that. Do not invent a guard for a path you could not trigger. Tasks 8 and 11 already cover the user-visible harm; say so plainly and move to Task 14.
- [ ] **Step 4: Write a failing test that encodes the mechanism**
Shape depends on Step 3. If the cause is a drop resolving to no node, the test asserts the invariant directly against a real editor:
```tsx
import { describe, test, expect } from 'vitest';
import { renderEditorHarness } from '../test-utils/editorHarness';
import { findUnreachableNodeIds } from '../utils/orphan-repair';
describe('canvas drop guard', () => {
test('after the reproduced interaction, every node is still reachable from ROOT', () => {
const harness = renderEditorHarness();
// ... drive the interaction identified in Step 3 ...
const serializable: Record<string, any> = {};
for (const [id, n] of Object.entries(JSON.parse(harness.getSerialized()) as Record<string, any>)) {
serializable[id] = { nodes: n.nodes || [], linkedNodes: n.linkedNodes || {} };
}
expect(findUnreachableNodeIds(serializable)).toEqual([]);
harness.unmount();
});
});
```
If the cause turns out to be purely a CSS hit-area problem (the drop never reaches a Craft node because a wrapper swallows it), a unit test cannot express it — say so in the commit message and rely on the manual check in Task 21 instead.
- [ ] **Step 5: Implement the narrowest fix the mechanism justifies**
Preferred, in order:
1. **CSS/hit-area** — if the wrapper `<div style={{ position: 'relative' }}>` around `<Frame>` in `Canvas.tsx:220` or the `.editor-canvas` padding is absorbing drops, constrain it so only real Craft nodes are droppable.
2. **Reject at the handler** — if Craft's retained last-valid indicator is the cause, pass a `DefaultEventHandlers` subclass to `<Editor handlers={...}>` in `src/App.tsx` that returns no indicator when the pointer is outside `.canvas-device-frame`.
Do not do both. Whichever you pick, comment it with the Step 3 mechanism.
- [ ] **Step 6: Verify the fix and re-run the whole suite**
Run: `npx vitest run && npx tsc --noEmit`
Then repeat Step 2 in `npm run dev` and confirm the Unplaced group never appears.
- [ ] **Step 7: Commit**
```bash
cd /workspace/site-builder
git add -A craft/src
git commit -m "fix(site-builder): reject drops that resolve outside the page root
Mechanism: <the finding from Step 3>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
## Phase 3 — Reset (spec item 3)
### Task 14: Reset Page
**Files:**
- Modify: `src/panels/left/PagesPanel.tsx`
- Test: `src/panels/left/PagesPanel.reset.test.tsx` (create)
**Interfaces:**
- Consumes: `EMPTY_CANVAS` (Task 8), `useEditor().actions.deserialize`.
- Produces: no new exports.
- [ ] **Step 1: Write the failing test**
Create `src/panels/left/PagesPanel.reset.test.tsx`:
```tsx
import { describe, test, expect } from 'vitest';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { EMPTY_CANVAS } from '../../state/PageContext';
const withHeading = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true,
props: { style: {}, tag: 'div' }, displayName: 'Container',
custom: {}, hidden: false, nodes: ['h1'], linkedNodes: {}, parent: null,
},
h1: {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Keep me', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
},
});
describe('resetting a page to EMPTY_CANVAS', () => {
test('clears the canvas', () => {
const harness = renderEditorHarness({ initialState: withHeading });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
harness.unmount();
});
test('the reset is undoable through Craft history', () => {
const harness = renderEditorHarness({ initialState: withHeading });
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
harness.act(() => { harness.actions.history.undo(); });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.unmount();
});
});
```
- [ ] **Step 2: Run and verify it passes or fails honestly**
Run: `npx vitest run src/panels/left/PagesPanel.reset.test.tsx`
This test characterises Craft's behaviour rather than new code. If the undo test FAILS, `deserialize` does not enter history in this Craft version — **remove the undo claim from the confirmation dialog copy in Step 3** and note it in the commit message. Do not ship a dialog promising an undo that does not work.
- [ ] **Step 3: Add the control**
In `src/panels/left/PagesPanel.tsx`:
1. Extend the imports:
```tsx
import { useEditor } from '@craftjs/core';
import { EMPTY_CANVAS } from '../../state/PageContext';
```
2. In the component body, alongside the existing `deleteConfirmId` state:
```tsx
const { actions: editorActions } = useEditor();
const [resetConfirmId, setResetConfirmId] = useState<string | null>(null);
const handleResetPage = (pageId: string): void => {
// Only the page on screen can be blanked -- deserialize() acts on the
// live Frame, so switch first if the target isn't already active.
if (pageId !== activePageId) switchPage(pageId);
setTimeout(() => editorActions.deserialize(EMPTY_CANVAS), 0);
setResetConfirmId(null);
};
```
3. Add the button to the per-page action row, next to the existing delete button (`fa-trash`):
```tsx
<button
onClick={() => setResetConfirmId(page.id)}
title="Reset this page to blank"
aria-label={`Reset ${page.name} to blank`}
className="page-action-btn"
>
<i className="fa fa-eraser" aria-hidden="true" />
</button>
```
Match the surrounding buttons' `className`/inline styles exactly — copy from the adjacent `fa-trash` button rather than inventing new styling.
4. Add the inline confirmation, mirroring the existing `deleteConfirmId` block:
```tsx
{resetConfirmId === page.id && (
<div className="page-confirm">
<p>
Clear every element from <strong>{page.name}</strong>? Your header,
footer and other pages are untouched, and the published site
doesn't change until you publish again. Ctrl+Z undoes this.
</p>
<button onClick={() => handleResetPage(page.id)}>Reset page</button>
<button onClick={() => setResetConfirmId(null)}>Cancel</button>
</div>
)}
```
Reuse whatever class names / inline styles the existing delete confirmation uses. If Step 2's undo test failed, delete the "Ctrl+Z undoes this." sentence.
- [ ] **Step 4: Run tests and typecheck**
Run: `npx vitest run src/panels/left/ && npx tsc --noEmit`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/left/PagesPanel.tsx craft/src/panels/left/PagesPanel.reset.test.tsx
git commit -m "feat(site-builder): add Reset Page to the Pages panel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 15: Reset Entire Site
**Files:**
- Modify: `src/panels/right/SiteDesignPanel.tsx` (append a danger zone below the existing "Reset to Defaults")
- Test: `src/panels/right/SiteDesignPanel.reset.test.tsx` (create)
**Interfaces:**
- Consumes: `usePages()``replaceAllPages`, `setHeader`, `setFooter`; `useSiteDesign()` → its existing `resetToDefaults`; `useEditorConfig()` for `siteDomain`.
- Produces: no new exports.
- [ ] **Step 1: Confirm the context method signatures**
```bash
cd /workspace/site-builder/craft/src
sed -n '/interface PageContextValue/,/^}/p' state/PageContext.tsx | grep -n "setHeader\|setFooter\|replaceAllPages"
grep -n "resetToDefaults" state/SiteDesignContext.tsx panels/right/SiteDesignPanel.tsx
```
`replaceAllPages` takes `{ name: string; tree: SerializedTreeNode }[]`, so the blank page must be expressed as a tree, not as a serialized string. Use:
```tsx
const BLANK_PAGE_TREE = {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: { minHeight: '100vh', backgroundColor: '#ffffff' }, tag: 'div' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
} as any;
```
If `setHeader`/`setFooter` take a different shape than a craft-state string, adapt — the component's signature wins over this plan.
- [ ] **Step 2: Write the failing test**
Create `src/panels/right/SiteDesignPanel.reset.test.tsx`:
```tsx
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
const replaceAllPages = vi.fn();
const setHeader = vi.fn();
const setFooter = vi.fn();
const resetToDefaults = vi.fn();
vi.mock('../../state/PageContext', () => ({
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }),
EMPTY_CANVAS: '{}',
}));
vi.mock('../../state/SiteDesignContext', () => ({
useSiteDesign: () => ({ design: {}, setDesign: vi.fn(), resetToDefaults }),
DEFAULT_SITE_DESIGN: {},
}));
vi.mock('../../state/EditorConfigContext', () => ({
useEditorConfig: () => ({ whpConfig: { siteDomain: 'example.com' }, isWHP: true }),
}));
import { SiteDesignPanel } from './SiteDesignPanel';
let container: HTMLDivElement;
let root: Root;
function render() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<SiteDesignPanel />);
});
}
describe('Reset Entire Site', () => {
test('the confirm button is disabled until the domain is typed exactly', () => {
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => {
input.value = 'example.co';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(true);
act(() => {
input.value = 'example.com';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(false);
});
test('confirming blanks pages, header, footer and design tokens', () => {
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => {
input.value = 'example.com';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
act(() => { (container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); });
expect(replaceAllPages).toHaveBeenCalledTimes(1);
expect(replaceAllPages.mock.calls[0][0]).toHaveLength(1);
expect(replaceAllPages.mock.calls[0][0][0].name).toBe('Home');
expect(setHeader).toHaveBeenCalledTimes(1);
expect(setFooter).toHaveBeenCalledTimes(1);
expect(resetToDefaults).toHaveBeenCalledTimes(1);
});
});
```
- [ ] **Step 3: Run and verify failure**
Run: `npx vitest run src/panels/right/SiteDesignPanel.reset.test.tsx`
Expected: FAIL — no `[data-action="open-site-reset"]` element exists.
- [ ] **Step 4: Implement the danger zone**
Append inside `SiteDesignPanel`, below the existing "Reset to Defaults" block and its caption:
```tsx
<div
style={{
marginTop: 18, padding: 12,
border: '1px solid rgba(239,68,68,0.35)',
borderRadius: 'var(--radius-sm)',
background: 'rgba(239,68,68,0.06)',
}}
>
<div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 6 }}>
Danger zone
</div>
{!siteResetOpen ? (
<button
data-action="open-site-reset"
onClick={() => setSiteResetOpen(true)}
style={{
width: '100%', padding: '8px 12px', fontSize: 11, fontWeight: 600,
color: '#ef4444', background: 'transparent',
border: '1px solid rgba(239,68,68,0.5)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}
>
Reset Entire Site
</button>
) : (
<>
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5, margin: '0 0 8px' }}>
This blanks <strong>every page</strong>, the header, the footer and all
design tokens, leaving one empty Home page. Uploaded images are kept.
<strong> This cannot be undone.</strong> Your published site stays as it
is until you publish again but the editor auto-saves, so the blank
version becomes your saved draft within about 30 seconds.
</p>
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: '0 0 4px' }}>
Type <code>{siteDomain}</code> to confirm:
</p>
<input
data-testid="site-reset-domain"
value={siteResetTyped}
onChange={(e) => setSiteResetTyped(e.target.value)}
placeholder={siteDomain}
style={inputStyle}
/>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
data-action="confirm-site-reset"
disabled={siteResetTyped.trim() !== siteDomain}
onClick={handleResetSite}
style={{
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
color: '#fff', background: '#ef4444', border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: siteResetTyped.trim() === siteDomain ? 'pointer' : 'not-allowed',
opacity: siteResetTyped.trim() === siteDomain ? 1 : 0.5,
}}
>
Reset everything
</button>
<button
onClick={() => { setSiteResetOpen(false); setSiteResetTyped(''); }}
style={{
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
color: 'var(--color-text-muted)', background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</>
)}
</div>
```
With this state and handler in the component body:
```tsx
const { replaceAllPages, setHeader, setFooter } = usePages();
const { resetToDefaults } = useSiteDesign();
const { whpConfig } = useEditorConfig();
const siteDomain = whpConfig?.siteDomain ?? '';
const [siteResetOpen, setSiteResetOpen] = useState(false);
const [siteResetTyped, setSiteResetTyped] = useState('');
const handleResetSite = (): void => {
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
setHeader('');
setFooter('');
resetToDefaults();
setSiteResetOpen(false);
setSiteResetTyped('');
};
```
Import `inputStyle` from `./styles/shared` and `useState` if not already imported. Match `setHeader`/`setFooter` to the real signatures found in Step 1.
**Standalone mode:** with no `WHP_CONFIG`, `siteDomain` is `''` and typing nothing would satisfy the check. Guard the open button so it does not render when `siteDomain` is empty.
- [ ] **Step 5: Run tests and typecheck**
Run: `npx vitest run src/panels/right/ && npx tsc --noEmit`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/right/SiteDesignPanel.tsx craft/src/panels/right/SiteDesignPanel.reset.test.tsx
git commit -m "feat(site-builder): add domain-confirmed Reset Entire Site
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
## Phase 4 — In-builder issue reporting (spec item 5)
### Task 16: Console error ring buffer
**Files:**
- Create: `src/utils/console-buffer.ts`
- Create: `src/utils/console-buffer.test.ts`
- Modify: `src/main.tsx`
**Interfaces:**
- Produces:
```ts
export interface ConsoleErrorEntry { ts: number; message: string }
/** Idempotent. Patches console.error and adds window error listeners. */
export function installConsoleErrorBuffer(): void;
/** Oldest-first copy of the retained entries (at most 20). */
export function getRecentConsoleErrors(): ConsoleErrorEntry[];
/** Test-only: clear the buffer and un-patch. */
export function __resetConsoleErrorBuffer(): void;
```
- [ ] **Step 1: Write the failing tests**
Create `src/utils/console-buffer.test.ts`:
```ts
import { describe, test, expect, vi, afterEach } from 'vitest';
import {
installConsoleErrorBuffer,
getRecentConsoleErrors,
__resetConsoleErrorBuffer,
} from './console-buffer';
afterEach(() => {
__resetConsoleErrorBuffer();
vi.restoreAllMocks();
});
describe('console error buffer', () => {
test('captures console.error calls', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
installConsoleErrorBuffer();
console.error('boom', 42);
const entries = getRecentConsoleErrors();
expect(entries).toHaveLength(1);
expect(entries[0].message).toBe('boom 42');
expect(typeof entries[0].ts).toBe('number');
});
test('always chains to the original console.error', () => {
const original = vi.spyOn(console, 'error').mockImplementation(() => {});
installConsoleErrorBuffer();
console.error('passed through');
expect(original).toHaveBeenCalledWith('passed through');
});
test('retains only the last 20 entries, oldest first', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
installConsoleErrorBuffer();
for (let i = 0; i < 25; i++) console.error(`e${i}`);
const entries = getRecentConsoleErrors();
expect(entries).toHaveLength(20);
expect(entries[0].message).toBe('e5');
expect(entries[19].message).toBe('e24');
});
test('truncates a long message to 500 characters', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
installConsoleErrorBuffer();
console.error('x'.repeat(900));
expect(getRecentConsoleErrors()[0].message).toHaveLength(500);
});
test('installing twice does not double-capture', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
installConsoleErrorBuffer();
installConsoleErrorBuffer();
console.error('once');
expect(getRecentConsoleErrors()).toHaveLength(1);
});
test('captures window error events', () => {
installConsoleErrorBuffer();
window.dispatchEvent(new ErrorEvent('error', { message: 'window blew up' }));
expect(getRecentConsoleErrors().some((e) => e.message.includes('window blew up'))).toBe(true);
});
test('getRecentConsoleErrors returns a copy, not the live array', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
installConsoleErrorBuffer();
console.error('a');
const first = getRecentConsoleErrors();
first.push({ ts: 0, message: 'injected' });
expect(getRecentConsoleErrors()).toHaveLength(1);
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/utils/console-buffer.test.ts`
Expected: FAIL — `Cannot find module './console-buffer'`.
- [ ] **Step 3: Implement it**
Create `src/utils/console-buffer.ts`:
```ts
/**
* A tiny ring buffer of the most recent errors, attached to the issue
* reports users file from inside the builder. Without it a report says
* "it broke" and nothing else.
*
* Message text only -- no stack traces. Production stacks are minified into
* uselessness and leak bundle paths for no diagnostic gain.
*
* The console.error patch ALWAYS chains to the original. A monitor that
* swallows diagnostics is worse than no monitor.
*/
export interface ConsoleErrorEntry {
ts: number;
message: string;
}
const MAX_ENTRIES = 20;
const MAX_MESSAGE = 500;
let buffer: ConsoleErrorEntry[] = [];
let installed = false;
let originalConsoleError: typeof console.error | null = null;
let errorListener: ((e: ErrorEvent) => void) | null = null;
let rejectionListener: ((e: PromiseRejectionEvent) => void) | null = null;
function record(message: string): void {
const text = message.length > MAX_MESSAGE ? message.slice(0, MAX_MESSAGE) : message;
buffer.push({ ts: Date.now(), message: text });
if (buffer.length > MAX_ENTRIES) buffer = buffer.slice(buffer.length - MAX_ENTRIES);
}
function stringifyArg(arg: unknown): string {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
export function installConsoleErrorBuffer(): void {
if (installed) return;
installed = true;
originalConsoleError = console.error.bind(console);
console.error = (...args: unknown[]): void => {
try {
record(args.map(stringifyArg).join(' '));
} catch {
// Recording must never break logging.
}
originalConsoleError!(...args);
};
if (typeof window !== 'undefined') {
errorListener = (e: ErrorEvent) => record(`window.onerror: ${e.message}`);
rejectionListener = (e: PromiseRejectionEvent) =>
record(`unhandledrejection: ${stringifyArg(e.reason)}`);
window.addEventListener('error', errorListener);
window.addEventListener('unhandledrejection', rejectionListener);
}
}
export function getRecentConsoleErrors(): ConsoleErrorEntry[] {
return buffer.slice();
}
/** Test-only teardown. */
export function __resetConsoleErrorBuffer(): void {
buffer = [];
if (originalConsoleError) console.error = originalConsoleError;
originalConsoleError = null;
if (typeof window !== 'undefined') {
if (errorListener) window.removeEventListener('error', errorListener);
if (rejectionListener) window.removeEventListener('unhandledrejection', rejectionListener);
}
errorListener = null;
rejectionListener = null;
installed = false;
}
```
- [ ] **Step 4: Install it at boot**
In `src/main.tsx`, above the `createRoot` call:
```tsx
import { installConsoleErrorBuffer } from './utils/console-buffer';
// Installed before React mounts so errors thrown during the first render are
// captured too.
installConsoleErrorBuffer();
```
- [ ] **Step 5: Run and verify pass**
Run: `npx vitest run src/utils/console-buffer.test.ts && npx tsc --noEmit`
Expected: PASS, 7 tests.
- [ ] **Step 6: Commit**
```bash
cd /workspace/site-builder
git add craft/src/utils/console-buffer.ts craft/src/utils/console-buffer.test.ts craft/src/main.tsx
git commit -m "feat(site-builder): capture recent console errors for issue reports
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 17: Build stamp
`craft/package.json`'s `2.0.0` never changes, so it cannot identify which bundle produced a report. Stamp the git SHA and build date at compile time.
**Files:**
- Modify: `vite.config.ts`
- Create: `src/utils/build-stamp.ts`
- Create: `src/vite-env.d.ts` (only if absent)
**Interfaces:**
- Produces: `export function editorBuild(): string` — the stamp, or `'dev'` when the define is absent (vitest, `npm run dev`).
- [ ] **Step 1: Add the define**
In `vite.config.ts`, above `export default`:
```ts
import { execSync } from 'child_process'
/** Short git SHA + build date, injected as __EDITOR_BUILD__ so an issue
* report identifies exactly which bundle produced it. package.json's
* version is hand-maintained and never changes between builds. */
const editorBuild = (() => {
let sha = 'nogit'
try {
sha = execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim()
} catch {
// Building outside a git checkout (release tarball) -- keep 'nogit'.
}
return `${sha}-${new Date().toISOString().slice(0, 10)}`
})()
```
and inside the config object:
```ts
define: {
__EDITOR_BUILD__: JSON.stringify(editorBuild),
},
```
- [ ] **Step 2: Declare the global**
In `src/vite-env.d.ts` (create if it does not exist):
```ts
/// <reference types="vite/client" />
/** Injected by vite.config.ts `define`. Absent under vitest -- always read
* it through `utils/build-stamp.ts`'s `editorBuild()`, never directly. */
declare const __EDITOR_BUILD__: string;
```
- [ ] **Step 3: Add the safe accessor**
Create `src/utils/build-stamp.ts`:
```ts
/**
* Reads the compile-time `__EDITOR_BUILD__` define.
*
* vitest does not apply Vite's `define`, and `npm run dev` in a non-git
* checkout may not either, so every read goes through here. `typeof` on an
* undeclared identifier is safe in JS -- it does not throw.
*/
export function editorBuild(): string {
return typeof __EDITOR_BUILD__ !== 'undefined' ? __EDITOR_BUILD__ : 'dev';
}
```
- [ ] **Step 4: Verify both modes**
Run: `npx vitest run src/utils/ && npx tsc --noEmit && npm run build`
Expected: tests pass (accessor returns `'dev'`), typecheck clean, and the build succeeds. Confirm the stamp made it in:
```bash
grep -o '[0-9a-f]\{7\}-2026-[0-9][0-9]-[0-9][0-9]' dist/js/editor.js | head -1
```
Expected: one match, e.g. `fb68fa6-2026-08-08`.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/vite.config.ts craft/src/utils/build-stamp.ts craft/src/vite-env.d.ts
git commit -m "feat(site-builder): stamp git sha + date into the editor bundle
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 18: `buildReportPayload` — assemble and size-cap
**Files:**
- Create: `src/utils/report-payload.ts`
- Create: `src/utils/report-payload.test.ts`
**Interfaces:**
- Consumes: `ConsoleErrorEntry` (Task 16).
- Produces:
```ts
export const MAX_PAYLOAD_BYTES = 512 * 1024;
export type ReportCategory = 'bug' | 'confusing' | 'feature';
export interface BuildReportPayloadInput {
category: ReportCategory;
description: string;
includeCanvas: boolean;
siteId: number | null;
siteDomain: string;
pageId: string;
pageSlug: string;
editorVersion: string;
userAgent: string;
viewport: string;
deviceMode: string;
selectedType: string | null;
consoleErrors: ConsoleErrorEntry[];
canvasState: string | null;
}
export interface ReportPayload {
category: ReportCategory;
description: string;
site_id: number | null;
site_domain: string;
page_id: string;
page_slug: string;
editor_version: string;
user_agent: string;
viewport: string;
device_mode: string;
selected_type: string | null;
console_errors: ConsoleErrorEntry[];
canvas_state: string | null;
/** Present only when the canvas state was dropped. */
canvas_state_omitted?: 'size' | 'opt-out';
}
export function buildReportPayload(input: BuildReportPayloadInput): ReportPayload;
```
- [ ] **Step 1: Write the failing tests**
Create `src/utils/report-payload.test.ts`:
```ts
import { describe, test, expect } from 'vitest';
import { buildReportPayload, MAX_PAYLOAD_BYTES } from './report-payload';
const base = {
category: 'bug' as const,
description: 'The HTML block colours do nothing',
includeCanvas: true,
siteId: 42,
siteDomain: 'example.com',
pageId: 'home',
pageSlug: 'index',
editorVersion: 'abc1234-2026-08-08',
userAgent: 'Mozilla/5.0 test',
viewport: '1920x1080',
deviceMode: 'desktop',
selectedType: 'HTML',
consoleErrors: [{ ts: 1, message: 'oops' }],
canvasState: '{"ROOT":{}}',
};
describe('buildReportPayload', () => {
test('carries every context field through', () => {
const p = buildReportPayload(base);
expect(p.category).toBe('bug');
expect(p.description).toBe('The HTML block colours do nothing');
expect(p.site_id).toBe(42);
expect(p.site_domain).toBe('example.com');
expect(p.page_slug).toBe('index');
expect(p.editor_version).toBe('abc1234-2026-08-08');
expect(p.device_mode).toBe('desktop');
expect(p.selected_type).toBe('HTML');
expect(p.console_errors).toEqual([{ ts: 1, message: 'oops' }]);
expect(p.canvas_state).toBe('{"ROOT":{}}');
expect(p.canvas_state_omitted).toBeUndefined();
});
test('includeCanvas=false drops the canvas and records why', () => {
const p = buildReportPayload({ ...base, includeCanvas: false });
expect(p.canvas_state).toBeNull();
expect(p.canvas_state_omitted).toBe('opt-out');
expect(p.console_errors).toHaveLength(1);
expect(p.site_domain).toBe('example.com');
});
test('an oversized canvas is dropped rather than truncated', () => {
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES + 1000);
const p = buildReportPayload({ ...base, canvasState: huge });
expect(p.canvas_state).toBeNull();
expect(p.canvas_state_omitted).toBe('size');
});
test('the resulting payload always fits under the cap', () => {
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES * 2);
const p = buildReportPayload({ ...base, canvasState: huge });
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
});
test('description is trimmed', () => {
expect(buildReportPayload({ ...base, description: ' spaced ' }).description).toBe('spaced');
});
test('a null canvasState is reported as opt-out-free but still null', () => {
const p = buildReportPayload({ ...base, canvasState: null });
expect(p.canvas_state).toBeNull();
expect(p.canvas_state_omitted).toBeUndefined();
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/utils/report-payload.test.ts`
Expected: FAIL — `Cannot find module './report-payload'`.
- [ ] **Step 3: Implement it**
Create `src/utils/report-payload.ts`:
```ts
import type { ConsoleErrorEntry } from './console-buffer';
/**
* Assembles the JSON body for an in-builder issue report.
*
* Pure: every environment value (user agent, viewport, serialized canvas)
* is passed IN rather than read from globals, so the whole thing is testable
* without a DOM and the caller decides what it is willing to send.
*
* The canvas state is the only field that can be large. When it would push
* the body over the cap it is DROPPED WHOLE and flagged -- a truncated craft
* state is not merely useless, it is misleading (it looks like a valid tree
* that lost nodes).
*/
export const MAX_PAYLOAD_BYTES = 512 * 1024;
export type ReportCategory = 'bug' | 'confusing' | 'feature';
export interface BuildReportPayloadInput {
category: ReportCategory;
description: string;
includeCanvas: boolean;
siteId: number | null;
siteDomain: string;
pageId: string;
pageSlug: string;
editorVersion: string;
userAgent: string;
viewport: string;
deviceMode: string;
selectedType: string | null;
consoleErrors: ConsoleErrorEntry[];
canvasState: string | null;
}
export interface ReportPayload {
category: ReportCategory;
description: string;
site_id: number | null;
site_domain: string;
page_id: string;
page_slug: string;
editor_version: string;
user_agent: string;
viewport: string;
device_mode: string;
selected_type: string | null;
console_errors: ConsoleErrorEntry[];
canvas_state: string | null;
canvas_state_omitted?: 'size' | 'opt-out';
}
function byteLength(value: string): number {
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length;
return value.length;
}
export function buildReportPayload(input: BuildReportPayloadInput): ReportPayload {
const payload: ReportPayload = {
category: input.category,
description: input.description.trim(),
site_id: input.siteId,
site_domain: input.siteDomain,
page_id: input.pageId,
page_slug: input.pageSlug,
editor_version: input.editorVersion,
user_agent: input.userAgent,
viewport: input.viewport,
device_mode: input.deviceMode,
selected_type: input.selectedType,
console_errors: input.consoleErrors,
canvas_state: null,
};
if (!input.includeCanvas) {
payload.canvas_state_omitted = 'opt-out';
return payload;
}
if (!input.canvasState) return payload;
payload.canvas_state = input.canvasState;
if (byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES) {
payload.canvas_state = null;
payload.canvas_state_omitted = 'size';
}
return payload;
}
```
- [ ] **Step 4: Run and verify pass**
Run: `npx vitest run src/utils/report-payload.test.ts`
Expected: PASS, 6 tests.
- [ ] **Step 5: Commit**
```bash
cd /workspace/site-builder
git add craft/src/utils/report-payload.ts craft/src/utils/report-payload.test.ts
git commit -m "feat(site-builder): add pure issue-report payload builder
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 19: Report an Issue modal and topbar entry point
**Files:**
- Create: `src/panels/topbar/ReportIssueModal.tsx`
- Create: `src/panels/topbar/ReportIssueModal.test.tsx`
- Modify: `src/panels/topbar/TopBar.tsx`
- Modify: `src/panels/topbar/TopBarOverflowMenu.tsx`
**Interfaces:**
- Consumes: `buildReportPayload` (Task 18), `getRecentConsoleErrors` (Task 16), `editorBuild` (Task 17), `useEditorConfig`, `usePages`, `useEditor`.
- Produces:
```ts
export interface ReportIssueModalProps {
open: boolean;
onClose: () => void;
device: string;
}
export const ReportIssueModal: React.FC<ReportIssueModalProps>;
```
- [ ] **Step 1: Write the failing test**
Create `src/panels/topbar/ReportIssueModal.test.tsx`:
```tsx
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('@craftjs/core', () => ({
useEditor: (collect?: (state: any) => any) => {
const state = { events: { selected: new Set<string>() }, nodes: {} };
return {
query: { serialize: () => '{"ROOT":{}}' },
actions: {},
...(collect ? collect(state) : {}),
};
},
}));
vi.mock('../../state/EditorConfigContext', () => ({
useEditorConfig: () => ({
whpConfig: {
apiUrl: '/panel/api/site-builder',
csrfToken: 'tok',
siteId: 42,
siteDomain: 'example.com',
},
isWHP: true,
}),
}));
vi.mock('../../state/PageContext', () => ({
usePages: () => ({
activePageId: 'home',
pages: [{ id: 'home', name: 'Home', slug: 'index', craftState: null }],
}),
}));
import { ReportIssueModal } from './ReportIssueModal';
let container: HTMLDivElement;
let root: Root;
function render(open = true) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
});
}
function typeDescription(text: string) {
const ta = document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement;
act(() => {
ta.value = text;
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
}
function submit() {
const btn = document.querySelector('[data-action="submit-report"]') as HTMLButtonElement;
act(() => { btn.click(); });
}
beforeEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
describe('ReportIssueModal', () => {
test('submit is disabled until a description is entered', () => {
render();
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(true);
typeDescription('something is wrong');
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
});
test('posts the payload to the report_issue action with the CSRF header', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
});
vi.stubGlobal('fetch', fetchMock);
render();
typeDescription('colours do nothing');
await act(async () => { submit(); });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('action=report_issue');
expect(init.method).toBe('POST');
expect(init.headers['X-CSRF-Token']).toBe('tok');
const body = JSON.parse(init.body);
expect(body.description).toBe('colours do nothing');
expect(body.category).toBe('bug');
expect(body.site_id).toBe(42);
expect(body.canvas_state).toBe('{"ROOT":{}}');
});
test('unchecking include-contents omits the canvas state', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, reference: 'SB-2', id: 2 }),
});
vi.stubGlobal('fetch', fetchMock);
render();
typeDescription('no canvas please');
const cb = document.querySelector('[data-testid="report-include-canvas"]') as HTMLInputElement;
act(() => { cb.click(); });
await act(async () => { submit(); });
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.canvas_state).toBeNull();
expect(body.canvas_state_omitted).toBe('opt-out');
});
test('shows the returned reference on success', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
}));
render();
typeDescription('x');
await act(async () => { submit(); });
expect(document.body.textContent).toContain('SB-1234');
});
test('keeps the text and shows an error when the request fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ success: false, error: 'Rate limited' }),
}));
render();
typeDescription('keep me');
await act(async () => { submit(); });
expect(document.body.textContent).toContain('Rate limited');
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe('keep me');
});
});
```
- [ ] **Step 2: Run and verify failure**
Run: `npx vitest run src/panels/topbar/ReportIssueModal.test.tsx`
Expected: FAIL — `Cannot find module './ReportIssueModal'`.
- [ ] **Step 3: Implement the modal**
Create `src/panels/topbar/ReportIssueModal.tsx`:
```tsx
import React, { useState } from 'react';
import { useEditor } from '@craftjs/core';
import { Modal } from '../../ui/Modal';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { usePages } from '../../state/PageContext';
import { buildReportPayload, type ReportCategory } from '../../utils/report-payload';
import { getRecentConsoleErrors } from '../../utils/console-buffer';
import { editorBuild } from '../../utils/build-stamp';
export interface ReportIssueModalProps {
open: boolean;
onClose: () => void;
device: string;
}
const CATEGORIES: { value: ReportCategory; label: string }[] = [
{ value: 'bug', label: 'Something is broken' },
{ value: 'confusing', label: 'Something is confusing' },
{ value: 'feature', label: 'I wish it could…' },
];
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose, device }) => {
const { whpConfig } = useEditorConfig();
const { activePageId, pages } = usePages();
const { query, selectedType } = useEditor((state) => {
const sel = state.events.selected;
const id = sel && sel.size > 0 ? (Array.from(sel)[0] as string) : null;
return { selectedType: id ? (state.nodes[id]?.data.displayName ?? null) : null };
});
const [category, setCategory] = useState<ReportCategory>('bug');
const [description, setDescription] = useState('');
const [includeCanvas, setIncludeCanvas] = useState(true);
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [reference, setReference] = useState('');
const [error, setError] = useState('');
const activePage = pages.find((p) => p.id === activePageId);
const reset = (): void => {
setDescription('');
setStatus('idle');
setReference('');
setError('');
};
const handleSubmit = async (): Promise<void> => {
if (!description.trim() || !whpConfig) return;
setStatus('sending');
setError('');
let canvasState: string | null = null;
try {
canvasState = query.serialize();
} catch {
// A serialize failure must not block the report -- it is often the
// very thing being reported.
canvasState = null;
}
const payload = buildReportPayload({
category,
description,
includeCanvas,
siteId: whpConfig.siteId ?? null,
siteDomain: whpConfig.siteDomain ?? '',
pageId: activePageId,
pageSlug: activePage?.slug ?? '',
editorVersion: editorBuild(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
viewport: typeof window !== 'undefined' ? `${window.innerWidth}x${window.innerHeight}` : '',
deviceMode: device,
selectedType,
consoleErrors: getRecentConsoleErrors(),
canvasState,
});
try {
const resp = await fetch(`${whpConfig.apiUrl}?action=report_issue`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok || !data.success) {
setError(data.error || 'Could not send the report. Please try again.');
setStatus('error');
return;
}
setReference(data.reference || `SB-${data.id}`);
setStatus('sent');
} catch (e) {
setError('Could not reach the server. Your text is still here — try again.');
setStatus('error');
}
};
const handleClose = (): void => {
if (status === 'sent') reset();
onClose();
};
return (
<Modal open={open} onClose={handleClose} width="min(560px, 92vw)">
<div
style={{
background: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Report an issue</div>
<button
onClick={handleClose}
aria-label="Close"
style={{
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
}}
>
<i className="fa fa-times" />
</button>
</div>
{status === 'sent' ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<i className="fa fa-check-circle" style={{ fontSize: 32, color: '#10b981' }} aria-hidden="true" />
<p style={{ fontSize: 14, color: 'var(--color-text)', margin: '12px 0 4px' }}>
Thanks that's been sent.
</p>
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: 0 }}>
Your reference is <strong>{reference}</strong>. Quote it if you open a support ticket.
</p>
<button
onClick={handleClose}
style={{
marginTop: 16, padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
}}
>
Done
</button>
</div>
) : (
<>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
What kind of issue is it?
</label>
<select
data-testid="report-category"
value={category}
onChange={(e) => setCategory(e.target.value as ReportCategory)}
style={{
width: '100%', padding: '6px 8px', fontSize: 12,
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
}}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
What happened?
</label>
<textarea
data-testid="report-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
maxLength={5000}
placeholder="What were you doing, and what did you expect to happen instead?"
style={{
width: '100%', padding: '8px 10px', fontSize: 12, lineHeight: 1.5,
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
resize: 'vertical', boxSizing: 'border-box',
}}
/>
</div>
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', cursor: 'pointer' }}>
<input
data-testid="report-include-canvas"
type="checkbox"
checked={includeCanvas}
onChange={(e) => setIncludeCanvas(e.target.checked)}
style={{ marginTop: 2 }}
/>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Include this page's contents to help debugging. This sends the text and
layout of the page you're editing along with your report. Uncheck it and
we'll still get your description, the page name and your browser details.
</span>
</label>
{status === 'error' && (
<div style={{
fontSize: 11, color: '#fca5a5', background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 4, padding: '8px 10px',
}}>
{error}
</div>
)}
</div>
<div style={{
padding: '10px 16px', borderTop: '1px solid var(--color-border)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>
<button
onClick={handleClose}
style={{
padding: '7px 16px', fontSize: 13,
background: 'var(--color-bg-elevated)', color: 'var(--color-text-muted)',
border: '1px solid var(--color-border)', borderRadius: 6, cursor: 'pointer',
}}
>
Cancel
</button>
<button
data-action="submit-report"
disabled={!description.trim() || status === 'sending'}
onClick={handleSubmit}
style={{
padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6,
cursor: description.trim() && status !== 'sending' ? 'pointer' : 'not-allowed',
opacity: description.trim() && status !== 'sending' ? 1 : 0.5,
}}
>
{status === 'sending' ? 'Sending…' : 'Send report'}
</button>
</div>
</>
)}
</div>
</Modal>
);
};
```
- [ ] **Step 4: Add the entry points**
In `src/panels/topbar/TopBar.tsx`:
1. `import { ReportIssueModal } from './ReportIssueModal';`
2. `const [reportOpen, setReportOpen] = useState(false);`
3. In the desktop `topbar-right` group, next to the Head Code button:
```tsx
<button
className="topbar-btn icon-only"
aria-label="Report an issue"
data-tooltip="Report an issue"
title="Report an issue"
onClick={() => setReportOpen(true)}
>
<i className="fa fa-bug" />
</button>
```
4. Render the modal alongside the existing `TemplateModal`/`HeadCodeModal` in **both** the mobile and desktop return branches:
```tsx
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
```
5. Pass an opener into the overflow menu: add `onOpenReportIssue={() => setReportOpen(true)}` to the `<TopBarOverflowMenu ... />` usage.
In `src/panels/topbar/TopBarOverflowMenu.tsx`, add `onOpenReportIssue: () => void;` to `TopBarOverflowMenuProps`, destructure it, and add the item after Head Code:
```tsx
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenReportIssue)}>
<i className="fa fa-bug" aria-hidden="true" /> Report an issue
</button>
```
`TopBar` already has `device` in scope (it drives the device switcher); pass that same value through.
- [ ] **Step 5: Run tests and typecheck**
Run: `npx vitest run src/panels/topbar/ && npx tsc --noEmit`
Expected: PASS — including the existing `TopBar.test.tsx`.
- [ ] **Step 6: Commit**
```bash
cd /workspace/site-builder
git add craft/src/panels/topbar/
git commit -m "feat(site-builder): add in-builder Report an Issue modal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 20: `site_builder_reports` table
**Files:**
- Create: `/workspace/whp/sql/migrations/staging/create-site-builder-reports.sql`
- Modify: `/workspace/whp/DOCS_FOR_AGENTS/DATABASE_SCHEMA.md`
**Interfaces:**
- Produces: table `whp.site_builder_reports`, consumed by Tasks 21 and 22.
- [ ] **Step 1: Read the migration skill first**
The canonical schema is GENERATED and the build aborts on drift. Follow `/workspace/whp/.claude/skills/whp-add-migration/SKILL.md` exactly — in particular the regeneration step. Do not hand-edit `sql/complete-database-setup.sql`.
- [ ] **Step 2: Write the migration**
Create `/workspace/whp/sql/migrations/staging/create-site-builder-reports.sql`:
```sql
-- Issue reports filed from inside the site builder.
-- Idempotent: safe to re-run (see feedback_idempotent_migrations).
CREATE TABLE IF NOT EXISTS site_builder_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
username VARCHAR(64) NOT NULL,
site_id INT NULL,
site_domain VARCHAR(255) NULL,
page_slug VARCHAR(255) NULL,
category ENUM('bug','confusing','feature') NOT NULL DEFAULT 'bug',
description TEXT NOT NULL,
editor_version VARCHAR(64) NULL,
user_agent VARCHAR(512) NULL,
viewport VARCHAR(32) NULL,
device_mode VARCHAR(16) NULL,
selected_type VARCHAR(64) NULL,
console_errors JSON NULL,
canvas_state LONGTEXT NULL,
status ENUM('new','triaged','fixed','wontfix') NOT NULL DEFAULT 'new',
admin_notes TEXT NULL,
INDEX idx_status_created (status, created_at),
INDEX idx_user_created (username, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
`AUTO_INCREMENT` and the `PRIMARY KEY` are load-bearing — a table that silently loses them produces `id=0` collisions. Any future migration touching this table must preserve both.
- [ ] **Step 3: Regenerate the canonical schema**
Run whatever `regenerate-canonical-schema.sh` invocation the `whp-add-migration` skill specifies. Confirm the generated schema now contains `site_builder_reports`.
- [ ] **Step 4: Document the table**
Add a `site_builder_reports` section to `DOCS_FOR_AGENTS/DATABASE_SCHEMA.md` in the same format as its neighbours: column list, types, and one line on purpose.
- [ ] **Step 5: Commit**
```bash
cd /workspace/whp
git add sql/ DOCS_FOR_AGENTS/DATABASE_SCHEMA.md
git commit -m "feat(whp): add site_builder_reports table
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 21: `report_issue` API endpoint
**Files:**
- Modify: `/workspace/whp/web-files/api/site-builder.php` (dispatch switch + new handler)
- Create: `/workspace/whp/scripts/test-site-builder-report-validation.php`
**Interfaces:**
- Consumes: `getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot)` (existing, throws when the user does not own the site), `validate_csrf_token()` from `auto-prepend.php`.
- Produces:
```php
function handleReportIssue($pdo, $isRoot); // echoes {success, reference, id}
function sbReportValidate(array $data): array; // ['ok'=>bool,'error'=>string,'code'=>int,'clean'=>array]
```
The validator is a separate pure function so Task 21's test script can exercise it without a request.
- [ ] **Step 1: Write the failing validator test**
Create `/workspace/whp/scripts/test-site-builder-report-validation.php`:
```php
#!/usr/bin/env php
<?php
/**
* Unit tests for the site-builder issue-report validator.
*
* Loads ONLY the validator out of api/site-builder.php (which otherwise
* requires a full authenticated request context), following the pattern of
* scripts/test-site-builder-asset-urls.php.
*/
require_once __DIR__ . '/../web-files/libs/SiteBuilderReportValidator.php';
$passed = 0;
$failed = 0;
function check(string $name, bool $cond): void {
global $passed, $failed;
if ($cond) { $passed++; echo " PASS $name\n"; }
else { $failed++; echo " FAIL $name\n"; }
}
function base(): array {
return [
'category' => 'bug',
'description' => 'Colours do nothing on the HTML block',
'site_id' => 42,
'site_domain' => 'example.com',
'page_slug' => 'index',
'editor_version' => 'abc1234-2026-08-08',
'user_agent' => 'Mozilla/5.0',
'viewport' => '1920x1080',
'device_mode' => 'desktop',
'selected_type' => 'HTML',
'console_errors' => [['ts' => 1, 'message' => 'oops']],
'canvas_state' => '{"ROOT":{}}',
];
}
echo "site-builder report validator\n";
$r = sbReportValidate(base());
check('a well-formed report validates', $r['ok'] === true);
check('description is trimmed', $r['clean']['description'] === 'Colours do nothing on the HTML block');
$r = sbReportValidate(array_merge(base(), ['category' => 'nonsense']));
check('an unknown category is rejected', $r['ok'] === false && $r['code'] === 400);
$r = sbReportValidate(array_merge(base(), ['description' => ' ']));
check('a blank description is rejected', $r['ok'] === false && $r['code'] === 400);
$r = sbReportValidate(array_merge(base(), ['description' => str_repeat('x', 5001)]));
check('a 5001-character description is rejected', $r['ok'] === false && $r['code'] === 400);
$r = sbReportValidate(array_merge(base(), ['description' => str_repeat('x', 5000)]));
check('a 5000-character description is accepted', $r['ok'] === true);
$r = sbReportValidate(array_merge(base(), ['canvas_state' => str_repeat('x', 512 * 1024 + 1)]));
check('an oversized canvas_state is rejected, not truncated', $r['ok'] === false && $r['code'] === 413);
$r = sbReportValidate(array_merge(base(), ['canvas_state' => null]));
check('a null canvas_state is fine', $r['ok'] === true && $r['clean']['canvas_state'] === null);
$r = sbReportValidate(array_merge(base(), ['console_errors' => 'not an array']));
check('a non-array console_errors is coerced to an empty list', $r['ok'] === true && $r['clean']['console_errors'] === '[]');
$r = sbReportValidate(array_merge(base(), ['user_agent' => str_repeat('u', 900)]));
check('an overlong user_agent is truncated to the column width', strlen($r['clean']['user_agent']) === 512);
$long = str_repeat('t', 100);
$r = sbReportValidate(array_merge(base(), ['selected_type' => $long]));
check('an overlong selected_type is truncated to 64', strlen($r['clean']['selected_type']) === 64);
echo "\n$passed passed, $failed failed\n";
exit($failed === 0 ? 0 : 1);
```
- [ ] **Step 2: Run and verify failure**
Run: `php /workspace/whp/scripts/test-site-builder-report-validation.php`
Expected: FAIL — `SiteBuilderReportValidator.php` does not exist.
- [ ] **Step 3: Write the validator**
Create `/workspace/whp/web-files/libs/SiteBuilderReportValidator.php`:
```php
<?php
/**
* Validation for site-builder issue reports.
*
* Kept in its own file (rather than inline in api/site-builder.php) so it can
* be unit-tested by scripts/test-site-builder-report-validation.php without a
* request context, the same way SiteBuilderAssetUrls.php is.
*
* Oversized input is REJECTED, never truncated: a half-written craft state
* looks like a valid tree that lost nodes, which is worse than no state.
*/
const SB_REPORT_MAX_DESCRIPTION = 5000;
const SB_REPORT_MAX_CANVAS_BYTES = 524288; // 512 KB, matches the editor's cap
const SB_REPORT_CATEGORIES = ['bug', 'confusing', 'feature'];
function sbReportTruncate(?string $value, int $max): ?string {
if ($value === null) return null;
return strlen($value) > $max ? substr($value, 0, $max) : $value;
}
/**
* @return array{ok:bool,error:string,code:int,clean:array}
*/
function sbReportValidate(array $data): array {
$fail = function (string $error, int $code): array {
return ['ok' => false, 'error' => $error, 'code' => $code, 'clean' => []];
};
$category = isset($data['category']) ? (string)$data['category'] : '';
if (!in_array($category, SB_REPORT_CATEGORIES, true)) {
return $fail('Unknown report category', 400);
}
$description = isset($data['description']) ? trim((string)$data['description']) : '';
if ($description === '') {
return $fail('A description is required', 400);
}
if (strlen($description) > SB_REPORT_MAX_DESCRIPTION) {
return $fail('Description is too long (5000 characters maximum)', 400);
}
$canvasState = isset($data['canvas_state']) && $data['canvas_state'] !== null
? (string)$data['canvas_state']
: null;
if ($canvasState !== null && strlen($canvasState) > SB_REPORT_MAX_CANVAS_BYTES) {
return $fail('Page contents too large to attach', 413);
}
$consoleErrors = isset($data['console_errors']) && is_array($data['console_errors'])
? $data['console_errors']
: [];
return [
'ok' => true,
'error' => '',
'code' => 200,
'clean' => [
'category' => $category,
'description' => $description,
'site_id' => isset($data['site_id']) && $data['site_id'] !== null ? (int)$data['site_id'] : null,
'site_domain' => sbReportTruncate(isset($data['site_domain']) ? (string)$data['site_domain'] : null, 255),
'page_slug' => sbReportTruncate(isset($data['page_slug']) ? (string)$data['page_slug'] : null, 255),
'editor_version' => sbReportTruncate(isset($data['editor_version']) ? (string)$data['editor_version'] : null, 64),
'user_agent' => sbReportTruncate(isset($data['user_agent']) ? (string)$data['user_agent'] : null, 512),
'viewport' => sbReportTruncate(isset($data['viewport']) ? (string)$data['viewport'] : null, 32),
'device_mode' => sbReportTruncate(isset($data['device_mode']) ? (string)$data['device_mode'] : null, 16),
'selected_type' => sbReportTruncate(isset($data['selected_type']) ? (string)$data['selected_type'] : null, 64),
'console_errors' => json_encode($consoleErrors),
'canvas_state' => $canvasState,
],
];
}
```
- [ ] **Step 4: Run the validator test and verify it passes**
Run: `php /workspace/whp/scripts/test-site-builder-report-validation.php`
Expected: `11 passed, 0 failed`, exit 0.
- [ ] **Step 5: Add the endpoint**
In `/workspace/whp/web-files/api/site-builder.php`:
1. Next to the other `require_once` lines at the top: `require_once __DIR__ . '/../libs/SiteBuilderReportValidator.php';`
2. Add to the dispatch `switch ($action)`, before `default:`:
```php
case 'report_issue':
handleReportIssue($pdo, $isRoot);
break;
```
3. Extend the `default:` branch's supported-actions string with `report_issue`.
4. Add the handler alongside the others:
```php
/**
* Store an issue report filed from inside the site builder.
*
* Session auth is already enforced at the top of this file. This handler adds
* CSRF validation (a new state-changing POST shouldn't wait on the fleet-wide
* rollout), site ownership via getSiteDocRoot(), and a per-user rate limit
* counted off the reports table itself -- no extra store to keep in sync.
*/
function handleReportIssue($pdo, $isRoot) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new Exception('POST required', 405);
}
if (function_exists('validate_csrf_token') && !validate_csrf_token()) {
throw new Exception('Invalid CSRF token', 403);
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
throw new Exception('Invalid JSON body', 400);
}
$result = sbReportValidate($data);
if (!$result['ok']) {
throw new Exception($result['error'], $result['code']);
}
$clean = $result['clean'];
// Ownership: a non-root user may only file against a site they own.
// getSiteDocRoot throws when they don't -- reuse it rather than
// re-implementing the lookup.
if (!empty($clean['site_id'])) {
getSiteDocRoot($pdo, $clean['site_id'], AUTH_USER, $isRoot);
}
// Rate limit: 5 per user per hour.
$limitStmt = $pdo->prepare(
'SELECT COUNT(*) FROM whp.site_builder_reports
WHERE username = ? AND created_at > (NOW() - INTERVAL 1 HOUR)'
);
$limitStmt->execute([AUTH_USER]);
if ((int)$limitStmt->fetchColumn() >= 5) {
throw new Exception('Too many reports in the last hour — please try again later.', 429);
}
$stmt = $pdo->prepare(
'INSERT INTO whp.site_builder_reports
(username, site_id, site_domain, page_slug, category, description,
editor_version, user_agent, viewport, device_mode, selected_type,
console_errors, canvas_state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
AUTH_USER, // never taken from the client
$clean['site_id'],
$clean['site_domain'],
$clean['page_slug'],
$clean['category'],
$clean['description'],
$clean['editor_version'],
$clean['user_agent'],
$clean['viewport'],
$clean['device_mode'],
$clean['selected_type'],
$clean['console_errors'],
$clean['canvas_state'],
]);
$id = (int)$pdo->lastInsertId();
echo json_encode(['success' => true, 'id' => $id, 'reference' => 'SB-' . $id]);
}
```
- [ ] **Step 6: Lint the PHP**
Run: `php -l /workspace/whp/web-files/api/site-builder.php && php -l /workspace/whp/web-files/libs/SiteBuilderReportValidator.php`
Expected: `No syntax errors detected` for both.
- [ ] **Step 7: Commit**
```bash
cd /workspace/whp
git add web-files/api/site-builder.php web-files/libs/SiteBuilderReportValidator.php scripts/test-site-builder-report-validation.php
git commit -m "feat(whp): add report_issue endpoint for site-builder issue reports
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
### Task 22: Root-only reports admin page
**Files:**
- Create: `/workspace/whp/web-files/pages/site-builder-reports.php`
- Modify: `/workspace/whp/web-files/index.php` (`$allowed_pages`, line ~126)
- Modify: `/workspace/whp/web-files/libs/permission_manager.php` (`$page_permissions`)
- Modify: the sidebar include under `/workspace/whp/web-files/includes/`
**Interfaces:**
- Consumes: table `site_builder_reports` (Task 20).
- Produces: page key `site-builder-reports`.
- [ ] **Step 1: Follow the registration skill**
Read `/workspace/whp/.claude/skills/whp-admin-page-registration/SKILL.md` and do every step it lists. A page missing from `$allowed_pages` 404s; a page missing from `$page_permissions` is reachable by non-root users. Both must be done.
- [ ] **Step 2: Build the page**
Create `/workspace/whp/web-files/pages/site-builder-reports.php`. Requirements, in the house style of a neighbouring page (copy the layout/table markup from an existing list page rather than inventing new patterns):
1. **Root gate at the top**, before any output:
```php
<?php
if (AUTH_USER !== 'root') {
http_response_code(403);
echo '<div class="alert alert-danger">Not authorised.</div>';
return;
}
```
2. **Status/category filters** read from `$_GET`, allow-listed against the same enum values as the table — never interpolated into SQL:
```php
$validStatus = ['new', 'triaged', 'fixed', 'wontfix'];
$validCategory = ['bug', 'confusing', 'feature'];
$filterStatus = in_array($_GET['status'] ?? '', $validStatus, true) ? $_GET['status'] : '';
$filterCategory = in_array($_GET['category'] ?? '', $validCategory, true) ? $_GET['category'] : '';
```
3. **List query** — parameterised, newest first, and **never selecting `canvas_state`** in the list (it is up to 512 KB per row):
```php
$sql = 'SELECT id, created_at, username, site_domain, page_slug, category, status,
LEFT(description, 120) AS excerpt
FROM whp.site_builder_reports WHERE 1=1';
$params = [];
if ($filterStatus !== '') { $sql .= ' AND status = ?'; $params[] = $filterStatus; }
if ($filterCategory !== '') { $sql .= ' AND category = ?'; $params[] = $filterCategory; }
$sql .= ' ORDER BY created_at DESC LIMIT 200';
```
State in the page footer that the list is capped at 200 rows, so a truncated view never reads as "that's all of them".
4. **Detail view** when `?id=N` is present: full description and every context field, `canvas_state` shown collapsed (a `<details>` block), everything escaped with `htmlspecialchars()`. Report text is user-submitted — treat it as hostile.
5. **Status + notes update** via POST, CSRF-validated with `validate_csrf_token()`, status allow-listed against `$validStatus`.
6. **"Copy for Claude" button** on the detail view, emitting one compact JSON object to the clipboard:
```php
$claudeBlob = json_encode([
'reference' => 'SB-' . $row['id'],
'created_at' => $row['created_at'],
'category' => $row['category'],
'description' => $row['description'],
'site_domain' => $row['site_domain'],
'page_slug' => $row['page_slug'],
'editor_version' => $row['editor_version'],
'device_mode' => $row['device_mode'],
'viewport' => $row['viewport'],
'selected_type' => $row['selected_type'],
'user_agent' => $row['user_agent'],
'console_errors' => json_decode($row['console_errors'] ?? '[]', true),
'canvas_state' => $row['canvas_state'],
], JSON_UNESCAPED_SLASHES);
```
Render it into a `data-` attribute (escaped) and copy it with `navigator.clipboard.writeText()`. This is the button that makes the whole feature worth having — do not skip it.
- [ ] **Step 3: Lint and verify registration**
```bash
php -l /workspace/whp/web-files/pages/site-builder-reports.php
grep -n "site-builder-reports" /workspace/whp/web-files/index.php \
/workspace/whp/web-files/libs/permission_manager.php \
/workspace/whp/web-files/includes/*.php
```
Expected: no syntax errors, and a hit in **all three** of `index.php`, `permission_manager.php` and the sidebar include.
- [ ] **Step 4: Commit**
```bash
cd /workspace/whp
git add web-files/pages/site-builder-reports.php web-files/index.php web-files/libs/permission_manager.php web-files/includes/
git commit -m "feat(whp): add root-only site-builder issue reports page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
---
## Phase 5 — Ship
### Task 23: Build, bundle, and verify on the canary
**Files:**
- Modify: `/workspace/whp/web-files/site-builder/` (built bundle)
**Interfaces:**
- Consumes: everything above.
- Produces: a deployed, verified build.
- [ ] **Step 1: Full green suite before building anything**
```bash
cd /workspace/site-builder/craft
npx tsc --noEmit && npx vitest run
php /workspace/whp/scripts/test-site-builder-report-validation.php
```
Expected: zero type errors, zero failing tests, `0 failed` from the PHP script. **Do not proceed on a red suite.**
- [ ] **Step 2: Build and copy every chunk**
```bash
cd /workspace/site-builder/craft && npm run build
ls dist/js/
```
Then follow **`whp-deploy` Step 1a** for the copy. The trap it exists to prevent: the code editor lazy-loads CodeMirror into `js/index*.js` chunks, so copying only `editor.js` leaves the Edit HTML modal silently degraded to a plain `<textarea>` — which would quietly undo half of Task 5 and Task 6. Copy `dist/index.html`, `dist/css/editor.css`, and **all** of `dist/js/*.js`.
- [ ] **Step 3: Commit both repos**
```bash
cd /workspace/site-builder && git add -A && git commit -m "build(site-builder): rebuild editor bundle
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
git add web-files/site-builder && git commit -m "chore(whp): ship rebuilt site-builder bundle
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 4: Build the release and deploy to the canary only**
Follow the `whp-deploy` skill. Deploy to **192.168.1.148 first and stop there** — do not touch whp02/sdbees/whp01 until Step 5 passes.
- [ ] **Step 5: Verify on the canary**
```bash
# CodeMirror chunks reachable -- 200, not 404 (404 => editor silently degraded)
for f in $(ls /workspace/site-builder/craft/dist/js/); do
printf '%s ' "$f"
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8080/site-builder/js/$f"
done
# The table exists
mysql whp -e "DESCRIBE whp.site_builder_reports" | head -5
```
Expected: every chunk `200`; the table describes with `id` as `PRI` and `auto_increment`.
Then, in the editor at `<canary>:8443/site-builder/?site_id=N` on a **throwaway test site** — never a customer site:
1. Drop an HTML block; confirm the right panel shows **only** Edit HTML with no colour swatches.
2. Open Edit HTML; confirm line numbers and syntax colouring (CodeMirror, not the textarea fallback), that typing `<di` offers a completion, and that the toolbar's insert/colour/Format buttons all work.
3. Add a Features Grid; confirm its features appear as rows under it in **Layers**, and that clicking one selects the grid and scrolls that feature's card into view.
4. Repeat Task 13 Step 2's drop attempts; confirm no **Unplaced** group ever appears.
5. Reset a page; confirm it blanks and that Ctrl+Z restores it (if Task 14 Step 2 established undo works).
6. File a report. Confirm the `SB-####` reference appears, the row lands in `whp.site_builder_reports`, and it renders on `?page=site-builder-reports` including the Copy for Claude button.
7. File six reports in a row; confirm the sixth is refused with the rate-limit message.
- [ ] **Step 6: Roll out to production**
Only after every check in Step 5 passes: whp02 → sdbees → whp01, verifying each host before starting the next. Published customer sites are static snapshots and none of this changes published output, so **no site needs re-publishing**.
- [ ] **Step 7: Report honestly**
Write up what shipped and what did not. In particular, if Task 13 Step 3 failed to reproduce the off-canvas drop, say so explicitly rather than implying prevention landed. Repair and recovery shipping without prevention is a legitimate outcome; misreporting it is not.
---
## Self-Review
**Spec coverage:**
| Spec section | Task(s) |
|---|---|
| 1a Prevention (drop time) | 13 |
| 1b Repair (load time) | 7, 8 |
| 1c Recovery (Unplaced group) | 11 |
| 2a Only the Edit HTML control | 2, 3 |
| 2b Render must match export | 1 |
| 2c Smarter HTML editor (handle, toolbar, colour, format) | 4, 5, 6 |
| 2c Verify chunks load in prod | 23 (Step 5) |
| 3a Reset Page | 14 |
| 3b Reset Entire Site | 15 |
| 4a Virtual rows registry + derivation | 9, 11 |
| 4b Selecting a virtual row scrolls the array card | 10, 12 |
| 4c Unplaced group | 11 |
| 4d Panel scrolling | 11 |
| 5a Editor UI (modal, reference, failure keeps text) | 19 |
| 5b Captured context, build stamp, ring buffer, 512 KB cap | 16, 17, 18 |
| 5c Endpoint (CSRF, ownership, rate limit, validation) | 21 |
| 5d Schema | 20 |
| 5e Admin page + Copy for Claude | 22 |
| Testing | every task; PHP validator in 21; manual matrix in 23 |
| Deployment | 23 |
No spec requirement is unassigned.
**Naming consistency across tasks:** `repairOrphanNodes` / `findUnreachableNodeIds` (7 → 8, 11, 13); `EMPTY_CANVAS` (8 → 14, 15); `deriveVirtualRows` / `VIRTUAL_CHILD_PROPS` (9 → 11); `useLayerFocus` / `requestFocus` / `focus.nonce` (10 → 11, 12); `CodeEditorHandle.insertAtCursor(text, caretOffset?)` (5 → 6); `formatHtml` (4 → 6); `buildReportPayload` / `MAX_PAYLOAD_BYTES` (18 → 19); `getRecentConsoleErrors` (16 → 19); `editorBuild()` (17 → 19); `sbReportValidate` (21 → its own test). All consistent.
**Known soft spots, called out rather than papered over:**
- **Task 9's registry** lists prop and label names taken from a grep of the components, not from reading each `craft.props` default in full. Step 1 of that task re-verifies them; the component wins any disagreement.
- **Task 13** may not reproduce. Its Step 3 says so and routes to an honest report rather than a speculative fix.
- **Task 14's undo claim** is conditional on Craft's `deserialize` entering history. Step 2 checks it and Step 3 removes the dialog copy if it does not.
- **Task 12** ships without a unit test because jsdom does not implement `scrollIntoView`; it is covered by Task 23's manual matrix.