From 0fde4dc22fd6839433f48606a717ec95e83e1eb3 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:23:26 -0800 Subject: [PATCH] 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); + } +}