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
+ + +
x
+
s
+

Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}

+`; +const BENIGN = ` +

Benign page

${'Hello world. '.repeat(30)}

`; + +const beacons = []; +const srv = http.createServer((q, r) => { + const p = q.url.split('?')[0]; + if (p === '/') { r.setHeader('content-type', 'text/html'); return r.end('hosthost'); } + if (p === '/beacon') { beacons.push(q.url); r.statusCode = 204; return r.end(); } + r.statusCode = 404; r.end('nf'); +}).listen(0); +const port = srv.address().port; + +let failed = 0; +const check = (name, label, ok, extra = '') => { if (!ok) failed++; console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); }; + +async function run(name, launcher, exe) { + const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const evilReq = []; + await ctx.route(/evil\.example/, (route) => { evilReq.push(route.request().url()); route.abort(); }); + // Only requests that actually reach the network count (Playwright also reports CSP-blocked ones as 'request'). + let messages = 0; + await page.exposeFunction('__onMsg', () => { messages++; }); + await page.goto(`http://localhost:${port}/`); + await page.evaluate(() => { document.cookie = 'sess=SECRET; path=/'; localStorage.setItem('csrf', 'SECRET'); window.addEventListener('message', (e) => { if (e.data === 'pwned') window.__onMsg(); }); }); + await page.addScriptTag({ content: js }); + + // 1) hostile page through the whole capture path + beacons.length = 0; + const res = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size } : r; }, EVIL); + await page.waitForTimeout(500); + const pwned = await page.evaluate(() => window.__pwned); + check(name, 'hostile page: parent sentinel window.__pwned untouched', pwned === undefined, `(${pwned})`); + check(name, 'hostile page: no postMessage to the editor', messages === 0, `(${messages})`); + check(name, 'hostile page: no request to the panel origin /beacon', beacons.length === 0, JSON.stringify(beacons)); + check(name, 'hostile page: no request to evil.example', evilReq.length === 0, JSON.stringify(evilReq)); + check(name, 'hostile page: capture result is safe (ok, or refused) and frame removed', (res.ok || typeof res.reason === 'string') && (await page.evaluate(() => document.querySelectorAll('iframe').length)) === 0, JSON.stringify(res)); + check(name, 'hostile page: sanitised content still captured (readable page)', res.ok === true && res.size > 3000, JSON.stringify(res)); + + // 2) defence in depth: same hostile page, NO sanitiser, only sandbox + CSP + beacons.length = 0; evilReq.length = 0; messages = 0; + await page.evaluate((h) => window.rawFrame(h), EVIL); + await page.waitForTimeout(500); + const pwned2 = await page.evaluate(() => window.__pwned); + check(name, 'unsanitised probe: sandbox+CSP alone stop script execution (__pwned)', pwned2 === undefined, `(${pwned2})`); + check(name, 'unsanitised probe: no postMessage', messages === 0); + // Same-origin passive GETs (img/css url) are allowed by CSP 'self'; that is why the sanitiser also restricts them. Script-initiated ones must never occur. + check(name, 'unsanitised probe: no script-initiated requests (js/img-js beacons)', !beacons.some((b) => /\?(js|img-js|refresh|import|iframe|object|embed|video)$/.test(b)), JSON.stringify(beacons)); + check(name, 'unsanitised probe: no evil.example requests', evilReq.length === 0, JSON.stringify(evilReq)); + await page.evaluate(() => document.querySelectorAll('iframe').forEach((f) => f.remove())); + + // 3) benign page still captures + const ok = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size, w: r.width, h: r.height } : r; }, BENIGN); + check(name, 'benign page captures', ok.ok === true && ok.size > 3000, JSON.stringify(ok)); + await browser.close(); +} +await run('chromium', chromium, process.env.CHROMIUM_PATH); +await run('firefox', firefox, process.env.FIREFOX_PATH); +srv.close(); +process.exit(failed ? 1 : 0); diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index 5542b94..4dc5b51 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -61,6 +61,10 @@ export interface SitesmithSendOpts { mockupIds?: string[]; referenceIds?: number[]; useAssets?: boolean; + /** Current site design (see utils/sitesmith-design.ts); read by the backend prompt builder. */ + design?: Record; + /** Uploaded assets Sitesmith may see (relative filenames). Omitted = all. Needs backend support; see report. */ + assetFilenames?: string[]; onProgress?: (phase: string) => void; signal?: AbortSignal; } @@ -332,6 +336,7 @@ export function useSitesmith(siteId: number) { if (target) body.target = target; if (profile) body.profile = profile; if (opts?.intent) body.intent = opts.intent; + if (opts?.design && Object.keys(opts.design).length) body.design = opts.design; let result: SendResult; let usedStart = false; @@ -340,6 +345,7 @@ export function useSitesmith(siteId: number) { if (opts?.mockupIds?.length) startBody.mockup_ids = opts.mockupIds; if (opts?.referenceIds?.length) startBody.reference_ids = opts.referenceIds; if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets; + if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames; const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, { method: 'POST', credentials: 'include', diff --git a/craft/src/panels/sitesmith/AssetAwareness.tsx b/craft/src/panels/sitesmith/AssetAwareness.tsx new file mode 100644 index 0000000..182d834 --- /dev/null +++ b/craft/src/panels/sitesmith/AssetAwareness.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type { SitesmithAsset } from '../../hooks/useSitesmith'; +import { note, thumb, errBox } from './Section'; + +interface Props { + assets: SitesmithAsset[]; + loaded: boolean; + error?: string | null; + /** Filenames Sitesmith may see. */ + included: Set; + onToggle: (filename: string) => void; + onAll: (on: boolean) => void; + /** Public-safe display URL for a thumbnail. */ + thumbUrl: (a: SitesmithAsset) => string; + visionAvailable: boolean; + disabled?: boolean; +} + +/** Which uploaded images Sitesmith will see, with the captions it has, and a per-image toggle. */ +export const AssetAwareness: React.FC = ({ assets, loaded, error, included, onToggle, onAll, thumbUrl, visionAvailable, disabled }) => ( +
+

