@pro-vi/designer 0.3.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.
@@ -0,0 +1,602 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import { createBrowser } from "./browser.js";
7
+ import { sessionDir, saveIteration } from "./artifact-store.js";
8
+ import { upsertSession, appendHistory, getSession } from "./session-store.js";
9
+ import { REPO_ROOT } from "./repo-root.js";
10
+ import { ensureCdpUp } from "./cdp-ensure.js";
11
+ const DESIGN_HOME = 'https://claude.ai/design';
12
+ const FLAT_LAYOUT_SUFFIX = '\n\nFile layout: keep all generated files at the project root. No subfolders.';
13
+ function loadSelectors() {
14
+ const base = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'selectors.json'), 'utf8'));
15
+ const overridePath = path.join(os.homedir(), '.designer', 'selectors.override.json');
16
+ if (fs.existsSync(overridePath)) {
17
+ try {
18
+ return deepMerge(base, JSON.parse(fs.readFileSync(overridePath, 'utf8')));
19
+ }
20
+ catch (e) {
21
+ console.warn(`[designer] failed to parse ${overridePath}: ${e.message}`);
22
+ }
23
+ }
24
+ return base;
25
+ }
26
+ function deepMerge(a, b) {
27
+ if (Array.isArray(a) || Array.isArray(b))
28
+ return b ?? a;
29
+ if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
30
+ return b ?? a;
31
+ const out = { ...a };
32
+ for (const k of Object.keys(b))
33
+ out[k] = deepMerge(a[k], b[k]);
34
+ return out;
35
+ }
36
+ export class DesignerController {
37
+ key;
38
+ selectors;
39
+ browser;
40
+ _preSendHtml = '';
41
+ constructor({ key, headed = true } = {}) {
42
+ this.key = key || 'default';
43
+ this.selectors = loadSelectors();
44
+ this.browser = createBrowser({ session: `designer-${this.key}`, headed });
45
+ }
46
+ async currentUrl() {
47
+ return (await this.browser.url().catch(() => '')) || '';
48
+ }
49
+ async isOnHome() {
50
+ const u = await this.currentUrl();
51
+ return /\/design\/?$/.test(u) || u.endsWith('/design');
52
+ }
53
+ async isInSession() {
54
+ const u = await this.currentUrl();
55
+ return /\/design\/p\/[a-f0-9-]+/i.test(u);
56
+ }
57
+ async getStatus() {
58
+ const stored = getSession(this.key);
59
+ const url = await this.currentUrl();
60
+ const inSession = /\/design\/p\/[a-f0-9-]+/i.test(url);
61
+ const availableFiles = inSession ? await this.listFiles().catch(() => []) : [];
62
+ return {
63
+ key: this.key,
64
+ stored,
65
+ currentUrl: url,
66
+ inSession,
67
+ onHome: /\/design\/?$/.test(url) || url.endsWith('/design'),
68
+ availableFiles
69
+ };
70
+ }
71
+ async session({ action = 'status', name, fidelity = 'wireframe' } = {}) {
72
+ if (action === 'status')
73
+ return this.getStatus();
74
+ if (action === 'ensure_ready') {
75
+ const r = await this.ensureReady();
76
+ return { ...r, status: await this.getStatus() };
77
+ }
78
+ if (action === 'resume') {
79
+ const stored = getSession(this.key);
80
+ if (!stored?.designUrl)
81
+ throw new Error(`No stored session for key=${this.key}. Use action='create' with a name.`);
82
+ const r = await this.resumeSession();
83
+ return { ...r, status: await this.getStatus() };
84
+ }
85
+ if (action === 'create') {
86
+ if (!name)
87
+ throw new Error("action='create' requires a name.");
88
+ const r = await this.createSession(name, fidelity);
89
+ return { ...r, status: await this.getStatus() };
90
+ }
91
+ throw new Error(`Unknown action: ${action}`);
92
+ }
93
+ async ensureReady() {
94
+ await ensureCdpUp();
95
+ const u = await this.currentUrl();
96
+ if (!/claude\.ai\/design/.test(u)) {
97
+ await this.browser.open(DESIGN_HOME);
98
+ await this.browser.waitLoad('networkidle').catch(() => null);
99
+ }
100
+ const homeOk = this.selectors.login.signedInIndicator
101
+ ? await this.browser.isVisible(this.selectors.login.signedInIndicator).catch(() => false)
102
+ : false;
103
+ const sessionOk = await this.browser.isVisible(this.selectors.composer.promptTextarea).catch(() => false);
104
+ if (!homeOk && !sessionOk) {
105
+ throw new Error('Not signed in to claude.ai/design, or on an unrecognized page. Sign in in the CDP-attached Chrome.');
106
+ }
107
+ upsertSession(this.key, { lastUrl: await this.currentUrl() });
108
+ return { ok: true, url: await this.currentUrl(), inSession: await this.isInSession() };
109
+ }
110
+ async createSession(name, fidelity = 'wireframe') {
111
+ const s = this.selectors.home;
112
+ await this.browser.open(DESIGN_HOME);
113
+ await this.browser.waitLoad('networkidle').catch(() => null);
114
+ await this.browser.waitFor(s.creator);
115
+ await this.browser.evalValue(`(() => {
116
+ const el = document.querySelector(${JSON.stringify(s.nameInput)});
117
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
118
+ setter.call(el, ${JSON.stringify(name)});
119
+ el.dispatchEvent(new Event('input', { bubbles: true }));
120
+ return true;
121
+ })()`);
122
+ const text = fidelity === 'highfi' ? s.highFiButtonText : s.wireframeButtonText;
123
+ await this._clickButtonByText(new RegExp('^' + text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
124
+ await new Promise((r) => setTimeout(r, 200));
125
+ await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(s.createButton)}); if (!b) throw new Error('create button missing'); b.click(); return true; })()`);
126
+ for (let i = 0; i < 40; i++) {
127
+ await new Promise((r) => setTimeout(r, 300));
128
+ if (await this.isInSession())
129
+ break;
130
+ }
131
+ if (!(await this.isInSession()))
132
+ throw new Error('Session creation did not navigate to a /p/ url in time.');
133
+ const url = await this.currentUrl();
134
+ upsertSession(this.key, { designUrl: url, name, fidelity, lastUrl: url });
135
+ appendHistory(this.key, { kind: 'session_create', name, fidelity, url });
136
+ return { ok: true, url, name, fidelity };
137
+ }
138
+ async resumeSession() {
139
+ const stored = getSession(this.key);
140
+ if (!stored?.designUrl)
141
+ throw new Error(`No designUrl stored for key=${this.key}. Create one first.`);
142
+ await this.browser.open(stored.designUrl);
143
+ await this.browser.waitLoad('networkidle').catch(() => null);
144
+ return { ok: true, url: stored.designUrl };
145
+ }
146
+ async _submitPrompt(prompt) {
147
+ const { promptTextarea, sendButton } = this.selectors.composer;
148
+ await this.browser.waitFor(promptTextarea);
149
+ await this.browser.evalValue(`(() => {
150
+ const ta = document.querySelector(${JSON.stringify(promptTextarea)});
151
+ if (!ta) throw new Error('textarea not found');
152
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
153
+ setter.call(ta, ${JSON.stringify(prompt)});
154
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
155
+ ta.focus();
156
+ return true;
157
+ })()`);
158
+ for (let i = 0; i < 30; i++) {
159
+ await new Promise((r) => setTimeout(r, 150));
160
+ const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled; })()`);
161
+ if (!disabled)
162
+ break;
163
+ }
164
+ await this.browser.evalValue(`(() => {
165
+ const b = document.querySelector(${JSON.stringify(sendButton)});
166
+ if (!b) throw new Error('send button not found');
167
+ b.click();
168
+ return true;
169
+ })()`);
170
+ }
171
+ async sendPrompt(prompt) {
172
+ const before = await this.fetchServedHtml();
173
+ this._preSendHtml = before.html;
174
+ const effective = prompt + FLAT_LAYOUT_SUFFIX;
175
+ await this._submitPrompt(effective);
176
+ appendHistory(this.key, { kind: 'prompt', prompt, suffixApplied: 'flat_layout' });
177
+ return { ok: true };
178
+ }
179
+ async waitForGenerationDone({ timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
180
+ const start = Date.now();
181
+ const preHtml = this._preSendHtml || '';
182
+ let lastHtml = '';
183
+ let lastLen = -1;
184
+ let stableSince = 0;
185
+ let sawChange = false;
186
+ while (Date.now() - start < timeoutMs) {
187
+ const { html, src } = await this.fetchServedHtml();
188
+ const len = html.length;
189
+ if (!preHtml) {
190
+ if (len > 0)
191
+ sawChange = true;
192
+ }
193
+ else if (html && html !== preHtml) {
194
+ sawChange = true;
195
+ }
196
+ if (sawChange) {
197
+ if (len === lastLen && html === lastHtml) {
198
+ if (!stableSince)
199
+ stableSince = Date.now();
200
+ if (Date.now() - stableSince > stabilityMs) {
201
+ const url = await this.currentUrl();
202
+ return { ok: true, elapsedMs: Date.now() - start, url, iframeSrc: src, htmlBytes: len, html };
203
+ }
204
+ }
205
+ else {
206
+ stableSince = 0;
207
+ }
208
+ }
209
+ lastHtml = html;
210
+ lastLen = len;
211
+ await new Promise((r) => setTimeout(r, pollMs));
212
+ }
213
+ return { ok: false, error: 'timeout', elapsedMs: Date.now() - start };
214
+ }
215
+ async snapshotDesign({ html: knownHtml, iframeSrc: knownSrc } = {}) {
216
+ const iframeSrc = knownSrc || (await this.getIframeSrc());
217
+ let html = knownHtml ?? null;
218
+ if (html == null && iframeSrc && /claudeusercontent\.com/.test(iframeSrc)) {
219
+ const res = await fetch(iframeSrc, { headers: { Accept: 'text/html' } }).catch(() => null);
220
+ if (res && res.ok)
221
+ html = await res.text();
222
+ }
223
+ const dir = sessionDir(this.key);
224
+ const shotPath = path.join(dir, `shot-${Date.now()}.png`);
225
+ const shotOk = await this.browser
226
+ .screenshot(shotPath, { full: true })
227
+ .then(() => true)
228
+ .catch(() => false);
229
+ const url = await this.currentUrl();
230
+ return { html, screenshotPath: shotOk ? shotPath : null, url, iframeSrc };
231
+ }
232
+ async _ensureInSession() {
233
+ await this.ensureReady();
234
+ if (await this.isInSession())
235
+ return;
236
+ const stored = getSession(this.key);
237
+ if (!stored?.designUrl)
238
+ throw new Error(`No active session for key=${this.key}. Call createSession first.`);
239
+ await this.resumeSession();
240
+ }
241
+ async iterate(prompt, { file, timeoutMs, stabilityMs } = {}) {
242
+ await this._ensureInSession();
243
+ if (file)
244
+ await this.openFile(file);
245
+ const preFiles = await this.listFiles().catch(() => []);
246
+ const preChatCount = (await this.getChatTurns()).length;
247
+ await this.sendPrompt(prompt);
248
+ const done = await this.waitForGenerationDone({ timeoutMs, stabilityMs });
249
+ const postFiles = await this.listFiles().catch(() => []);
250
+ const postTurns = await this.getChatTurns();
251
+ const lastTurn = postTurns[postTurns.length - 1];
252
+ const chatReply = postTurns.length > preChatCount && lastTurn && lastTurn.role === 'assistant'
253
+ ? lastTurn.text.replace(/^Claude(?:\n+)?/, '').trim()
254
+ : null;
255
+ const newFiles = postFiles.filter((f) => !preFiles.includes(f));
256
+ const removedFiles = preFiles.filter((f) => !postFiles.includes(f));
257
+ const snap = await this.snapshotDesign({ html: done.html, iframeSrc: done.iframeSrc });
258
+ const htmlHash = snap.html ? hashHex(snap.html) : null;
259
+ const activeFile = extractFileParam(snap.url);
260
+ let failureMode = null;
261
+ if (!done.ok)
262
+ failureMode = done.error === 'timeout' ? 'timeout' : 'unstable';
263
+ else if (snap.html === this._preSendHtml && newFiles.length === 0)
264
+ failureMode = 'no_change';
265
+ const fidelity = getSession(this.key)?.fidelity || null;
266
+ const record = saveIteration(this.key, {
267
+ prompt,
268
+ fidelity,
269
+ html: snap.html,
270
+ screenshotPath: snap.screenshotPath,
271
+ url: snap.url,
272
+ meta: { done: { ok: done.ok, elapsedMs: done.elapsedMs }, failureMode, activeFile, newFiles, htmlHash }
273
+ });
274
+ appendHistory(this.key, { kind: 'iteration', record: record.files, newFiles });
275
+ return {
276
+ done: { ok: done.ok, elapsedMs: done.elapsedMs, failureMode },
277
+ changed: !!(snap.html && snap.html !== this._preSendHtml) || newFiles.length > 0,
278
+ url: snap.url,
279
+ activeFile,
280
+ newFiles,
281
+ removedFiles,
282
+ htmlPath: record.files.html || null,
283
+ screenshotPath: record.files.screenshot || null,
284
+ htmlBytes: snap.html ? snap.html.length : 0,
285
+ htmlHash,
286
+ chatReply
287
+ };
288
+ }
289
+ async listProjects() {
290
+ await this.browser.open(DESIGN_HOME);
291
+ await this.browser.waitLoad('networkidle').catch(() => null);
292
+ await this.browser.waitFor(this.selectors.home.projectsList).catch(() => null);
293
+ const json = await this.browser.evalValue(`(() => {
294
+ const cards = Array.from(document.querySelectorAll('[data-testid="project-card"]'));
295
+ return cards.map((c) => {
296
+ const link = c.tagName === 'A' ? c : c.querySelector('a[href*="/design/p/"]');
297
+ const href = link && link.href ? link.href : null;
298
+ const text = (c.innerText || '').split('\\n').map((s) => s.trim()).filter(Boolean);
299
+ return { name: text[0] || null, sub: text[1] || null, url: href };
300
+ });
301
+ })()`).catch(() => []);
302
+ return Array.isArray(json) ? json : [];
303
+ }
304
+ async listFiles() {
305
+ const { files } = await this.listFilesDetailed();
306
+ return files;
307
+ }
308
+ async listFilesDetailed() {
309
+ const stored = getSession(this.key);
310
+ const currentUrl = await this.currentUrl();
311
+ const targetRoot = stored?.designUrl?.split('?')[0];
312
+ const currentRoot = currentUrl.split('?')[0];
313
+ if (!targetRoot) {
314
+ throw new Error(`No designUrl stored for key=${this.key}. createSession or resumeSession first.`);
315
+ }
316
+ if (currentRoot !== targetRoot) {
317
+ await this.browser.open(stored.designUrl);
318
+ await this.browser.waitLoad('networkidle').catch(() => null);
319
+ await new Promise((r) => setTimeout(r, 1500));
320
+ }
321
+ await this.browser.evalValue(`(() => {
322
+ const spans = Array.from(document.querySelectorAll('span'));
323
+ const label = spans.find(s => s.children.length === 0 && (s.textContent || '').trim() === 'Design Files');
324
+ if (!label) return false;
325
+ let row = label;
326
+ while (row && row.onclick === null) row = row.parentElement;
327
+ if (row) row.click();
328
+ return true;
329
+ })()`).catch(() => null);
330
+ await new Promise((r) => setTimeout(r, 600));
331
+ const result = await this.browser.evalValue(`(() => {
332
+ const spans = Array.from(document.querySelectorAll('span'));
333
+ const seen = new Set();
334
+ const files = [];
335
+ for (const s of spans) {
336
+ if (s.children.length) continue;
337
+ const t = (s.textContent || '').trim();
338
+ if (!/^[A-Za-z0-9 _.()\\-]+\\.(html|js|css|jsx)$/i.test(t) || seen.has(t)) continue;
339
+ seen.add(t);
340
+ files.push(t);
341
+ }
342
+ // Folders: rows whose sibling text is 'Folder' (a Claude-side label)
343
+ const folderSet = new Set();
344
+ const divs = Array.from(document.querySelectorAll('div'));
345
+ for (const d of divs) {
346
+ if (d.onclick === null) continue;
347
+ const lines = (d.innerText || '').trim().split('\\n').map((l) => l.trim());
348
+ if (lines.length >= 2 && lines[1] === 'Folder' && lines[0] && lines[0].length < 40) {
349
+ folderSet.add(lines[0]);
350
+ }
351
+ }
352
+ return { files, folders: Array.from(folderSet) };
353
+ })()`).catch(() => ({ files: [], folders: [] }));
354
+ return {
355
+ files: Array.isArray(result.files) ? result.files : [],
356
+ folders: Array.isArray(result.folders) ? result.folders : [],
357
+ authoritative: (result.folders?.length ?? 0) === 0
358
+ };
359
+ }
360
+ async openFile(filename) {
361
+ const stored = getSession(this.key);
362
+ const baseUrl = stored?.designUrl || (await this.currentUrl()).split('?')[0] || '';
363
+ if (!/\/design\/p\//.test(baseUrl))
364
+ throw new Error('No project open for this key.');
365
+ const target = `${baseUrl.split('?')[0]}?file=${encodeURIComponent(filename)}`;
366
+ await this.browser.open(target);
367
+ const wanted = encodeURIComponent(filename);
368
+ for (let i = 0; i < 30; i++) {
369
+ await new Promise((r) => setTimeout(r, 500));
370
+ const src = await this.getIframeSrc();
371
+ if (src.includes(wanted))
372
+ return { ok: true, file: filename, url: await this.currentUrl() };
373
+ }
374
+ return { ok: false, error: 'iframe-swap-timeout', file: filename, url: await this.currentUrl() };
375
+ }
376
+ async fetchFile(filename) {
377
+ const swap = await this.openFile(filename);
378
+ if (!swap.ok)
379
+ return { ok: false, error: swap.error, file: filename, html: '', htmlBytes: 0 };
380
+ const { html, src } = await this.fetchServedHtml();
381
+ return { ok: true, file: filename, iframeSrc: src, html, htmlBytes: html.length };
382
+ }
383
+ async getChatTurns() {
384
+ return ((await this.browser
385
+ .evalValue(`(() => {
386
+ const c = document.querySelector('[data-testid="chat-messages"]');
387
+ const inner = c && c.children[0];
388
+ if (!inner) return [];
389
+ return Array.from(inner.children).map((d) => {
390
+ const txt = (d.innerText || '').trim();
391
+ const isAssistant = /^Claude(\\n|$)/.test(txt);
392
+ const isUser = /^You(\\n|$)/.test(txt);
393
+ return { role: isAssistant ? 'assistant' : isUser ? 'user' : 'unknown', text: txt };
394
+ });
395
+ })()`)
396
+ .catch(() => [])) || []);
397
+ }
398
+ async ask(prompt, { file, timeoutMs = 5 * 60_000, stabilityMs = 2500, pollMs = 1000 } = {}) {
399
+ await this._ensureInSession();
400
+ if (file)
401
+ await this.openFile(file);
402
+ const beforeCount = (await this.getChatTurns()).length;
403
+ await this._submitPrompt(prompt);
404
+ appendHistory(this.key, { kind: 'ask', prompt });
405
+ const start = Date.now();
406
+ let lastText = '';
407
+ let stableSince = 0;
408
+ while (Date.now() - start < timeoutMs) {
409
+ const turns = await this.getChatTurns();
410
+ if (turns.length >= beforeCount + 2) {
411
+ const last = turns[turns.length - 1];
412
+ if (last && last.role === 'assistant') {
413
+ if (last.text === lastText && last.text.length > 0) {
414
+ if (!stableSince)
415
+ stableSince = Date.now();
416
+ if (Date.now() - stableSince > stabilityMs) {
417
+ const reply = last.text
418
+ .replace(/^Claude(?:\n+)?/, '')
419
+ .replace(/^(?:Searching|Reading|Thinking)\s*\n+/i, '')
420
+ .trim();
421
+ appendHistory(this.key, { kind: 'ask_reply', textBytes: reply.length });
422
+ return { ok: true, elapsedMs: Date.now() - start, reply, failureMode: null };
423
+ }
424
+ }
425
+ else {
426
+ stableSince = 0;
427
+ lastText = last.text;
428
+ }
429
+ }
430
+ }
431
+ await new Promise((r) => setTimeout(r, pollMs));
432
+ }
433
+ return { ok: false, elapsedMs: Date.now() - start, reply: null, failureMode: 'timeout' };
434
+ }
435
+ async getIframeSrc() {
436
+ const src = await this.browser
437
+ .evalValue(`(() => { const el = document.querySelector(${JSON.stringify(this.selectors.preview.iframeOrContainer)}); return (el && el.src) || ''; })()`)
438
+ .catch(() => '');
439
+ return src || '';
440
+ }
441
+ async fetchServedHtml() {
442
+ const src = await this.getIframeSrc();
443
+ if (!src || !/claudeusercontent\.com/.test(src))
444
+ return { src: '', html: '' };
445
+ try {
446
+ const res = await fetch(src, { headers: { Accept: 'text/html' } });
447
+ if (!res.ok)
448
+ return { src, html: '' };
449
+ return { src, html: await res.text() };
450
+ }
451
+ catch {
452
+ return { src, html: '' };
453
+ }
454
+ }
455
+ async handoff({ openFile } = {}) {
456
+ await this._ensureInSession();
457
+ if (openFile)
458
+ await this.openFile(openFile);
459
+ const opened = await this._clickButtonByText(/^Share$/).catch(() => null);
460
+ if (!opened)
461
+ await this._clickButtonByText(/^Export$/);
462
+ await new Promise((r) => setTimeout(r, 400));
463
+ await this._clickButtonByText(/handoff to claude code/i);
464
+ let handoffUrl = '';
465
+ for (let i = 0; i < 30; i++) {
466
+ await new Promise((r) => setTimeout(r, 300));
467
+ const text = await this._dialogText();
468
+ const match = String(text || '').match(/https:\/\/api\.anthropic\.com\/v1\/design\/h\/[A-Za-z0-9_-]+(?:\?[^\s]*)?/);
469
+ if (match && match[0]) {
470
+ handoffUrl = match[0];
471
+ break;
472
+ }
473
+ }
474
+ if (!handoffUrl)
475
+ throw new Error('Handoff URL did not appear in the dialog.');
476
+ await this.browser
477
+ .evalValue(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
478
+ .catch(() => null);
479
+ const dir = sessionDir(this.key);
480
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
481
+ const bundleDir = path.join(dir, `handoff-${stamp}`);
482
+ fs.mkdirSync(bundleDir, { recursive: true });
483
+ const tgzPath = path.join(bundleDir, 'bundle.tar.gz');
484
+ const res = await fetch(handoffUrl);
485
+ if (!res.ok)
486
+ throw new Error(`Handoff fetch failed: HTTP ${res.status}`);
487
+ const buf = Buffer.from(await res.arrayBuffer());
488
+ fs.writeFileSync(tgzPath, buf);
489
+ await new Promise((resolve, reject) => {
490
+ const child = spawn('tar', ['-xzf', tgzPath, '-C', bundleDir], { stdio: 'pipe' });
491
+ let err = '';
492
+ child.stderr.on('data', (d) => (err += d.toString()));
493
+ child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`tar exited ${code}: ${err}`)));
494
+ });
495
+ const entries = fs.readdirSync(bundleDir).filter((e) => e !== 'bundle.tar.gz');
496
+ const projectSlug = entries.find((e) => fs.statSync(path.join(bundleDir, e)).isDirectory());
497
+ const slugDir = projectSlug ? path.join(bundleDir, projectSlug) : bundleDir;
498
+ const repaired = repairEmDashLinks(path.join(slugDir, 'project'));
499
+ const readmePath = path.join(slugDir, 'README.md');
500
+ const readme = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, 'utf8') : null;
501
+ const files = listAllFiles(slugDir).map((p) => path.relative(bundleDir, p));
502
+ appendHistory(this.key, { kind: 'handoff', url: handoffUrl, bundleDir, fileCount: files.length, repaired });
503
+ return {
504
+ ok: true,
505
+ handoffUrl,
506
+ bundleDir,
507
+ slugDir,
508
+ readmePath,
509
+ readmeBytes: readme ? readme.length : 0,
510
+ tarballPath: tgzPath,
511
+ tarballBytes: buf.length,
512
+ files,
513
+ repaired
514
+ };
515
+ }
516
+ async _clickButtonByText(pattern) {
517
+ const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
518
+ return this.browser.evalValue(`(() => {
519
+ const re = new RegExp(${JSON.stringify(re.source)}, ${JSON.stringify(re.flags)});
520
+ const btn = Array.from(document.querySelectorAll('button')).find(b => re.test((b.textContent || '').trim()));
521
+ if (!btn) throw new Error('button not found: ' + ${JSON.stringify(re.source)});
522
+ btn.click();
523
+ return true;
524
+ })()`);
525
+ }
526
+ async _dialogText() {
527
+ return ((await this.browser
528
+ .evalValue(`(() => {
529
+ const dlg = document.querySelector('[role=dialog]');
530
+ return (dlg && dlg.innerText) || '';
531
+ })()`)
532
+ .catch(() => '')) || '');
533
+ }
534
+ async close() {
535
+ await this.browser.close().catch(() => null);
536
+ }
537
+ }
538
+ function hashHex(s) {
539
+ return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
540
+ }
541
+ function extractFileParam(url) {
542
+ try {
543
+ return new URL(url).searchParams.get('file');
544
+ }
545
+ catch {
546
+ return null;
547
+ }
548
+ }
549
+ function repairEmDashLinks(projectDir) {
550
+ const report = { renamed: [], skipped: [] };
551
+ if (!fs.existsSync(projectDir))
552
+ return report;
553
+ const indexPath = path.join(projectDir, 'index.html');
554
+ if (!fs.existsSync(indexPath))
555
+ return report;
556
+ const indexHtml = fs.readFileSync(indexPath, 'utf8');
557
+ const hrefs = new Set();
558
+ for (const m of indexHtml.matchAll(/href="([^"#?]+\.html)"/g)) {
559
+ const raw = m[1];
560
+ if (!raw)
561
+ continue;
562
+ try {
563
+ hrefs.add(decodeURIComponent(raw));
564
+ }
565
+ catch {
566
+ hrefs.add(raw);
567
+ }
568
+ }
569
+ for (const wanted of hrefs) {
570
+ const wantedPath = path.join(projectDir, wanted);
571
+ if (fs.existsSync(wantedPath))
572
+ continue;
573
+ const candidate = wanted.replace(/\u2014/g, '-').replace(/\s-\s/g, ' - ');
574
+ const candidatePath = path.join(projectDir, candidate);
575
+ if (candidate !== wanted && fs.existsSync(candidatePath)) {
576
+ fs.renameSync(candidatePath, wantedPath);
577
+ report.renamed.push({ from: candidate, to: wanted });
578
+ }
579
+ else {
580
+ report.skipped.push(wanted);
581
+ }
582
+ }
583
+ return report;
584
+ }
585
+ function listAllFiles(root) {
586
+ const out = [];
587
+ const stack = [root];
588
+ while (stack.length) {
589
+ const cur = stack.pop();
590
+ if (!cur)
591
+ continue;
592
+ for (const entry of fs.readdirSync(cur)) {
593
+ const p = path.join(cur, entry);
594
+ const st = fs.statSync(p);
595
+ if (st.isDirectory())
596
+ stack.push(p);
597
+ else
598
+ out.push(p);
599
+ }
600
+ }
601
+ return out;
602
+ }