@tendrilapp/cli 0.1.16 → 0.1.17

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.
Files changed (2) hide show
  1. package/dist/tendril.js +169 -11
  2. package/package.json +1 -1
package/dist/tendril.js CHANGED
@@ -1974,8 +1974,9 @@ async function runTokenLint(css, fileLabel = "generated.css", definedVars2) {
1974
1974
  codeFilename: fileLabel,
1975
1975
  config: STYLELINT_CONFIG
1976
1976
  });
1977
+ const mapKnownEmpty = definedVars2 !== void 0 && definedVars2.size === 0;
1977
1978
  const violations = result.results.flatMap(
1978
- (r) => r.warnings.map((w) => ({
1979
+ (r) => r.warnings.filter((w) => !(mapKnownEmpty && w.rule === "scale-unlimited/declaration-strict-value")).map((w) => ({
1979
1980
  file: fileLabel,
1980
1981
  line: w.line,
1981
1982
  property: w.rule,
@@ -2392,6 +2393,111 @@ function cornersMatchCanvas(ref, canvas) {
2392
2393
  (c) => Math.abs(c[0] - canvas[0]) + Math.abs(c[1] - canvas[1]) + Math.abs(c[2] - canvas[2]) <= INK_DELTA
2393
2394
  );
2394
2395
  }
2396
+ function absentInkClusters(render, reference, background = [255, 255, 255]) {
2397
+ const a = PNG.sync.read(Buffer.from(render));
2398
+ const b = PNG.sync.read(Buffer.from(reference));
2399
+ const width = Math.max(a.width, b.width);
2400
+ const height = Math.max(a.height, b.height);
2401
+ const canvasA = onCanvas(a, width, height);
2402
+ const canvasB = onCanvas(b, width, height);
2403
+ const ring = ringModal(canvasB);
2404
+ const backgrounds = channelDistance(ring, background) > INK_DELTA ? [background, ring] : [background];
2405
+ const found = [];
2406
+ for (const bg of backgrounds) {
2407
+ const renderInk = new Uint8Array(width * height);
2408
+ for (let y = 0; y < height; y++) {
2409
+ for (let x = 0; x < width; x++) {
2410
+ if (isInk(canvasA.data, (width * y + x) * 4, bg)) renderInk[width * y + x] = 1;
2411
+ }
2412
+ }
2413
+ const candidate = new Uint8Array(width * height);
2414
+ for (let y = 0; y < height; y++) {
2415
+ for (let x = 0; x < width; x++) {
2416
+ if (!isInk(canvasB.data, (width * y + x) * 4, bg)) continue;
2417
+ let covered = false;
2418
+ scan: for (let dy = -ABSENT_RADIUS; dy <= ABSENT_RADIUS; dy++) {
2419
+ for (let dx = -ABSENT_RADIUS; dx <= ABSENT_RADIUS; dx++) {
2420
+ const nx = x + dx;
2421
+ const ny = y + dy;
2422
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
2423
+ if (renderInk[width * ny + nx] === 1) {
2424
+ covered = true;
2425
+ break scan;
2426
+ }
2427
+ }
2428
+ }
2429
+ if (!covered) candidate[width * y + x] = 1;
2430
+ }
2431
+ }
2432
+ const seen = new Uint8Array(width * height);
2433
+ for (let sy = 0; sy < height; sy++) {
2434
+ for (let sx = 0; sx < width; sx++) {
2435
+ const si = width * sy + sx;
2436
+ if (candidate[si] !== 1 || seen[si] === 1) continue;
2437
+ let x0 = sx;
2438
+ let y0 = sy;
2439
+ let x1 = sx;
2440
+ let y1 = sy;
2441
+ let px = 0;
2442
+ const stack = [si];
2443
+ seen[si] = 1;
2444
+ while (stack.length > 0) {
2445
+ const i = stack.pop();
2446
+ const cx = i % width;
2447
+ const cy = (i - cx) / width;
2448
+ px += 1;
2449
+ if (cx < x0) x0 = cx;
2450
+ if (cy < y0) y0 = cy;
2451
+ if (cx > x1) x1 = cx;
2452
+ if (cy > y1) y1 = cy;
2453
+ for (let dy = -1; dy <= 1; dy++) {
2454
+ for (let dx = -1; dx <= 1; dx++) {
2455
+ const nx = cx + dx;
2456
+ const ny = cy + dy;
2457
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
2458
+ const ni = width * ny + nx;
2459
+ if (candidate[ni] === 1 && seen[ni] === 0) {
2460
+ seen[ni] = 1;
2461
+ stack.push(ni);
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+ if (px >= ABSENT_MIN_PX) found.push({ x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, px, memberX: sx, memberY: sy });
2467
+ }
2468
+ }
2469
+ }
2470
+ const merged = [];
2471
+ for (const c of found.sort((c1, c2) => c2.px - c1.px)) {
2472
+ const overlaps = merged.some((m) => c.x < m.x + m.w && m.x < c.x + c.w && c.y < m.y + m.h && m.y < c.y + c.h);
2473
+ if (!overlaps) merged.push(c);
2474
+ }
2475
+ return merged;
2476
+ }
2477
+ function zoomCrop(png, rect, scale = 8, pad = 6) {
2478
+ const src = PNG.sync.read(Buffer.from(png));
2479
+ const x0 = Math.max(0, rect.x - pad);
2480
+ const y0 = Math.max(0, rect.y - pad);
2481
+ const x1 = Math.min(src.width, rect.x + rect.w + pad);
2482
+ const y1 = Math.min(src.height, rect.y + rect.h + pad);
2483
+ const w = Math.max(1, x1 - x0);
2484
+ const h = Math.max(1, y1 - y0);
2485
+ const out = new PNG({ width: w * scale, height: h * scale });
2486
+ for (let y = 0; y < h * scale; y++) {
2487
+ for (let x = 0; x < w * scale; x++) {
2488
+ const sx = x0 + Math.floor(x / scale);
2489
+ const sy = y0 + Math.floor(y / scale);
2490
+ const si = (src.width * sy + sx) * 4;
2491
+ const di = (out.width * y + x) * 4;
2492
+ const alpha = src.data[si + 3] ?? 0;
2493
+ out.data[di] = Math.round(((src.data[si] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2494
+ out.data[di + 1] = Math.round(((src.data[si + 1] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2495
+ out.data[di + 2] = Math.round(((src.data[si + 2] ?? 0) * alpha + 255 * (255 - alpha)) / 255);
2496
+ out.data[di + 3] = 255;
2497
+ }
2498
+ }
2499
+ return Uint8Array.from(PNG.sync.write(out));
2500
+ }
2395
2501
  function measureCanvasCoupling(ref, renderA, renderB, backdrop, box) {
2396
2502
  const rRaw = PNG.sync.read(Buffer.from(ref));
2397
2503
  const r = onCanvas(rRaw, rRaw.width, rRaw.height);
@@ -2606,11 +2712,13 @@ function cropPng(png, x, y, width, height) {
2606
2712
  }
2607
2713
  return new Uint8Array(PNG.sync.write(out));
2608
2714
  }
2609
- var INK_DELTA;
2715
+ var INK_DELTA, ABSENT_RADIUS, ABSENT_MIN_PX;
2610
2716
  var init_image_diff = __esm({
2611
2717
  "packages/verify/src/image-diff.ts"() {
2612
2718
  "use strict";
2613
2719
  INK_DELTA = 30;
2720
+ ABSENT_RADIUS = 3;
2721
+ ABSENT_MIN_PX = 6;
2614
2722
  }
2615
2723
  });
2616
2724
 
@@ -4215,6 +4323,31 @@ function chooseSeat(cropAt, ref, padW, padH, backdrop, origin, extents) {
4215
4323
  }
4216
4324
  return { comparison, seat, scored: cropAt(origin - seat.x, origin - seat.y) };
4217
4325
  }
4326
+ function deepestNodeNameAt(set, rep, px, py) {
4327
+ let root;
4328
+ try {
4329
+ const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
4330
+ root = parseMetadataStructure(text);
4331
+ } catch {
4332
+ return void 0;
4333
+ }
4334
+ let best;
4335
+ const walk2 = (node, ox, oy, isRoot) => {
4336
+ if (node.hidden === true) return;
4337
+ const nx = isRoot ? 0 : ox + (node.x ?? 0);
4338
+ const ny = isRoot ? 0 : oy + (node.y ?? 0);
4339
+ const w = node.width ?? 0;
4340
+ const h = node.height ?? 0;
4341
+ const contains = px >= nx && py >= ny && px < nx + w && py < ny + h;
4342
+ if (contains && !isRoot && node.name !== "") {
4343
+ const area = w * h;
4344
+ if (best === void 0 || area <= best.area) best = { name: `${node.name} (${node.id})`, area };
4345
+ }
4346
+ for (const child of node.children) walk2(child, nx, ny, false);
4347
+ };
4348
+ walk2(root, 0, 0, true);
4349
+ return best?.name;
4350
+ }
4218
4351
  function repMeta(set, rep) {
4219
4352
  const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
4220
4353
  const root = parseMetadataStructure(text);
@@ -4335,10 +4468,18 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
4335
4468
  const slab = realPadBoth && coupling.padCorners.masked >= 8 && coupling.padCorners.opaque / coupling.padCorners.masked >= 0.9;
4336
4469
  const pass = r.similarity >= bar.sim && r.inkRecall >= bar.ink && !slab;
4337
4470
  const region = pass ? void 0 : localizeDifference(scored, ref);
4471
+ const absent = absentInkClusters(scored, ref, backdrop).map((c) => {
4472
+ const name = deepestNodeNameAt(task.set, cfg.rep, c.memberX - seat.x, c.memberY - seat.y);
4473
+ return name === void 0 ? c : { ...c, name };
4474
+ });
4338
4475
  if (opts.evidenceDir !== void 0) {
4339
4476
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-render.png`), scored);
4340
4477
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-ref.png`), ref);
4341
4478
  writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-diff.png`), diffPng(scored, ref));
4479
+ for (const [i, c] of absent.slice(0, 3).entries()) {
4480
+ writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-ref.png`), zoomCrop(ref, c));
4481
+ writeFileSync6(path15.join(opts.evidenceDir, `${cfg.rep}-absent-${i}-render.png`), zoomCrop(scored, c));
4482
+ }
4342
4483
  }
4343
4484
  return {
4344
4485
  rep: cfg.rep,
@@ -4349,6 +4490,7 @@ body{margin:0;background:rgb(${backdrop.join(",")})}
4349
4490
  ...slab ? { error: `paints the page canvas to the pad corners (${coupling.padCorners.opaque}/${coupling.padCorners.masked} opaque) \u2014 a square slab carrying the recorded canvas past the frame, wrong on any real page` } : {},
4350
4491
  ...region === void 0 ? {} : { region: { x0: region.x0, y0: region.y0, x1: region.x1, y1: region.y1, density: Math.round(region.density * 100) / 100 } },
4351
4492
  ...seat.x === 0 && seat.y === 0 ? {} : { seat },
4493
+ ...absent.length > 0 ? { absentInk: absent } : {},
4352
4494
  ...coupling.padMasked + coupling.inBoxMasked > 0 ? { canvasCoupling: coupling } : {}
4353
4495
  };
4354
4496
  } finally {
@@ -8052,6 +8194,7 @@ OVERLAYS GO IN THE TOP LAYER, NOT ON A Z-INDEX. The prelude requires isolation:
8052
8194
 
8053
8195
  PAINT & PLATFORM TRAPS (each measured on a paid run; every one passed typecheck, token lint, and all behaviour checks and was caught only by pixels or by use):
8054
8196
  - A composite custom property resolves on the element that DECLARES it. A composite token (e.g. --ring: 0 0 0 var(--w) var(--c)) may only reference variables declared at or above its own declaration site \u2014 declared higher while its parts are set on the component, it is invalid-at-computed-value-time and the property silently computes to nothing (measured: a hover ring vanished; three configs at ~0.78).
8197
+ - An <svg> inside an absolutely-positioned glyph layer defaults to display: inline \u2014 it seats on a text baseline, its painted content lands BELOW the box the layout allocated, and an overflow: hidden ancestor clips the displaced ink away entirely. Large glyphs survive; small marks (a 2px dot, a 1.5px stem) vanish while every geometry probe reports the boxes correct (measured: 12/16 configs shipped icons missing their inner marks, certified at ink 0.9995). Set display: block on every glyph svg.
8055
8198
  - Chrome pixel-snaps an <svg> root's paint offset. A fractional inset on an svg element floors to a whole pixel and moves a hairline a full pixel; express fractional placement as transform: scale() about an integer-origin frame (measured: 0.943 \u2192 1.000 on six configs).
8056
8199
  - Pre-composite translucent values over the ACTUAL parent surface, read off the reference pixels \u2014 a control sitting on an elevated surface composites against that surface, not against the page.
8057
8200
  - A native <dialog> carries user-agent padding: 1em. Override every side, or the root grows past its recorded box and every band inside shifts.
@@ -8880,9 +9023,15 @@ async function runVerify(opts) {
8880
9023
  const statuses = [
8881
9024
  ...scores.map((s) => {
8882
9025
  const { exact: _exact, ...reported } = s;
8883
- const base = { ...reported, status: tierOf(s, BARS2.cert) };
9026
+ let status = tierOf(s, BARS2.cert);
9027
+ const certDemote = [];
9028
+ if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
9029
+ status = "pass";
9030
+ 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)`));
9031
+ }
9032
+ const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
8884
9033
  const reasons = demotions.get(s.rep);
8885
- return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: reasons };
9034
+ return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote.length > 0 ? certDemote : [], ...reasons] };
8886
9035
  }),
8887
9036
  // ADR-010 §2 anti-gaming: recorded configs the adapter does not map
8888
9037
  // are FAILs, never silently absent.
@@ -9084,12 +9233,12 @@ function resolveEngineTask(opts, callerCwd) {
9084
9233
  const asPath = path32.resolve(callerCwd, opts.taskOrSet);
9085
9234
  const isSet = existsSync26(path32.join(asPath, "recording-set.json"));
9086
9235
  const registry = TASKS[opts.taskOrSet];
9087
- if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet };
9236
+ if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [] };
9088
9237
  if (isSet) {
9089
9238
  try {
9090
9239
  const authored = authorTaskFromSet(asPath);
9091
9240
  for (const d of authored.disclosures) warn(opts, d);
9092
- return { task: authored.task, name: path32.basename(asPath), apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9241
+ return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
9093
9242
  } catch (err) {
9094
9243
  fail(opts, ExitCode.InputValidation, {
9095
9244
  error: `cannot author a task from ${asPath}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -9107,9 +9256,13 @@ function resolveEngineTask(opts, callerCwd) {
9107
9256
  function runEngineBrief(opts) {
9108
9257
  requireEntitlement(opts);
9109
9258
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9110
- const { task, name } = resolveEngineTask(opts, callerCwd);
9259
+ const { task, name, disclosures } = resolveEngineTask(opts, callerCwd);
9111
9260
  const bar = BARS3[opts.bar];
9112
- const brief = buildBrief(task.systemApi, bar, { colorScheme: recordingIsDark(task) ? "dark" : "light" });
9261
+ const disclosureBlock = disclosures.length > 0 ? `
9262
+
9263
+ === RECORDED-SET DISCLOSURES (facts about this task's coverage \u2014 read them) ===
9264
+ ${disclosures.map((d) => `- ${d}`).join("\n")}` : "";
9265
+ const brief = buildBrief(task.systemApi, bar, { colorScheme: recordingIsDark(task) ? "dark" : "light" }) + disclosureBlock;
9113
9266
  const segments = buildSegments(task, "files");
9114
9267
  let notRecorded;
9115
9268
  const manifestPath2 = path32.join(task.set, "recording-set.json");
@@ -9249,9 +9402,14 @@ ${[
9249
9402
  const allPass = obj[0] === total && total > 0;
9250
9403
  const certBar = BARS3["cert"];
9251
9404
  const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
9252
- const certifiedReps = scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep)).map((sc) => sc.rep);
9405
+ const certifiedReps = scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
9253
9406
  const certifiedSet = new Set(certifiedReps);
9254
- const certificationFeedback = `
9407
+ const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass.`));
9408
+ const absentBlock = absentFindings.length > 0 ? `
9409
+
9410
+ MISSING FEATURES (absent-ink clusters \u2014 recorded ink your render leaves nowhere near covered; fix these first, the global numbers cannot see them):
9411
+ ${absentFindings.join("\n")}` : "";
9412
+ const certificationFeedback = `${absentBlock}
9255
9413
 
9256
9414
  CERTIFICATION: ${certifiedReps.length}/${scores.length} configs at the certification bar (sim \u2265${certBar.sim} AND ink \u2265${certBar.ink}, exact values, after parity demotion \u2014 composition checks at verify can demote further).${certifiedReps.length < scores.length ? ` Below cert: ${scores.filter((sc) => !certifiedSet.has(sc.rep)).map((sc) => sc.rep).join(", ")}.` : ""}
9257
9415
  METRIC DEADBAND (read before iterating on near-misses): the scored similarity/ink deliberately tolerate \xB11px edge shift and antialiased-edge differences \u2014 cross-rasterizer noise absorption. A change entirely inside that band moves these numbers by EXACTLY ZERO (working as designed, not a stuck scorer). The per-config \`exact\` fields in the JSON are tolerance-free and move first: compare exact across rounds to confirm a small fix landed, and stop iterating when only exact moves \u2014 the bar reads the tolerant numbers.`;
@@ -9286,7 +9444,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9286
9444
  bundleManifest: emitted.written[0],
9287
9445
  note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
9288
9446
  allPass,
9289
- certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity demotion; verify's composition checks can demote further" }
9447
+ certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity and absent-ink demotion; verify's composition checks can demote further" }
9290
9448
  },
9291
9449
  () => {
9292
9450
  for (const s of scores) process.stdout.write(`${s.pass ? certifiedSet.has(s.rep) ? "CERT" : "PASS" : "FAIL"} ${s.rep}: sim=${s.similarity} ink=${s.inkRecall}${s.error !== void 0 ? ` [${s.error}]` : ""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",