+ Sitesmith can use the images you uploaded to this site and place them on the page. Untick any you do not want it to use. + {visionAvailable + ? ' It looks at each picture (a small copy) and writes a short description the first time.' + : ' Image viewing is not available on this server, so Sitesmith works from file names and sizes only.'} +

+ {error &&
{error}
} + {!loaded && !error &&

Loading your images…

} + {loaded && !error && assets.length === 0 &&

You have not uploaded any images yet. Add some in the Assets tab and Sitesmith will be able to use them.

} + {assets.length > 0 && ( + <> +
+ + + {included.size} of {assets.length} included +
+
    + {assets.map((a) => ( +
  • + onToggle(a.filename)} aria-label={`Let Sitesmith use ${a.filename}`} /> + +
    +
    {a.filename}{a.width && a.height ? · {a.width}×{a.height} : null}
    +
    {a.caption || a.alt || 'No description yet'}
    +
    +
  • + ))} +
+ + )} +
+); diff --git a/craft/src/panels/sitesmith/AttachmentTray.tsx b/craft/src/panels/sitesmith/AttachmentTray.tsx new file mode 100644 index 0000000..c25689a --- /dev/null +++ b/craft/src/panels/sitesmith/AttachmentTray.tsx @@ -0,0 +1,113 @@ +import React, { useRef, useState } from 'react'; +import type { SitesmithRefImage } from '../../hooks/useSitesmith'; +import { formatBytes } from '../../utils/sitesmith-attachments'; +import { btn, btnDanger, errBox, note, thumb, warnBox } from './Section'; + +interface Props { + /** Mockup images already stored for this site (from refs_list). */ + images: SitesmithRefImage[]; + selected: string[]; + onSelectedChange: (ids: string[]) => void; + /** Total image budget for one send (mockups + reference screenshots). */ + maxAttach: number; + /** Images already counted by selected "Sites I like" entries. */ + referenceImageCount: number; + imageUrl: (id: string) => string; + onUpload: (file: File) => Promise<{ ok: true; image?: SitesmithRefImage } | { ok: false; message: string }>; + onRemove: (id: string) => Promise<{ ok: true } | { ok: false; message: string }>; + /** Image features off: why (vision unavailable). Text-only generation is unaffected. */ + unavailableReason?: string | null; + disabled?: boolean; + /** Called with the message when a file is rejected or an upload fails. */ +} + +/** Mockup picker: drag and drop or file picker, thumbnails, remove. Image names are rendered as text nodes only. */ +export const AttachmentTray: React.FC = ({ + images, selected, onSelectedChange, maxAttach, referenceImageCount, imageUrl, onUpload, onRemove, unavailableReason, disabled, +}) => { + const input = useRef(null); + const [drag, setDrag] = useState(false); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const mockups = images.filter((i) => i.kind === 'mockup'); + const off = !!unavailableReason || !!disabled; + const used = selected.length + referenceImageCount; + + const addFiles = async (files: FileList | File[] | null) => { + if (!files || off) return; + setErr(null); + setBusy(true); + try { + for (const f of Array.from(files)) { + if (used + 1 > maxAttach) { setErr(`You can attach up to ${maxAttach} images to one request.`); break; } + const r = await onUpload(f); + if (!r.ok) { setErr(r.message); break; } + if (r.image) onSelectedChange([...selected, r.image.id]); + } + } finally { setBusy(false); } + }; + + const toggle = (id: string) => { + if (selected.includes(id)) onSelectedChange(selected.filter((x) => x !== id)); + else if (used < maxAttach) onSelectedChange([...selected, id]); + else setErr(`You can attach up to ${maxAttach} images to one request.`); + }; + + const remove = async (id: string) => { + setErr(null); + const r = await onRemove(id); + if (!r.ok) { setErr(r.message); return; } + onSelectedChange(selected.filter((x) => x !== id)); + }; + + return ( +
+

+ Attach a picture of the page you want (a sketch, a screenshot, a design export). Sitesmith builds toward it. + The image is kept privately for Sitesmith; it is never published or added to your assets. +

+ {unavailableReason &&
{unavailableReason}
} +
{ if (off) return; e.preventDefault(); setDrag(true); }} + onDragLeave={() => setDrag(false)} + onDrop={(e) => { e.preventDefault(); setDrag(false); void addFiles(e.dataTransfer?.files ?? null); }} + style={{ + border: `1px dashed ${drag ? '#a78bfa' : '#3f3f46'}`, borderRadius: 8, padding: 12, textAlign: 'center', + background: drag ? 'rgba(124,58,237,0.12)' : 'transparent', opacity: off ? 0.5 : 1, + }} + > + + or drop it here (JPEG, PNG, WebP or GIF, up to 8 MB) + { void addFiles(e.target.files); e.target.value = ''; }} + /> +
+ {err &&
{err}
} + {mockups.length > 0 && ( +
    + {mockups.map((im) => { + const on = selected.includes(im.id); + return ( +
  • + +
    {im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}
    + +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/craft/src/panels/sitesmith/Section.tsx b/craft/src/panels/sitesmith/Section.tsx new file mode 100644 index 0000000..02687f2 --- /dev/null +++ b/craft/src/panels/sitesmith/Section.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +const sectionStyle: React.CSSProperties = { border: '1px solid #27272a', borderRadius: 8, marginBottom: 8, background: '#13131c' }; +const headStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, width: '100%', background: 'transparent', border: 'none', color: '#e4e4e7', padding: '8px 12px', cursor: 'pointer', fontSize: 13, fontWeight: 600, textAlign: 'left' }; +const bodyStyle: React.CSSProperties = { padding: '4px 12px 12px', fontSize: 13, color: '#d4d4d8' }; + +/** Collapsible block used by the Sitesmith context area. */ +export const Section: React.FC<{ + id: string; title: string; icon?: string; badge?: string; open: boolean; onToggle: () => void; children: React.ReactNode; +}> = ({ id, title, icon, badge, open, onToggle, children }) => ( +
+ + {open &&
{children}
} +
+); + +export const note: React.CSSProperties = { fontSize: 12, color: '#a1a1aa', lineHeight: 1.5, margin: '4px 0 8px' }; +export const warnBox: React.CSSProperties = { background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.4)', color: '#fde68a', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' }; +export const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' }; +export const btn: React.CSSProperties = { background: 'transparent', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' }; +export const btnPrimary: React.CSSProperties = { background: '#7c3aed', color: '#fff', border: '1px solid #7c3aed', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }; +export const btnDanger: React.CSSProperties = { background: 'transparent', color: '#fca5a5', border: '1px solid #7f1d1d', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' }; +export const inputStyle: React.CSSProperties = { width: '100%', boxSizing: 'border-box', background: '#0f0f17', color: '#e5e7eb', border: '1px solid #3f3f46', borderRadius: 6, padding: '6px 8px', fontSize: 13 }; +export const thumb: React.CSSProperties = { width: 64, height: 64, objectFit: 'cover', borderRadius: 6, border: '1px solid #3f3f46', background: '#0f0f17', display: 'block' }; diff --git a/craft/src/panels/sitesmith/SitesILikePanel.tsx b/craft/src/panels/sitesmith/SitesILikePanel.tsx new file mode 100644 index 0000000..06ffcfb --- /dev/null +++ b/craft/src/panels/sitesmith/SitesILikePanel.tsx @@ -0,0 +1,165 @@ +import React, { useRef, useState } from 'react'; +import type { SitesmithReference, SitesmithRefImage } from '../../hooks/useSitesmith'; +import { btn, btnDanger, btnPrimary, errBox, inputStyle, note, thumb, warnBox } from './Section'; +import type { Outcome } from './useSitesmithRefs'; + +interface Props { + references: SitesmithReference[]; + images: SitesmithRefImage[]; + selected: number[]; + onSelectedChange: (ids: number[]) => void; + imageUrl: (id: string) => string; + loadError?: string | null; + addRef: (url: string, likes: string, shots: string[]) => Promise; + updateRef: (id: number, url: string, likes: string, shots: string[]) => Promise; + deleteRef: (id: number) => Promise; + uploadShot: (f: File) => Promise; + removeImage: (id: string) => Promise; + maxRefs: number; + /** Image features unavailable (no vision): text-only entries still work. */ + imagesUnavailableReason?: string | null; + disabled?: boolean; +} + +const MAX_SHOTS = 3; + +interface FormState { id: number | null; url: string; likes: string; shots: string[]; added: string[] } +const EMPTY: FormState = { id: null, url: '', likes: '', shots: [], added: [] }; + +/** "Sites I like": a per-site list of label + notes + optional screenshots. The address is a label and is never fetched. */ +export const SitesILikePanel: React.FC = ({ + references, images, selected, onSelectedChange, imageUrl, loadError, addRef, updateRef, deleteRef, uploadShot, removeImage, + maxRefs, imagesUnavailableReason, disabled, +}) => { + const [form, setForm] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmDel, setConfirmDel] = useState(null); + const fileRef = useRef(null); + const imgById = new Map(images.map((i) => [i.id, i])); + + const startAdd = () => { setErr(null); setForm({ ...EMPTY }); }; + const startEdit = (r: SitesmithReference) => { setErr(null); setForm({ id: r.id, url: r.url_label, likes: r.what_i_like, shots: [...r.screenshot_ids], added: [] }); }; + + const cancel = async () => { + // Screenshots uploaded for a form that was never saved would be orphans: drop them. + if (form) for (const id of form.added) await removeImage(id); + setForm(null); setErr(null); + }; + + const save = async () => { + if (!form) return; + setBusy(true); setErr(null); + try { + const r = form.id === null + ? await addRef(form.url, form.likes, form.shots) + : await updateRef(form.id, form.url, form.likes, form.shots); + if (!r.ok) { setErr(r.message); return; } + setForm(null); + } finally { setBusy(false); } + }; + + const addShot = async (files: FileList | null) => { + if (!files || !form) return; + setErr(null); setBusy(true); + try { + for (const f of Array.from(files)) { + if (form.shots.length >= MAX_SHOTS) { setErr(`Up to ${MAX_SHOTS} screenshots per site.`); break; } + const r = await uploadShot(f); + if (!r.ok) { setErr(r.message); break; } + const id = r.image!.id; + setForm((s) => (s ? { ...s, shots: [...s.shots, id], added: [...s.added, id] } : s)); + form.shots = [...form.shots, id]; + } + } finally { setBusy(false); } + }; + + const dropShot = async (id: string) => { + if (!form) return; + // Removing from a saved entry detaches it; an image added in this form is deleted outright. + if (form.added.includes(id)) await removeImage(id); + setForm((s) => (s ? { ...s, shots: s.shots.filter((x) => x !== id), added: s.added.filter((x) => x !== id) } : s)); + }; + + const del = async (id: number) => { + setErr(null); + const r = await deleteRef(id); + setConfirmDel(null); + if (!r.ok) { setErr(r.message); return; } + onSelectedChange(selected.filter((x) => x !== id)); + }; + + const toggle = (id: number) => onSelectedChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]); + + return ( +
+

+ List websites whose look you admire and say what you like about each. The address is only a label for you: + Sitesmith does not open, visit or fetch it. It works from your description and any screenshots you attach yourself. +

+ {loadError &&
{loadError}
} + {err && !form &&
{err}
} + + {references.length === 0 && !form &&

No sites yet.

} +
    + {references.map((r) => ( +
  • +
    + toggle(r.id)} aria-label={`Use ${r.url_label} for the next request`} style={{ marginTop: 3 }} /> +
    +
    {r.url_label}
    +
    {r.what_i_like}
    + {r.screenshot_ids.length > 0 && ( +
    + {r.screenshot_ids.map((sid) => ( + {`Screenshot + ))} +
    + )} +
    + + {confirmDel === r.id + ? + : } +
    +
  • + ))} +
+ + {form ? ( +
+ + setForm({ ...form, url: e.target.value })} /> + +