ucode-agent 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,496 +1,546 @@
1
- /**
2
- * browser.js — looking at the app the way a person would.
3
- *
4
- * A build that passes and a page that works are different claims. This opens
5
- * the running app in a real browser at a phone width and a desktop width, and
6
- * reports what a person would run into: errors in the console, requests that
7
- * failed, a layout that spills off the side of a phone, broken images,
8
- * controls with no name. It saves a screenshot of each, and has the one model
9
- * in the set that can see — Nemotron Nano Omni — review them as a designer
10
- * would. The model building the app then has something concrete to fix.
11
- *
12
- * It drives the browser already on the machine (Edge or Chrome) through
13
- * playwright-core, so there is no separate 150 MB browser download.
14
- */
15
-
16
- import { promises as fs } from 'node:fs';
17
- import fsSync from 'node:fs';
18
- import path from 'node:path';
19
- import { ToolFailure } from '../core/failure.js';
20
- import { ask } from '../core/provider.js';
21
- import { getRoot, result } from './shared.js';
22
-
23
- const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
24
- const WIDTHS = [
25
- { name: 'phone', width: 375, height: 812 },
26
- { name: 'desktop', width: 1440, height: 900 },
27
- ];
28
- const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
29
- const MAX_SHOT_HEIGHT = 3000;
30
-
31
- let browserPromise = null;
32
-
33
- /**
34
- * One browser for the whole session, started on first use. The installed
35
- * Edge or Chrome is tried first; Playwright's own Chromium only if it happens
36
- * to be installed.
37
- */
38
- async function browser() {
39
- if (browserPromise) return browserPromise;
40
- browserPromise = (async () => {
41
- let chromium;
42
- try {
43
- ({ chromium } = await import('playwright-core'));
44
- } catch (err) {
45
- throw new ToolFailure({
46
- kind: 'no_playwright',
47
- attempted: 'starting a browser',
48
- failed: `playwright-core could not be loaded: ${err.message}`,
49
- fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
50
- });
51
- }
52
- const tried = [];
53
- for (const channel of ['msedge', 'chrome', undefined]) {
54
- try {
55
- return await chromium.launch({ channel, headless: true });
56
- } catch (err) {
57
- tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
58
- }
59
- }
60
- throw new ToolFailure({
61
- kind: 'no_browser',
62
- attempted: 'starting a browser',
63
- failed: `No browser could be started. Tried ${tried.join('; ')}.`,
64
- fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
65
- });
66
- })();
67
- browserPromise.catch(() => { browserPromise = null; });
68
- return browserPromise;
69
- }
70
-
71
- /** Close the shared browser, if one was started. Called when ucode exits. */
72
- export async function closeBrowser() {
73
- if (!browserPromise) return;
74
- try { await (await browserPromise).close(); } catch { /* already gone */ }
75
- browserPromise = null;
76
- }
77
-
78
- /** Layout and accessibility checks run inside the page. */
79
- function inspect() {
80
- const vw = window.innerWidth;
81
- const describeEl = (el) => {
82
- const id = el.id ? `#${el.id}` : '';
83
- const cls = typeof el.className === 'string' && el.className.trim()
84
- ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
85
- const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
86
- return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
87
- };
88
-
89
- const overflow = document.documentElement.scrollWidth - vw;
90
- const wide = [];
91
- if (overflow > 1) {
92
- for (const el of document.querySelectorAll('body *')) {
93
- const r = el.getBoundingClientRect();
94
- if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
95
- wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
96
- if (wide.length >= 5) break;
97
- }
98
- }
99
- }
100
-
101
- const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
102
- const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
103
- const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
104
- .filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
105
- && !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
106
- .slice(0, 5).map(describeEl);
107
- const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
108
- .filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
109
- && !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
110
- .length;
111
- const tiny = vw < 600
112
- ? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
113
- .filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
114
- .length
115
- : 0;
116
- const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
117
- .filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
118
- .length;
119
-
120
- return {
121
- title: document.title,
122
- overflow: overflow > 1 ? Math.round(overflow) : 0,
123
- wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
124
- empty: !(document.body.innerText || '').trim(),
125
- };
126
- }
127
-
128
- const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
129
-
130
- async function review(shots) {
131
- const request = [
132
- {
133
- role: 'system',
134
- content:
135
- 'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
136
- 'and one at desktop width. Each screenshot is the whole page, top to bottom, so anything not ' +
137
- 'in it is genuinely not there. List the concrete visual problems a user would notice, most ' +
138
- 'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
139
- 'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
140
- 'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
141
- 'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
142
- 'say so in one line and name the one thing that would improve it most. No preamble.',
143
- },
144
- {
145
- role: 'user',
146
- content: shots.map((s) => `${s.label}`).join(' and ') + '.',
147
- images: shots.map((s) => s.dataUrl),
148
- },
149
- ];
150
- const reply = await ask(request, [], {
151
- model: VISION_MODEL,
152
- temperature: 0.2,
153
- // A reasoning model spends its budget thinking before it writes; 900
154
- // tokens came back as an empty review. Keep the thinking short, and leave
155
- // room for the answer.
156
- maxOutputTokens: 4000,
157
- reasoning: { effort: 'low' },
158
- // The free vision model is often busy. One try, and a hard cap: a review
159
- // that cannot run is skipped, never waited on.
160
- attempts: 1,
161
- signal: AbortSignal.timeout(REVIEW_BUDGET_MS),
162
- });
163
- const text = reply.text.trim();
164
- if (!text) throw new Error('the vision model returned an empty review');
165
- return text;
166
- }
167
-
168
- const REVIEW_BUDGET_MS = 60_000;
169
-
170
- /**
171
- * The designer's review is the slow part — a reasoning model looking at
172
- * screenshots, most of a minute — so each app gets one per turn: a look after
173
- * the fixes only re-runs the fast checks. A review that failed (busy model,
174
- * empty reply) gets one more try on the next look, then is let go.
175
- */
176
- const reviews = new Map(); // base URL -> { done, tries }
177
-
178
- /** A new request from the user: the apps may be reviewed afresh. */
179
- export function forgetReviews() {
180
- reviews.clear();
181
- }
182
-
183
-
184
- /**
185
- * Text typed into the app while checking it. Distinctive enough to recognise
186
- * in a screenshot, and obviously not something a user wrote.
187
- */
188
- const PROBE_TEXT = 'ucode check';
189
-
190
- /** Did anything at all happen on the page? */
191
- const moved = (a, b) => a.nodes !== b.nodes || a.text !== b.text || a.stored !== b.stored;
192
-
193
- /**
194
- * Use the app, rather than only looking at it.
195
- *
196
- * Everything else here is an inspection: overflow, labels, broken images,
197
- * console errors on load. All of it passes on an app whose Add button does
198
- * nothing, because a page with dead JavaScript still renders, still has good
199
- * contrast and still has no console errors — it simply does not work. Nothing
200
- * in the harness ever pressed anything, so the model was never told, and never
201
- * fixed it.
202
- *
203
- * So: type into the first text field, press Enter, and if that changed nothing,
204
- * click the first button. Then look at whether the page has more nodes, more
205
- * text, or more in localStorage than it did. Any of those moving means the core
206
- * loop is wired up. None of them moving, on a page that has controls to press,
207
- * means it is not.
208
- *
209
- * Real keyboard and mouse input through the driver, never synthetic DOM events:
210
- * an implicit form submit does not fire for a dispatched event, which would
211
- * report a working form as dead.
212
- *
213
- * A page with nothing to press — a landing page, a chart, a page of prose — is
214
- * not exercised and not judged. Returning null there is the difference between
215
- * a check and a false accusation.
216
- */
217
- async function useTheApp(page) {
218
- const snapshot = () => page.evaluate(() => ({
219
- nodes: document.body.querySelectorAll('*').length,
220
- text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
221
- stored: (() => { try { return JSON.stringify(localStorage).length; } catch { return 0; } })(),
222
- }));
223
-
224
- const before = await snapshot().catch(() => null);
225
- if (!before) return null;
226
- const tried = [];
227
-
228
- // A field and the Enter key: the core loop of most one-page apps.
229
- const field = page.locator('input[type="text"], input[type="search"], input:not([type]), textarea').first();
230
- if (await field.count().catch(() => 0)) {
231
- const ok = await field.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
232
- if (ok) {
233
- await field.press('Enter', { timeout: 2_000 }).catch(() => {});
234
- await page.waitForTimeout(300);
235
- tried.push('typed into the first field and pressed Enter');
236
- }
237
- }
238
-
239
- let after = await snapshot().catch(() => before);
240
- if (tried.length && moved(before, after)) return { tried, worked: true };
241
-
242
- // Nothing moved, so try the other half of the same pattern — the button that
243
- // submits, before any other. Taking simply the first button in the document
244
- // finds the theme toggle in the header, clicks it, and reports a working app
245
- // as dead because switching to dark mode adds no elements.
246
- const candidates = [
247
- page.locator('form button[type="submit"], form input[type="submit"], button[type="submit"], input[type="submit"]'),
248
- page.locator('form button:not([disabled])'),
249
- page.locator('button:not([disabled]), [role="button"]'),
250
- ];
251
-
252
- const pressed = new Set();
253
- for (const group of candidates) {
254
- const count = Math.min(await group.count().catch(() => 0), 3);
255
- for (let i = 0; i < count; i++) {
256
- const button = group.nth(i);
257
- const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
258
- if (pressed.has(label || `#${i}`)) continue;
259
- pressed.add(label || `#${i}`);
260
-
261
- const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
262
- if (!ok) continue;
263
- await page.waitForTimeout(300);
264
- tried.push(`clicked ${label ? `"${label}"` : 'a button'}`);
265
-
266
- after = await snapshot().catch(() => after);
267
- if (moved(before, after)) return { tried, worked: true };
268
- }
269
- }
270
-
271
- if (!tried.length) return null;
272
- if (!moved(before, after)) return { tried, worked: false };
273
-
274
- // It worked. Did any of it last?
275
- //
276
- // An app that writes to localStorage and never reads it back looks perfect
277
- // for as long as you stay on the page, and loses everything the moment
278
- // anyone refreshes. Only asked when the app actually stored something —
279
- // otherwise no persistence was intended, and reporting its absence would be
280
- // inventing a requirement nobody asked for.
281
- if (after.stored > before.stored) {
282
- try {
283
- await page.reload({ waitUntil: 'load', timeout: 20_000 });
284
- await page.waitForTimeout(400);
285
- const reloaded = await snapshot();
286
- if (reloaded.text <= before.text) return { tried, worked: true, lost: true };
287
- } catch { /* a reload that will not happen is not evidence of anything */ }
288
- }
289
-
290
- return { tried, worked: true };
291
- }
292
-
293
- /**
294
- * Serve a folder over http just long enough to look at it.
295
- *
296
- * The default starter has no dev server — three files that open straight from
297
- * disk — so there was nothing for the checker to point at, and the most
298
- * thorough thing ucode runs could not run on the apps it makes most often. A
299
- * static server on an ephemeral port costs nothing and closes again the
300
- * moment the look is done.
301
- */
302
- export async function withStaticServer(dir, fn) {
303
- const http = await import('node:http');
304
- const root = path.resolve(dir);
305
- const types = {
306
- '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
307
- '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
308
- };
309
-
310
- const server = http.createServer((req, res) => {
311
- const rel = (req.url === '/' ? '/index.html' : req.url).split('?')[0];
312
- const file = path.join(root, decodeURIComponent(rel));
313
- if (!file.startsWith(root)) { res.writeHead(403); res.end(); return; }
314
- fsSync.readFile(file, (err, buf) => {
315
- if (err) { res.writeHead(404); res.end('not found'); return; }
316
- res.writeHead(200, { 'content-type': types[path.extname(file).toLowerCase()] ?? 'text/plain' });
317
- res.end(buf);
318
- });
319
- });
320
-
321
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
322
- try {
323
- return await fn(`http://127.0.0.1:${server.address().port}`);
324
- } finally {
325
- server.close();
326
- }
327
- }
328
-
329
- export async function lookAtApp({ url, paths = ['/'] }) {
330
- const base = String(url ?? '').trim().replace(/\/+$/, '');
331
- if (!LOCAL.test(`${base}/`)) {
332
- throw new ToolFailure({
333
- kind: 'bad_args',
334
- attempted: 'looking at the app',
335
- failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
336
- fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
337
- });
338
- }
339
-
340
- const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
341
- .map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
342
- .slice(0, 4);
343
-
344
- const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
345
- await fs.mkdir(shotsDir, { recursive: true });
346
-
347
- const b = await browser();
348
-
349
- // Every page at every width opens at once, each in its own context: the
350
- // wait is for the slowest one, not the sum of them all.
351
- const checks = await Promise.all(pages.flatMap((pagePath) => WIDTHS.map(async (size) => {
352
- let problems = 0;
353
- let shot = null;
354
- const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
355
- const page = await context.newPage();
356
- const errors = [];
357
- const failed = [];
358
- page.on('console', (m) => {
359
- if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
360
- });
361
- page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
362
- page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
363
- page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
364
-
365
- const target = `${base}${pagePath}`;
366
- let loadError = null;
367
- let used = null;
368
- try {
369
- // 'load', not 'networkidle': a dev server holds a hot-reload
370
- // connection open and polls, so the network may never go quiet and
371
- // 'networkidle' would wait out its whole timeout on every page, at
372
- // every width. 'load' covers the first-request compile; the page's own
373
- // data then gets a short, bounded chance to settle.
374
- await page.goto(target, { waitUntil: 'load', timeout: 90_000 });
375
- await page.waitForLoadState('networkidle', { timeout: 1_500 }).catch(() => {});
376
- } catch (err) {
377
- loadError = String(err.message).split('\n')[0];
378
- }
379
- await page.waitForTimeout(400); // let entrance animations settle
380
-
381
- const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
382
- let facts = null;
383
- try {
384
- if (!loadError) {
385
- facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
386
- // The whole page, so the reviewer never reports as missing what is
387
- // only below the fold — capped, so an endless feed stays one image.
388
- const tall = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
389
- const buffer = await page.screenshot({
390
- type: 'jpeg',
391
- quality: 70,
392
- fullPage: true,
393
- ...(tall > MAX_SHOT_HEIGHT ? { clip: { x: 0, y: 0, width: size.width, height: MAX_SHOT_HEIGHT } } : {}),
394
- });
395
- await fs.writeFile(file, buffer);
396
- shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
397
-
398
- // Once per look, not once per width: pressing the same button four
399
- // times says nothing the first press did not, and costs four seconds.
400
- if (size.name === 'desktop' && pagePath === pages[0]) {
401
- used = await useTheApp(page).catch(() => null);
402
- }
403
- }
404
- } catch (err) {
405
- loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
406
- } finally {
407
- await context.close().catch(() => {});
408
- }
409
-
410
- const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
411
- if (loadError) {
412
- lines.push(`Could not load: ${loadError}`);
413
- problems++;
414
- } else {
415
- lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
416
- if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
417
- if (facts?.overflow) {
418
- lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
419
- problems++;
420
- }
421
- if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
422
- if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
423
- if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
424
- if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
425
- if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
426
- if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
427
- if (used && !used.worked) {
428
- lines.push(
429
- `- NOTHING HAPPENS WHEN YOU USE IT. I ${used.tried.join(', then ')} — and the page`,
430
- ' gained no elements, changed no text and stored nothing. The markup and the styling',
431
- ' are there; the behaviour is not wired to them. Find the listener that was never',
432
- ' attached, or the handler that throws before it does anything, and fix that first:',
433
- ' everything else on this page is decoration until it works.',
434
- );
435
- problems++;
436
- } else if (used?.lost) {
437
- lines.push(
438
- `- It works until you refresh. I ${used.tried.join(', then ')}, the page`,
439
- ' responded, and it wrote to localStorage — but after a reload it was back to',
440
- ' empty. Something is being saved and never read back at start-up. Load the',
441
- ' stored state when the page boots, and check it survives a refresh.',
442
- );
443
- problems++;
444
- } else if (used) {
445
- lines.push(`- Core loop works: I ${used.tried.join(', then ')}, the page responded, and it survived a reload.`);
446
- }
447
- }
448
- if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
449
- if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
450
- if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
451
- // A dead core loop counts as broken: a screenshot of an app that does not
452
- // work is not worth a paragraph on its typography.
453
- const broken = Boolean(loadError || errors.length || facts?.empty || (used && !used.worked));
454
- return { section: lines.join('\n'), shot, problems, broken };
455
- })));
456
-
457
- const sections = checks.map((c) => c.section);
458
- const toReview = checks.map((c) => c.shot).filter(Boolean);
459
- const problems = checks.reduce((n, c) => n + c.problems, 0);
460
- const broken = checks.some((c) => c.broken);
461
-
462
- // A page that crashed or threw is fixed first; reviewing a screenshot of an
463
- // error overlay is a minute spent on nothing.
464
- let critique = '';
465
- const state = reviews.get(base) ?? { done: false, tries: 0 };
466
- if (!broken && !state.done && state.tries < 2 && toReview.length) {
467
- state.tries++;
468
- reviews.set(base, state);
469
- try {
470
- critique = await review(toReview.slice(0, 4));
471
- state.done = true;
472
- } catch (err) {
473
- const why = String(err.failed ?? err.message).replace(/[.\s]+$/, '');
474
- critique = `(The visual review could not run: ${why}. The checks above still apply.)`;
475
- }
476
- }
477
-
478
- const body = [
479
- ...sections,
480
- critique ? `## Visual review\n${critique}` : '',
481
- '',
482
- problems
483
- ? 'Fix the problems above, then look again to confirm.'
484
- : state.done && critique
485
- ? 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing - ' +
486
- 'the next look re-runs only the fast checks.'
487
- : 'The automatic checks found nothing.',
488
- ].filter(Boolean).join('\n\n');
489
-
490
- return result(
491
- body,
492
- problems
493
- ? `${problems} problem${problems === 1 ? '' : 's'} found · screenshots in .ucode/screenshots`
494
- : 'no errors · screenshots in .ucode/screenshots'
495
- );
496
- }
1
+ /**
2
+ * browser.js — looking at the app the way a person would.
3
+ *
4
+ * A build that passes and a page that works are different claims. This opens
5
+ * the running app in a real browser at a phone width and a desktop width, and
6
+ * reports what a person would run into: errors in the console, requests that
7
+ * failed, a layout that spills off the side of a phone, broken images,
8
+ * controls with no name. It saves a screenshot of each, and has the one model
9
+ * in the set that can see — Nemotron Nano Omni — review them as a designer
10
+ * would. The model building the app then has something concrete to fix.
11
+ *
12
+ * It drives the browser already on the machine (Edge or Chrome) through
13
+ * playwright-core, so there is no separate 150 MB browser download.
14
+ */
15
+
16
+ import { promises as fs } from 'node:fs';
17
+ import fsSync from 'node:fs';
18
+ import path from 'node:path';
19
+ import { ToolFailure } from '../core/failure.js';
20
+ import { ask } from '../core/provider.js';
21
+ import { getRoot, result } from './shared.js';
22
+
23
+ const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
24
+ const WIDTHS = [
25
+ { name: 'phone', width: 375, height: 812 },
26
+ { name: 'desktop', width: 1440, height: 900 },
27
+ ];
28
+ const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
29
+ const MAX_SHOT_HEIGHT = 3000;
30
+
31
+ let browserPromise = null;
32
+
33
+ /**
34
+ * One browser for the whole session, started on first use. The installed
35
+ * Edge or Chrome is tried first; Playwright's own Chromium only if it happens
36
+ * to be installed.
37
+ */
38
+ async function browser() {
39
+ if (browserPromise) return browserPromise;
40
+ browserPromise = (async () => {
41
+ let chromium;
42
+ try {
43
+ ({ chromium } = await import('playwright-core'));
44
+ } catch (err) {
45
+ throw new ToolFailure({
46
+ kind: 'no_playwright',
47
+ attempted: 'starting a browser',
48
+ failed: `playwright-core could not be loaded: ${err.message}`,
49
+ fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
50
+ });
51
+ }
52
+ const tried = [];
53
+ for (const channel of ['msedge', 'chrome', undefined]) {
54
+ try {
55
+ return await chromium.launch({ channel, headless: true });
56
+ } catch (err) {
57
+ tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
58
+ }
59
+ }
60
+ throw new ToolFailure({
61
+ kind: 'no_browser',
62
+ attempted: 'starting a browser',
63
+ failed: `No browser could be started. Tried ${tried.join('; ')}.`,
64
+ fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
65
+ });
66
+ })();
67
+ browserPromise.catch(() => { browserPromise = null; });
68
+ return browserPromise;
69
+ }
70
+
71
+ /** Close the shared browser, if one was started. Called when ucode exits. */
72
+ export async function closeBrowser() {
73
+ if (!browserPromise) return;
74
+ try { await (await browserPromise).close(); } catch { /* already gone */ }
75
+ browserPromise = null;
76
+ }
77
+
78
+ /** Layout and accessibility checks run inside the page. */
79
+ function inspect() {
80
+ const vw = window.innerWidth;
81
+ const describeEl = (el) => {
82
+ const id = el.id ? `#${el.id}` : '';
83
+ const cls = typeof el.className === 'string' && el.className.trim()
84
+ ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
85
+ const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
86
+ return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
87
+ };
88
+
89
+ const overflow = document.documentElement.scrollWidth - vw;
90
+ const wide = [];
91
+ if (overflow > 1) {
92
+ for (const el of document.querySelectorAll('body *')) {
93
+ const r = el.getBoundingClientRect();
94
+ if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
95
+ wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
96
+ if (wide.length >= 5) break;
97
+ }
98
+ }
99
+ }
100
+
101
+ const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
102
+ const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
103
+ const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
104
+ .filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
105
+ && !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
106
+ .slice(0, 5).map(describeEl);
107
+ const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
108
+ .filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
109
+ && !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
110
+ .length;
111
+ const tiny = vw < 600
112
+ ? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
113
+ .filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
114
+ .length
115
+ : 0;
116
+ const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
117
+ .filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
118
+ .length;
119
+
120
+ return {
121
+ title: document.title,
122
+ overflow: overflow > 1 ? Math.round(overflow) : 0,
123
+ wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
124
+ empty: !(document.body.innerText || '').trim(),
125
+ };
126
+ }
127
+
128
+ const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
129
+
130
+ async function review(shots) {
131
+ const request = [
132
+ {
133
+ role: 'system',
134
+ content:
135
+ 'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
136
+ 'and one at desktop width. Each screenshot is the whole page, top to bottom, so anything not ' +
137
+ 'in it is genuinely not there. List the concrete visual problems a user would notice, most ' +
138
+ 'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
139
+ 'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
140
+ 'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
141
+ 'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
142
+ 'say so in one line and name the one thing that would improve it most. No preamble.',
143
+ },
144
+ {
145
+ role: 'user',
146
+ content: shots.map((s) => `${s.label}`).join(' and ') + '.',
147
+ images: shots.map((s) => s.dataUrl),
148
+ },
149
+ ];
150
+ const reply = await ask(request, [], {
151
+ model: VISION_MODEL,
152
+ temperature: 0.2,
153
+ // A reasoning model spends its budget thinking before it writes; 900
154
+ // tokens came back as an empty review. Keep the thinking short, and leave
155
+ // room for the answer.
156
+ maxOutputTokens: 4000,
157
+ reasoning: { effort: 'low' },
158
+ // The free vision model is often busy. One try, and a hard cap: a review
159
+ // that cannot run is skipped, never waited on.
160
+ attempts: 1,
161
+ signal: AbortSignal.timeout(REVIEW_BUDGET_MS),
162
+ });
163
+ const text = reply.text.trim();
164
+ if (!text) throw new Error('the vision model returned an empty review');
165
+ return text;
166
+ }
167
+
168
+ const REVIEW_BUDGET_MS = 60_000;
169
+
170
+ /**
171
+ * The designer's review is the slow part — a reasoning model looking at
172
+ * screenshots, most of a minute — so each app gets one per turn: a look after
173
+ * the fixes only re-runs the fast checks. A review that failed (busy model,
174
+ * empty reply) gets one more try on the next look, then is let go.
175
+ */
176
+ const reviews = new Map(); // base URL -> { done, tries }
177
+
178
+ /** A new request from the user: the apps may be reviewed afresh. */
179
+ export function forgetReviews() {
180
+ reviews.clear();
181
+ }
182
+
183
+
184
+ /**
185
+ * Text typed into the app while checking it. Distinctive enough to recognise
186
+ * in a screenshot, and obviously not something a user wrote.
187
+ */
188
+ const PROBE_TEXT = 'ucode check';
189
+
190
+ /** Did anything at all happen on the page? */
191
+ const moved = (a, b) => a.nodes !== b.nodes || a.text !== b.text || a.stored !== b.stored;
192
+
193
+ /**
194
+ * Use the app, rather than only looking at it.
195
+ *
196
+ * Everything else here is an inspection: overflow, labels, broken images,
197
+ * console errors on load. All of it passes on an app whose Add button does
198
+ * nothing, because a page with dead JavaScript still renders, still has good
199
+ * contrast and still has no console errors — it simply does not work. Nothing
200
+ * in the harness ever pressed anything, so the model was never told, and never
201
+ * fixed it.
202
+ *
203
+ * So: type into the first text field, press Enter, and if that changed nothing,
204
+ * click the first button. Then look at whether the page has more nodes, more
205
+ * text, or more in localStorage than it did. Any of those moving means the core
206
+ * loop is wired up. None of them moving, on a page that has controls to press,
207
+ * means it is not.
208
+ *
209
+ * Real keyboard and mouse input through the driver, never synthetic DOM events:
210
+ * an implicit form submit does not fire for a dispatched event, which would
211
+ * report a working form as dead.
212
+ *
213
+ * A page with nothing to press — a landing page, a chart, a page of prose — is
214
+ * not exercised and not judged. Returning null there is the difference between
215
+ * a check and a false accusation.
216
+ */
217
+ async function useTheApp(page) {
218
+ const snapshot = () => page.evaluate(() => ({
219
+ nodes: document.body.querySelectorAll('*').length,
220
+ text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
221
+ stored: (() => { try { return JSON.stringify(localStorage).length; } catch { return 0; } })(),
222
+ }));
223
+
224
+ const before = await snapshot().catch(() => null);
225
+ if (!before) return null;
226
+ const tried = [];
227
+
228
+ // A field and the Enter key: the core loop of most one-page apps.
229
+ const field = page.locator('input[type="text"], input[type="search"], input:not([type]), textarea').first();
230
+ if (await field.count().catch(() => 0)) {
231
+ const ok = await field.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
232
+ if (ok) {
233
+ await field.press('Enter', { timeout: 2_000 }).catch(() => {});
234
+ await page.waitForTimeout(300);
235
+ tried.push('typed into the first field and pressed Enter');
236
+ }
237
+ }
238
+
239
+ let after = await snapshot().catch(() => before);
240
+ if (tried.length && moved(before, after)) return { tried, worked: true };
241
+
242
+ // Nothing moved, so try the other half of the same pattern — the button that
243
+ // submits, before any other. Taking simply the first button in the document
244
+ // finds the theme toggle in the header, clicks it, and reports a working app
245
+ // as dead because switching to dark mode adds no elements.
246
+ const candidates = [
247
+ page.locator('form button[type="submit"], form input[type="submit"], button[type="submit"], input[type="submit"]'),
248
+ page.locator('form button:not([disabled])'),
249
+ page.locator('button:not([disabled]), [role="button"]'),
250
+ ];
251
+
252
+ const pressed = new Set();
253
+ for (const group of candidates) {
254
+ const count = Math.min(await group.count().catch(() => 0), 3);
255
+ for (let i = 0; i < count; i++) {
256
+ const button = group.nth(i);
257
+ const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
258
+ if (pressed.has(label || `#${i}`)) continue;
259
+ pressed.add(label || `#${i}`);
260
+
261
+ const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
262
+ if (!ok) continue;
263
+ await page.waitForTimeout(300);
264
+ tried.push(`clicked ${label ? `"${label}"` : 'a button'}`);
265
+
266
+ after = await snapshot().catch(() => after);
267
+ if (moved(before, after)) return { tried, worked: true };
268
+ }
269
+ }
270
+
271
+ if (!tried.length) return null;
272
+ if (!moved(before, after)) return { tried, worked: false };
273
+
274
+ // It worked. Did any of it last?
275
+ //
276
+ // An app that writes to localStorage and never reads it back looks perfect
277
+ // for as long as you stay on the page, and loses everything the moment
278
+ // anyone refreshes. Only asked when the app actually stored something —
279
+ // otherwise no persistence was intended, and reporting its absence would be
280
+ // inventing a requirement nobody asked for.
281
+ if (after.stored > before.stored) {
282
+ try {
283
+ await page.reload({ waitUntil: 'load', timeout: 20_000 });
284
+ await page.waitForTimeout(400);
285
+ const reloaded = await snapshot();
286
+ if (reloaded.text <= before.text) return { tried, worked: true, lost: true };
287
+ } catch { /* a reload that will not happen is not evidence of anything */ }
288
+ }
289
+
290
+ return { tried, worked: true };
291
+ }
292
+
293
+ /**
294
+ * Serve a folder over http just long enough to look at it.
295
+ *
296
+ * The default starter has no dev server — three files that open straight from
297
+ * disk — so there was nothing for the checker to point at, and the most
298
+ * thorough thing ucode runs could not run on the apps it makes most often. A
299
+ * static server on an ephemeral port costs nothing and closes again the
300
+ * moment the look is done.
301
+ */
302
+ export async function withStaticServer(dir, fn) {
303
+ const http = await import('node:http');
304
+ const root = path.resolve(dir);
305
+ const types = {
306
+ '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
307
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
308
+ };
309
+
310
+ const server = http.createServer((req, res) => {
311
+ const rel = (req.url === '/' ? '/index.html' : req.url).split('?')[0];
312
+ const file = path.join(root, decodeURIComponent(rel));
313
+ if (!file.startsWith(root)) { res.writeHead(403); res.end(); return; }
314
+ fsSync.readFile(file, (err, buf) => {
315
+ if (err) { res.writeHead(404); res.end('not found'); return; }
316
+ res.writeHead(200, { 'content-type': types[path.extname(file).toLowerCase()] ?? 'text/plain' });
317
+ res.end(buf);
318
+ });
319
+ });
320
+
321
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
322
+ try {
323
+ return await fn(`http://127.0.0.1:${server.address().port}`);
324
+ } finally {
325
+ server.close();
326
+ }
327
+ }
328
+
329
+ export async function lookAtApp({ url, paths = ['/'] }) {
330
+ const base = String(url ?? '').trim().replace(/\/+$/, '');
331
+ if (!LOCAL.test(`${base}/`)) {
332
+ throw new ToolFailure({
333
+ kind: 'bad_args',
334
+ attempted: 'looking at the app',
335
+ failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
336
+ fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
337
+ });
338
+ }
339
+
340
+ const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
341
+ .map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
342
+ .slice(0, 4);
343
+
344
+ const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
345
+ await fs.mkdir(shotsDir, { recursive: true });
346
+
347
+ const b = await browser();
348
+
349
+ // Every page at every width opens at once, each in its own context: the
350
+ // wait is for the slowest one, not the sum of them all.
351
+ const checks = await Promise.all(pages.flatMap((pagePath) => WIDTHS.map(async (size) => {
352
+ let shot = null;
353
+ const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
354
+ const page = await context.newPage();
355
+ const errors = [];
356
+ const failed = [];
357
+ page.on('console', (m) => {
358
+ if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
359
+ });
360
+ page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
361
+ page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
362
+ page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
363
+
364
+ const target = `${base}${pagePath}`;
365
+ let loadError = null;
366
+ let used = null;
367
+ try {
368
+ // 'load', not 'networkidle': a dev server holds a hot-reload
369
+ // connection open and polls, so the network may never go quiet and
370
+ // 'networkidle' would wait out its whole timeout on every page, at
371
+ // every width. 'load' covers the first-request compile; the page's own
372
+ // data then gets a short, bounded chance to settle.
373
+ await page.goto(target, { waitUntil: 'load', timeout: 90_000 });
374
+ await page.waitForLoadState('networkidle', { timeout: 1_500 }).catch(() => {});
375
+ } catch (err) {
376
+ loadError = String(err.message).split('\n')[0];
377
+ }
378
+ await page.waitForTimeout(400); // let entrance animations settle
379
+
380
+ const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
381
+ let facts = null;
382
+ try {
383
+ if (!loadError) {
384
+ facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
385
+ // The whole page, so the reviewer never reports as missing what is
386
+ // only below the fold — capped, so an endless feed stays one image.
387
+ const tall = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
388
+ const buffer = await page.screenshot({
389
+ type: 'jpeg',
390
+ quality: 70,
391
+ fullPage: true,
392
+ ...(tall > MAX_SHOT_HEIGHT ? { clip: { x: 0, y: 0, width: size.width, height: MAX_SHOT_HEIGHT } } : {}),
393
+ });
394
+ await fs.writeFile(file, buffer);
395
+ shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
396
+
397
+ // Once per look, not once per width: pressing the same button four
398
+ // times says nothing the first press did not, and costs four seconds.
399
+ if (size.name === 'desktop' && pagePath === pages[0]) {
400
+ used = await useTheApp(page).catch(() => null);
401
+ }
402
+ }
403
+ } catch (err) {
404
+ loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
405
+ } finally {
406
+ await context.close().catch(() => {});
407
+ }
408
+
409
+ const rel = loadError ? null : path.relative(getRoot(), file).split(path.sep).join('/');
410
+ return { pagePath, size, facts, errors, failed, loadError, shot, used, rel };
411
+ })));
412
+
413
+ // One section per page, not one per page and width.
414
+ //
415
+ // Almost everything these checks find is a fact about the page and comes
416
+ // back identical at every width: an image that is broken at 375px is broken
417
+ // at 1440px, a console error fires in both, an unlabelled field is
418
+ // unlabelled twice. Printed per width, every one of those lines appeared
419
+ // twice over — and counted twice, so one broken image read as two problems
420
+ // and a clean page still produced two near-identical paragraphs to read.
421
+ //
422
+ // What genuinely changes with the viewport is the layout: overflow, tap
423
+ // targets, type size. Only those are still named by width.
424
+ const uniq = (xs) => [...new Set(xs)];
425
+ const sections = [];
426
+ const toReview = [];
427
+ let problems = 0;
428
+ let broken = false;
429
+
430
+ for (const pagePath of pages) {
431
+ const shots = checks.filter((c) => c.pagePath === pagePath);
432
+ const lines = [`### ${pagePath}`];
433
+ const loaded = shots.filter((s) => !s.loadError);
434
+
435
+ if (!loaded.length) {
436
+ lines.push(`Could not load: ${shots[0]?.loadError ?? 'no response'}`);
437
+ problems++;
438
+ broken = true;
439
+ sections.push(lines.join('\n'));
440
+ continue;
441
+ }
442
+
443
+ // The desktop render is where the page-wide facts are read from, and the
444
+ // only one the core loop was exercised on.
445
+ const main = loaded.find((s) => s.size.name === 'desktop') ?? loaded[0];
446
+ const facts = main.facts ?? {};
447
+ const errors = uniq(loaded.flatMap((s) => s.errors));
448
+ const failed = uniq(loaded.flatMap((s) => s.failed));
449
+ const used = loaded.find((s) => s.used)?.used ?? null;
450
+
451
+ lines.push(`Screenshots: ${loaded.map((s) => `${s.rel} (${s.size.name})`).join(', ')}`);
452
+ for (const s of loaded) if (s.shot) toReview.push(s.shot);
453
+
454
+ if (facts.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
455
+ if (facts.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
456
+ if (facts.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
457
+ if (facts.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
458
+ if (facts.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
459
+
460
+ // A width that failed on its own — the phone render timed out, the desktop
461
+ // one came back — is still a failure, and grouping by page must not let it
462
+ // disappear behind the width that worked.
463
+ for (const s of shots.filter((c) => c.loadError)) {
464
+ lines.push(`- At ${s.size.width}px (${s.size.name}) it could not load: ${s.loadError}`);
465
+ problems++;
466
+ broken = true;
467
+ }
468
+
469
+ for (const s of loaded) {
470
+ const f = s.facts ?? {};
471
+ const at = `At ${s.size.width}px (${s.size.name})`;
472
+ if (f.overflow) {
473
+ lines.push(`- ${at}: content is ${f.overflow}px wider than the screen, so it scrolls sideways:`,
474
+ ...f.wide.map((w) => ` - ${w}`));
475
+ problems++;
476
+ }
477
+ if (f.tiny) lines.push(`- ${at}: ${f.tiny} tap target(s) smaller than 32px.`);
478
+ if (f.smallText) lines.push(`- ${at}: ${f.smallText} text element(s) under 12px.`);
479
+ }
480
+
481
+ if (used && !used.worked) {
482
+ lines.push(
483
+ `- NOTHING HAPPENS WHEN YOU USE IT. I ${used.tried.join(', then ')} — and the page`,
484
+ ' gained no elements, changed no text and stored nothing. The markup and the styling',
485
+ ' are there; the behaviour is not wired to them. Find the listener that was never',
486
+ ' attached, or the handler that throws before it does anything, and fix that first:',
487
+ ' everything else on this page is decoration until it works.',
488
+ );
489
+ problems++;
490
+ } else if (used?.lost) {
491
+ lines.push(
492
+ `- It works until you refresh. I ${used.tried.join(', then ')}, the page`,
493
+ ' responded, and it wrote to localStorage — but after a reload it was back to',
494
+ ' empty. Something is being saved and never read back at start-up. Load the',
495
+ ' stored state when the page boots, and check it survives a refresh.',
496
+ );
497
+ problems++;
498
+ } else if (used) {
499
+ lines.push(`- Core loop works: I ${used.tried.join(', then ')}, the page responded, and it survived a reload.`);
500
+ }
501
+
502
+ if (errors.length) { lines.push('- Console errors:', ...errors.slice(0, 6).map((e) => ` - ${e}`)); problems++; }
503
+ if (failed.length) { lines.push('- Failed requests:', ...failed.slice(0, 6).map((f) => ` - ${f}`)); problems++; }
504
+ if (lines.length === 2) lines.push('- No errors, no overflow, nothing unlabeled.');
505
+
506
+ // A dead core loop counts as broken: a screenshot of an app that does not
507
+ // work is not worth a paragraph on its typography.
508
+ if (errors.length || facts.empty || (used && !used.worked)) broken = true;
509
+ sections.push(lines.join('\n'));
510
+ }
511
+
512
+ // A page that crashed or threw is fixed first; reviewing a screenshot of an
513
+ // error overlay is a minute spent on nothing.
514
+ let critique = '';
515
+ const state = reviews.get(base) ?? { done: false, tries: 0 };
516
+ if (!broken && !state.done && state.tries < 2 && toReview.length) {
517
+ state.tries++;
518
+ reviews.set(base, state);
519
+ try {
520
+ critique = await review(toReview.slice(0, 4));
521
+ state.done = true;
522
+ } catch (err) {
523
+ const why = String(err.failed ?? err.message).replace(/[.\s]+$/, '');
524
+ critique = `(The visual review could not run: ${why}. The checks above still apply.)`;
525
+ }
526
+ }
527
+
528
+ const body = [
529
+ ...sections,
530
+ critique ? `## Visual review\n${critique}` : '',
531
+ '',
532
+ problems
533
+ ? 'Fix the problems above, then look again to confirm.'
534
+ : state.done && critique
535
+ ? 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing - ' +
536
+ 'the next look re-runs only the fast checks.'
537
+ : 'The automatic checks found nothing.',
538
+ ].filter(Boolean).join('\n\n');
539
+
540
+ return result(
541
+ body,
542
+ problems
543
+ ? `${problems} problem${problems === 1 ? '' : 's'} found · screenshots in .ucode/screenshots`
544
+ : 'no errors · screenshots in .ucode/screenshots'
545
+ );
546
+ }