@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,136 @@
1
+ #!/usr/bin/env -S node --import tsx
2
+ import crypto from 'node:crypto';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { z } from 'zod';
8
+ import { DesignerController } from "./designer-controller.js";
9
+ import { sessionDir } from "./artifact-store.js";
10
+ const server = new McpServer({ name: 'designer', version: '0.3.0' });
11
+ const controllers = new Map();
12
+ function getController(key) {
13
+ const k = key || 'default';
14
+ if (!controllers.has(k))
15
+ controllers.set(k, new DesignerController({ key: k, headed: true }));
16
+ return controllers.get(k);
17
+ }
18
+ function textResult(obj) {
19
+ return {
20
+ content: [{ type: 'text', text: typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2) }],
21
+ structuredContent: typeof obj === 'object' && obj !== null ? obj : undefined
22
+ };
23
+ }
24
+ server.registerTool('designer_session', {
25
+ description: "Enter, inspect, or transition a claude.ai/design session. Default action='status' is a pure read — safe to call anytime to orient without side effects. Returns stored state + currentUrl + inSession + availableFiles so you can avoid a follow-up list call. Use this as the first tool in any agent loop.\n\nActions:\n- status (default): read-only, no mutations\n- ensure_ready: navigate to /design if not already there\n- resume: navigate into the stored designUrl for this key (fails if nothing stored)\n- create: new project (requires name)",
26
+ inputSchema: {
27
+ key: z.string().optional().describe('Stable key for this loop (e.g., feature name). Defaults to "default".'),
28
+ action: z.enum(['status', 'ensure_ready', 'resume', 'create']).optional().describe('Default: status'),
29
+ name: z.string().optional().describe('Required when action=create.'),
30
+ fidelity: z.enum(['wireframe', 'highfi']).optional().describe('Locked at creation. Default wireframe.')
31
+ }
32
+ }, async ({ key, action = 'status', name, fidelity }) => textResult(await getController(key).session({ action, name, fidelity })));
33
+ server.registerTool('designer_prompt', {
34
+ description: "Modify the design. Sends a prompt you expect to change the served HTML (e.g., 'create a login screen', 'add a Remember-me checkbox'). Waits for HTML to change and stabilize. Returns slim metadata — NOT inline HTML (written to disk at htmlPath).\n\n**Default taste path: hand the human `url` from the return.** The URL is the live claude.ai/design surface — fully interactive, tweak sliders work, variant switcher works. Only reach for `designer tasting` when full-viewport comparison matters more than interactivity.\n\nAuto-appended to every prompt: an instruction to keep all generated files at the project root (no subfolders). The live MCP's file-list scrape is flat-only; subfolder-nested files are invisible until `designer_handoff`. If you need nested layouts, explicitly contradict this in your prompt.\n\nKey return fields:\n- url: live URL to show the human (default taste path)\n- done.failureMode: null | 'timeout' | 'unstable' | 'no_change' (no_change means Claude replied text-only — did you want designer_ask?)\n- newFiles / removedFiles: diff vs pre-send\n- activeFile: what's currently rendered\n- htmlPath / screenshotPath: read these only if you need the content\n- chatReply: Claude's commentary",
35
+ inputSchema: {
36
+ key: z.string().optional(),
37
+ prompt: z.string(),
38
+ file: z.string().optional().describe('Switch to this file before sending (targets the prompt at it).'),
39
+ timeoutMs: z.number().optional().describe('Default 20m. Hi-fi generations can take 15+ min; bump this for complex multi-variant prompts.'),
40
+ stabilityMs: z.number().optional().describe('Default 4s.')
41
+ }
42
+ }, async ({ key, prompt, file, timeoutMs, stabilityMs }) => textResult(await getController(key).iterate(prompt, { file, timeoutMs, stabilityMs })));
43
+ server.registerTool('designer_ask', {
44
+ description: "Q&A with the design assistant — text-only, doesn't change any file. Use for 'why did you choose X?', 'compare A vs B', 'suggest 3 alternatives before I commit'. Returns the assistant's reply.",
45
+ inputSchema: {
46
+ key: z.string().optional(),
47
+ prompt: z.string(),
48
+ file: z.string().optional().describe('Switch to this file before asking (gives Claude context).'),
49
+ timeoutMs: z.number().optional().describe('Default 5m.'),
50
+ stabilityMs: z.number().optional().describe('Default 2.5s.')
51
+ }
52
+ }, async ({ key, prompt, file, timeoutMs, stabilityMs }) => textResult(await getController(key).ask(prompt, { file, timeoutMs, stabilityMs })));
53
+ server.registerTool('designer_list', {
54
+ description: "Inventory. scope='projects' lists all your Claude design projects; scope='files' lists files in the currently-open project. Usually you won't need this — designer_session already returns availableFiles, and designer_prompt returns newFiles.",
55
+ inputSchema: {
56
+ key: z.string().optional(),
57
+ scope: z.enum(['projects', 'files'])
58
+ }
59
+ }, async ({ key, scope }) => {
60
+ const c = getController(key);
61
+ if (scope === 'projects')
62
+ return textResult(await c.listProjects());
63
+ const detail = await c.listFilesDetailed();
64
+ if (!detail.authoritative) {
65
+ return textResult({
66
+ files: detail.files,
67
+ folders: detail.folders,
68
+ authoritative: false,
69
+ warning: 'This project has folders (' +
70
+ detail.folders.join(', ') +
71
+ '). Files under folders are not visible to the live file-list scrape. Call designer_handoff for an authoritative list.'
72
+ });
73
+ }
74
+ return textResult({ files: detail.files, authoritative: true });
75
+ });
76
+ server.registerTool('designer_snapshot', {
77
+ description: "Inspect a file's current state. Switches to `filename` first if given. Default returns paths + hash only (no inline HTML — read htmlPath if you need the content). Set includeHtml=true to get the HTML inline.",
78
+ inputSchema: {
79
+ key: z.string().optional(),
80
+ filename: z.string().optional().describe('Switch to this file first. Omit to snapshot whatever is active.'),
81
+ includeHtml: z.boolean().optional().describe('Default false.'),
82
+ screenshot: z.boolean().optional().describe('Default true.')
83
+ }
84
+ }, async ({ key, filename, includeHtml = false, screenshot = true }) => {
85
+ const c = getController(key);
86
+ if (filename) {
87
+ const swap = await c.openFile(filename);
88
+ if (!swap.ok)
89
+ return textResult({ ok: false, error: swap.error, file: filename });
90
+ }
91
+ const snap = await c.snapshotDesign({});
92
+ let htmlPath = null;
93
+ if (snap.html) {
94
+ htmlPath = path.join(sessionDir(c.key), `snap-${Date.now()}.html`);
95
+ fs.writeFileSync(htmlPath, snap.html);
96
+ }
97
+ return textResult({
98
+ ok: true,
99
+ file: filename || extractFileParamFromUrl(snap.url),
100
+ url: snap.url,
101
+ iframeSrc: snap.iframeSrc,
102
+ htmlPath,
103
+ screenshotPath: screenshot ? snap.screenshotPath : null,
104
+ htmlBytes: snap.html ? snap.html.length : 0,
105
+ htmlHash: snap.html ? simpleHash(snap.html) : null,
106
+ html: includeHtml ? snap.html : undefined
107
+ });
108
+ });
109
+ server.registerTool('designer_handoff', {
110
+ description: "Promote: trigger Export → Handoff to Claude Code, download the public tar.gz bundle (no auth), extract under ./artifacts/{key}/handoff-{ts}/. Bundle contains README.md, chat transcripts (decision record — every prompt + reply, verbatim), all design files, standalone HTML, shared CSS, design-canvas.jsx. Call when the human says 'yes, that's it'.",
111
+ inputSchema: {
112
+ key: z.string().optional(),
113
+ openFile: z.string().optional().describe('Set the open file before handoff.')
114
+ }
115
+ }, async ({ key, openFile }) => textResult(await getController(key).handoff({ openFile })));
116
+ function extractFileParamFromUrl(url) {
117
+ try {
118
+ return new URL(url).searchParams.get('file');
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ function simpleHash(s) {
125
+ return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
126
+ }
127
+ export async function startMcpServer() {
128
+ const transport = new StdioServerTransport();
129
+ await server.connect(transport);
130
+ }
131
+ const __isDirectInvoke = import.meta.url === `file://${process.argv[1]}` ||
132
+ process.argv[1]?.endsWith('mcp-server.ts') ||
133
+ process.argv[1]?.endsWith('mcp-server.js');
134
+ if (__isDirectInvoke) {
135
+ await startMcpServer();
136
+ }
@@ -0,0 +1,15 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ function findRepoRoot() {
4
+ let dir = path.dirname(new URL(import.meta.url).pathname);
5
+ for (let i = 0; i < 8; i++) {
6
+ if (fs.existsSync(path.join(dir, 'package.json')))
7
+ return dir;
8
+ const parent = path.dirname(dir);
9
+ if (parent === dir)
10
+ break;
11
+ dir = parent;
12
+ }
13
+ throw new Error('repo-root: could not find package.json walking up from ' + new URL(import.meta.url).pathname);
14
+ }
15
+ export const REPO_ROOT = findRepoRoot();
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env -S node --import tsx
2
+ import { createBrowser } from "../browser.js";
3
+ const cmd = process.argv[2];
4
+ const arg = process.argv[3];
5
+ const browser = createBrowser({ headed: true });
6
+ if (!process.env.DESIGNER_CDP) {
7
+ console.error('[probe] DESIGNER_CDP not set — using agent-browser-managed session (may be blocked by Cloudflare/SSO). Prefer: export DESIGNER_CDP=9222 and relaunch Chrome with --remote-debugging-port=9222.');
8
+ }
9
+ async function main() {
10
+ switch (cmd) {
11
+ case 'login':
12
+ console.log('Opening claude.ai/design in a headed browser window.');
13
+ console.log('Complete Cloudflare + sign in. Session state auto-persists to ~/.agent-browser/.');
14
+ await browser.open('https://claude.ai/design');
15
+ break;
16
+ case 'url':
17
+ console.log(await browser.url());
18
+ break;
19
+ case 'title':
20
+ console.log(await browser.title());
21
+ break;
22
+ case 'snapshot':
23
+ console.log(await browser.snapshotText({ interactive: true, scope: arg }));
24
+ break;
25
+ case 'snapshot-json':
26
+ console.log(JSON.stringify(await browser.snapshot({ interactive: true, scope: arg }), null, 2));
27
+ break;
28
+ case 'screenshot': {
29
+ const p = arg || `./logs/probe-${Date.now()}.png`;
30
+ await browser.screenshot(p, { full: true });
31
+ console.log(p);
32
+ break;
33
+ }
34
+ case 'eval':
35
+ if (!arg)
36
+ throw new Error('Usage: probe.ts eval <js>');
37
+ console.log(await browser.eval(arg));
38
+ break;
39
+ case 'open':
40
+ await browser.open(arg || 'https://claude.ai/design');
41
+ console.log(await browser.url());
42
+ break;
43
+ case 'close':
44
+ await browser.close();
45
+ break;
46
+ default:
47
+ console.log(`Usage:
48
+ probe.ts login open headed window for manual login
49
+ probe.ts open <url> navigate
50
+ probe.ts url print current url
51
+ probe.ts title print current title
52
+ probe.ts snapshot [scope] interactive a11y tree (text)
53
+ probe.ts snapshot-json [scope] interactive a11y tree (JSON)
54
+ probe.ts screenshot [path] full-page screenshot
55
+ probe.ts eval <js> evaluate JS in page
56
+ probe.ts close close browser`);
57
+ }
58
+ }
59
+ main().catch((e) => {
60
+ console.error(e.message);
61
+ process.exit(1);
62
+ });
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ const ROOT = process.env.DESIGNER_STATE_DIR || path.join(os.homedir(), '.designer');
5
+ const SESSIONS_FILE = path.join(ROOT, 'sessions.json');
6
+ function ensureRoot() {
7
+ fs.mkdirSync(ROOT, { recursive: true });
8
+ }
9
+ function readAll() {
10
+ ensureRoot();
11
+ if (!fs.existsSync(SESSIONS_FILE))
12
+ return {};
13
+ try {
14
+ return JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')) || {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ function writeAll(data) {
21
+ ensureRoot();
22
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
23
+ }
24
+ export function getSession(key) {
25
+ return readAll()[key] || null;
26
+ }
27
+ export function upsertSession(key, patch) {
28
+ const all = readAll();
29
+ const prev = all[key] || { key, createdAt: new Date().toISOString(), history: [] };
30
+ const next = { ...prev, ...patch, updatedAt: new Date().toISOString() };
31
+ all[key] = next;
32
+ writeAll(all);
33
+ return next;
34
+ }
35
+ export function listSessions() {
36
+ return Object.values(readAll());
37
+ }
38
+ export function appendHistory(key, entry) {
39
+ const all = readAll();
40
+ const prev = all[key] || { key, createdAt: new Date().toISOString(), history: [] };
41
+ prev.history = [...(prev.history || []), { ...entry, at: new Date().toISOString() }];
42
+ prev.updatedAt = new Date().toISOString();
43
+ all[key] = prev;
44
+ writeAll(all);
45
+ return prev;
46
+ }
47
+ export function stateDir() {
48
+ return ROOT;
49
+ }
package/dist/setup.js ADDED
@@ -0,0 +1,258 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
6
+ import { REPO_ROOT } from "./repo-root.js";
7
+ const SKILL_SRC = path.join(REPO_ROOT, 'skills', 'designer-loop', 'SKILL.md');
8
+ const SKILL_DEST_DIR = path.join(os.homedir(), '.claude', 'skills', 'designer-loop');
9
+ const SKILL_DEST = path.join(SKILL_DEST_DIR, 'SKILL.md');
10
+ const CHROME_BIN = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
11
+ const DEFAULT_PORT = process.env.DESIGNER_CDP || '9222';
12
+ const PROFILE = path.join(os.homedir(), '.chrome-designer-profile');
13
+ function log(stage, status, msg) {
14
+ const icon = status === 'ok' ? '✓' : status === 'wait' ? '·' : '✗';
15
+ console.log(`${icon} [${stage}] ${msg}`);
16
+ }
17
+ async function sleep(ms) {
18
+ return new Promise((r) => setTimeout(r, ms));
19
+ }
20
+ async function isCdpUp(port) {
21
+ try {
22
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(1500) });
23
+ return res.ok;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function getDesignTab(port) {
30
+ try {
31
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2000) });
32
+ if (!res.ok)
33
+ return null;
34
+ const tabs = (await res.json());
35
+ for (const t of tabs) {
36
+ if (t.url && /claude\.ai\/design/.test(t.url) && !/login/i.test(t.url)) {
37
+ return { url: t.url, title: t.title || '' };
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ function chromeRunning() {
47
+ const r = spawnSync('pgrep', ['-f', 'Google Chrome.app/Contents/MacOS/Google Chrome'], { stdio: 'pipe' });
48
+ return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
49
+ }
50
+ async function pollUntil(name, fn, opts) {
51
+ const start = Date.now();
52
+ let emittedReminder = false;
53
+ let emittedHint = false;
54
+ let dots = 0;
55
+ while (Date.now() - start < opts.timeoutMs) {
56
+ if (await fn()) {
57
+ if (dots > 0)
58
+ process.stdout.write('\n');
59
+ return true;
60
+ }
61
+ const elapsed = Date.now() - start;
62
+ if (!emittedReminder) {
63
+ log(name, 'wait', opts.reminder);
64
+ emittedReminder = true;
65
+ }
66
+ else {
67
+ process.stdout.write('.');
68
+ dots++;
69
+ }
70
+ if (!emittedHint && opts.hint60s && elapsed > 60_000) {
71
+ process.stdout.write('\n');
72
+ dots = 0;
73
+ log(name, 'wait', opts.hint60s);
74
+ emittedHint = true;
75
+ }
76
+ await sleep(opts.intervalMs);
77
+ }
78
+ if (dots > 0)
79
+ process.stdout.write('\n');
80
+ return false;
81
+ }
82
+ function lockfileHash(p) {
83
+ try {
84
+ return createHash('sha1').update(fs.readFileSync(p)).digest('hex');
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ async function step1NpmInstall() {
91
+ const nm = path.join(REPO_ROOT, 'node_modules');
92
+ const rootLock = path.join(REPO_ROOT, 'package-lock.json');
93
+ const innerLock = path.join(nm, '.package-lock.json');
94
+ if (!fs.existsSync(rootLock)) {
95
+ if (fs.existsSync(nm)) {
96
+ log('deps', 'ok', 'installed-mode (no package-lock to verify)');
97
+ return true;
98
+ }
99
+ log('deps', 'fail', 'no package-lock.json and no node_modules — reinstall the package');
100
+ return false;
101
+ }
102
+ if (fs.existsSync(nm)) {
103
+ const a = lockfileHash(rootLock);
104
+ const b = lockfileHash(innerLock);
105
+ if (a && b && a === b) {
106
+ log('deps', 'ok', 'node_modules in sync with package-lock');
107
+ return true;
108
+ }
109
+ log('deps', 'wait', b ? 'lockfile mismatch; reinstalling...' : 'no inner lockfile; reinstalling to sync...');
110
+ }
111
+ else {
112
+ log('deps', 'wait', 'running npm install...');
113
+ }
114
+ const r = spawnSync('npm', ['install'], { cwd: REPO_ROOT, stdio: 'inherit' });
115
+ if (r.status !== 0) {
116
+ log('deps', 'fail', `npm install exited ${r.status}`);
117
+ return false;
118
+ }
119
+ log('deps', 'ok', 'installed');
120
+ return true;
121
+ }
122
+ function step2AgentBrowser() {
123
+ const r = spawnSync('agent-browser', ['--version'], { stdio: 'pipe' });
124
+ if (r.status !== 0) {
125
+ log('agent-browser', 'fail', 'not found on PATH. Install: brew install agent-browser OR npm i -g agent-browser');
126
+ return false;
127
+ }
128
+ log('agent-browser', 'ok', r.stdout?.toString().trim() || 'present');
129
+ return true;
130
+ }
131
+ async function step3Chrome(port) {
132
+ if (await isCdpUp(port)) {
133
+ log('chrome', 'ok', `CDP already up on :${port}`);
134
+ return true;
135
+ }
136
+ if (chromeRunning()) {
137
+ log('chrome', 'wait', 'A non-debug Chrome is running. Quit it (Cmd+Q on the Chrome menu, then close Activity Monitor entries if any). I am polling.');
138
+ const quit = await pollUntil('chrome', () => !chromeRunning(), {
139
+ intervalMs: 1000,
140
+ timeoutMs: 5 * 60_000,
141
+ reminder: 'Still waiting for Chrome to fully quit. Cmd+Q on Chrome.'
142
+ });
143
+ if (!quit) {
144
+ log('chrome', 'fail', 'Timed out waiting for Chrome to quit. Quit manually then re-run setup.');
145
+ return false;
146
+ }
147
+ }
148
+ log('chrome', 'wait', `Launching debug Chrome on :${port} with --user-data-dir=${PROFILE}`);
149
+ if (!fs.existsSync(CHROME_BIN)) {
150
+ log('chrome', 'fail', `Chrome not found at ${CHROME_BIN}. Set CHROME_BIN to override.`);
151
+ return false;
152
+ }
153
+ const child = spawn(CHROME_BIN, ['--remote-debugging-port=' + port, '--user-data-dir=' + PROFILE, 'https://claude.ai/design'], {
154
+ detached: true,
155
+ stdio: 'ignore'
156
+ });
157
+ child.unref();
158
+ const up = await pollUntil('chrome', () => isCdpUp(port), {
159
+ intervalMs: 800,
160
+ timeoutMs: 30_000,
161
+ reminder: `Waiting for CDP at :${port}...`
162
+ });
163
+ if (!up) {
164
+ log('chrome', 'fail', 'Chrome launched but CDP did not come up. Try `./scripts/designer-chrome.sh` manually.');
165
+ return false;
166
+ }
167
+ log('chrome', 'ok', `CDP up on :${port}`);
168
+ return true;
169
+ }
170
+ async function step4SignIn(port) {
171
+ const tab = await getDesignTab(port);
172
+ if (tab) {
173
+ log('login', 'ok', `Signed in. Tab on ${tab.url.replace(/\?.*$/, '')}`);
174
+ return true;
175
+ }
176
+ log('login', 'wait', 'Sign in to Claude in the debug Chrome window I just opened, then navigate to claude.ai/design. I am polling.');
177
+ const ok = await pollUntil('login', async () => (await getDesignTab(port)) !== null, {
178
+ intervalMs: 2000,
179
+ timeoutMs: 10 * 60_000,
180
+ reminder: 'Still waiting for a tab on claude.ai/design (not on /login).',
181
+ hint60s: "If Chrome shows a Google 'new device' or 2FA prompt, complete that first — setup is waiting on you."
182
+ });
183
+ if (!ok) {
184
+ log('login', 'fail', 'Timed out waiting for sign-in. Re-run setup when ready.');
185
+ return false;
186
+ }
187
+ log('login', 'ok', 'Signed in.');
188
+ return true;
189
+ }
190
+ function step5Skill() {
191
+ if (fs.existsSync(SKILL_DEST)) {
192
+ let detail = `Already at ${SKILL_DEST}`;
193
+ try {
194
+ if (fs.lstatSync(SKILL_DEST_DIR).isSymbolicLink()) {
195
+ detail = `Already at ${SKILL_DEST_DIR} → ${fs.realpathSync(SKILL_DEST_DIR)} (bootstrap-managed, leaving alone)`;
196
+ }
197
+ }
198
+ catch { }
199
+ log('skill', 'ok', detail);
200
+ return true;
201
+ }
202
+ if (!fs.existsSync(SKILL_SRC)) {
203
+ log('skill', 'fail', `Source missing at ${SKILL_SRC}; reclone repo?`);
204
+ return false;
205
+ }
206
+ fs.mkdirSync(SKILL_DEST_DIR, { recursive: true });
207
+ fs.copyFileSync(SKILL_SRC, SKILL_DEST);
208
+ log('skill', 'ok', `Copied to ${SKILL_DEST}`);
209
+ return true;
210
+ }
211
+ function step6Mcp(port) {
212
+ const claudeBin = spawnSync('which', ['claude'], { stdio: 'pipe' });
213
+ if (claudeBin.status !== 0) {
214
+ log('mcp', 'wait', 'claude CLI not on PATH; skipping MCP registration. Install Claude Code to register.');
215
+ return true;
216
+ }
217
+ const list = spawnSync('claude', ['mcp', 'list'], { stdio: 'pipe' });
218
+ const stdout = list.stdout?.toString() || '';
219
+ if (/(\s|^)designer\b/i.test(stdout)) {
220
+ log('mcp', 'ok', 'Already registered.');
221
+ return true;
222
+ }
223
+ const wrapper = path.join(REPO_ROOT, 'bin', 'designer');
224
+ if (!fs.existsSync(wrapper)) {
225
+ log('mcp', 'fail', `Missing wrapper ${wrapper}`);
226
+ return false;
227
+ }
228
+ const cmd = ['mcp', 'add', '--scope', 'user', '--transport', 'stdio', 'designer', '--', 'env', `DESIGNER_CDP=${port}`, wrapper, 'mcp', 'serve'];
229
+ log('mcp', 'wait', `Registering: claude ${cmd.join(' ')}`);
230
+ const reg = spawnSync('claude', cmd, { stdio: 'inherit' });
231
+ if (reg.status !== 0) {
232
+ log('mcp', 'fail', `claude mcp add exited ${reg.status}. Run manually:\n claude ${cmd.join(' ')}`);
233
+ return false;
234
+ }
235
+ log('mcp', 'ok', 'Registered.');
236
+ return true;
237
+ }
238
+ export async function runSetup() {
239
+ console.log(`designer setup — port=${DEFAULT_PORT}, profile=${PROFILE}\n`);
240
+ if (!(await step1NpmInstall()))
241
+ return 1;
242
+ if (!step2AgentBrowser())
243
+ return 1;
244
+ if (!(await step3Chrome(DEFAULT_PORT)))
245
+ return 1;
246
+ if (!(await step4SignIn(DEFAULT_PORT)))
247
+ return 1;
248
+ if (!step5Skill())
249
+ return 1;
250
+ if (!step6Mcp(DEFAULT_PORT))
251
+ return 1;
252
+ console.log('\n✓ designer is ready. Verify: designer doctor');
253
+ console.log(` (or: DESIGNER_CDP=${DEFAULT_PORT} tsx cli.ts doctor)`);
254
+ if (!process.env.DESIGNER_CDP) {
255
+ console.log(`\nTip: export DESIGNER_CDP=${DEFAULT_PORT} in your shell rc if you'll invoke the CLI directly (MCP callers don't need this).`);
256
+ }
257
+ return 0;
258
+ }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ export function writeTastingHtml({ projectDir, variants, outPath, title = 'Tasting' }) {
5
+ const target = outPath || path.join(projectDir, 'tasting.html');
6
+ const html = renderTastingHtml({ variants, title });
7
+ fs.writeFileSync(target, html);
8
+ return target;
9
+ }
10
+ function renderTastingHtml({ variants, title }) {
11
+ const tabs = variants
12
+ .map((v, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-src="${encodeURI(v.file)}">${escapeHtml(v.name)}</button>`)
13
+ .join('');
14
+ const firstSrc = variants[0] ? encodeURI(variants[0].file) : '';
15
+ return `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8" />
19
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20
+ <title>${escapeHtml(title)} — tasting</title>
21
+ <style>
22
+ :root { color-scheme: light dark; }
23
+ * { box-sizing: border-box; }
24
+ html, body { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif; background: #0f0f10; color: #e8e6e3; }
25
+ .bar { position: fixed; top: 0; left: 0; right: 0; height: 44px; display: flex; align-items: center; gap: 0; padding: 0 12px; background: rgba(20,20,22,0.92); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.08); z-index: 9999; }
26
+ .title { font-size: 12px; letter-spacing: 0.05em; text-transform: uppercase; color: #8a8680; margin-right: 18px; }
27
+ .tabs { display: flex; gap: 2px; }
28
+ .tab { appearance: none; border: 0; background: transparent; color: #b4b0a9; padding: 8px 14px; font: inherit; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: color 120ms, border-color 120ms; }
29
+ .tab:hover { color: #e8e6e3; }
30
+ .tab.active { color: #ffffff; border-bottom-color: #ff8a3d; font-weight: 500; }
31
+ .spacer { flex: 1; }
32
+ .notes { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 6px 10px; color: #e8e6e3; font: inherit; font-size: 13px; min-width: 280px; }
33
+ .notes::placeholder { color: #6a665f; }
34
+ .keys { font-size: 11px; color: #6a665f; margin-left: 12px; }
35
+ iframe.stage { position: fixed; top: 44px; left: 0; right: 0; bottom: 0; width: 100%; height: calc(100% - 44px); border: 0; background: #fff; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <nav class="bar" role="toolbar" aria-label="Variant switcher">
40
+ <span class="title">${escapeHtml(title)}</span>
41
+ <div class="tabs">${tabs}</div>
42
+ <span class="keys">[1]–[${variants.length}] to switch</span>
43
+ <span class="spacer"></span>
44
+ <input class="notes" placeholder="Reaction in your own words — saved to tasting-notes.txt" />
45
+ </nav>
46
+ <iframe class="stage" id="stage" src="${firstSrc}" title="variant"></iframe>
47
+ <script>
48
+ const tabs = document.querySelectorAll('.tab');
49
+ const stage = document.getElementById('stage');
50
+ const notes = document.querySelector('.notes');
51
+ const NOTES_KEY = 'designer-tasting-notes:${encodeURIComponent(title)}';
52
+ const saved = localStorage.getItem(NOTES_KEY);
53
+ if (saved) notes.value = saved;
54
+ notes.addEventListener('input', () => localStorage.setItem(NOTES_KEY, notes.value));
55
+
56
+ function activate(tab) {
57
+ tabs.forEach(t => t.classList.remove('active'));
58
+ tab.classList.add('active');
59
+ stage.src = tab.dataset.src;
60
+ }
61
+ function onKey(e) {
62
+ if (e.target === notes) return;
63
+ const n = parseInt(e.key, 10);
64
+ if (!Number.isNaN(n) && n >= 1 && n <= tabs.length) {
65
+ e.preventDefault();
66
+ activate(tabs[n - 1]);
67
+ }
68
+ }
69
+ tabs.forEach(t => t.addEventListener('click', () => activate(t)));
70
+ // Parent-document listener handles keys when focus is on the bar/notes/body.
71
+ document.addEventListener('keydown', onKey);
72
+ // Iframe content captures keys when the user has interacted with the variant.
73
+ // Same-origin (we serve via http.server on the same port) so we can reach in.
74
+ stage.addEventListener('load', () => {
75
+ try { stage.contentDocument && stage.contentDocument.addEventListener('keydown', onKey); } catch {}
76
+ });
77
+ </script>
78
+ </body>
79
+ </html>
80
+ `;
81
+ }
82
+ function escapeHtml(s) {
83
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
84
+ }
85
+ const servers = new Map();
86
+ export async function serveAndOpen(projectDir, { file = 'tasting.html', port } = {}) {
87
+ const chosenPort = port || (await pickFreePort(8765));
88
+ const child = spawn('python3', ['-m', 'http.server', String(chosenPort)], {
89
+ cwd: projectDir,
90
+ stdio: 'ignore',
91
+ detached: true
92
+ });
93
+ child.unref();
94
+ await sleep(500);
95
+ const url = `http://127.0.0.1:${chosenPort}/${encodeURI(file)}`;
96
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
97
+ const pid = child.pid ?? -1;
98
+ servers.set(projectDir, { port: chosenPort, pid });
99
+ return { url, port: chosenPort, pid };
100
+ }
101
+ async function pickFreePort(start) {
102
+ const net = await import('node:net');
103
+ for (let p = start; p < start + 100; p++) {
104
+ const free = await new Promise((resolve) => {
105
+ const s = net.createServer();
106
+ s.once('error', () => resolve(false));
107
+ s.once('listening', () => s.close(() => resolve(true)));
108
+ s.listen(p, '0.0.0.0');
109
+ });
110
+ if (free)
111
+ return p;
112
+ }
113
+ throw new Error(`No free port between ${start} and ${start + 100}`);
114
+ }
115
+ function sleep(ms) {
116
+ return new Promise((r) => setTimeout(r, ms));
117
+ }