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:
1 parent
197b9c46ab
commit
c4f45541a3
2 files changed
+55
-6
No files matched your search
@@ -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([]);
|
||||
|
||||
Reference in new issue
Block a user