@chalky/chalky 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/app/SYSTEM.md +31 -0
  4. package/app/extensions/appdata/README.md +10 -0
  5. package/app/extensions/appdata/guide.md +4 -0
  6. package/app/extensions/appdata/index.ts +47 -0
  7. package/app/extensions/boards/browser.js +357 -0
  8. package/app/extensions/boards/guide.md +4 -0
  9. package/app/extensions/boards/index.ts +98 -0
  10. package/app/extensions/commands/browser.js +13 -0
  11. package/app/extensions/commands/guide.md +3 -0
  12. package/app/extensions/commands/index.ts +96 -0
  13. package/app/extensions/doodle/README.md +15 -0
  14. package/app/extensions/doodle/browser.js +398 -0
  15. package/app/extensions/doodle/guide.md +6 -0
  16. package/app/extensions/doodle/index.ts +54 -0
  17. package/app/extensions/files/browser.js +96 -0
  18. package/app/extensions/files/guide.md +7 -0
  19. package/app/extensions/files/index.ts +73 -0
  20. package/app/extensions/frames/README.md +37 -0
  21. package/app/extensions/frames/browser.js +166 -0
  22. package/app/extensions/frames/guide.md +7 -0
  23. package/app/extensions/frames/index.ts +85 -0
  24. package/app/extensions/login/browser.js +363 -0
  25. package/app/extensions/login/index.ts +214 -0
  26. package/app/extensions/mcp/browser.js +64 -0
  27. package/app/extensions/mcp/guide.md +5 -0
  28. package/app/extensions/mcp/index.ts +64 -0
  29. package/app/extensions/model/index.ts +20 -0
  30. package/app/extensions/page/index.ts +96 -0
  31. package/app/extensions/page/page.html +615 -0
  32. package/app/extensions/point/README.md +22 -0
  33. package/app/extensions/point/browser.js +247 -0
  34. package/app/extensions/point/guide.md +6 -0
  35. package/app/extensions/point/index.ts +112 -0
  36. package/app/extensions/prompt/index.ts +19 -0
  37. package/app/extensions/reload/index.ts +21 -0
  38. package/app/extensions/screen/browser.js +76 -0
  39. package/app/extensions/screen/index.ts +80 -0
  40. package/app/extensions/steps/index.ts +17 -0
  41. package/app/extensions/swarm/README.md +47 -0
  42. package/app/extensions/swarm/guide.md +11 -0
  43. package/app/extensions/swarm/index.ts +304 -0
  44. package/app/extensions/ui/README.md +69 -0
  45. package/app/extensions/ui/agents.js +45 -0
  46. package/app/extensions/ui/browser.js +364 -0
  47. package/app/extensions/ui/components/App.js +28 -0
  48. package/app/extensions/ui/components/Button.js +28 -0
  49. package/app/extensions/ui/components/Card.js +23 -0
  50. package/app/extensions/ui/components/Column.js +17 -0
  51. package/app/extensions/ui/components/Markdown.js +37 -0
  52. package/app/extensions/ui/components/Row.js +17 -0
  53. package/app/extensions/ui/components/Table.js +31 -0
  54. package/app/extensions/ui/components/Text.js +19 -0
  55. package/app/extensions/ui/components/TextField.js +30 -0
  56. package/app/extensions/ui/guide.md +7 -0
  57. package/app/extensions/ui/index.ts +291 -0
  58. package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
  59. package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
  60. package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
  61. package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
  62. package/app/extensions/ui/voice.js +114 -0
  63. package/app/extensions/ui_history/browser.js +97 -0
  64. package/app/extensions/ui_history/index.ts +109 -0
  65. package/app/extensions/versions/browser.js +58 -0
  66. package/app/extensions/versions/index.ts +168 -0
  67. package/app/extensions/viewer/README.md +12 -0
  68. package/app/extensions/viewer/browser.js +322 -0
  69. package/app/extensions/viewer/guide.md +5 -0
  70. package/app/extensions/viewer/index.ts +209 -0
  71. package/app/extensions/voice/browser.js +71 -0
  72. package/app/extensions/voice/index.ts +37 -0
  73. package/app/mcp.json +3 -0
  74. package/app/settings.json +3 -0
  75. package/app/skills/plugin-map/SKILL.md +49 -0
  76. package/app/skills/write-skill/SKILL.md +57 -0
  77. package/app/ui/apps/.keep +0 -0
  78. package/bin/chalky.mjs +11 -0
  79. package/node_modules/xlsx/CHANGELOG.md +361 -0
  80. package/node_modules/xlsx/LICENSE +201 -0
  81. package/node_modules/xlsx/README.md +41 -0
  82. package/node_modules/xlsx/bin/xlsx.njs +310 -0
  83. package/node_modules/xlsx/bower.json +22 -0
  84. package/node_modules/xlsx/dist/LICENSE +201 -0
  85. package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
  86. package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
  87. package/node_modules/xlsx/dist/cpexcel.js +1506 -0
  88. package/node_modules/xlsx/dist/shim.min.js +2 -0
  89. package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
  90. package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
  91. package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
  92. package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
  93. package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
  94. package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
  95. package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
  96. package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
  97. package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
  98. package/node_modules/xlsx/dist/zahl.d.ts +4 -0
  99. package/node_modules/xlsx/package.json +183 -0
  100. package/node_modules/xlsx/types/index.d.ts +1065 -0
  101. package/node_modules/xlsx/types/tsconfig.json +15 -0
  102. package/node_modules/xlsx/xlsx.js +28103 -0
  103. package/node_modules/xlsx/xlsx.mjs +28228 -0
  104. package/node_modules/xlsx/xlsxworker.js +14 -0
  105. package/package.json +42 -4
  106. package/server.ts +281 -0
