diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index 4dc5b51..cf084f2 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -167,6 +167,7 @@ export function useSitesmith(siteId: number) { const [error, setError] = useState(null); const [activeJob, setActiveJobState] = useState(null); const [capabilities, setCapabilities] = useState(null); + const [capabilitiesFailed, setCapabilitiesFailed] = useState(false); useEffect(() => { alive.current = true; @@ -189,6 +190,9 @@ export function useSitesmith(siteId: number) { setCapabilities({ vision: p.body.vision, features: p.body.features, limits: p.body.limits, } as SitesmithCapabilities); + } else { + // Older backend without `capabilities`: image features are unavailable, text generation is unaffected. + setCapabilitiesFailed(true); } }, [whpConfig]); @@ -483,7 +487,7 @@ export function useSitesmith(siteId: number) { return { summary, messages, loading, error, send, refreshEntitlement, clearHistory, - activeJob, resumeJob, cancel, capabilities, + activeJob, resumeJob, cancel, capabilities, capabilitiesFailed, listRefs, addReference, updateReference, deleteReference, uploadRefImage, deleteRefImage, refImageUrl, listAssets, }; diff --git a/craft/src/panels/sitesmith/JobStatus.tsx b/craft/src/panels/sitesmith/JobStatus.tsx new file mode 100644 index 0000000..cede96a --- /dev/null +++ b/craft/src/panels/sitesmith/JobStatus.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { btn } from './Section'; + +export const PHASE_LABEL: Record = { + queued: 'Waiting to start', + preparing: 'Preparing your request', + captioning: 'Looking at your images', + generating: 'Designing your page', + retrying: 'Trying again to get a valid result', + finalizing: 'Finishing up', + done: 'Done', + review: 'Reviewing the finished page', +}; + +const ORDER = ['queued', 'preparing', 'captioning', 'generating', 'finalizing']; + +interface Props { + phase: string; + /** Seconds since the job began, if the server knows (a resumed job); else counted from mount. */ + startedAgoS?: number; + onCancel?: () => void; + cancelling?: boolean; + resumed?: boolean; +} + +/** Live status line for a running Sitesmith job: phase, elapsed time, steps, and a Cancel button. */ +export const JobStatus: React.FC = ({ phase, startedAgoS = 0, onCancel, cancelling, resumed }) => { + const [t0] = useState(() => Date.now() - startedAgoS * 1000); + const [elapsed, setElapsed] = useState(startedAgoS); + useEffect(() => { + const id = window.setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 1000); + return () => window.clearInterval(id); + }, [t0]); + const label = cancelling ? 'Cancelling…' : (PHASE_LABEL[phase] ?? 'Working'); + const idx = ORDER.indexOf(phase); + return ( +
+
+ + {label}… + {elapsed}s + + {onCancel && } +
+ {idx >= 0 && ( +
+ {ORDER.map((p, i) => )} +
+ )} +
+ {resumed ? 'Picked up a request that was already running. ' : ''} + You can close this window: Sitesmith keeps working and the result will be waiting when you come back. +
+
+ ); +}; diff --git a/craft/src/panels/sitesmith/SitesmithModal.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.test.tsx index 041a8a1..e3febf9 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.test.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.test.tsx @@ -51,6 +51,8 @@ async function mount(withTarget: boolean, profile: unknown = PROFILE, initialSta if (profile) h.act(() => extras.set('sitesmithProfile', profile)); await flush(); } +/** start returns at once; the first poll is 1 s later (real timers). */ +const flushPoll = () => h.act(async () => { await new Promise((r) => setTimeout(r, 1300)); }); const regenBtn = () => [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Regenerate this section')) as HTMLButtonElement | undefined; beforeEach(() => { @@ -70,7 +72,11 @@ beforeEach(() => { }; vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => { const json = (v: unknown) => ({ json: async () => v }); - if (url.includes('action=send')) { sendBodies.push(JSON.parse(init.body)); return json(sendReply); } + if (url.includes('action=start')) { sendBodies.push(JSON.parse(init.body)); return json({ ok: true, job_id: 'j1', status: 'running', phase: 'queued' }); } + if (url.includes('action=poll')) return json({ ok: true, job_id: 'j1', status: 'done', phase: 'done', result: sendReply }); + if (url.includes('action=capabilities')) return json({ ok: true, vision: { available: false, source: 'none', message: 'No vision' }, features: { mockups: false, reference_screenshots: false, captions: false, self_review: false }, limits: {} }); + if (url.includes('action=refs_list')) return json({ ok: true, references: [], images: [] }); + if (url.includes('action=assets')) return json({ ok: true, assets: [] }); if (url.includes('action=entitlement')) return json({ ok: true, summary: { enabled: true, status: entitlementStatus, monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } }); return json({ ok: true, messages: [] }); })); @@ -82,7 +88,7 @@ describe('SitesmithModal regenerate', () => { await mount(true); expect(h.container.textContent).toContain('Original heading'); h.act(() => { regenBtn()!.click(); }); - await flush(); + await flushPoll(); expect(sendBodies).toHaveLength(1); expect(sendBodies[0].intent).toBe('regenerate_section'); @@ -108,7 +114,7 @@ describe('SitesmithModal regenerate', () => { sendReply = { ok: true, response: { type: 'patch', message: 'x', ops: [] } }; await mount(true); h.act(() => { regenBtn()!.click(); }); - await flush(); + await flushPoll(); expect(h.container.textContent).toContain('Original heading'); expect(document.querySelector('[role="alert"]')?.textContent).toMatch(/did not return/i); expect(onClose).not.toHaveBeenCalled(); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index 0e9f016..550135b 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -1,5 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { useEditor } from '@craftjs/core'; +import { useSiteDesign } from '../../state/SiteDesignContext'; +import { usePages } from '../../state/PageContext'; import { useNotices } from '../../state/NoticeContext'; import { useSitesmithModal } from '../../state/SitesmithContext'; import { @@ -9,14 +11,26 @@ import { import { applyRegeneratedSection, isProfileComplete, profileSummary } from '../../utils/sitesmith-profile'; import { useEditorConfig } from '../../state/EditorConfigContext'; import { useSitesmith } from '../../hooks/useSitesmith'; -import { useApplyAiResponse } from '../../utils/apply-ai-response'; +import { useApplyAiResponse, repairForCanvas } from '../../utils/apply-ai-response'; +import { summarizeAdjustments, type ContrastAdjustment } from '../../utils/contrast-repair'; +import { buildDesignPayload } from '../../utils/sitesmith-design'; +import { composeReviewHtml } from '../../utils/compose-review-html'; +import { captureHtml, resolveAssetUrls } from '../../utils/self-review-capture'; +import { runSelfReview } from './selfReview'; +import { useSitesmithRefs } from './useSitesmithRefs'; +import { Section, warnBox, errBox as errBoxStyle, note as noteStyle } from './Section'; +import { AttachmentTray } from './AttachmentTray'; +import { SitesILikePanel } from './SitesILikePanel'; +import { AssetAwareness } from './AssetAwareness'; +import { JobStatus } from './JobStatus'; +import type { SitesmithAsset } from '../../hooks/useSitesmith'; +import type { SendResult } from '../../types/sitesmith'; import { summarizeCanvas } from '../../utils/canvas-summary'; import { SitesmithTarget } from '../../state/SitesmithContext'; import { UpgradeBanner } from './UpgradeBanner'; import { ScopeConfirmDialog } from './ScopeConfirmDialog'; import { MessageList } from './MessageList'; import { ChatInput } from './ChatInput'; -import { WorkingIndicator } from './WorkingIndicator'; import { SitesmithResponse } from '../../types/sitesmith'; import { Modal } from '../../ui/Modal'; @@ -34,10 +48,35 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { const { notify } = useNotices(); const { open: reopen } = useSitesmithModal(); const profile = useSitesmithProfile(); - const { summary, messages, send, loading, clearHistory, error: entitlementError } = useSitesmith(siteId); + const ss = useSitesmith(siteId); + const { summary, messages, send, loading, clearHistory, error: entitlementError, activeJob, resumeJob, cancel, capabilities, capabilitiesFailed } = ss; const apply = useApplyAiResponse(); + const { design } = useSiteDesign(); + const pageCtx = usePages(); const [busy, setBusy] = useState(false); - const [pendingReplace, setPendingReplace] = useState(null); + const [phase, setPhase] = useState('queued'); + const [cancelling, setCancelling] = useState(false); + const [resumed, setResumed] = useState(false); + const [reviewing, setReviewing] = useState(false); + const [notes, setNotes] = useState([]); + const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null); + const [reviewNote, setReviewNote] = useState(null); + const abortRef = useRef(null); + const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean } | null>(null); + const [open, setOpen] = useState>({}); + const [selMockups, setSelMockups] = useState([]); + const [selRefs, setSelRefs] = useState([]); + const [assets, setAssets] = useState([]); + const [assetsLoaded, setAssetsLoaded] = useState(false); + const [assetsError, setAssetsError] = useState(null); + const [excluded, setExcluded] = useState>(new Set()); + const [selfReviewOn, setSelfReviewOn] = useState(() => { + try { return window.localStorage.getItem('sitesmith.selfReview') !== 'off'; } catch { return true; } + }); + const setSelfReview = (v: boolean) => { + setSelfReviewOn(v); + try { window.localStorage.setItem('sitesmith.selfReview', v ? 'on' : 'off'); } catch { /* storage unavailable */ } + }; const [error, setError] = useState(null); const [confirmClearChat, setConfirmClearChat] = useState(false); const clearChatTimeoutRef = useRef | null>(null); @@ -58,7 +97,8 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { && (query.node('ROOT').get().data.nodes ?? []).length === 0, ); - const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY'); + const canChat = !!summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY'); + const running = busy || !!activeJob; const wizardFired = useRef(false); useEffect(() => { @@ -73,40 +113,178 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }; const header: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid #27272a', gap: 8 }; const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' }; - const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a' }; + const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a', maxHeight: '48vh', overflowY: 'auto' }; const closeBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: 'none', fontSize: 18, cursor: 'pointer' }; const clearBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer', marginRight: 8 }; const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '8px 12px', borderRadius: 6, marginBottom: 10, fontSize: 13 }; - const handleSend = async (text: string) => { - setBusy(true); setError(null); - try { - const canvas = summarizeCanvas(query.getSerializedNodes()); - const result = await send(text, canvas, targetPayload); - if (!result.ok) { setError(result.message || 'Failed'); return; } - if (result.response.type === 'replace' && result.response.scope === 'site') { - setPendingReplace(result.response); - return; - } - const applied = await apply(result.response, target?.nodeId); - if (!applied.ok) setError(applied.message || 'Apply failed'); - } catch (e: any) { setError(String(e?.message ?? e)); } - finally { setBusy(false); } - }; - const targetPayload = target ? { node_id: target.nodeId, display_name: target.displayName, tree_json: target.treeJson } : undefined; + // ---- what the request carries + const visionOk = !!capabilities?.vision?.available; + const features = capabilities?.features; + const limits = capabilities?.limits; + const capsUnknown = !capabilities && !capabilitiesFailed; + const visionReason: string | null = capsUnknown + ? 'Checking whether image features are available…' + : visionOk ? null + : (capabilities?.vision?.message + || 'Image features are not available on this server, so Sitesmith cannot look at pictures yet. Describing what you want in words works as usual.'); + const imagesOn = visionOk && features?.mockups !== false; + const refsApi = useSitesmithRefs(ss, !!cfg.whpConfig && !!canChat, limits?.max_upload_bytes); + const refsOn = !!cfg.whpConfig && !!canChat; + const selectedRefObjs = refsApi.references.filter((r) => selRefs.includes(r.id)); + const refImageCount = visionOk ? selectedRefObjs.reduce((n, r) => n + r.screenshot_ids.length, 0) : 0; + const allAssetNames = assets.map((a) => a.filename); + const includedAssets = new Set(allAssetNames.filter((f) => !excluded.has(f))); + const useAssetsFlag = !(assetsLoaded && assets.length > 0 && includedAssets.size === 0); + const assetFilter = assetsLoaded && excluded.size > 0 && includedAssets.size > 0 ? [...includedAssets] : undefined; + + const loadAssets = async () => { + const r = await ss.listAssets(); + if (r.ok) { setAssets(r.assets ?? []); setAssetsError(null); } else setAssetsError(r.message); + setAssetsLoaded(true); + }; + useEffect(() => { if (cfg.whpConfig && canChat && !assetsLoaded) void loadAssets(); }); // eslint-disable-line react-hooks/exhaustive-deps + + /** A browser-level failure text ("NetworkError when attempting to fetch resource") never reaches the user. */ + const friendly = (m: string | undefined | null): string => { + const t = (m ?? '').trim(); + if (!t) return 'Something went wrong. Please try again.'; + if (/NetworkError|Failed to fetch|Load failed|network request failed|AbortError/i.test(t)) return 'Could not reach the server. Check your connection and try again.'; + return t; + }; + + const sendOpts = () => ({ + design: buildDesignPayload(design), + mockupIds: imagesOn ? selMockups : [], + referenceIds: selRefs, + useAssets: useAssetsFlag, + assetFilenames: assetFilter, + }); + + // ---- self-review (one round, after a normal send has been applied) + const assetUrlMap = () => { + const urls: Record = {}; + for (const a of assets) { + try { + const u = new URL(a.url, window.location.href); + if (u.origin === window.location.origin) urls[a.relUrl || `assets/${a.filename}`] = u.pathname + u.search; + } catch { /* skip */ } + } + return urls; + }; + + const doSelfReview = async () => { + setReviewing(true); setPhase('review'); setReviewNote(null); + const urls = assetUrlMap(); + try { + const out = await runSelfReview({ + composeHtml: () => composeReviewHtml({ + serialized: query.serialize(), + headerState: pageCtx.headerPage.craftState, + footerState: pageCtx.footerPage.craftState, + title: pageCtx.pages.find((p) => p.id === pageCtx.activePageId)?.name ?? cfg.whpConfig?.siteName ?? 'Page', + design, + }), + assetUrls: urls, + allowUrls: Object.values(urls), + resolveAssetUrls, + capture: (html, o) => captureHtml(html, o), + upload: async (file) => { + const r = await ss.uploadRefImage(file, 'mockup'); + return r.ok ? { ok: true, id: r.image.id } : { ok: false, message: r.message }; + }, + review: (msg, imageId) => send(msg, summarizeCanvas(query.getSerializedNodes()), undefined, { + design: buildDesignPayload(design), mockupIds: [imageId], useAssets: false, + onProgress: setPhase, signal: abortRef.current?.signal, + }), + applyResult: async (res) => { + if (!res.ok) return { ok: false, message: res.message }; + const a = await apply(res.response); + return { ok: a.ok, message: a.message, summary: a.adjustments?.length ? summarizeAdjustments(a.adjustments, a.notChecked) : undefined }; + }, + removeImage: (id) => ss.deleteRefImage(id), + }); + setReviewNote(out.note); + } catch { + setReviewNote('Self-review skipped: something went wrong. Your page was built as normal.'); + } finally { setReviewing(false); } + }; + + // ---- one pipeline for send, resume and confirm + const applyResponse = async (resp: SitesmithResponse, review: boolean) => { + const applied = await apply(resp, target?.nodeId); + if (!applied.ok) { setError(friendly(applied.message || 'Apply failed')); return; } + if (applied.adjustments?.length || applied.notChecked) setAdjust({ list: applied.adjustments ?? [], notChecked: applied.notChecked ?? 0 }); + if (review && selfReviewOn && visionOk && features?.self_review !== false && resp.type !== 'ask' && !target && !pageCtx.isEditingGlobal) { + await doSelfReview(); + } + }; + + const handleResult = async (res: SendResult, review: boolean) => { + if (!res.ok) { + const st = String((res as { status?: string }).status ?? ''); + if (st === 'ABORTED') return; + if (st === 'CANCELLED') { setNotes(['Cancelled. Nothing was changed on your page.']); return; } + setError(friendly(res.message)); + return; + } + setNotes(res.notes ?? []); + if (res.response.type === 'replace' && res.response.scope === 'site') { + setPendingReplace({ resp: res.response, review }); + return; + } + await applyResponse(res.response, review); + }; + + const runJob = async (start: (o: { onProgress: (p: string) => void; signal: AbortSignal }) => Promise, review: boolean) => { + setBusy(true); setError(null); setNotes([]); setAdjust(null); setReviewNote(null); setPhase('queued'); setCancelling(false); + const ac = new AbortController(); + abortRef.current = ac; + try { + const res = await start({ onProgress: setPhase, signal: ac.signal }); + await handleResult(res, review); + } catch (e: any) { setError(friendly(String(e?.message ?? e))); } + finally { setBusy(false); setCancelling(false); abortRef.current = null; } + }; + + const handleSend = async (text: string) => { + const canvas = summarizeCanvas(query.getSerializedNodes()); + const opts = sendOpts(); + await runJob((o) => send(text, canvas, targetPayload, { ...opts, ...o }), true); + setSelMockups([]); // an attached picture is for that request; sites I like stay selected + }; + + const handleCancel = async () => { + setCancelling(true); + const r = await cancel(); + if (!r.ok) { setCancelling(false); setError(friendly(r.message)); } + // The poll in flight returns CANCELLED; nothing else to do here. + }; + + // Resume a job that was running when the modal (or page) was last closed. + const resumedRef = useRef(false); + useEffect(() => { + if (!activeJob) { resumedRef.current = false; return; } + if (resumedRef.current || busy || !canChat) return; + resumedRef.current = true; + setResumed(true); + setPhase(activeJob.phase || 'generating'); + void runJob((o) => resumeJob(activeJob.job_id, o), true); + }); // eslint-disable-line react-hooks/exhaustive-deps + /** One click: rewrite the targeted section for this business, as a single undo step. */ const regenerate = async () => { if (!target || busy) return; setBusy(true); setError(null); try { const canvas = summarizeCanvas(query.getSerializedNodes()); - const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section' }); - if (!result.ok) { setError(result.message || 'Failed'); return; } - const r = applyRegeneratedSection(actions, query, result.response, target.nodeId); + const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter }); + if (!result.ok) { setError(friendly(result.message)); return; } + const r = applyRegeneratedSection(actions, query, repairForCanvas(result.response, design, query).response, target.nodeId); if (!r.ok) { setError(r.message || 'Apply failed'); return; } notify({ kind: 'success', @@ -116,11 +294,11 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { timeoutMs: 8000, }); onClose(); - } catch (e: any) { setError(String(e?.message ?? e)); } + } catch (e: any) { setError(friendly(String(e?.message ?? e))); } finally { setBusy(false); } }; - const regenerateClick = () => { + const regenerateClick = () => { if (isProfileComplete(profile)) void regenerate(); else openProfileWizard({ onDone: () => void regenerate() }); }; @@ -133,9 +311,10 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { const confirmReplace = async () => { if (!pendingReplace) return; - const r = await apply(pendingReplace); + const { resp, review } = pendingReplace; setPendingReplace(null); - if (!r.ok) setError(r.message || 'Apply failed'); + setBusy(true); + try { await applyResponse(resp, review); } finally { setBusy(false); } }; const cancelClearChat = () => { @@ -233,26 +412,101 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { Regenerate this section )} - {error &&
{error}
} + {error &&
{error}
} + {notes.map((n, i) =>
{n}
)} + {adjust && adjust.list.length > 0 && ( +
+ {summarizeAdjustments(adjust.list, adjust.notChecked)} +
    + {adjust.list.map((a, i) => ( +
  • + {a.kind === 'text-colour' + ? `${a.node}${a.text ? ` "${a.text}"` : ''}: text ${a.from} became ${a.to} (contrast ${a.ratioBefore}:1 to ${a.ratioAfter}:1)` + : `${a.node}: ${a.to} (was ${a.from})`} +
  • + ))} +
+
+ )} + {reviewNote &&
{reviewNote}
} {loading ?
Loading…
: }
- {busy ? ( - + {canChat && cfg.whpConfig && ( +
+
setOpen((o) => ({ ...o, mockups: !o.mockups }))}> + { const r = await refsApi.uploadImage(f, 'mockup'); return r; }} + onRemove={refsApi.removeImage} + unavailableReason={imagesOn ? null : (visionReason ?? 'Image features are not available.')} + disabled={running} + /> +
+
setOpen((o) => ({ ...o, sites: !o.sites }))}> + refsApi.updateRef(id, u, l, sh)} + deleteRef={refsApi.deleteRef} + uploadShot={(f) => refsApi.uploadImage(f, 'screenshot')} + removeImage={refsApi.removeImage} + maxRefs={limits?.max_site_refs ?? 20} + imagesUnavailableReason={imagesOn ? null : (visionReason ?? 'Image features are not available.')} + disabled={running} + /> +
+
setOpen((o) => ({ ...o, assets: !o.assets }))}> + setExcluded((x) => { const n = new Set(x); if (n.has(f)) n.delete(f); else n.add(f); return n; })} + onAll={(on) => setExcluded(on ? new Set() : new Set(allAssetNames))} + thumbUrl={(a) => a.url} + visionAvailable={visionOk} + disabled={running} + /> +
+ +
+ )} + {running ? ( + ) : ( - + <> + {!canChat && summary &&
Sitesmith is not available on your plan right now, so the message box is turned off. See the note above for what to do.
} + + )}
setPendingReplace(null)} /> diff --git a/craft/src/panels/sitesmith/selfReview.ts b/craft/src/panels/sitesmith/selfReview.ts index 9680566..150949c 100644 --- a/craft/src/panels/sitesmith/selfReview.ts +++ b/craft/src/panels/sitesmith/selfReview.ts @@ -3,11 +3,9 @@ import type { CaptureResult } from '../../utils/self-review-capture'; /** Instruction for the one follow-up job. The render rides in the mockup slot, so say plainly what it is. */ export const REVIEW_MESSAGE = - 'The attached image is a screenshot of the page as it is built right now. It is NOT a mockup to copy. ' + - 'Review it like a careful designer: look for text that is hard to read, cramped or uneven spacing, misaligned or ' + - 'broken layout, images that are cropped badly, missing icons, and sections that look generic. ' + - 'Fix only real problems with a small patch; if the page already looks right, answer with a short "ask"-free patch with no ops and say it looks good. ' + - 'Do not redesign the page and do not change the wording.'; + 'Review the attached screenshot of my page as it is built now (it is a render of the current page, not a mockup to copy). ' + + 'Fix only real problems with a small patch: hard-to-read text, cramped or uneven spacing, broken or misaligned layout, badly cropped images, missing icons. ' + + 'If it already looks right, reply with a patch that has no ops and say so. Do not redesign the page or change the wording.'; export interface SelfReviewDeps { /** Published-style HTML of the current page (untrusted; the capture sanitises it). */