Sitesmith: contrast auto-repair for model responses; capture module (html-to-image) for self-review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f5c311118
commit
0fde4dc22f
5 files changed
+667
No files matched your search
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<string, unknown>, 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)');
|
||||
});
|
||||
});
|
||||
@@ -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<string, any>; 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<string, NamedPair[]> = {
|
||||
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<string, number> = { 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<string, any> | 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<string, any>, 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<string, any>;
|
||||
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<string, any>, 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<SerializedTreeNode>();
|
||||
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<string, any>;
|
||||
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<string, any>, 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<string, any>,
|
||||
fgKey: string, bgKey: string | undefined, style: Record<string, any> | 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<string, any>, 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<string, any>, 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<string, any> = { ...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<string, any>;
|
||||
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;
|
||||
}
|
||||
@@ -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(/<link\b[^>]*>/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 = `<link rel="stylesheet" href="${FONT_AWESOME_CSS}" crossorigin="anonymous" referrerpolicy="no-referrer">`;
|
||||
out = /<\/head>/i.test(out) ? out.replace(/<\/head>/i, () => link + '</head>') : link + out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Replace relative `assets/<file>` references with fetchable URLs (the editor page is not at the site root). */
|
||||
export function resolveAssetUrls(html: string, urls: Record<string, string>): 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<string>((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<string | null>[] = [];
|
||||
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<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export async function captureHtml(html: string, opts: CaptureOptions = {}): Promise<CaptureResult> {
|
||||
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<typeof setTimeout> | undefined;
|
||||
const work = (async (): Promise<CaptureResult> => {
|
||||
await new Promise<void>((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<unknown> } }).fonts?.ready; } catch { /* ignore */ }
|
||||
await Promise.all(Array.from(doc.images).map((img) => (img.complete ? null : new Promise<void>((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<CaptureResult>((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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user