ucode-agent 1.10.0 → 1.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.10.0",
3
+ "version": "1.12.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
@@ -65,6 +65,32 @@ const MAX_ARG_RETRIES = 2;
65
65
  const MAX_CONTINUATIONS = 3;
66
66
 
67
67
  /** Read-only tools whose result line adds nothing — the user saw the output. */
68
+ /**
69
+ * How many rows a diff adds and removes.
70
+ *
71
+ * The rows come through as "+12| text" and "-12| text", with a "~" heading
72
+ * for each file in a multi-file write and an undecorated note counting what
73
+ * was elided. Only the signs are counted.
74
+ */
75
+ export function countDiff(rows = []) {
76
+ let added = 0;
77
+ let removed = 0;
78
+ for (const row of rows) {
79
+ const line = String(row ?? '');
80
+ if (line.startsWith('~')) continue;
81
+ // "… 218 more removed" / "… 508 more added" stand for rows not shown.
82
+ const more = /^\s*[….]+\s*(\d+)\s+more\s+(added|removed)/.exec(line);
83
+ if (more) {
84
+ if (more[2] === 'added') added += Number(more[1]);
85
+ else removed += Number(more[1]);
86
+ continue;
87
+ }
88
+ if (line.startsWith('+')) added++;
89
+ else if (line.startsWith('-')) removed++;
90
+ }
91
+ return { added, removed };
92
+ }
93
+
68
94
  const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
69
95
 
70
96
  /** Tools that draw their own line, so they get no "● Doing X" line of their own. */
@@ -897,10 +923,12 @@ export class Agent {
897
923
  this.ui.stopSpinner();
898
924
  this.ui.stopTimer?.();
899
925
  this.activity = null;
900
- this.ui.turnEnd?.({ ok: finished });
901
926
  await this.persist();
902
927
  if (this.full) this.showHeader({ clear: false });
903
928
  if (finished) this.openWhenReady(turnStarted);
929
+ // Last, so "Done" is the last thing that happens rather than the last
930
+ // thing said before several more things happen.
931
+ this.ui.turnEnd?.({ ok: finished });
904
932
  }
905
933
  }
906
934
 
@@ -1266,8 +1294,16 @@ export class Agent {
1266
1294
  }
1267
1295
 
1268
1296
  /** A dev server came up during this turn: open it in the browser, once. */
1297
+ /**
1298
+ * Open the running app in a browser — only when asked.
1299
+ *
1300
+ * This used to happen on its own whenever a dev server came up. Something
1301
+ * seizing the screen mid-thought is startling at the best of times, and
1302
+ * during a demo it is worse. UCODE_OPEN=1 brings the old behaviour back for
1303
+ * anyone who liked it; otherwise the URL is on screen to click.
1304
+ */
1269
1305
  openWhenReady(since) {
1270
- if (!this.full || process.env.UCODE_OPEN === '0') return;
1306
+ if (!this.full || process.env.UCODE_OPEN !== '1') return;
1271
1307
  const server = serversReadySince(since).at(-1);
1272
1308
  if (!server || (this.opened ??= new Set()).has(server.url)) return;
1273
1309
  this.opened.add(server.url);
@@ -1320,8 +1356,10 @@ export class Agent {
1320
1356
  this.sinceCheck?.clear();
1321
1357
  }
1322
1358
  if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
1323
- if (out.diff?.length) this.ui.diff(out.diff);
1324
- if (out.output?.length) this.ui.commandOutput(out.output);
1359
+ // The change as its two numbers, not as a copy of the file. The diff rows
1360
+ // are still built by the tool — the model reads them in the result — they
1361
+ // simply do not go on screen.
1362
+ if (out.diff?.length) this.ui.diffStat?.(countDiff(out.diff));
1325
1363
  this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1326
1364
  }
1327
1365
 
@@ -13,7 +13,7 @@ import { typeOf } from './types.js';
13
13
  import { runCommand, runCommands } from './shell.js';
14
14
  import { webSearch } from './web.js';
15
15
  import { lookAtApp } from './browser.js';
16
- import { createApp } from './scaffold.js';
16
+ import { createApp, TEMPLATE_NAMES, TEMPLATE_NOTES } from './scaffold.js';
17
17
  import { deploy } from './deploy.js';
18
18
  import { clip, READ_LINES } from './shared.js';
19
19
 
