paintress 0.5.2 → 0.7.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,3462 @@
1
+ import { A as genId, B as presetGroupsOf, C as update, E as saveDoc, F as splitFontStack, H as DocumentSchema, I as textFontGaps, L as PROP_TOKEN_KIND, M as isFontSourceKind, N as isGenericFamily, O as validateDoc, P as planFonts, R as isNumericPresetValue, S as remove, T as loadDoc, U as PEN_DOC_VERSION, b as move, g as batch, j as fontRegistryOf, k as collectIds, m as expandRefs, rn as VERSION, t as renderDocument, v as get, w as FileChangedError, x as query, y as insert, z as isPresetType } from "./render-DWs1iQ0v.js";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { pathToFileURL } from "node:url";
5
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
6
+ import { z } from "zod";
7
+ import { randomUUID } from "node:crypto";
8
+ import { chromium } from "playwright-core";
9
+ import { connect } from "node:net";
10
+ import { spawn } from "node:child_process";
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ // \0rolldown/runtime.js
14
+ var __defProp = Object.defineProperty;
15
+ var __exportAll = (all, flags) => {
16
+ let target = Object.setPrototypeOf({}, null);
17
+ for (var name in all) __defProp(target, name, {
18
+ get: all[name],
19
+ enumerable: true
20
+ });
21
+ if (!(flags & 1)) __defProp(target, Symbol.toStringTag, { value: "Module" });
22
+ return flags & 2 ? target : Object.preventExtensions(target);
23
+ };
24
+ // src/doc/create.ts
25
+ function createDoc(opts = {}) {
26
+ const boardId = opts.boardId ?? genId(/* @__PURE__ */ new Set());
27
+ const board = {
28
+ type: "frame",
29
+ id: boardId,
30
+ name: opts.name ?? "Board",
31
+ x: 0,
32
+ y: 0,
33
+ width: opts.width ?? 900,
34
+ height: opts.height ?? 600,
35
+ fill: opts.fill ?? "#FFFFFF"
36
+ };
37
+ const layout = opts.layout ?? "vertical";
38
+ if (layout !== "none") {
39
+ board["layout"] = layout;
40
+ board["gap"] = opts.gap ?? 0;
41
+ board["padding"] = opts.padding ?? 0;
42
+ }
43
+ return {
44
+ doc: DocumentSchema.parse({
45
+ version: PEN_DOC_VERSION,
46
+ fileToken: opts.fileToken ?? randomUUID(),
47
+ variables: {},
48
+ children: [board]
49
+ }),
50
+ boardId
51
+ };
52
+ }
53
+ // src/doc/audit.ts
54
+ const HEX_RE$1 = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
55
+ function isRecord$2(v) {
56
+ return typeof v === "object" && v !== null && !Array.isArray(v);
57
+ }
58
+ function countOf(findings) {
59
+ const counts = {
60
+ literal: 0,
61
+ "orphan-token": 0,
62
+ "unused-preset": 0,
63
+ "dangling-preset": 0,
64
+ "preset-target": 0,
65
+ "preset-literal": 0,
66
+ "font-missing": 0,
67
+ "font-unregistered": 0
68
+ };
69
+ for (const f of findings) counts[f.kind] += 1;
70
+ return counts;
71
+ }
72
+ function summarize(report) {
73
+ const c = report.counts;
74
+ return [
75
+ `越界裸值 ${c.literal}`,
76
+ `孤儿 token ${c["orphan-token"]}`,
77
+ `未使用的 preset 组 ${c["unused-preset"]}`,
78
+ `悬空 preset 引用 ${c["dangling-preset"] + c["preset-target"]}`,
79
+ `preset 里的裸值 ${c["preset-literal"]}`,
80
+ `缺字体 ${c["font-missing"]}`,
81
+ `未登记字体 ${c["font-unregistered"]}`
82
+ ].join(" · ");
83
+ }
84
+ function auditDoc(doc, input) {
85
+ const findings = [];
86
+ const colorTokens = /* @__PURE__ */ new Map();
87
+ const numberTokens = /* @__PURE__ */ new Map();
88
+ const familyTokens = /* @__PURE__ */ new Map();
89
+ for (const t of input.tokens) if (t.type === "color") colorTokens.set(t.value.toUpperCase(), t.key);
90
+ else if (t.type === "font") familyTokens.set(t.value, t.key);
91
+ else {
92
+ const n = Number(t.value);
93
+ if (Number.isFinite(n)) numberTokens.set(`${t.type}:${n}`, t.key);
94
+ }
95
+ const usedVars = /* @__PURE__ */ new Set();
96
+ const usedGroups = /* @__PURE__ */ new Set();
97
+ const walk = (nodes, inheritedAllowLiteral) => {
98
+ for (const raw of nodes) {
99
+ if (!isRecord$2(raw)) continue;
100
+ const node = raw;
101
+ const id = typeof node["id"] === "string" ? node["id"] : "?";
102
+ if (node["type"] === "text") {
103
+ const ff = node["fontFamily"];
104
+ if (typeof ff !== "string" || ff.trim() === "") findings.push({
105
+ kind: "font-missing",
106
+ where: id,
107
+ message: "text 没有 fontFamily,正在用默认字体"
108
+ });
109
+ else if (!ff.startsWith("$") && input.fonts !== void 0) {
110
+ for (const family of splitFontStack(ff)) if (!isGenericFamily(family) && input.fonts.families[family] === void 0) findings.push({
111
+ kind: "font-unregistered",
112
+ where: id,
113
+ message: `字体未登记:${family}`
114
+ });
115
+ }
116
+ }
117
+ const meta = node["metadata"];
118
+ const allowLiteral = inheritedAllowLiteral || isRecord$2(meta) && (meta["allowLiteral"] === true || meta["allowLiteral"] === "true");
119
+ const presetName = node["preset"];
120
+ if (typeof presetName === "string" && presetName !== "") {
121
+ const group = input.groups[presetName];
122
+ if (group === void 0) findings.push({
123
+ kind: "dangling-preset",
124
+ where: id,
125
+ message: `preset "${presetName}" 不存在`
126
+ });
127
+ else if (group.target !== node["type"]) findings.push({
128
+ kind: "preset-target",
129
+ where: id,
130
+ message: `preset "${presetName}" 适用于 ${group.target},这里是 ${String(node["type"])}`
131
+ });
132
+ else usedGroups.add(presetName);
133
+ }
134
+ if (!allowLiteral) {
135
+ const suggestColor = (value, path) => {
136
+ if (typeof value === "string") {
137
+ if (!HEX_RE$1.test(value)) return;
138
+ const hit = colorTokens.get(value.toUpperCase());
139
+ if (hit !== void 0) findings.push({
140
+ kind: "literal",
141
+ where: id,
142
+ message: `${path} 写了裸色值 ${value}`,
143
+ suggestion: `$${hit}`
144
+ });
145
+ return;
146
+ }
147
+ if (Array.isArray(value)) {
148
+ value.forEach((entry, index) => suggestColor(entry, `${path}[${index}]`));
149
+ return;
150
+ }
151
+ if (isRecord$2(value)) for (const [key, entry] of Object.entries(value)) suggestColor(entry, `${path}.${key}`);
152
+ };
153
+ const suggestNumber = (value, path, kind) => {
154
+ if (typeof value === "number" && Number.isFinite(value)) {
155
+ const hit = numberTokens.get(`${kind}:${value}`);
156
+ if (hit !== void 0) findings.push({
157
+ kind: "literal",
158
+ where: id,
159
+ message: `${path} 写了裸值 ${value}`,
160
+ suggestion: `$${hit}`
161
+ });
162
+ return;
163
+ }
164
+ if (Array.isArray(value)) {
165
+ value.forEach((entry, index) => suggestNumber(entry, `${path}[${index}]`, kind));
166
+ return;
167
+ }
168
+ if (isRecord$2(value)) for (const [key, entry] of Object.entries(value)) suggestNumber(entry, `${path}.${key}`, kind);
169
+ };
170
+ const suggestString = (value, path) => {
171
+ if (typeof value !== "string" || value.startsWith("$")) return;
172
+ const hit = familyTokens.get(value);
173
+ if (hit !== void 0) findings.push({
174
+ kind: "literal",
175
+ where: id,
176
+ message: `${path} 写了字体族 "${value}"`,
177
+ suggestion: `$${hit}`
178
+ });
179
+ };
180
+ for (const [prop, value] of Object.entries(node)) {
181
+ if (prop === "children") continue;
182
+ const kind = PROP_TOKEN_KIND[prop];
183
+ if (kind === "color") suggestColor(value, prop);
184
+ else if (kind === "font") suggestString(value, prop);
185
+ else if (kind === "radius" || kind === "spacing" || kind === "fontSize") suggestNumber(value, prop, kind);
186
+ }
187
+ }
188
+ const scan = (v) => {
189
+ if (typeof v === "string" && v.startsWith("$") && v.length > 1) usedVars.add(v.slice(1));
190
+ else if (Array.isArray(v)) v.forEach(scan);
191
+ else if (isRecord$2(v)) Object.values(v).forEach(scan);
192
+ };
193
+ scan(node);
194
+ const kids = node["children"];
195
+ if (Array.isArray(kids)) walk(kids, allowLiteral);
196
+ }
197
+ };
198
+ walk(doc.children, false);
199
+ for (const [name, group] of Object.entries(input.groups)) {
200
+ for (const [prop, value] of Object.entries(group.props)) if (!value.startsWith("$")) findings.push({
201
+ kind: "preset-literal",
202
+ where: `<preset:${name}>`,
203
+ message: `${prop} 写的是字面量 "${value}",应当引用 $token`
204
+ });
205
+ else usedVars.add(value.slice(1));
206
+ if (!usedGroups.has(name)) findings.push({
207
+ kind: "unused-preset",
208
+ where: `<preset:${name}>`,
209
+ message: `没有任何节点引用 preset "${name}"`
210
+ });
211
+ }
212
+ for (const t of input.tokens) if (!usedVars.has(t.key)) findings.push({
213
+ kind: "orphan-token",
214
+ where: `<token:${t.key}>`,
215
+ message: `token "$${t.key}" 从未被引用`
216
+ });
217
+ const report = {
218
+ findings,
219
+ counts: countOf(findings)
220
+ };
221
+ return {
222
+ ...report,
223
+ summary: summarize(report)
224
+ };
225
+ }
226
+ /** MCP / CLI 入口用:从设计稿 + 项目文件凑出审计输入 */
227
+ function auditInputFrom(doc, tokens, presetDoc) {
228
+ const projectFonts = fontRegistryOf(presetDoc);
229
+ const ownFonts = fontRegistryOf(doc);
230
+ return {
231
+ tokens,
232
+ groups: {
233
+ ...presetGroupsOf(presetDoc),
234
+ ...presetGroupsOf(doc)
235
+ },
236
+ fonts: {
237
+ ...projectFonts,
238
+ families: {
239
+ ...projectFonts.families,
240
+ ...ownFonts.families
241
+ }
242
+ }
243
+ };
244
+ }
245
+ // src/doc/project.ts
246
+ /**
247
+ * 项目级预设与组件库(规格见 specs/project-presets.md、specs/components.md)。
248
+ *
249
+ * 约定:项目根 = 从设计稿所在目录向上找、最近的含 `__preset.ptr` / `__components.ptr` 的目录。
250
+ * 两个文件都是**普通设计稿**:预设值走原生 `variables`,组件走 `reusable`;项目级语义只放在
251
+ * `x-paintress` 扩展位里。因此 pen 读取器看到它们仍是合法文档。
252
+ */
253
+ const PRESET_FILE = "__preset.ptr";
254
+ const COMPONENTS_FILE = "__components.ptr";
255
+ /** 拼出设计稿旁边那份笔记的绝对路径(只认 .ptr / .pen,其余扩展名返回 null) */
256
+ function notePathFor(designAbsPath) {
257
+ if (!/\.(ptr|pen)$/i.test(designAbsPath)) return null;
258
+ return designAbsPath.replace(/\.(ptr|pen)$/i, ".md");
259
+ }
260
+ function isRecord$1(v) {
261
+ return typeof v === "object" && v !== null && !Array.isArray(v);
262
+ }
263
+ /**
264
+ * 已注册的项目根(`paintress web` / `paintress watch` 的目录)。
265
+ *
266
+ * **注册根优先于「向上找最近的文件」**:设计稿放在 `design/`、而 `design/` 里又有一份
267
+ * 历史遗留的 `__preset.ptr` 时,向上找会命中错的那一份;watch 才是项目的权威定义。
268
+ */
269
+ let registeredRoots = [];
270
+ /** 由 daemon / CLI 注入注册根;传空数组等于清空(测试用) */
271
+ function setProjectRoots(roots) {
272
+ registeredRoots = [...new Set(roots.map((root) => resolve(root)))];
273
+ }
274
+ function registeredRootOf(from) {
275
+ const abs = resolve(from);
276
+ let best = null;
277
+ for (const root of registeredRoots) if (abs === root || abs.startsWith(`${root}${sep}`)) {
278
+ if (best === null || root.length > best.length) best = root;
279
+ }
280
+ return best;
281
+ }
282
+ /** 从 from 向上收集所有「含预设/组件文件」的目录(近 → 远) */
283
+ function ancestorRoots(from) {
284
+ let dir = resolve(from);
285
+ try {
286
+ if (!statSync(dir).isDirectory()) dir = dirname(dir);
287
+ } catch {
288
+ dir = dirname(dir);
289
+ }
290
+ const out = [];
291
+ for (;;) {
292
+ if (existsSync(join(dir, "__preset.ptr")) || existsSync(join(dir, "__components.ptr"))) out.push(dir);
293
+ const parent = dirname(dir);
294
+ if (parent === dir || !parent.startsWith(sep)) break;
295
+ dir = parent;
296
+ }
297
+ return out;
298
+ }
299
+ /** 目录 → 项目根(注册根优先;否则最近的含预设/组件文件的祖先);找不到返回 null */
300
+ function findProjectRoot(from) {
301
+ return registeredRootOf(from) ?? ancestorRoots(from)[0] ?? null;
302
+ }
303
+ /**
304
+ * 写入用的严格解析:注册根 → 唯一的祖先根 → 显式目录;歧义或无处可写时**直接报错**。
305
+ * 禁止替一个裸设计稿「就地」建项目文件——那正是 `design/__preset.ptr` 这类脏数据的来源。
306
+ */
307
+ function findProjectRootStrict(from) {
308
+ const registered = registeredRootOf(from);
309
+ if (registered !== null) return registered;
310
+ const candidates = ancestorRoots(from);
311
+ if (candidates.length > 1) throw new Error(`项目根不唯一:${candidates.join("、")} 都含 ${PRESET_FILE} / ${COMPONENTS_FILE}。\n先运行 paintress web 注册项目(注册目录优先),或显式传入项目目录。`);
312
+ if (candidates.length === 1) return candidates[0];
313
+ const abs = resolve(from);
314
+ try {
315
+ if (statSync(abs).isDirectory()) return abs;
316
+ } catch {}
317
+ throw new Error(`找不到项目根:${from} 不在已注册项目里。\n先在该项目目录下运行 paintress web(会自动建立并返回这两个文件的路径),不要自己另建。`);
318
+ }
319
+ /** 传入文件 → 其所在目录;传入目录 → 自身 */
320
+ function projectDirOf(from) {
321
+ const abs = resolve(from);
322
+ try {
323
+ return statSync(abs).isDirectory() ? abs : dirname(abs);
324
+ } catch {
325
+ return dirname(abs);
326
+ }
327
+ }
328
+ /** 从设计稿 / 目录定位预设文件(存在才算命中) */
329
+ function presetPathFor(from) {
330
+ const root = findProjectRoot(from);
331
+ if (root === null) return null;
332
+ const p = join(root, PRESET_FILE);
333
+ return existsSync(p) ? p : null;
334
+ }
335
+ function componentsPathFor(from) {
336
+ const root = findProjectRoot(from);
337
+ if (root === null) return null;
338
+ const p = join(root, COMPONENTS_FILE);
339
+ return existsSync(p) ? p : null;
340
+ }
341
+ /** 根目录下**嵌套**的预设/组件目录(历史遗留或误建);只报告,不吞掉 */
342
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
343
+ "node_modules",
344
+ ".git",
345
+ "dist",
346
+ ".agents",
347
+ ".paintress",
348
+ "coverage"
349
+ ]);
350
+ function findNestedProjectFiles(root, maxDepth = 4) {
351
+ const abs = resolve(root);
352
+ const out = [];
353
+ const walk = (dir, depth) => {
354
+ if (depth > maxDepth) return;
355
+ let entries;
356
+ try {
357
+ entries = readdirSync(dir, { withFileTypes: true });
358
+ } catch {
359
+ return;
360
+ }
361
+ for (const entry of entries) {
362
+ if (!entry.isDirectory() || entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
363
+ const child = join(dir, entry.name);
364
+ if (existsSync(join(child, "__preset.ptr")) || existsSync(join(child, "__components.ptr"))) out.push(child);
365
+ walk(child, depth + 1);
366
+ }
367
+ };
368
+ walk(abs, 1);
369
+ return out;
370
+ }
371
+ /** 项目里两个关键文件的现状;`ensure` 为 true 时先按需创建(只在项目根,绝不在子目录) */
372
+ function projectFilesInfo(root, opts = {}) {
373
+ const abs = resolve(root);
374
+ if (opts.ensure === true) ensureProjectScaffold(abs);
375
+ const presetPath = join(abs, PRESET_FILE);
376
+ const componentsPath = join(abs, COMPONENTS_FILE);
377
+ const presetExists = existsSync(presetPath);
378
+ const componentsExists = existsSync(componentsPath);
379
+ const file = presetExists ? readPresetFile(presetPath) : null;
380
+ const groups = file === null ? {} : presetGroupsOf(file.doc);
381
+ return {
382
+ root: abs,
383
+ presetPath,
384
+ componentsPath,
385
+ presetExists,
386
+ componentsExists,
387
+ presetTokens: file?.items.length ?? 0,
388
+ presetGroups: Object.keys(groups).length,
389
+ components: componentsExists ? listComponents(componentsPath).length : 0,
390
+ conflicts: findNestedProjectFiles(abs)
391
+ };
392
+ }
393
+ function presetKindOf(key, doc) {
394
+ const meta = doc["x-paintress"];
395
+ const items = isRecord$1(meta) && isRecord$1(meta["presets"]) ? meta["presets"]["items"] : void 0;
396
+ const declared = isRecord$1(items) && isRecord$1(items[key]) ? items[key]["type"] : void 0;
397
+ const def = doc.variables?.[key];
398
+ const raw = isRecord$1(def) ? def["value"] : void 0;
399
+ if ((declared === "font" || declared === void 0) && isNumericPresetValue(raw)) return "fontSize";
400
+ if (isPresetType(declared)) return declared;
401
+ const t = isRecord$1(def) ? def["type"] : void 0;
402
+ if (t === "color") return "color";
403
+ if (t === "number") return "radius";
404
+ if (t === "string") return "font";
405
+ return null;
406
+ }
407
+ /** 读预设文件;不存在返回 null。结构错误直接抛出(带路径上下文) */
408
+ function readPresetFile(path) {
409
+ if (!existsSync(path)) return null;
410
+ const loaded = loadDoc(path);
411
+ const items = [];
412
+ for (const [key, def] of Object.entries(loaded.doc.variables ?? {})) {
413
+ if (!isRecord$1(def)) continue;
414
+ const type = presetKindOf(key, loaded.doc);
415
+ if (type === null) continue;
416
+ const raw = def["value"];
417
+ if (Array.isArray(raw)) continue;
418
+ const meta = loaded.doc["x-paintress"];
419
+ const metaItems = isRecord$1(meta) && isRecord$1(meta["presets"]) ? meta["presets"]["items"] : void 0;
420
+ const declared = isRecord$1(metaItems) && isRecord$1(metaItems[key]) ? metaItems[key] : void 0;
421
+ items.push({
422
+ key,
423
+ type,
424
+ name: typeof declared?.["name"] === "string" && declared["name"] !== "" ? declared["name"] : key,
425
+ value: String(raw)
426
+ });
427
+ }
428
+ items.sort((a, b) => a.type === b.type ? a.key.localeCompare(b.key) : a.type.localeCompare(b.type));
429
+ return {
430
+ path,
431
+ doc: loaded.doc,
432
+ mtimeMs: loaded.mtimeMs,
433
+ items
434
+ };
435
+ }
436
+ /** 项目预设(从任意设计稿/目录出发);没有项目或没有文件都返回空列表 */
437
+ function listPresets(from) {
438
+ const path = presetPathFor(from);
439
+ if (path === null) return {
440
+ path: null,
441
+ items: []
442
+ };
443
+ return {
444
+ path,
445
+ items: readPresetFile(path)?.items ?? []
446
+ };
447
+ }
448
+ /**
449
+ * 读取归一:老文件里 `font` + 数字值的 token 在**合并副本**里按 number 呈现,
450
+ * 让 `resolveNum` 认得它。文件本身一个字都不改,面板与审计报 `fontSize`,
451
+ * 第一次编辑时才落新类型。
452
+ */
453
+ function normalizedPresetVariables(presetDoc) {
454
+ const vars = presetDoc?.variables;
455
+ if (vars === void 0) return void 0;
456
+ const meta = presetDoc["x-paintress"];
457
+ const items = isRecord$1(meta) && isRecord$1(meta["presets"]) ? meta["presets"]["items"] : void 0;
458
+ const out = { ...vars };
459
+ for (const [key, def] of Object.entries(vars)) {
460
+ if (!isRecord$1(def)) continue;
461
+ const declared = isRecord$1(items) && isRecord$1(items[key]) ? items[key]["type"] : void 0;
462
+ const raw = def["value"];
463
+ if ((declared === "font" || declared === void 0) && isNumericPresetValue(raw)) out[key] = {
464
+ ...def,
465
+ type: "number",
466
+ value: Number(raw)
467
+ };
468
+ }
469
+ return out;
470
+ }
471
+ /**
472
+ * 渲染/校验用的副本:预设打底,文档本地同名变量优先。
473
+ * **绝不能拿去保存** —— 否则一次写入就把预设值烤进了设计稿。
474
+ */
475
+ function withProjectPresets(doc, presetDoc) {
476
+ const vars = normalizedPresetVariables(presetDoc);
477
+ const presetGroups = presetGroupsOf(presetDoc);
478
+ const ownGroups = presetGroupsOf(doc);
479
+ const groups = {
480
+ ...presetGroups,
481
+ ...ownGroups
482
+ };
483
+ const hasVars = vars !== void 0 && Object.keys(vars).length > 0;
484
+ const hasGroups = Object.keys(groups).length > 0;
485
+ if (!hasVars && !hasGroups) return doc;
486
+ const merged = {
487
+ ...doc,
488
+ ...hasVars ? { variables: {
489
+ ...vars,
490
+ ...doc.variables ?? {}
491
+ } } : {}
492
+ };
493
+ if (!hasGroups) return merged;
494
+ const meta = doc["x-paintress"];
495
+ const metaObj = isRecord$1(meta) ? meta : {};
496
+ const presets = isRecord$1(metaObj["presets"]) ? metaObj["presets"] : {};
497
+ return {
498
+ ...merged,
499
+ ["x-paintress"]: {
500
+ ...metaObj,
501
+ presets: {
502
+ ...presets,
503
+ groups
504
+ }
505
+ }
506
+ };
507
+ }
508
+ /**
509
+ * 渲染 / 校验用:按设计稿所在项目的预设补全变量,返回**副本**(绝不可拿去保存)。
510
+ * 这是「预设只在项目里存一份」的唯一兑付点:渲染、截图、校验、写前检查都走这里。
511
+ */
512
+ function applyProjectPresets(doc, filePath) {
513
+ const p = presetPathFor(filePath);
514
+ const presetDoc = p === null ? null : readPresetFile(p)?.doc ?? null;
515
+ return {
516
+ doc: withProjectPresets(doc, presetDoc),
517
+ presetNames: presetVarNames(presetDoc)
518
+ };
519
+ }
520
+ /** 项目预设提供的变量名(校验用;只读名字,不做合并) */
521
+ function projectPresetNames(filePath) {
522
+ const p = presetPathFor(filePath);
523
+ if (p === null) return /* @__PURE__ */ new Set();
524
+ return presetVarNames(readPresetFile(p)?.doc ?? null);
525
+ }
526
+ /** 预设文件对应的变量名集合(校验时用来区分「未定义」与「来自项目预设」) */
527
+ function presetVarNames(presetDoc) {
528
+ return new Set(Object.keys(presetDoc?.variables ?? {}));
529
+ }
530
+ const COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
531
+ /** 值归一:对外字串 → 落盘值;类型不合法时抛出可读错误 */
532
+ function normalizePresetValue(type, value) {
533
+ const raw = value.trim();
534
+ if (type === "color") {
535
+ if (!COLOR_RE.test(raw)) throw new Error(`颜色预设只接受 #RGB / #RRGGBB / #RRGGBBAA,收到:${value}`);
536
+ return raw.toUpperCase();
537
+ }
538
+ if (type === "font") {
539
+ if (raw === "") throw new Error("字体预设不能为空");
540
+ return raw;
541
+ }
542
+ const n = Number(raw);
543
+ if (!Number.isFinite(n) || n < 0) throw new Error(`${type === "spacing" ? "间距" : type === "fontSize" ? "字号" : "倒角"}预设只接受非负数,收到:${value}`);
544
+ return n;
545
+ }
546
+ /** 变量表里的 type:color→color,radius/spacing/fontSize→number,font→string */
547
+ function varTypeOf(type) {
548
+ if (type === "color") return "color";
549
+ if (type === "font") return "string";
550
+ return "number";
551
+ }
552
+ /** x-paintress.presets 的两半:items(token 元数据)与 groups(谱式) */
553
+ function presetsMeta(doc) {
554
+ const meta = doc["x-paintress"];
555
+ const presets = isRecord$1(meta) ? meta["presets"] : void 0;
556
+ const items = isRecord$1(presets) ? presets["items"] : void 0;
557
+ return {
558
+ items: isRecord$1(items) ? items : {},
559
+ groups: presetGroupsOf(doc)
560
+ };
561
+ }
562
+ /** 写回 __preset.ptr:items 与 groups 必须一起写,否则改一条 token 会把谱式抹掉 */
563
+ function writePresets(path, doc, mtimeMs, items, groups) {
564
+ const meta = doc["x-paintress"];
565
+ const metaObj = isRecord$1(meta) ? meta : {};
566
+ const next = {
567
+ ...doc,
568
+ ["x-paintress"]: {
569
+ ...metaObj,
570
+ spec: "1",
571
+ presets: {
572
+ version: 1,
573
+ items,
574
+ groups
575
+ }
576
+ }
577
+ };
578
+ saveDoc(path, next, {
579
+ flavor: "ptr",
580
+ ...mtimeMs !== void 0 ? { expectedMtimeMs: mtimeMs } : {}
581
+ });
582
+ }
583
+ function emptyDoc() {
584
+ return {
585
+ version: PEN_DOC_VERSION,
586
+ fileToken: randomUUID(),
587
+ variables: {},
588
+ children: []
589
+ };
590
+ }
591
+ /** 注册项目时确保两个文件存在;已存在则一个字都不动 */
592
+ function ensureProjectScaffold(root) {
593
+ const created = [];
594
+ const targets = [{
595
+ file: PRESET_FILE,
596
+ doc: {
597
+ ...emptyDoc(),
598
+ ["x-paintress"]: {
599
+ spec: "1",
600
+ presets: {
601
+ version: 1,
602
+ items: {}
603
+ }
604
+ }
605
+ }
606
+ }, {
607
+ file: COMPONENTS_FILE,
608
+ doc: {
609
+ ...emptyDoc(),
610
+ ["x-paintress"]: {
611
+ spec: "1",
612
+ components: { version: 1 }
613
+ }
614
+ }
615
+ }];
616
+ for (const t of targets) {
617
+ const path = join(root, t.file);
618
+ if (existsSync(path)) continue;
619
+ try {
620
+ saveDoc(path, t.doc, {
621
+ flavor: "ptr",
622
+ createOnly: true
623
+ });
624
+ created.push(path);
625
+ } catch (cause) {
626
+ if (existsSync(path)) continue;
627
+ throw cause;
628
+ }
629
+ }
630
+ return { created };
631
+ }
632
+ /** 写一条预设(新建或改值);返回最新列表。from 可以是设计稿、预设文件或目录 */
633
+ function setPreset(from, input) {
634
+ const key = input.key.trim();
635
+ if (key === "") throw new Error("预设 key 不能为空");
636
+ if (!/^[A-Za-z0-9._-]+$/.test(key)) throw new Error(`预设 key 只允许字母数字与 . _ -,收到:${key}`);
637
+ let path = presetPathFor(from);
638
+ if (path === null) {
639
+ const root = findProjectRootStrict(from);
640
+ ensureProjectScaffold(root);
641
+ path = join(root, PRESET_FILE);
642
+ }
643
+ const file = readPresetFile(path);
644
+ const doc = file?.doc ?? emptyDoc();
645
+ const value = normalizePresetValue(input.type, input.value);
646
+ const variables = { ...doc.variables ?? {} };
647
+ variables[key] = {
648
+ type: varTypeOf(input.type),
649
+ value
650
+ };
651
+ const { items, groups } = presetsMeta(doc);
652
+ const metaItems = { ...items };
653
+ metaItems[key] = {
654
+ type: input.type,
655
+ ...input.name !== void 0 && input.name !== "" ? { name: input.name } : {}
656
+ };
657
+ const next = {
658
+ ...doc,
659
+ variables
660
+ };
661
+ writePresets(path, next, file?.mtimeMs, metaItems, groups);
662
+ return readPresetFile(path)?.items ?? [];
663
+ }
664
+ /** 删一条预设;返回最新列表(不存在时什么都不做) */
665
+ function removePreset(from, key) {
666
+ const path = presetPathFor(from);
667
+ if (path === null) return [];
668
+ const file = readPresetFile(path);
669
+ if (file === null) return [];
670
+ const variables = { ...file.doc.variables ?? {} };
671
+ if (!(key in variables)) return file.items;
672
+ delete variables[key];
673
+ const { items, groups } = presetsMeta(file.doc);
674
+ const metaItems = { ...items };
675
+ delete metaItems[key];
676
+ writePresets(path, {
677
+ ...file.doc,
678
+ variables
679
+ }, file.mtimeMs, metaItems, groups);
680
+ return readPresetFile(path)?.items ?? [];
681
+ }
682
+ /** 预设组列表(谱式);没有项目文件就返回空 */
683
+ function listPresetGroups(from) {
684
+ const path = presetPathFor(from);
685
+ if (path === null) return {
686
+ path: null,
687
+ groups: {}
688
+ };
689
+ return {
690
+ path,
691
+ groups: presetGroupsOf(readPresetFile(path)?.doc ?? null)
692
+ };
693
+ }
694
+ /** 新建 / 覆盖一个预设组;返回最新列表。from 可以是设计稿、预设文件或目录 */
695
+ function setPresetGroup(from, input) {
696
+ const name = input.name.trim();
697
+ if (name === "") throw new Error("预设组名字不能为空");
698
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error(`预设组名字只允许字母数字与 . _ -,收到:${name}`);
699
+ const target = input.target.trim();
700
+ if (target === "") throw new Error("预设组必须指定 target(节点类型,如 frame / text)");
701
+ const props = {};
702
+ for (const [key, value] of Object.entries(input.props)) {
703
+ if (typeof value !== "string" || value.trim() === "") continue;
704
+ props[key] = value.trim();
705
+ }
706
+ if (Object.keys(props).length === 0) throw new Error("预设组至少要有一个属性");
707
+ let path = presetPathFor(from);
708
+ if (path === null) {
709
+ const root = findProjectRootStrict(from);
710
+ ensureProjectScaffold(root);
711
+ path = join(root, PRESET_FILE);
712
+ }
713
+ const file = readPresetFile(path);
714
+ const doc = file?.doc ?? emptyDoc();
715
+ const { items, groups } = presetsMeta(doc);
716
+ const nextGroups = {
717
+ ...groups,
718
+ [name]: {
719
+ target,
720
+ props
721
+ }
722
+ };
723
+ writePresets(path, doc, file?.mtimeMs, items, nextGroups);
724
+ return {
725
+ path,
726
+ groups: presetGroupsOf(readPresetFile(path)?.doc ?? null)
727
+ };
728
+ }
729
+ /** 删一个预设组(不存在时什么都不做) */
730
+ function removePresetGroup(from, name) {
731
+ const path = presetPathFor(from);
732
+ if (path === null) return {
733
+ path: null,
734
+ groups: {}
735
+ };
736
+ const file = readPresetFile(path);
737
+ if (file === null) return {
738
+ path,
739
+ groups: {}
740
+ };
741
+ const { items, groups } = presetsMeta(file.doc);
742
+ if (!(name in groups)) return {
743
+ path,
744
+ groups
745
+ };
746
+ const nextGroups = { ...groups };
747
+ delete nextGroups[name];
748
+ writePresets(path, file.doc, file.mtimeMs, items, nextGroups);
749
+ return {
750
+ path,
751
+ groups: presetGroupsOf(readPresetFile(path)?.doc ?? null)
752
+ };
753
+ }
754
+ /** 全树按 id 找节点(组件库、复制插入、画廊都用这一处) */
755
+ function findNodeIn(nodes, id) {
756
+ for (const n of nodes) {
757
+ if (!n || typeof n !== "object") continue;
758
+ const rec = n;
759
+ if (rec["id"] === id) return rec;
760
+ const kids = rec["children"];
761
+ if (Array.isArray(kids)) {
762
+ const hit = findNodeIn(kids, id);
763
+ if (hit !== null) return hit;
764
+ }
765
+ }
766
+ return null;
767
+ }
768
+ /** 组件库里的所有 reusable 节点(全树扫描) */
769
+ function listComponents(componentsPath) {
770
+ const doc = readPresetFile(componentsPath)?.doc ?? loadDoc(componentsPath).doc;
771
+ const out = [];
772
+ const walk = (nodes) => {
773
+ for (const n of nodes) {
774
+ if (!isRecord$1(n)) continue;
775
+ if (n["reusable"] === true && typeof n["id"] === "string") out.push({
776
+ id: n["id"],
777
+ name: typeof n["name"] === "string" && n["name"] !== "" ? n["name"] : n["id"],
778
+ type: typeof n["type"] === "string" ? n["type"] : "?",
779
+ ...typeof n["width"] === "number" ? { width: n["width"] } : {},
780
+ ...typeof n["height"] === "number" ? { height: n["height"] } : {}
781
+ });
782
+ const kids = n["children"];
783
+ if (Array.isArray(kids)) walk(kids);
784
+ }
785
+ };
786
+ walk(doc.children ?? []);
787
+ return out;
788
+ }
789
+ /** 读组件库文件(含 mtime,供乐观并发写入用) */
790
+ function readComponentsFile(path) {
791
+ if (!existsSync(path)) return null;
792
+ const loaded = loadDoc(path);
793
+ return {
794
+ doc: loaded.doc,
795
+ mtimeMs: loaded.mtimeMs
796
+ };
797
+ }
798
+ /** 备注:`readFileSync` 保留给调用方做小文件快读(笔记接口用) */
799
+ function readTextIfExists(path, maxBytes = 524288) {
800
+ if (!existsSync(path)) return null;
801
+ const st = statSync(path);
802
+ if (!st.isFile()) return null;
803
+ if (st.size > maxBytes) throw new Error(`文件过大(${st.size} 字节,上限 ${maxBytes}):${path}`);
804
+ return readFileSync(path, "utf8");
805
+ }
806
+ /** 项目字体注册表(`x-paintress.fonts`);没有预设文件就是空表 */
807
+ function projectFontRegistry(from) {
808
+ const path = presetPathFor(from);
809
+ if (path === null) return {
810
+ path: null,
811
+ registry: { families: {} }
812
+ };
813
+ return {
814
+ path,
815
+ registry: fontRegistryOf(readPresetFile(path)?.doc ?? null)
816
+ };
817
+ }
818
+ /** 定位/初始化预设文件(写字体与 seed 共用) */
819
+ function presetFileForWrite(from) {
820
+ let path = presetPathFor(from);
821
+ if (path === null) {
822
+ const root = findProjectRootStrict(from);
823
+ ensureProjectScaffold(root);
824
+ path = join(root, PRESET_FILE);
825
+ }
826
+ return {
827
+ path,
828
+ file: readPresetFile(path)
829
+ };
830
+ }
831
+ function writeFontRegistry(path, doc, mtimeMs, registry) {
832
+ const meta = doc["x-paintress"];
833
+ const metaObj = isRecord$1(meta) ? meta : {};
834
+ saveDoc(path, {
835
+ ...doc,
836
+ ["x-paintress"]: {
837
+ ...metaObj,
838
+ spec: "1",
839
+ fonts: registry
840
+ }
841
+ }, {
842
+ flavor: "ptr",
843
+ ...mtimeMs !== void 0 ? { expectedMtimeMs: mtimeMs } : {}
844
+ });
845
+ }
846
+ /** 写一条字体登记;`system`/`bundle` 必须给 preview,`css`/`file` 的路径必须在项目内且存在 */
847
+ function setFontEntry(from, input) {
848
+ const family = input.family.trim();
849
+ if (family === "") throw new Error("字体 family 不能为空");
850
+ if (family.startsWith("$")) throw new Error("family 写真实字体名,不要写 $token");
851
+ if (!isFontSourceKind(input.source.kind)) throw new Error("source.kind 必须是 web / css / file / system / bundle");
852
+ const kind = input.source.kind;
853
+ const target = input.source.target?.trim();
854
+ if (kind !== "system" && kind !== "bundle" && (target === void 0 || target === "")) throw new Error(`${kind} 来源必须给 target(web 写 CSS URL,css/file 写项目内相对路径)`);
855
+ if (kind === "web" && !/^https?:\/\//i.test(target ?? "")) throw new Error("web 来源的 target 必须是 http(s) URL");
856
+ if (kind === "css" || kind === "file") {
857
+ const root = findProjectRoot(from) ?? projectDirOf(from);
858
+ const abs = resolve(root, target);
859
+ const rel = relative(root, abs);
860
+ if (rel.startsWith("..") || rel.includes(`..${sep}`)) throw new Error(`字体路径越出项目:${target}`);
861
+ if (!existsSync(abs)) throw new Error(`字体文件不存在:${target}(相对项目根 ${root})`);
862
+ }
863
+ const preview = input.preview?.trim();
864
+ if ((kind === "system" || kind === "bundle") && (preview === void 0 || preview === "")) throw new Error(`${kind} 来源必须给 preview(设计期替代字体)`);
865
+ const { path, file } = presetFileForWrite(from);
866
+ const registry = fontRegistryOf(file?.doc ?? null);
867
+ const entry = {
868
+ source: {
869
+ kind,
870
+ ...target !== void 0 && target !== "" ? { target } : {}
871
+ },
872
+ ...preview !== void 0 && preview !== "" ? { preview } : {},
873
+ ...input.metrics !== void 0 ? { metrics: input.metrics } : {},
874
+ ...input.note !== void 0 && input.note !== "" ? { note: input.note } : {}
875
+ };
876
+ const next = {
877
+ ...registry,
878
+ families: {
879
+ ...registry.families,
880
+ [family]: entry
881
+ }
882
+ };
883
+ writeFontRegistry(path, file?.doc ?? emptyDoc(), file?.mtimeMs, next);
884
+ return next;
885
+ }
886
+ /** 删一条字体登记(不动设计稿;引用它的节点会在 validate 出 warning) */
887
+ function removeFontEntry(from, family) {
888
+ const path = presetPathFor(from);
889
+ if (path === null) return { families: {} };
890
+ const file = readPresetFile(path);
891
+ if (file === null) return { families: {} };
892
+ const registry = fontRegistryOf(file.doc);
893
+ if (registry.families[family] === void 0) return registry;
894
+ const families = { ...registry.families };
895
+ delete families[family];
896
+ const next = {
897
+ ...registry,
898
+ families
899
+ };
900
+ writeFontRegistry(path, file.doc, file.mtimeMs, next);
901
+ return next;
902
+ }
903
+ /** 设项目默认字体(font token key 或 family) */
904
+ function setDefaultFont(from, value) {
905
+ const v = value.trim();
906
+ if (v === "") throw new Error("默认字体不能为空");
907
+ const { path, file } = presetFileForWrite(from);
908
+ const next = {
909
+ ...fontRegistryOf(file?.doc ?? null),
910
+ default: v
911
+ };
912
+ writeFontRegistry(path, file?.doc ?? emptyDoc(), file?.mtimeMs, next);
913
+ return next;
914
+ }
915
+ const SEED_TOKENS = [
916
+ {
917
+ key: "color-text",
918
+ type: "color",
919
+ value: "#111827"
920
+ },
921
+ {
922
+ key: "color-text-muted",
923
+ type: "color",
924
+ value: "#6B7280"
925
+ },
926
+ {
927
+ key: "color-surface",
928
+ type: "color",
929
+ value: "#FFFFFF"
930
+ },
931
+ {
932
+ key: "color-surface-muted",
933
+ type: "color",
934
+ value: "#F3F4F6"
935
+ },
936
+ {
937
+ key: "color-border",
938
+ type: "color",
939
+ value: "#E5E7EB"
940
+ },
941
+ {
942
+ key: "color-brand",
943
+ type: "color",
944
+ value: "#2563EB"
945
+ },
946
+ {
947
+ key: "color-danger",
948
+ type: "color",
949
+ value: "#DC2626"
950
+ },
951
+ {
952
+ key: "color-success",
953
+ type: "color",
954
+ value: "#059669"
955
+ },
956
+ {
957
+ key: "radius-4",
958
+ type: "radius",
959
+ value: "4"
960
+ },
961
+ {
962
+ key: "radius-8",
963
+ type: "radius",
964
+ value: "8"
965
+ },
966
+ {
967
+ key: "radius-12",
968
+ type: "radius",
969
+ value: "12"
970
+ },
971
+ {
972
+ key: "radius-full",
973
+ type: "radius",
974
+ value: "999"
975
+ },
976
+ {
977
+ key: "spacing-4",
978
+ type: "spacing",
979
+ value: "4"
980
+ },
981
+ {
982
+ key: "spacing-8",
983
+ type: "spacing",
984
+ value: "8"
985
+ },
986
+ {
987
+ key: "spacing-12",
988
+ type: "spacing",
989
+ value: "12"
990
+ },
991
+ {
992
+ key: "spacing-16",
993
+ type: "spacing",
994
+ value: "16"
995
+ },
996
+ {
997
+ key: "spacing-24",
998
+ type: "spacing",
999
+ value: "24"
1000
+ },
1001
+ {
1002
+ key: "spacing-32",
1003
+ type: "spacing",
1004
+ value: "32"
1005
+ },
1006
+ {
1007
+ key: "spacing-48",
1008
+ type: "spacing",
1009
+ value: "48"
1010
+ },
1011
+ {
1012
+ key: "fontSize-12",
1013
+ type: "fontSize",
1014
+ value: "12"
1015
+ },
1016
+ {
1017
+ key: "fontSize-14",
1018
+ type: "fontSize",
1019
+ value: "14"
1020
+ },
1021
+ {
1022
+ key: "fontSize-16",
1023
+ type: "fontSize",
1024
+ value: "16"
1025
+ },
1026
+ {
1027
+ key: "fontSize-20",
1028
+ type: "fontSize",
1029
+ value: "20"
1030
+ },
1031
+ {
1032
+ key: "fontSize-24",
1033
+ type: "fontSize",
1034
+ value: "24"
1035
+ },
1036
+ {
1037
+ key: "fontSize-32",
1038
+ type: "fontSize",
1039
+ value: "32"
1040
+ },
1041
+ {
1042
+ key: "font-sans",
1043
+ type: "font",
1044
+ value: "Inter Variable, Noto Sans SC Variable"
1045
+ },
1046
+ {
1047
+ key: "font-mono",
1048
+ type: "font",
1049
+ value: "ui-monospace, SFMono-Regular, monospace"
1050
+ }
1051
+ ];
1052
+ const SEED_GROUPS = [
1053
+ {
1054
+ name: "text.body",
1055
+ target: "text",
1056
+ props: {
1057
+ fontSize: "$fontSize-16",
1058
+ fill: "$color-text"
1059
+ }
1060
+ },
1061
+ {
1062
+ name: "text.muted",
1063
+ target: "text",
1064
+ props: {
1065
+ fontSize: "$fontSize-14",
1066
+ fill: "$color-text-muted"
1067
+ }
1068
+ },
1069
+ {
1070
+ name: "text.title",
1071
+ target: "text",
1072
+ props: {
1073
+ fontSize: "$fontSize-24",
1074
+ fill: "$color-text"
1075
+ }
1076
+ },
1077
+ {
1078
+ name: "button",
1079
+ target: "frame",
1080
+ props: {
1081
+ fill: "$color-brand",
1082
+ cornerRadius: "$radius-8"
1083
+ }
1084
+ },
1085
+ {
1086
+ name: "card",
1087
+ target: "frame",
1088
+ props: {
1089
+ fill: "$color-surface",
1090
+ cornerRadius: "$radius-12",
1091
+ stroke: "$color-border"
1092
+ }
1093
+ },
1094
+ {
1095
+ name: "input",
1096
+ target: "frame",
1097
+ props: {
1098
+ fill: "$color-surface",
1099
+ cornerRadius: "$radius-8",
1100
+ stroke: "$color-border"
1101
+ }
1102
+ }
1103
+ ];
1104
+ const SEED_FONTS = {
1105
+ "Inter Variable": {
1106
+ source: {
1107
+ kind: "web",
1108
+ target: "https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5.3.0/wght.css"
1109
+ },
1110
+ preview: "system-ui"
1111
+ },
1112
+ "Noto Sans SC Variable": {
1113
+ source: {
1114
+ kind: "web",
1115
+ target: "https://cdn.jsdelivr.net/npm/@fontsource-variable/noto-sans-sc@5.3.1/wght.css"
1116
+ },
1117
+ preview: "system-ui"
1118
+ }
1119
+ };
1120
+ const SEED_DEFAULT_FONT = "font-sans";
1121
+ /**
1122
+ * 预设种子:一次给出颜色 / 倒角 / 间距 / 字号 / 字体 + 六个常用 preset 组。
1123
+ * **只补缺失、永不覆盖**;默认 dry-run(`apply: true` 才落盘),只写一次文件。
1124
+ */
1125
+ function seedDesignSystem(from, opts = {}) {
1126
+ const apply = opts.apply === true;
1127
+ const path = presetPathFor(from) ?? (apply ? presetFileForWrite(from).path : null);
1128
+ const file = path === null ? null : readPresetFile(path);
1129
+ const doc = file?.doc ?? emptyDoc();
1130
+ const items = file?.items ?? [];
1131
+ const groups = presetGroupsOf(doc);
1132
+ const registry = fontRegistryOf(doc);
1133
+ const existingTokens = new Set(items.map((item) => item.key));
1134
+ const existingGroups = new Set(Object.keys(groups));
1135
+ const existingFonts = new Set(Object.keys(registry.families));
1136
+ const created = {
1137
+ tokens: [],
1138
+ groups: [],
1139
+ fonts: [],
1140
+ defaultFont: false
1141
+ };
1142
+ const skipped = {
1143
+ tokens: [],
1144
+ groups: [],
1145
+ fonts: [],
1146
+ defaultFont: false
1147
+ };
1148
+ for (const token of SEED_TOKENS) (existingTokens.has(token.key) ? skipped.tokens : created.tokens).push(token.key);
1149
+ for (const group of SEED_GROUPS) (existingGroups.has(group.name) ? skipped.groups : created.groups).push(group.name);
1150
+ for (const family of Object.keys(SEED_FONTS)) (existingFonts.has(family) ? skipped.fonts : created.fonts).push(family);
1151
+ if (registry.default === void 0) created.defaultFont = true;
1152
+ else skipped.defaultFont = true;
1153
+ if (!apply || path === null) return {
1154
+ applied: false,
1155
+ created,
1156
+ skipped
1157
+ };
1158
+ const variables = { ...doc.variables ?? {} };
1159
+ const metaItems = { ...presetsMeta(doc).items };
1160
+ for (const token of SEED_TOKENS) {
1161
+ if (!created.tokens.includes(token.key)) continue;
1162
+ variables[token.key] = {
1163
+ type: varTypeOf(token.type),
1164
+ value: normalizePresetValue(token.type, token.value)
1165
+ };
1166
+ metaItems[token.key] = { type: token.type };
1167
+ }
1168
+ const nextGroups = { ...groups };
1169
+ for (const group of SEED_GROUPS) if (created.groups.includes(group.name)) nextGroups[group.name] = {
1170
+ target: group.target,
1171
+ props: group.props
1172
+ };
1173
+ const families = { ...registry.families };
1174
+ for (const [family, entry] of Object.entries(SEED_FONTS)) if (created.fonts.includes(family)) families[family] = entry;
1175
+ const nextRegistry = {
1176
+ ...registry,
1177
+ families,
1178
+ ...created.defaultFont ? { default: SEED_DEFAULT_FONT } : {}
1179
+ };
1180
+ const meta = doc["x-paintress"];
1181
+ const metaObj = isRecord$1(meta) ? meta : {};
1182
+ saveDoc(path, {
1183
+ ...doc,
1184
+ variables,
1185
+ ["x-paintress"]: {
1186
+ ...metaObj,
1187
+ spec: "1",
1188
+ presets: {
1189
+ version: 1,
1190
+ items: metaItems,
1191
+ groups: nextGroups
1192
+ },
1193
+ fonts: nextRegistry
1194
+ }
1195
+ }, {
1196
+ flavor: "ptr",
1197
+ ...file?.mtimeMs !== void 0 ? { expectedMtimeMs: file.mtimeMs } : {}
1198
+ });
1199
+ return {
1200
+ applied: true,
1201
+ created,
1202
+ skipped
1203
+ };
1204
+ }
1205
+ // src/doc/album.ts
1206
+ /**
1207
+ * 画谱的两个操作:establish(从现有稿里提取 token / preset)与 adjust(改一处、看影响面)。
1208
+ *
1209
+ * 分工:本模块只做分析与改写计划;写盘走 `project.ts` 的 setPreset / setPresetGroup,
1210
+ * 设计稿改写走 `saveDoc`(带 mtime 防覆盖)。`apply=false` 时一个字节都不写。
1211
+ */
1212
+ function isRecord(v) {
1213
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1214
+ }
1215
+ /** 一次性设计的逃生舱:建立候选与改写都跳过,但继续检查后代 */
1216
+ function allowsLiteral(node) {
1217
+ const meta = node["metadata"];
1218
+ return isRecord(meta) && (meta["allowLiteral"] === true || meta["allowLiteral"] === "true");
1219
+ }
1220
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
1221
+ /** 参与提取的属性:颜色(fill/stroke)与数值(圆角/间距/字号) */
1222
+ const COLOR_PROPS = ["fill", "stroke"];
1223
+ const NUM_PROPS = [
1224
+ {
1225
+ prop: "cornerRadius",
1226
+ kind: "radius"
1227
+ },
1228
+ {
1229
+ prop: "gap",
1230
+ kind: "spacing"
1231
+ },
1232
+ {
1233
+ prop: "fontSize",
1234
+ kind: "fontSize"
1235
+ }
1236
+ ];
1237
+ /** 建议的 token key:由值推导,可重复运行;必须符合 VARIABLE_NAME(不允许小数点等字符) */
1238
+ function suggestTokenKey(type, value) {
1239
+ const safe = (type === "color" ? value.replace(/^#/, "") : value).replace(/[^A-Za-z0-9_-]+/g, "-");
1240
+ if (type === "color") return `color-${safe.toLowerCase()}`;
1241
+ if (type === "radius") return `radius-${safe}`;
1242
+ if (type === "spacing") return `spacing-${safe}`;
1243
+ if (type === "fontSize") return `fontSize-${safe}`;
1244
+ return `font-${safe}`;
1245
+ }
1246
+ /**
1247
+ * 从设计稿提取候选:同一字面量被 ≥ minUses 处使用 → 建议 token;
1248
+ * 同一类节点带同一组属性 ≥ minUses 次 → 建议 preset 组(属性值只引用已建议的 token)。
1249
+ */
1250
+ function planEstablish(doc, opts = {}) {
1251
+ const minUses = Math.max(2, opts.minUses ?? 2);
1252
+ const colorUses = /* @__PURE__ */ new Map();
1253
+ const numUses = /* @__PURE__ */ new Map();
1254
+ const signatures = /* @__PURE__ */ new Map();
1255
+ const walk = (nodes, inheritedAllowLiteral = false) => {
1256
+ for (const raw of nodes) {
1257
+ if (!isRecord(raw)) continue;
1258
+ const node = raw;
1259
+ const allowLiteral = inheritedAllowLiteral || allowsLiteral(node);
1260
+ const id = typeof node["id"] === "string" ? node["id"] : "?";
1261
+ const type = typeof node["type"] === "string" ? node["type"] : "?";
1262
+ if (!allowLiteral) {
1263
+ for (const prop of COLOR_PROPS) {
1264
+ const v = node[prop];
1265
+ if (typeof v === "string" && HEX_RE.test(v)) {
1266
+ const list = colorUses.get(v.toUpperCase()) ?? [];
1267
+ list.push({
1268
+ id,
1269
+ prop
1270
+ });
1271
+ colorUses.set(v.toUpperCase(), list);
1272
+ }
1273
+ }
1274
+ for (const { prop, kind } of NUM_PROPS) {
1275
+ const v = node[prop];
1276
+ if (typeof v === "number" && Number.isFinite(v)) {
1277
+ const mapKey = `${kind}:${v}`;
1278
+ const hit = numUses.get(mapKey) ?? {
1279
+ kind,
1280
+ value: String(v),
1281
+ uses: []
1282
+ };
1283
+ hit.uses.push({
1284
+ id,
1285
+ prop
1286
+ });
1287
+ numUses.set(mapKey, hit);
1288
+ }
1289
+ }
1290
+ const styleProps = {};
1291
+ for (const prop of [
1292
+ "fill",
1293
+ "stroke",
1294
+ "cornerRadius",
1295
+ "gap"
1296
+ ]) {
1297
+ const v = node[prop];
1298
+ if (typeof v === "string" && HEX_RE.test(v)) styleProps[prop] = v.toUpperCase();
1299
+ else if (typeof v === "number" && Number.isFinite(v)) styleProps[prop] = String(v);
1300
+ }
1301
+ if (Object.keys(styleProps).length >= 2) {
1302
+ const sig = `${type}|${JSON.stringify(styleProps)}`;
1303
+ const hit = signatures.get(sig) ?? {
1304
+ target: type,
1305
+ props: styleProps,
1306
+ ids: []
1307
+ };
1308
+ hit.ids.push(id);
1309
+ signatures.set(sig, hit);
1310
+ }
1311
+ }
1312
+ const kids = node["children"];
1313
+ if (Array.isArray(kids)) walk(kids, allowLiteral);
1314
+ }
1315
+ };
1316
+ walk(doc.children);
1317
+ const tokens = [];
1318
+ const tokenByValue = /* @__PURE__ */ new Map();
1319
+ for (const [value, uses] of colorUses) {
1320
+ if (uses.length < minUses) continue;
1321
+ const key = suggestTokenKey("color", value);
1322
+ tokens.push({
1323
+ key,
1324
+ type: "color",
1325
+ value,
1326
+ uses
1327
+ });
1328
+ tokenByValue.set(`color:${value}`, key);
1329
+ }
1330
+ for (const [, entry] of numUses) {
1331
+ if (entry.uses.length < minUses) continue;
1332
+ const key = suggestTokenKey(entry.kind, entry.value);
1333
+ tokens.push({
1334
+ key,
1335
+ type: entry.kind,
1336
+ value: entry.value,
1337
+ uses: entry.uses
1338
+ });
1339
+ tokenByValue.set(`${entry.kind}:${entry.value}`, key);
1340
+ }
1341
+ const seen = /* @__PURE__ */ new Set();
1342
+ const uniqueTokens = tokens.filter((t) => seen.has(t.key) ? false : (seen.add(t.key), true));
1343
+ const groups = [];
1344
+ const groupNames = /* @__PURE__ */ new Set();
1345
+ for (const [, hit] of signatures) {
1346
+ if (hit.ids.length < minUses) continue;
1347
+ const props = {};
1348
+ let complete = true;
1349
+ for (const [prop, value] of Object.entries(hit.props)) {
1350
+ const kind = HEX_RE.test(value) ? "color" : prop === "cornerRadius" ? "radius" : "spacing";
1351
+ const key = tokenByValue.get(`${kind}:${value}`);
1352
+ if (key === void 0) {
1353
+ complete = false;
1354
+ break;
1355
+ }
1356
+ props[prop] = `$${key}`;
1357
+ }
1358
+ if (!complete || Object.keys(props).length < 2) continue;
1359
+ let name = `preset-${hit.target}`;
1360
+ let i = 2;
1361
+ while (groupNames.has(name)) name = `preset-${hit.target}-${i++}`;
1362
+ groupNames.add(name);
1363
+ groups.push({
1364
+ name,
1365
+ target: hit.target,
1366
+ props,
1367
+ uses: hit.ids
1368
+ });
1369
+ }
1370
+ return {
1371
+ tokens: uniqueTokens,
1372
+ groups
1373
+ };
1374
+ }
1375
+ /**
1376
+ * 应用提取结果:写 token / preset 组,并把命中的节点改写成引用。
1377
+ * 传 expectedMtimeMs 时,**先**检查设计稿再写预设,避免计划过期后留下半套变更。
1378
+ */
1379
+ function applyEstablish(designPath, plan, opts = {}) {
1380
+ if (opts.expectedMtimeMs !== void 0 && statSync(designPath).mtimeMs !== opts.expectedMtimeMs) throw new FileChangedError(designPath);
1381
+ const presetPath = `${findProjectRootStrict(designPath)}/${PRESET_FILE}`;
1382
+ const existingTokens = new Map(listPresets(presetPath).items.map((item) => [item.key, item]));
1383
+ for (const token of plan.tokens) {
1384
+ const existing = existingTokens.get(token.key);
1385
+ if (existing !== void 0 && (existing.type !== token.type || existing.value !== token.value)) throw new Error(`token "${token.key}" 已存在且定义不同,拒绝覆盖(现有 ${existing.value},候选 ${token.value})`);
1386
+ }
1387
+ const existingGroups = listPresetGroups(presetPath).groups;
1388
+ for (const group of plan.groups) {
1389
+ const existing = existingGroups[group.name];
1390
+ if (existing !== void 0 && (existing.target !== group.target || JSON.stringify(existing.props) !== JSON.stringify(group.props))) throw new Error(`preset 组 "${group.name}" 已存在且定义不同,拒绝覆盖;请先调整候选组名`);
1391
+ }
1392
+ const writtenTokens = [];
1393
+ for (const t of plan.tokens) {
1394
+ setPreset(presetPath, {
1395
+ key: t.key,
1396
+ type: t.type,
1397
+ value: t.value
1398
+ });
1399
+ writtenTokens.push(t.key);
1400
+ }
1401
+ const writtenGroups = [];
1402
+ for (const g of plan.groups) {
1403
+ setPresetGroup(presetPath, {
1404
+ name: g.name,
1405
+ target: g.target,
1406
+ props: g.props
1407
+ });
1408
+ writtenGroups.push(g.name);
1409
+ }
1410
+ const loaded = loadDoc(designPath);
1411
+ const groupByNode = /* @__PURE__ */ new Map();
1412
+ for (const g of plan.groups) for (const id of g.uses) groupByNode.set(id, g);
1413
+ const tokenByValue = /* @__PURE__ */ new Map();
1414
+ for (const t of plan.tokens) tokenByValue.set(`${t.type}:${t.value.toUpperCase()}`, t.key);
1415
+ let updated = 0;
1416
+ const rewrite = (nodes, inheritedAllowLiteral = false) => {
1417
+ for (const raw of nodes) {
1418
+ if (!isRecord(raw)) continue;
1419
+ const node = raw;
1420
+ if (inheritedAllowLiteral || allowsLiteral(node)) {
1421
+ const kids = node["children"];
1422
+ if (Array.isArray(kids)) rewrite(kids, true);
1423
+ continue;
1424
+ }
1425
+ const id = typeof node["id"] === "string" ? node["id"] : "";
1426
+ const group = groupByNode.get(id);
1427
+ if (group !== void 0 && node["preset"] === void 0) {
1428
+ for (const prop of Object.keys(group.props)) delete node[prop];
1429
+ node["preset"] = group.name;
1430
+ updated += 1;
1431
+ } else {
1432
+ let touched = false;
1433
+ for (const prop of COLOR_PROPS) {
1434
+ const value = node[prop];
1435
+ if (typeof value !== "string" || !HEX_RE.test(value)) continue;
1436
+ const key = tokenByValue.get(`color:${value.toUpperCase()}`);
1437
+ if (key !== void 0) {
1438
+ node[prop] = `$${key}`;
1439
+ touched = true;
1440
+ }
1441
+ }
1442
+ for (const { prop, kind } of NUM_PROPS) {
1443
+ const value = node[prop];
1444
+ if (typeof value !== "number" || !Number.isFinite(value)) continue;
1445
+ const key = tokenByValue.get(`${kind}:${String(value)}`);
1446
+ if (key !== void 0) {
1447
+ node[prop] = `$${key}`;
1448
+ touched = true;
1449
+ }
1450
+ }
1451
+ if (touched) updated += 1;
1452
+ }
1453
+ const kids = node["children"];
1454
+ if (Array.isArray(kids)) rewrite(kids);
1455
+ }
1456
+ };
1457
+ rewrite(loaded.doc.children);
1458
+ if (updated > 0) saveDoc(designPath, loaded.doc, {
1459
+ expectedMtimeMs: loaded.mtimeMs,
1460
+ flavor: loaded.flavor
1461
+ });
1462
+ return {
1463
+ applied: true,
1464
+ tokens: plan.tokens,
1465
+ groups: plan.groups,
1466
+ written: {
1467
+ tokens: writtenTokens,
1468
+ groups: writtenGroups,
1469
+ nodes: updated
1470
+ }
1471
+ };
1472
+ }
1473
+ function resolveVars(doc, presetDoc) {
1474
+ const out = {};
1475
+ for (const src of [presetDoc?.variables, doc.variables]) for (const [k, def] of Object.entries(src ?? {})) {
1476
+ const raw = isRecord(def) ? def["value"] : void 0;
1477
+ const v = Array.isArray(raw) ? raw[0]?.["value"] : raw;
1478
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = String(v);
1479
+ }
1480
+ return out;
1481
+ }
1482
+ /**
1483
+ * 改一处的影响面(dry-run):token 被哪些节点/组引用、preset 组被哪些节点引用,
1484
+ * 每条都给出 `before → after`。不写盘。
1485
+ */
1486
+ function planAdjust(doc, presetDoc, req) {
1487
+ const groups = {
1488
+ ...presetGroupsOf(presetDoc),
1489
+ ...presetGroupsOf(doc)
1490
+ };
1491
+ const vars = resolveVars(doc, presetDoc);
1492
+ const changes = [];
1493
+ if (req.key !== void 0 && req.key !== "") {
1494
+ const ref = `$${req.key}`;
1495
+ const before = vars[req.key] ?? "";
1496
+ const walk = (nodes) => {
1497
+ for (const raw of nodes) {
1498
+ if (!isRecord(raw)) continue;
1499
+ const id = typeof raw["id"] === "string" ? raw["id"] : "?";
1500
+ const scan = (value, propPath) => {
1501
+ if (value === ref) {
1502
+ changes.push({
1503
+ where: id,
1504
+ prop: propPath,
1505
+ before,
1506
+ after: req.value
1507
+ });
1508
+ return;
1509
+ }
1510
+ if (Array.isArray(value)) {
1511
+ value.forEach((entry, index) => scan(entry, `${propPath}[${index}]`));
1512
+ return;
1513
+ }
1514
+ if (isRecord(value)) for (const [key, entry] of Object.entries(value)) scan(entry, `${propPath}.${key}`);
1515
+ };
1516
+ for (const [prop, value] of Object.entries(raw)) if (prop !== "children") scan(value, prop);
1517
+ const kids = raw["children"];
1518
+ if (Array.isArray(kids)) walk(kids);
1519
+ }
1520
+ };
1521
+ walk(doc.children);
1522
+ for (const [name, g] of Object.entries(groups)) for (const [prop, value] of Object.entries(g.props)) if (value === ref) changes.push({
1523
+ where: `<preset:${name}>`,
1524
+ prop,
1525
+ before,
1526
+ after: req.value
1527
+ });
1528
+ return {
1529
+ kind: "token",
1530
+ target: req.key,
1531
+ value: req.value,
1532
+ changes
1533
+ };
1534
+ }
1535
+ if (req.group !== void 0 && req.group !== "" && req.prop !== void 0 && req.prop !== "") {
1536
+ const group = groups[req.group];
1537
+ if (group === void 0) throw new Error(`preset 组不存在:${req.group}`);
1538
+ const groupProp = req.prop;
1539
+ const beforeRef = group.props[groupProp] ?? "";
1540
+ const before = beforeRef.startsWith("$") ? vars[beforeRef.slice(1)] ?? beforeRef : beforeRef;
1541
+ const walk = (nodes) => {
1542
+ for (const raw of nodes) {
1543
+ if (!isRecord(raw)) continue;
1544
+ const id = typeof raw["id"] === "string" ? raw["id"] : "?";
1545
+ if (raw["preset"] === req.group && raw[groupProp] === void 0) changes.push({
1546
+ where: id,
1547
+ prop: groupProp,
1548
+ before,
1549
+ after: req.value
1550
+ });
1551
+ const kids = raw["children"];
1552
+ if (Array.isArray(kids)) walk(kids);
1553
+ }
1554
+ };
1555
+ walk(doc.children);
1556
+ return {
1557
+ kind: "group",
1558
+ target: req.group,
1559
+ prop: groupProp,
1560
+ value: req.value,
1561
+ changes
1562
+ };
1563
+ }
1564
+ throw new Error("adjust 需要 key(改 token)或 group + prop(改 preset 组属性)");
1565
+ }
1566
+ /** 应用:只写 `__preset.ptr`;设计稿里的引用自动跟着变 */
1567
+ function applyAdjust(designPath, presetDoc, plan) {
1568
+ if (plan.kind === "token") {
1569
+ const existing = listPresets(designPath).items.find((p) => p.key === plan.target);
1570
+ if (existing === void 0) throw new Error(`token 不存在:${plan.target}`);
1571
+ setPreset(`${findProjectRootStrict(designPath)}/${PRESET_FILE}`, {
1572
+ key: existing.key,
1573
+ type: existing.type,
1574
+ value: plan.value,
1575
+ name: existing.name
1576
+ });
1577
+ return {
1578
+ ...plan,
1579
+ applied: true
1580
+ };
1581
+ }
1582
+ const group = presetGroupsOf(presetDoc)[plan.target];
1583
+ if (group === void 0) throw new Error(`preset 组不存在:${plan.target}`);
1584
+ const prop = plan.prop;
1585
+ if (prop === void 0 || prop === "") throw new Error(`adjust 计划缺少组属性名:${plan.target}`);
1586
+ const nextProps = {
1587
+ ...group.props,
1588
+ [prop]: plan.value
1589
+ };
1590
+ setPresetGroup(`${findProjectRootStrict(designPath)}/${PRESET_FILE}`, {
1591
+ name: plan.target,
1592
+ target: group.target,
1593
+ props: nextProps
1594
+ });
1595
+ return {
1596
+ ...plan,
1597
+ applied: true
1598
+ };
1599
+ }
1600
+ /** 读项目预设文档(没有就是 null) */
1601
+ function readPresetDoc(designPath) {
1602
+ const root = findProjectRoot(designPath);
1603
+ if (root === null) return null;
1604
+ return readPresetFile(`${root}/__preset.ptr`)?.doc ?? null;
1605
+ }
1606
+ // src/shot/screenshot.ts
1607
+ /**
1608
+ * 截图层:渲染 → 临时 HTML → headless Chromium → PNG(决策 3:两个消费者分开,AI 要 PNG)。
1609
+ * 浏览器探测:PTR_BROWSER 环境变量 → 系统 chrome → playwright 浏览器缓存。
1610
+ */
1611
+ function findBrowser() {
1612
+ const env = process.env["PTR_BROWSER"];
1613
+ if (env && existsSync(env)) return env;
1614
+ for (const c of [
1615
+ "/usr/bin/google-chrome",
1616
+ "/usr/bin/google-chrome-stable",
1617
+ "/usr/bin/chromium",
1618
+ "/usr/bin/chromium-browser"
1619
+ ]) if (existsSync(c)) return c;
1620
+ const cacheDir = join(homedir(), ".cache", "ms-playwright");
1621
+ if (existsSync(cacheDir)) {
1622
+ const dirs = readdirSync(cacheDir).filter((d) => d.startsWith("chromium-")).sort((a, b) => b.localeCompare(a));
1623
+ for (const d of dirs) for (const rel of [
1624
+ "chrome-linux/chrome",
1625
+ "chrome-linux64/chrome",
1626
+ "chrome-headless-shell-linux64/chrome-headless-shell"
1627
+ ]) {
1628
+ const p = join(cacheDir, d, rel);
1629
+ if (existsSync(p)) return p;
1630
+ }
1631
+ }
1632
+ return null;
1633
+ }
1634
+ /** 单边像素上限:超出按比例压回,只为不把内存/临时文件吃爆 */
1635
+ const MAX_SHOT_EDGE = 8192;
1636
+ /** agent 自审图的默认倍率 */
1637
+ const DEFAULT_SHOT_SCALE = .8;
1638
+ /** 倍率下限:再小就看不清字了 */
1639
+ const MIN_SHOT_SCALE = .2;
1640
+ /**
1641
+ * 倍率决策:先夹进 [0.2, 2],再按画布/画板的**设计尺寸**(1x 像素数)等比压进 8192。
1642
+ * 1x 的定义就是查看器里的 `1:1`——画板自身的像素尺寸,所以这里量的是 CSS px。
1643
+ */
1644
+ function planShotScale(requested, width, height) {
1645
+ const asked = requested !== void 0 && Number.isFinite(requested) ? requested : DEFAULT_SHOT_SCALE;
1646
+ const clampedValue = Math.min(2, Math.max(MIN_SHOT_SCALE, asked));
1647
+ const w = Math.max(1, width);
1648
+ const h = Math.max(1, height);
1649
+ const fit = Math.min(MAX_SHOT_EDGE / w, MAX_SHOT_EDGE / h);
1650
+ const scale = Math.min(clampedValue, fit);
1651
+ return {
1652
+ scale,
1653
+ clamped: clampedValue !== asked,
1654
+ capped: scale < clampedValue
1655
+ };
1656
+ }
1657
+ function boardLabel(b) {
1658
+ return b.name !== void 0 && b.name !== "" ? `${b.name}(id=${b.id})` : b.id;
1659
+ }
1660
+ /**
1661
+ * 画板选择子:id 精确 → name 精确 → name/id 模糊(不区分大小写)。
1662
+ * 模糊命中多个时报歧义并列出候选,不猜——猜错会让 AI 以为看到了另一页。
1663
+ */
1664
+ function selectBoard(boards, query) {
1665
+ if (boards.length === 0) throw new Error("设计稿没有画板(顶层节点为空),无法按画板截图");
1666
+ const q = query.trim();
1667
+ if (q === "") throw new Error("board 参数不能为空");
1668
+ const byId = boards.filter((b) => b.id === q);
1669
+ if (byId.length === 1) return byId[0];
1670
+ const byName = boards.filter((b) => b.name === q);
1671
+ if (byName.length === 1) return byName[0];
1672
+ const lower = q.toLowerCase();
1673
+ const fuzzy = boards.filter((b) => b.id.toLowerCase().includes(lower) || (b.name ?? "").toLowerCase().includes(lower));
1674
+ if (fuzzy.length === 1) return fuzzy[0];
1675
+ const all = boards.map(boardLabel).join("、");
1676
+ if (fuzzy.length === 0) throw new Error(`没有匹配「${q}」的画板;可选:${all}`);
1677
+ throw new Error(`「${q}」命中 ${fuzzy.length} 个画板,请写更精确的名字或 id:${fuzzy.map(boardLabel).join("、")}`);
1678
+ }
1679
+ /** 渲染并截图整张画布;指定 board 时只裁该画板(走同一套渲染与浏览器) */
1680
+ async function screenshotPen(penPath, outPng, opts = {}) {
1681
+ const browserPath = findBrowser();
1682
+ if (!browserPath) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER 指向可执行文件");
1683
+ const loaded = loadDoc(penPath);
1684
+ const root = findProjectRoot(penPath) ?? dirname(resolve(penPath));
1685
+ const { registry: fontRegistry } = projectFontRegistry(penPath);
1686
+ const fontPlan = planFonts(fontRegistry, (source) => source.target === void 0 ? null : pathToFileURL(resolve(root, source.target)).href);
1687
+ const rendered = renderDocument(applyProjectPresets(loaded.doc, penPath).doc, { fonts: fontPlan });
1688
+ const probeFamilies = [.../* @__PURE__ */ new Set([...Object.keys(fontRegistry.families), ...splitFontStack(fontPlan.defaultStack).filter((family) => !isGenericFamily(family))])];
1689
+ const target = opts.board !== void 0 ? selectBoard(rendered.boards, opts.board) : void 0;
1690
+ const tmpDir = mkdtempSync(join(tmpdir(), "paintress-shot-"));
1691
+ const htmlPath = join(tmpDir, "page.html");
1692
+ writeFileSync(htmlPath, rendered.html, "utf8");
1693
+ let clip;
1694
+ let measuredBoard;
1695
+ let usedScale = DEFAULT_SHOT_SCALE;
1696
+ let fontsReport;
1697
+ const extraWarnings = [];
1698
+ const cacheDir = join(process.env["PAINTRESS_HOME"] ?? join(homedir(), ".paintress"), "chromium-cache");
1699
+ try {
1700
+ mkdirSync(cacheDir, { recursive: true });
1701
+ } catch {}
1702
+ try {
1703
+ const browser = await chromium.launch({
1704
+ executablePath: browserPath,
1705
+ args: [
1706
+ "--no-sandbox",
1707
+ "--disable-dev-shm-usage",
1708
+ `--disk-cache-dir=${cacheDir}`,
1709
+ "--disk-cache-size=67108864"
1710
+ ]
1711
+ });
1712
+ try {
1713
+ const initial = planShotScale(opts.scale, rendered.canvas.width || 1, rendered.canvas.height || 1);
1714
+ const page = await browser.newPage({
1715
+ viewport: {
1716
+ width: Math.max(800, rendered.canvas.width, target !== void 0 ? Math.ceil(target.x + 3200) : 0),
1717
+ height: Math.max(600, rendered.canvas.height, target !== void 0 ? Math.ceil(target.y + 3200) : 0)
1718
+ },
1719
+ deviceScaleFactor: initial.scale
1720
+ });
1721
+ await page.goto(`file://${htmlPath}`);
1722
+ const fontsScript = `(async () => {
1723
+ const families = ${JSON.stringify(probeFamilies)};
1724
+ const wait = new Promise((r) => setTimeout(r, 3000));
1725
+ if (window.__paintressFontsReady) await Promise.race([window.__paintressFontsReady, wait]);
1726
+ else if (document.fonts && document.fonts.ready) await Promise.race([document.fonts.ready, wait]);
1727
+ if (!document.fonts) return { status: 'n/a', checked: families, missing: [] };
1728
+ const c = document.createElement('canvas').getContext('2d');
1729
+ const text = '漢字Abc123 光伴';
1730
+ const w = (f, b) => { c.font = '32px "' + f + '", ' + b; return c.measureText(text).width; };
1731
+ const sb = w('__paintress_missing__', 'serif');
1732
+ const mb = w('__paintress_missing__', 'monospace');
1733
+ const missing = families.filter((f) => Math.abs(w(f, 'serif') - sb) < 0.5 && Math.abs(w(f, 'monospace') - mb) < 0.5);
1734
+ return { status: document.fonts.status, checked: families, missing };
1735
+ })()`;
1736
+ fontsReport = await page.evaluate(fontsScript);
1737
+ await page.waitForTimeout(opts.settleMs ?? 120);
1738
+ const measured = await page.evaluate(`(() => {
1739
+ const canvasEl = document.querySelector('.ptr-canvas');
1740
+ const cr = canvasEl ? canvasEl.getBoundingClientRect() : null;
1741
+ const nodes = Array.from(document.querySelectorAll('[data-board]'));
1742
+ const boards = nodes.map((el) => {
1743
+ const r = el.getBoundingClientRect();
1744
+ return {
1745
+ id: el.getAttribute('data-node-id') || '',
1746
+ name: el.getAttribute('data-board-name') || undefined,
1747
+ x: cr ? r.left - cr.left : 0,
1748
+ y: cr ? r.top - cr.top : 0,
1749
+ w: r.width,
1750
+ h: r.height
1751
+ };
1752
+ });
1753
+ // 自动尺寸的设计稿常常撑出 .ptr-canvas 的固定宽高:以真实内容盒为准
1754
+ let contentW = cr ? cr.width : 0;
1755
+ let contentH = cr ? cr.height : 0;
1756
+ for (const b of boards) {
1757
+ contentW = Math.max(contentW, b.x + b.w);
1758
+ contentH = Math.max(contentH, b.y + b.h);
1759
+ }
1760
+ return { width: Math.ceil(contentW), height: Math.ceil(contentH), boards };
1761
+ })()`);
1762
+ const realTarget = target === void 0 ? void 0 : measured.boards.find((b) => b.id === target.id) ?? void 0;
1763
+ if (realTarget !== void 0 && target !== void 0) {
1764
+ if (realTarget.w <= 0 || realTarget.h <= 0) throw new Error(`画板 ${boardLabel(target)} 尺寸为 0(${realTarget.w}×${realTarget.h}),无法截图`);
1765
+ measuredBoard = {
1766
+ id: target.id,
1767
+ ...target.name !== void 0 ? { name: target.name } : {},
1768
+ x: Math.round(realTarget.x),
1769
+ y: Math.round(realTarget.y),
1770
+ w: realTarget.w,
1771
+ h: realTarget.h
1772
+ };
1773
+ } else if (target !== void 0) {
1774
+ if (target.w <= 0 || target.h <= 0) throw new Error(`画板 ${boardLabel(target)} 尺寸为 0(${target.w}×${target.h}),无法截图`);
1775
+ measuredBoard = {
1776
+ id: target.id,
1777
+ ...target.name !== void 0 ? { name: target.name } : {},
1778
+ x: target.x,
1779
+ y: target.y,
1780
+ w: target.w,
1781
+ h: target.h
1782
+ };
1783
+ }
1784
+ const box = measuredBoard ?? {
1785
+ x: 0,
1786
+ y: 0,
1787
+ w: measured.width,
1788
+ h: measured.height
1789
+ };
1790
+ clip = {
1791
+ x: Math.max(0, Math.floor(box.x)),
1792
+ y: Math.max(0, Math.floor(box.y)),
1793
+ width: Math.max(1, Math.ceil(box.w)),
1794
+ height: Math.max(1, Math.ceil(box.h))
1795
+ };
1796
+ const vp = page.viewportSize();
1797
+ const needW = clip.x + clip.width;
1798
+ const needH = clip.y + clip.height;
1799
+ if (vp === null || vp.width < needW || vp.height < needH) {
1800
+ await page.setViewportSize({
1801
+ width: Math.max(vp?.width ?? 0, needW),
1802
+ height: Math.max(vp?.height ?? 0, needH)
1803
+ });
1804
+ await page.waitForTimeout(20);
1805
+ }
1806
+ const plan = planShotScale(opts.scale, clip.width, clip.height);
1807
+ usedScale = plan.scale;
1808
+ if (plan.clamped) extraWarnings.push(`倍率 ${String(opts.scale)} 超出 0.2–2,已按 ${plan.scale}x 输出`);
1809
+ if (plan.capped) extraWarnings.push(`画板 ${clip.width}×${clip.height}(1x)按 ${plan.scale.toFixed(2)}x 输出:单边超过 ${MAX_SHOT_EDGE} 上限,已等比压回`);
1810
+ if (Math.abs(plan.scale - initial.scale) > 1e-6) {
1811
+ const finalVp = page.viewportSize();
1812
+ await page.close();
1813
+ const fitted = await browser.newPage({
1814
+ viewport: {
1815
+ width: Math.max(1, Math.ceil(finalVp?.width ?? clip.x + clip.width)),
1816
+ height: Math.max(1, Math.ceil(finalVp?.height ?? clip.y + clip.height))
1817
+ },
1818
+ deviceScaleFactor: plan.scale
1819
+ });
1820
+ try {
1821
+ await fitted.goto(`file://${htmlPath}`);
1822
+ await fitted.waitForTimeout(opts.settleMs ?? 120);
1823
+ await fitted.screenshot({
1824
+ path: outPng,
1825
+ clip
1826
+ });
1827
+ } finally {
1828
+ await fitted.close();
1829
+ }
1830
+ } else await page.screenshot({
1831
+ path: outPng,
1832
+ clip
1833
+ });
1834
+ } finally {
1835
+ await browser.close();
1836
+ }
1837
+ } finally {
1838
+ rmSync(tmpDir, {
1839
+ recursive: true,
1840
+ force: true
1841
+ });
1842
+ }
1843
+ if (statSync(outPng).size === 0) throw new Error("截图产物为空文件");
1844
+ const canvas = {
1845
+ width: Math.max(rendered.canvas.width, clip.x + clip.width),
1846
+ height: Math.max(rendered.canvas.height, clip.y + clip.height)
1847
+ };
1848
+ const substituted = (fontsReport?.missing ?? []).filter((family) => fontPlan.previews[family] !== void 0);
1849
+ if (fontsReport !== void 0 && fontsReport.missing.length > 0) extraWarnings.push(`字体缺失:${fontsReport.missing.join("、")}${substituted.length > 0 ? `(用替代:${substituted.join("、")})` : ""}`);
1850
+ const result = {
1851
+ out: outPng,
1852
+ canvas,
1853
+ scale: usedScale,
1854
+ warnings: [...rendered.warnings, ...extraWarnings],
1855
+ browser: browserPath,
1856
+ ...fontsReport !== void 0 ? { fonts: {
1857
+ ...fontsReport,
1858
+ substituted
1859
+ } } : {}
1860
+ };
1861
+ if (measuredBoard !== void 0) result.board = {
1862
+ id: measuredBoard.id,
1863
+ ...measuredBoard.name !== void 0 ? { name: measuredBoard.name } : {},
1864
+ x: measuredBoard.x,
1865
+ y: measuredBoard.y,
1866
+ width: measuredBoard.w,
1867
+ height: measuredBoard.h
1868
+ };
1869
+ return result;
1870
+ }
1871
+ // src/web/protocol.ts
1872
+ function encodeLine(msg) {
1873
+ return `${JSON.stringify(msg)}\n`;
1874
+ }
1875
+ /** 逐行解析器:处理流式分片,容忍尾部不完整行 */
1876
+ var LineDecoder = class {
1877
+ buf = "";
1878
+ push(chunk) {
1879
+ this.buf += chunk;
1880
+ const out = [];
1881
+ for (;;) {
1882
+ const idx = this.buf.indexOf("\n");
1883
+ if (idx < 0) break;
1884
+ const line = this.buf.slice(0, idx);
1885
+ this.buf = this.buf.slice(idx + 1);
1886
+ if (line.trim() !== "") out.push(line);
1887
+ }
1888
+ return out;
1889
+ }
1890
+ };
1891
+ // src/web/paths.ts
1892
+ /** 运行期文件位置:默认 ~/.paintress/;可用 PAINTRESS_HOME 覆盖(测试隔离用) */
1893
+ function runtimeDir() {
1894
+ const dir = process.env["PAINTRESS_HOME"] ?? join(homedir(), ".paintress");
1895
+ mkdirSync(dir, {
1896
+ recursive: true,
1897
+ mode: 448
1898
+ });
1899
+ return dir;
1900
+ }
1901
+ function socketPath() {
1902
+ return join(runtimeDir(), "web.sock");
1903
+ }
1904
+ function lockPath() {
1905
+ return join(runtimeDir(), "web.lock");
1906
+ }
1907
+ function pidPath() {
1908
+ return join(runtimeDir(), "web.pid");
1909
+ }
1910
+ /**
1911
+ * 项目名:URL 安全(字母数字点下划线连字符与 `/`),冲突时加短 hash 后缀。
1912
+ * 允许 `/` 表达目录层级:默认项目名就是 `父目录/自身`(如 `c-thun/raybend`)。
1913
+ */
1914
+ function sanitizeProjectName(name) {
1915
+ const cleaned = name.replace(/[^A-Za-z0-9._/-]/g, "-").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/^[./-]+|[./-]+$/g, "");
1916
+ return cleaned === "" ? "project" : cleaned;
1917
+ }
1918
+ /**
1919
+ * 由目录推导默认项目名:`父目录/自身`(适配 repos/<org>/<project> 结构)。
1920
+ * key 里直接带 `/`,URL 与展示都无需再转义。
1921
+ */
1922
+ function deriveProjectName(dir) {
1923
+ const abs = resolve(dir);
1924
+ const parent = basename(dirname(abs));
1925
+ const self = basename(abs);
1926
+ return sanitizeProjectName(parent && parent !== "/" ? `${parent}/${self}` : self);
1927
+ }
1928
+ /** 校验并解析注册目录:必须存在且是目录;返回真实路径(供穿越校验基准) */
1929
+ function resolveProjectDir(dir) {
1930
+ const abs = resolve(dir);
1931
+ if (!existsSync(abs)) throw new Error(`目录不存在:${abs}`);
1932
+ if (!statSync(abs).isDirectory()) throw new Error(`不是目录:${abs}`);
1933
+ return {
1934
+ root: abs,
1935
+ rootReal: realpathSync(abs)
1936
+ };
1937
+ }
1938
+ /**
1939
+ * 路径穿越防护:把请求路径解析为真实绝对路径,并确认它仍在项目根内。
1940
+ * 失败返回 null(调用方回 404,不泄露真实路径)。
1941
+ */
1942
+ function safeJoin(projectRootReal, requestPath) {
1943
+ if (requestPath.includes("\0")) return null;
1944
+ const normalized = requestPath.replace(/^\/+/, "");
1945
+ const target = resolve(join(projectRootReal, normalized));
1946
+ const relStr = relative(projectRootReal, target);
1947
+ if (relStr === "" || relStr.startsWith("..") || relStr.includes(`..${sep}`)) return null;
1948
+ if (!existsSync(target)) return null;
1949
+ let real;
1950
+ try {
1951
+ real = realpathSync(target);
1952
+ } catch {
1953
+ return null;
1954
+ }
1955
+ const relReal = relative(projectRootReal, real);
1956
+ if (relReal.startsWith("..") || relReal === "") return null;
1957
+ return real;
1958
+ }
1959
+ // src/web/client.ts
1960
+ /** 控制面客户端:一次请求/响应,或保持长连接(attach 用) */
1961
+ function isDaemonRunning() {
1962
+ return existsSync(socketPath());
1963
+ }
1964
+ /**
1965
+ * 查询运行中的 daemon:未运行、socket 残留、daemon 无响应都返回 null。
1966
+ * 调用方拿到 null 就可以安全地走「启动」分支 —— 幂等的判据只有这一处。
1967
+ */
1968
+ async function queryDaemon(timeoutMs = 2e3) {
1969
+ if (!isDaemonRunning()) return null;
1970
+ try {
1971
+ const res = await sendRequest({ kind: "ping" }, timeoutMs);
1972
+ if (res.ok && "pong" in res) return {
1973
+ pid: res.pid,
1974
+ port: res.port,
1975
+ uptimeMs: res.uptimeMs
1976
+ };
1977
+ } catch {}
1978
+ return null;
1979
+ }
1980
+ function connectSocket() {
1981
+ return new Promise((resolve, reject) => {
1982
+ const sock = connect(socketPath());
1983
+ const onError = (err) => {
1984
+ sock.destroy();
1985
+ reject(/* @__PURE__ */ new Error(`无法连接 paintress web(${socketPath()}):${err.message}`));
1986
+ };
1987
+ sock.once("error", onError);
1988
+ sock.once("connect", () => {
1989
+ sock.off("error", onError);
1990
+ sock.on("error", () => {});
1991
+ resolve(sock);
1992
+ });
1993
+ });
1994
+ }
1995
+ /** 单次请求;超时默认 5s */
1996
+ async function sendRequest(req, timeoutMs = 5e3) {
1997
+ const sock = await connectSocket();
1998
+ return new Promise((resolve, reject) => {
1999
+ const decoder = new LineDecoder();
2000
+ const timer = setTimeout(() => {
2001
+ sock.destroy();
2002
+ reject(/* @__PURE__ */ new Error(`请求超时(${timeoutMs}ms)`));
2003
+ }, timeoutMs);
2004
+ sock.on("data", (chunk) => {
2005
+ for (const line of decoder.push(chunk.toString("utf8"))) {
2006
+ let parsed;
2007
+ try {
2008
+ parsed = JSON.parse(line);
2009
+ } catch {
2010
+ continue;
2011
+ }
2012
+ clearTimeout(timer);
2013
+ sock.end();
2014
+ resolve(parsed);
2015
+ return;
2016
+ }
2017
+ });
2018
+ sock.on("close", () => {
2019
+ clearTimeout(timer);
2020
+ reject(/* @__PURE__ */ new Error("daemon 在响应前关闭了连接"));
2021
+ });
2022
+ sock.on("error", (err) => {
2023
+ clearTimeout(timer);
2024
+ reject(err);
2025
+ });
2026
+ sock.write(encodeLine(req));
2027
+ });
2028
+ }
2029
+ /**
2030
+ * 持久 attach:连接保持打开,进程退出时 OS 关闭连接 → daemon 立即感知。
2031
+ * 这取代了 PID 轮询(PID 会被内核复用,不可靠)。
2032
+ */
2033
+ async function attachPersistent(project) {
2034
+ const sock = await connectSocket();
2035
+ const decoder = new LineDecoder();
2036
+ let closed = false;
2037
+ const closeCbs = [];
2038
+ return new Promise((resolve, reject) => {
2039
+ const timer = setTimeout(() => {
2040
+ sock.destroy();
2041
+ reject(/* @__PURE__ */ new Error("attach 超时"));
2042
+ }, 5e3);
2043
+ sock.on("data", (chunk) => {
2044
+ for (const line of decoder.push(chunk.toString("utf8"))) {
2045
+ let parsed;
2046
+ try {
2047
+ parsed = JSON.parse(line);
2048
+ } catch {
2049
+ continue;
2050
+ }
2051
+ if (parsed.ok && "attached" in parsed) {
2052
+ clearTimeout(timer);
2053
+ resolve({
2054
+ close: () => {
2055
+ if (closed) return;
2056
+ closed = true;
2057
+ sock.end();
2058
+ },
2059
+ isClosed: () => closed,
2060
+ onClose: (cb) => closeCbs.push(cb)
2061
+ });
2062
+ } else if (!parsed.ok) {
2063
+ clearTimeout(timer);
2064
+ sock.destroy();
2065
+ reject(new Error(parsed.error));
2066
+ }
2067
+ }
2068
+ });
2069
+ sock.on("close", () => {
2070
+ closed = true;
2071
+ for (const cb of closeCbs) cb();
2072
+ });
2073
+ sock.on("error", (err) => {
2074
+ clearTimeout(timer);
2075
+ reject(err);
2076
+ });
2077
+ sock.write(encodeLine({
2078
+ kind: "attach",
2079
+ project
2080
+ }));
2081
+ });
2082
+ }
2083
+ function daemonPid() {
2084
+ try {
2085
+ const raw = readFileSync(pidPath(), "utf8").trim();
2086
+ const pid = Number(raw);
2087
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
2088
+ } catch {
2089
+ return null;
2090
+ }
2091
+ }
2092
+ // src/web/spawn.ts
2093
+ const START_TIMEOUT_MS = 6e3;
2094
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2095
+ /**
2096
+ * 复用已在运行的 daemon;显式指定了端口且与运行中的不一致时抛错
2097
+ * (幂等不等于「悄悄忽略你的参数」:要换端口得先 `paintress stop`)。
2098
+ */
2099
+ async function reuseDaemon(port) {
2100
+ const info = await queryDaemon();
2101
+ if (info === null) return null;
2102
+ if (port !== void 0 && port !== info.port) throw new Error(`paintress web 已在运行(pid ${info.pid},端口 ${info.port}),与请求的端口 ${port} 不同;如需换端口请先 paintress stop`);
2103
+ return info;
2104
+ }
2105
+ /** 启动超时的诊断信息:区分「进程没起来」与「进程在但控制面不通」 */
2106
+ function timeoutHint() {
2107
+ const pid = daemonPid();
2108
+ if (pid !== null) try {
2109
+ process.kill(pid, 0);
2110
+ return `;进程 ${pid} 仍在运行但控制面无响应,先运行 paintress stop(或 kill ${pid})后重试`;
2111
+ } catch {}
2112
+ return ";可能是锁或端口残留,先 paintress ping 看状态,或前台跑 paintress web 看报错";
2113
+ }
2114
+ async function ensureDaemon(opts = {}) {
2115
+ const running = await reuseDaemon(opts.port);
2116
+ if (running !== null) return {
2117
+ started: false,
2118
+ pid: running.pid,
2119
+ port: running.port
2120
+ };
2121
+ const entry = process.argv[1];
2122
+ if (!entry) throw new Error("无法确定 CLI 入口路径,不能后台启动 daemon");
2123
+ const args = [
2124
+ entry,
2125
+ "web",
2126
+ "--foreground"
2127
+ ];
2128
+ if (opts.port !== void 0) args.push("--port", String(opts.port));
2129
+ if (opts.idleTimeoutMs !== void 0) args.push("--idle", String(opts.idleTimeoutMs));
2130
+ if (opts.noOpen === true) args.push("--no-open");
2131
+ const child = spawn(process.execPath, args, {
2132
+ detached: true,
2133
+ stdio: "ignore"
2134
+ });
2135
+ child.unref();
2136
+ let exitCode = null;
2137
+ child.once("exit", (code) => {
2138
+ exitCode = code;
2139
+ });
2140
+ const deadline = Date.now() + START_TIMEOUT_MS;
2141
+ while (Date.now() < deadline) {
2142
+ const info = await queryDaemon(500);
2143
+ if (info !== null) {
2144
+ if (opts.port !== void 0 && info.port !== opts.port) throw new Error(`daemon 起在端口 ${info.port} 而不是请求的 ${opts.port};先 paintress stop 再重试`);
2145
+ return {
2146
+ started: true,
2147
+ pid: info.pid,
2148
+ port: info.port
2149
+ };
2150
+ }
2151
+ if (exitCode !== null) throw new Error(`后台 daemon 启动失败(退出码 ${exitCode})${timeoutHint()}`);
2152
+ await sleep(100);
2153
+ }
2154
+ throw new Error(`后台启动 daemon 超时(${START_TIMEOUT_MS}ms)${timeoutHint()}`);
2155
+ }
2156
+ // src/web/open-browser.ts
2157
+ var open_browser_exports = /* @__PURE__ */ __exportAll({ openBrowser: () => openBrowser });
2158
+ /** 按 PATH 找可执行文件(不 shell out,避免和 shell 引号规则纠缠) */
2159
+ function onPath(cmd) {
2160
+ if (cmd.includes("/")) return existsSync(cmd);
2161
+ for (const dir of (process.env["PATH"] ?? "").split(":")) if (dir !== "" && existsSync(`${dir}/${cmd}`)) return true;
2162
+ return false;
2163
+ }
2164
+ function readOsrelease() {
2165
+ try {
2166
+ return readFileSync("/proc/sys/kernel/osrelease", "utf8");
2167
+ } catch {
2168
+ return null;
2169
+ }
2170
+ }
2171
+ /**
2172
+ * 是否跑在 WSL 里。环境变量优先(WSL_DISTRO_NAME / WSL_INTEROP),
2173
+ * 内核 release 里带 "microsoft" 是兜底(detached 子进程可能继承不到变量)。
2174
+ */
2175
+ function isWsl(ctx = {}) {
2176
+ if ((ctx.platform ?? process.platform) !== "linux") return false;
2177
+ const env = ctx.env ?? process.env;
2178
+ if ((env["WSL_DISTRO_NAME"] ?? "") !== "" || (env["WSL_INTEROP"] ?? "") !== "") return true;
2179
+ const rel = ctx.osrelease !== void 0 ? ctx.osrelease : readOsrelease();
2180
+ return rel !== null && /microsoft/i.test(rel);
2181
+ }
2182
+ /** Windows 侧打开 URL 的候选命令,按可靠度排序 */
2183
+ function windowsLaunchers(url) {
2184
+ const ps = `Start-Process '${url.replace(/'/g, "''")}'`;
2185
+ return [
2186
+ {
2187
+ cmd: "wslview",
2188
+ args: [url]
2189
+ },
2190
+ {
2191
+ cmd: "powershell.exe",
2192
+ args: [
2193
+ "-NoProfile",
2194
+ "-NonInteractive",
2195
+ "-Command",
2196
+ ps
2197
+ ]
2198
+ },
2199
+ {
2200
+ cmd: "cmd.exe",
2201
+ args: [
2202
+ "/c",
2203
+ "start",
2204
+ "",
2205
+ url
2206
+ ]
2207
+ },
2208
+ {
2209
+ cmd: "explorer.exe",
2210
+ args: [url]
2211
+ }
2212
+ ];
2213
+ }
2214
+ /**
2215
+ * 选出「在宿主浏览器里打开」的启动命令;返回 null 表示这台机器上没有可用的浏览器入口
2216
+ * (无图形环境、或 WSL 里一个 interop 命令都没有 —— 此时宁可不弹,也不要弹到错的浏览器)。
2217
+ */
2218
+ function browserLauncher(url, ctx = {}) {
2219
+ const platform = ctx.platform ?? process.platform;
2220
+ const env = ctx.env ?? process.env;
2221
+ const has = ctx.has ?? onPath;
2222
+ if (isWsl(ctx)) return windowsLaunchers(url).find((l) => has(l.cmd)) ?? null;
2223
+ if (platform === "darwin") return has("open") ? {
2224
+ cmd: "open",
2225
+ args: [url]
2226
+ } : null;
2227
+ if (platform === "win32") return {
2228
+ cmd: "cmd",
2229
+ args: [
2230
+ "/c",
2231
+ "start",
2232
+ "",
2233
+ url
2234
+ ]
2235
+ };
2236
+ if ((env["DISPLAY"] ?? "") === "" && (env["WAYLAND_DISPLAY"] ?? "") === "") return null;
2237
+ return has("xdg-open") ? {
2238
+ cmd: "xdg-open",
2239
+ args: [url]
2240
+ } : null;
2241
+ }
2242
+ function openBrowser(url, ctx = {}) {
2243
+ const env = ctx.env ?? process.env;
2244
+ if (env["PAINTRESS_NO_OPEN"] === "1") return false;
2245
+ if (env["CI"] === "1" || env["CI"] === "true") return false;
2246
+ const launcher = browserLauncher(url, ctx);
2247
+ if (launcher === null) return false;
2248
+ try {
2249
+ const child = spawn(launcher.cmd, launcher.args, {
2250
+ detached: true,
2251
+ stdio: "ignore"
2252
+ });
2253
+ child.on("error", () => {});
2254
+ child.unref();
2255
+ return true;
2256
+ } catch {
2257
+ return false;
2258
+ }
2259
+ }
2260
+ /** 顶层页面地址(项目列表);项目深链见 projectUrl */
2261
+ function baseUrl(port) {
2262
+ return `http://localhost:${port}/`;
2263
+ }
2264
+ /** 项目深链:项目 key 本身就带 `/`(`c-thun/raybend`),URL 里不需要转义 */
2265
+ function projectUrl(port, project, file) {
2266
+ if (!project) return baseUrl(port);
2267
+ return `http://localhost:${port}/${project.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${file ? `?file=${encodeURIComponent(file)}` : ""}`;
2268
+ }
2269
+ // src/web/autoregister.ts
2270
+ /**
2271
+ * 每个项目的 attach 连接必须被持有:daemon 用「连接是否还在」判定 agent 是否活着。
2272
+ * 连接一断,agent 自动注册的项目会在宽限期后退场。
2273
+ */
2274
+ const handles = /* @__PURE__ */ new Map();
2275
+ /** 从 daemon 同步「注册根」,让 doc 层的 findProjectRoot 以 watch 为准 */
2276
+ let rootsSynced = false;
2277
+ async function syncProjectRoots() {
2278
+ try {
2279
+ const res = await sendRequest({ kind: "status" }, 2e3);
2280
+ if (res.ok && "projects" in res && Array.isArray(res.projects)) {
2281
+ setProjectRoots(res.projects.map((p) => p.root));
2282
+ rootsSynced = true;
2283
+ }
2284
+ } catch {}
2285
+ }
2286
+ /** 进程退出时释放连接(daemon 立刻看到 agent 走了,比等 OS 回收快) */
2287
+ function closeViewerHandles() {
2288
+ for (const handle of handles.values()) try {
2289
+ handle.close();
2290
+ } catch {}
2291
+ handles.clear();
2292
+ }
2293
+ function autoWebDisabled() {
2294
+ return process.env["PAINTRESS_NO_AUTO_WEB"] === "1";
2295
+ }
2296
+ /** 幂等入口:确保 daemon 在跑、项目已注册、本进程持有存活连接,返回可分享的 URL */
2297
+ async function ensureViewer(opts = {}) {
2298
+ if (autoWebDisabled()) throw new Error("PAINTRESS_NO_AUTO_WEB=1:已关闭自动查看器,需要手动运行 paintress web");
2299
+ const dir = resolve(opts.dir ?? process.cwd());
2300
+ const project = opts.project !== void 0 ? sanitizeProjectName(opts.project) : deriveProjectName(dir);
2301
+ const running = await queryDaemon();
2302
+ const attached = handles.get(project);
2303
+ if (running !== null && attached !== void 0 && !attached.isClosed()) {
2304
+ if (!rootsSynced) await syncProjectRoots();
2305
+ return {
2306
+ project,
2307
+ root: dir,
2308
+ pid: running.pid,
2309
+ port: running.port,
2310
+ url: projectUrl(running.port, project),
2311
+ startedDaemon: false
2312
+ };
2313
+ }
2314
+ const { started, pid, port } = await ensureDaemon({ noOpen: true });
2315
+ const watched = await sendRequest({
2316
+ kind: "watch",
2317
+ project,
2318
+ dir,
2319
+ owner: "agent",
2320
+ ...opts.depth !== void 0 ? { depth: opts.depth } : {}
2321
+ }, 8e3);
2322
+ if (!watched.ok) throw new Error(watched.error);
2323
+ attached?.close();
2324
+ handles.set(project, await attachPersistent(project));
2325
+ rootsSynced = false;
2326
+ await syncProjectRoots();
2327
+ if (started) openBrowser(projectUrl(port, project));
2328
+ return {
2329
+ project,
2330
+ root: dir,
2331
+ pid,
2332
+ port,
2333
+ url: projectUrl(port, project),
2334
+ startedDaemon: started
2335
+ };
2336
+ }
2337
+ /**
2338
+ * MCP server 启动时自动注册自身(best-effort,失败不阻断 MCP)。
2339
+ * 与 ensureViewer 同一条路径 —— 启动时注册和「开始画之前补一次」是同一件事。
2340
+ */
2341
+ async function autoRegister(cwd = process.cwd()) {
2342
+ if (autoWebDisabled()) return null;
2343
+ try {
2344
+ return await ensureViewer({ dir: cwd });
2345
+ } catch (cause) {
2346
+ process.stderr.write(`paintress: web 自动注册失败(${cause instanceof Error ? cause.message : String(cause)}),MCP 工具不受影响\n`);
2347
+ return null;
2348
+ }
2349
+ }
2350
+ // src/mcp/tools.ts
2351
+ function reqStr(args, key) {
2352
+ const v = args[key];
2353
+ if (typeof v !== "string" || v === "") throw new Error(`缺少必填参数:${key}`);
2354
+ return v;
2355
+ }
2356
+ function optStr(args, key) {
2357
+ const v = args[key];
2358
+ return typeof v === "string" && v !== "" ? v : void 0;
2359
+ }
2360
+ function optNum(args, key) {
2361
+ const v = args[key];
2362
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
2363
+ }
2364
+ function optBool(args, key) {
2365
+ const v = args[key];
2366
+ return typeof v === "boolean" ? v : void 0;
2367
+ }
2368
+ function objArg(args, key) {
2369
+ const v = args[key];
2370
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(`参数 ${key} 必须是对象`);
2371
+ return v;
2372
+ }
2373
+ /** 读:load 后执行,直接返回负载 */
2374
+ function read(path, fn) {
2375
+ return fn(loadDoc(path));
2376
+ }
2377
+ /**
2378
+ * 把「本次写操作动到的节点 id」告知 daemon,供前端做变更高亮。
2379
+ * 先转绝对路径:daemon 的 cwd 未必与调用方一致,不能让 daemon 替我们 resolve 相对路径。
2380
+ * 非阻塞发送(写路径性能语义不变);socket 默认有 ref,进程不会在响应前提前退出。
2381
+ */
2382
+ function notifyChanges(path, ids) {
2383
+ if (ids.length === 0 || !isDaemonRunning()) return;
2384
+ sendRequest({
2385
+ kind: "changes",
2386
+ path: resolve(path),
2387
+ ids
2388
+ }, 2e3).catch(() => {});
2389
+ }
2390
+ /** 最近一次字体探针报告(viewer + 截图都写进 daemon);daemon 没跑就返回空 */
2391
+ async function fetchFontReports(path) {
2392
+ if (!isDaemonRunning()) return [];
2393
+ const name = (findProjectRoot(path) ?? resolve(path)).split("/").slice(-2).join("/");
2394
+ try {
2395
+ const res = await sendRequest({
2396
+ kind: "fonts",
2397
+ project: name
2398
+ }, 2e3);
2399
+ if (!res.ok) return [];
2400
+ return (res.reports ?? []).map((r) => ({
2401
+ path: r.path,
2402
+ checked: r.checked,
2403
+ missing: r.missing
2404
+ }));
2405
+ } catch {
2406
+ return [];
2407
+ }
2408
+ }
2409
+ /** 当前设计系统概览:token 计数、preset 组、字体、组件、字体缺口 */
2410
+ function designSystemFor(path, doc) {
2411
+ const presets = listPresets(path);
2412
+ const groups = listPresetGroups(path).groups;
2413
+ const fonts = projectFontRegistry(path).registry;
2414
+ const compPath = componentsPathFor(path);
2415
+ const components = compPath === null ? [] : listComponents(compPath);
2416
+ const gaps = textFontGaps(doc, fonts);
2417
+ const tokens = {
2418
+ color: 0,
2419
+ radius: 0,
2420
+ spacing: 0,
2421
+ fontSize: 0,
2422
+ font: 0
2423
+ };
2424
+ for (const item of presets.items) tokens[item.type] = (tokens[item.type] ?? 0) + 1;
2425
+ const empty = presets.items.length === 0 && components.length === 0;
2426
+ return {
2427
+ presetPath: presets.path,
2428
+ tokens,
2429
+ groups: Object.entries(groups).map(([name, group]) => ({
2430
+ name,
2431
+ target: group.target,
2432
+ props: group.props
2433
+ })),
2434
+ fonts: {
2435
+ default: fonts.default ?? null,
2436
+ families: Object.keys(fonts.families)
2437
+ },
2438
+ components: components.length,
2439
+ gaps: {
2440
+ textNodes: gaps.textNodes,
2441
+ ids: gaps.ids,
2442
+ unregistered: gaps.unregistered
2443
+ },
2444
+ ...empty ? { next: "项目还没有设计系统:用 paintress_preset seed 建立尺度,再 paintress_font list 看字体" } : {}
2445
+ };
2446
+ }
2447
+ /** 写完给 agent 的提示:等值 token、没写字体、未登记字体(最多 3 条,不阻断) */
2448
+ function collectHints(path, doc, ids) {
2449
+ const items = listPresets(path).items;
2450
+ const colorByValue = /* @__PURE__ */ new Map();
2451
+ const numberByKind = /* @__PURE__ */ new Map();
2452
+ const familyByValue = /* @__PURE__ */ new Map();
2453
+ for (const item of items) if (item.type === "color") colorByValue.set(item.value.toUpperCase(), item.key);
2454
+ else if (item.type === "font") familyByValue.set(item.value, item.key);
2455
+ else numberByKind.set(`${item.type}:${item.value}`, item.key);
2456
+ const registry = projectFontRegistry(path).registry;
2457
+ const hints = [];
2458
+ for (const id of [...new Set(ids)]) {
2459
+ const node = findNodeIn(doc.children, id);
2460
+ if (node === null) continue;
2461
+ if (node["type"] === "text") {
2462
+ const ff = node["fontFamily"];
2463
+ if (typeof ff !== "string" || ff.trim() === "") hints.push(`${id}: text 没有 fontFamily,正在用默认字体;补一个 $font-* 或 family`);
2464
+ else if (!ff.startsWith("$")) {
2465
+ for (const family of splitFontStack(ff)) if (!isGenericFamily(family) && registry.families[family] === void 0) hints.push(`${id}: 字体 ${family} 未登记(paintress_font add)`);
2466
+ }
2467
+ }
2468
+ for (const [prop, value] of Object.entries(node)) {
2469
+ if (prop === "children") continue;
2470
+ const kind = PROP_TOKEN_KIND[prop];
2471
+ if (kind === void 0) continue;
2472
+ if (typeof value === "string" && value.startsWith("$")) continue;
2473
+ if (kind === "color" && typeof value === "string") {
2474
+ const hit = colorByValue.get(value.toUpperCase());
2475
+ if (hit !== void 0) hints.push(`${id}.${prop} 写了裸色值 ${value},可写 $${hit}`);
2476
+ } else if ((kind === "radius" || kind === "spacing" || kind === "fontSize") && typeof value === "number") {
2477
+ const hit = numberByKind.get(`${kind}:${String(value)}`);
2478
+ if (hit !== void 0) hints.push(`${id}.${prop} 写了裸值 ${value},可写 $${hit}`);
2479
+ } else if (kind === "font" && typeof value === "string") {
2480
+ const hit = familyByValue.get(value);
2481
+ if (hit !== void 0) hints.push(`${id}.${prop} 写了字体族 ${value},可写 $${hit}`);
2482
+ }
2483
+ }
2484
+ }
2485
+ return [...new Set(hints)].slice(0, 3);
2486
+ }
2487
+ /**
2488
+ * 写:load → 变更 → 校验(结构错误不落盘)→ 原子保存(保持原风味)。
2489
+ * fn 可通过 mark() 登记受影响的节点 id,写成功后统一通知 daemon。
2490
+ */
2491
+ async function write(path, fn) {
2492
+ const loaded = loadDoc(path);
2493
+ const touched = [];
2494
+ const mark = (ids) => {
2495
+ touched.push(...ids);
2496
+ };
2497
+ const result = fn(loaded.doc, mark);
2498
+ const presetNames = projectPresetNames(path);
2499
+ const v = validateDoc(loaded.doc, {
2500
+ knownVars: presetNames,
2501
+ presetVars: presetNames,
2502
+ presetGroups: listPresetGroups(path).groups,
2503
+ presetKinds: Object.fromEntries(listPresets(path).items.map((item) => [item.key, item.type])),
2504
+ fontRegistry: projectFontRegistry(path).registry
2505
+ });
2506
+ if (!v.ok) throw new Error(`变更后结构校验失败,未落盘:\n${v.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
2507
+ saveDoc(path, loaded.doc, {
2508
+ expectedMtimeMs: loaded.mtimeMs,
2509
+ flavor: loaded.flavor
2510
+ });
2511
+ notifyChanges(path, touched);
2512
+ return {
2513
+ ok: true,
2514
+ result,
2515
+ warnings: v.warnings,
2516
+ hints: collectHints(path, loaded.doc, touched)
2517
+ };
2518
+ }
2519
+ function countByType(children) {
2520
+ const counts = {};
2521
+ const walk = (nodes) => {
2522
+ for (const n of nodes) {
2523
+ if (!n || typeof n !== "object") continue;
2524
+ const rec = n;
2525
+ const t = typeof rec["type"] === "string" ? rec["type"] : "?";
2526
+ counts[t] = (counts[t] ?? 0) + 1;
2527
+ const kids = rec["children"];
2528
+ if (Array.isArray(kids)) walk(kids);
2529
+ }
2530
+ };
2531
+ walk(children);
2532
+ return counts;
2533
+ }
2534
+ /**
2535
+ * 新建设计稿。path 不带扩展名时补默认扩展名;调用方显式写 `.pen` 时按兼容格式产出
2536
+ * (不替用户转换已有文件)。
2537
+ */
2538
+ function newDoc(args) {
2539
+ const raw = reqStr(args, "path");
2540
+ const path = /\.(pen|ptr)$/i.test(raw) ? raw : `${raw}.ptr`;
2541
+ const flavor = path.toLowerCase().endsWith(".pen") ? "pen" : "ptr";
2542
+ if (existsSync(path) && optBool(args, "overwrite") !== true) throw new Error(`文件已存在,未覆盖:${path}(确需覆盖传 overwrite=true)`);
2543
+ const width = optNum(args, "width");
2544
+ const height = optNum(args, "height");
2545
+ if (width !== void 0 && width <= 0) throw new Error(`width 必须大于 0,收到:${width}`);
2546
+ if (height !== void 0 && height <= 0) throw new Error(`height 必须大于 0,收到:${height}`);
2547
+ const layoutArg = optStr(args, "layout");
2548
+ if (layoutArg !== void 0 && ![
2549
+ "vertical",
2550
+ "horizontal",
2551
+ "none"
2552
+ ].includes(layoutArg)) throw new Error(`layout 仅支持 vertical / horizontal / none,收到:${layoutArg}`);
2553
+ const { doc, boardId } = createDoc({
2554
+ name: optStr(args, "name"),
2555
+ width,
2556
+ height,
2557
+ fill: optStr(args, "fill"),
2558
+ layout: layoutArg === "vertical" || layoutArg === "horizontal" || layoutArg === "none" ? layoutArg : void 0,
2559
+ padding: optNum(args, "padding"),
2560
+ gap: optNum(args, "gap")
2561
+ });
2562
+ const check = validateDoc(doc);
2563
+ if (!check.ok) throw new Error(`新建文档结构校验失败,未落盘:\n${check.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
2564
+ mkdirSync(dirname(path), { recursive: true });
2565
+ saveDoc(path, doc, {
2566
+ flavor,
2567
+ createOnly: optBool(args, "overwrite") !== true
2568
+ });
2569
+ const board = doc.children[0];
2570
+ return {
2571
+ ok: true,
2572
+ path,
2573
+ flavor,
2574
+ version: doc.version,
2575
+ boardId,
2576
+ canvas: {
2577
+ width: Number(board?.["width"] ?? 0),
2578
+ height: Number(board?.["height"] ?? 0)
2579
+ },
2580
+ designSystem: designSystemFor(path, doc),
2581
+ warnings: check.warnings
2582
+ };
2583
+ }
2584
+ /** 摘掉整棵子树的 `reusable`:复制件是实例,不是组件定义 */
2585
+ function stripReusableDeep(node) {
2586
+ if (!node || typeof node !== "object") return;
2587
+ const rec = node;
2588
+ delete rec["reusable"];
2589
+ const kids = rec["children"];
2590
+ if (Array.isArray(kids)) for (const k of kids) stripReusableDeep(k);
2591
+ }
2592
+ /** 按 name 找节点(组件同名提示用);返回前 5 个 */
2593
+ function findNodesByName(nodes, name) {
2594
+ const out = [];
2595
+ const walk = (list) => {
2596
+ for (const raw of list) {
2597
+ if (!raw || typeof raw !== "object") continue;
2598
+ const rec = raw;
2599
+ if (rec["name"] === name && typeof rec["id"] === "string") out.push({
2600
+ id: rec["id"],
2601
+ name
2602
+ });
2603
+ const kids = rec["children"];
2604
+ if (Array.isArray(kids)) walk(kids);
2605
+ }
2606
+ };
2607
+ walk(nodes);
2608
+ return out.slice(0, 5);
2609
+ }
2610
+ /** 复制进设计稿前给整棵子树换新 id:组件库与目标文件的 id 空间互不相干 */
2611
+ function remapIds(node, taken) {
2612
+ if (!node || typeof node !== "object") return;
2613
+ const rec = node;
2614
+ if (typeof rec["id"] === "string") {
2615
+ const fresh = genId(taken);
2616
+ taken.add(fresh);
2617
+ rec["id"] = fresh;
2618
+ }
2619
+ const kids = rec["children"];
2620
+ if (Array.isArray(kids)) for (const k of kids) remapIds(k, taken);
2621
+ }
2622
+ const TOOLS = {
2623
+ paintress_viewer: {
2624
+ description: "Ensure the local viewer is running and register the current project. Idempotent: reuse the running process (returns its pid/port, never starts a second one). Call it before drawing, editing or showing a design, without waiting to be asked.",
2625
+ inputSchema: {
2626
+ dir: z.string().optional().describe("Directory to register; defaults to the current working directory"),
2627
+ project: z.string().optional().describe("Project name (the URL segment); defaults to parent/dir"),
2628
+ depth: z.number().int().min(1).max(6).optional().describe("Max depth when listing designs; default 3")
2629
+ },
2630
+ run: async (a) => {
2631
+ const dir = optStr(a, "dir");
2632
+ const project = optStr(a, "project");
2633
+ const depth = optNum(a, "depth");
2634
+ const info = await ensureViewer({
2635
+ ...dir !== void 0 ? { dir } : {},
2636
+ ...project !== void 0 ? { project } : {},
2637
+ ...depth !== void 0 ? { depth } : {}
2638
+ });
2639
+ const designSystem = projectFilesInfo(info.root, { ensure: true });
2640
+ return {
2641
+ ok: true,
2642
+ project: info.project,
2643
+ root: info.root,
2644
+ url: info.url,
2645
+ pid: info.pid,
2646
+ port: info.port,
2647
+ started: info.startedDaemon,
2648
+ designSystem
2649
+ };
2650
+ }
2651
+ },
2652
+ paintress_project: {
2653
+ description: "Project design-system files: resolve the registered project root, create __preset.ptr and __components.ptr if missing, and report nested duplicates that would split the design system. Use this instead of creating these files yourself.",
2654
+ inputSchema: {
2655
+ path: z.string().optional().describe("Design file or project directory; defaults to the current working directory"),
2656
+ ensure: z.boolean().optional().describe("Create the two files when missing (default true)")
2657
+ },
2658
+ run: async (a) => {
2659
+ const from = resolve(optStr(a, "path") ?? process.cwd());
2660
+ let project = null;
2661
+ let root = null;
2662
+ if (isDaemonRunning()) try {
2663
+ const res = await sendRequest({ kind: "status" }, 2e3);
2664
+ if (res.ok && "projects" in res && Array.isArray(res.projects)) {
2665
+ setProjectRoots(res.projects.map((p) => p.root));
2666
+ const abs = resolve(from);
2667
+ const hit = res.projects.filter((p) => abs === p.root || abs.startsWith(`${p.root}${sep}`)).sort((x, y) => y.root.length - x.root.length)[0];
2668
+ if (hit !== void 0) {
2669
+ root = hit.root;
2670
+ project = hit.name;
2671
+ }
2672
+ }
2673
+ } catch {}
2674
+ if (root === null) root = findProjectRoot(from) ?? findProjectRootStrict(from);
2675
+ const info = projectFilesInfo(root, { ensure: optBool(a, "ensure") !== false });
2676
+ return {
2677
+ ok: true,
2678
+ project,
2679
+ ...info
2680
+ };
2681
+ }
2682
+ },
2683
+ paintress_new: {
2684
+ description: "Create a design file: a minimal document with one empty board, ready to render/screenshot and fill with insert. Refuses to overwrite unless overwrite=true. A path without an extension gets the default one.",
2685
+ inputSchema: {
2686
+ path: z.string().describe("New document path; a missing extension gets the default one"),
2687
+ name: z.string().optional().describe("Board name (default Board)"),
2688
+ width: z.number().positive().optional().describe("Board width (default 900)"),
2689
+ height: z.number().positive().optional().describe("Board height (default 600)"),
2690
+ fill: z.string().optional().describe("Board background (default #FFFFFF)"),
2691
+ layout: z.enum([
2692
+ "vertical",
2693
+ "horizontal",
2694
+ "none"
2695
+ ]).optional().describe("Child layout (default vertical)"),
2696
+ padding: z.number().optional().describe("Board padding (default 0)"),
2697
+ gap: z.number().optional().describe("Main-axis gap (default 0)"),
2698
+ overwrite: z.boolean().optional().describe("Overwrite when the file exists (default false)")
2699
+ },
2700
+ run: (a) => newDoc(a)
2701
+ },
2702
+ paintress_doc_info: {
2703
+ description: "Document summary: version, flavor, node counts, variables, top-level nodes, and a design-system overview including font gaps.",
2704
+ inputSchema: { path: z.string().describe("Design file path") },
2705
+ run: (a) => {
2706
+ const path = reqStr(a, "path");
2707
+ return read(path, (ctx) => ({
2708
+ flavor: ctx.flavor,
2709
+ version: ctx.doc.version,
2710
+ fileToken: ctx.doc.fileToken,
2711
+ variables: ctx.doc.variables ? Object.keys(ctx.doc.variables) : [],
2712
+ projectPresets: (() => {
2713
+ const { path: presetPath, items } = listPresets(path);
2714
+ return {
2715
+ path: presetPath,
2716
+ count: items.length
2717
+ };
2718
+ })(),
2719
+ topLevel: ctx.doc.children.map((c) => ({
2720
+ id: c["id"],
2721
+ name: c["name"],
2722
+ type: c["type"]
2723
+ })),
2724
+ nodeCount: Object.values(countByType(ctx.doc.children)).reduce((s, n) => s + n, 0),
2725
+ byType: countByType(ctx.doc.children),
2726
+ designSystem: designSystemFor(path, ctx.doc),
2727
+ warnings: ctx.validation.warnings
2728
+ }));
2729
+ }
2730
+ },
2731
+ paintress_query: {
2732
+ description: "Find nodes by type / name (exact or regex) / id / reusable.",
2733
+ inputSchema: {
2734
+ path: z.string(),
2735
+ type: z.string().optional(),
2736
+ name: z.string().optional(),
2737
+ nameRegex: z.boolean().optional().describe("Interpret name as a regex"),
2738
+ id: z.string().optional(),
2739
+ reusable: z.boolean().optional()
2740
+ },
2741
+ run: (a) => read(reqStr(a, "path"), (ctx) => query(ctx.doc, {
2742
+ type: optStr(a, "type"),
2743
+ name: optStr(a, "name"),
2744
+ nameRegex: optBool(a, "nameRegex"),
2745
+ id: optStr(a, "id"),
2746
+ reusable: optBool(a, "reusable")
2747
+ }))
2748
+ },
2749
+ paintress_get: {
2750
+ description: "Read one node subtree; depth controls how far down (0 = this node only, children elided as ...).",
2751
+ inputSchema: {
2752
+ path: z.string(),
2753
+ id: z.string(),
2754
+ depth: z.number().int().min(0).optional()
2755
+ },
2756
+ run: (a) => read(reqStr(a, "path"), (ctx) => {
2757
+ const filePath = reqStr(a, "path");
2758
+ const res = get(ctx.doc, reqStr(a, "id"), optNum(a, "depth") ?? Infinity);
2759
+ if (!res) throw new Error(`节点不存在:${reqStr(a, "id")}`);
2760
+ if (res !== null && typeof res === "object" && !Array.isArray(res)) {
2761
+ const rec = res;
2762
+ if (rec["type"] === "text" && (typeof rec["fontFamily"] !== "string" || rec["fontFamily"].trim() === "")) {
2763
+ const fonts = listPresets(filePath).items.filter((item) => item.type === "font").map((item) => item.key);
2764
+ const suggest = fonts.length > 0 ? fonts.map((key) => `$${key}`).join(" / ") : "先 paintress_preset set 一个 font token";
2765
+ return {
2766
+ ...rec,
2767
+ hint: `这个 text 节点没有 fontFamily,正在用默认字体;补一个 ${suggest} 或 family`
2768
+ };
2769
+ }
2770
+ }
2771
+ return res;
2772
+ })
2773
+ },
2774
+ paintress_update: {
2775
+ description: "Shallow-merge attributes on one node (id and type are immutable).",
2776
+ inputSchema: {
2777
+ path: z.string(),
2778
+ id: z.string(),
2779
+ patch: z.record(z.unknown())
2780
+ },
2781
+ run: (a) => write(reqStr(a, "path"), (doc, mark) => {
2782
+ const id = reqStr(a, "id");
2783
+ const res = update(doc, id, objArg(a, "patch"));
2784
+ mark([id]);
2785
+ return res;
2786
+ })
2787
+ },
2788
+ paintress_insert: {
2789
+ description: "Insert a child node (an id is generated when missing; returns the new id).",
2790
+ inputSchema: {
2791
+ path: z.string(),
2792
+ parent: z.string().describe("父节点 id,或 'root'"),
2793
+ node: z.record(z.unknown()),
2794
+ index: z.number().int().min(0).optional()
2795
+ },
2796
+ run: (a) => write(reqStr(a, "path"), (doc, mark) => {
2797
+ const newId = insert(doc, reqStr(a, "parent"), objArg(a, "node"), optNum(a, "index"));
2798
+ mark([newId]);
2799
+ return newId;
2800
+ })
2801
+ },
2802
+ paintress_delete: {
2803
+ description: "Delete a node and its subtree.",
2804
+ inputSchema: {
2805
+ path: z.string(),
2806
+ id: z.string()
2807
+ },
2808
+ run: (a) => write(reqStr(a, "path"), (doc, mark) => {
2809
+ const id = reqStr(a, "id");
2810
+ const node = remove(doc, id);
2811
+ mark([id]);
2812
+ return { deleted: {
2813
+ id: node["id"],
2814
+ name: node["name"],
2815
+ type: node["type"]
2816
+ } };
2817
+ })
2818
+ },
2819
+ paintress_move: {
2820
+ description: "Reparent or reorder a node (cycle-checked).",
2821
+ inputSchema: {
2822
+ path: z.string(),
2823
+ id: z.string(),
2824
+ parent: z.string(),
2825
+ index: z.number().int().min(0).optional()
2826
+ },
2827
+ run: (a) => write(reqStr(a, "path"), (doc, mark) => {
2828
+ const id = reqStr(a, "id");
2829
+ move(doc, id, reqStr(a, "parent"), optNum(a, "index"));
2830
+ mark([id]);
2831
+ return {
2832
+ moved: id,
2833
+ to: reqStr(a, "parent")
2834
+ };
2835
+ })
2836
+ },
2837
+ paintress_batch: {
2838
+ description: "Atomic batch: an array of update/insert/delete/move ops; one failure discards the whole batch.",
2839
+ inputSchema: {
2840
+ path: z.string(),
2841
+ ops: z.array(z.record(z.unknown()))
2842
+ },
2843
+ run: (a) => write(reqStr(a, "path"), (doc, mark) => {
2844
+ const raw = a["ops"];
2845
+ if (!Array.isArray(raw)) throw new Error("ops 必须是数组");
2846
+ const ops = raw.map((op, idx) => {
2847
+ const rec = op;
2848
+ const kind = rec["op"];
2849
+ if (kind === "update") return {
2850
+ op: "update",
2851
+ id: String(rec["id"]),
2852
+ patch: rec["patch"] ?? {}
2853
+ };
2854
+ if (kind === "insert") return {
2855
+ op: "insert",
2856
+ parent: String(rec["parent"]),
2857
+ node: rec["node"],
2858
+ index: typeof rec["index"] === "number" ? rec["index"] : void 0
2859
+ };
2860
+ if (kind === "delete") return {
2861
+ op: "delete",
2862
+ id: String(rec["id"])
2863
+ };
2864
+ if (kind === "move") return {
2865
+ op: "move",
2866
+ id: String(rec["id"]),
2867
+ parent: String(rec["parent"]),
2868
+ index: typeof rec["index"] === "number" ? rec["index"] : void 0
2869
+ };
2870
+ throw new Error(`ops[${idx}] 的 op 必须是 update|insert|delete|move`);
2871
+ });
2872
+ const before = new Set(collectIds(doc.children));
2873
+ doc.children = batch(doc, ops).children;
2874
+ const insertedIds = collectIds(doc.children).filter((id) => !before.has(id));
2875
+ mark([...ops.flatMap((op) => op.op === "insert" ? [] : [op.id]), ...insertedIds]);
2876
+ return {
2877
+ applied: ops.length,
2878
+ inserted: insertedIds.length
2879
+ };
2880
+ })
2881
+ },
2882
+ paintress_validate: {
2883
+ description: "Validate a document: structural errors plus semantic warnings (duplicate ids, dangling refs, unknown types, undefined variables, wrong token kinds, font gaps). Project presets count as defined.",
2884
+ inputSchema: { path: z.string() },
2885
+ run: (a) => {
2886
+ const path = reqStr(a, "path");
2887
+ const loaded = loadDoc(path);
2888
+ const presetNames = projectPresetNames(path);
2889
+ return validateDoc(loaded.doc, {
2890
+ knownVars: presetNames,
2891
+ presetVars: presetNames,
2892
+ presetGroups: listPresetGroups(path).groups,
2893
+ presetKinds: Object.fromEntries(listPresets(path).items.map((item) => [item.key, item.type])),
2894
+ fontRegistry: projectFontRegistry(path).registry
2895
+ });
2896
+ }
2897
+ },
2898
+ paintress_preset: {
2899
+ description: "Project presets (album tokens and preset groups): colors, radii, spacing, font sizes and families that get tweaked repeatedly live in __preset.ptr; designs reference them as $key. action=list shows everything (including groups), set creates or updates a token, remove deletes it, seed fills a complete scale in one go (dry-run by default, only missing keys). set-group defines a type-targeted set of properties (values must be $token, e.g. {fill:\"$color-brand\"}); nodes opt in with preset:\"<group>\".",
2900
+ inputSchema: {
2901
+ action: z.enum([
2902
+ "list",
2903
+ "set",
2904
+ "remove",
2905
+ "seed",
2906
+ "set-group",
2907
+ "remove-group"
2908
+ ]),
2909
+ apply: z.boolean().optional().describe("seed only: true writes; default false returns created/skipped only"),
2910
+ path: z.string().optional().describe("Design file or project directory; defaults to the current working directory"),
2911
+ key: z.string().optional().describe("Token key, referenced as $key; required for set/remove"),
2912
+ type: z.enum([
2913
+ "color",
2914
+ "radius",
2915
+ "spacing",
2916
+ "fontSize",
2917
+ "font"
2918
+ ]).optional().describe("Token type; required for set"),
2919
+ name: z.string().optional().describe("Human-readable name; defaults to the key"),
2920
+ value: z.string().optional().describe("值:颜色 #RRGGBB(或 #RGB / #RRGGBBAA),倒角/间距写非负数字,字体写字体族;set 必填"),
2921
+ group: z.string().optional().describe("Preset group name; required for set-group/remove-group"),
2922
+ target: z.string().optional().describe("Node type the group applies to (frame/text/rectangle...); required for set-group"),
2923
+ props: z.record(z.string()).optional().describe("Group properties mapped to $tokens, e.g. {\"fill\":\"$color-brand\"}; required for set-group")
2924
+ },
2925
+ run: (a) => {
2926
+ const from = resolve(optStr(a, "path") ?? process.cwd());
2927
+ const action = optStr(a, "action") ?? "list";
2928
+ if (action === "seed") return {
2929
+ ok: true,
2930
+ ...seedDesignSystem(from, { apply: a["apply"] === true })
2931
+ };
2932
+ if (action === "list") {
2933
+ const { path, items } = listPresets(from);
2934
+ return {
2935
+ ok: true,
2936
+ path,
2937
+ items,
2938
+ groups: listPresetGroups(from).groups
2939
+ };
2940
+ }
2941
+ if (action === "set-group") {
2942
+ const props = a["props"];
2943
+ if (props === null || typeof props !== "object" || Array.isArray(props)) throw new Error("props 必须是对象:属性 → $token 引用");
2944
+ const clean = {};
2945
+ for (const [k, v] of Object.entries(props)) {
2946
+ if (typeof v !== "string") throw new Error(`props.${k} 必须是 $token 字符串`);
2947
+ clean[k] = v;
2948
+ }
2949
+ return {
2950
+ ok: true,
2951
+ ...setPresetGroup(from, {
2952
+ name: reqStr(a, "group"),
2953
+ target: reqStr(a, "target"),
2954
+ props: clean
2955
+ })
2956
+ };
2957
+ }
2958
+ if (action === "remove-group") return {
2959
+ ok: true,
2960
+ ...removePresetGroup(from, reqStr(a, "group"))
2961
+ };
2962
+ const key = reqStr(a, "key");
2963
+ if (action === "remove") {
2964
+ const items = removePreset(from, key);
2965
+ return {
2966
+ ok: true,
2967
+ path: presetPathFor(from),
2968
+ items,
2969
+ groups: listPresetGroups(from).groups
2970
+ };
2971
+ }
2972
+ const type = optStr(a, "type");
2973
+ if (!isPresetType(type)) throw new Error("type 必须是 color / radius / spacing / fontSize / font");
2974
+ const name = optStr(a, "name");
2975
+ const items = setPreset(from, {
2976
+ key,
2977
+ type,
2978
+ value: reqStr(a, "value"),
2979
+ ...name !== void 0 ? { name } : {}
2980
+ });
2981
+ return {
2982
+ ok: true,
2983
+ path: presetPathFor(from),
2984
+ items,
2985
+ groups: listPresetGroups(from).groups
2986
+ };
2987
+ }
2988
+ },
2989
+ paintress_font: {
2990
+ description: "Project font registry (x-paintress.fonts in __preset.ptr): list shows sources, previews and the default; add registers a family; remove deletes it; default sets the default font. system/bundle require preview; css/file sources are project-relative paths that must exist; web sources are CSS URLs.",
2991
+ inputSchema: {
2992
+ action: z.enum([
2993
+ "list",
2994
+ "add",
2995
+ "remove",
2996
+ "default"
2997
+ ]),
2998
+ path: z.string().optional().describe("Design file or project directory; defaults to the current working directory"),
2999
+ family: z.string().optional().describe("Real font family name; required for add/remove"),
3000
+ kind: z.enum([
3001
+ "web",
3002
+ "css",
3003
+ "file",
3004
+ "system",
3005
+ "bundle"
3006
+ ]).optional().describe("Source kind; required for add"),
3007
+ source: z.string().optional().describe("web: a CSS URL; css/file: a project-relative path"),
3008
+ preview: z.string().optional().describe("Design-time substitute font; required for system/bundle"),
3009
+ note: z.string().optional(),
3010
+ metrics: z.object({
3011
+ sizeAdjust: z.string().optional(),
3012
+ ascentOverride: z.string().optional(),
3013
+ descentOverride: z.string().optional(),
3014
+ lineGapOverride: z.string().optional()
3015
+ }).optional().describe("Metrics overrides for the substitute (CSS font metrics overrides)"),
3016
+ value: z.string().optional().describe("For default: a font token key or a family")
3017
+ },
3018
+ run: async (a) => {
3019
+ const from = resolve(optStr(a, "path") ?? process.cwd());
3020
+ const action = optStr(a, "action") ?? "list";
3021
+ if (action === "list") {
3022
+ const { path, registry } = projectFontRegistry(from);
3023
+ const reports = await fetchFontReports(from);
3024
+ const missing = new Set(reports.flatMap((r) => r.missing));
3025
+ const families = Object.fromEntries(Object.entries(registry.families).map(([family, entry]) => [family, {
3026
+ ...entry,
3027
+ status: missing.has(family) ? "missing" : "loaded",
3028
+ missingIn: reports.filter((r) => r.missing.includes(family)).map((r) => r.path)
3029
+ }]));
3030
+ return {
3031
+ ok: true,
3032
+ path,
3033
+ ...registry,
3034
+ families,
3035
+ reports
3036
+ };
3037
+ }
3038
+ if (action === "remove") return {
3039
+ ok: true,
3040
+ ...removeFontEntry(from, reqStr(a, "family"))
3041
+ };
3042
+ if (action === "default") return {
3043
+ ok: true,
3044
+ ...setDefaultFont(from, reqStr(a, "value"))
3045
+ };
3046
+ const kind = reqStr(a, "kind");
3047
+ const source = optStr(a, "source");
3048
+ const preview = optStr(a, "preview");
3049
+ const note = optStr(a, "note");
3050
+ const metrics = a["metrics"];
3051
+ return {
3052
+ ok: true,
3053
+ ...setFontEntry(from, {
3054
+ family: reqStr(a, "family"),
3055
+ source: {
3056
+ kind,
3057
+ ...source !== void 0 ? { target: source } : {}
3058
+ },
3059
+ ...preview !== void 0 ? { preview } : {},
3060
+ ...note !== void 0 ? { note } : {},
3061
+ ...metrics !== null && typeof metrics === "object" ? { metrics } : {}
3062
+ })
3063
+ };
3064
+ }
3065
+ },
3066
+ paintress_audit: {
3067
+ description: "Read-only album audit: nodes whose colors/radii/spacing are literals that could be $token; orphan tokens; preset groups never referenced; dangling preset refs; literal values inside preset groups. You and the human decide what to change.",
3068
+ inputSchema: { path: z.string().describe("Design file path (project presets are read from its project)") },
3069
+ run: (a) => {
3070
+ const path = reqStr(a, "path");
3071
+ const loaded = loadDoc(path);
3072
+ const { items } = listPresets(path);
3073
+ const presetPath = presetPathFor(path);
3074
+ const presetDoc = presetPath === null ? null : readPresetFile(presetPath)?.doc ?? null;
3075
+ const input = auditInputFrom(loaded.doc, items.map((p) => ({
3076
+ key: p.key,
3077
+ type: p.type,
3078
+ value: p.value
3079
+ })), presetDoc);
3080
+ return {
3081
+ ok: true,
3082
+ path,
3083
+ ...auditDoc(loaded.doc, input)
3084
+ };
3085
+ }
3086
+ },
3087
+ paintress_establish: {
3088
+ description: "Album extraction: find repeated literals (colors/radii/spacing) and suggest $tokens, and suggest preset groups from same-type nodes carrying the same property set. Dry-run by default (apply=false); apply=true writes __preset.ptr and rewrites matched nodes to references (mtime guarded).",
3089
+ inputSchema: {
3090
+ path: z.string().describe("Design file path"),
3091
+ apply: z.boolean().optional().describe("true writes the preset file and rewrites nodes; default false only suggests"),
3092
+ minUses: z.number().int().min(2).optional().describe("Minimum occurrences for a candidate; default 2")
3093
+ },
3094
+ run: (a) => {
3095
+ const path = reqStr(a, "path");
3096
+ const loaded = loadDoc(path);
3097
+ const minUses = typeof a["minUses"] === "number" ? a["minUses"] : void 0;
3098
+ const plan = planEstablish(loaded.doc, { ...minUses !== void 0 ? { minUses } : {} });
3099
+ if (a["apply"] !== true) return {
3100
+ ok: true,
3101
+ applied: false,
3102
+ ...plan
3103
+ };
3104
+ return {
3105
+ ok: true,
3106
+ ...applyEstablish(path, plan, { expectedMtimeMs: loaded.mtimeMs })
3107
+ };
3108
+ }
3109
+ },
3110
+ paintress_adjust: {
3111
+ description: "Album adjustment: change one value and see the blast radius first. key=change one token; group+prop=change one group property. Dry-run by default (apply=false) returns affected nodes with before->after; apply=true writes __preset.ptr only, so design references follow automatically.",
3112
+ inputSchema: {
3113
+ path: z.string().describe("Design file path (project presets are read from its project)"),
3114
+ key: z.string().optional().describe("Token key to change"),
3115
+ group: z.string().optional().describe("Preset group to change"),
3116
+ prop: z.string().optional().describe("Group property to change (with group)"),
3117
+ value: z.string().describe("New value: #RRGGBB for colors, a number for dimensions, a family for fonts"),
3118
+ apply: z.boolean().optional().describe("true writes the preset file; default false only previews")
3119
+ },
3120
+ run: (a) => {
3121
+ const path = reqStr(a, "path");
3122
+ const value = reqStr(a, "value");
3123
+ const loaded = loadDoc(path);
3124
+ const presetDoc = readPresetDoc(path);
3125
+ const key = optStr(a, "key");
3126
+ const group = optStr(a, "group");
3127
+ const prop = optStr(a, "prop");
3128
+ if (key !== void 0 && (group !== void 0 || prop !== void 0)) throw new Error("key 与 group/prop 是两种调整方式,只能选一种");
3129
+ if (group === void 0 !== (prop === void 0)) throw new Error("改 preset 组必须同时提供 group 与 prop");
3130
+ const plan = planAdjust(loaded.doc, presetDoc, {
3131
+ ...key !== void 0 ? { key } : {},
3132
+ ...group !== void 0 ? { group } : {},
3133
+ ...prop !== void 0 ? { prop } : {},
3134
+ value
3135
+ });
3136
+ if (a["apply"] !== true) return {
3137
+ ok: true,
3138
+ applied: false,
3139
+ ...plan
3140
+ };
3141
+ return {
3142
+ ok: true,
3143
+ ...applyAdjust(path, presetDoc, plan)
3144
+ };
3145
+ }
3146
+ },
3147
+ paintress_component: {
3148
+ description: "Project component library: look before you build. action=list lists reusable components (reusable nodes in __components.ptr); action=insert copies one into a design (fresh ids, internal refs expanded, standalone render; copy only, no live link); action=add registers a design subtree as a component (fresh ids, nested reusable stripped, optional rename).",
3149
+ inputSchema: {
3150
+ action: z.enum([
3151
+ "list",
3152
+ "insert",
3153
+ "add"
3154
+ ]),
3155
+ path: z.string().optional().describe("Design file or project directory; for insert this is the target design"),
3156
+ id: z.string().optional().describe("Component id; required for insert"),
3157
+ parent: z.string().optional().describe("插到哪个父节点,默认 'root'"),
3158
+ index: z.number().int().min(0).optional()
3159
+ },
3160
+ run: (a) => {
3161
+ const action = optStr(a, "action") ?? "list";
3162
+ const from = resolve(optStr(a, "path") ?? process.cwd());
3163
+ if (action === "list") {
3164
+ const compPath = componentsPathFor(from);
3165
+ if (compPath === null) return {
3166
+ ok: true,
3167
+ path: null,
3168
+ items: [],
3169
+ hint: "项目还没有组件库:先 paintress_viewer 注册项目(会自动创建 __components.ptr)"
3170
+ };
3171
+ return {
3172
+ ok: true,
3173
+ path: compPath,
3174
+ items: listComponents(compPath)
3175
+ };
3176
+ }
3177
+ if (action === "add") {
3178
+ const sourcePath = reqStr(a, "path");
3179
+ const sourceId = reqStr(a, "id");
3180
+ const loaded = loadDoc(sourcePath);
3181
+ const node = findNodeIn(expandRefs(applyProjectPresets(loaded.doc, sourcePath).doc, []), sourceId);
3182
+ if (node === null) throw new Error(`节点不存在:${sourceId}(先 paintress_query 拿 id)`);
3183
+ const copy = structuredClone(node);
3184
+ const name = optStr(a, "name") ?? (typeof copy["name"] === "string" && copy["name"] !== "" ? copy["name"] : sourceId);
3185
+ copy["name"] = name;
3186
+ stripReusableDeep(copy);
3187
+ copy["reusable"] = true;
3188
+ const root = findProjectRootStrict(sourcePath);
3189
+ ensureProjectScaffold(root);
3190
+ const compPath = join(root, COMPONENTS_FILE);
3191
+ return write(compPath, (doc, mark) => {
3192
+ remapIds(copy, new Set(collectIds(doc.children)));
3193
+ const newId = insert(doc, optStr(a, "parent") ?? "root", copy, optNum(a, "index"));
3194
+ mark([newId]);
3195
+ return {
3196
+ id: newId,
3197
+ name,
3198
+ componentPath: compPath,
3199
+ source: sourceId
3200
+ };
3201
+ });
3202
+ }
3203
+ const target = reqStr(a, "path");
3204
+ const compId = reqStr(a, "id");
3205
+ const compPath = componentsPathFor(resolve(target));
3206
+ if (compPath === null) throw new Error("找不到项目组件库 __components.ptr(先在项目里注册)");
3207
+ const compLoaded = readComponentsFile(compPath);
3208
+ if (compLoaded === null) throw new Error(`组件库不可读:${compPath}`);
3209
+ const warnings = [];
3210
+ const node = findNodeIn(expandRefs(applyProjectPresets(compLoaded.doc, compPath).doc, warnings), compId);
3211
+ if (node === null) throw new Error(`组件不存在:${compId}(先 paintress_component list 看有哪些)`);
3212
+ const copy = structuredClone(node);
3213
+ stripReusableDeep(copy);
3214
+ const componentName = typeof node["name"] === "string" && node["name"] !== "" ? node["name"] : compId;
3215
+ const existing = findNodesByName(loadDoc(target).doc.children, componentName);
3216
+ return write(target, (doc, mark) => {
3217
+ remapIds(copy, new Set(collectIds(doc.children)));
3218
+ const newId = insert(doc, optStr(a, "parent") ?? "root", copy, optNum(a, "index"));
3219
+ mark([newId]);
3220
+ return {
3221
+ id: newId,
3222
+ from: compId,
3223
+ componentPath: compPath,
3224
+ warnings,
3225
+ ...existing.length > 0 ? { existing } : {}
3226
+ };
3227
+ });
3228
+ }
3229
+ },
3230
+ paintress_convert: {
3231
+ description: "Convert a .pen design file to .ptr (the paintress flavor): reads, validates and writes a new .ptr file with a fresh fileToken and x-paintress metadata. Does not modify the source; refuses to overwrite unless overwrite=true. The two flavors are identical today, but .ptr may evolve.",
3232
+ inputSchema: {
3233
+ path: z.string().describe("Source .pen path"),
3234
+ out: z.string().optional().describe("Target .ptr path; defaults to the source with .ptr extension"),
3235
+ overwrite: z.boolean().optional().describe("Overwrite an existing target (default false)")
3236
+ },
3237
+ run: (a) => {
3238
+ const source = reqStr(a, "path");
3239
+ if (!source.toLowerCase().endsWith(".pen")) throw new Error(`source must be a .pen file, got: ${source}`);
3240
+ const target = optStr(a, "out") ?? source.replace(/\.pen$/i, ".ptr");
3241
+ if (resolve(target) === resolve(source)) throw new Error("Target must differ from the source");
3242
+ if (existsSync(target) && optBool(a, "overwrite") !== true) throw new Error(`Target exists, not overwritten: ${target} (pass overwrite=true to replace it)`);
3243
+ const loaded = loadDoc(source);
3244
+ if (!loaded.validation.ok) throw new Error(`Source fails validation, nothing written:\n${loaded.validation.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
3245
+ const meta = loaded.doc["x-paintress"];
3246
+ const doc = {
3247
+ ...loaded.doc,
3248
+ fileToken: randomUUID(),
3249
+ ...meta !== void 0 ? {} : { ["x-paintress"]: { spec: "1" } }
3250
+ };
3251
+ saveDoc(target, doc, {
3252
+ flavor: "ptr",
3253
+ createOnly: optBool(a, "overwrite") !== true
3254
+ });
3255
+ return {
3256
+ ok: true,
3257
+ source,
3258
+ out: target,
3259
+ fileToken: doc.fileToken,
3260
+ version: doc.version,
3261
+ nodeCount: doc.children.length,
3262
+ warnings: loaded.validation.warnings
3263
+ };
3264
+ }
3265
+ },
3266
+ paintress_render: {
3267
+ description: "Render a design to an HTML file (each node carries data-node-id); returns the output path and canvas size. Project fonts are injected from the registry (web/css/file); embedFonts=true inlines file fonts as data URIs.",
3268
+ inputSchema: {
3269
+ path: z.string(),
3270
+ out: z.string().describe("Output .html path"),
3271
+ embedFonts: z.boolean().optional().describe("Inline file-source fonts as data URIs (default false)")
3272
+ },
3273
+ run: (a) => {
3274
+ const out = reqStr(a, "out");
3275
+ const path = reqStr(a, "path");
3276
+ const merged = applyProjectPresets(loadDoc(path).doc, path).doc;
3277
+ const { registry } = projectFontRegistry(path);
3278
+ const root = findProjectRoot(path) ?? dirname(resolve(path));
3279
+ const embed = a["embedFonts"] === true;
3280
+ const plan = planFonts(registry, (source) => {
3281
+ if (source.target === void 0) return null;
3282
+ const abs = resolve(root, source.target);
3283
+ if (embed && source.kind === "file") try {
3284
+ const data = readFileSync(abs);
3285
+ const ext = extname(abs).toLowerCase();
3286
+ return `data:${ext === ".woff2" ? "font/woff2" : ext === ".woff" ? "font/woff" : ext === ".otf" ? "font/otf" : "font/ttf"};base64,${data.toString("base64")}`;
3287
+ } catch {
3288
+ return null;
3289
+ }
3290
+ return pathToFileURL(abs).href;
3291
+ }, merged);
3292
+ const r = renderDocument(merged, { fonts: plan });
3293
+ if (embed && Object.values(registry.families).some((entry) => entry.source.kind === "css")) r.warnings.push("css 来源的字体未内联(分片 CSS 的 url 无法安全重写);导出件仍需能访问那些文件");
3294
+ writeFileSync(out, r.html, "utf8");
3295
+ return {
3296
+ ok: true,
3297
+ out,
3298
+ canvas: r.canvas,
3299
+ warnings: r.warnings
3300
+ };
3301
+ }
3302
+ },
3303
+ paintress_screenshot: {
3304
+ description: "Render and screenshot to PNG. Scale 0.2-2, default 0.8 (for agent review: legible without being huge); either edge over 8192px is scaled back proportionally. Needs local Chromium/Chrome or PTR_BROWSER. Pass board to capture one board (a top-level frame, i.e. one page) instead of the whole canvas.",
3305
+ inputSchema: {
3306
+ path: z.string(),
3307
+ out: z.string().describe("Output .png path"),
3308
+ scale: z.number().min(.2).max(2).optional().describe("Pixel scale 0.2-2, default 0.8; 1x = board design pixels (the viewer 1:1)"),
3309
+ board: z.string().optional().describe("Capture one board by id or name; an ambiguous name errors and lists candidates")
3310
+ },
3311
+ run: async (a) => {
3312
+ if (!findBrowser()) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER");
3313
+ const board = typeof a["board"] === "string" ? a["board"] : "";
3314
+ const res = await screenshotPen(reqStr(a, "path"), reqStr(a, "out"), {
3315
+ ...typeof a["scale"] === "number" ? { scale: a["scale"] } : {},
3316
+ ...board !== "" ? { board } : {}
3317
+ });
3318
+ return {
3319
+ ok: true,
3320
+ out: res.out,
3321
+ canvas: res.canvas,
3322
+ ...res.board !== void 0 ? { board: res.board } : {},
3323
+ scale: res.scale,
3324
+ browser: res.browser,
3325
+ ...res.fonts !== void 0 ? { fonts: res.fonts } : {},
3326
+ warnings: res.warnings
3327
+ };
3328
+ }
3329
+ },
3330
+ paintress_marks: {
3331
+ description: "Read or clear the node marks for this session: clicking a node in the viewer assigns #1 #2...; returns the #N -> node id mapping so the conversation can refer to it (e.g. \"make #1 blue\").",
3332
+ inputSchema: {
3333
+ project: z.string().optional().describe("Project name; omit to return marks from all projects"),
3334
+ action: z.enum(["list", "clear"]).optional().describe("Default: list")
3335
+ },
3336
+ run: async (a) => {
3337
+ if (!isDaemonRunning()) throw new Error("paintress web 未运行,无法读取标记;先调用 paintress_viewer(或运行 paintress web)");
3338
+ const project = optStr(a, "project") ?? "";
3339
+ if ((optStr(a, "action") ?? "list") === "clear") return await sendRequest({
3340
+ kind: "clear-marks",
3341
+ project
3342
+ });
3343
+ return await sendRequest({
3344
+ kind: "marks",
3345
+ project
3346
+ });
3347
+ }
3348
+ }
3349
+ };
3350
+ // src/mcp/server.ts
3351
+ const SERVER_NAME = "paintress";
3352
+ const SERVER_VERSION = VERSION;
3353
+ /**
3354
+ * 连接时交给客户端模型的使用说明(MCP initialize 的 instructions)。
3355
+ * 头号规则是「作画前把查看器拉起来」——人看不到进程状态,agent 不等提醒就该做。
3356
+ *
3357
+ * 写法纪律(operator 2026-11-14):**只说「跑 `paintress web`」就结章**。
3358
+ * 不开窗、不指 URL、不让人去打开浏览器 —— 窗口是程序的事(openBrowser),
3359
+ * 提示词里多一句「你自己去开浏览器」就必然会在 WSL 里开到错的那一个。
3360
+ */
3361
+ const INSTRUCTIONS = `paintress is a design tool for AI agents: design files are JSON, edited through tools and rendered by a local viewer.
3362
+
3363
+ VIEWER — the human only sees your work in the viewer:
3364
+ - Run \`paintress web\` before the first write of a drawing session (creating, editing or showing a design) — do not wait to be asked. It ensures the viewer is running and returns immediately.
3365
+ - The window is the command's job, not yours: never launch a browser or start a process for it.
3366
+ - It is idempotent: if the viewer is already up it prints the existing pid/port/URL and exits; calling it again is safe and never starts a second process.
3367
+ - MCP equivalent: \`paintress_viewer\` returns the same URL. Append \`?file=<relative path>\` to deep-link one document.
3368
+
3369
+ WORKFLOW: \`paintress_new\` → \`paintress_query\`/\`paintress_get\` for real node ids (never invent them) → \`paintress_update\`/\`insert\`/\`move\`/\`delete\` or \`paintress_batch\` → \`paintress_validate\` → \`paintress_screenshot\` to check the result with your own eyes.
3370
+ \`#1\` in the conversation is a click marker from the viewer: resolve it with \`paintress_marks\`.
3371
+
3372
+ DESIGN SYSTEM — one source of truth; do not paste values:
3373
+ - Run \`paintress_project\` (or read \`designSystem\` from \`paintress_viewer\`) for the project root and the two files; never create \`__preset.ptr\` / \`__components.ptr\` yourself.
3374
+ - Before the first shape call \`paintress_preset list\`; if it is empty use \`paintress_preset seed\` (dry-run, then apply) or set tokens, and reference them as \`$key\`.
3375
+ - Before building a button, card or input call \`paintress_component list\`; insert one that fits. Use \`paintress_component add\` once the same shape is needed a second time.
3376
+ - \`paintress_font list\` shows the font registry; register families with a source and a preview. Text without a fontFamily uses the default and is reported on every read — close that gap.
3377
+ - After a batch run \`paintress_validate\`; it reports design-system drift, wrong token kinds and font gaps.`;
3378
+ /** 写操作前补一次查看器:这是「光画不开 web」的兜底网,不靠 agent 自觉 */
3379
+ const WRITE_TOOLS = /* @__PURE__ */ new Set([
3380
+ "paintress_new",
3381
+ "paintress_update",
3382
+ "paintress_insert",
3383
+ "paintress_delete",
3384
+ "paintress_move",
3385
+ "paintress_batch"
3386
+ ]);
3387
+ /** 构建 MCP server:把共享工具注册到 stdio 协议上 */
3388
+ function createMcpServer(opts = {}) {
3389
+ const server = new McpServer({
3390
+ name: SERVER_NAME,
3391
+ version: SERVER_VERSION
3392
+ }, { instructions: INSTRUCTIONS });
3393
+ for (const [name, def] of Object.entries(TOOLS)) server.registerTool(name, {
3394
+ description: def.description,
3395
+ inputSchema: def.inputSchema
3396
+ }, async (args) => {
3397
+ try {
3398
+ if (opts.ensureViewerBeforeWrite === true && WRITE_TOOLS.has(name)) await ensureViewerQuiet();
3399
+ const payload = await def.run(args);
3400
+ return { content: [{
3401
+ type: "text",
3402
+ text: JSON.stringify(payload, null, 2)
3403
+ }] };
3404
+ } catch (cause) {
3405
+ return {
3406
+ content: [{
3407
+ type: "text",
3408
+ text: cause instanceof Error ? cause.message : String(cause)
3409
+ }],
3410
+ isError: true
3411
+ };
3412
+ }
3413
+ });
3414
+ return server;
3415
+ }
3416
+ /**
3417
+ * 父进程看门狗:stdio MCP server 必须随宿主死亡而退出,否则变成孤儿进程
3418
+ * (MCP 生态的已知问题)。两道保险:ppid 变化 + stdin EOF。
3419
+ */
3420
+ function installParentWatchdog() {
3421
+ const parentPid = process.ppid;
3422
+ setInterval(() => {
3423
+ if (process.ppid !== parentPid) exitClean(0);
3424
+ try {
3425
+ process.kill(parentPid, 0);
3426
+ } catch {
3427
+ exitClean(0);
3428
+ }
3429
+ }, 5e3).unref();
3430
+ process.stdin.on("end", () => exitClean(0));
3431
+ process.stdin.on("close", () => exitClean(0));
3432
+ }
3433
+ /**
3434
+ * attach 连接引用:由 autoregister 持有(ensureViewer 每次调用都会复用/刷新)。
3435
+ * 退出时显式关闭——比等 OS 回收更快让 daemon 看到 we're gone。
3436
+ */
3437
+ function exitClean(code = 0) {
3438
+ closeViewerHandles();
3439
+ process.exit(code);
3440
+ }
3441
+ /** 写之前补一次查看器:best-effort,失败不阻断写入(人看不到图,但文件还是要改) */
3442
+ async function ensureViewerQuiet() {
3443
+ try {
3444
+ await ensureViewer();
3445
+ } catch (cause) {
3446
+ process.stderr.write(`paintress: 写操作前自动拉起查看器失败(${cause instanceof Error ? cause.message : String(cause)})\n`);
3447
+ }
3448
+ }
3449
+ /** 自动注册自身(best-effort,失败不阻断 MCP) */
3450
+ async function registerSelf() {
3451
+ const res = await autoRegister();
3452
+ if (res) process.stderr.write(`paintress: 已注册项目 "${res.project}"(${res.url})\n`);
3453
+ }
3454
+ /** CLI `paintress mcp` 入口:stdio transport + 自动注册 */
3455
+ async function runStdioServer() {
3456
+ const server = createMcpServer({ ensureViewerBeforeWrite: true });
3457
+ const transport = new StdioServerTransport();
3458
+ await server.connect(transport);
3459
+ installParentWatchdog();
3460
+ registerSelf();
3461
+ }
3462
+ export { presetPathFor as A, ensureProjectScaffold as C, listPresetGroups as D, listComponents as E, setProjectRoots as F, projectFontRegistry as M, readTextIfExists as N, listPresets as O, setPreset as P, componentsPathFor as S, findProjectRoot as T, LineDecoder as _, ensureDaemon as a, screenshotPen as b, isDaemonRunning as c, lockPath as d, pidPath as f, socketPath as g, sanitizeProjectName as h, open_browser_exports as i, projectFilesInfo as j, notePathFor as k, queryDaemon as l, safeJoin as m, runStdioServer as n, reuseDaemon as o, resolveProjectDir as p, TOOLS as r, attachPersistent as s, createMcpServer as t, sendRequest as u, encodeLine as v, findNodeIn as w, applyProjectPresets as x, findBrowser as y };