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:
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}
|
||||
/>
|
||||
|
||||
@@ -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); });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }; }
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user