feat(site-builder): add domain-confirmed Reset Entire Site

Adds a danger-zone escape hatch below SiteDesignPanel's existing "Reset to
Defaults": blanks every page down to one empty Home, blanks the header and
footer, and resets all design tokens. Guarded by typing the exact site
domain to arm the confirm button.

The brief's setHeader('')/setFooter('') calls were wrong -- both take a
SerializedTreeNode (same tree shape as replaceAllPages), not a craft-state
string; passing '' would have hit treeToCraftState's sanitizeAiTree and
silently fallen back to a generic div-shaped empty canvas instead of a
proper header/footer. Built BLANK_HEADER_TREE/BLANK_FOOTER_TREE (tagged
header/footer) alongside the brief's page tree, and dropped the brief's
literal's extraneous flat-state fields (isCanvas/displayName/custom/hidden/
linkedNodes) that made it need an `as any` cast -- sanitizeAiTree/
flattenTreeForCraft only ever read type/props/nodes.

In standalone mode (no WHP_CONFIG) siteDomain is '', so the entire danger
zone -- not just the button -- is hidden behind `siteDomain &&`, closing off
the empty-string-trivially-matches guard bypass.

Dialog copy states the reset is undoable... is NOT undoable, and that
auto-save (confirmed exactly 30s via TopBar.tsx's setInterval) turns the
blank canvas into the saved draft shortly after, so no false safety-net
claim is made.

Verified load-bearing: reverted the implementation via git stash and
confirmed 3 of 4 new tests fail (the 4th, an absence-only standalone-mode
check, passed vacuously on first draft -- rewritten into a same-test
contrast against the non-standalone case, which does fail on revert).
Split into a second mock-free integration test file
(SiteDesignPanel.reset.integration.test.tsx) after vi.mock's file-scoped
hoisting made a same-file vi.doUnmock silently keep using the mocks --
it exercises the real PageProvider/SiteDesignProvider/treeToCraftState
pipeline end to end via the real editorHarness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:17:53 -07:00
co-authored by Claude Opus 5
parent bfcf6278e9
commit 1a01834068
3 changed files with 368 additions and 0 deletions
@@ -0,0 +1,97 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
import { PageProvider, usePages } from '../../state/PageContext';
import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { SiteDesignPanel } from './SiteDesignPanel';
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
* a plain `input.value = x` assignment doesn't go through it, so React never
* sees the change and skips onChange. */
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Real-provider integration test -- NO mocks. Exercises the actual
* `replaceAllPages`/`setHeader`/`setFooter`/`resetToDefaults` implementations,
* which route the component's blank trees through the real
* `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft` pipeline (see
* `state/PageContext.tsx` and `utils/craft-tree.ts`). This is the check the
* task brief calls out specifically: a tree shape that fails sanitisation
* would silently fall back to an empty canvas (masking a bug) rather than
* throwing, so asserting only on mock call arguments (as
* `SiteDesignPanel.reset.test.tsx` does) would not catch a bad tree shape --
* the mocked assertions there only prove the component PASSED a
* `{type:{resolvedName:'Container'}, nodes:[]}`-shaped object; they can't
* prove that object is actually valid input to the real pipeline.
*/
describe('Reset Entire Site -- real PageContext/SiteDesignContext integration', () => {
test('confirming produces a real, valid single blank Home page + header + footer + default design', () => {
let pageCtx: ReturnType<typeof usePages> | null = null;
let designCtx: ReturnType<typeof useSiteDesign> | null = null;
const Probe: React.FC = () => {
pageCtx = usePages();
designCtx = useSiteDesign();
return null;
};
const harness = renderEditorHarness();
harness.mountChild(
<EditorConfigProvider config={{
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
}}>
<PageProvider>
<SiteDesignProvider>
<Probe />
<SiteDesignPanel />
</SiteDesignProvider>
</PageProvider>
</EditorConfigProvider>,
);
// Dirty the design tokens first so resetToDefaults has something to undo.
harness.act(() => { designCtx!.updateDesign({ primaryColor: '#000000' }); });
expect(designCtx!.design.primaryColor).toBe('#000000');
harness.act(() => {
(harness.container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click();
});
const input = harness.container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
harness.act(() => { setInputValue(input, 'example.com'); });
harness.act(() => {
(harness.container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click();
});
// Exactly one page, "Home", landing-page slug -- and its stored
// craftState is real, parseable Craft.js state with a Container ROOT
// and no children (i.e. sanitizeAiTree did NOT reject the tree and fall
// back to a silent empty canvas for the wrong reason -- it's genuinely
// ROOT with zero nodes because that's what we asked for).
expect(pageCtx!.pages).toHaveLength(1);
expect(pageCtx!.pages[0].name).toBe('Home');
expect(pageCtx!.pages[0].slug).toBe('index');
const homeState = JSON.parse(pageCtx!.pages[0].craftState!);
expect(homeState.ROOT.type.resolvedName).toBe('Container');
expect(homeState.ROOT.nodes).toEqual([]);
const headerState = JSON.parse(pageCtx!.headerPage.craftState!);
expect(headerState.ROOT.type.resolvedName).toBe('Container');
expect(headerState.ROOT.nodes).toEqual([]);
expect(headerState.ROOT.props.tag).toBe('header');
const footerState = JSON.parse(pageCtx!.footerPage.craftState!);
expect(footerState.ROOT.type.resolvedName).toBe('Container');
expect(footerState.ROOT.nodes).toEqual([]);
expect(footerState.ROOT.props.tag).toBe('footer');
// Design tokens are back to the defaults.
expect(designCtx!.design).toEqual(DEFAULT_SITE_DESIGN);
harness.unmount();
});
});
@@ -0,0 +1,138 @@
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';
/* -------------------------------------------------------------------------
* Mocked-hook tests -- pure UI-logic coverage: the domain-match guard, that
* confirming calls the right context functions with tree-shaped arguments,
* and that standalone mode (empty siteDomain) hides the entry point
* entirely. The real `replaceAllPages`/`setHeader`/`setFooter`/
* `resetToDefaults` implementations (and whether the blank trees this
* component builds actually survive `treeToCraftState`/`sanitizeAiTree`) are
* covered separately, WITHOUT mocks, in
* `SiteDesignPanel.reset.integration.test.tsx` -- `vi.mock` is hoisted and
* file-scoped, so it can't be selectively "undone" partway through one file
* for a real-provider test.
* ---------------------------------------------------------------------- */
const replaceAllPages = vi.fn();
const setHeader = vi.fn();
const setFooter = vi.fn();
const resetToDefaults = vi.fn();
let mockSiteDomain = 'example.com';
vi.mock('../../state/PageContext', () => ({
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }),
}));
vi.mock('../../state/SiteDesignContext', () => ({
useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }),
DEFAULT_SITE_DESIGN: {},
}));
vi.mock('../../state/EditorConfigContext', () => ({
useEditorConfig: () => ({ whpConfig: mockSiteDomain ? { siteDomain: mockSiteDomain } : null, isWHP: !!mockSiteDomain }),
}));
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 />);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
* a plain `input.value = x` assignment doesn't go through it, so React never
* sees the change and skips onChange. Using the real native setter (same
* pattern as `MediaStylePanel.slides.test.tsx`) makes the subsequent
* `input` event register as a genuine value change. */
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => {
test('the confirm button is disabled until the domain is typed exactly', () => {
mockSiteDomain = 'example.com';
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(() => { setInputValue(input, 'example.co'); });
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(true);
act(() => { setInputValue(input, 'example.com'); });
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(false);
unmount();
});
test('confirming blanks pages, header, footer and design tokens', () => {
mockSiteDomain = 'example.com';
replaceAllPages.mockClear();
setHeader.mockClear();
setFooter.mockClear();
resetToDefaults.mockClear();
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => { setInputValue(input, 'example.com'); });
act(() => { (container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); });
expect(replaceAllPages).toHaveBeenCalledTimes(1);
const pagesArg = replaceAllPages.mock.calls[0][0];
expect(pagesArg).toHaveLength(1);
expect(pagesArg[0].name).toBe('Home');
// The replacement is a SerializedTreeNode (recursive tree), not a flat
// craft-state entry: a real Container root with no children.
expect(pagesArg[0].tree.type.resolvedName).toBe('Container');
expect(pagesArg[0].tree.nodes).toEqual([]);
expect(setHeader).toHaveBeenCalledTimes(1);
expect(setHeader.mock.calls[0][0].type.resolvedName).toBe('Container');
expect(setFooter).toHaveBeenCalledTimes(1);
expect(setFooter.mock.calls[0][0].type.resolvedName).toBe('Container');
expect(resetToDefaults).toHaveBeenCalledTimes(1);
// The panel closes its own dialog back up after confirming.
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeNull();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
unmount();
});
test('standalone mode (no WHP_CONFIG, siteDomain "") hides the entry point entirely, ' +
'while non-standalone mode shows it -- an empty typed input must never trivially ' +
'satisfy the guard. Asserted as a contrast within one test (not "absence" alone) ' +
'so this fails if the whole feature -- not just the guard -- were ever removed.', () => {
mockSiteDomain = '';
render();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeNull();
expect(container.querySelector('[data-testid="site-reset-domain"]')).toBeNull();
expect(container.textContent).not.toContain('Danger zone');
expect(container.textContent).not.toContain('Reset Entire Site');
unmount();
mockSiteDomain = 'example.com';
render();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
expect(container.textContent).toContain('Danger zone');
unmount();
});
});
+133
View File
@@ -1,7 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext'; import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { usePages } from '../../state/PageContext';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { SerializedTreeNode } from '../../types/sitesmith';
import { FONT_FAMILIES } from '../../constants/presets'; import { FONT_FAMILIES } from '../../constants/presets';
import { AssetPicker } from '../../ui/AssetPicker'; import { AssetPicker } from '../../ui/AssetPicker';
import { inputStyle } from './styles/shared';
type DesignTab = 'basic' | 'advanced'; type DesignTab = 'basic' | 'advanced';
@@ -156,12 +160,63 @@ const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
</div> </div>
); );
/* ---------- Reset Entire Site (danger zone) ---------- */
// Blank replacement trees for the "Reset Entire Site" escape hatch. Shaped
// as a `SerializedTreeNode` (type + props + nodes), NOT a flat Craft.js
// state entry -- this is what `replaceAllPages`/`setHeader`/`setFooter`
// feed into `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft`
// (see `state/PageContext.tsx`/`utils/craft-tree.ts`). A single `Container`
// root with no children mirrors the (unexported) EMPTY_CANVAS/EMPTY_HEADER/
// EMPTY_FOOTER constants already used elsewhere for "blank".
const BLANK_PAGE_TREE: SerializedTreeNode = {
type: { resolvedName: 'Container' },
props: { style: { minHeight: '100vh', backgroundColor: '#ffffff' }, tag: 'div' },
nodes: [],
};
const BLANK_HEADER_TREE: SerializedTreeNode = {
type: { resolvedName: 'Container' },
props: {
style: { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' },
tag: 'header',
},
nodes: [],
};
const BLANK_FOOTER_TREE: SerializedTreeNode = {
type: { resolvedName: 'Container' },
props: {
style: { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' },
tag: 'footer',
},
nodes: [],
};
/* ---------- Main SiteDesignPanel ---------- */ /* ---------- Main SiteDesignPanel ---------- */
export const SiteDesignPanel: React.FC = () => { export const SiteDesignPanel: React.FC = () => {
const { design, updateDesign, resetToDefaults } = useSiteDesign(); const { design, updateDesign, resetToDefaults } = useSiteDesign();
const { replaceAllPages, setHeader, setFooter } = usePages();
const { whpConfig } = useEditorConfig();
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
// an empty typed input would then trivially "match" an empty siteDomain,
// arming the destructive confirm button with no guard at all. The entry
// point itself is hidden in that case (see the `siteDomain &&` guard below).
const siteDomain = whpConfig?.siteDomain ?? '';
const [siteResetOpen, setSiteResetOpen] = useState(false);
const [siteResetTyped, setSiteResetTyped] = useState('');
const [tab, setTab] = useState<DesignTab>('basic'); const [tab, setTab] = useState<DesignTab>('basic');
const handleResetSite = (): void => {
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
setHeader(BLANK_HEADER_TREE);
setFooter(BLANK_FOOTER_TREE);
resetToDefaults();
setSiteResetOpen(false);
setSiteResetTyped('');
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Header */} {/* Header */}
@@ -371,6 +426,84 @@ export const SiteDesignPanel: React.FC = () => {
</p> </p>
</div> </div>
{/* Reset Entire Site (danger zone) -- hidden entirely in standalone
mode, where siteDomain is '' and typing nothing would trivially
satisfy an empty-string match. */}
{siteDomain && (
<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 roughly every 30
seconds, so the blank version becomes your saved draft shortly after.
</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>
)}
</div> </div>
); );
}; };