paintress 0.3.1 → 0.5.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.
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { z } from "zod";
5
5
  import { basename, dirname, join } from "node:path";
@@ -410,6 +410,19 @@ const IconSchema = z.object({
410
410
  weight: NumOrVar.optional(),
411
411
  fill: z.unknown().optional()
412
412
  }).passthrough();
413
+ /**
414
+ * icon_font:上游设计工具的图标节点写法(`iconFontName` + `iconFontFamily`)。
415
+ * 语义与 `icon`(`icon` + `library`)等价,仅字段名不同;渲染与校验按同一套处理。
416
+ */
417
+ const IconFontSchema = z.object({
418
+ type: z.literal("icon_font"),
419
+ ...NodeBase,
420
+ ...sizeBlock,
421
+ iconFontFamily: z.string().optional(),
422
+ iconFontName: StringOrVar.optional(),
423
+ weight: NumOrVar.optional(),
424
+ fill: z.unknown().optional()
425
+ }).passthrough();
413
426
  const ScriptSchema = z.object({
414
427
  type: z.literal("script"),
415
428
  ...NodeBase,
@@ -458,11 +471,12 @@ const KNOWN_NODE_TYPES = [
458
471
  "text",
459
472
  "note",
460
473
  "icon",
474
+ "icon_font",
461
475
  "script",
462
476
  "browser",
463
477
  "ref"
464
478
  ];
465
- /** 完整节点 union:已知 12 种 + 未知兜底。 */
479
+ /** 完整节点 union:已知 13 种 + 未知兜底。 */
466
480
  const UnknownNodeSchema = z.object({
467
481
  type: z.string(),
468
482
  id: z.string().optional()
@@ -477,6 +491,7 @@ const NodeSchema = z.union([
477
491
  TextSchema,
478
492
  NoteSchema,
479
493
  IconSchema,
494
+ IconFontSchema,
480
495
  ScriptSchema,
481
496
  BrowserSchema,
482
497
  RefSchema,
@@ -493,6 +508,7 @@ const SCHEMA_BY_TYPE = {
493
508
  text: TextSchema,
494
509
  note: NoteSchema,
495
510
  icon: IconSchema,
511
+ icon_font: IconFontSchema,
496
512
  script: ScriptSchema,
497
513
  browser: BrowserSchema,
498
514
  ref: RefSchema
@@ -531,6 +547,12 @@ const XPaintress = z.object({
531
547
  }).passthrough();
532
548
  const X_PAINTRESS_KEY = "x-paintress";
533
549
  const PTR_SPEC_VERSION = "1";
550
+ /**
551
+ * 新文档写入的 `version` 字段值。
552
+ * 必须与 `format/spec.yaml§meta.layers.pen_profile.observed_document_version` 同步
553
+ * (`test/spec-conformance.spec.ts` 会断言两者相等)。
554
+ */
555
+ const PEN_DOC_VERSION = "2.22";
534
556
  /** 文档根。passthrough:fileToken 与未来未知顶层键全部透传。 */
535
557
  const DocumentObjectSchema = z.object({
536
558
  version: z.string(),
@@ -715,7 +737,18 @@ function saveDoc(path, doc, opts = {}) {
715
737
  const text = JSON.stringify(out, null, 2);
716
738
  const tmp = join(dirname(path), `.${basename(path)}.tmp-${process.pid}`);
717
739
  writeFileSync(tmp, text, "utf8");
718
- renameSync(tmp, path);
740
+ if (opts.createOnly) {
741
+ try {
742
+ linkSync(tmp, path);
743
+ } catch (cause) {
744
+ try {
745
+ unlinkSync(tmp);
746
+ } catch {}
747
+ if (cause.code === "EEXIST") throw new Error(`文件已存在,拒绝覆盖:${path}`);
748
+ throw cause;
749
+ }
750
+ unlinkSync(tmp);
751
+ } else renameSync(tmp, path);
719
752
  return statSync(path).mtimeMs;
720
753
  }
721
754
  /** round-trip 序列化(不落盘):用于 diff、快照与测试 */
@@ -1486,11 +1519,15 @@ function renderNode(node, parentLayout, vars, warnings, extraAttrs = []) {
1486
1519
  css.push(...textToCss(node, vars, warnings));
1487
1520
  inner = escapeHtml(typeof node["content"] === "string" ? vars.resolve(node["content"]) : "");
1488
1521
  break;
1489
- case "icon": {
1522
+ case "icon":
1523
+ case "icon_font": {
1490
1524
  const colorCss = fillsToCss(node["fill"], vars, warnings, label).find(([p]) => p === "background-color");
1491
1525
  if (colorCss) css.push(["color", colorCss[1]], ["background", "transparent"]);
1492
- const lib = typeof node["library"] === "string" ? node["library"] : "lucide";
1493
- const iconName = typeof node["icon"] === "string" ? node["icon"] : "";
1526
+ const fontVariant = type === "icon_font";
1527
+ const libField = fontVariant ? node["iconFontFamily"] : node["library"];
1528
+ const nameField = fontVariant ? node["iconFontName"] : node["icon"];
1529
+ const lib = typeof libField === "string" && libField !== "" ? libField : "lucide";
1530
+ const iconName = typeof nameField === "string" ? vars.resolve(nameField) : "";
1494
1531
  let svg = null;
1495
1532
  if (lib === "lucide" || lib === "feather") svg = getLucideIcon(iconName, warnings);
1496
1533
  else warnings.push(`${label}: 图标库 "${lib}" 暂只支持 lucide/feather,渲染为占位框`);
@@ -1550,6 +1587,7 @@ const KNOWN_RENDER_TYPES = /* @__PURE__ */ new Set([
1550
1587
  "text",
1551
1588
  "note",
1552
1589
  "icon",
1590
+ "icon_font",
1553
1591
  "script",
1554
1592
  "browser"
1555
1593
  ]);
@@ -1568,6 +1606,23 @@ function computeCanvas(nodes, vars) {
1568
1606
  height: Math.max(1, Math.ceil(maxH))
1569
1607
  };
1570
1608
  }
1609
+ function boardBoxes(nodes, vars) {
1610
+ const out = [];
1611
+ for (const n of nodes) {
1612
+ if (!isRecord(n)) continue;
1613
+ const box = nodeBox(n, vars);
1614
+ const name = typeof n["name"] === "string" && n["name"] !== "" ? n["name"] : void 0;
1615
+ out.push({
1616
+ id: typeof n["id"] === "string" ? n["id"] : "",
1617
+ ...name !== void 0 ? { name } : {},
1618
+ x: box.x,
1619
+ y: box.y,
1620
+ w: box.w,
1621
+ h: box.h
1622
+ });
1623
+ }
1624
+ return out;
1625
+ }
1571
1626
  /** 渲染整棵(已展开的)树:顶层节点在画布上绝对定位,并带画布元数据 */
1572
1627
  function renderTree(nodes, vars, warnings) {
1573
1628
  return nodes.filter(isRecord).map((n) => renderNode(n, "none", vars, warnings, boardAttrs(n, vars))).join("");
@@ -1583,6 +1638,7 @@ function renderDocument(doc, opts = {}) {
1583
1638
  const vars = makeVarResolver(doc, warnings);
1584
1639
  const expanded = expandRefs(doc, warnings);
1585
1640
  const canvas = computeCanvas(expanded, vars);
1641
+ const boards = boardBoxes(expanded, vars);
1586
1642
  const title = opts.title ?? "paintress render";
1587
1643
  const body = renderTree(expanded, vars, warnings);
1588
1644
  return {
@@ -1607,7 +1663,8 @@ function renderDocument(doc, opts = {}) {
1607
1663
  ""
1608
1664
  ].join("\n"),
1609
1665
  warnings,
1610
- canvas
1666
+ canvas,
1667
+ boards
1611
1668
  };
1612
1669
  }
1613
- export { UnknownNodeSchema as $, DocumentObjectSchema as A, TextAlign as At, GroupSchema as B, Sizing as Bt, FileChangedError as C, LayoutDir as Ct, validateDoc as D, StrokeAlign as Dt, serializeDoc as E, ShadowKind as Et, XPaintress as F, Color as Ft, NodeSchema as G, KNOWN_NODE_TYPES as H, VARIABLE_NAME as Ht, X_PAINTRESS_KEY as I, ColorOrVar as It, PolygonSchema as J, NoteSchema as K, BrowserSchema as L, CornerRadius as Lt, PTR_SPEC_VERSION as M, TextGrowth as Mt, PtrDocumentSchema as N, Affine as Nt, collectIds as O, StrokeCap as Ot, VariableDef as P, BoolOrVar as Pt, TextSchema as Q, EllipseSchema as R, NumOrVar as Rt, update as S, ImageFit as St, saveDoc as T, PositionMode as Tt, LayoutBlock as U, VariableRef as Ut, IconSchema as V, StringOrVar as Vt, NodeBase as W, VERSION as Wt, RefSchema as X, RectangleSchema as Y, ScriptSchema as Z, get as _, BlendMode as _t, renderTree as a, FillEntry as at, query as b, GradientType as bt, cornerRadiusToCss as c, GraphicsBlock as ct, strokeToCss as d, ShaderEffect as dt, schemaForType as et, textToCss as f, ShaderFill as ft, buildIndex as g, StrokeWidth as gt, batch as h, StrokeBlock as ht, escapeHtml as i, Effects as it, DocumentSchema as j, TextAlignVertical as jt, genId as k, StrokeJoin as kt, effectsToCss as l, ImageFill as lt, makeVarResolver as m, SolidFill as mt, computeCanvas as n, BlurEffect as nt, getLucideIcon as o, Fills as ot, expandRefs as p, ShadowEffect as pt, PathSchema as q, escapeAttr as r, EffectEntry as rt, commonToCss as s, GradientFill as st, renderDocument as t, BackgroundBlurEffect as tt, fillsToCss as u, MeshGradientFill as ut, insert as v, CrossAxis as vt, loadDoc as w, MainAxis as wt, remove as x, IconLibrary as xt, move as y, FillRule as yt, FrameSchema as z, Padding as zt };
1670
+ export { RefSchema as $, genId as A, StrokeAlign as At, EllipseSchema as B, CornerRadius as Bt, update as C, GradientType as Ct, serializeDoc as D, MainAxis as Dt, saveDoc as E, LayoutDir as Et, PtrDocumentSchema as F, TextGrowth as Ft, KNOWN_NODE_TYPES as G, VARIABLE_NAME as Gt, GroupSchema as H, Padding as Ht, VariableDef as I, Affine as It, NodeSchema as J, LayoutBlock as K, VariableRef as Kt, XPaintress as L, BoolOrVar as Lt, DocumentSchema as M, StrokeJoin as Mt, PEN_DOC_VERSION as N, TextAlign as Nt, validateDoc as O, PositionMode as Ot, PTR_SPEC_VERSION as P, TextAlignVertical as Pt, RectangleSchema as Q, X_PAINTRESS_KEY as R, Color as Rt, remove as S, FillRule as St, loadDoc as T, ImageFit as Tt, IconFontSchema as U, Sizing as Ut, FrameSchema as V, NumOrVar as Vt, IconSchema as W, StringOrVar as Wt, PathSchema as X, NoteSchema as Y, PolygonSchema as Z, buildIndex as _, SolidFill as _t, escapeHtml as a, BlurEffect as at, move as b, BlendMode as bt, commonToCss as c, FillEntry as ct, fillsToCss as d, GraphicsBlock as dt, ScriptSchema as et, strokeToCss as f, ImageFill as ft, batch as g, ShadowEffect as gt, makeVarResolver as h, ShaderFill as ht, escapeAttr as i, BackgroundBlurEffect as it, DocumentObjectSchema as j, StrokeCap as jt, collectIds as k, ShadowKind as kt, cornerRadiusToCss as l, Fills as lt, expandRefs as m, ShaderEffect as mt, boardBoxes as n, UnknownNodeSchema as nt, renderTree as o, EffectEntry as ot, textToCss as p, MeshGradientFill as pt, NodeBase as q, VERSION as qt, computeCanvas as r, schemaForType as rt, getLucideIcon as s, Effects as st, renderDocument as t, TextSchema as tt, effectsToCss as u, GradientFill as ut, get as v, StrokeBlock as vt, FileChangedError as w, IconLibrary as wt, query as x, CrossAxis as xt, insert as y, StrokeWidth as yt, BrowserSchema as z, ColorOrVar as zt };
@@ -1,13 +1,43 @@
1
- import { D as validateDoc, O as collectIds, S as update, T as saveDoc, Wt as VERSION, _ as get, b as query, h as batch, t as renderDocument, v as insert, w as loadDoc, x as remove, y as move } from "./render-CbYe9rII.js";
1
+ import { A as genId, C as update, E as saveDoc, M as DocumentSchema, N as PEN_DOC_VERSION, O as validateDoc, S as remove, T as loadDoc, b as move, g as batch, k as collectIds, qt as VERSION, t as renderDocument, v as get, x as query, y as insert } from "./render-CzpO1cOe.js";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { z } from "zod";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
+ import { randomUUID } from "node:crypto";
6
7
  import { chromium } from "playwright-core";
7
8
  import { connect } from "node:net";
8
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
11
  import { spawn } from "node:child_process";
12
+ // src/doc/create.ts
13
+ function createDoc(opts = {}) {
14
+ const boardId = opts.boardId ?? genId(/* @__PURE__ */ new Set());
15
+ const board = {
16
+ type: "frame",
17
+ id: boardId,
18
+ name: opts.name ?? "Board",
19
+ x: 0,
20
+ y: 0,
21
+ width: opts.width ?? 900,
22
+ height: opts.height ?? 600,
23
+ fill: opts.fill ?? "#FFFFFF"
24
+ };
25
+ const layout = opts.layout ?? "vertical";
26
+ if (layout !== "none") {
27
+ board["layout"] = layout;
28
+ board["gap"] = opts.gap ?? 0;
29
+ board["padding"] = opts.padding ?? 0;
30
+ }
31
+ return {
32
+ doc: DocumentSchema.parse({
33
+ version: PEN_DOC_VERSION,
34
+ fileToken: opts.fileToken ?? randomUUID(),
35
+ variables: {},
36
+ children: [board]
37
+ }),
38
+ boardId
39
+ };
40
+ }
11
41
  // src/shot/screenshot.ts
12
42
  /**
13
43
  * 截图层:渲染 → 临时 HTML → headless Chromium → PNG(决策 3:两个消费者分开,AI 要 PNG)。
@@ -36,12 +66,51 @@ function findBrowser() {
36
66
  }
37
67
  return null;
38
68
  }
39
- /** 渲染并截图整张画布 */
69
+ function boardLabel(b) {
70
+ return b.name !== void 0 && b.name !== "" ? `${b.name}(id=${b.id})` : b.id;
71
+ }
72
+ /**
73
+ * 画板选择子:id 精确 → name 精确 → name/id 模糊(不区分大小写)。
74
+ * 模糊命中多个时报歧义并列出候选,不猜——猜错会让 AI 以为看到了另一页。
75
+ */
76
+ function selectBoard(boards, query) {
77
+ if (boards.length === 0) throw new Error("设计稿没有画板(顶层节点为空),无法按画板截图");
78
+ const q = query.trim();
79
+ if (q === "") throw new Error("board 参数不能为空");
80
+ const byId = boards.filter((b) => b.id === q);
81
+ if (byId.length === 1) return byId[0];
82
+ const byName = boards.filter((b) => b.name === q);
83
+ if (byName.length === 1) return byName[0];
84
+ const lower = q.toLowerCase();
85
+ const fuzzy = boards.filter((b) => b.id.toLowerCase().includes(lower) || (b.name ?? "").toLowerCase().includes(lower));
86
+ if (fuzzy.length === 1) return fuzzy[0];
87
+ const all = boards.map(boardLabel).join("、");
88
+ if (fuzzy.length === 0) throw new Error(`没有匹配「${q}」的画板;可选:${all}`);
89
+ throw new Error(`「${q}」命中 ${fuzzy.length} 个画板,请写更精确的名字或 id:${fuzzy.map(boardLabel).join("、")}`);
90
+ }
91
+ /** 渲染并截图整张画布;指定 board 时只裁该画板(走同一套渲染与浏览器) */
40
92
  async function screenshotPen(penPath, outPng, opts = {}) {
41
93
  const browserPath = findBrowser();
42
94
  if (!browserPath) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER 指向可执行文件");
43
95
  const loaded = loadDoc(penPath);
44
96
  const rendered = renderDocument(loaded.doc);
97
+ const target = opts.board !== void 0 ? selectBoard(rendered.boards, opts.board) : void 0;
98
+ if (target !== void 0 && (target.w <= 0 || target.h <= 0)) throw new Error(`画板 ${boardLabel(target)} 尺寸为 0(${target.w}×${target.h}),无法截图`);
99
+ const viewport = {
100
+ width: Math.max(rendered.canvas.width, target !== void 0 ? Math.ceil(target.x + target.w) : 0),
101
+ height: Math.max(rendered.canvas.height, target !== void 0 ? Math.ceil(target.y + target.h) : 0)
102
+ };
103
+ const clip = target !== void 0 ? {
104
+ x: target.x,
105
+ y: target.y,
106
+ width: Math.ceil(target.w),
107
+ height: Math.ceil(target.h)
108
+ } : {
109
+ x: 0,
110
+ y: 0,
111
+ width: rendered.canvas.width,
112
+ height: rendered.canvas.height
113
+ };
45
114
  const tmpDir = mkdtempSync(join(tmpdir(), "paintress-shot-"));
46
115
  const htmlPath = join(tmpDir, "page.html");
47
116
  writeFileSync(htmlPath, rendered.html, "utf8");
@@ -52,22 +121,14 @@ async function screenshotPen(penPath, outPng, opts = {}) {
52
121
  });
53
122
  try {
54
123
  const page = await browser.newPage({
55
- viewport: {
56
- width: rendered.canvas.width,
57
- height: rendered.canvas.height
58
- },
124
+ viewport,
59
125
  deviceScaleFactor: opts.scale ?? 2
60
126
  });
61
127
  await page.goto(`file://${htmlPath}`);
62
128
  await page.waitForTimeout(opts.settleMs ?? 120);
63
129
  await page.screenshot({
64
130
  path: outPng,
65
- clip: {
66
- x: 0,
67
- y: 0,
68
- width: rendered.canvas.width,
69
- height: rendered.canvas.height
70
- }
131
+ clip
71
132
  });
72
133
  } finally {
73
134
  await browser.close();
@@ -79,12 +140,21 @@ async function screenshotPen(penPath, outPng, opts = {}) {
79
140
  });
80
141
  }
81
142
  if (statSync(outPng).size === 0) throw new Error("截图产物为空文件");
82
- return {
143
+ const result = {
83
144
  out: outPng,
84
145
  canvas: rendered.canvas,
85
146
  warnings: rendered.warnings,
86
147
  browser: browserPath
87
148
  };
149
+ if (target !== void 0) result.board = {
150
+ id: target.id,
151
+ ...target.name !== void 0 ? { name: target.name } : {},
152
+ x: target.x,
153
+ y: target.y,
154
+ width: target.w,
155
+ height: target.h
156
+ };
157
+ return result;
88
158
  }
89
159
  // src/web/protocol.ts
90
160
  function encodeLine(msg) {
@@ -357,7 +427,75 @@ function countByType(children) {
357
427
  walk(children);
358
428
  return counts;
359
429
  }
430
+ /**
431
+ * 新建文档。path 不带扩展名时补 `.ptr`:新建一律默认标准格式,
432
+ * `.pen` 只有在调用方显式写扩展名时才产出(兼容旧工具链)。
433
+ */
434
+ function newDoc(args) {
435
+ const raw = reqStr(args, "path");
436
+ const path = /\.(pen|ptr)$/i.test(raw) ? raw : `${raw}.ptr`;
437
+ const flavor = path.toLowerCase().endsWith(".pen") ? "pen" : "ptr";
438
+ if (existsSync(path) && optBool(args, "overwrite") !== true) throw new Error(`文件已存在,未覆盖:${path}(确需覆盖传 overwrite=true)`);
439
+ const width = optNum(args, "width");
440
+ const height = optNum(args, "height");
441
+ if (width !== void 0 && width <= 0) throw new Error(`width 必须大于 0,收到:${width}`);
442
+ if (height !== void 0 && height <= 0) throw new Error(`height 必须大于 0,收到:${height}`);
443
+ const layoutArg = optStr(args, "layout");
444
+ if (layoutArg !== void 0 && ![
445
+ "vertical",
446
+ "horizontal",
447
+ "none"
448
+ ].includes(layoutArg)) throw new Error(`layout 仅支持 vertical / horizontal / none,收到:${layoutArg}`);
449
+ const { doc, boardId } = createDoc({
450
+ name: optStr(args, "name"),
451
+ width,
452
+ height,
453
+ fill: optStr(args, "fill"),
454
+ layout: layoutArg === "vertical" || layoutArg === "horizontal" || layoutArg === "none" ? layoutArg : void 0,
455
+ padding: optNum(args, "padding"),
456
+ gap: optNum(args, "gap")
457
+ });
458
+ const check = validateDoc(doc);
459
+ if (!check.ok) throw new Error(`新建文档结构校验失败,未落盘:\n${check.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
460
+ mkdirSync(dirname(path), { recursive: true });
461
+ saveDoc(path, doc, {
462
+ flavor,
463
+ createOnly: optBool(args, "overwrite") !== true
464
+ });
465
+ const board = doc.children[0];
466
+ return {
467
+ ok: true,
468
+ path,
469
+ flavor,
470
+ version: doc.version,
471
+ boardId,
472
+ canvas: {
473
+ width: Number(board?.["width"] ?? 0),
474
+ height: Number(board?.["height"] ?? 0)
475
+ },
476
+ warnings: check.warnings
477
+ };
478
+ }
360
479
  const TOOLS = {
480
+ paintress_new: {
481
+ description: "新建文档(.ptr 是 paintress 的标准格式;.pen 仅作兼容)。创建一个带空 board 的最小可用文档,可直接 render/screenshot 再逐层 insert;已存在同名文件时报错,除非 overwrite=true。path 不带扩展名时自动补 .ptr",
482
+ inputSchema: {
483
+ path: z.string().describe("新文档路径;不带扩展名时自动补 .ptr"),
484
+ name: z.string().optional().describe("board 名称(默认 Board)"),
485
+ width: z.number().positive().optional().describe("board 宽(默认 900)"),
486
+ height: z.number().positive().optional().describe("board 高(默认 600)"),
487
+ fill: z.string().optional().describe("board 背景色(默认 #FFFFFF)"),
488
+ layout: z.enum([
489
+ "vertical",
490
+ "horizontal",
491
+ "none"
492
+ ]).optional().describe("子节点布局(默认 vertical)"),
493
+ padding: z.number().optional().describe("board 内边距(默认 0)"),
494
+ gap: z.number().optional().describe("主轴向间距(默认 0)"),
495
+ overwrite: z.boolean().optional().describe("已存在时覆盖(默认 false)")
496
+ },
497
+ run: (a) => newDoc(a)
498
+ },
361
499
  paintress_doc_info: {
362
500
  description: "文档摘要:版本、格式风味、节点统计、变量表、顶层节点",
363
501
  inputSchema: { path: z.string().describe("设计稿路径") },
@@ -540,19 +678,25 @@ const TOOLS = {
540
678
  }
541
679
  },
542
680
  paintress_screenshot: {
543
- description: "渲染并截图为 PNG(默认 2x);需要本机 Chromium/Chrome 或 PTR_BROWSER 指向的可执行文件",
681
+ description: "渲染并截图为 PNG(默认 2x);需要本机 Chromium/Chrome 或 PTR_BROWSER 指向的可执行文件。指定 board 时只截该画板(顶层 frame,即“一页”),而不是整张画布",
544
682
  inputSchema: {
545
683
  path: z.string(),
546
684
  out: z.string().describe("输出 .png 路径"),
547
- scale: z.number().int().min(1).max(4).optional().describe("像素倍率,默认 2")
685
+ scale: z.number().int().min(1).max(4).optional().describe("像素倍率,默认 2"),
686
+ board: z.string().optional().describe("只截某个画板:id 或 name;模糊命中多个时会报错并列出候选")
548
687
  },
549
688
  run: async (a) => {
550
689
  if (!findBrowser()) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER");
551
- const res = await screenshotPen(reqStr(a, "path"), reqStr(a, "out"), { ...typeof a["scale"] === "number" ? { scale: a["scale"] } : {} });
690
+ const board = typeof a["board"] === "string" ? a["board"] : "";
691
+ const res = await screenshotPen(reqStr(a, "path"), reqStr(a, "out"), {
692
+ ...typeof a["scale"] === "number" ? { scale: a["scale"] } : {},
693
+ ...board !== "" ? { board } : {}
694
+ });
552
695
  return {
553
696
  ok: true,
554
697
  out: res.out,
555
698
  canvas: res.canvas,
699
+ ...res.board !== void 0 ? { board: res.board } : {},
556
700
  browser: res.browser,
557
701
  warnings: res.warnings
558
702
  };
@@ -614,12 +758,13 @@ async function ensureDaemon(opts = {}) {
614
758
  async function autoRegister(cwd = process.cwd()) {
615
759
  if (process.env["PAINTRESS_NO_AUTO_WEB"] === "1") return null;
616
760
  try {
617
- const { started } = await ensureDaemon();
761
+ const { started } = await ensureDaemon({ idleTimeoutMs: 36e5 });
618
762
  const project = deriveProjectName(cwd);
619
763
  const watched = await sendRequest({
620
764
  kind: "watch",
621
765
  project,
622
- dir: cwd
766
+ dir: cwd,
767
+ owner: "agent"
623
768
  }, 8e3);
624
769
  if (!watched.ok) throw new Error(watched.error);
625
770
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paintress",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Design-document operation layer for AI agents: read, query, edit, render",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,8 @@
38
38
  "verify:pkg": "node scripts/verify-package.mjs",
39
39
  "prepack": "pnpm build && pnpm verify:pkg",
40
40
  "build:ui": "pnpm --dir ui build",
41
- "dev:ui": "pnpm --dir ui dev"
41
+ "dev:ui": "pnpm --dir ui dev",
42
+ "release": "node ./scripts/release.mjs"
42
43
  },
43
44
  "dependencies": {
44
45
  "@modelcontextprotocol/sdk": "^1.32.2",
@@ -1,11 +1,17 @@
1
1
  ---
2
2
  name: paintress
3
- description: Read, query, edit, render and screenshot .pen/.ptr design documents through MCP tools, and drive the local viewer. Use when the user refers to a design file, a node in it, a board screenshot, or shorthand ids like #1.
3
+ description: Create, read, query, edit, render and screenshot `.ptr` (paintress standard format) and `.pen` (compatible) design documents through MCP tools, and drive the local viewer. Use when the user refers to a design file, wants a new design file created, a node in it, a board screenshot, or shorthand ids like #1.
4
4
  ---
5
5
 
6
6
  # paintress
7
7
 
8
- paintress is a design-document operation layer: JSON design files (`.pen`, and paintress's own `.ptr`) in, MCP tools and a CLI out. It does not draw anything itself — it reads and writes the file, and renders it to HTML for a browser.
8
+ paintress is a design-document operation layer: JSON design files in, MCP tools and a CLI out. It does not draw anything itself — it reads and writes the file, and renders it to HTML for a browser.
9
+
10
+ ## Formats
11
+
12
+ - **`.ptr` is paintress's standard format.** Create this by default for anything new. It carries the `x-paintress` block (`spec`, `generator`, timestamps) and is the layer that evolves.
13
+ - **`.pen` is compatible**: read, queried, edited and rendered exactly the same way. Do not convert a `.pen` the user is still working with — edit it in place.
14
+ - Both render identically; the difference is only which layer owns future format changes.
9
15
 
10
16
  ## Check before installing
11
17
 
@@ -20,6 +26,7 @@ If either prints something, **do not install again**. Use the existing binary. `
20
26
 
21
27
  | Tool | What it does |
22
28
  | --- | --- |
29
+ | `paintress_new` | **create a document.** Writes a valid `.ptr` with one empty board; returns the `boardId`. Refuses to overwrite; `path` without an extension gets `.ptr` |
23
30
  | `paintress_doc_info` | document summary: version, format flavor, node counts, variables, top-level nodes |
24
31
  | `paintress_query` | find nodes by type / name (exact or regex) / id / reusable |
25
32
  | `paintress_get` | fetch one node's subtree; `depth` controls how far down |
@@ -30,16 +37,21 @@ If either prints something, **do not install again**. Use the existing binary. `
30
37
  | `paintress_batch` | several operations in one call |
31
38
  | `paintress_validate` | check the document against the format spec |
32
39
  | `paintress_render` | write the canvas as a standalone HTML file |
33
- | `paintress_screenshot` | render to PNG (headless Chromium) |
40
+ | `paintress_screenshot` | render to PNG (headless Chromium); pass `board` (id or name) to capture one board instead of the whole canvas |
34
41
  | `paintress_marks` | read the user's click markers (`#1 → node id`) |
35
42
 
36
43
  ## Working rules
37
44
 
38
- 1. **Query before you edit.** Node ids come from the file; never invent them.
39
- 2. **`#N` means a marker.** When the user writes `#1`, call `paintress_marks` and map `#1` to a real node id. Markers are per viewer session.
40
- 3. **Top-level nodes are boards.** Use `paintress_doc_info` to list them before guessing what "the second screen" means.
41
- 4. **After a batch, validate.** `paintress_validate` catches structural mistakes before the user opens the file.
42
- 5. **Screenshots are for you.** `paintress_screenshot` is how you look at the design; the HTML viewer is for the human.
45
+ 1. **Nothing to edit? Create it.** `paintress_new` is the only way to make a file; there is no "empty document" state to work around, and no need to hand-write JSON.
46
+ 2. **Query before you edit.** Node ids come from the file; never invent them.
47
+ 3. **`#N` means a marker.** When the user writes `#1`, call `paintress_marks` and map `#1` to a real node id. Markers are per viewer session.
48
+ 4. **Top-level nodes are boards.** Use `paintress_doc_info` to list them before guessing what "the second screen" means.
49
+ 5. **After a batch, validate.** `paintress_validate` catches structural mistakes before the user opens the file.
50
+ 6. **Screenshots are for you.** `paintress_screenshot` is how you look at the design; the HTML viewer is for the human. When a document has several boards, pass `board` to get just that page — an ambiguous name errors out and lists the candidates rather than guessing.
51
+
52
+ ## Node fields worth knowing
53
+
54
+ `type` and `id` are immutable after creation. Text nodes hold their string in **`content`** (not `text`). `update` is a shallow merge and does **not** validate field names — a typo silently creates a new key and the render does not change. When unsure of a field name, `paintress_get` the node and read the real one.
43
55
 
44
56
  ## Viewer
45
57
 
@@ -51,3 +63,18 @@ paintress watch <name> <dir> # register a directory
51
63
  ```
52
64
 
53
65
  Ask the user to open the URL and click the nodes they mean; then read `paintress_marks` to learn which ids those clicks became.
66
+
67
+ ## Bind the project yourself — do not wait to be asked
68
+
69
+ When the user refers to a design, asks to see one, or asks to draw something in this project, **you** make it viewable. The MCP server auto-registers the working directory at startup (its name is derived from the last two path segments, e.g. `acme-shop`); if that already covers the request, just hand over the viewer URL:
70
+
71
+ - viewer: `http://localhost:3316/`
72
+ - deep link to a file: `http://localhost:3316/?project=<name>&file=<relative/path>`
73
+
74
+ If the design lives elsewhere (a `.pen`/`.ptr` outside the working directory, or a second project), bind it yourself:
75
+
76
+ ```bash
77
+ paintress watch <name> <dir> [--depth 1..6]
78
+ ```
79
+
80
+ Prefer the same auto-derived name when it applies, so the URL stays stable. Never ask the user to run `watch` — the goal is that "look at / draw a design" resolves to a viewable URL with zero extra steps. The project list in any open viewer window refreshes on its own once the registration lands.
@@ -0,0 +1 @@
1
+ :root{--bg:#f6f1ea;--panel:#fffcf8;--border:#e6ded2;--text:#1f2a37;--muted:#7a7166;--accent:#205080;--accent-soft:#20508014;--gold:#908030;--gold-soft:#90803073;--radius:8px}*{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%}body{color:var(--text);background:var(--bg);-webkit-font-smoothing:antialiased;font:14px/1.55 system-ui,-apple-system,Segoe UI,Noto Sans SC,sans-serif}.app{grid-template-columns:280px 1fr;height:100%;display:grid}.sidebar{background:var(--panel);border-right:1px solid var(--border);flex-direction:column;min-height:0;display:flex}.sidebar-head{border-bottom:1px solid var(--border);align-items:center;gap:8px;padding:16px 16px 12px;display:flex}.sidebar-head h1{letter-spacing:-.01em;color:var(--accent);font-size:15px;font-weight:650}.badge{color:var(--muted);background:var(--bg);border-radius:999px;padding:1px 6px;font-size:11px}.badge-stack{font:inherit;cursor:pointer;border:1px solid #0000;flex-direction:column;align-items:flex-start;gap:1px;font-size:11px;line-height:1.2;display:inline-flex}.badge-stack:hover{border-color:#b4402a59}.badge-new{color:#b4402a;font-weight:700}.prompt{background:var(--bg);border:1px solid var(--border);font:inherit;white-space:pre-wrap;word-break:break-word;-webkit-user-select:all;user-select:all;border-radius:6px;margin:10px 0 8px;padding:8px 10px;font-size:12px;line-height:1.6}a.ghost{text-decoration:none;display:inline-block}.section{min-height:0;padding:12px 8px 4px;overflow-y:auto}.section.files{border-top:1px solid var(--border);flex:1;padding-top:12px}.section h2{text-transform:uppercase;letter-spacing:.06em;color:var(--muted);justify-content:space-between;gap:8px;padding:0 8px 8px;font-size:11px;font-weight:600;display:flex}.hint{color:var(--muted);text-transform:none;letter-spacing:0;font-size:11px;font-weight:400}.list{list-style:none}.item{text-align:left;cursor:pointer;width:100%;font:inherit;color:inherit;background:0 0;border:0;border-radius:6px;flex-direction:column;gap:1px;padding:7px 8px;display:flex}.item:hover{background:var(--bg)}.item.active{background:var(--accent-soft);color:var(--accent)}.item-name{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.item-meta{color:var(--muted);font-size:11px}.item.active .item-meta{color:var(--accent);opacity:.75}.empty{color:var(--muted);padding:4px 8px 12px;font-size:12px;line-height:1.7}.empty code{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:1px 5px;font-size:11px}.sidebar-foot{border-top:1px solid var(--border);color:var(--muted);flex-wrap:wrap;align-items:center;gap:8px;padding:10px 16px;font-size:12px;display:flex}.toggle{cursor:pointer;align-items:center;gap:5px;display:inline-flex}.ghost{background:var(--bg);border:1px solid var(--border);font:inherit;color:var(--text);cursor:pointer;border-radius:6px;padding:3px 9px;font-size:12px}.ghost:hover{border-color:var(--accent);color:var(--accent)}.stage{background:var(--canvas-bg,var(--bg));flex-direction:column;min-width:0;min-height:0;display:flex}.toolbar{border-bottom:1px solid var(--border);background:var(--panel);justify-content:space-between;align-items:center;gap:12px;padding:8px 14px;display:flex}.toolbar code{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.preview{border:1px solid var(--border);background:var(--canvas-bg,#fff);border-radius:10px;flex:1;width:calc(100% - 24px);min-height:0;margin:12px;box-shadow:0 1px 2px #1f2a370d,0 8px 24px #1f2a370d}.stage[data-canvas-bg=dark] .preview{border-color:#ffffff1f;box-shadow:0 1px 2px #00000059,0 10px 30px #00000052}.placeholder{color:var(--muted);flex:1;place-items:center;display:grid}.error{border-radius:var(--radius);color:#b91c1c;white-space:pre-wrap;background:#fef2f2;border:1px solid #fecaca;margin:12px;padding:10px 12px;font-size:12px}.section.marks{border-top:1px solid var(--border);flex:0 auto;max-height:34vh}.mark-no{background:var(--accent-soft);min-width:20px;color:var(--accent);font-variant-numeric:tabular-nums;border-radius:4px;margin-right:4px;padding:0 4px;font-size:11px;display:inline-block}.item.active .mark-no{background:#fff}.ghost.tiny{text-transform:none;letter-spacing:0;padding:1px 6px;font-size:11px}.crumbs{flex-wrap:wrap;align-items:center;gap:3px;min-width:0;display:flex;overflow:hidden}.crumbs code{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.crumb{font:inherit;color:var(--muted);cursor:pointer;text-overflow:ellipsis;white-space:nowrap;background:0 0;border:0;border-radius:4px;max-width:220px;padding:2px 5px;font-size:12px;overflow:hidden}.crumb:hover{background:var(--bg);color:var(--text)}.crumb.active{color:var(--accent);background:var(--accent-soft)}.sep{color:var(--border);font-size:11px}.brand-mark{flex:none;width:auto;height:32px}.canvas-bg{-webkit-user-select:none;user-select:none;flex:none;align-items:center;gap:8px;display:inline-flex}.canvas-bg-label{letter-spacing:.04em;text-transform:uppercase;color:var(--muted);text-align:right;min-width:34px;font-size:11px;font-weight:500}.canvas-bg-track{width:84px;height:calc(var(--thumb) + var(--pad) * 2);cursor:pointer;touch-action:none;background:linear-gradient(#e7e0d5,#f3eee6 60%,#f7f2ea);border-radius:999px;position:relative;box-shadow:inset 0 1px 2px #1f2a373d,inset 0 -1px #ffffffe6,inset 0 0 0 1px #1f2a3712,0 1px #fff}.canvas-bg-track:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.canvas-bg-thumb{top:var(--pad);left:var(--pad);width:var(--thumb);height:var(--thumb);transform:translateX(calc(var(--pos) * var(--step)));background:linear-gradient(#fff,#f1ebe1);border-radius:50%;justify-content:center;align-items:center;transition:transform .17s cubic-bezier(.22,.61,.36,1);display:flex;position:absolute;box-shadow:0 1px 2px #1f2a374d,0 0 0 1px #1f2a3714,inset 0 1px #fff}.canvas-bg-track.dragging .canvas-bg-thumb{transition:none}.canvas-bg-dot{background:var(--dot,#fff);border-radius:50%;width:11px;height:11px;transition:background .17s;box-shadow:0 0 0 1px #1f2a3742,0 1px 1px #1f2a371f}.lang-switch{background:linear-gradient(#ece6dc,#f6f1e9);border-radius:999px;flex:none;align-items:center;gap:2px;margin-left:4px;padding:2px;display:inline-flex;box-shadow:inset 0 1px 2px #1f2a3729,inset 0 0 0 1px #1f2a370d}.lang-opt{font:inherit;color:var(--muted);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:2px 7px;font-size:11px;line-height:16px}.lang-opt:hover{color:var(--text)}.lang-opt.active{color:var(--accent);background:linear-gradient(#fffdf9,#f3ede4);font-weight:600;box-shadow:0 1px 2px #1f2a372e,inset 0 1px #fff}.tool-sep{background:var(--border);flex:none;width:1px;height:18px;margin:0 2px}.tools{flex:none;align-items:center;gap:6px;display:flex}.zoom{font-variant-numeric:tabular-nums;text-align:right;min-width:40px}.boardbar{border-bottom:1px solid var(--border);background:var(--panel);align-items:center;gap:6px;padding:6px 14px;display:flex;overflow-x:auto}.board{background:var(--bg);border:1px solid var(--border);font:inherit;color:var(--text);cursor:pointer;border-radius:6px;flex:none;align-items:baseline;gap:6px;padding:3px 9px;font-size:12px;display:inline-flex}.board:hover{border-color:var(--accent);color:var(--accent)}.board.active{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.board-size{color:var(--muted);font-variant-numeric:tabular-nums;font-size:10px}.board.active .board-size{color:var(--accent);opacity:.75}.row{align-items:center;display:flex;position:relative}.row .item{flex:1;min-width:0}.row-action{background:var(--panel);width:20px;height:20px;color:var(--muted);font:inherit;cursor:pointer;opacity:0;border:1px solid #0000;border-radius:5px;place-items:center;padding:0;font-size:13px;line-height:1;transition:opacity .12s;display:grid;position:absolute;top:50%;right:4px;transform:translateY(-50%)}.row:hover .row-action,.row-action:focus-visible{opacity:1}.row-action:hover{color:#a33;border-color:#aa333359}@media (hover:none){.row-action{opacity:1}}.modal-backdrop{z-index:50;background:#1f2a3747;place-items:center;display:grid;position:fixed;inset:0}.modal{background:var(--panel);border:1px solid var(--border);border-radius:10px;width:min(380px,100vw - 32px);padding:16px 18px 14px;box-shadow:0 12px 40px #1f2a372e}.modal h3{color:var(--accent);margin-bottom:6px;font-size:14px;font-weight:650}.modal-body{color:var(--muted);font-size:12.5px;line-height:1.65}.modal-actions{justify-content:flex-end;gap:8px;margin-top:14px;display:flex}.ghost.danger{color:#a33}.ghost.danger:hover,.ghost.danger:focus-visible{color:#a33;background:#aa33330f;border-color:#a33}