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>
542 lines
19 KiB
TypeScript
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>
|
|
);
|
|
};
|