diff --git a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx index 9769bd1..37ab043 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx @@ -427,6 +427,47 @@ describe('Sitesmith modal: replace scope follows the request', () => { expect(headerLogo()).toBe('OLS Digital'); }, 30000); + test('new header that omits the existing pages: dialog says so, and the applied header links to them with followPages', async () => { + net.polls = [done(siteAnswer())]; + await mountP(); + await sendText('Build my home page'); + await flush(1250); + expect(document.body.textContent).toContain("Sitesmith's header left out 1 of your pages (Home)"); + expect(document.body.textContent).not.toContain('your other pages are not changed'); + await h.act(async () => { btn('Use the new header and footer').click(); }); + await flush(300); + const st = JSON.parse(pagesCtx.latestChrome().headerState!); + const navP = Object.values(st).find((n) => n.type?.resolvedName === 'Navbar')!.props; + expect(navP.followPages).toBe(true); + expect(navP.links.map((l: any) => l.href)).toEqual(['/']); + }, 30000); + + test('whole-site replace leaves the model-written nav links alone', async () => { + net.polls = [done(siteAnswer())]; + await mountP(); + await sendText('Build me a website for my company'); + await flush(1250); + expect(document.body.textContent).not.toContain('left out'); + await h.act(async () => { btn('Replace site').click(); }); + await flush(300); + const st = JSON.parse(pagesCtx.latestChrome().headerState!); + const navP = Object.values(st).find((n) => n.type?.resolvedName === 'Navbar')!.props; + expect(navP.links).toEqual([]); + }, 30000); + + test('start carries site_pages; a non-accent primary CTA is logged (report only), page unchanged', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + const ans: any = replacePage('Site hero'); + ans.pages[0].tree = { type: { resolvedName: 'Container' }, props: { tag: 'div' }, nodes: [{ type: { resolvedName: 'ButtonLink' }, props: { text: 'Go', style: { backgroundColor: '#ff0000' } }, nodes: [] }] }; + net.polls = [done(ans)]; + await mountP(); + await sendText('Redo this page'); + await flush(1250); + expect(net.starts[0].site_pages).toEqual([{ title: 'Home', path: '/' }]); + expect(info.mock.calls.some((c) => String(c[0]).startsWith('[sitesmith]') && /primary-cta-not-accent/.test(String(c[0])))).toBe(true); + info.mockRestore(); + }, 30000); + test('an explicit whole-site request keeps the whole-site confirm', async () => { net.polls = [done(siteAnswer())]; await mountP(); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index fea9f53..3f90dbc 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -31,6 +31,7 @@ import { summarizeCanvas } from '../../utils/canvas-summary'; import { SitesmithTarget } from '../../state/SitesmithContext'; import { UpgradeBanner } from './UpgradeBanner'; import { ScopeConfirmDialog } from './ScopeConfirmDialog'; +import { buildSitePagesPayload } from '../../utils/site-pages'; import { planReplace, isDefaultChrome, withoutChrome, missingUploadedImageNote } from '../../sitesmith/replace-plan'; import { MessageList } from './MessageList'; import { ChatInput } from './ChatInput'; @@ -65,7 +66,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null); const [reviewNote, setReviewNote] = useState(null); const abortRef = useRef(null); - const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean; mode: 'site' | 'chrome' } | null>(null); + const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean; mode: 'site' | 'chrome'; addedLinks?: string[] } | null>(null); /** What the owner typed for the job in flight (a replace's scope is decided from it, see replace-plan). */ const lastRequestRef = useRef(null); const [open, setOpen] = useState>({}); @@ -173,6 +174,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { const sendOpts = () => ({ design: buildDesignPayload(design), chromeDefault: chromeDefault(), + sitePages: buildSitePagesPayload(pageCtx.pages), mockupIds: imagesOn ? selMockups : [], referenceIds: selRefs, useAssets: useAssetsFlag, @@ -243,7 +245,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { // ---- one pipeline for send, resume and confirm const applyResponse = async (resp: SitesmithResponse, review: boolean) => { - const applied = await apply(resp, target?.nodeId); + const applied = await apply(resp, target?.nodeId, { userText: lastRequestRef.current }); if (!applied.ok) { setError(friendly(applied.message || 'Apply failed')); return; } if (applied.adjustments?.length || applied.notChecked) setAdjust({ list: applied.adjustments ?? [], notChecked: applied.notChecked ?? 0 }); if (review && selfReviewOn && visionOk && features?.self_review !== false && resp.type !== 'ask' && !target && !pageCtx.isEditingGlobal) { @@ -261,12 +263,12 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { // A whole-site replace needs an explicit whole-site request; the shared header/footer change only // when they are untouched defaults and the owner says yes (replace-plan.ts). const userText = lastRequestRef.current ?? [...messages].reverse().find((m) => m.role === 'user')?.content ?? null; - const plan = planReplace(res.response, { userText, chromeDefault: chromeDefault() }); + const plan = planReplace(res.response, { userText, chromeDefault: chromeDefault(), pages: pageCtx.pages }); const offered = useAssetsFlag ? (assetFilter ? assetFilter.length : assets.length) : 0; const imgNote = missingUploadedImageNote(plan.resp, offered); setNotes([...(res.notes ?? []), ...(plan.note ? [plan.note] : []), ...(imgNote ? [imgNote] : [])]); if (plan.confirm) { - setPendingReplace({ resp: plan.resp, review, mode: plan.confirm }); + setPendingReplace({ resp: plan.resp, review, mode: plan.confirm, addedLinks: plan.addedLinks }); return; } await applyResponse(plan.resp, review); @@ -316,7 +318,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { setBusy(true); setError(null); try { const canvas = summarizeCanvas(query.getSerializedNodes()); - const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter }); + const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', sitePages: buildSitePagesPayload(pageCtx.pages), design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter }); if (!result.ok) { setError(failureMessage(result)); return; } const r = applyRegeneratedSection(actions, query, repairForCanvas(result.response, design, query).response, target.nodeId); if (!r.ok) { setError(r.message || 'Apply failed'); return; } @@ -572,6 +574,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { open={!!pendingReplace} pendingMessage={pendingReplace && 'message' in pendingReplace.resp ? pendingReplace.resp.message : undefined} mode={pendingReplace?.mode ?? 'site'} + addedLinks={pendingReplace?.addedLinks} onConfirm={confirmReplace} onCancel={cancelReplace} /> diff --git a/craft/src/sitesmith/cta-check.test.ts b/craft/src/sitesmith/cta-check.test.ts new file mode 100644 index 0000000..ab2b67b --- /dev/null +++ b/craft/src/sitesmith/cta-check.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from 'vitest'; +import { checkPrimaryCtaAccent, ownerNamedButtonColour } from './cta-check'; +import type { SitesmithResponse } from '../types/sitesmith'; + +const node = (name: string, props: Record = {}, nodes: any[] = []): any => ({ type: { resolvedName: name }, props, nodes }); +const page = (name: string, ...sections: any[]) => ({ name, tree: node('Container', {}, sections) }); +const replace = (scope: 'page' | 'site' | 'section', ...pages: any[]): SitesmithResponse => ({ type: 'replace', scope, pages, message: 'm' }); +const btn = (bg?: string, extra: Record = {}) => node('ButtonLink', { text: 'Go', style: bg ? { backgroundColor: bg } : {}, ...extra }); + +describe('checkPrimaryCtaAccent', () => { + test('no false positive when the first ButtonLink is var(--wsb-accent)', () => { + expect(checkPrimaryCtaAccent(replace('page', page('Home', node('Container', {}, [btn('var(--wsb-accent)')]))), 'build my page')).toEqual([]); + expect(checkPrimaryCtaAccent(replace('page', page('Home', node('Container', {}, [{ ...btn(), props: { style: { background: 'var(--wsb-accent)' } } }]))), 'x')).toEqual([]); + }); + test('first ButtonLink in the first section with another fill is reported', () => { + const out = checkPrimaryCtaAccent(replace('page', page('Home', node('Container', {}, [btn('#ff0000'), btn('var(--wsb-accent)')]))), 'build my page'); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ page: 'Home', component: 'ButtonLink', fill: '#ff0000' }); + }); + test('a ButtonLink with no fill at all is reported; later sections are not the primary CTA', () => { + expect(checkPrimaryCtaAccent(replace('page', page('Home', node('Container', {}, [btn()]))), 'x')).toHaveLength(1); + expect(checkPrimaryCtaAccent(replace('page', page('Home', node('Container', {}, [node('Heading')]), node('Container', {}, [btn('#000')]))), 'x')).toEqual([]); + }); + test('HeroSimple / CTASection / CallToAction primary cta fill', () => { + const hero = (props: any) => node('HeroSimple', { ctas: [{ text: 'a', href: '#', variant: 'outline' }, { text: 'b', href: '#', variant: 'primary' }], ...props }); + expect(checkPrimaryCtaAccent(replace('page', page('P', hero({ buttonBgColor: 'var(--wsb-accent)' }))), 'x')).toEqual([]); + expect(checkPrimaryCtaAccent(replace('page', page('P', hero({ buttonBgColor: 'var(--wsb-primary)' }))), 'x')).toMatchObject([{ component: 'HeroSimple', fill: 'var(--wsb-primary)' }]); + expect(checkPrimaryCtaAccent(replace('page', page('P', node('CallToAction', { ctas: [{ text: 'a', href: '#' }], buttonColor: 'var(--wsb-accent)' }))), 'x')).toEqual([]); + expect(checkPrimaryCtaAccent(replace('page', page('P', node('CTASection', { ctas: [{ text: 'a', href: '#', variant: 'primary' }] }))), 'x')).toHaveLength(1); + // ghost/outline-only: no primary, first entry is used + expect(checkPrimaryCtaAccent(replace('page', page('P', node('CTASection', { ctas: [{ text: 'a', href: '#', variant: 'outline' }] }))), 'x')).toHaveLength(1); + }); + test('owner who names a button colour is exempt', () => { + const r = replace('page', page('Home', node('Container', {}, [btn('#ff0000')]))); + expect(checkPrimaryCtaAccent(r, 'make the buttons red')).toEqual([]); + expect(checkPrimaryCtaAccent(r, 'call to action in #0a7 please')).toEqual([]); + expect(checkPrimaryCtaAccent(r, 'button colour should match my logo')).toEqual([]); + expect(checkPrimaryCtaAccent(r, 'a red hero image please')).toHaveLength(1); // a colour word with no button talk + }); + test('site replaces check every page; section replaces and non-replaces are skipped', () => { + const bad = (n: string) => page(n, node('Container', {}, [btn('#123')])); + expect(checkPrimaryCtaAccent(replace('site', bad('A'), bad('B')), 'x').map((c) => c.page)).toEqual(['A', 'B']); + expect(checkPrimaryCtaAccent(replace('section', bad('A')), 'x')).toEqual([]); + expect(checkPrimaryCtaAccent({ type: 'ask', question: 'q' }, 'x')).toEqual([]); + }); +}); + +describe('ownerNamedButtonColour', () => { + test.each([ + ['make the buttons green', true], ['orange call to action', true], ['CTA colour: #1a2b3c', true], ['button color teal', true], + ['build a bakery site', false], ['green theme and a clear button', true], ['click the button', false], + ])('%s -> %s', (t, want) => { expect(ownerNamedButtonColour(t)).toBe(want); }); +}); diff --git a/craft/src/sitesmith/cta-check.ts b/craft/src/sitesmith/cta-check.ts new file mode 100644 index 0000000..d889b11 --- /dev/null +++ b/craft/src/sitesmith/cta-check.ts @@ -0,0 +1,74 @@ +/** + * Report-only post-check (eval finding 3): the primary call to action should be filled with the site's + * accent colour (`var(--wsb-accent)`). Nothing here changes a page or tells the owner anything; the + * apply layer returns the findings in its outcome and logs them with a `[sitesmith]` prefix so we can + * see how often the model ignores the rule. An owner who names a button colour is exempt. + */ +import type { SerializedTreeNode, SitesmithResponse } from '../types/sitesmith'; +import { normalizeCtas } from '../components/sections/_cta-helpers'; + +export interface CtaCheck { + kind: 'primary-cta-not-accent'; + page: string; + component: string; + /** The fill found, or null when the button sets none. */ + fill: string | null; +} + +const COLOUR_WORD = /\b(red|orange|yellow|green|teal|cyan|blue|navy|indigo|purple|violet|magenta|pink|brown|black|white|gr[ae]y|gold|silver|beige|maroon|turquoise|coral|crimson)\b/i; +const HEX = /#[0-9a-f]{3,8}\b/i; +const BUTTON_WORD = /\b(buttons?|ctas?|call[\s-]+to[\s-]+actions?)\b/i; + +/** True when the request names a button colour: "button colour ...", or button talk plus a colour word / hex / rgb(). */ +export function ownerNamedButtonColour(text: string | null | undefined): boolean { + if (!text) return false; + if (/\b(?:button|cta)s?\s+colou?rs?\b/i.test(text)) return true; + return BUTTON_WORD.test(text) && (COLOUR_WORD.test(text) || HEX.test(text) || /\brgba?\(/i.test(text)); +} + +const isAccent = (v: unknown) => typeof v === 'string' && /^var\(--wsb-accent[,)]/i.test(v.trim().replace(/\s+/g, '')); +const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null); + +function firstButtonLink(n: SerializedTreeNode): SerializedTreeNode | null { + if (n.type?.resolvedName === 'ButtonLink') return n; + for (const k of n.nodes ?? []) { const f = firstButtonLink(k); if (f) return f; } + return null; +} + +function* walk(n: SerializedTreeNode): Generator { + yield n; + for (const k of n.nodes ?? []) yield* walk(k); +} + +/** Component prop that holds the primary button fill, with the component's own default (not accent). */ +const CTA_FILL_PROP: Record = { HeroSimple: ['buttonBgColor'], CallToAction: ['buttonColor'], CTASection: ['buttonBgColor', 'buttonColor'] }; + +export function checkPrimaryCtaAccent(resp: SitesmithResponse, userText: string | null | undefined): CtaCheck[] { + if (resp.type !== 'replace' || (resp.scope !== 'page' && resp.scope !== 'site')) return []; + if (ownerNamedButtonColour(userText)) return []; + const out: CtaCheck[] = []; + for (const pg of resp.pages) { + const root = pg.tree; + if (!root) continue; + // 1. The first ButtonLink in the page's first section. + const section = root.nodes?.[0] ?? root; + const link = firstButtonLink(section); + if (link) { + const style = (link.props?.style && typeof link.props.style === 'object' ? link.props.style : {}) as Record; + const fill = str(style.backgroundColor) ?? str(style.background); + if (!isAccent(fill)) out.push({ kind: 'primary-cta-not-accent', page: pg.name, component: 'ButtonLink', fill }); + } + // 2. Section components with ctas[]: the primary entry (else the first) is filled by the component's button colour prop. + for (const n of walk(root)) { + const name = n.type?.resolvedName; + const fillProps = CTA_FILL_PROP[name]; + if (!fillProps) continue; + const ctas = normalizeCtas(n.props as any); + if (!ctas.length) continue; + // The primary entry (or, with none, the first) is drawn with this fill, so the fill is what we check. + const fill = fillProps.map((p) => str(n.props?.[p])).find(Boolean) ?? null; + if (!isAccent(fill)) out.push({ kind: 'primary-cta-not-accent', page: pg.name, component: name, fill }); + } + } + return out; +} diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 1791a90..0f42178 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -8,6 +8,7 @@ import { sanitizeAiTree, buildNodeTree } from './craft-tree'; import { cleanAiResponse } from '../sitesmith/clean-ai-response'; import { useSiteDesign, type SiteDesign } from '../state/SiteDesignContext'; import { effectiveBackgroundOf } from './effective-background'; +import { checkPrimaryCtaAccent, type CtaCheck } from '../sitesmith/cta-check'; import { repairResponseContrast, type ContrastAdjustment, type PatchContext } from './contrast-repair'; // Re-exported for existing callers/tests that import sanitizeAiTree/ @@ -67,6 +68,8 @@ export interface AppliedCanvas { settled: boolean; expected: CanvasFingerprint | export interface ApplyOutcome { ok: boolean; message?: string; adjustments?: ContrastAdjustment[]; notChecked?: number; canvas?: AppliedCanvas; + /** Report-only post-checks on a page/site replace (e.g. primary CTA not accent-filled); never shown to the owner. */ + checks?: CtaCheck[]; /** Patch only: how many ops actually changed the canvas (an op naming a missing node does nothing). */ changed?: number; /** Patch only: how many ops the response carried. */ @@ -109,7 +112,7 @@ export function useApplyAiResponse() { return async function apply( resp: SitesmithResponse, targetNodeId?: string, - opts: { repairContrast?: boolean } = {}, + opts: { repairContrast?: boolean; /** The owner's request text (an owner-named button colour exempts the CTA check). */ userText?: string | null } = {}, ): Promise { // 'ask' type = AI wants clarification, nothing to apply if (resp.type === 'ask') return { ok: true }; @@ -127,6 +130,10 @@ export function useApplyAiResponse() { // AI-built pages are full-bleed (no 20px white frame from the Container default) -- new replaces only. resp = fullBleedPageRoots(resp); + // Report-only: the primary CTA should use the accent colour (nothing is changed or shown to the owner). + const checks = checkPrimaryCtaAccent(resp, opts.userText); + for (const c of checks) console.info(`[sitesmith] check ${c.kind}: page "${c.page}" ${c.component} fill ${c.fill ?? '(none)'}`); + // Contrast auto-repair (design "Settled calls" 8): fix failing text colours before they reach the canvas. let adjustments: ContrastAdjustment[] = []; let notChecked = 0; @@ -135,7 +142,7 @@ export function useApplyAiResponse() { resp = rep.response; adjustments = rep.adjustments; notChecked = rep.notChecked; } const done = (r: { ok: boolean; message?: string; changed?: number; ops?: number }, canvas?: AppliedCanvas): ApplyOutcome => - (r.ok ? { ...r, adjustments, notChecked, canvas: canvas ?? liveCanvas() } : r); + (r.ok ? { ...r, adjustments, notChecked, canvas: canvas ?? liveCanvas(), ...(checks.length ? { checks } : {}) } : r); const liveCanvas = (): AppliedCanvas => { try { return { settled: true, expected: canvasFingerprint(query.serialize()) }; } catch { return { settled: true, expected: null }; } };