Files
site-builder/craft/src/panels/right/SiteDesignPanel.tsx
T
shadowdaoandClaude Opus 5 aba0d187d7 fix(site-builder): make Reset Entire Site's guard load-bearing in the handler
Two Important review findings on the previous commit (1a01834):

1. handleResetSite ran unconditionally -- the confirm button's `disabled`
   attribute was the only thing standing between a mismatched/empty typed
   value and a full site wipe. Extracted the match check into a single
   exported pure predicate, siteResetConfirmMatches(typed, domain), used
   for the button's disabled/cursor/opacity (previously three duplicated
   inline comparisons) AND as the first line of handleResetSite itself,
   which now returns early if it doesn't hold. An empty domain is rejected
   outright (`!!domain &&` short-circuits) so the guard holds even if the
   handler were ever reached with no configured domain, independent of the
   entry point being hidden.

2. The dialog said "design tokens" but resetToDefaults() also wipes
   headCode (analytics/search-console/third-party scripts) and favicon --
   neither is one of the 17 documented design properties, so a user had no
   reason to read them as included. Copy now names both explicitly.
   Re-verified every remaining claim in the paragraph against what the
   handler actually does (page/header/footer replacement, no undo, no
   publish call, images untouched, 30000ms auto-save) -- all still hold.

Verified load-bearing by temporarily reverting each guard in place (no git
stash -- shared across worktrees/sessions per review feedback) and
confirming the corresponding test fails: dropping the !!domain check broke
the empty-domain unit test; removing the handleResetSite check broke a new
test that invokes the confirm button's React onClick directly (bypassing
both the disabled attribute and react-dom's own disabled-click suppression,
which independent investigation confirmed blocks a plain DOM `.disabled =
false; .click()`/dispatchEvent bypass -- pulling onClick off the element's
stashed __reactProps$ key was the only way to actually exercise the
handler's own guard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:30:20 -07:00

