site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith

Three related features:

1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction.
   New shared ctas[] array (text + href + variant + target) replaces the
   primary/secondary pair. Settings panel gets add/remove/reorder controls.
   Legacy fields stay readable for backwards compat — first user edit
   migrates the section onto the new array.

2. Anchor IDs on all layout/section components (Container, Section,
   BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel
   subagent, plus Hero/CTA/CallToAction). Anchor input lives in the
   settings panel with an "auto from heading" button that walks the
   subtree for the first Heading.text. Renders as id="..." on the
   outermost element so #anchor URLs resolve.

3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith"
   and a button at the top of the right-side settings panel both open the
   modal pre-targeted at the selected node. The node's serialized subtree
   is sent to the server; system prompt is augmented to require a patch
   with replace_node. Editor lifts modal state into a new SitesmithContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:43:28 -07:00
parent 7b747f775f
commit d0925d9e2d
24 changed files with 723 additions and 237 deletions
@@ -1,6 +1,8 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
interface ContextMenuProps {
visible: boolean;
@@ -27,6 +29,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
onClose,
}) => {
const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(null);
const clipboardRef = useRef<string | null>(null);
@@ -143,6 +146,17 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
onClose();
}, [nodeId, actions, getParentId, onClose]);
const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const target = buildSitesmithTarget(query, nodeId);
if (target) openSitesmith(target);
} catch (e) {
console.error('Ask Sitesmith failed:', e);
}
onClose();
}, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId);
if (!target) {
@@ -162,6 +176,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const isRoot = nodeId === 'ROOT' || !nodeId;
const items: MenuItem[] = [
{
label: '✨ Ask Sitesmith',
action: askSitesmith,
disabled: isRoot,
dividerAfter: true,
},
{
label: 'Duplicate',
shortcut: 'Ctrl+D',
+26 -3
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { useEditor } from '@craftjs/core';
import { componentResolver } from '../../components/resolver';
import { SiteDesignPanel } from './SiteDesignPanel';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import {
TextStylePanel,
ButtonStylePanel,
@@ -30,6 +32,8 @@ import {
export const GuidedStyles: React.FC = () => {
const resolverMap = componentResolver as Record<string, any>;
const { open: openSitesmith } = useSitesmithModal();
const { query } = useEditor();
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
const currentNodeId = state.events.selected
@@ -97,14 +101,33 @@ export const GuidedStyles: React.FC = () => {
: isUtility ? 'fa-ellipsis-h'
: 'fa-cube';
const handleAskSitesmith = () => {
if (!selected || selected === 'ROOT') return;
const target = buildSitesmithTarget(query, selected);
if (target) openSitesmith(target);
};
return (
<div className="guided-styles">
{/* Component type badge */}
<div className="guided-section guided-type-header">
<span className="guided-type-badge">
{/* Component type badge + Sitesmith shortcut */}
<div className="guided-section guided-type-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="guided-type-badge" style={{ flex: 1 }}>
<i className={`fa ${typeIcon}`} />
{' '}{typeName}
</span>
<button
onClick={handleAskSitesmith}
title="Ask Sitesmith to edit this block"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 8px', fontSize: 11, fontWeight: 600,
color: '#a78bfa', background: 'rgba(139,92,246,0.12)',
border: '1px solid rgba(139,92,246,0.4)',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}
>
<i className="fa fa-magic" /> Ask Sitesmith
</button>
</div>
{/* TEXT */}
+24 -4
View File
@@ -4,6 +4,7 @@ import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSitesmith } from '../../hooks/useSitesmith';
import { useApplyAiResponse } from '../../utils/apply-ai-response';
import { summarizeCanvas } from '../../utils/canvas-summary';
import { SitesmithTarget } from '../../state/SitesmithContext';
import { UpgradeBanner } from './UpgradeBanner';
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
import { MessageList } from './MessageList';
@@ -11,9 +12,14 @@ import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith';
interface Props { onClose: () => void; }
interface Props {
onClose: () => void;
/** When set, the chat is biased toward editing this specific node and the AI is
* instructed to return a `patch` op. The node's serialized tree is sent along. */
target?: SitesmithTarget | null;
}
export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const cfg = useEditorConfig();
const siteId = cfg.whpConfig?.siteId ?? 0;
const { query } = useEditor();
@@ -38,13 +44,17 @@ export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send(text, canvas);
const result = await send(text, canvas, target ? {
node_id: target.nodeId,
display_name: target.displayName,
tree_json: target.treeJson,
} : undefined);
if (!result.ok) { setError(result.message || 'Failed'); return; }
if (result.response.type === 'replace' && result.response.scope === 'site') {
setPendingReplace(result.response);
return;
}
const applied = await apply(result.response);
const applied = await apply(result.response, target?.nodeId);
if (!applied.ok) setError(applied.message || 'Apply failed');
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBusy(false); }
@@ -86,6 +96,16 @@ export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
</div>
<div style={body}>
<UpgradeBanner summary={summary} />
{target && (
<div style={{
background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.4)',
borderRadius: 6, padding: '8px 12px', marginBottom: 10, fontSize: 13, color: '#bfdbfe',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<i className="fa fa-magic" style={{ color: '#60a5fa' }} />
<span>Editing <strong style={{ color: '#fff' }}>{target.displayName}</strong> describe the change you want and Sitesmith will modify just this block.</span>
</div>
)}
{error && <div role="alert" style={errBox}>{error}</div>}
{loading
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading</div>
+3 -4
View File
@@ -7,7 +7,7 @@ import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { SitesmithModal } from '../sitesmith/SitesmithModal';
import { useSitesmithModal } from '../../state/SitesmithContext';
interface TopBarProps {
device: DeviceMode;
@@ -28,7 +28,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const [sitesmithOpen, setSitesmithOpen] = useState(false);
const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasLoadedRef = useRef(false);
@@ -242,7 +242,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
</span>
)}
<SitesmithButton onClick={() => setSitesmithOpen(true)} />
<SitesmithButton onClick={() => openSitesmith()} />
<button
className="topbar-btn primary"
@@ -274,7 +274,6 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
</div>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
{sitesmithOpen && <SitesmithModal onClose={() => setSitesmithOpen(false)} />}
</nav>
);
};