ucode-agent 1.4.0 → 1.6.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.
Files changed (76) hide show
  1. package/README.md +64 -4
  2. package/package.json +5 -2
  3. package/skills/build-app/SKILL.md +19 -1
  4. package/skills/ui-ux/SKILL.md +2 -2
  5. package/src/core/doctor.js +122 -0
  6. package/src/core/loop.js +303 -12
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/tools/browser.js +121 -59
  10. package/src/tools/deploy.js +283 -0
  11. package/src/tools/files.js +91 -8
  12. package/src/tools/index.js +65 -8
  13. package/src/tools/scaffold.js +190 -0
  14. package/src/tools/shell.js +89 -1
  15. package/src/ui/activity.js +203 -0
  16. package/src/ui/plain.js +22 -3
  17. package/src/ui/screen.js +65 -19
  18. package/templates/next-shadcn/AGENTS.md +9 -0
  19. package/templates/next-shadcn/README.md +9 -0
  20. package/templates/next-shadcn/TEMPLATE.md +83 -0
  21. package/templates/next-shadcn/_gitignore +41 -0
  22. package/templates/next-shadcn/_package-lock.json +11465 -0
  23. package/templates/next-shadcn/components.json +25 -0
  24. package/templates/next-shadcn/eslint.config.mjs +18 -0
  25. package/templates/next-shadcn/next-env.d.ts +5 -0
  26. package/templates/next-shadcn/next.config.ts +8 -0
  27. package/templates/next-shadcn/package.json +39 -0
  28. package/templates/next-shadcn/postcss.config.mjs +7 -0
  29. package/templates/next-shadcn/presets/citrus.json +77 -0
  30. package/templates/next-shadcn/presets/graphite.json +77 -0
  31. package/templates/next-shadcn/presets/grove.json +77 -0
  32. package/templates/next-shadcn/presets/ocean.json +78 -0
  33. package/templates/next-shadcn/presets/sunset.json +77 -0
  34. package/templates/next-shadcn/presets/violet.json +77 -0
  35. package/templates/next-shadcn/src/app/favicon.ico +0 -0
  36. package/templates/next-shadcn/src/app/globals.css +155 -0
  37. package/templates/next-shadcn/src/app/layout.tsx +42 -0
  38. package/templates/next-shadcn/src/app/page.tsx +14 -0
  39. package/templates/next-shadcn/src/components/theme-provider.tsx +7 -0
  40. package/templates/next-shadcn/src/components/theme-toggle.tsx +27 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +199 -0
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +111 -0
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +48 -0
  45. package/templates/next-shadcn/src/components/ui/button.tsx +66 -0
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +222 -0
  47. package/templates/next-shadcn/src/components/ui/card.tsx +102 -0
  48. package/templates/next-shadcn/src/components/ui/checkbox.tsx +32 -0
  49. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  50. package/templates/next-shadcn/src/components/ui/command.tsx +195 -0
  51. package/templates/next-shadcn/src/components/ui/dialog.tsx +168 -0
  52. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +268 -0
  53. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  54. package/templates/next-shadcn/src/components/ui/input-group.tsx +156 -0
  55. package/templates/next-shadcn/src/components/ui/input.tsx +18 -0
  56. package/templates/next-shadcn/src/components/ui/label.tsx +23 -0
  57. package/templates/next-shadcn/src/components/ui/popover.tsx +88 -0
  58. package/templates/next-shadcn/src/components/ui/progress.tsx +30 -0
  59. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  60. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +54 -0
  61. package/templates/next-shadcn/src/components/ui/select.tsx +191 -0
  62. package/templates/next-shadcn/src/components/ui/separator.tsx +27 -0
  63. package/templates/next-shadcn/src/components/ui/sheet.tsx +147 -0
  64. package/templates/next-shadcn/src/components/ui/skeleton.tsx +13 -0
  65. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  66. package/templates/next-shadcn/src/components/ui/sonner.tsx +49 -0
  67. package/templates/next-shadcn/src/components/ui/switch.tsx +32 -0
  68. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  69. package/templates/next-shadcn/src/components/ui/tabs.tsx +89 -0
  70. package/templates/next-shadcn/src/components/ui/textarea.tsx +17 -0
  71. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  72. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  73. package/templates/next-shadcn/src/components/ui/tooltip.tsx +56 -0
  74. package/templates/next-shadcn/src/lib/utils.ts +6 -0
  75. package/templates/next-shadcn/tsconfig.json +34 -0
  76. package/ucode.js +8 -1
