From c4f45541a39229aedb1e2481ba3c878ca9cf765d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:32:50 -0800 Subject: [PATCH] Sitesmith: contrast repair wired into apply-ai-response; scoped guard exception for the script-less capture frame Co-Authored-By: Claude Sonnet 5.5 --- craft/src/utils/apply-ai-response.ts | 56 ++++++++++++++++++++--- craft/src/utils/sandboxed-preview.test.ts | 5 ++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 9e33851..5a4eec7 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -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; + 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 { // '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' }; diff --git a/craft/src/utils/sandboxed-preview.test.ts b/craft/src/utils/sandboxed-preview.test.ts index ba82475..1d81b8d 100644 --- a/craft/src/utils/sandboxed-preview.test.ts +++ b/craft/src/utils/sandboxed-preview.test.ts @@ -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([]);