Sitesmith: contrast repair wired into apply-ai-response; scoped guard exception for the script-less capture frame

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:32:50 -08:00
1 parent 197b9c46ab
commit c4f45541a3
2 files changed
+55 -6

No files matched your search

+50 -6
View File
@@ -3,6 +3,8 @@ import { insertTargetOutsideInstance, isInsideInstance } from './global-sections
import { usePages } from '../state/PageContext';
import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
import { sanitizeAiTree, buildNodeTree } from './craft-tree';
import { useSiteDesign, type SiteDesign } from '../state/SiteDesignContext';
import { repairResponseContrast, type ContrastAdjustment, type PatchContext } from './contrast-repair';
// Re-exported for existing callers/tests that import sanitizeAiTree/
// buildNodeTree from this module — the implementation lives in craft-tree.ts
@@ -32,6 +34,37 @@ export function findNodeIdByAiNodeId(query: any, aiNodeId: string): string | nul
return null;
}
/**
* Existing-canvas context for `update_props` ops: the node's props and the nearest ancestor's flat
* background, so a text colour the response sets can be judged against what is really behind it.
*/
export function canvasNodeContext(query: any): PatchContext['nodeContext'] {
return (aiNodeId: string) => {
try {
const id = findNodeIdByAiNodeId(query, aiNodeId);
if (!id) return undefined;
const props = { ...(query.node(id).get().data.props ?? {}) } as Record<string, any>;
let ancestorBg: string | undefined;
let cur: string | null | undefined = query.node(id).get().data.parent;
while (cur) {
const p = query.node(cur).get().data.props ?? {};
const bg = p.style?.backgroundColor ?? p.bgColor;
if (typeof bg === 'string' && bg.trim() && !/gradient|url\(/i.test(bg)) { ancestorBg = bg; break; }
if ((p.bgType && p.bgType !== 'color' && p.bgType !== 'solid') || p.bgImage) break;
cur = query.node(cur).get().data.parent;
}
return { props, ancestorBg };
} catch { return undefined; }
};
}
export interface ApplyOutcome { ok: boolean; message?: string; adjustments?: ContrastAdjustment[]; notChecked?: number }
/** Repair text/background contrast in a response against the current design (pure; no model call). */
export function repairForCanvas(resp: SitesmithResponse, design: SiteDesign, query: any) {
return repairResponseContrast(resp, design, { nodeContext: canvasNodeContext(query) });
}
/** Exported for unit tests */
export const __test = { findNodeIdByAiNodeId, buildNodeTree, applyPatch };
@@ -43,6 +76,7 @@ export const __test = { findNodeIdByAiNodeId, buildNodeTree, applyPatch };
export function useApplyAiResponse() {
const { actions, query } = useEditor();
const pages = usePages();
const { design } = useSiteDesign();
/**
* @param targetNodeId If set and the AI returned a section-scoped replace
@@ -53,10 +87,20 @@ export function useApplyAiResponse() {
return async function apply(
resp: SitesmithResponse,
targetNodeId?: string,
): Promise<{ ok: boolean; message?: string }> {
opts: { repairContrast?: boolean } = {},
): Promise<ApplyOutcome> {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
// Contrast auto-repair (design "Settled calls" 8): fix failing text colours before they reach the canvas.
let adjustments: ContrastAdjustment[] = [];
let notChecked = 0;
if (opts.repairContrast !== false) {
const rep = repairForCanvas(resp, design, query);
resp = rep.response; adjustments = rep.adjustments; notChecked = rep.notChecked;
}
const done = (r: { ok: boolean; message?: string }): ApplyOutcome => (r.ok ? { ...r, adjustments, notChecked } : r);
if (resp.type === 'replace') {
if (resp.scope === 'site') {
if (!pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })))) {
@@ -69,7 +113,7 @@ export function useApplyAiResponse() {
}
if (resp.header) pages.setHeader(resp.header.tree);
if (resp.footer) pages.setFooter(resp.footer.tree);
return { ok: true };
return done({ ok: true });
}
if (resp.scope === 'page') {
@@ -79,7 +123,7 @@ export function useApplyAiResponse() {
message: 'You are editing a global section. Click Done, then ask again to replace the page.',
};
}
return { ok: true };
return done({ ok: true });
}
if (resp.scope === 'section') {
@@ -95,7 +139,7 @@ export function useApplyAiResponse() {
const index = siblings.indexOf(targetNodeId);
actions.delete(targetNodeId);
actions.addNodeTree(nodeTree, parent, index);
return { ok: true };
return done({ ok: true });
} catch (e) {
console.warn('sitesmith: targeted section replace failed, falling back to append', e);
}
@@ -109,12 +153,12 @@ export function useApplyAiResponse() {
console.warn('sitesmith: failed to add section tree', e);
}
}
return { ok: true };
return done({ ok: true });
}
}
if (resp.type === 'patch') {
return applyPatch(actions, query, resp.ops);
return done(applyPatch(actions, query, resp.ops));
}
return { ok: false, message: 'Unknown response type' };
@@ -70,6 +70,11 @@ describe('no same-origin path for composed-page HTML remains in the source', ()
// on a srcdoc frame it would hand customer markup the panel origin.
const offenders = files.filter((f) => {
const src = readFileSync(f, 'utf8');
// The one sanctioned exception: the Sitesmith self-review capture frame. It is script-less (the file must
// never mention allow-scripts), fed only sanitizeCaptureHtml output, CSP-locked, and allow-same-origin is
// there only so html-to-image can read the frame's layout. See sanitize-capture-html.test.ts and
// scripts/e2e-capture-security.mjs for the hostile-page proof.
if (f.endsWith('self-review-capture.ts')) return /allow-scripts/.test(src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')) || !/sanitizeCaptureHtml\(/.test(src);
return /srcdoc/i.test(src) && /allow-same-origin['"\s]/.test(src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''));
});
expect(offenders.map((f) => f.slice(root.length + 1))).toEqual([]);