@penpair/core 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +8 -12
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +99 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +28 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +177 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +28 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +114 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +52 -3
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +132 -52
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +35 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +770 -139
- package/dist/client.d.ts +26 -5
- package/dist/client.js +46 -20
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +179 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +122 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +61 -0
- package/dist/deep-check.js +9 -5
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +135 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +48 -4
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +42 -7
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +32 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +40 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +28 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +344 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +76 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +102 -11
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +53 -12
- package/dist/instance/connection.d.ts +14 -0
- package/dist/instance/connection.js +13 -2
- package/dist/instance/init.js +1 -1
- package/dist/instance/setup.js +1 -1
- package/dist/instance/tty.js +3 -1
- package/dist/journal.d.ts +77 -0
- package/dist/journal.js +260 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +83 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +309 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +90 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +79 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +14 -0
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +102 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +42 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +15 -0
- package/dist/operation.d.ts +20 -1
- package/dist/operation.js +113 -13
- package/dist/png.d.ts +5 -0
- package/dist/png.js +7 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +134 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +19 -3
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +51 -0
- package/dist/project-snippets.js +12 -7
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +79 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +17 -3
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +100 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +90 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +75 -0
- package/dist/skills.d.ts +11 -0
- package/dist/skills.js +71 -33
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +12 -8
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +71 -40
- package/dist/structure-snapshot.d.ts +73 -0
- package/dist/structure-snapshot.js +171 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +197 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +89 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +12 -40
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
- package/skills/penpair-design/references/project-facts.md +1 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +14 -0
- package/skills/penpair-handoff/references/handoff-flow.md +11 -0
- package/skills/penpair-recipes/SKILL.md +16 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +6 -2
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/** Plain plugin-sandbox JavaScript. Inclusion defines helpers without touching the document. */
|
|
2
|
+
export const coreKitSource = String.raw `// penpair: read
|
|
3
|
+
const pp = (() => {
|
|
4
|
+
const bindings = [], styles = [], recipes = new Map();
|
|
5
|
+
let flushing = false, queuedContext = null;
|
|
6
|
+
const numeric = (value, label) => { if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
7
|
+
throw new Error(label + " must be finite and nonnegative"); return value; };
|
|
8
|
+
const activeSets = () => { const sets = penpot.library.local.tokens.sets; if (!Array.isArray(sets))
|
|
9
|
+
throw new Error("Token API unavailable"); return sets.filter(set => set.active); };
|
|
10
|
+
const context = () => JSON.stringify(activeSets().map(set => [set.id || null, set.name, set.tokens.map(token => [token.id || null, token.name, token.type, token.value])]));
|
|
11
|
+
const tok = name => { if (typeof name !== "string" || !name)
|
|
12
|
+
throw new Error("Token name required"); let found = null, count = 0; for (const set of activeSets()) {
|
|
13
|
+
const matches = [];
|
|
14
|
+
for (const token of set.tokens) {
|
|
15
|
+
if (++count > 10000)
|
|
16
|
+
throw new Error("Active token budget exceeded");
|
|
17
|
+
if (token.name === name)
|
|
18
|
+
matches.push(token);
|
|
19
|
+
}
|
|
20
|
+
if (matches.length > 1)
|
|
21
|
+
throw new Error("Ambiguous token in set: " + name);
|
|
22
|
+
if (matches.length)
|
|
23
|
+
found = matches[0];
|
|
24
|
+
} if (!found)
|
|
25
|
+
throw new Error("No active token: " + name); return found; };
|
|
26
|
+
const numericToken = (value, label) => { if (value === undefined || value === 0)
|
|
27
|
+
return { value: 0, token: null }; if (typeof value !== "string")
|
|
28
|
+
throw new Error(label + " needs a token name (only unbound zero is allowed)"); const token = tok(value); if (token.type !== "spacing")
|
|
29
|
+
throw new Error(label + " needs a spacing token"); const resolved = token.resolvedValue === undefined ? Number(token.value) : Number(token.resolvedValue); return { value: numeric(resolved, label), token: value }; };
|
|
30
|
+
const reserve = () => { const actual = context(); if (queuedContext === null)
|
|
31
|
+
queuedContext = actual;
|
|
32
|
+
else if (queuedContext !== actual)
|
|
33
|
+
throw new Error("Active token context changed while building"); };
|
|
34
|
+
const bind = (shape, name, properties) => { if (!shape || !shape.id || typeof shape.applyToken !== "function")
|
|
35
|
+
throw new Error("Bindable shape required"); if (!Array.isArray(properties) || !properties.length || properties.some(key => typeof key !== "string" || !key))
|
|
36
|
+
throw new Error("Explicit binding properties required"); tok(name); reserve(); if (bindings.length >= 10000)
|
|
37
|
+
throw new Error("Binding queue exceeds budget"); bindings.push({ shape, name, properties: [...properties] }); return shape; };
|
|
38
|
+
const checkStyle = options => { for (const key of ["bg", "stroke", "radius"])
|
|
39
|
+
if (options[key] !== undefined && (typeof options[key] !== "string" || !options[key]))
|
|
40
|
+
throw new Error(key + " needs a token name"); for (const key of ["bg", "stroke", "radius"])
|
|
41
|
+
if (options[key] !== undefined) {
|
|
42
|
+
const token = tok(options[key]);
|
|
43
|
+
if (token.type !== (key === "radius" ? "borderRadius" : "color"))
|
|
44
|
+
throw new Error("Wrong token type for " + key);
|
|
45
|
+
} numericToken(options.gap, "gap"); const pad = options.pad === undefined ? [0, 0, 0, 0] : options.pad; if (!Array.isArray(pad) || pad.length !== 4)
|
|
46
|
+
throw new Error("Padding requires four token names or zeros"); pad.forEach(value => numericToken(value, "padding")); };
|
|
47
|
+
const style = (shape, options = {}) => {
|
|
48
|
+
checkStyle(options);
|
|
49
|
+
if ((options.gap !== undefined || options.pad !== undefined) && !shape.flex)
|
|
50
|
+
throw new Error("Spacing style requires a flex board");
|
|
51
|
+
for (const name of [options.bg, options.stroke, options.radius].filter(Boolean))
|
|
52
|
+
tok(name);
|
|
53
|
+
const gap = numericToken(options.gap, "gap"), pad = options.pad === undefined ? [0, 0, 0, 0] : options.pad;
|
|
54
|
+
if (!Array.isArray(pad) || pad.length !== 4)
|
|
55
|
+
throw new Error("Padding requires four token names or zeros");
|
|
56
|
+
const pads = pad.map(value => numericToken(value, "padding"));
|
|
57
|
+
reserve();
|
|
58
|
+
if (styles.length >= 5000)
|
|
59
|
+
throw new Error("Style queue exceeds budget");
|
|
60
|
+
styles.push(() => {
|
|
61
|
+
if (shape.flex) {
|
|
62
|
+
if (options.gap !== undefined) {
|
|
63
|
+
shape.flex.rowGap = gap.value;
|
|
64
|
+
shape.flex.columnGap = gap.value;
|
|
65
|
+
}
|
|
66
|
+
if (options.pad !== undefined) {
|
|
67
|
+
shape.flex.paddingType = "multiple";
|
|
68
|
+
["topPadding", "rightPadding", "bottomPadding", "leftPadding"].forEach((key, index) => { shape.flex[key] = pads[index].value; });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (options.stroke) {
|
|
72
|
+
shape.strokes = [{ strokeColor: "#000000", strokeWidth: options.strokeWidth === undefined ? 1 : numeric(options.strokeWidth, "stroke width"), strokeAlignment: "inner" }];
|
|
73
|
+
bind(shape, options.stroke, ["strokeColor"]);
|
|
74
|
+
}
|
|
75
|
+
if (options.bg)
|
|
76
|
+
bind(shape, options.bg, ["fill"]);
|
|
77
|
+
if (options.radius)
|
|
78
|
+
bind(shape, options.radius, ["borderRadiusTopLeft", "borderRadiusTopRight", "borderRadiusBottomRight", "borderRadiusBottomLeft"]);
|
|
79
|
+
if (gap.token)
|
|
80
|
+
bind(shape, gap.token, ["rowGap", "columnGap"]);
|
|
81
|
+
pads.forEach((entry, index) => { if (entry.token)
|
|
82
|
+
bind(shape, entry.token, [["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"][index]]); });
|
|
83
|
+
});
|
|
84
|
+
return shape;
|
|
85
|
+
};
|
|
86
|
+
const box = (parent, options = {}) => {
|
|
87
|
+
checkStyle(options);
|
|
88
|
+
const gap = numericToken(options.gap, "gap");
|
|
89
|
+
if (options.w !== undefined)
|
|
90
|
+
numeric(options.w, "width");
|
|
91
|
+
if (options.h !== undefined)
|
|
92
|
+
numeric(options.h, "height");
|
|
93
|
+
const board = penpot.createBoard();
|
|
94
|
+
if (parent)
|
|
95
|
+
parent.appendChild(board);
|
|
96
|
+
board.name = options.name || "Board";
|
|
97
|
+
board.fills = [];
|
|
98
|
+
board.clipContent = !!options.clip;
|
|
99
|
+
board.addFlexLayout();
|
|
100
|
+
board.flex.dir = options.dir || "column";
|
|
101
|
+
board.flex.wrap = options.wrap ? "wrap" : "nowrap";
|
|
102
|
+
board.flex.alignItems = options.align || "start";
|
|
103
|
+
board.flex.justifyContent = options.justify || "start";
|
|
104
|
+
board.flex.rowGap = gap.value;
|
|
105
|
+
board.flex.columnGap = gap.value;
|
|
106
|
+
if (options.w !== undefined || options.h !== undefined)
|
|
107
|
+
board.resize(options.w === undefined ? board.width : options.w, options.h === undefined ? board.height : options.h);
|
|
108
|
+
board.flex.horizontalSizing = options.w === undefined ? "auto" : "fix";
|
|
109
|
+
board.flex.verticalSizing = options.h === undefined ? "auto" : "fix";
|
|
110
|
+
return style(board, { ...options, gap: options.gap === undefined ? 0 : options.gap, pad: options.pad === undefined ? [0, 0, 0, 0] : options.pad });
|
|
111
|
+
};
|
|
112
|
+
const text = (parent, value, options = {}) => {
|
|
113
|
+
if (typeof value !== "string" || value.length === 0)
|
|
114
|
+
throw new Error("Text needs nonempty characters; use an explicit space for blank text");
|
|
115
|
+
const typography = tok(options.typo);
|
|
116
|
+
if (typography.type !== "typography" || tok(options.color).type !== "color")
|
|
117
|
+
throw new Error("Text needs typography and color token types");
|
|
118
|
+
let resolved = typography.resolvedValue;
|
|
119
|
+
if (Array.isArray(resolved) && resolved.length === 1)
|
|
120
|
+
resolved = resolved[0];
|
|
121
|
+
const data = resolved && typeof resolved === "object" && !Array.isArray(resolved) ? resolved : typography.value;
|
|
122
|
+
const families = options.font || (data && typeof data === "object" && (data.fontFamilies || data.fontFamily));
|
|
123
|
+
const family = Array.isArray(families) ? families[0] : families;
|
|
124
|
+
if (typeof family !== "string" || !family || family.includes("{"))
|
|
125
|
+
throw new Error("An explicit or resolved font family is required");
|
|
126
|
+
const font = penpot.fonts.findByName(family);
|
|
127
|
+
if (!font || !Array.isArray(font.variants) || typeof font.applyToText !== "function")
|
|
128
|
+
throw new Error("Font unavailable: " + family);
|
|
129
|
+
const weight = String(options.weight || (data && typeof data === "object" && (data.fontWeight || data.fontWeights)) || "400"), fontStyle = options.fontStyle || "normal";
|
|
130
|
+
const variants = font.variants.filter(variant => String(variant.fontWeight) === weight && (variant.fontStyle || "normal") === fontStyle);
|
|
131
|
+
if (variants.length !== 1)
|
|
132
|
+
throw new Error("Font weight/style is missing or ambiguous: " + family + " " + weight);
|
|
133
|
+
const shape = penpot.createText(value);
|
|
134
|
+
if (parent)
|
|
135
|
+
parent.appendChild(shape);
|
|
136
|
+
shape.name = options.name || value.slice(0, 80);
|
|
137
|
+
shape.growType = options.wrap ? "auto-height" : options.grow || "auto-width";
|
|
138
|
+
if (options.align)
|
|
139
|
+
shape.align = options.align;
|
|
140
|
+
font.applyToText(shape, variants[0]);
|
|
141
|
+
if (options.wrap || options.fill) {
|
|
142
|
+
if (!shape.layoutChild)
|
|
143
|
+
throw new Error("Text layout child unavailable");
|
|
144
|
+
shape.layoutChild.horizontalSizing = "fill";
|
|
145
|
+
}
|
|
146
|
+
bind(shape, options.typo, ["typography"]);
|
|
147
|
+
bind(shape, options.color, ["fill"]);
|
|
148
|
+
return shape;
|
|
149
|
+
};
|
|
150
|
+
const glyphs = { chevron: { path: "M9 6 L15 12 L9 18", bounds: [9, 6, 6, 12] }, check: { path: "M4 12 L10 18 L20 6", bounds: [4, 6, 16, 12] }, alert: { path: "M12 3 L22 20 H2 Z M12 10 V14", bounds: [2, 3, 20, 17] }, calendar: { path: "M4 6 H20 V20 H4 Z M4 10 H20 M8 3 V7 M16 3 V7", bounds: [4, 3, 16, 17] } };
|
|
151
|
+
const icon = (parent, kind, color, size = 20) => {
|
|
152
|
+
numeric(size, "icon size");
|
|
153
|
+
if (size === 0)
|
|
154
|
+
throw new Error("Icon size must be positive");
|
|
155
|
+
tok(color);
|
|
156
|
+
const glyph = typeof kind === "string" ? glyphs[kind] : kind;
|
|
157
|
+
if (!glyph || typeof glyph.path !== "string" || !Array.isArray(glyph.bounds) || glyph.bounds.length !== 4 || !glyph.bounds.every(Number.isFinite) || glyph.bounds[2] <= 0 || glyph.bounds[3] <= 0)
|
|
158
|
+
throw new Error("Icon requires known path and positive bounds");
|
|
159
|
+
const board = penpot.createBoard();
|
|
160
|
+
if (parent)
|
|
161
|
+
parent.appendChild(board);
|
|
162
|
+
board.name = "Icon";
|
|
163
|
+
board.fills = [];
|
|
164
|
+
board.clipContent = false;
|
|
165
|
+
board.resize(size, size);
|
|
166
|
+
const path = penpot.createPath();
|
|
167
|
+
board.appendChild(path);
|
|
168
|
+
path.name = "Glyph";
|
|
169
|
+
path.content = glyph.path;
|
|
170
|
+
path.fills = [];
|
|
171
|
+
path.strokes = [{ strokeColor: "#000000", strokeWidth: Math.max(1, 2 * size / 24), strokeCapStyle: "round", strokeJoinStyle: "round" }];
|
|
172
|
+
const [x, y, width, height] = glyph.bounds, scale = size / 24;
|
|
173
|
+
path.proportionLock = true;
|
|
174
|
+
path.resize(width * scale, height * scale);
|
|
175
|
+
path.x = board.x + x * scale;
|
|
176
|
+
path.y = board.y + y * scale;
|
|
177
|
+
bind(path, color, ["strokeColor"]);
|
|
178
|
+
return board;
|
|
179
|
+
};
|
|
180
|
+
const dot = (parent, color, size = 8) => { numeric(size, "dot size"); if (!size)
|
|
181
|
+
throw new Error("Positive dot size required"); tok(color); const shape = penpot.createEllipse(); if (parent)
|
|
182
|
+
parent.appendChild(shape); shape.name = "Dot"; shape.resize(size, size); bind(shape, color, ["fill"]); return shape; };
|
|
183
|
+
const rule = (parent, color) => { tok(color); const shape = penpot.createRectangle(); if (parent)
|
|
184
|
+
parent.appendChild(shape); shape.name = "Rule"; shape.resize(10, 1); if (!shape.layoutChild)
|
|
185
|
+
throw new Error("Rule layout child unavailable"); shape.layoutChild.horizontalSizing = "fill"; bind(shape, color, ["fill"]); return shape; };
|
|
186
|
+
const descendants = root => { const out = [], stack = [root], seen = new Set(); while (stack.length) {
|
|
187
|
+
if (out.length >= 20000)
|
|
188
|
+
throw new Error("Component traversal exceeds budget");
|
|
189
|
+
const shape = stack.pop();
|
|
190
|
+
if (!shape.id || seen.has(shape.id))
|
|
191
|
+
throw new Error("Invalid component identity");
|
|
192
|
+
seen.add(shape.id);
|
|
193
|
+
out.push(shape);
|
|
194
|
+
for (const child of shape.children || [])
|
|
195
|
+
stack.push(child);
|
|
196
|
+
} return out; };
|
|
197
|
+
const inst = (parent, path, name, texts = {}, library = penpot.library.local) => {
|
|
198
|
+
const components = library.components.filter(component => component.path === path && component.name === name);
|
|
199
|
+
if (components.length !== 1)
|
|
200
|
+
throw new Error("Component missing or ambiguous: " + path + "/" + name);
|
|
201
|
+
const entries = Object.entries(texts);
|
|
202
|
+
if (entries.some(([, value]) => typeof value !== "string" || !value))
|
|
203
|
+
throw new Error("Text overrides must be nonempty strings");
|
|
204
|
+
const validate = root => { const rows = descendants(root); return entries.map(([key, value]) => { const matches = rows.filter(shape => shape.type === "text" && shape.name === key); if (matches.length !== 1)
|
|
205
|
+
throw new Error("Override target missing or ambiguous: " + key); return { shape: matches[0], value }; }); };
|
|
206
|
+
if (typeof components[0].mainInstance === "function")
|
|
207
|
+
validate(components[0].mainInstance());
|
|
208
|
+
const shape = components[0].instance();
|
|
209
|
+
const overrides = validate(shape);
|
|
210
|
+
if (parent)
|
|
211
|
+
parent.appendChild(shape);
|
|
212
|
+
for (const override of overrides)
|
|
213
|
+
override.shape.characters = override.value;
|
|
214
|
+
return shape;
|
|
215
|
+
};
|
|
216
|
+
const fill = shape => { if (!shape.layoutChild)
|
|
217
|
+
throw new Error("Layout child unavailable"); shape.layoutChild.horizontalSizing = "fill"; return shape; };
|
|
218
|
+
const fillV = shape => { if (!shape.layoutChild)
|
|
219
|
+
throw new Error("Layout child unavailable"); shape.layoutChild.verticalSizing = "fill"; return shape; };
|
|
220
|
+
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
221
|
+
const settle = async (root = penpot.currentPage.root, options = {}) => {
|
|
222
|
+
const timeout = options.timeoutMs === undefined ? 10000 : options.timeoutMs, interval = options.intervalMs === undefined ? 100 : options.intervalMs, maxNodes = options.maxNodes === undefined ? 10000 : options.maxNodes;
|
|
223
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 30000 || !Number.isInteger(interval) || interval < 1 || interval > 1000 || !Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 10000)
|
|
224
|
+
throw new Error("Invalid settling budgets");
|
|
225
|
+
const started = Date.now(), sample = () => { const rows = [], stack = [root], seen = new Set(); while (stack.length) {
|
|
226
|
+
if (rows.length >= maxNodes)
|
|
227
|
+
throw new Error("Settling node budget exceeded");
|
|
228
|
+
const shape = stack.pop();
|
|
229
|
+
if (!shape || !shape.id || seen.has(shape.id) || ![shape.x, shape.y, shape.width, shape.height].every(Number.isFinite))
|
|
230
|
+
throw new Error("Malformed settling geometry");
|
|
231
|
+
seen.add(shape.id);
|
|
232
|
+
rows.push([shape.id, shape.x, shape.y, shape.width, shape.height]);
|
|
233
|
+
for (const child of shape.children || [])
|
|
234
|
+
stack.push(child);
|
|
235
|
+
} return JSON.stringify(rows); };
|
|
236
|
+
try {
|
|
237
|
+
const target = root && typeof root.waitForLayoutUpdate === "function" ? root : penpot;
|
|
238
|
+
if (typeof target.waitForLayoutUpdate === "function") {
|
|
239
|
+
await target.waitForLayoutUpdate(timeout);
|
|
240
|
+
sample();
|
|
241
|
+
if (Date.now() - started >= timeout)
|
|
242
|
+
return { stable: false, method: "native", samples: 1, reason: "Native layout exceeded deadline" };
|
|
243
|
+
return { stable: true, method: "native", samples: 1 };
|
|
244
|
+
}
|
|
245
|
+
let previous = sample();
|
|
246
|
+
for (let samples = 2; samples <= 1000 && Date.now() - started < timeout; samples++) {
|
|
247
|
+
await wait(Math.min(interval, Math.max(1, timeout - (Date.now() - started))));
|
|
248
|
+
const current = sample();
|
|
249
|
+
if (Date.now() - started >= timeout)
|
|
250
|
+
break;
|
|
251
|
+
if (current === previous)
|
|
252
|
+
return { stable: true, method: "geometry", samples };
|
|
253
|
+
previous = current;
|
|
254
|
+
}
|
|
255
|
+
return { stable: false, method: "geometry", reason: "Layout did not settle within budget" };
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
return { stable: false, method: "unavailable", reason: String(error && error.message || error) };
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const flush = async (options = {}) => {
|
|
262
|
+
if (flushing)
|
|
263
|
+
throw new Error("Concurrent flush is unsupported");
|
|
264
|
+
flushing = true;
|
|
265
|
+
const timeout = options.timeoutMs === undefined ? 10000 : options.timeoutMs, started = Date.now();
|
|
266
|
+
let applied = 0;
|
|
267
|
+
const remaining = () => { const value = timeout - (Date.now() - started); if (value < 1)
|
|
268
|
+
throw new Error("Flush deadline exceeded"); return value; };
|
|
269
|
+
try {
|
|
270
|
+
if (queuedContext !== null && context() !== queuedContext)
|
|
271
|
+
throw new Error("Active token context changed before flush");
|
|
272
|
+
let layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining() });
|
|
273
|
+
if (!layout.stable)
|
|
274
|
+
throw new Error(layout.reason || "Layout did not settle");
|
|
275
|
+
if (queuedContext !== null && context() !== queuedContext)
|
|
276
|
+
throw new Error("Active token context changed during layout");
|
|
277
|
+
while (styles.length) {
|
|
278
|
+
remaining();
|
|
279
|
+
styles[0]();
|
|
280
|
+
styles.shift();
|
|
281
|
+
}
|
|
282
|
+
while (bindings.length) {
|
|
283
|
+
remaining();
|
|
284
|
+
if (context() !== queuedContext)
|
|
285
|
+
throw new Error("Token context changed during binding");
|
|
286
|
+
const binding = bindings[0];
|
|
287
|
+
await binding.shape.applyToken(tok(binding.name), binding.properties);
|
|
288
|
+
bindings.shift();
|
|
289
|
+
applied++;
|
|
290
|
+
}
|
|
291
|
+
layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining() });
|
|
292
|
+
if (!layout.stable)
|
|
293
|
+
throw new Error(layout.reason || "Layout did not settle");
|
|
294
|
+
queuedContext = null;
|
|
295
|
+
return { applied, layout };
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
flushing = false;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const defineRecipe = (name, build, description = "") => { if (!/^[a-z][a-z0-9-]*$/.test(name) || typeof build !== "function" || recipes.has(name))
|
|
302
|
+
throw new Error("Invalid or duplicate recipe: " + name); recipes.set(name, { build, description }); return build; };
|
|
303
|
+
const recipe = (name, parent, props) => { const found = recipes.get(name); if (!found)
|
|
304
|
+
throw new Error("Unknown recipe: " + name); return found.build(parent, props || {}); };
|
|
305
|
+
defineRecipe("hello-card", (parent, props) => { if (!props.box || !props.text)
|
|
306
|
+
throw new Error("hello-card needs explicit box/text token styles"); tok(props.text.typo); tok(props.text.color); const board = box(parent, props.box); text(board, props.label || "Hello, card", props.text); return board; }, "A neutral text-in-board example, with explicit token styles");
|
|
307
|
+
return { tok, bind, box, text, icon, dot, rule, inst, style, fill, fillV, settle, flush, defineRecipe, recipe, recipes: () => [...recipes].map(([name, value]) => ({ name, description: value.description })) };
|
|
308
|
+
})();
|
|
309
|
+
`;
|
package/dist/kit.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface RecipeInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
}
|
|
5
|
+
export interface BuildKit {
|
|
6
|
+
include: string;
|
|
7
|
+
source: string;
|
|
8
|
+
recipes: RecipeInfo[];
|
|
9
|
+
requires?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare const coreKit: BuildKit;
|
|
12
|
+
/** Plain JavaScript to concatenate into a reviewed plugin body; defines the pp namespace. */
|
|
13
|
+
export declare const kit: () => string;
|
|
14
|
+
export declare function checkKits(kits: BuildKit[]): {
|
|
15
|
+
valid: boolean;
|
|
16
|
+
bytes: number;
|
|
17
|
+
sources: {
|
|
18
|
+
include: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
}[];
|
|
22
|
+
problems: string[];
|
|
23
|
+
};
|
|
24
|
+
/** Adapter imports happen only when an explicitly requested alias cannot be supplied by the core. */
|
|
25
|
+
export declare function loadKits(root: string, includes?: string[]): Promise<BuildKit[]>;
|
package/dist/kit.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { coreKitSource } from "./kit-source.js";
|
|
3
|
+
import { compiles, snippet } from "./snippet.js";
|
|
4
|
+
import { loadAdapters } from "./adapters.js";
|
|
5
|
+
import { loadOptionalConfig } from "./instance/config.js";
|
|
6
|
+
export const coreKit = { include: "@kit", source: coreKitSource, recipes: [{ name: "hello-card", description: "A neutral text-in-board example, with explicit token styles" }] };
|
|
7
|
+
/** Plain JavaScript to concatenate into a reviewed plugin body; defines the pp namespace. */
|
|
8
|
+
export const kit = () => coreKitSource;
|
|
9
|
+
export function checkKits(kits) {
|
|
10
|
+
const problems = [], names = new Set(), recipeNames = new Set();
|
|
11
|
+
const sources = kits.map((value, index) => {
|
|
12
|
+
if (!value || typeof value !== "object" || typeof value.include !== "string" || typeof value.source !== "string") {
|
|
13
|
+
problems.push("Malformed kit at index " + index);
|
|
14
|
+
return { include: "invalid", bytes: 0, sha256: "" };
|
|
15
|
+
}
|
|
16
|
+
if (!/^@[a-z][a-z0-9-]*$/.test(value.include) || names.has(value.include))
|
|
17
|
+
problems.push("Invalid or duplicate kit include: " + value.include);
|
|
18
|
+
if (value.requires !== undefined && (!Array.isArray(value.requires) || value.requires.some(name => typeof name !== "string")))
|
|
19
|
+
problems.push("Invalid kit dependencies: " + value.include);
|
|
20
|
+
else
|
|
21
|
+
for (const dependency of value.requires ?? [])
|
|
22
|
+
if (!names.has(dependency))
|
|
23
|
+
problems.push(`${value.include} requires earlier include ${dependency}`);
|
|
24
|
+
names.add(value.include);
|
|
25
|
+
if (!value.source.startsWith("// penpair: read\n"))
|
|
26
|
+
problems.push(`${value.include} must declare read-only top-level inclusion`);
|
|
27
|
+
const bytes = Buffer.byteLength(value.source);
|
|
28
|
+
if (bytes > 32768)
|
|
29
|
+
problems.push(`${value.include} exceeds the 32 KiB source budget`);
|
|
30
|
+
const error = compiles(value.source);
|
|
31
|
+
if (error)
|
|
32
|
+
problems.push(`${value.include}: ${error}`);
|
|
33
|
+
if (!Array.isArray(value.recipes))
|
|
34
|
+
problems.push(`${value.include} has invalid recipe metadata`);
|
|
35
|
+
else
|
|
36
|
+
for (const recipe of value.recipes) {
|
|
37
|
+
if (!recipe || typeof recipe.name !== "string" || !/^[a-z][a-z0-9-]*$/.test(recipe.name) || typeof recipe.description !== "string")
|
|
38
|
+
problems.push(`${value.include} has invalid recipe metadata`);
|
|
39
|
+
else if (recipeNames.has(recipe.name))
|
|
40
|
+
problems.push("Duplicate recipe: " + recipe.name);
|
|
41
|
+
else
|
|
42
|
+
recipeNames.add(recipe.name);
|
|
43
|
+
}
|
|
44
|
+
return { include: value.include, bytes, sha256: createHash("sha256").update(value.source).digest("hex") };
|
|
45
|
+
});
|
|
46
|
+
let bytes = sources.reduce((sum, source) => sum + source.bytes, 0);
|
|
47
|
+
try {
|
|
48
|
+
const combined = snippet("kit-check", kits.filter(value => value && typeof value.source === "string").map(value => value.source).join("\n") + "\nreturn {ok:true};");
|
|
49
|
+
bytes = Buffer.byteLength(combined.code);
|
|
50
|
+
const error = compiles(combined.code);
|
|
51
|
+
if (error)
|
|
52
|
+
problems.push(error);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
problems.push(String(error.message));
|
|
56
|
+
}
|
|
57
|
+
if (bytes > 49152)
|
|
58
|
+
problems.push("Combined kit exceeds the 48 KiB budget reserved before project code");
|
|
59
|
+
return { valid: problems.length === 0, bytes, sources, problems };
|
|
60
|
+
}
|
|
61
|
+
/** Adapter imports happen only when an explicitly requested alias cannot be supplied by the core. */
|
|
62
|
+
export async function loadKits(root, includes = ["@kit"]) {
|
|
63
|
+
const config = await loadOptionalConfig(root);
|
|
64
|
+
if (includes.length > 20 || new Set(includes).size !== includes.length)
|
|
65
|
+
throw new Error("Use at most 20 distinct kit includes");
|
|
66
|
+
const available = new Map([["@kit", coreKit]]);
|
|
67
|
+
if (includes.some(name => name !== "@kit")) {
|
|
68
|
+
const loaded = await loadAdapters(root, config?.adapters);
|
|
69
|
+
for (const adapter of loaded.adapters) {
|
|
70
|
+
if (adapter.kits !== undefined && !Array.isArray(adapter.kits))
|
|
71
|
+
throw new Error("Adapter kits must be an array: " + adapter.name);
|
|
72
|
+
for (const value of adapter.kits ?? []) {
|
|
73
|
+
if (!value || typeof value.include !== "string")
|
|
74
|
+
throw new Error("Malformed adapter kit: " + adapter.name);
|
|
75
|
+
if (available.has(value.include))
|
|
76
|
+
throw new Error("Ambiguous kit alias: " + value.include);
|
|
77
|
+
available.set(value.include, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const name of includes)
|
|
81
|
+
if (!available.has(name))
|
|
82
|
+
throw new Error(`Unknown kit ${name}${loaded.problems.length ? ": " + loaded.problems.join("; ") : "; configure an adapter that supplies it"}`);
|
|
83
|
+
}
|
|
84
|
+
const selected = includes.map(name => { const value = available.get(name); if (!value)
|
|
85
|
+
throw new Error("Unknown kit: " + name); return value; });
|
|
86
|
+
const report = checkKits(selected);
|
|
87
|
+
if (!report.valid)
|
|
88
|
+
throw new Error(report.problems.join("; "));
|
|
89
|
+
return selected;
|
|
90
|
+
}
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PenpotToolClient } from "./client.js";
|
|
2
|
+
export interface LayoutOptions {
|
|
3
|
+
shapeId?: string;
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
intervalMs?: number;
|
|
6
|
+
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface LayoutResult {
|
|
9
|
+
stable: boolean;
|
|
10
|
+
method: "native" | "geometry" | "unavailable";
|
|
11
|
+
samples: number;
|
|
12
|
+
reason?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Native completion is preferred. Older APIs fall back to bounded geometry samples; pixel verification stays separate. */
|
|
15
|
+
export declare function waitForStableLayout(client: PenpotToolClient, options?: LayoutOptions): Promise<LayoutResult>;
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { commandSleep } from "./command-result.js";
|
|
2
|
+
import { PenpairError } from "./errors.js";
|
|
3
|
+
import { runSnippet, snippet } from "./snippet.js";
|
|
4
|
+
const layoutState = `
|
|
5
|
+
const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
|
|
6
|
+
const shape = CFG.shapeId ? penpot.currentPage.getShapeById(CFG.shapeId) : penpot.currentPage.root;
|
|
7
|
+
if (!shape) return { ok: false, message: "layout target does not exist" };
|
|
8
|
+
const target = CFG.shapeId && typeof shape.waitForLayoutUpdate === "function" ? shape : penpot;
|
|
9
|
+
let native = false;
|
|
10
|
+
if (CFG.native && typeof target.waitForLayoutUpdate === "function") {
|
|
11
|
+
try { await target.waitForLayoutUpdate(CFG.timeout); native = true; }
|
|
12
|
+
catch (error) { return { ok: true, fileId, pageId, native: true, error: String(error && error.message || error) }; }
|
|
13
|
+
}
|
|
14
|
+
if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) return { ok: false, message: "layout target changed while waiting" };
|
|
15
|
+
const geometry = [], stack = [shape];
|
|
16
|
+
while (stack.length && geometry.length < 10000) {
|
|
17
|
+
const s = stack.pop(); geometry.push([s.id, s.x, s.y, s.width, s.height]);
|
|
18
|
+
for (const child of s.children || []) stack.push(child);
|
|
19
|
+
}
|
|
20
|
+
return { ok: true, fileId, pageId, native, geometry, truncated: stack.length > 0 };`;
|
|
21
|
+
/** Native completion is preferred. Older APIs fall back to bounded geometry samples; pixel verification stays separate. */
|
|
22
|
+
export async function waitForStableLayout(client, options = {}) {
|
|
23
|
+
const { timeoutMs = 30_000, intervalMs = 200, shapeId, sleep = commandSleep } = options;
|
|
24
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 30_000 || !Number.isFinite(intervalMs) || intervalMs <= 0)
|
|
25
|
+
throw new Error("Layout timeout must be in (0, 30000] ms and the interval must be positive");
|
|
26
|
+
if (Math.ceil(timeoutMs / intervalMs) + 1 > 1000)
|
|
27
|
+
throw new Error("Layout polling must stay within 1000 samples");
|
|
28
|
+
const deadline = Date.now() + timeoutMs;
|
|
29
|
+
const sample = (native) => runSnippet(client, snippet("layout-state", layoutState, { shapeId, native, timeout: Math.max(1, deadline - Date.now()) }, "read"), { retries: 0, timeoutMs: Math.max(1, deadline - Date.now()) });
|
|
30
|
+
const validGeometry = (value) => Array.isArray(value) && value.length > 0 && value.every((row) => Array.isArray(row) && row.length === 5 && typeof row[0] === "string" && !!row[0] && row.slice(1).every((entry) => typeof entry === "number" && Number.isFinite(entry)));
|
|
31
|
+
let first;
|
|
32
|
+
try {
|
|
33
|
+
first = await sample(true);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof PenpairError && error.code === "mcp-unavailable")
|
|
37
|
+
return { stable: false, method: "unavailable", samples: 0, reason: "Layout observation did not complete within its request budget" };
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
if (first.ok !== true || typeof first.fileId !== "string" || !first.fileId || typeof first.pageId !== "string" || !first.pageId)
|
|
41
|
+
throw new Error("Layout returned no target identity evidence");
|
|
42
|
+
if (first.error)
|
|
43
|
+
return { stable: false, method: "native", samples: 1, reason: String(first.error) };
|
|
44
|
+
if (Date.now() > deadline)
|
|
45
|
+
return { stable: false, method: first.native ? "native" : "geometry", samples: 1, reason: "Layout observation exceeded its deadline" };
|
|
46
|
+
if (first.truncated)
|
|
47
|
+
return { stable: false, method: first.native ? "native" : "geometry", samples: 1, reason: "Layout sample exceeded 10000 nodes" };
|
|
48
|
+
if (typeof first.native !== "boolean" || typeof first.truncated !== "boolean" || !validGeometry(first.geometry))
|
|
49
|
+
throw new Error("Layout API returned no geometry evidence");
|
|
50
|
+
if (first.native)
|
|
51
|
+
return { stable: true, method: "native", samples: 1 };
|
|
52
|
+
let previous = JSON.stringify(first.geometry);
|
|
53
|
+
const maxSamples = Math.ceil(timeoutMs / intervalMs) + 1;
|
|
54
|
+
let samples = 1;
|
|
55
|
+
for (let count = 2; count <= maxSamples && Date.now() <= deadline; count++) {
|
|
56
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
57
|
+
let next;
|
|
58
|
+
try {
|
|
59
|
+
next = await sample(false);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error instanceof PenpairError && error.code === "mcp-unavailable")
|
|
63
|
+
return { stable: false, method: "geometry", samples, reason: "Layout did not settle within its request budget" };
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
samples = count;
|
|
67
|
+
if (Date.now() > deadline)
|
|
68
|
+
return { stable: false, method: "geometry", samples, reason: "Layout did not settle before its deadline" };
|
|
69
|
+
if (next.fileId !== first.fileId || next.pageId !== first.pageId)
|
|
70
|
+
throw new Error("Layout target changed between samples");
|
|
71
|
+
if (next.ok !== true || !validGeometry(next.geometry) || next.truncated)
|
|
72
|
+
return { stable: false, method: "geometry", samples: count, reason: "Incomplete layout sample" };
|
|
73
|
+
const current = JSON.stringify(next.geometry);
|
|
74
|
+
if (current === previous)
|
|
75
|
+
return { stable: true, method: "geometry", samples: count };
|
|
76
|
+
previous = current;
|
|
77
|
+
}
|
|
78
|
+
return { stable: false, method: "geometry", samples, reason: "Layout did not settle within its bounded samples" };
|
|
79
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type PenpotToolClient } from "./client.js";
|
|
2
|
+
import { type DeepOptions, type DeepReport } from "./deep-check.js";
|
|
3
|
+
/** A live write probe is never scheduled blindly: the caller explicitly identifies a disposable file and tested version. */
|
|
4
|
+
export declare function runLiveSmoke(client: PenpotToolClient, options: DeepOptions & {
|
|
5
|
+
scratchFileId: string;
|
|
6
|
+
expectedVersion: string;
|
|
7
|
+
confirmedScratch: boolean;
|
|
8
|
+
}): Promise<DeepReport>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { executeCode } from "./client.js";
|
|
2
|
+
import { runDeepCheck } from "./deep-check.js";
|
|
3
|
+
import { guardFile } from "./file-guard.js";
|
|
4
|
+
/** A live write probe is never scheduled blindly: the caller explicitly identifies a disposable file and tested version. */
|
|
5
|
+
export async function runLiveSmoke(client, options) {
|
|
6
|
+
if (!options.confirmedScratch || !options.scratchFileId.trim() || !/^\d+\.\d+\.\d+$/.test(options.expectedVersion))
|
|
7
|
+
throw new Error("Live smoke requires an explicitly confirmed scratch file ID and expected Penpot version");
|
|
8
|
+
const guarded = guardFile(client, { id: options.scratchFileId });
|
|
9
|
+
const { result } = await executeCode(guarded, "return { version: penpot.version, fileId: penpot.currentFile.id };", { effect: "read", retries: 0 });
|
|
10
|
+
const actual = result;
|
|
11
|
+
if (actual?.version !== options.expectedVersion || actual?.fileId !== options.scratchFileId)
|
|
12
|
+
throw new Error("Live smoke target/version differs from the pinned expectation. No probe was applied.");
|
|
13
|
+
return runDeepCheck(guarded, options);
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { type CliOptions } from "./cli-main.js";
|
|
3
|
+
export interface McpOptions {
|
|
4
|
+
root: string;
|
|
5
|
+
allowWrites?: boolean;
|
|
6
|
+
cli?: CliOptions;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
maxOutputBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Long-lived server with an immutable explicit root and no process.chdir or raw-code tool. */
|
|
11
|
+
export declare function createMcpServer(options: McpOptions): Promise<Server>;
|
|
12
|
+
export declare function serveMcp(options: McpOptions): Promise<Server>;
|