@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 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
@@ -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 an explicit maxDimension of at least ${Math.max(box.w, box.h)} (the tool defaults to 1024 and scales to fit)`;
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) mkdirSync3(opts.evidenceDir, { recursive: true });
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 an explicit maxDimension of AT LEAST the node's longer edge from the get_metadata you just recorded \u2014 the tool DEFAULTS TO 1024 and SCALES the node to fit, so a component wider or taller than that silently becomes a downscaled reference, which is not pixel ground truth (ingest now refuses a reference smaller than its own recorded box). 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.`;
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(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
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"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",