From 0c9faf04954e454e9d0c8bf883d60a087fe2a260 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 16:16:04 -0800 Subject: [PATCH] sitesmith: AI-built pages render full-bleed (page root padding 0) The page root Container's default 20px padding framed every AI-built section in a white margin (visible in every eval screenshot). New AI page/site replaces now set padding 0 on the page root unless the model set padding itself. Saved pages, section inserts, patches and the Container default are unchanged, so existing sites do not move. Co-Authored-By: Claude Opus 5.5 --- craft/src/sitesmith/replace-plan.test.ts | 27 ++++++++++++++++++++++++ craft/src/sitesmith/replace-plan.ts | 21 ++++++++++++++++++ craft/src/utils/apply-ai-response.ts | 4 ++++ 3 files changed, 52 insertions(+) diff --git a/craft/src/sitesmith/replace-plan.test.ts b/craft/src/sitesmith/replace-plan.test.ts index 49330c7..273e31e 100644 --- a/craft/src/sitesmith/replace-plan.test.ts +++ b/craft/src/sitesmith/replace-plan.test.ts @@ -88,3 +88,30 @@ describe('planReplace', () => { expect(planReplace(patch, { userText: 'x', chromeDefault: true })).toEqual({ resp: patch, confirm: null }); }); }); + +import { fullBleedPageRoots } from './replace-plan'; +describe('fullBleedPageRoots (AI-built pages have no white frame around every section)', () => { + const page = (rootProps: any): SitesmithResponse => ({ type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: rootProps, nodes: [] } as any }] }); + test('a root Container without padding gets padding 0 (the Container default is 20px)', () => { + const out = fullBleedPageRoots(page({ tag: 'div', node_id: 'ai-root-1' })) as any; + expect(out.pages[0].tree.props.style).toEqual({ padding: '0px' }); + const out2 = fullBleedPageRoots(page({ tag: 'div', style: { backgroundColor: '#000' } })) as any; + expect(out2.pages[0].tree.props.style).toEqual({ padding: '0px', backgroundColor: '#000' }); + }); + test('padding the model set on purpose is kept; non-Container roots, sections and patches are untouched', () => { + expect((fullBleedPageRoots(page({ style: { padding: '40px' } })) as any).pages[0].tree.props.style).toEqual({ padding: '40px' }); + expect((fullBleedPageRoots(page({ style: { paddingTop: '8px' } })) as any).pages[0].tree.props.style).toEqual({ paddingTop: '8px' }); + const sec: SitesmithResponse = { ...(page({}) as any), scope: 'section' }; + expect(fullBleedPageRoots(sec)).toBe(sec); + const patch: SitesmithResponse = { type: 'patch', message: 'm', ops: [] }; + expect(fullBleedPageRoots(patch)).toBe(patch); + const hero: SitesmithResponse = { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: { type: { resolvedName: 'HeroSimple' }, props: {}, nodes: [] } as any }] }; + expect((fullBleedPageRoots(hero) as any).pages[0].tree.props.style).toBeUndefined(); + }); + test('site replace: every page root; the input is not mutated', () => { + const r: SitesmithResponse = { type: 'replace', scope: 'site', message: 'm', pages: [0, 1].map((i) => ({ name: `P${i}`, tree: { type: { resolvedName: 'Container' }, props: {}, nodes: [] } as any })) }; + const out = fullBleedPageRoots(r) as any; + expect(out.pages.map((p: any) => p.tree.props.style)).toEqual([{ padding: '0px' }, { padding: '0px' }]); + expect((r as any).pages[0].tree.props.style).toBeUndefined(); + }); +}); diff --git a/craft/src/sitesmith/replace-plan.ts b/craft/src/sitesmith/replace-plan.ts index 48862f3..5c13560 100644 --- a/craft/src/sitesmith/replace-plan.ts +++ b/craft/src/sitesmith/replace-plan.ts @@ -99,3 +99,24 @@ export function withoutChrome(resp: SitesmithResponse): SitesmithResponse { const { header: _h, footer: _f, ...rest } = resp; return rest as SitesmithResponse; } + +const PADDING_KEYS = ['padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'paddingBlock', 'paddingInline']; + +/** + * AI-built pages render full-bleed: the page root Container gets padding 0 unless the model set padding + * itself. Without this the Container default (20px) framed every section in a white margin. Only new AI + * page/site replaces are touched -- saved pages and the Container default stay as they are. + */ +export function fullBleedPageRoots(resp: SitesmithResponse): SitesmithResponse { + if (resp.type !== 'replace' || (resp.scope !== 'page' && resp.scope !== 'site')) return resp; + return { + ...resp, + pages: resp.pages.map((p) => { + const t = p.tree; + if (!t || t.type?.resolvedName !== 'Container') return p; + const style = (t.props?.style && typeof t.props.style === 'object' && !Array.isArray(t.props.style) ? t.props.style : {}) as Record; + if (PADDING_KEYS.some((k) => style[k] !== undefined && style[k] !== '')) return p; + return { ...p, tree: { ...t, props: { ...(t.props ?? {}), style: { padding: '0px', ...style } } } }; + }), + }; +} diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index aba1d8c..1791a90 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -1,6 +1,7 @@ import { useEditor } from '@craftjs/core'; import { insertTargetOutsideInstance, isInsideInstance } from './global-sections'; import { usePages, treeToCraftState } from '../state/PageContext'; +import { fullBleedPageRoots } from '../sitesmith/replace-plan'; import { canvasFingerprint, type CanvasFingerprint } from './canvas-fingerprint'; import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith'; import { sanitizeAiTree, buildNodeTree } from './craft-tree'; @@ -123,6 +124,9 @@ export function useApplyAiResponse() { return typeof name === 'string' ? name : undefined; }); + // AI-built pages are full-bleed (no 20px white frame from the Container default) -- new replaces only. + resp = fullBleedPageRoots(resp); + // Contrast auto-repair (design "Settled calls" 8): fix failing text colours before they reach the canvas. let adjustments: ContrastAdjustment[] = []; let notChecked = 0;