@@ -0,0 +1,269 @@
1
+ /**
2
+ * stuck.js — noticing when the model is going round in circles.
3
+ *
4
+ * Measured on a real build (a tip calculator, 84 model steps): six edits whose
5
+ * old_string and new_string were identical, the same build failing three times
6
+ * running on the same two type errors, and thirty reads of files whose current
7
+ * text was already in the conversation. Every one of those is a whole round
8
+ * trip, and a small model that has started repeating itself rarely stops on
9
+ * its own — the error it keeps getting says what is wrong, but nothing says
10
+ * "you have tried exactly this before".
11
+ *
12
+ * So each finished tool call becomes an event, and the recent events are
13
+ * checked for four patterns:
14
+ *
15
+ * repeat the same call, with the same arguments, failing the same way
16
+ * identical edits refused because old_string and new_string are the same
17
+ * reread reading a file whose unchanged text is still in view
18
+ * build a build failing with the same error text
19
+ *
20
+ * The first time a pattern reaches its threshold, the result that completed it
21
+ * carries a firm, specific note: what was repeated, the error, what to do
22
+ * instead. If the same pattern turns up again after that note, a nudge has not
23
+ * worked, and the caller hands the turn to another model.
24
+ *
25
+ * Everything here is pure: events in, verdicts out. The loop owns the side
26
+ * effects — appending the text and switching the model.
27
+ */
28
+
29
+ import { createHash } from 'node:crypto';
30
+
31
+ /** How many recent tool calls the patterns are looked for in. */
32
+ export const WINDOW = 20;
33
+
34
+ /** Occurrences inside the window that make a hit. */
35
+ export const THRESHOLDS = { repeat: 3, identical: 2, reread: 1, build: 3 };
36
+
37
+ const hash = (s) => createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
38
+
39
+ /** Argument values compared the way a person would: whitespace runs do not make a call different. */
40
+ function stable(value) {
41
+ if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim();
42
+ if (Array.isArray(value)) return value.map(stable);
43
+ if (value && typeof value === 'object') {
44
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]));
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /** Name and arguments, reduced to one short string. */
50
+ export function signature(call) {
51
+ return `${call?.name}:${hash(JSON.stringify(stable(call?.args ?? {})))}`;
52
+ }
53
+
54
+ /** Commands whose job is to build or type-check the project. */
55
+ const BUILD = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b|\btsc\b(?![^&|;]*--watch)/i;
56
+
57
+ export const isBuild = (command) => BUILD.test(String(command ?? ''));
58
+
59
+ const ERROR_LINE = /\berror\b|Error:|Module not found|Can't resolve|Type error|is not defined|Unterminated/i;
60
+ const GENERIC = /^(?:>\s*)?Build error occurred|Failed to (?:type check|compile)|build worker exited|exiting the build|^exit code:|^What to do:|^- |build failed with \d+ errors?|^Import trace|^\d+ errors? found/i;
61
+
62
+ /**
63
+ * The part of a build's output that names what is wrong, with the noise that
64
+ * changes from run to run — timings, digests — taken out, so two failures on
65
+ * the same errors compare equal.
66
+ */
67
+ export function buildErrors(output) {
68
+ const clean = (l) => l
69
+ .replace(/\b\d+(?:\.\d+)?\s?m?s\b/g, '')
70
+ .replace(/digest: '[^']*'/g, '')
71
+ .replace(/\s+/g, ' ')
72
+ .trim();
73
+ const lines = String(output ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
74
+ const errors = [...new Set(lines.filter((l) => ERROR_LINE.test(l) && !GENERIC.test(l)).map(clean))];
75
+ return (errors.length ? errors.slice(0, 12) : lines.slice(-5).map(clean)).join('\n');
76
+ }
77
+
78
+ /**
79
+ * One finished call as the detector sees it.
80
+ *
81
+ * @param {object} call { name, args }
82
+ * @param {object} outcome
83
+ * @param {object} [outcome.out] a tool result ({ content, exitCode? })
84
+ * @param {object} [outcome.err] a ToolFailure
85
+ * @param {string[]} [outcome.rereads] files this call re-read while their text was still in view
86
+ */
87
+ export function eventFor(call, { out, err, rereads = [] } = {}) {
88
+ const args = call?.args ?? {};
89
+ const event = { tool: call?.name, sig: signature(call), failed: false, rereads: [...rereads] };
90
+ event.path = args.path ?? args.files?.[0]?.path ?? null;
91
+
92
+ if (err) {
93
+ event.failed = true;
94
+ event.kind = err.kind ?? 'error';
95
+ event.error = String(err.failed ?? err.message ?? err);
96
+ event.identical = event.kind === 'bad_args' && /old_string and new_string are identical/.test(event.error);
97
+ return event;
98
+ }
99
+
100
+ if (call?.name === 'run_command' && out && out.exitCode !== undefined && out.exitCode !== 0) {
101
+ const command = String(args.command ?? '');
102
+ event.failed = true;
103
+ event.kind = `exit ${out.exitCode}`;
104
+ event.command = command;
105
+ if (isBuild(command)) {
106
+ event.build = true;
107
+ event.error = buildErrors(out.content);
108
+ } else {
109
+ event.error = buildErrors(out.content).slice(0, 400);
110
+ }
111
+ }
112
+ return event;
113
+ }
114
+
115
+ /**
116
+ * Does the latest event complete a pattern? Returns the hit, or null.
117
+ *
118
+ * Only the newest event can complete one: every earlier event was checked when
119
+ * it arrived, so looking again would report the same hit twice.
120
+ */
121
+ export function detect(events, { window = WINDOW, thresholds = THRESHOLDS } = {}) {
122
+ const recent = events.slice(-window);
123
+ const last = recent[recent.length - 1];
124
+ if (!last) return null;
125
+
126
+ if (last.identical) {
127
+ const count = recent.filter((e) => e.identical).length;
128
+ return count >= thresholds.identical
129
+ ? { pattern: 'identical', key: 'identical', count, tool: last.tool, path: last.path, error: last.error }
130
+ : null;
131
+ }
132
+
133
+ if (last.build) {
134
+ const count = recent.filter((e) => e.build && e.error === last.error).length;
135
+ return count >= thresholds.build
136
+ ? { pattern: 'build', key: `build:${hash(last.error)}`, count, command: last.command, error: last.error }
137
+ : null;
138
+ }
139
+
140
+ if (last.rereads?.length) {
141
+ const count = recent.filter((e) => e.rereads?.length).length;
142
+ return count >= thresholds.reread
143
+ ? { pattern: 'reread', key: 'reread', count, paths: last.rereads }
144
+ : null;
145
+ }
146
+
147
+ if (last.failed) {
148
+ const same = (e) => e.failed && !e.identical && !e.build && e.sig === last.sig && e.kind === last.kind && e.error === last.error;
149
+ const count = recent.filter(same).length;
150
+ return count >= thresholds.repeat
151
+ ? {
152
+ pattern: 'repeat', key: `repeat:${last.sig}:${last.kind}:${hash(last.error)}`, count,
153
+ tool: last.tool, kind: last.kind, error: last.error, path: last.path, command: last.command,
154
+ }
155
+ : null;
156
+ }
157
+
158
+ return null;
159
+ }
160
+
161
+ /** What each pattern is, as a few words for the user and for the model-switch note. */
162
+ export function describeHit(hit) {
163
+ switch (hit.pattern) {
164
+ case 'identical': return 'making edits that change nothing';
165
+ case 'build': return 'rebuilding on the same errors';
166
+ case 'reread': return 're-reading files it already has';
167
+ default: return `repeating a failing ${hit.tool}`;
168
+ }
169
+ }
170
+
171
+ const nth = (n) => `${n}${n % 10 === 1 && n % 100 !== 11 ? 'st' : n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th'}`;
172
+
173
+ /** What to do instead, by the kind of failure being repeated. */
174
+ function adviceFor(hit) {
175
+ if (hit.tool === 'run_command') {
176
+ return 'Read the output above: it names the problem. Change the cause - the code, the ' +
177
+ 'command, or its cwd - before running it again, or take a different route.';
178
+ }
179
+ switch (hit.kind) {
180
+ case 'no_match':
181
+ return `Read ${hit.path ?? 'the file'} once, copy old_string from that result exactly as it ` +
182
+ 'stands, without the line-number gutter - or, if the change is large, rewrite the file with write_file.';
183
+ case 'ambiguous':
184
+ return 'Add the lines around it to old_string until it matches one place only.';
185
+ case 'not_found':
186
+ return 'That path does not exist. Find the right one with glob or list_dir first.';
187
+ case 'bad_args':
188
+ return 'Fix exactly the argument the error names before calling it again.';
189
+ default:
190
+ return 'Change what the error points at, or take a different route.';
191
+ }
192
+ }
193
+
194
+ /** The note appended to the result that completed a hit. Empty when the result already says it. */
195
+ export function nudge(hit, { switched = false } = {}) {
196
+ const lead = switched
197
+ ? 'ucode has handed this turn to another model, because the last one kept ' +
198
+ `${describeHit(hit)} after being told to stop. `
199
+ : '';
200
+
201
+ switch (hit.pattern) {
202
+ case 'identical':
203
+ return `${lead}STOP - that is ${hit.count} edits in a row whose old_string and new_string are ` +
204
+ 'identical. An edit like that changes nothing, so it is refused every time. If ' +
205
+ `${hit.path ?? 'the file'} already says what you want, that part is finished: move on to the ` +
206
+ 'next thing. If it does not, put the text you actually want in new_string.';
207
+ case 'build':
208
+ return `${lead}STOP - \`${hit.command}\` has now failed ${hit.count} times with the same errors:\n` +
209
+ `${hit.error}\n` +
210
+ 'Building again without changing the code those lines point at fails the same way, and each ' +
211
+ 'build takes most of a minute. Fix every error listed - in one pass, with edit_files or ' +
212
+ 'multi_edit - then build once.';
213
+ case 'reread':
214
+ // The result itself already says the text was not sent again and why;
215
+ // only a model switch has anything to add.
216
+ return switched
217
+ ? `${lead}Work from the file text already in this conversation instead of reading it again.`
218
+ : '';
219
+ default:
220
+ return `${lead}STOP - this is the ${nth(hit.count)} time ${hit.tool} has been called with exactly ` +
221
+ `these arguments, and it failed the same way every time (${hit.kind}: ${oneLine(hit.error)}). ` +
222
+ `Calling it again will fail again. ${adviceFor(hit)}`;
223
+ }
224
+ }
225
+
226
+ const oneLine = (s) => {
227
+ const line = String(s ?? '').split('\n')[0];
228
+ return line.length > 200 ? `${line.slice(0, 200)}…` : line;
229
+ };
230
+
231
+ /**
232
+ * The detector with memory: which hits have already had their nudge.
233
+ *
234
+ * observe() returns null, a nudge, or a switch. A nudge is remembered for as
235
+ * long as it is inside the window; the same pattern turning up again while it
236
+ * is remembered means the nudge did not work. After a switch the slate is
237
+ * wiped, so the new model gets a nudge of its own before any further switch.
238
+ */
239
+ export class StuckWatch {
240
+ constructor({ window = WINDOW, thresholds = THRESHOLDS } = {}) {
241
+ this.window = window;
242
+ this.thresholds = thresholds;
243
+ this.events = [];
244
+ this.seq = 0;
245
+ this.nudged = new Map(); // hit key -> seq of the event that was nudged
246
+ }
247
+
248
+ observe(event) {
249
+ this.seq++;
250
+ this.events.push({ ...event, seq: this.seq });
251
+ if (this.events.length > this.window * 2) this.events.splice(0, this.events.length - this.window);
252
+
253
+ const hit = detect(this.events, { window: this.window, thresholds: this.thresholds });
254
+ if (!hit) return null;
255
+
256
+ const at = this.nudged.get(hit.key);
257
+ if (at !== undefined && this.seq - at < this.window) {
258
+ this.reset();
259
+ return { action: 'switch', hit, text: nudge(hit, { switched: true }) };
260
+ }
261
+ this.nudged.set(hit.key, this.seq);
262
+ return { action: 'nudge', hit, text: nudge(hit) };
263
+ }
264
+
265
+ reset() {
266
+ this.events = [];
267
+ this.nudged.clear();
268
+ }
269
+ }
@@ -25,6 +25,7 @@ const WIDTHS = [
25
25
  { name: 'desktop', width: 1440, height: 900 },
26
26
  ];
27
27
  const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
28
+ const MAX_SHOT_HEIGHT = 3000;
28
29
 
29
30
  let browserPromise = null;
30
31
 
@@ -131,7 +132,8 @@ async function review(shots) {
131
132
  role: 'system',
132
133
  content:
133
134
  'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
134
- 'and one at desktop width. List the concrete visual problems a user would notice, most ' +
135
+ 'and one at desktop width. Each screenshot is the whole page, top to bottom, so anything not ' +
136
+ 'in it is genuinely not there. List the concrete visual problems a user would notice, most ' +
135
137
  'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
136
138
  'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
137
139
  'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
@@ -144,11 +146,40 @@ async function review(shots) {
144
146
  images: shots.map((s) => s.dataUrl),
145
147
  },
146
148
  ];
147
- const reply = await ask(request, [], { model: VISION_MODEL, temperature: 0.2, maxOutputTokens: 900 });
148
- return reply.text.trim();
149
+ const reply = await ask(request, [], {
150
+ model: VISION_MODEL,
151
+ temperature: 0.2,
152
+ // A reasoning model spends its budget thinking before it writes; 900
153
+ // tokens came back as an empty review. Keep the thinking short, and leave
154
+ // room for the answer.
155
+ maxOutputTokens: 4000,
156
+ reasoning: { effort: 'low' },
157
+ // The free vision model is often busy. One try, and a hard cap: a review
158
+ // that cannot run is skipped, never waited on.
159
+ attempts: 1,
160
+ signal: AbortSignal.timeout(REVIEW_BUDGET_MS),
161
+ });
162
+ const text = reply.text.trim();
163
+ if (!text) throw new Error('the vision model returned an empty review');
164
+ return text;
149
165
  }