542 lines
19 KiB
TypeScript

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';
/* ---------- Color picker with preset swatches ---------- */
const COLOR_SWATCHES = [
'#3b82f6', '#8b5cf6', '#10b981', '#ef4444',
'#f59e0b', '#ec4899', '#06b6d4', '#f97316',
];
const NEUTRAL_SWATCHES = [
'#ffffff', '#f9fafb', '#e5e7eb', '#9ca3af',
'#6b7280', '#374151', '#1f2937', '#111827',
];
interface ColorFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
swatches?: string[];
}
const ColorField: React.FC<ColorFieldProps> = ({ label, value, onChange, swatches = COLOR_SWATCHES }) => (
<div className="guided-section">
<label className="guided-section-label">{label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: 32,
height: 32,
padding: 0,
border: '2px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
background: 'none',
}}
/>
<input
type="text"
className="guided-input"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ flex: 1, fontSize: 11, fontFamily: 'monospace' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
{swatches.map((c) => (
<button
key={c}
onClick={() => onChange(c)}
style={{
width: '100%',
aspectRatio: '1',
borderRadius: 'var(--radius-sm)',
border: value === c ? '2px solid var(--color-accent)' : '2px solid var(--color-border)',
background: c,
cursor: 'pointer',
boxShadow: value === c ? '0 0 0 2px var(--color-accent)' : 'none',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
title={c}
/>
))}
</div>
</div>
);
/* ---------- Font dropdown ---------- */
interface FontFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
const FontField: React.FC<FontFieldProps> = ({ label, value, onChange }) => (
<div className="guided-section">
<label className="guided-section-label">{label}</label>
<select
className="control-select"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ fontSize: 12 }}
>
{FONT_FAMILIES.map((f) => (
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>
{f.label}
</option>
))}
</select>
</div>
);
/* ---------- Border radius presets ---------- */
const BORDER_RADIUS_PRESETS = [
{ label: '0', value: '0' },
{ label: '4px', value: '4px' },
{ label: '8px', value: '8px' },
{ label: '12px', value: '12px' },
{ label: '16px', value: '16px' },
];
interface RadiusFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
const RadiusField: React.FC<RadiusFieldProps> = ({ label, value, onChange }) => (
<div className="guided-section">
<label className="guided-section-label">{label}</label>
<div className="preset-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{BORDER_RADIUS_PRESETS.map((p) => (
<button
key={p.value}
className={`preset-btn ${value === p.value ? 'active' : ''}`}
onClick={() => onChange(p.value)}
>
{p.label}
</button>
))}
</div>
</div>
);
/* ---------- Nav style toggle ---------- */
interface NavStyleFieldProps {
value: 'light' | 'dark';
onChange: (value: 'light' | 'dark') => void;
}
const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
<div className="guided-section">
<label className="guided-section-label">Nav Style</label>
<div style={{ display: 'flex', gap: 6 }}>
{(['light', 'dark'] as const).map((s) => (
<button
key={s}
className={`preset-btn ${value === s ? 'active' : ''}`}
style={{ flex: 1, textTransform: 'capitalize' }}
onClick={() => onChange(s)}
>
{s === 'light' ? 'Light' : 'Dark'}
</button>
))}
</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: [],
};
/**
* Single source of truth for "has the user typed enough to arm the confirm
* button" -- used for the `disabled` attribute/cursor/opacity AND, more
* importantly, inside `handleResetSite` itself. `disabled` is a UI
* affordance, not a safety mechanism (it only stops a plain mouse click);
* for the one action in this app that irreversibly wipes a user's whole
* site draft, the real guard has to live in the handler, checked against
* this exact same predicate rather than a second hand-rolled comparison
* that could drift out of sync with it.
*
* An empty/falsy `domain` always returns `false`, even if `typed` is also
* empty -- `''.trim() === ''.trim()` would otherwise "match" trivially.
* Standalone mode (no `WHP_CONFIG`) has `siteDomain === ''`, and while the
* entry point that would let a user reach this code is hidden in that case
* (see the `siteDomain &&` guard around the whole danger zone below), this
* function must not depend on that -- it has to fail safe on its own if
* ever reached with no configured domain.
*
* Exported so it's directly unit-testable without needing a way to render
* the (deliberately unreachable-when-`siteDomain`-is-empty) confirm UI.
*/
export function siteResetConfirmMatches(typed: string, domain: string): boolean {
return !!domain && typed.trim() === domain.trim();
}
/* ---------- 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 => {
// Load-bearing guard -- see `siteResetConfirmMatches`'s docstring. Not
// just a UI nicety: this must hold even if the confirm button's
// `disabled` attribute were ever bypassed (a synthetic click, or a
// future refactor that drops it).
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
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 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<i className="fa fa-paint-brush" style={{ color: 'var(--color-accent)', fontSize: 14 }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Site Design Tokens
</span>
</div>
{/* Tab switcher */}
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
{(['basic', 'advanced'] as DesignTab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 1,
padding: '6px 12px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: tab === t ? '#fff' : 'var(--color-text-muted)',
background: tab === t ? 'var(--color-accent)' : 'var(--color-bg-elevated)',
border: tab === t ? '1px solid var(--color-accent)' : '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
{t}
</button>
))}
</div>
{/* Basic tab */}
{tab === 'basic' && (
<>
<div className="guided-section">
<label className="guided-section-label">Favicon</label>
<AssetPicker
value={design.favicon}
onChange={(url) => updateDesign({ favicon: url })}
mediaType="image"
variant="full"
placeholder="Or paste favicon URL..."
/>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', margin: '4px 0 0' }}>
Shown in browser tabs and bookmarks. Applies site-wide.
</p>
</div>
<ColorField
label="Primary Color"
value={design.primaryColor}
onChange={(v) => updateDesign({ primaryColor: v })}
/>
<ColorField
label="Secondary Color"
value={design.secondaryColor}
onChange={(v) => updateDesign({ secondaryColor: v })}
/>
<ColorField
label="Accent Color"
value={design.accentColor}
onChange={(v) => updateDesign({ accentColor: v })}
/>
<FontField
label="Heading Font"
value={design.headingFont}
onChange={(v) => updateDesign({ headingFont: v })}
/>
<FontField
label="Body Font"
value={design.bodyFont}
onChange={(v) => updateDesign({ bodyFont: v })}
/>
<ColorField
label="Link Color"
value={design.linkColor}
onChange={(v) => updateDesign({ linkColor: v })}
/>
</>
)}
{/* Advanced tab */}
{tab === 'advanced' && (
<>
<ColorField
label="Primary Color"
value={design.primaryColor}
onChange={(v) => updateDesign({ primaryColor: v })}
/>
<ColorField
label="Secondary Color"
value={design.secondaryColor}
onChange={(v) => updateDesign({ secondaryColor: v })}
/>
<ColorField
label="Accent Color"
value={design.accentColor}
onChange={(v) => updateDesign({ accentColor: v })}
/>
<ColorField
label="Success Color"
value={design.successColor}
onChange={(v) => updateDesign({ successColor: v })}
/>
<ColorField
label="Warning Color"
value={design.warningColor}
onChange={(v) => updateDesign({ warningColor: v })}
/>
<ColorField
label="Error Color"
value={design.errorColor}
onChange={(v) => updateDesign({ errorColor: v })}
/>
<ColorField
label="Background Color"
value={design.backgroundColor}
onChange={(v) => updateDesign({ backgroundColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Text Color"
value={design.textColor}
onChange={(v) => updateDesign({ textColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Muted Text Color"
value={design.mutedTextColor}
onChange={(v) => updateDesign({ mutedTextColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Border Color"
value={design.borderColor}
onChange={(v) => updateDesign({ borderColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<FontField
label="Heading Font"
value={design.headingFont}
onChange={(v) => updateDesign({ headingFont: v })}
/>
<FontField
label="Body Font"
value={design.bodyFont}
onChange={(v) => updateDesign({ bodyFont: v })}
/>
<FontField
label="Button Font"
value={design.buttonFont}
onChange={(v) => updateDesign({ buttonFont: v })}
/>
<ColorField
label="Link Color"
value={design.linkColor}
onChange={(v) => updateDesign({ linkColor: v })}
/>
<RadiusField
label="Default Border Radius"
value={design.borderRadius}
onChange={(v) => updateDesign({ borderRadius: v })}
/>
<RadiusField
label="Button Radius"
value={design.buttonRadius}
onChange={(v) => updateDesign({ buttonRadius: v })}
/>
<NavStyleField
value={design.navStyle}
onChange={(v) => updateDesign({ navStyle: v })}
/>
</>
)}
{/* Reset button */}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--color-border)' }}>
<button
onClick={resetToDefaults}
style={{
width: '100%',
padding: '8px 12px',
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',
transition: 'all 0.15s ease',
}}
>
Reset to Defaults
</button>
<p style={{
fontSize: 10,
color: 'var(--color-text-dim)',
textAlign: 'center',
margin: '8px 0 0',
lineHeight: 1.4,
}}>
Design tokens are saved with your project and applied to new components.
</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, all
design tokens (colors, fonts, radii), and your custom head code and
favicon, 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={!siteResetConfirmMatches(siteResetTyped, siteDomain)}
onClick={handleResetSite}
style={{
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
color: '#fff', background: '#ef4444', border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: siteResetConfirmMatches(siteResetTyped, siteDomain) ? 'pointer' : 'not-allowed',
opacity: siteResetConfirmMatches(siteResetTyped, 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>
);
};