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 +1 -1
- package/src/core/loop.js +42 -4
- package/src/tools/index.js +17 -7
- package/src/tools/scaffold.js +221 -209
- package/src/ui/screen.js +117 -12
- package/src/ui/theme.js +62 -37
- package/templates/plain-html/TEMPLATE.md +25 -0
- package/templates/plain-html/_gitignore +2 -0
- package/templates/plain-html/app.js +43 -0
- package/templates/plain-html/index.html +22 -0
- package/templates/plain-html/styles.css +100 -0
package/package.json
CHANGED
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
|
|
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
|
-
|
|
1324
|
-
|
|
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
|
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
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'],
|
package/src/tools/scaffold.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*/
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
309
|
-
this.
|
|
310
|
+
run.targets.push(groupTarget(label));
|
|
311
|
+
this.paintRun();
|
|
310
312
|
} else {
|
|
311
313
|
this.push(`${narrationMark()} ${narration(asLabel(label))}`);
|
|
312
|
-
this.run = {
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,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
|
+
}
|