ucode-agent 1.36.0 → 1.38.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.
package/README.md CHANGED
@@ -27,7 +27,7 @@ corner:
27
27
  add a dark mode toggle that remembers the choice
28
28
 
29
29
 
30
- v1.29.0
30
+ v1.36.0
31
31
  ```
32
32
 
33
33
  A light crosses the wordmark once as it opens, and the three lines under the box
@@ -107,12 +107,14 @@ of what ucode is asked to do, and it answers far sooner than the big reasoning
107
107
  models. Switch to Ultra when a problem needs the million-token window more than
108
108
  the speed.
109
109
 
110
- **A busy model never stops a build.** Free endpoints are shared, and "too many
111
- requests" is routine. ucode waits it out with growing pauses, and if the model
112
- stays busy it carries on with the next one — North Mini Code, then Nemotron 3.5
113
- Lightning, Super, Ultra — from exactly where it was, and tells you it switched.
114
- If every model is busy at once it waits a minute and goes round again. Your
115
- chosen model gets another go a few minutes later.
110
+ **The model you chose is the model you keep.** Free endpoints are shared and
111
+ "too many requests" is routine, so ucode waits it out with growing pauses and
112
+ comes back to the same model. It does not quietly hand your build to a
113
+ different one: a build that starts on one model and finishes on another
114
+ finishes to a different standard, and the swap lands exactly when you are least
115
+ placed to work out why the output changed. Set `UCODE_FALLBACK=1` if you would
116
+ rather it moved down the list — North Mini Code, Nemotron 3.5 Lightning, Super,
117
+ Ultra — when a model stays busy.
116
118
 
117
119
  ## What it does
118
120
 
@@ -211,6 +213,13 @@ same link. Needs a token from vercel.com/account/tokens in `~/.ucode/.env` as
211
213
  sunset, graphite, violet or citrus — each a full light and dark palette with its
212
214
  own font, so apps stop looking like the same default blue.
213
215
 
216
+ **Every turn can be taken back.** `/undo` puts back every file the last turn
217
+ changed — a rewritten file returns byte for byte, a file that did not exist
218
+ before is removed again. Each write keeps the original the first time that turn
219
+ touches it, so what comes back is the state before the turn rather than before
220
+ the last of six edits to the same file. An agent that writes to your disk on
221
+ its own should be able to take it back, whether or not the project has git.
222
+
214
223
  **It notices when it is going round in circles.** The same failing edit, an edit
215
224
  that changes nothing, or a build failing on the same errors three times gets a
216
225
  firm, specific note; if that does not work, the turn moves to another model.
@@ -238,13 +247,23 @@ written, its install starts in the background while the rest of the app is
238
247
  still being written. An install the model asks for later waits for that one
239
248
  instead of running twice, and anything run in that folder waits for it too.
240
249
 
241
- **It looks at what it built.** `look_at_app` opens the running app in a real
242
- browser — the Edge or Chrome already on your machine, so there is nothing extra
243
- to download — at 375px and 1440px. It reports console errors, failed requests,
244
- content that spills off a phone screen, broken images and unlabeled controls,
245
- saves screenshots to `.ucode/screenshots`, and has Nemotron Nano Omni review them
246
- the way a designer would. The model fixes what it finds before calling the app
247
- done. Both widths load at once, and the designer review — the slow part — runs
250
+ **It opens what it built and uses it.** Every app build ends with a look — not
251
+ when the model remembers to ask for one, but as part of the same pass that
252
+ type-checks. It opens the app in a real browser (the Edge or Chrome already on
253
+ your machine, so there is nothing extra to download) at 375px and 1440px, and
254
+ serves the folder itself when there is no dev server to point at, which is how
255
+ a three-file app gets checked at all.
256
+
257
+ Then it uses the app. It types into the first field, presses Enter, and clicks
258
+ the button that submits — and if the page gains no elements, changes no text
259
+ and stores nothing, that is reported as the thing to fix before anything else.
260
+ A page that renders and has no working behaviour passes a type check, a syntax
261
+ check and a screenshot; the only way to find out is to press something.
262
+
263
+ It also reports console errors, failed requests, content that spills off a
264
+ phone screen, broken images and unlabeled controls, saves screenshots to
265
+ `.ucode/screenshots`, and has Nemotron Nano Omni review them the way a designer
266
+ would. The model fixes what it finds before calling the app done. Both widths load at once, and the designer review — the slow part — runs
248
267
  on the first look at an app in each request and is skipped, not waited on, when
249
268
  the vision model is busy. The look after the fixes re-runs only the fast checks:
250
269
  a few seconds.
@@ -355,6 +374,11 @@ Everything after the frontmatter is the instruction.
355
374
  | `/session delete 2,5` | delete saved conversations by number (or `d d` in the list) |
356
375
  | `/new` | save this one and start fresh |
357
376
  | `/remember <note>` | add a standing note to this project's `UCODE.md` |
377
+ | `/undo` | put back every file the last turn changed |
378
+ | `/look [url]` | open the running app and report what is on the page |
379
+ | `/deploy [folder]` | put the app online and get its link |
380
+ | `/stats` | time, steps and tokens this session |
381
+ | `/doctor` | check that everything ucode needs is working |
358
382
  | `/skills` | what it knows how to do, and what is loaded |
359
383
  | `/search <query>` | look something up on the web |
360
384
  | `/copy` | last reply to the clipboard |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.36.0",
3
+ "version": "1.38.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -19,6 +19,7 @@ import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
19
19
  import { LogWatch } from './livelog.js';
20
20
  import { checkHtml } from './htmlcheck.js';
21
21
  import { runningServers } from '../tools/shell.js';
22
+ import { unprefixOwnFolder } from './relink.js';
22
23
  import { beginTurn, undoTurn, changedCount } from './undo.js';
23
24
  import { spawn } from 'node:child_process';
24
25
 
@@ -1912,6 +1913,17 @@ export class Agent {
1912
1913
  if (!changed.length) return null;
1913
1914
 
1914
1915
  const root = path.resolve(this.cwd);
1916
+
1917
+ // A page that links to its own folder, whoever wrote it. create_app fixes
1918
+ // the files it is handed, but an app written across several calls — a
1919
+ // scaffold, then two edits — reintroduces the prefix on the next write,
1920
+ // and nothing was watching after the first one. The page then loads no
1921
+ // stylesheet and no script, which is the difference between an app and a
1922
+ // wall of unstyled markup, so it is corrected wherever it turns up.
1923
+ const relinked = await unprefixOwnFolder(root, changed);
1924
+ if (relinked.length) {
1925
+ this.ui.toolCall(`Fixing self-referencing links in ${relinked.join(', ')}`);
1926
+ }
1915
1927
  const tsRoots = new Set();
1916
1928
  const singles = [];
1917
1929
  const problems = [];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * relink.js — taking an app's own folder back out of its own links.
3
+ *
4
+ * create_app wants paths relative to the project root — "tide/index.html" —
5
+ * because that is where every file tool works from. The model then carries the
6
+ * same prefix into the markup and writes `<link href="tide/styles.css">` inside
7
+ * tide/index.html, where it resolves to tide/tide/styles.css and 404s. The page
8
+ * comes up as bare markup: no stylesheet, no script, dead buttons. It is the
9
+ * commonest way a finished build looks like nothing was built.
10
+ *
11
+ * It lived inside create_app first, which covered only the files that call was
12
+ * handed. An app written across a scaffold and two edits put the prefix back on
13
+ * the next write and nothing was watching, so it runs over every changed page
14
+ * now, on every turn, wherever the page came from.
15
+ */
16
+
17
+ import { promises as fs } from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ /** Relative href/src only — absolute paths, URLs, data: and anchors are left alone. */
21
+ const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
22
+
23
+ /**
24
+ * Strip a page's own folder from its relative links.
25
+ *
26
+ * `files` are paths relative to `root`. A page at "tide/index.html" has "tide/"
27
+ * taken off its links, because inside that folder the prefix is always wrong.
28
+ * A page sitting directly in the root has no folder to strip and is skipped.
29
+ *
30
+ * Returns the pages it changed, for reporting. Never throws: a file that cannot
31
+ * be read is a file that is left exactly as it is.
32
+ */
33
+ export async function unprefixOwnFolder(root, files) {
34
+ const fixed = [];
35
+
36
+ for (const rel of files) {
37
+ if (!/\.html?$/i.test(rel)) continue;
38
+
39
+ const abs = path.resolve(root, rel);
40
+ const folder = path.basename(path.dirname(abs));
41
+ if (!folder || path.dirname(abs) === path.resolve(root)) continue;
42
+
43
+ const text = await fs.readFile(abs, 'utf8').catch(() => null);
44
+ if (text === null) continue;
45
+
46
+ const prefix = `${folder}/`;
47
+ let hits = 0;
48
+ const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
49
+ if (!value.startsWith(prefix)) return all;
50
+ hits++;
51
+ return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
52
+ });
53
+
54
+ if (!hits) continue;
55
+ try {
56
+ await fs.writeFile(abs, next, 'utf8');
57
+ fixed.push(`${rel} (${hits})`);
58
+ } catch { /* unwritable: leave it, and say nothing that is not true */ }
59
+ }
60
+
61
+ return fixed;
62
+ }
@@ -298,7 +298,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
298
298
 
299
299
  try {
300
300
  await fs.mkdir(path.dirname(target.abs), { recursive: true });
301
- await remember(target.abs);
301
+ await remember(target.abs);
302
302
  await fs.writeFile(target.abs, content, 'utf8');
303
303
  } catch (err) {
304
304
  throw fsFailure(err, attempted, target.show);
@@ -454,12 +454,13 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
454
454
  fix: 'edit_file replaces existing text. Use write_file to create a file.',
455
455
  });
456
456
  }
457
+ // An edit whose two halves are the same asks for the file to stay as it is,
458
+ // which it will. Refusing that was a hard failure, and the model answered it
459
+ // by sending the same edit again — the stuck detector carries a special case
460
+ // for exactly this loop. Saying "already done" ends it in one step.
457
461
  if (old_string === new_string) {
458
- throw new ToolFailure({
459
- kind: 'bad_args', attempted,
460
- failed: `${prefix}old_string and new_string are identical, so the edit would change nothing.`,
461
- fix: 'Set new_string to the text you actually want there.',
462
- });
462
+ const found = text.indexOf(old_string);
463
+ return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, loose: false };
463
464
  }
464
465
 
465
466
  // Models write \n. A file checked out on Windows is often \r\n, and then an
@@ -557,7 +558,7 @@ export async function editFile({ path: p, old_string, new_string }) {
557
558
  });
558
559
 
559
560
  try {
560
- await remember(target.abs);
561
+ await remember(target.abs);
561
562
  await fs.writeFile(target.abs, text, 'utf8');
562
563
  } catch (err) {
563
564
  throw fsFailure(err, attempted, target.show);
@@ -631,7 +632,7 @@ export async function multiEdit({ path: p, edits }) {
631
632
  }
632
633
 
633
634
  try {
634
- await remember(target.abs);
635
+ await remember(target.abs);
635
636
  await fs.writeFile(target.abs, text, 'utf8');
636
637
  } catch (err) {
637
638
  throw fsFailure(err, attempted, target.show);
@@ -720,7 +721,7 @@ export async function editFiles({ files }) {
720
721
 
721
722
  for (const { target, text } of planned) {
722
723
  try {
723
- await remember(target.abs);
724
+ await remember(target.abs);
724
725
  await fs.writeFile(target.abs, text, 'utf8');
725
726
  } catch (err) {
726
727
  throw fsFailure(err, `editing ${target.show}`, target.show);
@@ -108,88 +108,88 @@ async function copyTree(from, to, fill) {
108
108
  * globals.css and layout.tsx. Apps stop looking like the same default blue.
109
109
  * Returns the preset used, or null when the starter has none.
110
110
  */
111
- /**
112
- * The file in each starter that carries the design, not just some of the rules.
113
- *
114
- * next-shadcn has globals.css, which applyDesign re-tints. plain-html has one
115
- * stylesheet and it is the whole design system: the palette, a spacing scale,
116
- * radii, motion timings, focus rings, a reduced-motion rule and a breakpoint.
117
- */
118
- const TOKEN_FILE = { 'plain-html': 'styles.css' };
119
-
120
- /**
121
- * Put the starter's token block back when the app wrote over it without one.
122
- *
123
- * The stylesheet in plain-html is a design, not a placeholder — but `files`
124
- * lands straight on top of the starter, so a model passing its own styles.css
125
- * replaces the scale, the palette and the timings with whatever it typed. What
126
- * comes back is hand-rolled CSS with no system behind it, and an app built on
127
- * raw pixel values has uneven spacing everywhere for the rest of its life.
128
- *
129
- * A replacement that declares its own custom properties is left alone: that is
130
- * a model doing the job properly, and second-guessing it would be worse. Only
131
- * a stylesheet with no :root variables at all gets the starter's block put
132
- * back above it, where every rule underneath can reach it.
133
- */
134
- const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
135
-
136
- /**
137
- * Take the app's own folder back out of its own links.
138
- *
139
- * create_app wants paths relative to the project root — "todo/index.html" —
140
- * because that is where every other file tool works from. The model then
141
- * carries the same prefix into the markup and writes
142
- * <link href="todo/styles.css"> inside todo/index.html, where it resolves to
143
- * todo/todo/styles.css and 404s. The page comes up as bare markup with no
144
- * stylesheet and no script: no design, no behaviour, nothing in the console
145
- * but two failed requests.
146
- *
147
- * It is the tool's own convention leaking into the file, so the tool takes it
148
- * back out. Only a leading "<folder>/" on a relative reference, which inside
149
- * that folder is always wrong — absolute paths, URLs, data: and anchors are
150
- * left exactly as they are.
151
- */
152
- async function unprefixOwnFolder(appDir, written) {
153
- const prefix = `${path.basename(appDir)}/`;
154
- const fixed = [];
155
-
156
- for (const abs of written) {
157
- if (!/\.html?$/i.test(abs)) continue;
158
- const text = await fs.readFile(abs, 'utf8').catch(() => null);
159
- if (text === null) continue;
160
-
161
- let hits = 0;
162
- const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
163
- if (!value.startsWith(prefix)) return all;
164
- hits++;
165
- return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
166
- });
167
-
168
- if (hits) {
169
- await fs.writeFile(abs, next, 'utf8');
170
- fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
171
- }
172
- }
173
- return fixed;
174
- }
175
-
176
- async function keepDesignTokens(appDir, template, written, starterCss) {
177
- const rel = TOKEN_FILE[template];
178
- if (!rel || !starterCss) return null;
179
-
180
- const abs = path.join(appDir, rel);
181
- if (!written.has(abs)) return null; // never overwritten
182
-
183
- const now = await fs.readFile(abs, 'utf8').catch(() => null);
184
- if (now === null || /:root\s*\{[^}]*--/.test(now)) return null;
185
-
186
- const block = /:root\s*\{[\s\S]*?\n\}/.exec(starterCss);
187
- if (!block) return null;
188
-
189
- await fs.writeFile(abs, `${block[0]}\n\n${now}`, 'utf8');
190
- return rel;
191
- }
192
-
111
+ /**
112
+ * The file in each starter that carries the design, not just some of the rules.
113
+ *
114
+ * next-shadcn has globals.css, which applyDesign re-tints. plain-html has one
115
+ * stylesheet and it is the whole design system: the palette, a spacing scale,
116
+ * radii, motion timings, focus rings, a reduced-motion rule and a breakpoint.
117
+ */
118
+ const TOKEN_FILE = { 'plain-html': 'styles.css' };
119
+
120
+ /**
121
+ * Put the starter's token block back when the app wrote over it without one.
122
+ *
123
+ * The stylesheet in plain-html is a design, not a placeholder — but `files`
124
+ * lands straight on top of the starter, so a model passing its own styles.css
125
+ * replaces the scale, the palette and the timings with whatever it typed. What
126
+ * comes back is hand-rolled CSS with no system behind it, and an app built on
127
+ * raw pixel values has uneven spacing everywhere for the rest of its life.
128
+ *
129
+ * A replacement that declares its own custom properties is left alone: that is
130
+ * a model doing the job properly, and second-guessing it would be worse. Only
131
+ * a stylesheet with no :root variables at all gets the starter's block put
132
+ * back above it, where every rule underneath can reach it.
133
+ */
134
+ const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
135
+
136
+ /**
137
+ * Take the app's own folder back out of its own links.
138
+ *
139
+ * create_app wants paths relative to the project root — "todo/index.html" —
140
+ * because that is where every other file tool works from. The model then
141
+ * carries the same prefix into the markup and writes
142
+ * <link href="todo/styles.css"> inside todo/index.html, where it resolves to
143
+ * todo/todo/styles.css and 404s. The page comes up as bare markup with no
144
+ * stylesheet and no script: no design, no behaviour, nothing in the console
145
+ * but two failed requests.
146
+ *
147
+ * It is the tool's own convention leaking into the file, so the tool takes it
148
+ * back out. Only a leading "<folder>/" on a relative reference, which inside
149
+ * that folder is always wrong — absolute paths, URLs, data: and anchors are
150
+ * left exactly as they are.
151
+ */
152
+ async function unprefixOwnFolder(appDir, written) {
153
+ const prefix = `${path.basename(appDir)}/`;
154
+ const fixed = [];
155
+
156
+ for (const abs of written) {
157
+ if (!/\.html?$/i.test(abs)) continue;
158
+ const text = await fs.readFile(abs, 'utf8').catch(() => null);
159
+ if (text === null) continue;
160
+
161
+ let hits = 0;
162
+ const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
163
+ if (!value.startsWith(prefix)) return all;
164
+ hits++;
165
+ return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
166
+ });
167
+
168
+ if (hits) {
169
+ await fs.writeFile(abs, next, 'utf8');
170
+ fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
171
+ }
172
+ }
173
+ return fixed;
174
+ }
175
+
176
+ async function keepDesignTokens(appDir, template, written, starterCss) {
177
+ const rel = TOKEN_FILE[template];
178
+ if (!rel || !starterCss) return null;
179
+
180
+ const abs = path.join(appDir, rel);
181
+ if (!written.has(abs)) return null; // never overwritten
182
+
183
+ const now = await fs.readFile(abs, 'utf8').catch(() => null);
184
+ if (now === null || /:root\s*\{[^}]*--/.test(now)) return null;
185
+
186
+ const block = /:root\s*\{[\s\S]*?\n\}/.exec(starterCss);
187
+ if (!block) return null;
188
+
189
+ await fs.writeFile(abs, `${block[0]}\n\n${now}`, 'utf8');
190
+ return rel;
191
+ }
192
+
193
193
  export async function applyDesign(appDir, design) {
194
194
  const dir = path.join(appDir, 'presets');
195
195
  const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
@@ -304,12 +304,21 @@ export async function createApp({ folder, name, description, template = 'plain-h
304
304
  } catch {
305
305
  existing = [];
306
306
  }
307
- if (existing.length) {
307
+ // A folder with something already in it, and the whole app passed in
308
+ // alongside: that is the second attempt at a build that half happened.
309
+ // Refusing it is how a Next.js build spent two calls being told no and then
310
+ // started over from nothing. The starter is already on disk — take the files
311
+ // and write them into it.
312
+ //
313
+ // With no files it is still a refusal, because then there is nothing to do
314
+ // but copy a starter over work that is already there.
315
+ const adopt = existing.length > 0 && given.length > 0;
316
+ if (existing.length && !adopt) {
308
317
  throw new ToolFailure({
309
318
  kind: 'not_empty',
310
319
  attempted,
311
320
  failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
312
- fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
321
+ fix: 'Pick a new folder name, or pass the app in "files" to write it into the folder that is already there.',
313
322
  });
314
323
  }
315
324
 
@@ -320,23 +329,23 @@ export async function createApp({ folder, name, description, template = 'plain-h
320
329
  __APP_DESCRIPTION__: plain(description) || display,
321
330
  };
322
331
 
323
- const copied = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
332
+ const copied = adopt ? [] : await copyTree(path.join(TEMPLATES, template), target.abs, fill);
324
333
  // Next.js serves static files from public/; a plain page has no such place
325
334
  // and an empty folder in a three-file app is clutter.
326
- if (template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
327
- const look = await applyDesign(target.abs, design);
328
-
329
- // Read before the app's own files land on top of it, so the tokens can be
330
- // put back if the replacement arrives without any.
331
- const starterCss = TOKEN_FILE[template]
332
- ? await fs.readFile(path.join(target.abs, TOKEN_FILE[template]), 'utf8').catch(() => null)
335
+ if (!adopt && template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
336
+ const look = await applyDesign(target.abs, design);
337
+
338
+ // Read before the app's own files land on top of it, so the tokens can be
339
+ // put back if the replacement arrives without any.
340
+ const starterCss = TOKEN_FILE[template]
341
+ ? await fs.readFile(path.join(target.abs, TOKEN_FILE[template]), 'utf8').catch(() => null)
333
342
  : null;
334
343
 
335
344
  // The starter has been installed on this machine before: hard-link that
336
345
  // tree in, which is seconds where npm is a minute. Otherwise install as
337
346
  // usual, and keep the result so the next app is instant.
338
347
  let linked = 0;
339
- const needsInstall = template !== 'plain-html';
348
+ const needsInstall = !adopt && template !== 'plain-html';
340
349
  if (install && needsInstall) {
341
350
  // Keyed on the starter's lockfile, which is the same for every app made
342
351
  // from it — the app's own is rewritten by npm as it installs.
@@ -359,8 +368,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
359
368
  // one, and round trips are nearly all of the time a build takes.
360
369
  const mine = given;
361
370
  const wrote = mine.length ? await batchWrite({ files: mine }) : null;
362
- const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
363
- const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
371
+ const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
372
+ const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
364
373
  const relinked = await unprefixOwnFolder(target.abs, written);
365
374
 
366
375
  // The starter's own files, in full, so there is never a reason to read them
@@ -377,20 +386,20 @@ export async function createApp({ folder, name, description, template = 'plain-h
377
386
  const out = result(
378
387
  `Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
