sitesmith: fix blank canvas on Replace site

treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.

Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.

Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
This commit is contained in:
2026-05-24 15:35:05 -07:00
parent 5c5066c20b
commit ac0347ae5f

View File

@@ -4,6 +4,17 @@ import { PageData } from '../types';
import { SerializedTreeNode } from '../types/sitesmith'; import { SerializedTreeNode } from '../types/sitesmith';
import { useSiteDesign, SiteDesign } from './SiteDesignContext'; import { useSiteDesign, SiteDesign } from './SiteDesignContext';
/** Layout components that accept children. Must match the .craft.rules.canMoveIn
* config of each component. Leaf components (Heading, TextBlock, ButtonLink, …)
* are NOT canvases and must serialize with isCanvas:false or their rendered
* content gets swallowed by an empty drop-target wrapper. */
const CANVAS_TYPES = new Set<string>([
'Container', 'Section', 'ColumnLayout', 'BackgroundSection',
'HeaderZone', 'FooterZone',
'HeroSimple', 'FeaturesGrid', 'CTASection',
'FormContainer', 'Navbar', 'Footer',
]);
interface PageContextValue { interface PageContextValue {
pages: PageData[]; pages: PageData[];
headerPage: PageData; headerPage: PageData;
@@ -290,11 +301,21 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const walk = (node: SerializedTreeNode, parent: string | null): string => { const walk = (node: SerializedTreeNode, parent: string | null): string => {
const id = (node.props.node_id as string | undefined) || `ai-auto-${counter++}`; const id = (node.props.node_id as string | undefined) || `ai-auto-${counter++}`;
const childIds: string[] = []; const childIds: string[] = [];
const typeName = node.type?.resolvedName;
// Normalize props: the AI sometimes emits `style: []` instead of `{}`.
// React/Craft.js choke when a CSSProperties slot is an array — normalize it.
const rawProps = node.props ?? {};
const props: Record<string, unknown> = { ...rawProps };
if (Array.isArray(props.style)) props.style = {};
nodes[id] = { nodes[id] = {
type: node.type, type: node.type,
isCanvas: true, // isCanvas must match the component's craft.rules — only layout
props: node.props, // wrappers accept children. Setting it true on leaf components
displayName: node.type.resolvedName, // (Heading, TextBlock, ButtonLink, etc) makes Craft.js render them
// as empty drop-canvas wrappers and the actual content disappears.
isCanvas: typeName ? CANVAS_TYPES.has(typeName) : false,
props,
displayName: typeName,
custom: {}, custom: {},
hidden: false, hidden: false,
parent, parent,
@@ -311,6 +332,8 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (rootId !== 'ROOT') { if (rootId !== 'ROOT') {
nodes['ROOT'] = nodes[rootId]; nodes['ROOT'] = nodes[rootId];
(nodes['ROOT'] as any).parent = null; (nodes['ROOT'] as any).parent = null;
// ROOT must be a canvas regardless of component type so children render.
(nodes['ROOT'] as any).isCanvas = true;
delete nodes[rootId]; delete nodes[rootId];
// Fix up parent references from ROOT's children // Fix up parent references from ROOT's children
for (const childId of (nodes['ROOT'] as any).nodes) { for (const childId of (nodes['ROOT'] as any).nodes) {