@@ -0,0 +1,306 @@
1
+ // Open a page in headless Chrome, act on it, and report what a user would hit. (Node port of check.py.)
2
+ //
3
+ // usage: node check.mjs URL [STEP ...] [--size 1280x800] [--shot out.png] [--full] [--fit SELECTOR]
4
+ // steps, run in order after the page loads:
5
+ // wait:MS wait MS milliseconds (the page gets 1500 ms after load before the first step)
6
+ // click:SELECTOR click the centre of the first element matching the CSS selector
7
+ // drag:SELECTOR=DX,DY press on its centre, move DX,DY px (in steps, button held), release
8
+ // altdrag:SELECTOR=DX,DY the same with Alt (Option) held down
9
+ // hold:SELECTOR=MS press on its centre, keep the button down MS milliseconds without moving, release
10
+ // dblclick:SELECTOR double-click the centre of the first match
11
+ // type:SELECTOR=TEXT focus the element and type TEXT
12
+ // key:KEY press a key: Enter, Escape, Tab, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space, or a letter
13
+ // eval:JS run a JavaScript expression; its value is printed (promises are awaited)
14
+ // shot:PATH screenshot now (a page can be captured several times)
15
+ // dialog:accept answer later confirm()/alert()/prompt() dialogs with OK (dialog:accept=TEXT types TEXT into a
16
+ // prompt); dialog:dismiss answers Cancel. Default: dismiss, so a confirm on the live page doesn't act.
17
+ // --fit SELECTOR: the --shot shows just the box around every element matching SELECTOR (even parts outside the
18
+ // window), plus a margin, in the window's shape. Nothing matching: the plain shot.
19
+ // Prints: errors (exceptions, console errors, failed requests), layout problems (clipped or overflowing
20
+ // elements, cut-off text), step results, every dialog and how it was answered, screenshot paths. Exit code 1 if
21
+ // a step failed or there were errors; layout problems alone don't count (some are meant: the agent judges them,
22
+ // and pi shows an exit code 1 as a failed command); 2 if there is no browser (it prints the command that downloads one).
23
+ // Talks to Chrome over the DevTools Protocol (CDP) with Node's own WebSocket and fetch; no libraries.
24
+ // The browser: $CHROME, else chrome-headless-shell downloaded into BROWSERS, else an installed Chrome, Chromium or Edge.
25
+ import { spawn } from "node:child_process";
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ const BROWSERS = process.env.CHALKY_BROWSERS || path.join(os.homedir(), ".cache", "chalky-browsers");
31
+ const DOWNLOAD = `npx --yes @puppeteer/browsers@3.2.4 install chrome-headless-shell@155.0.8059.84 --path "${BROWSERS}"`;
32
+ const SHELL = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
33
+ const INSTALLED = {
34
+ linux: ["/usr/local/bin/chrome-headless-shell", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
35
+ "/usr/bin/chromium-browser", "/snap/bin/chromium", "/usr/bin/microsoft-edge"],
36
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium",
37
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
38
+ win32: [`${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
39
+ `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`],
40
+ }[process.platform] ?? [];
41
+
42
+ function findBrowser() {
43
+ if (process.env.CHROME) return process.env.CHROME;
44
+ const downloaded = fs.existsSync(BROWSERS) ? fs.readdirSync(BROWSERS, { recursive: true }).map(String)
45
+ .filter((p) => path.basename(p) === SHELL).map((p) => path.join(BROWSERS, p)).sort().at(-1) : undefined;
46
+ return downloaded ?? INSTALLED.find((p) => fs.existsSync(p));
47
+ }
48
+
49
+ const KEYS = { Enter: ["\r", 13], Escape: ["", 27], Tab: ["\t", 9], Space: [" ", 32], ArrowUp: ["", 38],
50
+ ArrowDown: ["", 40], ArrowLeft: ["", 37], ArrowRight: ["", 39], Backspace: ["", 8] };
51
+
52
+ // Layout problems, found in the page itself. Inside scrollable boxes overflow is normal, so only boxes that
53
+ // hide overflow (hidden/clip) count as clipping. Canvas drawings are pixels: check those on the screenshot.
54
+ const LAYOUT = String.raw`(() => {
55
+ const name = el => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
56
+ (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '');
57
+ const shown = el => { const s = getComputedStyle(el), r = el.getBoundingClientRect();
58
+ return s.display !== 'none' && s.visibility !== 'hidden' && +s.opacity > 0 && r.width > 0 && r.height > 0; };
59
+ const out = [], W = innerWidth;
60
+ if (document.documentElement.scrollWidth > W + 1)
61
+ out.push('page scrolls sideways: content is ' + document.documentElement.scrollWidth + 'px wide, window ' + W + 'px');
62
+ for (const el of document.body.querySelectorAll('*')) {
63
+ if (['SCRIPT', 'STYLE', 'TEMPLATE'].includes(el.tagName) || !shown(el)) continue;
64
+ const r = el.getBoundingClientRect(), s = getComputedStyle(el);
65
+ const leaf = !el.children.length || ['CANVAS', 'IMG', 'SVG', 'svg', 'BUTTON', 'INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName);
66
+ if (leaf && (r.right > W + 1 || r.left < -1)) out.push(name(el) + ' sticks out of the window (x ' + Math.round(r.left) + '..' + Math.round(r.right) + ', window 0..' + W + ')');
67
+ if (leaf) for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
68
+ const o = getComputedStyle(a); if (!/hidden|clip/.test(o.overflow + o.overflowX + o.overflowY)) continue;
69
+ const b = a.getBoundingClientRect();
70
+ if (r.left < b.left - 1 || r.right > b.right + 1 || r.top < b.top - 1 || r.bottom > b.bottom + 1)
71
+ out.push(name(el) + ' is cut off by ' + name(a) + ' (element ' + Math.round(r.width) + 'x' + Math.round(r.height) + ' at ' + Math.round(r.left) + ',' + Math.round(r.top)
72
+ + '; box ' + Math.round(b.width) + 'x' + Math.round(b.height) + ' at ' + Math.round(b.left) + ',' + Math.round(b.top) + ')');
73
+ break;
74
+ }
75
+ if (el.children.length === 0 && el.textContent.trim() && /hidden|clip/.test(s.overflow + s.overflowX) && el.scrollWidth > el.clientWidth + 1)
76
+ out.push(name(el) + ' text is cut off: "' + el.textContent.trim().slice(0, 40) + '"');
77
+ }
78
+ const box = el => { const r = el.getBoundingClientRect(); return Math.round(r.width) + 'x' + Math.round(r.height); };
79
+ const big = [...document.body.children].filter(shown).map(el => name(el) + ' ' + box(el)).join(', ');
80
+ return {title: document.title, window: W + 'x' + innerHeight, top_level: big, problems: [...new Set(out)].slice(0, 40)};
81
+ })()`;
82
+
83
+ // The box around all matches, in page coordinates (scrolling included): 32px margin, at least half the window,
84
+ // widened or heightened to the window's shape and kept centred on the matches (not past the page's top or left).
85
+ const FIT = `((sel, W, H) => { // W x H: the window as asked for (it may be enlarged before the second measure)
86
+ const rs = [...document.querySelectorAll(sel)].map(e => e.getBoundingClientRect()).filter(r => r.width && r.height);
87
+ if (!rs.length) return null;
88
+ const m = 32, ratio = W / H;
89
+ const x0 = Math.min(...rs.map(r => r.left)) + scrollX - m, x1 = Math.max(...rs.map(r => r.right)) + scrollX + m;
90
+ const y0 = Math.min(...rs.map(r => r.top)) + scrollY - m, y1 = Math.max(...rs.map(r => r.bottom)) + scrollY + m;
91
+ let w = Math.max(x1 - x0, W / 2), h = Math.max(y1 - y0, H / 2);
92
+ if (w / h < ratio) w = h * ratio; else h = w / ratio;
93
+ return { x: Math.max(0, (x0 + x1 - w) / 2), y: Math.max(0, (y0 + y1 - h) / 2), width: w, height: h, scale: 1 };
94
+ })`;
95
+
96
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
97
+
98
+ class Page {
99
+ constructor(ws) {
100
+ this.ws = ws; this.n = 0; this.pending = new Map(); this.events = [];
101
+ this.dialog = [false, ""]; // how to answer dialogs: [accept, prompt text]; dismiss by default
102
+ ws.addEventListener("message", (m) => {
103
+ const msg = JSON.parse(m.data);
104
+ if (msg.id && this.pending.has(msg.id)) { this.pending.get(msg.id)(msg); this.pending.delete(msg.id); }
105
+ else if (msg.method) {
106
+ this.events.push(msg);
107
+ if (msg.method === "Page.javascriptDialogOpening") this.answer(msg.params); // the page is blocked until answered
108
+ }
109
+ });
110
+ }
111
+ async answer(dialog) {
112
+ const [accept, text] = this.dialog;
113
+ await this.call("Page.handleJavaScriptDialog", { accept, promptText: text });
114
+ console.log(`dialog ${dialog.type} "${dialog.message.slice(0, 100)}" -> ${accept ? "accepted" : "dismissed"}`
115
+ + (accept && dialog.type === "prompt" ? ` with ${JSON.stringify(text)}` : ""));
116
+ }
117
+ call(method, params = {}) {
118
+ const id = ++this.n;
119
+ return new Promise((resolve, reject) => {
120
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method}: no answer in 30 s`)); }, 30000);
121
+ this.pending.set(id, (msg) => { clearTimeout(timer); msg.error ? reject(new Error(`${method}: ${msg.error.message}`)) : resolve(msg.result); });
122
+ this.ws.send(JSON.stringify({ id, method, params }));
123
+ });
124
+ }
125
+ async js(expression) {
126
+ const r = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
127
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
128
+ return r.result.value;
129
+ }
130
+ }
131
+
132
+ async function shot(page, file, full, fit) {
133
+ const [w, h] = await page.js("[innerWidth, innerHeight]");
134
+ let clip = fit && await page.js(`${FIT}(${JSON.stringify(fit)}, ${w}, ${h})`);
135
+ let r;
136
+ if (clip) { // parts outside the window aren't drawn if the page scrolls inside an element: make the window reach
137
+ if (clip.x + clip.width > w || clip.y + clip.height > h) {
138
+ await page.call("Emulation.setDeviceMetricsOverride", { width: Math.floor(Math.max(w, clip.x + clip.width) + 1),
139
+ height: Math.floor(Math.max(h, clip.y + clip.height) + 1), deviceScaleFactor: 1, mobile: false });
140
+ await sleep(500); // relayout, and apps that redraw on resize
141
+ clip = (await page.js(`${FIT}(${JSON.stringify(fit)}, ${w}, ${h})`)) ?? clip;
142
+ }
143
+ r = await page.call("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip });
144
+ } else r = await page.call("Page.captureScreenshot", { format: "png", captureBeyondViewport: full });
145
+ fs.writeFileSync(file, Buffer.from(r.data, "base64"));
146
+ return `screenshot: ${path.resolve(file)}`;
147
+ }
148
+
149
+ async function centre(page, sel) { // the centre of the first match, scrolled into view
150
+ const box = await page.js(`(() => { const el = document.querySelector(${JSON.stringify(sel)});
151
+ if (!el) return null; el.scrollIntoView({block: 'center'}); const r = el.getBoundingClientRect();
152
+ return {x: r.left + r.width / 2, y: r.top + r.height / 2}; })()`);
153
+ if (!box) throw new Error(`no element matches ${sel}`);
154
+ return box;
155
+ }
156
+
157
+ const split = (s, sep, last = false) => { // [before, after] at the first (or last) sep; [s, ""] if none
158
+ const i = last ? s.lastIndexOf(sep) : s.indexOf(sep);
159
+ return i < 0 ? (last ? ["", s] : [s, ""]) : [s.slice(0, i), s.slice(i + sep.length)];
160
+ };
161
+ const mouse = (page, type, x, y, extra = {}) => page.call("Input.dispatchMouseEvent", { type, x, y, ...extra });
162
+
163
+ async function step(page, s, full) {
164
+ const [kind, arg] = split(s, ":");
165
+ if (kind === "wait") { await sleep(Number(arg)); return `waited ${arg} ms`; }
166
+ if (kind === "drag" || kind === "altdrag") {
167
+ const [sel, by] = split(arg, "=", true); // the last "=": selectors may contain one ([data-x=y])
168
+ const [dx, dy] = by.split(",").map(Number);
169
+ const { x, y } = await centre(page, sel), modifiers = kind === "altdrag" ? 1 : 0; // CDP modifiers: 1 = Alt
170
+ await mouse(page, "mouseMoved", x, y, { modifiers });
171
+ await mouse(page, "mousePressed", x, y, { button: "left", clickCount: 1, modifiers });
172
+ for (let i = 1; i <= 10; i++) {
173
+ await mouse(page, "mouseMoved", x + dx * i / 10, y + dy * i / 10, { button: "left", buttons: 1, modifiers });
174
+ await sleep(30);
175
+ }
176
+ await mouse(page, "mouseReleased", x + dx, y + dy, { button: "left", clickCount: 1, modifiers });
177
+ await sleep(300);
178
+ return `${modifiers ? "alt-" : ""}dragged ${sel} by ${dx},${dy}`;
179
+ }
180
+ if (kind === "hold") {
181
+ const [sel, ms] = split(arg, "=", true);
182
+ const { x, y } = await centre(page, sel);
183
+ await mouse(page, "mouseMoved", x, y);
184
+ await mouse(page, "mousePressed", x, y, { button: "left", clickCount: 1 });
185
+ await sleep(Number(ms));
186
+ await mouse(page, "mouseReleased", x, y, { button: "left", clickCount: 1 });
187
+ await sleep(300);
188
+ return `held ${sel} for ${ms} ms`;
189
+ }
190
+ if (kind === "dblclick") {
191
+ const { x, y } = await centre(page, arg);
192
+ for (const clickCount of [1, 2]) for (const t of ["mousePressed", "mouseReleased"]) await mouse(page, t, x, y, { button: "left", clickCount });
193
+ await sleep(300);
194
+ return `double-clicked ${arg}`;
195
+ }
196
+ if (kind === "click") {
197
+ const { x, y } = await centre(page, arg);
198
+ for (const t of ["mousePressed", "mouseReleased"]) await mouse(page, t, x, y, { button: "left", clickCount: 1 });
199
+ await sleep(300);
200
+ return `clicked ${arg}`;
201
+ }
202
+ if (kind === "type") {
203
+ const [sel, text] = split(arg, "=");
204
+ if (!await page.js(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (el) el.focus(); return !!el; })()`))
205
+ throw new Error(`no element matches ${sel}`);
206
+ await page.call("Input.insertText", { text });
207
+ return `typed into ${sel}`;
208
+ }
209
+ if (kind === "key") {
210
+ const [text, code] = KEYS[arg] ?? [arg, arg.length === 1 ? arg.toUpperCase().charCodeAt(0) : 0];
211
+ await page.call("Input.dispatchKeyEvent", { type: "keyDown", key: arg, text, windowsVirtualKeyCode: code });
212
+ await page.call("Input.dispatchKeyEvent", { type: "keyUp", key: arg, windowsVirtualKeyCode: code });
213
+ await sleep(200);
214
+ return `pressed ${arg}`;
215
+ }
216
+ if (kind === "eval") return `eval ${arg.slice(0, 60)} -> ${String(JSON.stringify(await page.js(arg))).slice(0, 2000)}`;
217
+ if (kind === "shot") return shot(page, arg, full);
218
+ if (kind === "dialog") {
219
+ const [answer, text] = split(arg, "=");
220
+ if (!["accept", "dismiss"].includes(answer)) throw new Error("dialog:accept, dialog:accept=TEXT or dialog:dismiss");
221
+ page.dialog = [answer === "accept", text];
222
+ return `dialogs from now on: ${answer}` + (text ? ` with ${JSON.stringify(text)}` : "");
223
+ }
224
+ throw new Error(`unknown step ${JSON.stringify(s)} (wait, click, drag, altdrag, hold, dblclick, type, key, eval, shot, dialog)`);
225
+ }
226
+
227
+ function problems(events) { // errors from the events Chrome sent: uncaught exceptions, console errors, failed requests
228
+ const out = [];
229
+ for (const { method: m, params: p } of events) {
230
+ if (m === "Runtime.exceptionThrown") {
231
+ const d = p.exceptionDetails;
232
+ out.push(`exception: ${d.exception?.description ?? d.text}`.split("\n at ")[0]);
233
+ } else if (m === "Runtime.consoleAPICalled" && ["error", "assert"].includes(p.type))
234
+ out.push(("console.error: " + p.args.map((a) => String(a.value ?? a.description ?? "")).join(" ")).slice(0, 300));
235
+ else if (m === "Log.entryAdded" && p.entry.level === "error") out.push(`browser: ${p.entry.text.slice(0, 300)} ${p.entry.url ?? ""}`.trim());
236
+ else if (m === "Network.responseReceived" && p.response.status >= 400) out.push(`HTTP ${p.response.status}: ${p.response.url}`);
237
+ else if (m === "Network.loadingFailed" && !p.canceled) out.push(`request failed: ${p.errorText} (request ${p.requestId})`);
238
+ }
239
+ return [...new Set(out)];
240
+ }
241
+
242
+ async function main(url, steps, size, shotPath, full, fit) {
243
+ const chromePath = findBrowser();
244
+ if (!chromePath) {
245
+ console.log(`no headless browser found (looked at $CHROME, ${BROWSERS}, and the usual Chrome, Chromium and Edge places).\n`
246
+ + `Download one (about 270 MB on disk, once), then run this again:\n ${DOWNLOAD}`);
247
+ return 2;
248
+ }
249
+ const [w, h] = size.split("x").map(Number);
250
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), "ui-check-"));
251
+ const headless = path.basename(chromePath).startsWith("chrome-headless-shell") ? [] : ["--headless"]; // a full browser: no window
252
+ const chrome = spawn(chromePath, [...headless, "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--no-first-run",
253
+ `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"], { stdio: "ignore" });
254
+ let failed = false, page, layout;
255
+ try {
256
+ const portFile = path.join(profile, "DevToolsActivePort");
257
+ for (let i = 0; i < 100 && !(fs.existsSync(portFile) && fs.readFileSync(portFile, "utf8").trim()); i++) await sleep(100); // written once ready
258
+ const port = fs.readFileSync(portFile, "utf8").split("\n")[0];
259
+ const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
260
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
261
+ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error("could not connect to the browser")); });
262
+ page = new Page(ws);
263
+ for (const domain of ["Page", "Runtime", "Log", "Network"]) await page.call(`${domain}.enable`);
264
+ await page.call("Emulation.setDeviceMetricsOverride", { width: w, height: h, deviceScaleFactor: 1, mobile: false });
265
+ await page.call("Page.navigate", { url });
266
+ let loaded = false;
267
+ for (let i = 0; i < 150 && !(loaded = page.events.some((e) => e.method === "Page.loadEventFired")); i++) await sleep(100); // up to 15 s
268
+ if (!loaded) { console.log("problem: the page did not finish loading in 15 s"); failed = true; }
269
+ await sleep(1500); // scripts, sockets and first renders
270
+ console.log(`opened ${url} at ${size}`);
271
+ for (const st of steps) {
272
+ try { console.log(await step(page, st, full)); } catch (e) { console.log(`step failed: ${st}: ${e.message}`); failed = true; }
273
+ }
274
+ layout = await page.js(LAYOUT);
275
+ if (shotPath) console.log(await shot(page, shotPath, full, fit));
276
+ ws.close();
277
+ const errors = problems(page.events);
278
+ console.log(`title: ${JSON.stringify(layout.title)}, window ${layout.window}; top-level elements: ${layout.top_level}`);
279
+ console.log(`errors (${errors.length}):` + errors.map((e) => `\n - ${e}`).join(""));
280
+ console.log(`layout problems (${layout.problems.length}):` + layout.problems.map((p) => `\n - ${p}`).join(""));
281
+ failed = failed || errors.length > 0; // layout problems: printed, for the agent to judge
282
+ } finally {
283
+ chrome.kill();
284
+ await new Promise((r) => chrome.exitCode !== null ? r() : chrome.on("exit", r));
285
+ fs.rmSync(profile, { recursive: true, force: true });
286
+ }
287
+ return failed ? 1 : 0;
288
+ }
289
+
290
+ const args = process.argv.slice(2);
291
+ if (!args.length || ["-h", "--help"].includes(args[0])) {
292
+ console.log(fs.readFileSync(new URL(import.meta.url), "utf8").split("\nimport ")[0].replace(/^\/\/ ?/gm, ""));
293
+ process.exit(0);
294
+ }
295
+ const steps = [];
296
+ let size = "1280x800", shotPath = null, full = false, fit = null;
297
+ const rest = args.slice(1);
298
+ while (rest.length) {
299
+ const a = rest.shift();
300
+ if (a === "--size") size = rest.shift();
301
+ else if (a === "--shot") shotPath = rest.shift();
302
+ else if (a === "--full") full = true;
303
+ else if (a === "--fit") fit = rest.shift();
304
+ else steps.push(a);
305
+ }
306
+ process.exit(await main(args[0], steps, size, shotPath, full, fit));
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: ui-design
3
+ description: How to make what you build for the user look good and read clearly - surfaces, components, apps, charts and diagrams. Use before building or restyling anything the user will see in the main space.
4
+ ---
5
+ # Designing what the user sees
6
+
7
+ What you build fills the user's screen, and the user then points at it and marks it up.
8
+ So it must look like it belongs on the page, and each piece must be a clear thing to point at.
9
+ Precedence: the user's own words, then the page's existing look, then your choices.
10
+
11
+ ## 1. Fit the page
12
+ The page is light, glassy and quiet (Apple-like): soft gradient background, translucent white glass,
13
+ thin edges, system font. Match it unless the user asks for another look.
14
+ - Use the page's tokens instead of literal colours (they are defined on the page and reach components;
15
+ apps in a frame copy the values they need):
16
+ `--text #1d1d1f`, `--soft rgba(29,29,31,.72)`, `--muted rgba(60,60,67,.55)`, `--accent #0071e3`,
17
+ `--danger #e5484d`, `--line rgba(0,0,0,.06)`, `--glass rgba(255,255,255,.45)`,
18
+ `--glass-strong rgba(255,255,255,.7)`, `--edge rgba(255,255,255,.75)`, `--radius 16px`.
19
+ - The built-in components (Card, Button, Table, Text, TextField, Markdown) already use these tokens;
20
+ prefer them, and style your own components the same way.
21
+ - Font: inherit the page's system font. Use a Google Font only when the subject calls for it
22
+ (a pixel-art game, a poster), and always give a fallback stack.
23
+ - Show just the thing: no frame, card or background around it unless it needs one. Transparent app backgrounds.
24
+ - A user-requested look (pixel art, neon, paper) wins. Commit to it fully inside that surface,
25
+ and keep the rest of the page as it is.
26
+
27
+ ## 2. Ground it in the subject
28
+ - Pick one concrete job for each surface. Use real content, never lorem ipsum.
29
+ - Take at least one detail from the subject's own world: its real units, terms, conventions
30
+ (a chess clock shows mm:ss; a kanban card has an owner and a state).
31
+ - Structure must mean something: number things only when order matters; a divider only between real groups.
32
+
33
+ ## 3. Lay it out
34
+ - Group siblings with flex or grid and `gap`, not per-element margins.
35
+ - Make repeated things consistent: same padding, edges and baselines on every card in a row;
36
+ a recurring element sits in the same place on each.
37
+ - Let content set the height. Text that can outgrow its box wraps or scrolls inside it; clipped text is a bug.
38
+ - `font-variant-numeric: tabular-nums` wherever digits line up.
39
+ - Borders, fills, radius and shadow mark something as a separate object. Use them on the one
40
+ element that needs it, not on every block.
41
+ - Keep it complete at rest: visible when it loads, never left at `opacity: 0` waiting for an animation.
42
+ - A tool opens in a working state: sample rows or a loaded example (marked as such), not an empty shell.
43
+
44
+ ## 4. Type and words
45
+ - One type scale; keep to it. Running text near 65 characters wide. Headings `text-wrap: balance`.
46
+ - Small uppercase labels get a little letter-spacing.
47
+ - Name things by what the user recognises, not how it's built. Buttons say exactly what happens
48
+ ("Start round", then "Round 2 started"). Errors say what went wrong and how to fix it. Short, plain sentences.
49
+
50
+ ## 5. Avoid the generic AI look (unless the user asks for it)
51
+ Cream background with a serif and terracotta accent; near-black with one acid-green pop;
52
+ purple-to-blue gradient heroes; emoji as section markers; everything centred; the same rounded
53
+ shadowed card on everything; a coloured accent bar down the side of cards.
54
+
55
+ ## 6. Dashboards and tools
56
+ They are scanned and operated, not read top to bottom.
57
+ - Summary before detail. Show state in form as well as numbers (a pill, a chip, a stripe),
58
+ so what needs attention is visible at a glance.
59
+ - Status colours (good / warning / critical) are separate from the accent, and always come with
60
+ an icon or a word, never colour alone.
61
+ - Interactive things look interactive; keyboard focus is visible.
62
+
63
+ ## 7. Charts
64
+ Pick the form first, colour last.
65
+ - The data's job picks the chart: magnitude (bar), change over time (line), share (stacked bar),
66
+ one headline number (a stat, not a chart). Sometimes the answer is a sentence or a table.
67
+ - Colour by job: identity = distinct hues in a fixed order (never cycled, and a hue follows its
68
+ entity even when filters change); magnitude = one hue light to dark; polarity = two hues with a grey middle.
69
+ Never a rainbow. More than about 8 series: fold into "Other" or split into small charts.
70
+ - One y-axis. Never two scales on one chart; use two charts instead.
71
+ - Thin marks: 2px lines, bars with a 2px gap, markers at least 8px. Faint grid, quiet axes.
72
+ - Place marks, ticks and labels with one scale; every tick names a value the chart reaches.
73
+ - Two or more series: a legend, plus direct labels when there are 4 or fewer. Label a few points, not every one.
74
+ - Text uses text colours (`--text`, `--muted`), never the series colour.
75
+ - Add hover: a tooltip per bar/dot, a crosshair on lines. Hit targets bigger than the mark.
76
+
77
+ ## 8. Diagrams
78
+ Draw only when a picture shows a mechanism faster than a sentence: what flows where, which parts
79
+ talk, what changes between two options.
80
+ - Draw the mechanism, not its name: the path a request takes, the boundary crossed, the edge that appears or disappears.
81
+ - Comparing options: draw the difference side by side, so the user can point at what they are choosing between.
82
+ - Label every arrow (`writes`, `polls every 30s`). Short labels (1-3 words); sentences go in a caption.
83
+ - Inline `<svg viewBox="0 0 W H">` scaled by CSS (`max-width:100%; height:auto`). Strokes and text in
84
+ `currentColor`; one literal hue only for the element that carries the point. Arrowheads via `<marker>`.
85
+ Text about 11-13px. Align to a grid with even gaps. Give every shape an explicit fill.
86
+ - Wrap in `<figure>` with a `<figcaption>` that states the claim.
87
+
88
+ ## 9. Make it easy to reshape
89
+ The user changes what you build by hand (pins, levers on the back side, combining, undo), so build for that.
90
+ - One clear thing per surface, with a name the user would say (`lunch-orders`, not `surface3`).
91
+ - Write `about`: what it is for and any decision a later change should respect ("drinks are not counted").
92
+ - Put what changes in data (rows, scores, settings) and the look in components: data changes are instant
93
+ for the user and never need you.
94
+ - Read other surfaces' data with `{"ref": "<surface>", "path": "/..."}` instead of copying it, so it stays live.
95
+ - A component you write: its about block lists every prop with a type, and `levers` for the few a user
96
+ would want to change (a size, a mode, a label), written as plain sentences.
97
+
98
+ ## 10. One look
99
+ What the user builds over time should look like one hand made it: a garden and the wrestler standing
100
+ in it share a palette, a way of shading and a level of detail.
101
+ - Before drawing something new, look at what is already there (a screenshot, and the code of the
102
+ surfaces it sits beside). Match its palette, shading, line weight and detail. Read their code and
103
+ reuse their colours and helpers rather than redrawing them from memory.
104
+ - Keep a small palette: a ground, a main colour for shapes, an ink for words, and one accent for the
105
+ one thing to look at. Mix shades from those (`color-mix(in srgb, <main> 70%, <ground>)`, lighter and
106
+ darker) rather than inventing new colours. Many shades of a few colours look designed; many
107
+ unrelated colours look accidental.
108
+ - Keep the palette in one place the code can reuse (CSS variables, a small shared module), so a
109
+ later change touches one spot.
110
+ - Readable first: text needs strong contrast with what is behind it (about 4.5:1; shapes about 3:1;
111
+ compute it if unsure). Colours that differ in hue but are equally bright blur together: vary the lightness.
112
+ - Taste: start from the subject's own world (a temple garden, a night market, a ledger). Tone the
113
+ extremes (a warm off-white, a deep ink, rarely pure white or black). One accent used sparingly beats
114
+ three. Neon, glows, gloss and full saturation everywhere look machine-made unless the subject calls for them.
115
+ - Games and 3D: the same idea as art direction, not exact colours. One sky or fog colour and one light
116
+ colour across the scene hold it together, however many materials it has; the accent marks what the
117
+ player should notice; the HUD and menus use the palette like any surface.
118
+ - A default, not a rule: when the user asks for another look, or a piece clearly needs its own world
119
+ (a dark space game beside a light garden), give it one, commit to it, and say so in its `about`.
120
+
121
+ ## 11. Check it
122
+ Load ui-check and look at a screenshot: overlaps, clipped text, labels colliding, things outside their box.
123
+ Fix what you see once, then stop polishing; the user will point at what they want changed.
@@ -0,0 +1,114 @@
1
+ // Voice input for apps: speech from the microphone, as text. An app imports it:
2
+ // import voice from "/ui/voice.js";
3
+ // await voice.start(onText, opts) start listening; onText(text) gets each finished piece of speech, in order
4
+ // opts: language ("en", optional), onState(state, detail) with state one of
5
+ // "listening", "speech", "sending", "off", "error" (detail: the message)
6
+ // voice.stop() stop listening; a piece still being spoken is sent first
7
+ // voice.mode() "api" or "browser", known after start
8
+ // "api" (the sandbox has STT_BASE_URL, see /app/plugins/voice.py): the mic is cut into pieces at pauses
9
+ // (2 to 10 s), each sent as a complete audio file to /voice-api/transcribe; silent pieces aren't sent.
10
+ // "browser": the browser's own speech recognition (Chrome, Edge, Safari; audio goes to their vendor).
11
+ const shot = new URLSearchParams(window.top.location.search).has("shot"); // a screenshot: don't listen
12
+ let mode = null, stopNow = null;
13
+
14
+ async function start(onText, { language = "", onState = () => {} } = {}) {
15
+ if (shot || stopNow) return;
16
+ const config = await fetch("/voice-api/config").then((r) => r.json()).catch(() => ({ api: false }));
17
+ mode = config.api ? "api" : "browser";
18
+ stopNow = mode === "api" ? await startApi(onText, language, onState) : startBrowser(onText, language, onState);
19
+ }
20
+
21
+ function stop() {
22
+ if (stopNow) stopNow();
23
+ stopNow = null;
24
+ }
25
+
26
+ // ---------- api: pauses by loudness, one complete file per piece ----------
27
+ const QUIET_MS = 700, MIN_MS = 2000, MAX_MS = 10000;
28
+
29
+ async function startApi(onText, language, onState) {
30
+ let stream;
31
+ try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
32
+ catch (e) { onState("error", "microphone: " + e.message); return null; }
33
+ const ctx = new AudioContext();
34
+ const analyser = ctx.createAnalyser();
35
+ ctx.createMediaStreamSource(stream).connect(analyser);
36
+ const samples = new Float32Array(analyser.fftSize);
37
+
38
+ let floor = 0.005; // the room's quiet level, learned as it goes
39
+ let recorder = null, began = 0, spoke = false, quietSince = null;
40
+ let lastText = "", queue = Promise.resolve();
41
+
42
+ // A recorder makes one complete file from start() to stop(); timeslice chunks would lack the file header.
43
+ function newPiece() {
44
+ const old = recorder, hadSpeech = spoke;
45
+ recorder = new MediaRecorder(stream);
46
+ recorder.start(); // start the next piece before stopping this one: no gap between them
47
+ began = Date.now(); spoke = false; quietSince = null;
48
+ if (!old) return;
49
+ old.ondataavailable = (e) => { if (hadSpeech) queue = queue.then(() => send(e.data)); };
50
+ old.stop();
51
+ }
52
+
53
+ async function send(blob) { // one at a time, in order: each gets the text before it as its prompt
54
+ const form = new FormData();
55
+ form.append("file", blob, "chunk." + (blob.type.split(/[/;]/)[1] || "webm"));
56
+ if (language) form.append("language", language);
57
+ if (lastText) form.append("prompt", lastText.slice(-200));
58
+ onState("sending");
59
+ try {
60
+ const r = await fetch("/voice-api/transcribe", { method: "POST", body: form });
61
+ const data = await r.json();
62
+ if (data.error) return onState("error", data.error);
63
+ if (data.text.trim()) { lastText = data.text; onText(data.text.trim()); }
64
+ onState("listening");
65
+ } catch (e) { onState("error", "transcribe: " + e.message); }
66
+ }
67
+
68
+ const timer = setInterval(() => {
69
+ analyser.getFloatTimeDomainData(samples);
70
+ const rms = Math.sqrt(samples.reduce((s, x) => s + x * x, 0) / samples.length);
71
+ const speaking = rms > Math.max(0.008, floor * 3);
72
+ floor += (rms - floor) * (speaking ? 0.001 : 0.05); // follows the room, slowly while someone speaks
73
+ const now = Date.now(), age = now - began;
74
+ if (speaking) { if (!spoke) onState("speech"); spoke = true; quietSince = null; }
75
+ else quietSince ??= now;
76
+ if (spoke && quietSince && now - quietSince > QUIET_MS && age > MIN_MS) newPiece();
77
+ else if (age > MAX_MS) newPiece(); // a long speech is cut anyway; silence just starts over
78
+ }, 50);
79
+
80
+ newPiece();
81
+ onState("listening");
82
+ return () => {
83
+ clearInterval(timer);
84
+ newPiece(); // send what was being said
85
+ recorder.ondataavailable = null;
86
+ recorder.stop();
87
+ stream.getTracks().forEach((t) => t.stop());
88
+ ctx.close();
89
+ onState("off");
90
+ };
91
+ }
92
+
93
+ // ---------- browser: its speech recognition, restarted when it ends by itself ----------
94
+ function startBrowser(onText, language, onState) {
95
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
96
+ if (!SR) { onState("error", "no speech recognition in this browser, and no STT_BASE_URL in the sandbox"); return null; }
97
+ const rec = new SR();
98
+ let on = true;
99
+ rec.continuous = true;
100
+ rec.interimResults = false;
101
+ if (language) rec.lang = language;
102
+ rec.onresult = (e) => {
103
+ for (let i = e.resultIndex; i < e.results.length; i++)
104
+ if (e.results[i].isFinal && e.results[i][0].transcript.trim()) onText(e.results[i][0].transcript.trim());
105
+ };
106
+ rec.onspeechstart = () => onState("speech");
107
+ rec.onerror = (e) => { if (e.error !== "no-speech" && e.error !== "aborted") onState("error", e.error); };
108
+ rec.onend = () => { if (on) setTimeout(() => on && rec.start(), 300); }; // it stops after silence or minutes
109
+ rec.start();
110
+ onState("listening");
111
+ return () => { on = false; rec.stop(); onState("off"); };
112
+ }
113
+
114
+ export default { start, stop, mode: () => mode };
@@ -0,0 +1,97 @@
1
+ // Browser half of the ui_history plugin: undo, redo and rewind the surfaces.
2
+ // Ctrl+Z / ⌘Z: undo. Ctrl+Shift+Z / ⌘⇧Z / Ctrl+Y: redo. (Not while typing in a field: that undoes the typing.)
3
+ // Hold Alt (Option) and drag sideways anywhere: scrub along the history; let go to stay there.
4
+ // Doing something new after going back starts a branch; a note offers to bring the other one back.
5
+ // It only sends messages (ui_undo, ui_redo, ui_goto); the server puts the surfaces back and ui.js redraws them.
6
+ const CSS = `
7
+ #ui-history-bar { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 9999; display: flex;
8
+ flex-direction: column; align-items: center; gap: 6px; max-width: calc(100vw - 32px); padding: 8px 14px; border-radius: 12px;
9
+ font-size: 12px; color: #fff; background: rgba(29, 29, 31, .92); pointer-events: none; }
10
+ #ui-history-bar .ticks { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; }
11
+ #ui-history-bar .ticks i { width: 6px; height: 14px; border-radius: 2px; background: #fff; opacity: .3; }
12
+ #ui-history-bar .ticks i.now { opacity: 1; background: #f4d35e; }
13
+ #ui-history-bar .ticks i.ahead { opacity: .12; }
14
+ #ui-history-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 9999; display: flex; gap: 10px;
15
+ align-items: center; padding: 8px 14px; border-radius: 12px; font-size: 13px; color: #fff; background: rgba(29, 29, 31, .92); }
16
+ #ui-history-toast button { font: inherit; color: inherit; background: none; border: 1px solid currentColor; border-radius: 8px;
17
+ padding: 2px 10px; cursor: pointer; }`;
18
+
19
+ export default function (page) {
20
+ document.head.insertAdjacentHTML("beforeend", `<style id="ui-history-css">${CSS}</style>`);
21
+ const bar = Object.assign(document.createElement("div"), { id: "ui-history-bar", hidden: true });
22
+ const toastEl = Object.assign(document.createElement("div"), { id: "ui-history-toast", hidden: true });
23
+ document.body.append(bar, toastEl);
24
+ let toastTimer;
25
+ function toast(text, button, onclick) {
26
+ toastEl.replaceChildren(Object.assign(document.createElement("span"), { textContent: text }));
27
+ if (button) toastEl.append(Object.assign(document.createElement("button"), { textContent: button,
28
+ onclick: () => { toastEl.hidden = true; onclick(); } }));
29
+ toastEl.hidden = false;
30
+ clearTimeout(toastTimer);
31
+ toastTimer = setTimeout(() => (toastEl.hidden = true), button ? 8000 : 2500);
32
+ }
33
+
34
+ const typing = (e) => e.target.closest?.("input, textarea, select, [contenteditable='true']");
35
+ document.addEventListener("keydown", (e) => {
36
+ if (!(e.ctrlKey || e.metaKey) || typing(e)) return;
37
+ const k = e.key.toLowerCase();
38
+ if (k === "z" && !e.shiftKey) { e.preventDefault(); page.send({ type: "ui_undo" }); }
39
+ else if ((k === "z" && e.shiftKey) || k === "y") { e.preventDefault(); page.send({ type: "ui_redo" }); }
40
+ });
41
+
42
+ // {x0, x, line, start, i} while Alt-dragging. It starts at the press, before the history has come (line: null till
43
+ // then), so a release or moves during the fetch are not lost; it ends at the release, or at the first move without
44
+ // the button held (released outside the window), a cancelled drag, or the window losing focus.
45
+ let scrub = null;
46
+ function showBar() {
47
+ const { line, i } = scrub, from = Math.max(0, line.length - 80);
48
+ const ticks = Object.assign(document.createElement("div"), { className: "ticks" });
49
+ for (let j = from; j < line.length; j++) ticks.append(Object.assign(document.createElement("i"), { className: j === i ? "now" : j > i ? "ahead" : "" }));
50
+ const step = line[i];
51
+ bar.replaceChildren(Object.assign(document.createElement("span"), {
52
+ textContent: `${i + 1} of ${line.length}: ${step.what}, ${new Date(step.ts * 1000).toLocaleTimeString()}` }), ticks);
53
+ bar.hidden = false;
54
+ }
55
+ function follow() { // go to the step under the pointer
56
+ const step = Math.max(10, Math.min(40, 600 / scrub.line.length)); // px per step: the whole line fits in ~600 px
57
+ const i = Math.max(0, Math.min(scrub.line.length - 1, scrub.start + Math.round((scrub.x - scrub.x0) / step)));
58
+ if (i === scrub.i) return;
59
+ scrub.i = i;
60
+ page.send({ type: "ui_goto", step: scrub.line[i].n });
61
+ showBar();
62
+ }
63
+ document.addEventListener("pointerdown", async (e) => {
64
+ if (!e.altKey) return;
65
+ e.preventDefault(); e.stopPropagation();
66
+ const me = scrub = { x0: e.clientX, x: e.clientX, line: null };
67
+ let history;
68
+ try { history = await (await fetch("/ui-api/history")).json(); } catch { if (scrub === me) scrub = null; return; }
69
+ if (scrub !== me) return; // let go before the history came: nothing to do
70
+ me.line = history.line;
71
+ me.start = me.i = history.line.findIndex((s) => s.n === history.at);
72
+ showBar();
73
+ follow(); // the pointer may have moved meanwhile
74
+ }, true);
75
+ document.addEventListener("pointermove", (e) => {
76
+ if (!scrub) return;
77
+ if (!(e.buttons & 1)) return end(); // the button is up: it was let go outside the window
78
+ scrub.x = e.clientX;
79
+ if (scrub.line) follow();
80
+ });
81
+ document.addEventListener("pointerup", () => end());
82
+ document.addEventListener("pointercancel", () => end());
83
+ addEventListener("blur", () => end());
84
+ function end() {
85
+ if (!scrub) return;
86
+ const ahead = scrub.line && scrub.i < scrub.line.length - 1;
87
+ scrub = null;
88
+ bar.hidden = true;
89
+ if (ahead) toast("Gone back. Do something new to branch from here, or Ctrl+Shift+Z to come forward.");
90
+ }
91
+
92
+ page.on("ui_history", (ev) => {
93
+ if (ev.event === "moved" && !scrub) toast(`Back to just after: ${ev.what}`);
94
+ if (ev.event === "branched") toast("You branched from an earlier moment. The other version is kept.", "Bring it back",
95
+ () => page.send({ type: "ui_goto", step: ev.tip }));
96
+ });
97
+ }