150
166
 
151
- export async function lookAtApp({ url, paths = ['/'], review: wantReview = true }) {
167
+ const REVIEW_BUDGET_MS = 60_000;
168
+
169
+ /**
170
+ * The designer's review is the slow part — a reasoning model looking at
171
+ * screenshots, most of a minute — so each app gets one per turn: a look after
172
+ * the fixes only re-runs the fast checks. A review that failed (busy model,
173
+ * empty reply) gets one more try on the next look, then is let go.
174
+ */
175
+ const reviews = new Map(); // base URL -> { done, tries }
176
+
177
+ /** A new request from the user: the apps may be reviewed afresh. */
178
+ export function forgetReviews() {
179
+ reviews.clear();
180
+ }
181
+
182
+ export async function lookAtApp({ url, paths = ['/'] }) {
152
183
  const base = String(url ?? '').trim().replace(/\/+$/, '');
153
184
  if (!LOCAL.test(`${base}/`)) {
154
185
  throw new ToolFailure({
@@ -167,76 +198,104 @@ export async function lookAtApp({ url, paths = ['/'], review: wantReview = true
167
198
  await fs.mkdir(shotsDir, { recursive: true });
168
199
 
169
200
  const b = await browser();
170
- const sections = [];
171
- const toReview = [];
172
- let problems = 0;
173
201
 
174
- for (const pagePath of pages) {
175
- for (const size of WIDTHS) {
176
- const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
177
- const page = await context.newPage();
178
- const errors = [];
179
- const failed = [];
180
- page.on('console', (m) => {
181
- if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
182
- });
183
- page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
184
- page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
185
- page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
202
+ // Every page at every width opens at once, each in its own context: the
203
+ // wait is for the slowest one, not the sum of them all.
204
+ const checks = await Promise.all(pages.flatMap((pagePath) => WIDTHS.map(async (size) => {
205
+ let problems = 0;
206
+ let shot = null;
207
+ const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
208
+ const page = await context.newPage();
209
+ const errors = [];
210
+ const failed = [];
211
+ page.on('console', (m) => {
212
+ if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
213
+ });
214
+ page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
215
+ page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
216
+ page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
186
217
 
187
- const target = `${base}${pagePath}`;
188
- let loadError = null;
189
- try {
190
- // A dev server compiles a page on its first request, which can take a
191
- // while; networkidle then waits for the page's own data to arrive.
192
- await page.goto(target, { waitUntil: 'networkidle', timeout: 60_000 });
193
- } catch (err) {
194
- try { await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); }
195
- catch (err2) { loadError = String(err2.message).split('\n')[0]; }
196
- }
197
- await page.waitForTimeout(600); // let entrance animations settle
218
+ const target = `${base}${pagePath}`;
219
+ let loadError = null;
220
+ try {
221
+ // 'load', not 'networkidle': a dev server holds a hot-reload
222
+ // connection open and polls, so the network may never go quiet and
223
+ // 'networkidle' would wait out its whole timeout on every page, at
224
+ // every width. 'load' covers the first-request compile; the page's own
225
+ // data then gets a short, bounded chance to settle.
226
+ await page.goto(target, { waitUntil: 'load', timeout: 90_000 });
227
+ await page.waitForLoadState('networkidle', { timeout: 1_500 }).catch(() => {});
228
+ } catch (err) {
229
+ loadError = String(err.message).split('\n')[0];
230
+ }
231
+ await page.waitForTimeout(400); // let entrance animations settle
198
232
 
199
- const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
200
- let facts = null;
233
+ const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
234
+ let facts = null;
235
+ try {
201
236
  if (!loadError) {
202
237
  facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
203
- const buffer = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false });
238
+ // The whole page, so the reviewer never reports as missing what is
239
+ // only below the fold — capped, so an endless feed stays one image.
240
+ const tall = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
241
+ const buffer = await page.screenshot({
242
+ type: 'jpeg',
243
+ quality: 70,
244
+ fullPage: true,
245
+ ...(tall > MAX_SHOT_HEIGHT ? { clip: { x: 0, y: 0, width: size.width, height: MAX_SHOT_HEIGHT } } : {}),
246
+ });
204
247
  await fs.writeFile(file, buffer);
205
- toReview.push({ label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` });
248
+ shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
206
249
  }
207
- await context.close();
250
+ } catch (err) {
251
+ loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
252
+ } finally {
253
+ await context.close().catch(() => {});
254
+ }
208
255
 
209
- const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
210
- if (loadError) {
211
- lines.push(`Could not load: ${loadError}`);
256
+ const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
257
+ if (loadError) {
258
+ lines.push(`Could not load: ${loadError}`);
259
+ problems++;
260
+ } else {
261
+ lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
262
+ if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
263
+ if (facts?.overflow) {
264
+ lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
212
265
  problems++;
213
- } else {
214
- lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
215
- if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
216
- if (facts?.overflow) {
217
- lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
218
- problems++;
219
- }
220
- if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
221
- if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
222
- if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
223
- if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
224
- if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
225
- if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
226
266
  }
227
- if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
228
- if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
229
- if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
230
- sections.push(lines.join('\n'));
267
+ if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
268
+ if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
269
+ if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
270
+ if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
271
+ if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
272
+ if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
231
273
  }
232
- }
274
+ if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
275
+ if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
276
+ if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
277
+ const broken = Boolean(loadError || errors.length || facts?.empty);
278
+ return { section: lines.join('\n'), shot, problems, broken };
279
+ })));
280
+
281
+ const sections = checks.map((c) => c.section);
282
+ const toReview = checks.map((c) => c.shot).filter(Boolean);
283
+ const problems = checks.reduce((n, c) => n + c.problems, 0);
284
+ const broken = checks.some((c) => c.broken);
233
285
 
286
+ // A page that crashed or threw is fixed first; reviewing a screenshot of an
287
+ // error overlay is a minute spent on nothing.
234
288
  let critique = '';
235
- if (wantReview !== false && toReview.length) {
289
+ const state = reviews.get(base) ?? { done: false, tries: 0 };
290
+ if (!broken && !state.done && state.tries < 2 && toReview.length) {
291
+ state.tries++;
292
+ reviews.set(base, state);
236
293
  try {
237
294
  critique = await review(toReview.slice(0, 4));
295
+ state.done = true;
238
296
  } catch (err) {
239
- critique = `(The visual review could not run: ${err.failed ?? err.message}. The checks above still apply.)`;
297
+ const why = String(err.failed ?? err.message).replace(/[.\s]+$/, '');
298
+ critique = `(The visual review could not run: ${why}. The checks above still apply.)`;
240
299
  }
241
300
  }
242
301
 
@@ -246,7 +305,10 @@ export async function lookAtApp({ url, paths = ['/'], review: wantReview = true
246
305
  '',
247
306
  problems
248
307
  ? 'Fix the problems above, then look again to confirm.'
249
- : 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing.',
308
+ : state.done && critique
309
+ ? 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing - ' +
310
+ 'the next look re-runs only the fast checks.'
311
+ : 'The automatic checks found nothing.',
250
312
  ].filter(Boolean).join('\n\n');
251
313
 
252
314
  return result(