claude-design-mode 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/bin/cli.mjs +148 -0
- package/package.json +74 -0
- package/skills/design-mode/SKILL.md +142 -0
- package/src/overlay.d.ts +6 -0
- package/src/overlay.js +2673 -0
- package/src/serve.mjs +70 -0
- package/src/server.d.ts +45 -0
- package/src/server.js +177 -0
- package/src/stamp.js +32 -0
- package/src/vite.d.ts +38 -0
- package/src/vite.js +95 -0
- package/src/watch.mjs +54 -0
package/src/serve.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHandler, newToken, configScript, OVERLAY_ROUTE, SELECTION_ROUTE } from './server.js';
|
|
4
|
+
|
|
5
|
+
const BOOT_ROUTE = '/__design-mode/boot.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Standalone Design Mode server for apps that are not on Vite (Next, Remix,
|
|
9
|
+
* Rails, Django, a static site...). The app adds one script tag in development:
|
|
10
|
+
*
|
|
11
|
+
* <script src="http://localhost:3850/__design-mode/boot.js" referrerpolicy="origin"></script>
|
|
12
|
+
*
|
|
13
|
+
* referrerpolicy="origin" is required: the server identifies the requesting app
|
|
14
|
+
* by its Referer/Origin, and default browser policy strips the Referer on
|
|
15
|
+
* https -> http requests (and some frameworks set same-origin/no-referrer).
|
|
16
|
+
*
|
|
17
|
+
* boot.js carries this boot's token and loads the overlay; selections POST back
|
|
18
|
+
* here and land in the queue dir exactly as with the Vite plugin. There is no
|
|
19
|
+
* JSX stamping in this mode, so sources resolve from React debug stacks or the
|
|
20
|
+
* DOM path plus repo search (the skill handles both).
|
|
21
|
+
*
|
|
22
|
+
* Only pages from --app origins get the token: boot.js is served with a token
|
|
23
|
+
* only when the request's Referer/Origin is an allowed origin, and the
|
|
24
|
+
* selection endpoint accepts POSTs only from those origins.
|
|
25
|
+
*/
|
|
26
|
+
export function serve({ port = 3850, apps = [], queueDir, root = process.cwd(), log = console.log } = {}) {
|
|
27
|
+
const token = newToken();
|
|
28
|
+
const apps_ = apps.map((a) => a.replace(/\/$/, ''));
|
|
29
|
+
const dir = path.resolve(root, queueDir || path.join('.design-mode', 'queue'));
|
|
30
|
+
const handle = createHandler({ token, queueDir: dir, root, allowOrigins: apps_, cors: true, log });
|
|
31
|
+
|
|
32
|
+
const referrerOrigin = (req) => {
|
|
33
|
+
const ref = req.headers.origin || req.headers.referer;
|
|
34
|
+
if (!ref) return null;
|
|
35
|
+
try { return new URL(ref).origin; } catch { return null; }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const server = http.createServer((req, res) => {
|
|
39
|
+
const url = (req.url || '').split('?')[0];
|
|
40
|
+
if (url === BOOT_ROUTE && req.method === 'GET') {
|
|
41
|
+
const from = referrerOrigin(req);
|
|
42
|
+
const allowed = from && apps_.includes(from);
|
|
43
|
+
res.setHeader('content-type', 'application/javascript');
|
|
44
|
+
res.setHeader('cache-control', 'no-store');
|
|
45
|
+
if (!allowed) {
|
|
46
|
+
const who = from ? `${JSON.stringify(from).slice(1, -1)} is not an allowed app origin; start the server with --app <origin>` : 'the request carried no Referer or Origin, so the server cannot tell which app is asking; add referrerpolicy="origin" to the boot.js script tag (and check --app <origin>)';
|
|
47
|
+
res.end(`console.warn(${JSON.stringify(`[design-mode] ${who}`)});`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const self = `http://localhost:${port}`;
|
|
51
|
+
res.end([
|
|
52
|
+
configScript({ endpoint: `${self}${SELECTION_ROUTE}`, token }) + ';',
|
|
53
|
+
`(function(){var s=document.createElement('script');s.src=${JSON.stringify(self + OVERLAY_ROUTE)};s.defer=true;document.head.appendChild(s);})();`,
|
|
54
|
+
].join('\n'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (handle(req, res)) return;
|
|
58
|
+
res.statusCode = 404;
|
|
59
|
+
res.end('not found');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
server.listen(port, '127.0.0.1', () => {
|
|
63
|
+
log(`[design-mode] serving on http://localhost:${port}`);
|
|
64
|
+
log(`[design-mode] queue: ${path.relative(root, dir) || '.'}`);
|
|
65
|
+
if (apps_.length) log(`[design-mode] allowed apps: ${apps_.join(', ')}`);
|
|
66
|
+
else log('[design-mode] no --app origins given: pages cannot load the overlay until you pass --app http://localhost:<your-port>');
|
|
67
|
+
log(`[design-mode] add to your app (dev only): <script src="http://localhost:${port}${BOOT_ROUTE}" referrerpolicy="origin"></script>`);
|
|
68
|
+
});
|
|
69
|
+
return server;
|
|
70
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export declare const OVERLAY_ROUTE: '/__design-mode/overlay.js';
|
|
4
|
+
export declare const SELECTION_ROUTE: '/__design-mode/selection';
|
|
5
|
+
export declare const HEALTH_ROUTE: '/__design-mode/health';
|
|
6
|
+
export declare const MAX_BODY: number;
|
|
7
|
+
|
|
8
|
+
/** Absolute filesystem path of the bundled overlay script. */
|
|
9
|
+
export declare function overlayPath(): string;
|
|
10
|
+
/** Absolute filesystem path of the bundled session skill directory. */
|
|
11
|
+
export declare function skillDir(): string;
|
|
12
|
+
/** A fresh per-boot token for the selection endpoint. */
|
|
13
|
+
export declare function newToken(): string;
|
|
14
|
+
|
|
15
|
+
export interface CreateHandlerOptions {
|
|
16
|
+
/** Per-boot secret the overlay sends back (see `newToken`). */
|
|
17
|
+
token: string;
|
|
18
|
+
/** Absolute directory where payloads are written as JSON files. */
|
|
19
|
+
queueDir: string;
|
|
20
|
+
/** Project root, used only to print relative paths. Default: `process.cwd()`. */
|
|
21
|
+
root?: string;
|
|
22
|
+
/** Extra Host names besides localhost/127.0.0.1/::1/*.localhost. */
|
|
23
|
+
allowedHosts?: string[];
|
|
24
|
+
/** Extra Origins to accept (the standalone server needs the app's origin). */
|
|
25
|
+
allowOrigins?: string[];
|
|
26
|
+
/** Answer preflights and echo an allowed Origin back (cross-origin overlay only). */
|
|
27
|
+
cors?: boolean;
|
|
28
|
+
log?: (msg: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The Design Mode HTTP surface, framework-free. Returns a connect-style
|
|
33
|
+
* handler that returns true when it handled the request.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createHandler(
|
|
36
|
+
opts: CreateHandlerOptions
|
|
37
|
+
): (req: IncomingMessage, res: ServerResponse) => boolean;
|
|
38
|
+
|
|
39
|
+
/** Serialize the plugin's `tokens` option (RegExp or strings) for the browser. */
|
|
40
|
+
export declare function serializeTokenHints(
|
|
41
|
+
tokens?: Record<string, RegExp | string | null | undefined>
|
|
42
|
+
): Record<string, string>;
|
|
43
|
+
|
|
44
|
+
/** The inline config the page needs before the overlay script runs. */
|
|
45
|
+
export declare function configScript(config: Record<string, unknown>): string;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const OVERLAY_ROUTE = '/__design-mode/overlay.js';
|
|
7
|
+
export const SELECTION_ROUTE = '/__design-mode/selection';
|
|
8
|
+
export const HEALTH_ROUTE = '/__design-mode/health';
|
|
9
|
+
export const MAX_BODY = 512 * 1024;
|
|
10
|
+
|
|
11
|
+
// fileURLToPath, never URL.pathname: pathname is percent-encoded (a space is %20)
|
|
12
|
+
// and carries a leading slash before the drive letter on Windows.
|
|
13
|
+
export const overlayPath = () => fileURLToPath(new URL('./overlay.js', import.meta.url));
|
|
14
|
+
export const skillDir = () => fileURLToPath(new URL('../skills/design-mode/', import.meta.url));
|
|
15
|
+
export const newToken = () => randomBytes(16).toString('hex');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The Design Mode HTTP surface, framework-free so the Vite plugin and the
|
|
19
|
+
* standalone `claude-design-mode serve` command share one implementation.
|
|
20
|
+
*
|
|
21
|
+
* Returns a connect-style handler: (req, res) => boolean (true when handled).
|
|
22
|
+
*
|
|
23
|
+
* Security model for the selection endpoint: a localhost URL that ultimately
|
|
24
|
+
* feeds an agent is a remote prompt-injection surface, so every request must
|
|
25
|
+
* carry a per-boot random token in a custom header (which also forces a CORS
|
|
26
|
+
* preflight that cross-origin pages fail), any Origin header must match an
|
|
27
|
+
* allowed origin (the server's own origin by default), and the Host header must
|
|
28
|
+
* be a local name (so a DNS rebinding page cannot become same-origin with the
|
|
29
|
+
* server and read the token out of the HTML). Token comparison is constant-time.
|
|
30
|
+
*
|
|
31
|
+
* Options:
|
|
32
|
+
* token per-boot secret the overlay sends back (newToken())
|
|
33
|
+
* queueDir absolute dir where payloads are written as JSON files
|
|
34
|
+
* root project root, used only to print relative paths
|
|
35
|
+
* allowedHosts extra Host names besides localhost/127.0.0.1/::1/*.localhost
|
|
36
|
+
* allowOrigins extra Origins to accept (the standalone server needs the app's origin)
|
|
37
|
+
* cors when true, answer preflights and echo an allowed Origin back
|
|
38
|
+
* (needed only when the overlay lives on a different origin)
|
|
39
|
+
* log (msg) => void
|
|
40
|
+
*/
|
|
41
|
+
export function createHandler(opts) {
|
|
42
|
+
const { token, queueDir, root = process.cwd(), log = () => {} } = opts;
|
|
43
|
+
const extraHosts = opts.allowedHosts || [];
|
|
44
|
+
const extraOrigins = opts.allowOrigins || [];
|
|
45
|
+
const cors = !!opts.cors;
|
|
46
|
+
let counter = 0;
|
|
47
|
+
fs.mkdirSync(queueDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const selfOrigins = (req) => {
|
|
50
|
+
const host = req.headers.host;
|
|
51
|
+
return host ? [`http://${host}`, `https://${host}`] : [];
|
|
52
|
+
};
|
|
53
|
+
const originAllowed = (req, origin) => selfOrigins(req).includes(origin) || extraOrigins.includes(origin);
|
|
54
|
+
const isLocalHost = (host) => {
|
|
55
|
+
if (!host) return false;
|
|
56
|
+
const name = host.replace(/:\d+$/, '').replace(/^\[(.*)\]$/, '$1');
|
|
57
|
+
return name === 'localhost' || name.endsWith('.localhost') || name === '127.0.0.1'
|
|
58
|
+
|| name === '::1' || extraHosts.includes(name);
|
|
59
|
+
};
|
|
60
|
+
const tokenMatches = (sent) => {
|
|
61
|
+
const a = Buffer.from(String(sent || ''));
|
|
62
|
+
const b = Buffer.from(token);
|
|
63
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
64
|
+
};
|
|
65
|
+
const corsHeaders = (req, res) => {
|
|
66
|
+
const origin = req.headers.origin;
|
|
67
|
+
if (!cors || !origin || !originAllowed(req, origin)) return;
|
|
68
|
+
res.setHeader('access-control-allow-origin', origin);
|
|
69
|
+
res.setHeader('vary', 'Origin');
|
|
70
|
+
res.setHeader('access-control-allow-headers', 'content-type, x-design-mode-token');
|
|
71
|
+
res.setHeader('access-control-allow-methods', 'POST, OPTIONS');
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return function handle(req, res) {
|
|
75
|
+
const url = (req.url || '').split('?')[0];
|
|
76
|
+
|
|
77
|
+
if (url === OVERLAY_ROUTE && req.method === 'GET') {
|
|
78
|
+
res.setHeader('content-type', 'application/javascript');
|
|
79
|
+
res.setHeader('cache-control', 'no-store');
|
|
80
|
+
corsHeaders(req, res);
|
|
81
|
+
res.end(fs.readFileSync(overlayPath(), 'utf8'));
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (url === HEALTH_ROUTE && req.method === 'GET') {
|
|
86
|
+
const pending = fs.existsSync(queueDir)
|
|
87
|
+
? fs.readdirSync(queueDir).filter((f) => f.endsWith('.json')).length
|
|
88
|
+
: 0;
|
|
89
|
+
res.setHeader('content-type', 'application/json');
|
|
90
|
+
res.end(JSON.stringify({ ok: true, pending, queueDir }));
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (url !== SELECTION_ROUTE) return false;
|
|
95
|
+
|
|
96
|
+
if (req.method === 'OPTIONS') {
|
|
97
|
+
// Same-origin (Vite plugin) never preflights in practice; the standalone
|
|
98
|
+
// server does, and only echoes an allowed origin. The token and Origin
|
|
99
|
+
// checks below hold on their own regardless.
|
|
100
|
+
corsHeaders(req, res);
|
|
101
|
+
res.statusCode = 204;
|
|
102
|
+
res.end();
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return true; }
|
|
106
|
+
// set CORS headers before any check: the overlay must be able to READ a 403
|
|
107
|
+
// (to say "reload the page") even when the token rotated; corsHeaders only
|
|
108
|
+
// ever echoes an allowed origin, so this widens nothing
|
|
109
|
+
corsHeaders(req, res);
|
|
110
|
+
|
|
111
|
+
if (!isLocalHost(req.headers.host)) {
|
|
112
|
+
res.statusCode = 403;
|
|
113
|
+
res.end('{"error":"host not allowed"}');
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
const origin = req.headers.origin;
|
|
117
|
+
if (origin && !originAllowed(req, origin)) {
|
|
118
|
+
res.statusCode = 403;
|
|
119
|
+
res.end('{"error":"origin not allowed"}');
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (!tokenMatches(req.headers['x-design-mode-token'])) {
|
|
123
|
+
res.statusCode = 403;
|
|
124
|
+
res.end('{"error":"bad token"}');
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (!String(req.headers['content-type'] || '').includes('application/json')) {
|
|
128
|
+
res.statusCode = 415;
|
|
129
|
+
res.end('{"error":"json only"}');
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let size = 0;
|
|
134
|
+
const chunks = [];
|
|
135
|
+
req.on('data', (c) => {
|
|
136
|
+
size += c.length;
|
|
137
|
+
if (size > MAX_BODY) { res.statusCode = 413; res.end(); req.destroy(); return; }
|
|
138
|
+
chunks.push(c);
|
|
139
|
+
});
|
|
140
|
+
req.on('end', () => {
|
|
141
|
+
let payload;
|
|
142
|
+
try {
|
|
143
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
144
|
+
} catch {
|
|
145
|
+
res.statusCode = 400;
|
|
146
|
+
res.end('{"error":"invalid json"}');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!payload || typeof payload.instruction !== 'string' || typeof payload.seq !== 'number') {
|
|
150
|
+
res.statusCode = 422;
|
|
151
|
+
res.end('{"error":"missing instruction/seq"}');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// collision-proof, order-stable name; atomic write so the watcher
|
|
155
|
+
// never wakes on a half-written file
|
|
156
|
+
counter += 1;
|
|
157
|
+
fs.mkdirSync(queueDir, { recursive: true });
|
|
158
|
+
const file = path.join(queueDir, `${Date.now()}-${String(counter).padStart(6, '0')}-${randomBytes(3).toString('hex')}.json`);
|
|
159
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(payload, null, 2));
|
|
160
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
161
|
+
log(`[design-mode] ${payload.kind || 'selection'} #${payload.seq} -> ${path.relative(root, file)}`);
|
|
162
|
+
res.setHeader('content-type', 'application/json');
|
|
163
|
+
res.end(JSON.stringify({ ok: true, file: path.relative(root, file) }));
|
|
164
|
+
});
|
|
165
|
+
return true;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Serialize the plugin's `tokens` option (RegExp or strings) for the browser. */
|
|
170
|
+
export const serializeTokenHints = (tokens) => Object.fromEntries(
|
|
171
|
+
Object.entries(tokens || {})
|
|
172
|
+
.filter(([, v]) => v)
|
|
173
|
+
.map(([k, v]) => [k, v instanceof RegExp ? v.source : String(v)]),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/** The inline config the page needs before the overlay script runs. */
|
|
177
|
+
export const configScript = (config) => `window.__CDM_CONFIG=${JSON.stringify(config)}`;
|
package/src/stamp.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Babel visitor that stamps data-claude-source="relpath:line:col" onto JSX host
|
|
3
|
+
* elements (lowercase tags only). Component call sites are deliberately left
|
|
4
|
+
* unstamped: passing an unknown data attribute as a prop would either get
|
|
5
|
+
* forwarded to an unpredictable host (spread props, Radix asChild) or dropped;
|
|
6
|
+
* component identity comes from the fiber owner chain instead.
|
|
7
|
+
*/
|
|
8
|
+
export default function stampPlugin({ types: t }) {
|
|
9
|
+
return {
|
|
10
|
+
name: 'design-mode-stamp',
|
|
11
|
+
visitor: {
|
|
12
|
+
JSXOpeningElement(path, state) {
|
|
13
|
+
const name = path.node.name;
|
|
14
|
+
if (name.type !== 'JSXIdentifier') return; // <Foo.Bar>, namespaced: skip
|
|
15
|
+
const first = name.name[0];
|
|
16
|
+
if (first !== first.toLowerCase()) return; // components: skip
|
|
17
|
+
if (!path.node.loc) return;
|
|
18
|
+
const exists = path.node.attributes.some(
|
|
19
|
+
(a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'data-claude-source'
|
|
20
|
+
);
|
|
21
|
+
if (exists) return;
|
|
22
|
+
const { line, column } = path.node.loc.start;
|
|
23
|
+
path.node.attributes.push(
|
|
24
|
+
t.jsxAttribute(
|
|
25
|
+
t.jsxIdentifier('data-claude-source'),
|
|
26
|
+
t.stringLiteral(`${state.opts.relFile}:${line}:${column + 1}`)
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/vite.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
export interface DesignModeOptions {
|
|
4
|
+
/** Where selection payloads are written. Default: `<vite-root>/.design-mode/queue`. */
|
|
5
|
+
queueDir?: string;
|
|
6
|
+
/** Extra Host names to accept besides localhost/127.0.0.1/::1/*.localhost. */
|
|
7
|
+
allowedHosts?: string[];
|
|
8
|
+
/** Set false to skip JSX stamping (non-React apps). */
|
|
9
|
+
stamp?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Optional map of design-token families to the project's own custom-property
|
|
12
|
+
* patterns, e.g. `{ color: /^--brand-/, spacing: /^--space-/ }`. The overlay
|
|
13
|
+
* discovers tokens from the page's CSS on its own; these take precedence
|
|
14
|
+
* when a project's names are unusual.
|
|
15
|
+
*/
|
|
16
|
+
tokens?: Partial<
|
|
17
|
+
Record<
|
|
18
|
+
| 'color'
|
|
19
|
+
| 'fontFamily'
|
|
20
|
+
| 'fontWeight'
|
|
21
|
+
| 'fontSize'
|
|
22
|
+
| 'lineHeight'
|
|
23
|
+
| 'tracking'
|
|
24
|
+
| 'radius'
|
|
25
|
+
| 'shadow'
|
|
26
|
+
| 'spacing',
|
|
27
|
+
RegExp | string
|
|
28
|
+
>
|
|
29
|
+
>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Design Mode Vite plugin. Dev-serve only (and never inside Vitest); production
|
|
34
|
+
* builds are untouched. Stamps JSX host elements with
|
|
35
|
+
* `data-claude-source="relpath:line:col"`, serves the inspector overlay from
|
|
36
|
+
* the app's own origin, and receives selections on a token-checked endpoint.
|
|
37
|
+
*/
|
|
38
|
+
export default function designMode(options?: DesignModeOptions): Plugin;
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { transformSync } from '@babel/core';
|
|
3
|
+
import stampPlugin from './stamp.js';
|
|
4
|
+
import { createHandler, newToken, serializeTokenHints, configScript, OVERLAY_ROUTE, SELECTION_ROUTE } from './server.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Design Mode Vite plugin. Dev-serve only; never touches production builds.
|
|
8
|
+
*
|
|
9
|
+
* - stamps data-claude-source="relpath:line:col" on JSX host elements
|
|
10
|
+
* - serves the inspector overlay from the app's own origin
|
|
11
|
+
* - receives selection payloads on a token-checked endpoint and writes them
|
|
12
|
+
* to .design-mode/queue/ for the agent (`claude-design-mode wait` blocks on it)
|
|
13
|
+
*
|
|
14
|
+
* Options:
|
|
15
|
+
* queueDir where selections are written; default <vite-root>/.design-mode/queue
|
|
16
|
+
* allowedHosts extra Host names to accept besides localhost/127.0.0.1/::1/*.localhost
|
|
17
|
+
* tokens optional map of design-token families to the project's own custom-property
|
|
18
|
+
* patterns, e.g. { color: /^--brand-/, spacing: /^--space-/ }. The overlay
|
|
19
|
+
* discovers tokens from the page's CSS on its own (naming conventions, then
|
|
20
|
+
* value type); these patterns take precedence when a project's names are
|
|
21
|
+
* unusual. Families: color, fontFamily, fontWeight, fontSize, lineHeight,
|
|
22
|
+
* tracking, radius, shadow, spacing. Values may be RegExp or regex-source strings.
|
|
23
|
+
* stamp set false to skip JSX stamping (non-React apps); the overlay then resolves
|
|
24
|
+
* sources from React debug stacks or the DOM path instead
|
|
25
|
+
*/
|
|
26
|
+
export default function designMode(options = {}) {
|
|
27
|
+
const token = newToken();
|
|
28
|
+
const tokenHints = serializeTokenHints(options.tokens);
|
|
29
|
+
let root = process.cwd();
|
|
30
|
+
let queueDir = '';
|
|
31
|
+
const warned = new Set();
|
|
32
|
+
|
|
33
|
+
const stamp = (code, id) => {
|
|
34
|
+
if (options.stamp === false) return null;
|
|
35
|
+
const [file] = id.split('?');
|
|
36
|
+
if (!/\.[jt]sx$/.test(file) || file.includes('node_modules')) return null;
|
|
37
|
+
const relFile = path.relative(root, file).split(path.sep).join('/');
|
|
38
|
+
try {
|
|
39
|
+
const result = transformSync(code, {
|
|
40
|
+
filename: file,
|
|
41
|
+
configFile: false,
|
|
42
|
+
babelrc: false,
|
|
43
|
+
sourceMaps: true,
|
|
44
|
+
retainLines: true,
|
|
45
|
+
// decorators: esbuild accepts them, so the stamping parse must too
|
|
46
|
+
parserOpts: { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'] },
|
|
47
|
+
plugins: [[stampPlugin, { relFile }]],
|
|
48
|
+
});
|
|
49
|
+
return result ? { code: result.code, map: result.map } : null;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// stamping is an aid, never a gate: an unparseable file loads unstamped
|
|
52
|
+
if (!warned.has(relFile)) {
|
|
53
|
+
warned.add(relFile);
|
|
54
|
+
console.warn(`[design-mode] could not stamp ${relFile} (${e.message ? e.message.split('\n')[0] : e}); serving it unstamped`);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name: 'design-mode',
|
|
62
|
+
// dev serve only, and never inside Vitest: stamping test renders would
|
|
63
|
+
// break DOM snapshots, and tests have no use for the endpoint
|
|
64
|
+
apply: (_config, env) => env.command === 'serve' && env.mode !== 'test' && !process.env.VITEST,
|
|
65
|
+
enforce: 'pre', // must run before @vitejs/plugin-react compiles JSX away
|
|
66
|
+
|
|
67
|
+
configResolved(config) {
|
|
68
|
+
root = config.root;
|
|
69
|
+
queueDir = path.resolve(root, options.queueDir || path.join('.design-mode', 'queue'));
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
transform(code, id) {
|
|
73
|
+
return stamp(code, id);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
transformIndexHtml() {
|
|
77
|
+
return [
|
|
78
|
+
{ tag: 'script', injectTo: 'head', children: configScript({ endpoint: SELECTION_ROUTE, token, tokens: tokenHints }) },
|
|
79
|
+
{ tag: 'script', injectTo: 'body', attrs: { src: OVERLAY_ROUTE, defer: true } },
|
|
80
|
+
];
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
configureServer(server) {
|
|
84
|
+
const handle = createHandler({
|
|
85
|
+
token,
|
|
86
|
+
queueDir,
|
|
87
|
+
root,
|
|
88
|
+
allowedHosts: options.allowedHosts,
|
|
89
|
+
log: (msg) => server.config.logger.info(msg, { timestamp: true }),
|
|
90
|
+
});
|
|
91
|
+
server.middlewares.use((req, res, next) => { if (!handle(req, res)) next(); });
|
|
92
|
+
server.config.logger.info(`[design-mode] queue: ${path.relative(root, queueDir) || '.'} (toggle with Cmd+D in the page)`, { timestamp: true });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/watch.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wake watcher for agent sessions. Blocks until a selection payload lands in
|
|
3
|
+
* the queue dir, prints the path(s), and exits 0. The agent runs this as a
|
|
4
|
+
* background process; the harness re-invokes the agent when it exits.
|
|
5
|
+
*
|
|
6
|
+
* claude-design-mode wait [queueDir] [--timeout <minutes>]
|
|
7
|
+
*
|
|
8
|
+
* Exit codes: 0 selection available · 2 timed out (re-arm me) · 1 error
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export function wait({ dir, timeoutMin = 15, out = console.log, err = console.error, exit = process.exit } = {}) {
|
|
14
|
+
const queue = path.resolve(dir || path.join(process.cwd(), '.design-mode', 'queue'));
|
|
15
|
+
fs.mkdirSync(queue, { recursive: true });
|
|
16
|
+
|
|
17
|
+
const pending = () => {
|
|
18
|
+
try {
|
|
19
|
+
return fs.readdirSync(queue).filter((f) => f.endsWith('.json')).sort();
|
|
20
|
+
} catch {
|
|
21
|
+
fs.mkdirSync(queue, { recursive: true }); // dir was removed (git clean etc.); keep waiting
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const finish = (files) => {
|
|
27
|
+
for (const f of files) out(path.join(queue, f));
|
|
28
|
+
exit(0);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const existing = pending();
|
|
32
|
+
if (existing.length) return finish(existing);
|
|
33
|
+
|
|
34
|
+
const check = () => {
|
|
35
|
+
const files = pending();
|
|
36
|
+
if (files.length) finish(files);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
fs.watch(queue, check);
|
|
41
|
+
} catch {
|
|
42
|
+
/* fs.watch can be flaky; the poll below still covers us */
|
|
43
|
+
}
|
|
44
|
+
const poll = setInterval(check, 2000);
|
|
45
|
+
poll.unref?.();
|
|
46
|
+
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
err(`no selection within ${timeoutMin}m`);
|
|
49
|
+
exit(2);
|
|
50
|
+
}, timeoutMin * 60 * 1000);
|
|
51
|
+
|
|
52
|
+
// keep the process alive while waiting
|
|
53
|
+
setInterval(() => {}, 1 << 30);
|
|
54
|
+
}
|