sitesmith: report-only check that the primary CTA uses the accent colour; modal wiring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 17:21:14 -08:00
1 parent 65d388ff89
commit 9781e02043
5 files changed
+185 -7

No files matched your search

@@ -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<any>(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<any>(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();
@@ -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<Props> = ({ onClose, target }) => {
const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null);
const [reviewNote, setReviewNote] = useState<SelfReviewOutcome | null>(null);
const abortRef = useRef<AbortController | null>(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<string | null>(null);
const [open, setOpen] = useState<Record<string, boolean>>({});
@@ -173,6 +174,7 @@ export const SitesmithModal: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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}
/>
+53
View File
@@ -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<string, unknown> = {}, 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<string, unknown> = {}) => 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); });
});
+74
View File
@@ -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<SerializedTreeNode> {
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<string, string[]> = { 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<string, unknown>;
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;
}
+9 -2
View File
@@ -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<ApplyOutcome> {
// '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 }; }
};