@tendrilapp/cli 0.1.39 → 0.1.40
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/SKILL.md +4 -1
- package/dist/tendril-mcp.js +1 -1
- package/dist/tendril.js +64 -9
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -198,7 +198,10 @@ Batch runs (several components in one session):
|
|
|
198
198
|
real faces is the only path to certification.
|
|
199
199
|
3. Record each planned rep with FOUR tool calls: make the THREE
|
|
200
200
|
Figma MCP calls in protocol order — get_metadata, then
|
|
201
|
-
get_design_context (excludeScreenshot=true), then get_screenshot
|
|
201
|
+
get_design_context (excludeScreenshot=true), then get_screenshot
|
|
202
|
+
(ALWAYS with contentsOnly: true and maxDimension: 4096 — without
|
|
203
|
+
isolation the export bakes in the editor's component-set chrome,
|
|
204
|
+
which scores as ink no implementation can paint) —
|
|
202
205
|
and then ONE `tendril_record_ingest_rep` carrying all three:
|
|
203
206
|
metadata and context response text passed VERBATIM — responses
|
|
204
207
|
often arrive as MULTIPLE output blocks (block counts vary BY
|
package/dist/tendril-mcp.js
CHANGED
|
@@ -112,7 +112,7 @@ var TOOLS = [
|
|
|
112
112
|
},
|
|
113
113
|
{
|
|
114
114
|
name: "tendril_record_ingest_rep",
|
|
115
|
-
description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
|
|
115
|
+
description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot with contentsOnly=true and maxDimension=4096 \u2014 isolation keeps the editor's component-set chrome out of the padded export, where it would otherwise score as unpaintable reference ink), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
|
|
116
116
|
schema: z.object({
|
|
117
117
|
setDir: str("recording set directory"),
|
|
118
118
|
rep: str("planned rep slug"),
|
package/dist/tendril.js
CHANGED
|
@@ -1217,7 +1217,7 @@ function clampedReference(setDir, slug, payload) {
|
|
|
1217
1217
|
const dims = referencePngDims(payload);
|
|
1218
1218
|
if (dims === void 0) return void 0;
|
|
1219
1219
|
if (dims.w >= box.w - 1 && dims.h >= box.h - 1) return void 0;
|
|
1220
|
-
return `the reference image is ${dims.w}\xD7${dims.h} but the recorded box is ${box.w}\xD7${box.h} \u2014 it was SCALED DOWN, so it is not pixel ground truth. Re-run get_screenshot passing
|
|
1220
|
+
return `the reference image is ${dims.w}\xD7${dims.h} but the recorded box is ${box.w}\xD7${box.h} \u2014 it was SCALED DOWN, so it is not pixel ground truth. Re-run get_screenshot passing contentsOnly: true and maxDimension: 4096 (the tool defaults to 1024 and clamps anything longer; a flat generous cap is always at least the ${Math.max(box.w, box.h)} this box needs, and contentsOnly keeps the editor's component-set chrome out of the padding)`;
|
|
1221
1221
|
}
|
|
1222
1222
|
function referencePngDims(payload) {
|
|
1223
1223
|
const parts = payload?.content;
|
|
@@ -3308,6 +3308,7 @@ function absentInkClusters(render, reference, background = [255, 255, 255]) {
|
|
|
3308
3308
|
let x1 = sx;
|
|
3309
3309
|
let y1 = sy;
|
|
3310
3310
|
let px = 0;
|
|
3311
|
+
let chromePx = 0;
|
|
3311
3312
|
const stack = [si];
|
|
3312
3313
|
seen[si] = 1;
|
|
3313
3314
|
while (stack.length > 0) {
|
|
@@ -3315,6 +3316,8 @@ function absentInkClusters(render, reference, background = [255, 255, 255]) {
|
|
|
3315
3316
|
const cx = i % width;
|
|
3316
3317
|
const cy = (i - cx) / width;
|
|
3317
3318
|
px += 1;
|
|
3319
|
+
const ci = i * 4;
|
|
3320
|
+
if (canvasB.data[ci] === FIGMA_CHROME[0] && canvasB.data[ci + 1] === FIGMA_CHROME[1] && canvasB.data[ci + 2] === FIGMA_CHROME[2]) chromePx += 1;
|
|
3318
3321
|
if (cx < x0) x0 = cx;
|
|
3319
3322
|
if (cy < y0) y0 = cy;
|
|
3320
3323
|
if (cx > x1) x1 = cx;
|
|
@@ -3332,7 +3335,7 @@ function absentInkClusters(render, reference, background = [255, 255, 255]) {
|
|
|
3332
3335
|
}
|
|
3333
3336
|
}
|
|
3334
3337
|
}
|
|
3335
|
-
if (px >= ABSENT_MIN_PX) found.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, px, memberX: sx, memberY: sy });
|
|
3338
|
+
if (px >= ABSENT_MIN_PX) found.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, px, memberX: sx, memberY: sy, ...chromePx * 2 >= px ? { chrome: true } : {} });
|
|
3336
3339
|
}
|
|
3337
3340
|
}
|
|
3338
3341
|
}
|
|
@@ -3604,11 +3607,12 @@ function cropPng(png, x, y, width, height) {
|
|
|
3604
3607
|
}
|
|
3605
3608
|
return new Uint8Array(PNG.sync.write(out));
|
|
3606
3609
|
}
|
|
3607
|
-
var INK_DELTA, ABSENT_RADIUS, ABSENT_MIN_PX, DIFF_LEGEND;
|
|
3610
|
+
var INK_DELTA, FIGMA_CHROME, ABSENT_RADIUS, ABSENT_MIN_PX, DIFF_LEGEND, DIFF_LEGEND_TEXT;
|
|
3608
3611
|
var init_image_diff = __esm({
|
|
3609
3612
|
"packages/verify/src/image-diff.ts"() {
|
|
3610
3613
|
"use strict";
|
|
3611
3614
|
INK_DELTA = 30;
|
|
3615
|
+
FIGMA_CHROME = [138, 56, 245];
|
|
3612
3616
|
ABSENT_RADIUS = 3;
|
|
3613
3617
|
ABSENT_MIN_PX = 6;
|
|
3614
3618
|
DIFF_LEGEND = {
|
|
@@ -3623,8 +3627,45 @@ var init_image_diff = __esm({
|
|
|
3623
3627
|
* ALREADY FORGIVEN by inkRecall. Dimmed grey, so it reads as tolerated
|
|
3624
3628
|
* rather than as a defect to chase (measured at only 3.0% of red, but
|
|
3625
3629
|
* chasing it is exactly what the nudge saga was). */
|
|
3626
|
-
shift: [150, 150, 150]
|
|
3630
|
+
shift: [150, 150, 150],
|
|
3631
|
+
/** pixelmatch's OWN anti-aliasing colour, passed through unrepainted.
|
|
3632
|
+
* `includeAA` is false, so these pixels are painted but NOT counted as
|
|
3633
|
+
* mismatches — they are in neither the similarity number nor the ink
|
|
3634
|
+
* deficit. Listed here because the first field run measured 343-380 of
|
|
3635
|
+
* them per config and the reader had a fourth colour with no key
|
|
3636
|
+
* (CompTest run, 2026-08-19). Do not repaint it: passing pixelmatch's
|
|
3637
|
+
* own marking through unchanged is what keeps "not counted" honest. */
|
|
3638
|
+
antialias: [255, 255, 0]
|
|
3627
3639
|
};
|
|
3640
|
+
DIFF_LEGEND_TEXT = [
|
|
3641
|
+
"Tendril diff image legend \u2014 what each colour means.",
|
|
3642
|
+
"",
|
|
3643
|
+
"A verdict has TWO gates and this one image serves both, so the colour",
|
|
3644
|
+
"says WHICH number a pixel belongs to. Marked pixels are exactly the",
|
|
3645
|
+
"ones pixelmatch marks; only their colour carries the cause.",
|
|
3646
|
+
"",
|
|
3647
|
+
` #ff0000 RED reference ink the render never painted, and no render`,
|
|
3648
|
+
` ink within 1px. THE INK DEFICIT \u2014 the only category`,
|
|
3649
|
+
` that moves inkRecall.`,
|
|
3650
|
+
` #ffaa00 AMBER ink in BOTH images, different colour. Moves`,
|
|
3651
|
+
` similarity. CANNOT move inkRecall.`,
|
|
3652
|
+
` #0078ff BLUE render ink where the reference records none. Moves`,
|
|
3653
|
+
` similarity. Cannot move inkRecall.`,
|
|
3654
|
+
` #969696 GREY reference ink covered within 1px \u2014 a displacement`,
|
|
3655
|
+
` inkRecall ALREADY FORGIVES. Not a defect to chase.`,
|
|
3656
|
+
` #ffff00 YELLOW pixelmatch judged this anti-aliasing. Painted but`,
|
|
3657
|
+
` NOT COUNTED: in neither the similarity number nor`,
|
|
3658
|
+
` the ink deficit.`,
|
|
3659
|
+
"",
|
|
3660
|
+
"Raw marked-pixel count is a poor guide to cause: a field run measured a",
|
|
3661
|
+
"config where BLUE dominated 1797 to 270 while only the 270 red pixels",
|
|
3662
|
+
"demoted it.",
|
|
3663
|
+
"",
|
|
3664
|
+
"If red clusters sit OUTSIDE the recorded node box, suspect the",
|
|
3665
|
+
"reference rather than the code \u2014 Figma pads exports to contain outer",
|
|
3666
|
+
"effects, and the pad can capture the editor's own component-set chrome.",
|
|
3667
|
+
""
|
|
3668
|
+
].join("\n");
|
|
3628
3669
|
}
|
|
3629
3670
|
});
|
|
3630
3671
|
|
|
@@ -6909,7 +6950,10 @@ function repEffectExtents(set, rep) {
|
|
|
6909
6950
|
}
|
|
6910
6951
|
}
|
|
6911
6952
|
async function scoreBundleForTask(task, bundleDir, bar = BAR, opts = {}) {
|
|
6912
|
-
if (opts.evidenceDir !== void 0)
|
|
6953
|
+
if (opts.evidenceDir !== void 0) {
|
|
6954
|
+
mkdirSync3(opts.evidenceDir, { recursive: true });
|
|
6955
|
+
writeFileSync6(path20.join(opts.evidenceDir, "diff-legend.txt"), DIFF_LEGEND_TEXT);
|
|
6956
|
+
}
|
|
6913
6957
|
const CONFIGS2 = task.configs;
|
|
6914
6958
|
const timeoutMs = opts.mountDeadlineMs ?? ENGINE_MOUNT_DEADLINE_MS;
|
|
6915
6959
|
const entryTsx = path20.join(bundleDir, task.entry);
|
|
@@ -7012,6 +7056,7 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
|
|
|
7012
7056
|
const pass = r.similarity >= bar.sim && r.inkRecall >= bar.ink && !slab;
|
|
7013
7057
|
const region = pass ? void 0 : localizeDifference(scored, ref);
|
|
7014
7058
|
const absent = absentInkClusters(scored, ref, backdrop).map((c) => {
|
|
7059
|
+
if (c.chrome === true) return c;
|
|
7015
7060
|
const name = deepestNodeNameAt(task.set, cfg.rep, c.memberX - seat.x, c.memberY - seat.y);
|
|
7016
7061
|
return name === void 0 ? c : { ...c, name };
|
|
7017
7062
|
});
|
|
@@ -14371,7 +14416,7 @@ var init_record = __esm({
|
|
|
14371
14416
|
const shown = items.length <= MAX_SPOKEN_VALUES ? items : [...items.slice(0, MAX_SPOKEN_VALUES), `${items.length - MAX_SPOKEN_VALUES} more`];
|
|
14372
14417
|
return shown.length <= 1 ? shown[0] ?? "" : `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
|
|
14373
14418
|
};
|
|
14374
|
-
ENVELOPE_HELP = `Envelope format: text tools save {"content":[{"type":"text","text":"<VERBATIM response text incl. any Currently-selected-nodes block>"}]}; VERBATIM MEANS EVERY PART, NOT JUST THE TEXT ONE: if a response carries additional content blocks \u2014 an image block especially \u2014 keep them in the envelope you save. get_design_context is documented to return a screenshot alongside the code, and whether it actually does is a question our whole recorded corpus cannot answer because every saved envelope holds text only. Saving what arrives settles it at no extra call. get_screenshot: pass
|
|
14419
|
+
ENVELOPE_HELP = `Envelope format: text tools save {"content":[{"type":"text","text":"<VERBATIM response text incl. any Currently-selected-nodes block>"}]}; VERBATIM MEANS EVERY PART, NOT JUST THE TEXT ONE: if a response carries additional content blocks \u2014 an image block especially \u2014 keep them in the envelope you save. get_design_context is documented to return a screenshot alongside the code, and whether it actually does is a question our whole recorded corpus cannot answer because every saved envelope holds text only. Saving what arrives settles it at no extra call. get_screenshot: pass contentsOnly: true AND maxDimension: 4096 \u2014 always, on every call, whatever the node's size. contentsOnly renders the node in isolation; without it the export is what the CANVAS looks like, which for a node inside a component set includes the editor's own component-set chrome (#8a38f5 dashes) baked into the padding. That chrome is reference ink no implementation can paint, so it lands as absent-ink clusters and demotes an otherwise perfect component: measured 2026-08-19, a Modal scored 0/3 certified whose every absent cluster was chrome, and the same render against a contentsOnly reference scored 3/3 (ink 0.9949 -> 0.9996). Isolation does NOT drop the outer effects we deliberately capture \u2014 the padded dimensions come back byte-identical (1276x199 and 1836x175, drop shadow intact). maxDimension is a CAP, not a target: a 56x28 node requested at a high cap comes back 56x28, never upscaled (measured across 24 poses, 2026-08-19). The tool DEFAULTS TO 1024 and clamps anything longer, which silently yields a downscaled reference that is not pixel ground truth, and ingest refuses that. Do NOT compute the cap from the node box: Figma PADS the export to contain outer effects, so the reference is routinely larger than the box it came from \u2014 a recorded 1800px-wide modal exported at 1836px, and a cap sized to the box would have been refused. A flat generous cap removes the arithmetic and the whole failure class. Then do NOT download the image yourself \u2014 pass its image_url to \`${tendrilCommand("record fetch")}\` (MCP: tendril_record_fetch), which pulls the bytes to disk directly. That keeps the pixel ground truth out of your context and costs one approval instead of a shell command per asset. Assets are asset-<first-8-hex-of-figma-uuid>.<ext>; their URLs appear as const declarations inside the design-context text and also expire.`;
|
|
14375
14420
|
isAutoFetchAssetUrl = (url) => isFigmaAssetUrl(url) || isLocalAssetUrl(url);
|
|
14376
14421
|
describeRoleLoss = (loss) => loss.kind === "main" ? `drops main "${loss.main}"` : `stops "${loss.part}" being a part of main "${loss.main}"`;
|
|
14377
14422
|
}
|
|
@@ -14874,7 +14919,17 @@ function foldConfigStatus(s, failDemotions, substitutedFamilies) {
|
|
|
14874
14919
|
if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
|
|
14875
14920
|
status = "pass";
|
|
14876
14921
|
absentInkDemoted = true;
|
|
14877
|
-
certDemote.push(
|
|
14922
|
+
certDemote.push(
|
|
14923
|
+
...s.absentInk.map(
|
|
14924
|
+
(c) => (
|
|
14925
|
+
// A chrome cluster is a defect in the RECORDING, and saying
|
|
14926
|
+
// "missing or invisible feature" points the reader at code that
|
|
14927
|
+
// is not wrong — run 26's operator spent a round hunting a
|
|
14928
|
+
// layout bug that did not exist.
|
|
14929
|
+
c.chrome === true ? `absent ink: ${c.px}px of Figma's component-set CHROME baked into the reference \u2014 this is the editor's own outline, not your component, and no implementation can paint it. RE-RECORD this rep with get_screenshot contentsOnly: true (the outer effect survives isolation; only the chrome goes).` : `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`
|
|
14930
|
+
)
|
|
14931
|
+
)
|
|
14932
|
+
);
|
|
14878
14933
|
}
|
|
14879
14934
|
if (status === "certified" && substitutedFamilies.length > 0) {
|
|
14880
14935
|
status = "pass";
|
|
@@ -15612,7 +15667,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
15612
15667
|
if (certBlockedByAbsentInk.length > 0) {
|
|
15613
15668
|
warn(
|
|
15614
15669
|
opts,
|
|
15615
|
-
`${certBlockedByAbsentInk.length} config(s) demoted by absent-ink clusters (${certBlockedByAbsentInk.join(", ")}) \u2014 certification never ships a missing or invisible feature; each cluster is named in the report above. Fix the component and re-verify, or verify at --bar pass for the disclosed result.`
|
|
15670
|
+
`${certBlockedByAbsentInk.length} config(s) demoted by absent-ink clusters (${certBlockedByAbsentInk.join(", ")}) \u2014 certification never ships a missing or invisible feature; each cluster is named in the report above. Fix the component and re-verify, or verify at --bar pass for the disclosed result. BEFORE changing code, check WHERE the clusters sit: open the -absent-*-ref.png crops in the evidence dir. If the missing ink lies at the extreme EDGES of the reference rather than inside the component, it is probably not yours \u2014 Figma pads an export to contain outer effects (a shadow, a glow), and the pad can capture the editor's own component-set chrome, which is purple #8a38f5 and which no correct implementation can ever paint. Measured 2026-08-19: a shadowed modal was demoted 0/3 with EVERY absent cluster made of chrome, while the same kit's unshadowed button, exported with no pad, certified 24/24 \u2014 and re-recording with contentsOnly: true took the same code to 3/3. That is a recording artifact, not a defect in your component.`
|
|
15616
15671
|
);
|
|
15617
15672
|
process.exitCode = ExitCode.VerificationFailed;
|
|
15618
15673
|
}
|
|
@@ -16449,7 +16504,7 @@ var init_server = __esm({
|
|
|
16449
16504
|
},
|
|
16450
16505
|
{
|
|
16451
16506
|
name: "tendril_record_ingest_rep",
|
|
16452
|
-
description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
|
|
16507
|
+
description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot with contentsOnly=true and maxDimension=4096 \u2014 isolation keeps the editor's component-set chrome out of the padded export, where it would otherwise score as unpaintable reference ink), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
|
|
16453
16508
|
schema: z14.object({
|
|
16454
16509
|
setDir: str("recording set directory"),
|
|
16455
16510
|
rep: str("planned rep slug"),
|