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
+133
View File
@@ -1,7 +1,11 @@
import React, { useState } from 'react';
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 { AssetPicker } from '../../ui/AssetPicker';
import { inputStyle } from './styles/shared';
type DesignTab = 'basic' | 'advanced';
@@ -156,12 +160,63 @@ const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
</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 ---------- */
export const SiteDesignPanel: React.FC = () => {
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 handleResetSite = (): void => {
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
setHeader(BLANK_HEADER_TREE);
setFooter(BLANK_FOOTER_TREE);
resetToDefaults();
setSiteResetOpen(false);
setSiteResetTyped('');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Header */}
@@ -371,6 +426,84 @@ export const SiteDesignPanel: React.FC = () => {
</p>
</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>
);
};