23 TDD tasks across five phases: HTML block (render/export mismatch, code-only panel, editor toolbar), tree integrity (orphan repair + Unplaced recovery), reset page/site, Layers virtual rows for array-prop composites, and in-builder issue reporting (endpoint, table, root-only admin page). Off-canvas drop prevention is scoped as an investigation whose acceptance criterion is a written mechanism, not a guessed fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
152 KiB
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.tsdefines noresolve.alias. Use relative imports in all source and test files. - No
@testing-library/react. This repo renders withcreateRoot+actfromreact-dom/test-utils. For a component needing onlyuseNode, mock@craftjs/core(pattern:src/components/media/ImageBlock.render.test.tsx). For anything needing a real editor, userenderEditorHarness()fromsrc/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
renderand the static.toHtml(props, childrenHtml).HtmlBlockis 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 emptyopen_basedir. - SQL migrations are idempotent (
CREATE TABLE IF NOT EXISTS), go insql/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:
HtmlBlockrender no longer readsprops.style.HtmlBlockPropskeeps itsstyle?: CSSPropertiesfield so stored state stays loadable. -
Step 1: Write the failing render test
Create src/components/basic/HtmlBlock.render.test.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:
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:
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
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 aCollapsibleSectiontitled "HTML Code" containing an "Edit HTML" button that opens aModalwith aCodeEditor. -
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:
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 23–92 (the comment block and the local HtmlCodeField), drop the now-unused useState, Modal and CodeEditor imports, and add:
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
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:79and:161-167
Interfaces:
-
Consumes:
HtmlCodeField(Task 2),useNodePropfrom./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:
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:
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:
export { HtmlStylePanel } from './HtmlStylePanel';
In src/panels/right/GuidedStyles.tsx:
- Add
HtmlStylePanelto the import list from'./styles'. - Change the utility classifier (line 79) so HTML no longer matches it, and add an HTML classifier:
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);
- Give it an icon in the badge chain, before
isUtility:
: isHtml ? 'fa-code'
: isUtility ? 'fa-ellipsis-h'
- Render it, immediately above the UTILITY block:
{/* HTML -- code editor only */}
{isHtml && <HtmlStylePanel selectedId={selected} nodeProps={nodeProps} />}
- Add
&& !isHtmlto the FALLBACK condition so an HTML block never renders two panels:
{!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
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:
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:
/**
* 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
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 — addforwardRef) - Test:
src/ui/CodeEditor.test.tsx(extend)
Interfaces:
- Produces:
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:
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:
- Extend the React import:
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; - Add the handle interface next to
CodeEditorProps:
export interface CodeEditorHandle {
insertAtCursor(text: string, caretOffset?: number): void;
getValue(): string;
}
- Add a textarea ref alongside the existing refs:
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
- Change the declaration line from
export const CodeEditor: React.FC<CodeEditorProps> = ({to:
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:
}: CodeEditorProps, ref) {
- Register the handle just before the
showFallbackline:
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]);
- 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
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 }>andexport 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:
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:
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:
- Extend imports:
import React, { useRef, useState } from 'react';
import { CodeEditor, type CodeEditorHandle } from '../../../ui/CodeEditor';
import { HtmlToolbar } from './HtmlToolbar';
import { formatHtml } from '../../../utils/format-html';
- Inside the component, above the
return:
const editorRef = useRef<CodeEditorHandle>(null);
const handleFormat = (): void => {
const current = editorRef.current?.getValue() ?? value;
onChange(formatHtml(current));
};
- Replace the modal body
<div style={{ padding: 16 }}>…</div>with:
<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
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:
/** 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:
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:
/**
* 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
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(exportEMPTY_CANVAS) and:372-388(loadState) - Test:
src/state/PageContext.orphan-repair.test.ts(create)
Interfaces:
-
Consumes:
repairOrphanNodes(Task 7). -
Produces:
export const EMPTY_CANVASfromPageContext.tsx— consumed by Tasks 12 and 13. -
Step 1: Write the failing integration test
Create src/state/PageContext.orphan-repair.test.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_CANVASand repair insideloadState
In src/state/PageContext.tsx:
- Change
const EMPTY_CANVAS =(line 84) toexport const EMPTY_CANVAS =. - Add the import at the top:
import { repairOrphanNodes } from '../utils/orphan-repair'; - Replace the
loadStatebody:
/** 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
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:
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:
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:
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:
/**
* 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
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:
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:
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:
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
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:
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:
- Extend the imports:
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
import { useLayerFocus } from './LayerFocusContext';
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
- Add this component above
LayerNode:
/**
* 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">▪</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
</div>
);
- Inside
LayerNode, afterconst allChildren = [...], add:
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
-
Change the disclosure-triangle condition from
allChildren.length > 0toallChildren.length + virtualRows.length > 0(both the visible and the transparent branch use the same condition). -
Render the virtual rows immediately before the real-children map:
{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);
}}
/>
))}
- Pull
requestFocusfrom the context at the top ofLayerNode:
const { requestFocus } = useLayerFocus();
- Step 4: Add the Unplaced group and a scroll container
Replace the LayersPanel component at the bottom of the file:
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:
/* 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
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:
<div data-array-item={`${propKey}:${index}`} ...existing props>
- Step 2: Scroll on request
Add to each editor component body:
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
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.tsxand/orsrc/styles/editor.css; possiblysrc/App.tsxif a customDefaultEventHandlerssubclass 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
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:
- The grey gutter to the left/right of the device frame.
- The header
ZonePreviewband at the top (pointer-events: none). - The footer
ZonePreviewband at the bottom. - Below the footer band, inside
.editor-canvasbut past all content. - Over the left or right panel, then back and release outside the frame.
After each drop, in the browser console:
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:
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:
- CSS/hit-area — if the wrapper
<div style={{ position: 'relative' }}>around<Frame>inCanvas.tsx:220or the.editor-canvaspadding is absorbing drops, constrain it so only real Craft nodes are droppable. - Reject at the handler — if Craft's retained last-valid indicator is the cause, pass a
DefaultEventHandlerssubclass to<Editor handlers={...}>insrc/App.tsxthat 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
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:
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:
- Extend the imports:
import { useEditor } from '@craftjs/core';
import { EMPTY_CANVAS } from '../../state/PageContext';
- In the component body, alongside the existing
deleteConfirmIdstate:
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);
};
- Add the button to the per-page action row, next to the existing delete button (
fa-trash):
<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.
- Add the inline confirmation, mirroring the existing
deleteConfirmIdblock:
{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
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 existingresetToDefaults;useEditorConfig()forsiteDomain. -
Produces: no new exports.
-
Step 1: Confirm the context method signatures
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:
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:
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:
<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:
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
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:
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:
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:
/**
* 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:
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
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:
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:
define: {
__EDITOR_BUILD__: JSON.stringify(editorBuild),
},
- Step 2: Declare the global
In src/vite-env.d.ts (create if it does not exist):
/// <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:
/**
* 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:
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
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:
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:
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:
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
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:
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:
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:
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:
import { ReportIssueModal } from './ReportIssueModal';const [reportOpen, setReportOpen] = useState(false);- In the desktop
topbar-rightgroup, next to the Head Code button:
<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>
- Render the modal alongside the existing
TemplateModal/HeadCodeModalin both the mobile and desktop return branches:
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
- 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:
<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
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:
-- 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
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()fromauto-prepend.php. - Produces:
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:
#!/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
/**
* 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:
- Next to the other
require_oncelines at the top:require_once __DIR__ . '/../libs/SiteBuilderReportValidator.php'; - Add to the dispatch
switch ($action), beforedefault::
case 'report_issue':
handleReportIssue($pdo, $isRoot);
break;
- Extend the
default:branch's supported-actions string withreport_issue. - Add the handler alongside the others:
/**
* 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
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):
- Root gate at the top, before any output:
<?php
if (AUTH_USER !== 'root') {
http_response_code(403);
echo '<div class="alert alert-danger">Not authorised.</div>';
return;
}
- Status/category filters read from
$_GET, allow-listed against the same enum values as the table — never interpolated into SQL:
$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'] : '';
- List query — parameterised, newest first, and never selecting
canvas_statein the list (it is up to 512 KB per row):
$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".
-
Detail view when
?id=Nis present: full description and every context field,canvas_stateshown collapsed (a<details>block), everything escaped withhtmlspecialchars(). Report text is user-submitted — treat it as hostile. -
Status + notes update via POST, CSRF-validated with
validate_csrf_token(), status allow-listed against$validStatus. -
"Copy for Claude" button on the detail view, emitting one compact JSON object to the clipboard:
$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
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
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
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
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
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
# 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:
- Drop an HTML block; confirm the right panel shows only Edit HTML with no colour swatches.
- Open Edit HTML; confirm line numbers and syntax colouring (CodeMirror, not the textarea fallback), that typing
<dioffers a completion, and that the toolbar's insert/colour/Format buttons all work. - 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.
- Repeat Task 13 Step 2's drop attempts; confirm no Unplaced group ever appears.
- Reset a page; confirm it blanks and that Ctrl+Z restores it (if Task 14 Step 2 established undo works).
- File a report. Confirm the
SB-####reference appears, the row lands inwhp.site_builder_reports, and it renders on?page=site-builder-reportsincluding the Copy for Claude button. - 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.propsdefault 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
deserializeentering 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.