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