From 0fde4dc22fd6839433f48606a717ec95e83e1eb3 Mon Sep 17 00:00:00 2001
From: Josh Knapp
Date: Sun, 1 Nov 2026 10:23:26 -0800
Subject: [PATCH 01/10] Sitesmith: contrast auto-repair for model responses;
capture module (html-to-image) for self-review
Co-Authored-By: Claude Sonnet 5.5
---
craft/package-lock.json | 7 +
craft/package.json | 1 +
craft/src/utils/contrast-repair.test.ts | 161 +++++++++++++
craft/src/utils/contrast-repair.ts | 303 ++++++++++++++++++++++++
craft/src/utils/self-review-capture.ts | 195 +++++++++++++++
5 files changed, 667 insertions(+)
create mode 100644 craft/src/utils/contrast-repair.test.ts
create mode 100644 craft/src/utils/contrast-repair.ts
create mode 100644 craft/src/utils/self-review-capture.ts
diff --git a/craft/package-lock.json b/craft/package-lock.json
index 07fd023..f7bea9a 100644
--- a/craft/package-lock.json
+++ b/craft/package-lock.json
@@ -20,6 +20,7 @@
"@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5",
+ "html-to-image": "1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -2349,6 +2350,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-to-image": {
+ "version": "1.11.13",
+ "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
+ "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
+ "license": "MIT"
+ },
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
diff --git a/craft/package.json b/craft/package.json
index 5789f0c..da09908 100644
--- a/craft/package.json
+++ b/craft/package.json
@@ -28,6 +28,7 @@
"@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5",
+ "html-to-image": "1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/craft/src/utils/contrast-repair.test.ts b/craft/src/utils/contrast-repair.test.ts
new file mode 100644
index 0000000..a6d1136
--- /dev/null
+++ b/craft/src/utils/contrast-repair.test.ts
@@ -0,0 +1,161 @@
+import { describe, test, expect } from 'vitest';
+import { repairResponseContrast, nearestPassing, summarizeAdjustments } from './contrast-repair';
+import { contrastRatio } from './publish-checks';
+import { parseColor } from './color';
+import { resolveTokenValue } from './tokens';
+import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
+import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
+
+const D = { ...DEFAULT_SITE_DESIGN, primaryColor: '#1e3a8a', textColor: '#111827', backgroundColor: '#ffffff', mutedTextColor: '#6b7280' };
+const n = (name: string, props: Record, nodes: SerializedTreeNode[] = []): SerializedTreeNode =>
+ ({ type: { resolvedName: name }, props, nodes });
+const replace = (tree: SerializedTreeNode): SitesmithResponse => ({ type: 'replace', scope: 'section', message: 'm', pages: [{ name: 'p', tree }] });
+const firstTree = (r: SitesmithResponse) => (r as any).pages[0].tree as SerializedTreeNode;
+const ratio = (fg: string, bg: string) => contrastRatio(parseColor(resolveTokenValue(fg, D))!, parseColor(resolveTokenValue(bg, D))!);
+
+describe('repairResponseContrast', () => {
+ test('repairs a failing heading on a flat dark section to a passing colour and reports it', () => {
+ const resp = replace(n('Container', { style: { backgroundColor: '#1e3a8a' } }, [
+ n('Heading', { text: 'Hello', style: { color: '#334155' } }),
+ ]));
+ const out = repairResponseContrast(resp, D);
+ const h = firstTree(out.response).nodes![0];
+ const to = (h.props.style as any).color as string;
+ expect(to).not.toBe('#334155');
+ expect(ratio(to, '#1e3a8a')).toBeGreaterThanOrEqual(4.5);
+ expect(out.adjustments).toHaveLength(1);
+ expect(out.adjustments[0]).toMatchObject({ kind: 'text-colour', node: 'Heading', text: 'Hello', from: '#334155', to });
+ expect(out.adjustments[0].ratioBefore!).toBeLessThan(4.5);
+ expect(out.adjustments[0].ratioAfter!).toBeGreaterThanOrEqual(4.5);
+ });
+
+ test('does not mutate the input response', () => {
+ const resp = replace(n('Container', { style: { backgroundColor: '#1e3a8a' } }, [n('Heading', { text: 'x', style: { color: '#334155' } })]));
+ const snap = JSON.stringify(resp);
+ repairResponseContrast(resp, D);
+ expect(JSON.stringify(resp)).toBe(snap);
+ });
+
+ test('passing pairs are left alone', () => {
+ const resp = replace(n('Container', { style: { backgroundColor: '#0f172a' } }, [n('TextBlock', { text: 'ok', style: { color: '#ffffff' } })]));
+ const out = repairResponseContrast(resp, D);
+ expect(out.adjustments).toEqual([]);
+ expect(firstTree(out.response).nodes![0].props.style).toEqual({ color: '#ffffff' });
+ });
+
+ test('resolves var(--wsb-*) tokens on both sides from the current design', () => {
+ // muted (#6b7280) on primary (#1e3a8a) fails
+ const resp = replace(n('Container', { style: { backgroundColor: 'var(--wsb-primary)' } }, [n('TextBlock', { text: 't', style: { color: 'var(--wsb-muted)' } })]));
+ const out = repairResponseContrast(resp, D);
+ expect(out.adjustments).toHaveLength(1);
+ const to = (firstTree(out.response).nodes![0].props.style as any).color as string;
+ expect(ratio(to, 'var(--wsb-primary)')).toBeGreaterThanOrEqual(4.5);
+ // prefers a site token over raw black/white when one passes and is nearer
+ expect(to.startsWith('var(--wsb-') || to === '#ffffff' || to === '#000000').toBe(true);
+ });
+
+ test('the same token pair passes under a different design (design is really used)', () => {
+ const resp = replace(n('Container', { style: { backgroundColor: 'var(--wsb-primary)' } }, [n('TextBlock', { text: 't', style: { color: 'var(--wsb-muted)' } })]));
+ const light = { ...D, primaryColor: '#ffffff' };
+ expect(repairResponseContrast(resp, light).adjustments).toHaveLength(0);
+ });
+
+ test('uses 3:1 for large text', () => {
+ // #888 on white = 3.54:1: fails body (4.5) but passes large (3)
+ const small = replace(n('Container', {}, [n('Heading', { text: 'a', style: { color: '#888888', fontSize: '16px' } })]));
+ const large = replace(n('Container', {}, [n('Heading', { text: 'a', style: { color: '#888888', fontSize: '32px' } })]));
+ expect(repairResponseContrast(small, D).adjustments).toHaveLength(1);
+ expect(repairResponseContrast(large, D).adjustments).toHaveLength(0);
+ });
+
+ test('gradient and video backgrounds are not checked (and counted)', () => {
+ const resp = replace(n('Container', { style: { background: 'x', backgroundColor: 'linear-gradient(135deg,#000,#fff)' } }, [n('Heading', { text: 'g', style: { color: '#777777' } })]));
+ const out = repairResponseContrast(resp, D);
+ expect(out.adjustments).toEqual([]);
+ expect(out.notChecked).toBe(1);
+ const hero = replace(n('HeroSimple', { bgType: 'gradient', bgGradientFrom: '#000', bgGradientTo: '#fff', textColor: '#777777' }));
+ const o2 = repairResponseContrast(hero, D);
+ expect(o2.adjustments).toEqual([]);
+ expect(o2.notChecked).toBe(1);
+ });
+
+ test('text over an image without an overlay gets one; text colour is not touched', () => {
+ const resp = replace(n('HeroSimple', { bgType: 'image', bgImage: 'assets/a.jpg', textColor: '#ffffff', overlayOpacity: 0 }));
+ const out = repairResponseContrast(resp, D);
+ const p = firstTree(out.response).props as any;
+ expect(p.overlayOpacity).toBeGreaterThanOrEqual(40);
+ expect(p.overlayColor).toBe('#000000');
+ expect(p.textColor).toBe('#ffffff');
+ expect(out.adjustments).toEqual([expect.objectContaining({ kind: 'overlay', node: 'HeroSimple' })]);
+ });
+
+ test('dark text over an image gets a LIGHT overlay; an existing strong overlay is kept', () => {
+ const weak = replace(n('HeroSimple', { bgType: 'image', bgImage: 'assets/a.jpg', textColor: '#111111', overlayOpacity: 10 }));
+ expect((firstTree(repairResponseContrast(weak, D).response).props as any).overlayColor).toBe('#ffffff');
+ const strong = replace(n('HeroSimple', { bgType: 'image', bgImage: 'assets/a.jpg', textColor: '#ffffff', overlayOpacity: 60 }));
+ expect(repairResponseContrast(strong, D).adjustments).toEqual([]);
+ });
+
+ test('BackgroundSection overlay uses its 0..1 scale and covers child text', () => {
+ const resp = replace(n('BackgroundSection', { bgImage: 'assets/a.jpg', overlayOpacity: 0.1 }, [n('Heading', { text: 'On photo', style: { color: '#ffffff' } })]));
+ const out = repairResponseContrast(resp, D);
+ const p = firstTree(out.response).props as any;
+ expect(p.overlayOpacity).toBe(0.55);
+ expect(p.overlayColor).toBe('#000000');
+ expect(out.adjustments.filter((a) => a.kind === 'overlay')).toHaveLength(1);
+ });
+
+ test('named section pairs: nav links and hero button are repaired', () => {
+ const resp = replace(n('Container', {}, [
+ n('Navbar', { backgroundColor: '#0f172a', textColor: '#1e293b', ctaColor: '#3b82f6', ctaTextColor: '#ffffff' }),
+ ]));
+ const out = repairResponseContrast(resp, D);
+ const nav = firstTree(out.response).nodes![0].props as any;
+ expect(ratio(nav.textColor, '#0f172a')).toBeGreaterThanOrEqual(4.5);
+ expect(out.adjustments.map((a) => a.node)).toContain('Navbar');
+ });
+
+ test('root text is judged against the page background token', () => {
+ const resp = replace(n('Container', {}, [n('TextBlock', { text: 'pale', style: { color: '#cccccc' } })]));
+ expect(repairResponseContrast(resp, D).adjustments).toHaveLength(1);
+ const dark = { ...D, backgroundColor: '#000000' };
+ expect(repairResponseContrast(resp, dark).adjustments).toHaveLength(0);
+ });
+
+ test('brand swatches are candidates when present', () => {
+ const d = { ...D, swatches: [{ label: 'Brand', hex: '#fde68a', brand: true }] } as any;
+ const pick = nearestPassing(parseColor('#fcd34d')!, parseColor('#0f172a')!, 4.5, d)!;
+ expect(pick.value).toBe('#fde68a');
+ });
+
+ test('update_props patch is repaired using the existing node context', () => {
+ const resp: SitesmithResponse = { type: 'patch', message: 'm', ops: [{ op: 'update_props', node_id: 'x', props: { style: { color: '#334155' } } }] };
+ const out = repairResponseContrast(resp, D, { nodeContext: () => ({ props: { style: { backgroundColor: '#1e3a8a' } } }) });
+ const op = (out.response as any).ops[0];
+ expect(ratio(op.props.style.color, '#1e3a8a')).toBeGreaterThanOrEqual(4.5);
+ expect(out.adjustments).toHaveLength(1);
+ // without context nothing can be judged
+ expect(repairResponseContrast(resp, D).adjustments).toEqual([]);
+ });
+
+ test('replace_node / insert trees in patches, header and footer are covered', () => {
+ const bad = () => n('Container', { style: { backgroundColor: '#1e3a8a' } }, [n('Heading', { text: 'h', style: { color: '#334155' } })]);
+ const p: SitesmithResponse = { type: 'patch', message: 'm', ops: [{ op: 'insert_after', node_id: 'a', tree: bad() }] };
+ expect(repairResponseContrast(p, D).adjustments).toHaveLength(1);
+ const r: SitesmithResponse = { type: 'replace', scope: 'site', message: 'm', pages: [{ name: 'p', tree: n('Container', {}) }], header: { tree: bad() }, footer: { tree: bad() } };
+ expect(repairResponseContrast(r, D).adjustments).toHaveLength(2);
+ });
+
+ test('ask responses and malformed trees pass through untouched', () => {
+ const ask: SitesmithResponse = { type: 'ask', question: 'q' };
+ expect(repairResponseContrast(ask, D).response).toEqual(ask);
+ const weird = { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'p', tree: null }] } as any;
+ expect(() => repairResponseContrast(weird, D)).not.toThrow();
+ });
+
+ test('summary text', () => {
+ expect(summarizeAdjustments([])).toBe('');
+ expect(summarizeAdjustments([{ kind: 'text-colour', node: 'H', from: 'a', to: 'b' }, { kind: 'overlay', node: 'Hero', from: '0%', to: 'x' }], 2))
+ .toBe('1 text colour adjusted for readability; 1 image overlay added so text stays readable (2 gradient/image areas not checked)');
+ });
+});
diff --git a/craft/src/utils/contrast-repair.ts b/craft/src/utils/contrast-repair.ts
new file mode 100644
index 0000000..118dd62
--- /dev/null
+++ b/craft/src/utils/contrast-repair.ts
@@ -0,0 +1,303 @@
+/**
+ * Contrast auto-repair for Sitesmith responses (design "Settled calls" 8).
+ *
+ * Runs on the model's response BEFORE it is applied: every text/background pair the
+ * response sets is checked with the editor's own `contrastRatio` (publish-checks.ts,
+ * the same checker as the colour-system work) and a failing text colour is replaced
+ * by the nearest passing site token or black/white. No model call. Pure: the input
+ * response is never mutated.
+ *
+ * What is checked: a node's own text colour against its own flat background, or
+ * against the nearest ancestor's flat background (the page background at the root).
+ * What is NOT checked (reported as such): gradients, background images and videos.
+ * Text over an image needs an overlay: a missing/weak overlay is strengthened.
+ */
+import type { SiteDesign } from '../state/SiteDesignContext';
+import type { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
+import { contrastRatio } from './publish-checks';
+import { parseColor, type RGB } from './color';
+import { resolveTokenValue, tokenRef, type TokenKey } from './tokens';
+
+export interface ContrastAdjustment {
+ kind: 'text-colour' | 'overlay';
+ /** Component display label, e.g. "Heading". */
+ node: string;
+ /** Short quote of the affected text (<= 40 chars), plain text only. */
+ text?: string;
+ from: string;
+ to: string;
+ ratioBefore?: number;
+ ratioAfter?: number;
+}
+
+export interface ContrastRepairResult {
+ response: SitesmithResponse;
+ adjustments: ContrastAdjustment[];
+ /** Pairs found but not computable (gradients / images / videos). */
+ notChecked: number;
+}
+
+/** Existing canvas context for `update_props` ops (the node is not in the response). */
+export interface PatchContext {
+ nodeContext?: (aiNodeId: string) => { props: Record; ancestorBg?: string } | undefined;
+}
+
+type Bg =
+ | { kind: 'flat'; rgb: RGB }
+ | { kind: 'image'; node: SerializedTreeNode }
+ | { kind: 'unknown' };
+
+const AA = 4.5;
+const AA_LARGE = 3;
+const MIN_OVERLAY = 40; // percent
+
+/** Per-component named colour props (components that do not use style.color/backgroundColor). */
+interface NamedPair { fg: string; bg?: string; label: string }
+const NAMED_PAIRS: Record = {
+ HeroSimple: [
+ { fg: 'textColor', bg: 'bgColor', label: 'hero text' },
+ { fg: 'buttonTextColor', bg: 'buttonBgColor', label: 'hero button' },
+ ],
+ CallToAction: [{ fg: 'textColor', label: 'call-to-action text' }],
+ Navbar: [
+ { fg: 'textColor', bg: 'backgroundColor', label: 'nav links' },
+ { fg: 'ctaTextColor', bg: 'ctaColor', label: 'nav button' },
+ ],
+};
+
+const OVERLAY_SCALE: Record = { HeroSimple: 100, CallToAction: 100, BackgroundSection: 1 };
+
+const plain = (s: unknown) => (typeof s === 'string' ? s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() : '');
+const quote = (s: string) => (s.length > 40 ? s.slice(0, 39) + '…' : s);
+
+function pxSize(v: unknown): number | null {
+ if (typeof v === 'number') return v;
+ if (typeof v !== 'string') return null;
+ const m = /^([\d.]+)(px|rem|em)$/.exec(v.trim());
+ if (!m) return null;
+ return m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;
+}
+
+function isLargeText(style: Record | undefined): boolean {
+ const size = pxSize(style?.fontSize);
+ if (size === null) return false;
+ const w = parseInt(String(style?.fontWeight ?? ''), 10);
+ return size >= 24 || (size >= 18.66 && (w >= 700 || style?.fontWeight === 'bold'));
+}
+
+const toRgb = (v: unknown, design: SiteDesign): RGB | null =>
+ typeof v === 'string' && v.trim() ? parseColor(resolveTokenValue(v.trim(), design)) : null;
+
+const dist = (a: RGB, b: RGB) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
+const toHex = (c: RGB) => '#' + c.map((x) => x.toString(16).padStart(2, '0')).join('');
+
+interface Candidate { value: string; rgb: RGB }
+
+function candidates(design: SiteDesign): Candidate[] {
+ const out: Candidate[] = [];
+ const keys: TokenKey[] = ['text', 'bg', 'primary', 'secondary', 'accent', 'muted', 'link'];
+ for (const k of keys) {
+ const ref = tokenRef(k);
+ const rgb = parseColor(resolveTokenValue(ref, design));
+ if (rgb) out.push({ value: ref, rgb });
+ }
+ const sw = (design as unknown as { swatches?: Array<{ hex?: string }> }).swatches;
+ if (Array.isArray(sw)) {
+ for (const s of sw.slice(0, 24)) {
+ const rgb = s && typeof s.hex === 'string' ? parseColor(s.hex) : null;
+ if (rgb) out.push({ value: toHex(rgb), rgb });
+ }
+ }
+ out.push({ value: '#000000', rgb: [0, 0, 0] }, { value: '#ffffff', rgb: [255, 255, 255] });
+ return out;
+}
+
+/** Nearest (in RGB) candidate that passes `need` against `bg`; black/white always exist so one passes at 3:1 and, for any bg, at least one passes 4.5:1. */
+export function nearestPassing(fg: RGB, bg: RGB, need: number, design: SiteDesign): Candidate | null {
+ let best: Candidate | null = null;
+ let bestD = Infinity;
+ for (const c of candidates(design)) {
+ if (contrastRatio(c.rgb, bg) < need) continue;
+ const d = dist(c.rgb, fg);
+ if (d < bestD) { best = c; bestD = d; }
+ }
+ return best;
+}
+
+function ownBg(props: Record, design: SiteDesign, namedBg?: string): Bg | 'none' {
+ if (props.bgType && props.bgType !== 'color' && props.bgType !== 'solid') {
+ return props.bgType === 'image' ? { kind: 'image', node: undefined as unknown as SerializedTreeNode } : { kind: 'unknown' };
+ }
+ if (props.bgImage) return { kind: 'image', node: undefined as unknown as SerializedTreeNode };
+ const raw = (namedBg ? props[namedBg] : undefined) ?? props.style?.backgroundColor ?? props.bgColor;
+ if (typeof raw !== 'string' || !raw.trim()) return 'none';
+ if (/gradient|url\(/i.test(raw)) return { kind: 'unknown' };
+ const rgb = toRgb(raw, design);
+ return rgb ? { kind: 'flat', rgb } : 'none';
+}
+
+function overlayPercent(node: SerializedTreeNode): number {
+ const p = node.props as Record;
+ const scale = OVERLAY_SCALE[node.type?.resolvedName] ?? 100;
+ const o = Number(p.overlayOpacity ?? (scale === 1 ? 0.4 : 0));
+ if (!Number.isFinite(o)) return 0;
+ return scale === 1 ? o * 100 : o;
+}
+
+function resolveBg(chain: SerializedTreeNode[], design: SiteDesign, rootBg: Bg): Bg {
+ for (let i = chain.length - 1; i >= 0; i--) {
+ const node = chain[i];
+ const b = ownBg(node.props as Record, design);
+ if (b === 'none') continue;
+ if (b.kind === 'image') return { kind: 'image', node };
+ return b;
+ }
+ return rootBg;
+}
+
+class Repairer {
+ adjustments: ContrastAdjustment[] = [];
+ notChecked = 0;
+ private overlaid = new Set();
+ private rootBg: Bg;
+
+ constructor(private design: SiteDesign, rootBgOverride?: string) {
+ const raw = rootBgOverride ?? design.backgroundColor;
+ const rgb = toRgb(raw, design) ?? toRgb(design.backgroundColor, design);
+ this.rootBg = rgb ? { kind: 'flat', rgb } : { kind: 'unknown' };
+ }
+
+ tree(root: SerializedTreeNode, ancestors: SerializedTreeNode[] = []): void {
+ const node = root;
+ if (!node || typeof node !== 'object' || !node.props || typeof node.props !== 'object') return;
+ const name = node.type?.resolvedName ?? 'Component';
+ const props = node.props as Record;
+ const chain = ancestors;
+
+ // 1) Named pairs for section components, bg is own (or the inherited one).
+ for (const pair of NAMED_PAIRS[name] ?? []) this.checkPair(node, name, chain, props, pair.fg, pair.bg, undefined);
+ // 2) style.color text vs own style.backgroundColor / ancestors.
+ if (typeof props.style?.color === 'string' && props.style.color.trim()) {
+ this.checkPair(node, name, chain, props, 'style.color', undefined, props.style);
+ }
+ // 3) Sections with an image background: text on top needs an overlay.
+ if (props.bgType === 'image' || props.bgImage) this.checkOverlay(node, name, props);
+
+ for (const c of node.nodes ?? []) this.tree(c, [...chain, node]);
+ }
+
+ private bgFor(node: SerializedTreeNode, chain: SerializedTreeNode[], props: Record, namedBg: string | undefined): Bg {
+ const own = ownBg(props, this.design, namedBg);
+ if (own !== 'none') {
+ if (own.kind === 'image') return { kind: 'image', node };
+ return own;
+ }
+ return resolveBg(chain, this.design, this.rootBg);
+ }
+
+ private checkPair(
+ node: SerializedTreeNode, name: string, chain: SerializedTreeNode[], props: Record,
+ fgKey: string, bgKey: string | undefined, style: Record | undefined,
+ ): void {
+ const raw = fgKey === 'style.color' ? props.style?.color : props[fgKey];
+ const fg = toRgb(raw, this.design);
+ if (!fg) return;
+ const bg = this.bgFor(node, chain, props, bgKey);
+ if (bg.kind === 'unknown') { this.notChecked++; return; }
+ if (bg.kind === 'image') {
+ // Image behind the text: ensure an overlay on the node that owns the image.
+ if (bg.node) this.checkOverlay(bg.node, bg.node.type?.resolvedName ?? 'Section', bg.node.props as Record, fg);
+ return;
+ }
+ const need = isLargeText(style ?? props.style) ? AA_LARGE : AA;
+ const before = contrastRatio(fg, bg.rgb);
+ if (before >= need) return;
+ const pick = nearestPassing(fg, bg.rgb, need, this.design);
+ if (!pick) return;
+ const target = fgKey === 'style.color' ? props.style : props;
+ const key = fgKey === 'style.color' ? 'color' : fgKey;
+ const from = String(target[key]);
+ target[key] = pick.value;
+ this.adjustments.push({
+ kind: 'text-colour', node: name,
+ text: quote(plain(props.text ?? props.html ?? props.heading ?? props.label ?? '')) || undefined,
+ from, to: pick.value,
+ ratioBefore: Math.round(before * 100) / 100,
+ ratioAfter: Math.round(contrastRatio(pick.rgb, bg.rgb) * 100) / 100,
+ });
+ }
+
+ private checkOverlay(node: SerializedTreeNode, name: string, props: Record, fgHint?: RGB): void {
+ if (this.overlaid.has(node)) return;
+ if (!(name in OVERLAY_SCALE)) { this.notChecked++; return; } // an image we cannot add an overlay to
+ const fg = fgHint ?? toRgb(props.textColor, this.design);
+ if (overlayPercent(node) >= MIN_OVERLAY) { this.notChecked++; return; }
+ if (!fg && name !== 'HeroSimple' && name !== 'CallToAction') { this.notChecked++; return; }
+ const scale = OVERLAY_SCALE[name];
+ const light = fg ? (0.299 * fg[0] + 0.587 * fg[1] + 0.114 * fg[2]) / 255 > 0.5 : true;
+ const from = `${Math.round(overlayPercent(node))}%`;
+ props.overlayColor = light ? '#000000' : '#ffffff';
+ props.overlayOpacity = scale === 1 ? 0.55 : 55;
+ this.overlaid.add(node);
+ this.adjustments.push({ kind: 'overlay', node: name, from, to: `${props.overlayColor === '#000000' ? 'dark' : 'light'} overlay 55%` });
+ }
+
+ patchOp(op: SitesmithPatchOp, ctx?: PatchContext): void {
+ if (op.op === 'update_props') {
+ const known = ctx?.nodeContext?.(op.node_id);
+ if (!known || !op.props || typeof op.props !== 'object') return;
+ // Evaluate the merged props, then write the repaired colour into the patch only.
+ const merged: Record = { ...known.props, ...op.props };
+ merged.style = { ...(known.props.style ?? {}), ...((op.props as any).style ?? {}) };
+ const probe: SerializedTreeNode = { type: { resolvedName: 'Component' }, props: merged };
+ const inner = new Repairer(this.design, known.ancestorBg);
+ inner.tree(probe);
+ for (const a of inner.adjustments) {
+ if (a.kind !== 'text-colour') continue;
+ const patch = op.props as Record;
+ if (merged.style?.color !== undefined && (patch.style?.color !== undefined || patch.style)) {
+ patch.style = { ...(patch.style ?? {}), color: merged.style.color };
+ } else {
+ for (const k of Object.keys(merged)) if (k !== 'style' && merged[k] === a.to && patch[k] !== undefined) patch[k] = a.to;
+ }
+ this.adjustments.push(a);
+ }
+ this.notChecked += inner.notChecked;
+ return;
+ }
+ if ('tree' in op) this.tree(op.tree);
+ }
+}
+
+export function repairResponseContrast(
+ response: SitesmithResponse, design: SiteDesign, ctx?: PatchContext,
+): ContrastRepairResult {
+ let copy: SitesmithResponse;
+ try { copy = JSON.parse(JSON.stringify(response)); } catch { return { response, adjustments: [], notChecked: 0 }; }
+ const r = new Repairer(design);
+ try {
+ if (copy.type === 'replace') {
+ for (const p of copy.pages ?? []) r.tree(p.tree);
+ if (copy.header) r.tree(copy.header.tree);
+ if (copy.footer) r.tree(copy.footer.tree);
+ } else if (copy.type === 'patch') {
+ for (const op of copy.ops ?? []) r.patchOp(op, ctx);
+ }
+ } catch {
+ // Repair is best-effort: never block applying the model's answer.
+ return { response, adjustments: [], notChecked: 0 };
+ }
+ return { response: copy, adjustments: r.adjustments, notChecked: r.notChecked };
+}
+
+/** One-line summary for the "what was adjusted" note. */
+export function summarizeAdjustments(a: ContrastAdjustment[], notChecked = 0): string {
+ const text = a.filter((x) => x.kind === 'text-colour').length;
+ const ov = a.filter((x) => x.kind === 'overlay').length;
+ const parts: string[] = [];
+ if (text) parts.push(`${text} text colour${text === 1 ? '' : 's'} adjusted for readability`);
+ if (ov) parts.push(`${ov} image overlay${ov === 1 ? '' : 's'} added so text stays readable`);
+ if (!parts.length) return '';
+ const tail = notChecked ? ` (${notChecked} gradient/image area${notChecked === 1 ? '' : 's'} not checked)` : '';
+ return parts.join('; ') + tail;
+}
diff --git a/craft/src/utils/self-review-capture.ts b/craft/src/utils/self-review-capture.ts
new file mode 100644
index 0000000..e393a1f
--- /dev/null
+++ b/craft/src/utils/self-review-capture.ts
@@ -0,0 +1,195 @@
+/**
+ * Client-side page capture for the Sitesmith self-review loop.
+ *
+ * Renders published-style HTML into a hidden same-origin iframe whose sandbox
+ * allows NO scripts (customer head code and model output can never run in the
+ * panel origin; `allow-same-origin` is only there so html-to-image can read the
+ * DOM) and rasterises it to a JPEG with html-to-image (MIT, zero dependencies).
+ *
+ * Never throws: every failure is a `{ ok: false, reason }` so the caller can
+ * skip the review with a non-blocking note.
+ */
+import { toJpeg } from 'html-to-image';
+
+export const FONT_AWESOME_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css';
+
+export interface CaptureOptions {
+ /** Render width in CSS px (default 1280). */
+ width?: number;
+ /** The page is cropped to this height (default 3000 px). */
+ maxHeight?: number;
+ /** Whole-capture time limit (default 20 s). */
+ timeoutMs?: number;
+ /** JPEG quality 0..1 (default 0.8). */
+ quality?: number;
+ /** Reject captures bigger than this many bytes (default 3 MB). */
+ maxBytes?: number;
+ /** Add the Font Awesome stylesheet when the HTML has none (Preview head may lack it). Default true. */
+ injectFontAwesome?: boolean;
+ /** Extra settle time after load for fonts/images (default 600 ms). */
+ settleMs?: number;
+}
+
+export type CaptureResult =
+ | { ok: true; blob: Blob; width: number; height: number; cropped: boolean }
+ | { ok: false; reason: string };
+
+/** Make `html` safe and complete for capture: FA link if missing, scripts and head code are not executed anyway (sandbox). */
+export function prepareCaptureHtml(html: string, injectFontAwesome = true): string {
+ // A cross-origin stylesheet without CORS is unreadable (cssRules throws), so its @font-face
+ // rules could never be embedded in the capture. Google Fonts and cdnjs both answer CORS.
+ let out = html.replace(/]*>/gi, (tag) => (
+ /rel=["']?stylesheet/i.test(tag) && /href=["']?https:\/\/(fonts\.googleapis\.com|cdnjs\.cloudflare\.com)\//i.test(tag) && !/crossorigin/i.test(tag)
+ ? tag.replace(/\s*\/?>$/, ' crossorigin="anonymous">')
+ : tag
+ ));
+ if (injectFontAwesome && !/font-awesome|fontawesome/i.test(out)) {
+ const link = ``;
+ out = /<\/head>/i.test(out) ? out.replace(/<\/head>/i, () => link + '') : link + out;
+ }
+ return out;
+}
+
+/** Replace relative `assets/` references with fetchable URLs (the editor page is not at the site root). */
+export function resolveAssetUrls(html: string, urls: Record): string {
+ let out = html;
+ for (const [rel, url] of Object.entries(urls)) {
+ if (!/^assets\/[A-Za-z0-9._%~+-]{1,200}$/.test(rel)) continue;
+ if (!/^(https?:\/\/|\/)[^\s"'<>]*$/.test(url)) continue;
+ out = out.split(rel).join(url);
+ }
+ return out;
+}
+
+const MAX_FONT_FACES = 24;
+const MAX_FONT_BYTES = 2.5 * 1024 * 1024;
+
+const blobToDataUrl = (b: Blob) => new Promise((resolve, reject) => {
+ const r = new FileReader();
+ r.onload = () => resolve(String(r.result));
+ r.onerror = () => reject(new Error('read failed'));
+ r.readAsDataURL(b);
+});
+
+/** True when a @font-face unicode-range could cover basic Latin (or has no range at all). */
+function coversLatin(range: string): boolean {
+ if (!range.trim()) return true;
+ return range.split(',').some((part) => {
+ const m = /^U\+([0-9A-F?]+)(?:-([0-9A-F]+))?$/i.exec(part.trim());
+ if (!m) return false;
+ const lo = parseInt(m[1].replace(/\?/g, '0'), 16);
+ const hi = m[2] ? parseInt(m[2], 16) : m[1].includes('?') ? parseInt(m[1].replace(/\?/g, 'F'), 16) : lo;
+ return lo <= 0x7e && hi >= 0x41;
+ });
+}
+
+/**
+ * Our own web-font embedding. html-to-image's built-in version silently produced
+ * fallback fonts for Google Fonts, Font Awesome and same-origin @font-face in the
+ * measurement (see the capture notes), so we inline every readable @font-face as a
+ * data URL ourselves and hand the result in as `fontEmbedCSS`.
+ */
+export async function buildFontEmbedCss(doc: Document): Promise<{ css: string; faces: number; failed: number }> {
+ const jobs: Promise[] = [];
+ let failed = 0;
+ let total = 0;
+ for (const sheet of Array.from(doc.styleSheets)) {
+ let rules: CSSRuleList;
+ try { rules = sheet.cssRules; } catch { failed++; continue; }
+ for (const rule of Array.from(rules)) {
+ if (!(rule instanceof (doc.defaultView as unknown as typeof globalThis).CSSFontFaceRule)) continue;
+ const ff = rule as CSSFontFaceRule;
+ if (!coversLatin(ff.style.getPropertyValue('unicode-range'))) continue;
+ if (jobs.length >= MAX_FONT_FACES) break;
+ const base = sheet.href || doc.baseURI;
+ const src = ff.style.getPropertyValue('src');
+ const urls = Array.from(src.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)).map((m) => m[1]);
+ const family = ff.style.getPropertyValue('font-family');
+ const weight = ff.style.getPropertyValue('font-weight') || 'normal';
+ const style = ff.style.getPropertyValue('font-style') || 'normal';
+ jobs.push((async () => {
+ for (const u of urls) {
+ if (u.startsWith('data:')) return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${u})}`;
+ try {
+ const res = await fetch(new URL(u, base).href);
+ if (!res.ok) continue;
+ const blob = await res.blob();
+ total += blob.size;
+ if (total > MAX_FONT_BYTES) return null;
+ return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${await blobToDataUrl(blob)})}`;
+ } catch { /* try the next format */ }
+ }
+ failed++;
+ return null;
+ })());
+ }
+ }
+ const parts = (await Promise.all(jobs)).filter((x): x is string => !!x);
+ return { css: parts.join('\n'), faces: parts.length, failed };
+}
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+export async function captureHtml(html: string, opts: CaptureOptions = {}): Promise {
+ const width = opts.width ?? 1280;
+ const maxHeight = opts.maxHeight ?? 3000;
+ const timeoutMs = opts.timeoutMs ?? 20000;
+ const maxBytes = opts.maxBytes ?? 3 * 1024 * 1024;
+ if (typeof document === 'undefined' || !document.body) return { ok: false, reason: 'no document' };
+
+ const frame = document.createElement('iframe');
+ frame.setAttribute('sandbox', 'allow-same-origin');
+ frame.setAttribute('aria-hidden', 'true');
+ frame.tabIndex = -1;
+ frame.style.cssText = `position:fixed;left:-99999px;top:0;width:${width}px;height:900px;border:0;visibility:hidden;pointer-events:none`;
+
+ let timer: ReturnType | undefined;
+ const work = (async (): Promise => {
+ await new Promise((resolve, reject) => {
+ frame.addEventListener('load', () => resolve(), { once: true });
+ frame.addEventListener('error', () => reject(new Error('frame failed to load')), { once: true });
+ frame.srcdoc = prepareCaptureHtml(html, opts.injectFontAwesome !== false);
+ document.body.appendChild(frame);
+ });
+ const doc = frame.contentDocument;
+ if (!doc || !doc.body) return { ok: false, reason: 'page not readable' };
+ // Fonts + images: wait for what the page asked for, then a short settle.
+ try { await (doc as Document & { fonts?: { ready: Promise } }).fonts?.ready; } catch { /* ignore */ }
+ await Promise.all(Array.from(doc.images).map((img) => (img.complete ? null : new Promise((r) => {
+ img.addEventListener('load', () => r(), { once: true });
+ img.addEventListener('error', () => r(), { once: true });
+ }))));
+ await sleep(opts.settleMs ?? 600);
+
+ const full = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight, 1);
+ const height = Math.min(full, maxHeight);
+ frame.style.height = `${height}px`;
+ await sleep(50);
+ const fonts = await buildFontEmbedCss(doc);
+ const dataUrl = await toJpeg(doc.body, {
+ quality: opts.quality ?? 0.8,
+ width, height, canvasWidth: width, canvasHeight: height, pixelRatio: 1,
+ backgroundColor: '#ffffff',
+ cacheBust: false,
+ fontEmbedCSS: fonts.css,
+ style: { margin: '0' },
+ });
+ const blob = await (await fetch(dataUrl)).blob();
+ if (blob.size < 2000) return { ok: false, reason: 'capture came out empty' };
+ if (blob.size > maxBytes) return { ok: false, reason: 'capture too large' };
+ return { ok: true, blob, width, height, cropped: full > maxHeight };
+ })();
+
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve({ ok: false, reason: 'capture timed out' }), timeoutMs);
+ });
+ try {
+ return await Promise.race([work, timeout]);
+ } catch (e) {
+ return { ok: false, reason: e instanceof Error ? e.message.slice(0, 120) : 'capture failed' };
+ } finally {
+ if (timer) clearTimeout(timer);
+ frame.remove();
+ work.catch(() => undefined);
+ }
+}
From 197b9c46ab6bcd7b7bc3c8d1961e6db2b00aa5e7 Mon Sep 17 00:00:00 2001
From: Josh Knapp
Date: Sun, 1 Nov 2026 10:31:29 -0800
Subject: [PATCH 02/10] Sitesmith self-review capture: sanitise page HTML
before it reaches the frame (inert DOMParser allow-list, fixed-point guard),
CSP + script-less sandbox, real-browser hostile-page proof; UI building
blocks (attachments, sites I like, asset awareness, design payload)
Co-Authored-By: Claude Sonnet 5.5
---
craft/scripts/e2e-capture-security-entry.ts | 16 ++
craft/scripts/e2e-capture-security.mjs | 101 +++++++++++
craft/src/hooks/useSitesmith.ts | 6 +
craft/src/panels/sitesmith/AssetAwareness.tsx | 53 ++++++
craft/src/panels/sitesmith/AttachmentTray.tsx | 113 ++++++++++++
craft/src/panels/sitesmith/Section.tsx | 30 ++++
.../src/panels/sitesmith/SitesILikePanel.tsx | 165 +++++++++++++++++
.../src/panels/sitesmith/useSitesmithRefs.ts | 85 +++++++++
craft/src/types/sitesmith.ts | 5 +-
craft/src/utils/sanitize-capture-html.test.ts | 116 ++++++++++++
craft/src/utils/sanitize-capture-html.ts | 169 ++++++++++++++++++
craft/src/utils/self-review-capture.ts | 32 +++-
craft/src/utils/sitesmith-attachments.ts | 45 +++++
craft/src/utils/sitesmith-design.ts | 39 ++++
14 files changed, 965 insertions(+), 10 deletions(-)
create mode 100644 craft/scripts/e2e-capture-security-entry.ts
create mode 100644 craft/scripts/e2e-capture-security.mjs
create mode 100644 craft/src/panels/sitesmith/AssetAwareness.tsx
create mode 100644 craft/src/panels/sitesmith/AttachmentTray.tsx
create mode 100644 craft/src/panels/sitesmith/Section.tsx
create mode 100644 craft/src/panels/sitesmith/SitesILikePanel.tsx
create mode 100644 craft/src/panels/sitesmith/useSitesmithRefs.ts
create mode 100644 craft/src/utils/sanitize-capture-html.test.ts
create mode 100644 craft/src/utils/sanitize-capture-html.ts
create mode 100644 craft/src/utils/sitesmith-attachments.ts
create mode 100644 craft/src/utils/sitesmith-design.ts
diff --git a/craft/scripts/e2e-capture-security-entry.ts b/craft/scripts/e2e-capture-security-entry.ts
new file mode 100644
index 0000000..ba83388
--- /dev/null
+++ b/craft/scripts/e2e-capture-security-entry.ts
@@ -0,0 +1,16 @@
+// Bundled by scripts/e2e-capture-security.mjs and run in real Firefox + Chromium.
+import { captureHtml } from '../src/utils/self-review-capture';
+import { captureCsp } from '../src/utils/sanitize-capture-html';
+
+(window as any).captureHtml = captureHtml;
+/** Defence-in-depth probe: the capture frame WITHOUT the sanitiser (sandbox + CSP only). */
+(window as any).rawFrame = (html: string) => new Promise((resolve) => {
+ const f = document.createElement('iframe');
+ f.setAttribute('sandbox', 'allow-same-origin');
+ const CSP = captureCsp(location.origin);
+ f.setAttribute('csp', CSP);
+ f.style.cssText = 'position:fixed;left:-9999px;width:800px;height:600px';
+ f.addEventListener('load', () => setTimeout(resolve, 800), { once: true });
+ f.srcdoc = html.replace('', ``);
+ document.body.appendChild(f);
+});
diff --git a/craft/scripts/e2e-capture-security.mjs b/craft/scripts/e2e-capture-security.mjs
new file mode 100644
index 0000000..11fa801
--- /dev/null
+++ b/craft/scripts/e2e-capture-security.mjs
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+/**
+ * Real-browser proof (Firefox + Chromium) that the self-review capture cannot run or leak hostile page
+ * markup. A malicious fixture goes through the WHOLE capture path; sentinels: window.__pwned in the
+ * parent, any request to /beacon or evil.example, parent cookie/localStorage reachability, frame
+ * navigation. A second probe feeds the SAME hostile page to the frame WITHOUT the sanitiser to show the
+ * sandbox + CSP alone also hold. Benign pages must still capture.
+ * Run: node scripts/e2e-capture-security.mjs (CHROMIUM_PATH / FIREFOX_PATH override the binaries)
+ */
+import { build } from 'esbuild';
+import { chromium, firefox } from '@playwright/test';
+import http from 'node:http';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const out = await build({
+ entryPoints: [path.join(root, 'scripts/e2e-capture-security-entry.ts')],
+ bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error',
+ define: { 'process.env.NODE_ENV': '"production"' },
+});
+const js = out.outputFiles[0].text;
+
+const PWN = "(function(){try{parent.__pwned=1;top.__pwned=1}catch(e){}try{fetch('/beacon?js')}catch(e){}try{top.postMessage('pwned','*')}catch(e){}try{new Image().src='/beacon?img-js'}catch(e){}})()";
+const EVIL = `
+
+
+
+
+
+Hostile but readable
+
+
+js link
+
+
">',
+ '