@@ -27,18 +27,28 @@ export const tools = [
27
27
  {
28
28
  name: 'create_app',
29
29
  description:
30
- 'Start a new Next.js + shadcn/ui app from the ready-made ucode starter. This is how every ' +
31
- 'Next.js app begins - never run create-next-app or shadcn init. It copies a project that ' +
32
- 'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with 33 common ' +
33
- 'components, light/dark mode, toasts, a design preset of colours and fonts) into a new empty folder, and ' +
34
- 'starts installing its packages in the background so you can write components at once. ' +
35
- 'The result lists everything included.',
30
+ 'Start a new app from a starter that already works. Two to choose between, and the ' +
31
+ 'choice matters. "plain-html": one index.html, one stylesheet, one ES module nothing ' +
32
+ 'to install, nothing to build, opens straight in a browser. Use it whenever the user ' +
33
+ 'asks for plain HTML/CSS/JS, or for a single page, a toy, a game or a visualisation. ' +
34
+ '"next-shadcn": Next.js 16, TypeScript, Tailwind 4 and shadcn with 33 components, light ' +
35
+ 'and dark, toasts, and a design preset — for anything with routes, data or many screens; ' +
36
+ 'its packages install in the background so you can write components at once. This is how ' +
37
+ 'every Next.js app begins - never run create-next-app or shadcn init. Do not reach for ' +
38
+ 'Next.js when a single HTML file is what was asked for.',
36
39
  parameters: {
37
40
  type: 'object',
38
41
  properties: {
39
42
  folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
40
43
  name: str('The display name of the app, e.g. "Stride".'),
41
44
  description: str('One line about the app, used in the page metadata.'),
45
+ template: {
46
+ type: 'string',
47
+ enum: ['next-shadcn', 'plain-html'],
48
+ description:
49
+ 'Which starter. "plain-html" for plain HTML/CSS/JS, a single page, a toy or a game: ' +
50
+ 'no install, no build. "next-shadcn" (the default) for routes, data or many screens.',
51
+ },
42
52
  design: {
43
53
  type: 'string',
44
54
  enum: ['ocean', 'grove', 'sunset', 'graphite', 'violet', 'citrus'],
@@ -1,209 +1,221 @@
1
- /**
2
- * scaffold.js — starting an app from a starter that is known to work.
3
- *
4
- * Setting up a Next.js + shadcn project from nothing is four minutes of
5
- * create-next-app and shadcn CLI runs — measured at 116s and 130s — plus a
6
- * dozen model round trips to drive them and then theme the result. Every app
7
- * starts from the same place anyway, so ucode ships that place: a project
8
- * that has already been built and type-checked, copied in one step, with its
9
- * install starting in the background while the model writes the first
10
- * component.
11
- */
12
-
13
- import { promises as fs } from 'node:fs';
14
- import path from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
- import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, result } from './shared.js';
18
- import { packageJsonWritten, installIn } from './shell.js';
19
- import { restore, populate } from './cache.js';
20
-
21
- const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
22
-
23
- /** npm silently drops these two names from published packages, so they ship renamed. */
24
- const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
25
-
26
- /** Files the placeholders are filled into. Everything else is copied byte for byte. */
27
- const TEXT = /\.(?:json|md|mjs|css|tsx?)$/i;
28
-
29
- export const TEMPLATE_NAMES = ['next-shadcn'];
30
-
31
- function slug(name) {
32
- return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
33
- }
34
-
35
- /** Text that is safe inside a JS string and a JSON string. */
36
- const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
37
-
38
- async function copyTree(from, to, fill) {
39
- await fs.mkdir(to, { recursive: true });
40
- const copied = [];
41
- for (const entry of await fs.readdir(from, { withFileTypes: true })) {
42
- const name = RENAME[entry.name] ?? entry.name;
43
- const src = path.join(from, entry.name);
44
- const dest = path.join(to, name);
45
- if (entry.isDirectory()) {
46
- copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
47
- } else if (TEXT.test(entry.name) || entry.name in RENAME) {
48
- let text = await fs.readFile(src, 'utf8');
49
- for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
50
- await fs.writeFile(dest, text, 'utf8');
51
- copied.push(name);
52
- } else {
53
- await fs.copyFile(src, dest);
54
- copied.push(name);
55
- }
56
- }
57
- return copied;
58
- }
59
-
60
- /**
61
- * @param {object} o
62
- * @param {string} o.folder new, empty folder for the app
63
- * @param {string} o.name display name, e.g. "Stride"
64
- * @param {string} [o.description]
65
- * @param {string} [o.template]
66
- * @param {boolean} [o.install] start the background install (tests turn it off)
67
- */
68
- /**
69
- * Give the new app its look: one of the hand-picked presets in the starter's
70
- * presets/ folder — a full light and dark palette and a font — written into
71
- * globals.css and layout.tsx. Apps stop looking like the same default blue.
72
- * Returns the preset used, or null when the starter has none.
73
- */
74
- export async function applyDesign(appDir, design) {
75
- const dir = path.join(appDir, 'presets');
76
- const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
77
- if (!names.length) return null;
78
- const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
79
- await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
80
- const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
81
-
82
- const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
83
- let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
84
- if (css !== null) {
85
- const retint = (selector, tokens) => {
86
- const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
87
- css = css.replace(block, (all, open, body, close) => {
88
- const seen = new Set();
89
- let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
90
- if (!(key in tokens)) return line;
91
- seen.add(key);
92
- return `${lead}--${key}: ${tokens[key]};`;
93
- });
94
- for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
95
- return open + next + close;
96
- });
97
- };
98
- retint(':root', { radius: preset.radius, ...preset.light });
99
- retint('\\.dark', preset.dark);
100
- await fs.writeFile(cssFile, css);
101
- }
102
-
103
- const sans = preset.fonts?.sans;
104
- const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
105
- if (sans && sans !== 'Geist') {
106
- const id = sans.replace(/\s+/g, '_');
107
- const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
108
- if (layout !== null) {
109
- await fs.writeFile(layoutFile, layout
110
- .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
111
- .replace('const sans = Geist({', `const sans = ${id}({`));
112
- }
113
- }
114
-
115
- const guide = path.join(appDir, 'TEMPLATE.md');
116
- const text = await fs.readFile(guide, 'utf8').catch(() => null);
117
- if (text !== null) {
118
- await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
119
- `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
120
- 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
121
- 'so every screen stays in one look.\n');
122
- }
123
- return preset;
124
- }
125
-
126
- export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
127
- if (!TEMPLATE_NAMES.includes(template)) {
128
- throw new ToolFailure({
129
- kind: 'bad_args',
130
- attempted: 'creating an app',
131
- failed: `There is no starter called "${template}".`,
132
- fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
133
- });
134
- }
135
-
136
- const target = resolveIn(folder, 'create_app', 'folder');
137
- const attempted = `creating an app in ${target.show}`;
138
- if (target.show === '.') {
139
- throw new ToolFailure({
140
- kind: 'bad_args',
141
- attempted,
142
- failed: 'The app needs its own folder, not the project root.',
143
- fix: 'Pass a new folder name, e.g. "stride".',
144
- });
145
- }
146
- await guard(target, `create an app in ${target.abs}`);
147
-
148
- let existing = [];
149
- try {
150
- existing = await fs.readdir(target.abs);
151
- } catch {
152
- existing = [];
153
- }
154
- if (existing.length) {
155
- throw new ToolFailure({
156
- kind: 'not_empty',
157
- attempted,
158
- failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
159
- fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
160
- });
161
- }
162
-
163
- const display = plain(name) || path.basename(target.abs);
164
- const fill = {
165
- __APP_NAME__: display,
166
- __APP_SLUG__: slug(display),
167
- __APP_DESCRIPTION__: plain(description) || display,
168
- };
169
-
170
- const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
171
- await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
172
- const look = await applyDesign(target.abs, design);
173
-
174
- // The starter has been installed on this machine before: hard-link that
175
- // tree in, which is seconds where npm is a minute. Otherwise install as
176
- // usual, and keep the result so the next app is instant.
177
- let linked = 0;
178
- if (install) {
179
- // Keyed on the starter's lockfile, which is the same for every app made
180
- // from it the app's own is rewritten by npm as it installs.
181
- const lockText = await fs
182
- .readFile(path.join(TEMPLATES, template, '_package-lock.json'), 'utf8')
183
- .catch(() => null);
184
- linked = await restore(target.abs, template, lockText);
185
- if (!linked) {
186
- const pkg = path.join(target.abs, 'package.json');
187
- packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
188
- installIn(target.abs)?.then((done) => {
189
- if (done?.code === 0) populate(target.abs, template, lockText);
190
- });
191
- }
192
- }
193
-
194
- const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
195
-
196
- return result(
197
- `Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
198
- (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
199
- (linked
200
- ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
201
- 'nothing to install: build and run straight away.\n'
202
- : install
203
- ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
204
- `${target.show} waits for that install first, so there is no need to run npm install.\n`
205
- : '') +
206
- `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`,
207
- `${files.length} files${linked ? ' · packages ready' : install ? ' · installing in the background' : ''}`
208
- );
209
- }
1
+ /**
2
+ * scaffold.js — starting an app from a starter that is known to work.
3
+ *
4
+ * Setting up a Next.js + shadcn project from nothing is four minutes of
5
+ * create-next-app and shadcn CLI runs — measured at 116s and 130s — plus a
6
+ * dozen model round trips to drive them and then theme the result. Every app
7
+ * starts from the same place anyway, so ucode ships that place: a project
8
+ * that has already been built and type-checked, copied in one step, with its
9
+ * install starting in the background while the model writes the first
10
+ * component.
11
+ */
12
+
13
+ import { promises as fs } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { ToolFailure } from '../core/failure.js';
17
+ import { resolveIn, guard, result } from './shared.js';
18
+ import { packageJsonWritten, installIn } from './shell.js';
19
+ import { restore, populate } from './cache.js';
20
+
21
+ const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
22
+
23
+ /** npm silently drops these two names from published packages, so they ship renamed. */
24
+ const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
25
+
26
+ /** Files the placeholders are filled into. Everything else is copied byte for byte. */
27
+ const TEXT = /\.(?:json|md|mjs|css|html|jsx?|tsx?)$/i;
28
+
29
+ export const TEMPLATE_NAMES = ['next-shadcn', 'plain-html'];
30
+
31
+ /** What each starter is for, so the choice is made on purpose. */
32
+ export const TEMPLATE_NOTES = {
33
+ 'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
34
+ 'plain-html': 'One index.html, one stylesheet, one module. No install, no build, opens straight in a browser.',
35
+ };
36
+
37
+ function slug(name) {
38
+ return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
39
+ }
40
+
41
+ /** Text that is safe inside a JS string and a JSON string. */
42
+ const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
43
+
44
+ async function copyTree(from, to, fill) {
45
+ await fs.mkdir(to, { recursive: true });
46
+ const copied = [];
47
+ for (const entry of await fs.readdir(from, { withFileTypes: true })) {
48
+ const name = RENAME[entry.name] ?? entry.name;
49
+ const src = path.join(from, entry.name);
50
+ const dest = path.join(to, name);
51
+ if (entry.isDirectory()) {
52
+ copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
53
+ } else if (TEXT.test(entry.name) || entry.name in RENAME) {
54
+ let text = await fs.readFile(src, 'utf8');
55
+ for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
56
+ await fs.writeFile(dest, text, 'utf8');
57
+ copied.push(name);
58
+ } else {
59
+ await fs.copyFile(src, dest);
60
+ copied.push(name);
61
+ }
62
+ }
63
+ return copied;
64
+ }
65
+
66
+ /**
67
+ * @param {object} o
68
+ * @param {string} o.folder new, empty folder for the app
69
+ * @param {string} o.name display name, e.g. "Stride"
70
+ * @param {string} [o.description]
71
+ * @param {string} [o.template]
72
+ * @param {boolean} [o.install] start the background install (tests turn it off)
73
+ */
74
+ /**
75
+ * Give the new app its look: one of the hand-picked presets in the starter's
76
+ * presets/ folder a full light and dark palette and a font — written into
77
+ * globals.css and layout.tsx. Apps stop looking like the same default blue.
78
+ * Returns the preset used, or null when the starter has none.
79
+ */
80
+ export async function applyDesign(appDir, design) {
81
+ const dir = path.join(appDir, 'presets');
82
+ const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
83
+ if (!names.length) return null;
84
+ const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
85
+ await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
86
+ const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
87
+
88
+ const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
89
+ let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
90
+ if (css !== null) {
91
+ const retint = (selector, tokens) => {
92
+ const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
93
+ css = css.replace(block, (all, open, body, close) => {
94
+ const seen = new Set();
95
+ let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
96
+ if (!(key in tokens)) return line;
97
+ seen.add(key);
98
+ return `${lead}--${key}: ${tokens[key]};`;
99
+ });
100
+ for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
101
+ return open + next + close;
102
+ });
103
+ };
104
+ retint(':root', { radius: preset.radius, ...preset.light });
105
+ retint('\\.dark', preset.dark);
106
+ await fs.writeFile(cssFile, css);
107
+ }
108
+
109
+ const sans = preset.fonts?.sans;
110
+ const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
111
+ if (sans && sans !== 'Geist') {
112
+ const id = sans.replace(/\s+/g, '_');
113
+ const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
114
+ if (layout !== null) {
115
+ await fs.writeFile(layoutFile, layout
116
+ .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
117
+ .replace('const sans = Geist({', `const sans = ${id}({`));
118
+ }
119
+ }
120
+
121
+ const guide = path.join(appDir, 'TEMPLATE.md');
122
+ const text = await fs.readFile(guide, 'utf8').catch(() => null);
123
+ if (text !== null) {
124
+ await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
125
+ `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
126
+ 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
127
+ 'so every screen stays in one look.\n');
128
+ }
129
+ return preset;
130
+ }
131
+
132
+ export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
133
+ if (!TEMPLATE_NAMES.includes(template)) {
134
+ throw new ToolFailure({
135
+ kind: 'bad_args',
136
+ attempted: 'creating an app',
137
+ failed: `There is no starter called "${template}".`,
138
+ fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
139
+ });
140
+ }
141
+
142
+ const target = resolveIn(folder, 'create_app', 'folder');
143
+ const attempted = `creating an app in ${target.show}`;
144
+ if (target.show === '.') {
145
+ throw new ToolFailure({
146
+ kind: 'bad_args',
147
+ attempted,
148
+ failed: 'The app needs its own folder, not the project root.',
149
+ fix: 'Pass a new folder name, e.g. "stride".',
150
+ });
151
+ }
152
+ await guard(target, `create an app in ${target.abs}`);
153
+
154
+ let existing = [];
155
+ try {
156
+ existing = await fs.readdir(target.abs);
157
+ } catch {
158
+ existing = [];
159
+ }
160
+ if (existing.length) {
161
+ throw new ToolFailure({
162
+ kind: 'not_empty',
163
+ attempted,
164
+ failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
165
+ fix: 'Pick a new folder name. If this folder is the app from an earlier attempt, work in it instead of creating it again.',
166
+ });
167
+ }
168
+
169
+ const display = plain(name) || path.basename(target.abs);
170
+ const fill = {
171
+ __APP_NAME__: display,
172
+ __APP_SLUG__: slug(display),
173
+ __APP_DESCRIPTION__: plain(description) || display,
174
+ };
175
+
176
+ const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
177
+ // Next.js serves static files from public/; a plain page has no such place
178
+ // and an empty folder in a three-file app is clutter.
179
+ if (template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
180
+ const look = await applyDesign(target.abs, design);
181
+
182
+ // The starter has been installed on this machine before: hard-link that
183
+ // tree in, which is seconds where npm is a minute. Otherwise install as
184
+ // usual, and keep the result so the next app is instant.
185
+ let linked = 0;
186
+ const needsInstall = template !== 'plain-html';
187
+ if (install && needsInstall) {
188
+ // Keyed on the starter's lockfile, which is the same for every app made
189
+ // from it the app's own is rewritten by npm as it installs.
190
+ const lockText = await fs
191
+ .readFile(path.join(TEMPLATES, template, '_package-lock.json'), 'utf8')
192
+ .catch(() => null);
193
+ linked = await restore(target.abs, template, lockText);
194
+ if (!linked) {
195
+ const pkg = path.join(target.abs, 'package.json');
196
+ packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
197
+ installIn(target.abs)?.then((done) => {
198
+ if (done?.code === 0) populate(target.abs, template, lockText);
199
+ });
200
+ }
201
+ }
202
+
203
+ const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
204
+
205
+ return result(
206
+ `Created ${target.show} from the ${template} starter ${files.length} files, already known to build.\n` +
207
+ (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
208
+ (linked
209
+ ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
210
+ 'nothing to install: build and run straight away.\n'
211
+ : install && needsInstall
212
+ ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
213
+ `${target.show} waits for that install first, so there is no need to run npm install.\n`
214
+ : '') +
215
+ (needsInstall
216
+ ? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
217
+ : `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
218
+ `folder with "python -m http.server 8000" if it fetches anything.\n\n${guide}`),
219
+ `${files.length} files${linked ? ' · packages ready' : install && needsInstall ? ' · installing in the background' : ''}`
220
+ );
221
+ }
package/src/ui/screen.js CHANGED
@@ -39,7 +39,7 @@ import chalk from 'chalk';
39
39
  import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
- shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel } from './theme.js';
42
+ shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine } from './theme.js';
43
43
  import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
44
  import { renderer, render, polish } from './markdown.js';
45
45
  import { VERSION } from '../core/version.js';
@@ -84,6 +84,8 @@ const TRACK = process.platform === 'win32'
84
84
  const UNTRACK = process.platform === 'win32'
85
85
  ? '' : `${ESC}[?1006l${ESC}[?1015l${ESC}[?1002l${ESC}[?1000l`;
86
86
 
87
+ const PASTE_ON = `${ESC}[?2004h`;
88
+ const PASTE_OFF = `${ESC}[?2004l`;
87
89
  const MOUSE_ON = `${ESC}[?1007h${TRACK}`;
88
90
  const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
89
91
  const HIDE = `${ESC}[?25l`;
@@ -149,7 +151,7 @@ export class Screen {
149
151
 
150
152
  async start() {
151
153
  ensureColour(this.output);
152
- this.output.write(ALT_ON + MOUSE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
154
+ this.output.write(ALT_ON + MOUSE_ON + PASTE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
153
155
  this.input.setRawMode?.(true);
154
156
  this.input.resume();
155
157
  this.input.setEncoding('utf8');
@@ -173,7 +175,7 @@ export class Screen {
173
175
  this.output.off?.('resize', this.onResize);
174
176
  this.input.setRawMode?.(false);
175
177
  this.input.pause();
176
- this.output.write(MOUSE_OFF + ALT_OFF + SHOW);
178
+ this.output.write(PASTE_OFF + MOUSE_OFF + ALT_OFF + SHOW);
177
179
  }
178
180
 
179
181
  close() {
@@ -305,11 +307,14 @@ export class Screen {
305
307
  if (gap === 1) this.lines.pop(); // its single result line, now counted
306
308
  run.count++;
307
309
  run.label = label;
308
- this.lines[run.at] = `${narrationMark()} ${narration(asLabel(groupLabel(label, run.count)))}`;
309
- this.render();
310
+ run.targets.push(groupTarget(label));
311
+ this.paintRun();
310
312
  } else {
311
313
  this.push(`${narrationMark()} ${narration(asLabel(label))}`);
312
- this.run = { kind, count: 1, at: this.lines.length - 1, label };
314
+ this.run = {
315
+ kind, count: 1, at: this.lines.length - 1, label,
316
+ targets: [groupTarget(label)], added: 0, removed: 0,
317
+ };
313
318
  }
314
319
  this.updateSpinner(label);
315
320
  }
@@ -317,18 +322,42 @@ export class Screen {
317
322
  /** Anything that is not another step of the same kind ends the run. */
318
323
  endRun() { this.run = null; }
319
324
 
325
+ /** Redraw the run's single line from what it has accumulated. */
326
+ paintRun() {
327
+ if (!this.run) return;
328
+ this.lines[this.run.at] = `${narrationMark()} ${narration(asLabel(runLine(this.run)))}`;
329
+ this.render();
330
+ }
331
+
332
+ /**
333
+ * A change, as its two numbers.
334
+ *
335
+ * The diff itself used to go into the transcript. A 539-line file printed
336
+ * there buries the answer under a copy of something already on disk, so
337
+ * what is kept is the shape of the change: how much arrived, how much left.
338
+ */
339
+ diffStat({ added = 0, removed = 0 } = {}) {
340
+ if (!this.run) return;
341
+ this.run.added += added;
342
+ this.run.removed += removed;
343
+ this.paintRun();
344
+ }
345
+
320
346
  /** The checklist, when the model updates it. One line, wrapped if it must. */
321
347
  plan(items) {
322
348
  const line = planLine(items);
323
349
  if (line) this.push(line);
324
350
  }
325
351
 
326
- toolResult(summary) {
327
- // Inside a run, the count on the line above already says what happened;
328
- // a result line per step is the noise this is meant to remove.
329
- if (this.run && this.run.count > 1) return;
330
- this.push(dim(` └ ${summary}`));
331
- }
352
+ /**
353
+ * What came of a step.
354
+ *
355
+ * Nothing goes underneath the bullet any more: a line of its own for every
356
+ * result doubles the height of the transcript to say "ok". The bullet
357
+ * already names the step, and a change adds its numbers to that same line.
358
+ * Only a failure earns a line of its own.
359
+ */
360
+ toolResult() {}
332
361
 
333
362
  toolFailed(summary) {
334
363
  // A failure is never folded away.
@@ -953,7 +982,55 @@ export class Screen {
953
982
  this.render();
954
983
  }
955
984
 
985
+ /**
986
+ * Text arriving as a paste rather than as typing.
987
+ *
988
+ * A terminal in bracketed-paste mode wraps pasted text in markers, which is
989
+ * the only way to tell forty lines pasted at once from forty lines typed
990
+ * very fast. Without it every newline in the paste reads as Enter, so a
991
+ * pasted block submits itself a line at a time and arrives as forty
992
+ * messages. Inside the markers a newline is just a character.
993
+ */
994
+ onPaste(text) {
995
+ const clean = String(text).replace(/\r\n?/g, '\n');
996
+ this.buffer = this.buffer.slice(0, this.cursor) + clean + this.buffer.slice(this.cursor);
997
+ this.cursor += clean.length;
998
+ this.render();
999
+ }
1000
+
956
1001
  onData(chunk) {
1002
+ // Pasted text first: it is wrapped in markers and must not be read as
1003
+ // keys, or its newlines submit it in pieces.
1004
+ const paste = /\[200~([\s\S]*?)\[201~/g;
1005
+ if (paste.test(chunk)) {
1006
+ paste.lastIndex = 0;
1007
+ let at = 0;
1008
+ let m;
1009
+ while ((m = paste.exec(chunk))) {
1010
+ if (m.index > at) this.onData(chunk.slice(at, m.index));
1011
+ this.onPaste(m[1]);
1012
+ at = m.index + m[0].length;
1013
+ }
1014
+ if (at < chunk.length) this.onData(chunk.slice(at));
1015
+ return;
1016
+ }
1017
+ // An unterminated paste: hold what has arrived and wait for the rest.
1018
+ const open = chunk.indexOf('[200~');
1019
+ if (open !== -1) {
1020
+ if (open > 0) this.onData(chunk.slice(0, open));
1021
+ this.pasting = chunk.slice(open + 6);
1022
+ return;
1023
+ }
1024
+ if (this.pasting !== undefined && this.pasting !== null) {
1025
+ const close = chunk.indexOf('[201~');
1026
+ if (close === -1) { this.pasting += chunk; return; }
1027
+ this.onPaste(this.pasting + chunk.slice(0, close));
1028
+ this.pasting = null;
1029
+ const after = chunk.slice(close + 6);
1030
+ if (after) this.onData(after);
1031
+ return;
1032
+ }
1033
+
957
1034
  // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
958
1035
  // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
959
1036
  // terminal forwarding it; this is how to find out.
@@ -987,6 +1064,13 @@ export class Screen {
987
1064
  }
988
1065
  rest += chunk.slice(index);
989
1066
 
1067
+ // A chunk carrying a line break *and* other text did not come from a
1068
+ // keyboard: nobody types a newline in the middle of a burst. Many
1069
+ // terminals, Windows ones especially, send a paste with no markers at
1070
+ // all, so without this every newline in it reads as Enter and the paste
1071
+ // submits itself a line at a time.
1072
+ if (looksPasted(rest)) { this.onPaste(rest); return; }
1073
+
990
1074
  for (const key of splitKeys(rest)) this.onKey(key);
991
1075
  }
992
1076
 
@@ -1256,6 +1340,27 @@ export class Screen {
1256
1340
  * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1257
1341
  * so the key handler only ever sees one of them.
1258
1342
  */
1343
+ /**
1344
+ * Did this arrive as a paste, judged by shape rather than by markers?
1345
+ *
1346
+ * Someone pressing Enter sends one carriage return on its own. A paste sends
1347
+ * a line break with text around it, in a single read. That difference is all
1348
+ * there is to go on when a terminal does not implement bracketed paste, and
1349
+ * it is enough.
1350
+ *
1351
+ * Anything carrying an escape sequence is left alone: that is a key or a
1352
+ * mouse report, and reading one as text would put gibberish in the input.
1353
+ */
1354
+ export function looksPasted(chunk) {
1355
+ const text = String(chunk ?? '');
1356
+ if (text.length < 2 || text.includes(ESC)) return false;
1357
+ const breaks = (text.match(/[\r\n]/g) ?? []).length;
1358
+ if (breaks === 0) return false;
1359
+ // One trailing break is someone finishing a line, not pasting one.
1360
+ if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1361
+ return true;
1362
+ }
1363
+
1259
1364
  export function splitKeys(chunk) {
1260
1365
  const keys = [];
1261
1366
  let i = 0;
package/src/ui/theme.js CHANGED
@@ -291,40 +291,65 @@ export const narration = (text) => chalk.dim(text);
291
291
 
292
292
  /** The bullet beside a narration line: present, not loud. */
293
293
  export const narrationMark = () => chalk.dim(deep('●'));
294
-
295
- /**
296
- * How a run of the same kind of step reads once it is over.
297
- *
298
- * While it happens, "Running npm test" is the useful thing to show. Once
299
- * three of them have happened, three near-identical lines are just noise
300
- * between the reader and the answer, so they fold into one: "Ran 3 commands".
301
- * The present tense belongs to the thing happening now; the past tense to the
302
- * summary of what did.
303
- */
304
- const GROUPS = {
305
- Running: ['Ran', 'command', 'commands'],
306
- Reading: ['Read', 'file', 'files'],
307
- Searching: ['Searched', 'time', 'times'],
308
- Finding: ['Found', 'pattern', 'patterns'],
309
- Listing: ['Listed', 'directory', 'directories'],
310
- Writing: ['Wrote', 'file', 'files'],
311
- Editing: ['Edited', 'file', 'files'],
312
- Checking: ['Checked', 'thing', 'things'],
313
- Looking: ['Looked up', 'name', 'names'],
314
- Asking: ['Asked about', 'name', 'names'],
315
- Mapping: ['Mapped', 'folder', 'folders'],
316
- Adding: ['Added', 'block', 'blocks'],
317
- Renaming: ['Renamed', 'name', 'names'],
318
- };
319
-
320
- /** The first word of a label, which is what decides whether two steps match. */
321
- export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
322
-
323
- /** One line standing in for `count` steps that all began with the same word. */
324
- export function groupLabel(label, count) {
325
- if (count <= 1) return String(label ?? '');
326
- const g = GROUPS[groupKind(label)];
327
- if (!g) return `${label} (+${count - 1} more)`;
328
- const [past, one, many] = g;
329
- return `${past} ${count} ${count === 1 ? one : many}`;
330
- }
294
+
295
+ /**
296
+ * How a run of the same kind of step reads once it is over.
297
+ *
298
+ * While it happens, "Running npm test" is the useful thing to show. Once
299
+ * three of them have happened, three near-identical lines are just noise
300
+ * between the reader and the answer, so they fold into one: "Ran 3 commands".
301
+ * The present tense belongs to the thing happening now; the past tense to the
302
+ * summary of what did.
303
+ */
304
+ const GROUPS = {
305
+ Running: ['Ran', 'command', 'commands'],
306
+ Reading: ['Read', 'file', 'files'],
307
+ Searching: ['Searched', 'time', 'times'],
308
+ Finding: ['Found', 'pattern', 'patterns'],
309
+ Listing: ['Listed', 'directory', 'directories'],
310
+ Writing: ['Wrote', 'file', 'files'],
311
+ Editing: ['Edited', 'file', 'files'],
312
+ Checking: ['Checked', 'thing', 'things'],
313
+ Looking: ['Looked up', 'name', 'names'],
314
+ Asking: ['Asked about', 'name', 'names'],
315
+ Mapping: ['Mapped', 'folder', 'folders'],
316
+ Adding: ['Added', 'block', 'blocks'],
317
+ Renaming: ['Renamed', 'name', 'names'],
318
+ };
319
+
320
+ /** The first word of a label, which is what decides whether two steps match. */
321
+ export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
322
+
323
+ /** One line standing in for `count` steps that all began with the same word. */
324
+ export function groupLabel(label, count) {
325
+ if (count <= 1) return String(label ?? '');
326
+ const g = GROUPS[groupKind(label)];
327
+ if (!g) return `${label} (+${count - 1} more)`;
328
+ const [past, one, many] = g;
329
+ return `${past} ${count} ${count === 1 ? one : many}`;
330
+ }
331
+
332
+ /** The part of a label after its opening word: the file or command it is about. */
333
+ export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
334
+
335
+ /**
336
+ * One narration line, standing for everything that happened under it.
337
+ *
338
+ * The transcript is a record of what was done, not a copy of what was
339
+ * written. A 539-line file printed into it buries the answer and tells the
340
+ * reader nothing they could not get from the file itself, so a change is its
341
+ * two numbers. Several steps on one file stay one line naming that file;
342
+ * several files become a count.
343
+ */
344
+ export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
345
+ const counts = added || removed
346
+ ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
347
+ : '';
348
+ if (count <= 1) return `${label}${counts}`;
349
+
350
+ const g = GROUPS[groupKind(label)];
351
+ const unique = [...new Set(targets.filter(Boolean))];
352
+ if (g && unique.length === 1) return `${g[0]} ${unique[0]}${counts}`;
353
+ if (!g) return `${label} (+${count - 1} more)${counts}`;
354
+ return `${g[0]} ${count} ${count === 1 ? g[1] : g[2]}${counts}`;
355
+ }
@@ -0,0 +1,25 @@
1
+ # __APP_NAME__
2
+
3
+ A plain HTML, CSS and JavaScript app. No framework, no build step, no install:
4
+ `index.html` is the whole program's front door.
5
+
6
+ ## The files
7
+
8
+ - `index.html` — the markup, and the only place scripts and styles are linked
9
+ - `styles.css` — the design tokens at the top, then the components
10
+ - `app.js` — an ES module; `state` holds everything, `render()` draws it
11
+
12
+ ## Working in it
13
+
14
+ Open `index.html` in a browser, or serve the folder if the app fetches
15
+ anything: `python -m http.server 8000`. There is nothing to install and
16
+ nothing to compile, so a change is visible on refresh.
17
+
18
+ ## Conventions worth keeping
19
+
20
+ - **One state object, one render.** Patching the DOM from several places is
21
+ where these apps become impossible to reason about.
22
+ - **Tokens in `:root`.** Colour, spacing and timing are defined once, so the
23
+ look can be changed without touching components.
24
+ - **Reduced motion is honoured** at the bottom of the stylesheet. Leave it in.
25
+ - Keep it to these three files until there is a real reason not to.
@@ -0,0 +1,2 @@
1
+ .DS_Store
2
+ node_modules/
@@ -0,0 +1,43 @@
1
+ /**
2
+ * __APP_NAME__
3
+ *
4
+ * Plain modules, no build step: this file is what the browser runs. Keep the
5
+ * state in one place and render from it, so there is one answer to "what is
6
+ * on screen" rather than a DOM that has been patched in six directions.
7
+ */
8
+
9
+ const app = document.querySelector('#app');
10
+
11
+ /** Everything the page knows. Change this, then call render(). */
12
+ const state = {
13
+ items: load(),
14
+ };
15
+
16
+ function load() {
17
+ try {
18
+ return JSON.parse(localStorage.getItem('__APP_SLUG__') ?? '[]');
19
+ } catch {
20
+ return []; // a corrupt store is an empty one, not a broken page
21
+ }
22
+ }
23
+
24
+ function save() {
25
+ try {
26
+ localStorage.setItem('__APP_SLUG__', JSON.stringify(state.items));
27
+ } catch {
28
+ // Private mode, or the quota is full. Losing the save is survivable;
29
+ // throwing here would take the whole page down with it.
30
+ }
31
+ }
32
+
33
+ function render() {
34
+ app.innerHTML = '';
35
+ app.append(
36
+ Object.assign(document.createElement('div'), {
37
+ className: 'card',
38
+ textContent: 'Edit app.js to build __APP_NAME__.',
39
+ })
40
+ );
41
+ }
42
+
43
+ render();
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>__APP_NAME__</title>
7
+ <meta name="description" content="__APP_DESCRIPTION__" />
8
+ <link rel="stylesheet" href="styles.css" />
9
+ </head>
10
+ <body>
11
+ <main class="shell">
12
+ <header class="head">
13
+ <h1>__APP_NAME__</h1>
14
+ <p class="sub">__APP_DESCRIPTION__</p>
15
+ </header>
16
+
17
+ <section id="app"></section>
18
+ </main>
19
+
20
+ <script type="module" src="app.js"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,100 @@
1
+ :root {
2
+ /* One accent, a handful of greys, and a scale. Everything else is composed
3
+ from these, so the whole look changes by editing this block. */
4
+ --bg: #0b0d10;
5
+ --surface: #14171c;
6
+ --line: #232830;
7
+ --text: #e7eaf0;
8
+ --muted: #8b93a3;
9
+ --accent: #2dd4bf;
10
+
11
+ --r: 16px;
12
+ --s1: 8px;
13
+ --s2: 12px;
14
+ --s3: 16px;
15
+ --s4: 24px;
16
+ --s5: 40px;
17
+
18
+ --fast: 160ms cubic-bezier(0.2, 0, 0.2, 1);
19
+ --slow: 260ms cubic-bezier(0.2, 0, 0.2, 1);
20
+ }
21
+
22
+ * { box-sizing: border-box; }
23
+
24
+ html, body { height: 100%; }
25
+
26
+ body {
27
+ margin: 0;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
31
+ -webkit-font-smoothing: antialiased;
32
+ }
33
+
34
+ .shell {
35
+ max-width: 720px;
36
+ margin: 0 auto;
37
+ padding: var(--s5) var(--s4) var(--s5);
38
+ }
39
+
40
+ .head { margin-bottom: var(--s4); }
41
+
42
+ h1 {
43
+ margin: 0;
44
+ font-size: 28px;
45
+ font-weight: 600;
46
+ letter-spacing: -0.02em;
47
+ }
48
+
49
+ .sub {
50
+ margin: var(--s1) 0 0;
51
+ color: var(--muted);
52
+ }
53
+
54
+ .card {
55
+ background: var(--surface);
56
+ border: 1px solid var(--line);
57
+ border-radius: var(--r);
58
+ padding: var(--s3);
59
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.2);
60
+ }
61
+
62
+ button {
63
+ font: inherit;
64
+ color: var(--bg);
65
+ background: var(--accent);
66
+ border: 0;
67
+ border-radius: 10px;
68
+ padding: 10px var(--s3);
69
+ cursor: pointer;
70
+ transition: transform var(--fast), filter var(--fast);
71
+ }
72
+
73
+ button:hover { filter: brightness(1.08); }
74
+ button:active { transform: translateY(1px); }
75
+
76
+ input, textarea {
77
+ font: inherit;
78
+ color: var(--text);
79
+ background: transparent;
80
+ border: 1px solid var(--line);
81
+ border-radius: 10px;
82
+ padding: 10px var(--s2);
83
+ width: 100%;
84
+ transition: border-color var(--fast);
85
+ }
86
+
87
+ input:focus, textarea:focus {
88
+ outline: none;
89
+ border-color: var(--accent);
90
+ }
91
+
92
+ /* Anyone who has asked their system not to animate things means it. */
93
+ @media (prefers-reduced-motion: reduce) {
94
+ * { animation: none !important; transition: none !important; }
95
+ }
96
+
97
+ @media (max-width: 480px) {
98
+ .shell { padding: var(--s4) var(--s3); }
99
+ h1 { font-size: 24px; }
100
+ }