379
388
  (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
380
- (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
381
- (relinked.length
382
- ? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
383
- 'Paths in "files" are relative to the project root, but a link inside a page is ' +
384
- 'relative to that page — so "index.html" beside "styles.css" links to it as ' +
385
- '"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
386
- 'script 404 and the page comes up as bare markup.\n'
387
- : '') +
388
- (keptTokens
389
- ? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
390
- 'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
391
- 'Build the rest of the stylesheet out of those variables: spacing that comes from a ' +
392
- 'scale is the difference between a designed page and an arranged one. Re-tint the ' +
393
- 'values to suit this app; do not go back to raw pixels.\n'
389
+ (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
390
+ (relinked.length
391
+ ? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
392
+ 'Paths in "files" are relative to the project root, but a link inside a page is ' +
393
+ 'relative to that page — so "index.html" beside "styles.css" links to it as ' +
394
+ '"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
395
+ 'script 404 and the page comes up as bare markup.\n'
396
+ : '') +
397
+ (keptTokens
398
+ ? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
399
+ 'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
400
+ 'Build the rest of the stylesheet out of those variables: spacing that comes from a ' +
401
+ 'scale is the difference between a designed page and an arranged one. Re-tint the ' +
402
+ 'values to suit this app; do not go back to raw pixels.\n'
394
403
  : '') +
395
404
  (linked
396
405
  ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +