@tendrilapp/cli 0.1.6 → 0.1.8

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.
@@ -158,7 +158,8 @@ var TOOLS = [
158
158
  candidateDir: str("directory containing the proposed bundle files"),
159
159
  bar: optStr("pass (default) or cert"),
160
160
  host: optStr("your host identity (e.g. claude-code, cursor, codex) \u2014 recorded as self-reported provenance"),
161
- model: str("the model that proposed the candidate \u2014 REQUIRED; recorded as self-reported provenance, and the CLI refuses to score without it")
161
+ model: str("the model that proposed the candidate \u2014 REQUIRED; recorded as self-reported provenance, and the CLI refuses to score without it"),
162
+ rebind: z.boolean().optional().describe("explicitly re-bind an already-bound bundle to a DIFFERENT recording set \u2014 scoring refuses this otherwise, because rebinding silently rewrites the bundle's verification identity; only pass after telling the user")
162
163
  }),
163
164
  argv: (i) => [
164
165
  "engine",
@@ -168,7 +169,26 @@ var TOOLS = [
168
169
  ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
169
170
  ...i["host"] !== void 0 ? ["--host", i["host"]] : [],
170
171
  "--model",
171
- i["model"]
172
+ i["model"],
173
+ ...i["rebind"] === true ? ["--rebind"] : []
174
+ ]
175
+ },
176
+ {
177
+ name: "tendril_codeconnect",
178
+ description: "Emit a Figma Code Connect template (.figma.ts) for a certified bundle \u2014 every Figma variant value mapped to its verified prop fragment from recorded truth, stamped with the bundle's trust statement. EXTRA VALUE step after verify passes: offer it to the user. Publishing is the USER'S action (their Figma token, Organization/Enterprise plan) \u2014 via npx @figma/code-connect connect publish, or the Figma MCP's own add_code_connect_map/send_code_connect_mappings tools if available in this session.",
179
+ schema: z.object({
180
+ bundleDir: str("bundle directory (carries component.json)"),
181
+ figmaUrl: str("figma.com /design/ URL of the COMPONENT SET, with node-id (ask the user to Copy link to selection if you don't have it)"),
182
+ set: optStr("recording set override (default: the bundle's provenance path)"),
183
+ out: optStr("output file path (default: <bundle>/<Component>.figma.ts)")
184
+ }),
185
+ argv: (i) => [
186
+ "codeconnect",
187
+ i["bundleDir"],
188
+ "--figma-url",
189
+ i["figmaUrl"],
190
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : [],
191
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
172
192
  ]
173
193
  },
174
194
  {
package/dist/tendril.js CHANGED
@@ -414,26 +414,32 @@ function toNode(tag, attrs) {
414
414
  if (attrs["hidden"] === "true") node.hidden = true;
415
415
  return node;
416
416
  }
417
- function parseMetadataStructure(response) {
417
+ function parseMetadataForest(response) {
418
418
  const stack = [];
419
- let root;
419
+ const roots = [];
420
420
  for (const m of response.matchAll(TAG_RE)) {
421
421
  const [, closing, tag, rawAttrs, selfClosing] = m;
422
422
  if (closing === "/") {
423
423
  const done = stack.pop();
424
- if (done !== void 0 && stack.length === 0 && root === void 0) root = done;
424
+ if (done !== void 0 && stack.length === 0) roots.push(done);
425
425
  continue;
426
426
  }
427
427
  const node = toNode(tag.toLowerCase(), parseAttrs(rawAttrs));
428
428
  const parent = stack[stack.length - 1];
429
429
  if (parent !== void 0) parent.children.push(node);
430
430
  if (selfClosing === "/") {
431
- if (stack.length === 0 && root === void 0) root = node;
431
+ if (stack.length === 0) roots.push(node);
432
432
  continue;
433
433
  }
434
434
  stack.push(node);
435
435
  }
436
- if (root === void 0) root = stack[0];
436
+ const truncated = stack.length > 0;
437
+ if (truncated && stack[0] !== void 0) roots.push(stack[0]);
438
+ return { roots, truncated };
439
+ }
440
+ function parseMetadataStructure(response) {
441
+ const { roots } = parseMetadataForest(response);
442
+ const root = roots[0];
437
443
  if (root === void 0) {
438
444
  throw new Error("get_metadata response contains no parseable structure");
439
445
  }
@@ -878,7 +884,7 @@ function planQueue(symbols, opts = {}) {
878
884
  if (axes === void 0) {
879
885
  reps.push({ slug: slugify(kebab2(sym.name) || sym.nodeId.replace(":", "-")), nodeId: sym.nodeId, ...sym.sourceFrame !== void 0 ? { sourceFrame: sym.sourceFrame } : {}, tier: "singleton" });
880
886
  } else {
881
- reps.push({ slug: slugify(kebab2(Object.values(axes).join("-"))), nodeId: sym.nodeId, ...sym.sourceFrame !== void 0 ? { sourceFrame: sym.sourceFrame } : {}, tier: "anchor", axes });
887
+ reps.push({ slug: slugify(kebab2(Object.values(axes).join("-")) || sym.nodeId.replace(":", "-")), nodeId: sym.nodeId, ...sym.sourceFrame !== void 0 ? { sourceFrame: sym.sourceFrame } : {}, tier: "anchor", axes });
882
888
  }
883
889
  }
884
890
  continue;
@@ -898,11 +904,13 @@ function planQueue(symbols, opts = {}) {
898
904
  reps.push({ slug: slugify("anchor"), nodeId: sym.nodeId, ...frame, tier: "anchor", axes });
899
905
  } else if (diffs.length === 1) {
900
906
  const k = diffs[0];
901
- reps.push({ slug: slugify(kebab2(`${k}-${axes[k]}`)), nodeId: sym.nodeId, ...frame, tier: "one-factor", axes });
907
+ reps.push({ slug: slugify(kebab2(`${k}-${axes[k]}`) || `pose-${sym.nodeId.replace(":", "-")}`), nodeId: sym.nodeId, ...frame, tier: "one-factor", axes });
902
908
  } else if (diffs.length === 2 && diffs.some((k) => crossAxes.includes(k.toLowerCase()))) {
903
- reps.push({ slug: slugify(kebab2(`cross-${diffs.map((k) => axes[k]).join("-")}`)), nodeId: sym.nodeId, ...frame, tier: "cross", axes });
909
+ reps.push({ slug: slugify(kebab2(`cross-${diffs.map((k) => axes[k]).join("-")}`) || `cross-${sym.nodeId.replace(":", "-")}`), nodeId: sym.nodeId, ...frame, tier: "cross", axes });
910
+ } else if (opts.sample !== true) {
911
+ reps.push({ slug: slugify(kebab2(`cross-${diffs.map((k) => `${k}-${axes[k]}`).join("-")}`) || `cross-${sym.nodeId.replace(":", "-")}`), nodeId: sym.nodeId, ...frame, tier: "cross", axes });
904
912
  } else {
905
- notRecorded.push({ nodeId: sym.nodeId, name: sym.name, reason: `${diffs.length}-factor pose outside the queue rule (diffs: ${diffs.join(", ")})` });
913
+ notRecorded.push({ nodeId: sym.nodeId, name: sym.name, reason: `${diffs.length}-factor pose outside the SAMPLED queue rule (diffs: ${diffs.join(", ")}) \u2014 sampling is blind to multi-axis interactions; re-plan without --sample for the full matrix` });
906
914
  }
907
915
  }
908
916
  }
@@ -940,6 +948,27 @@ function planSet(setDir, component, symbols, opts = {}) {
940
948
  const wantsNewDefaults = opts.defaults !== void 0 && JSON.stringify(opts.defaults) !== JSON.stringify(existing.defaults ?? {});
941
949
  const anythingRecorded = existing.reps.some((r) => RECORD_TOOLS.some((t) => existsSync(path.join(setDir, r.slug, `${t}.json`))));
942
950
  if (!wantsNewDefaults || anythingRecorded) {
951
+ if (opts.sample !== true) {
952
+ const known = new Set(existing.reps.map((r) => r.nodeId));
953
+ const fullPlan = planQueue(symbols, { ...opts, sample: false });
954
+ const usedSlugs = new Set(existing.reps.map((r) => r.slug));
955
+ const missing = fullPlan.reps.filter((r) => !known.has(r.nodeId));
956
+ if (missing.length > 0) {
957
+ const appended = missing.map((r) => {
958
+ let slug = r.slug;
959
+ let n = 2;
960
+ while (usedSlugs.has(slug)) slug = `${r.slug}-${n++}`;
961
+ usedSlugs.add(slug);
962
+ return { slug, nodeId: r.nodeId, ...r.sourceFrame !== void 0 ? { sourceFrame: r.sourceFrame } : {} };
963
+ });
964
+ existing.reps.push(...appended);
965
+ existing.planMode = "full";
966
+ delete existing.notRecorded;
967
+ writeFileSync(manifestPath(setDir), `${JSON.stringify(existing, null, 1)}
968
+ `);
969
+ return { manifest: existing, plan: { reps: [], notRecorded: [] }, resumed: true, toppedUp: appended.map((a) => ({ slug: a.slug, nodeId: a.nodeId })) };
970
+ }
971
+ }
943
972
  return { manifest: existing, plan: { reps: [], notRecorded: [] }, resumed: true };
944
973
  }
945
974
  }
@@ -955,6 +984,7 @@ function planSet(setDir, component, symbols, opts = {}) {
955
984
  return variants.length > 0 ? { latticeNames: variants } : {};
956
985
  })(),
957
986
  reps: plan.reps.map((r) => ({ slug: r.slug, nodeId: r.nodeId, ...r.sourceFrame !== void 0 ? { sourceFrame: r.sourceFrame } : {} })),
987
+ planMode: opts.sample === true ? "sample" : "full",
958
988
  ...plan.notRecorded.length > 0 ? { notRecorded: plan.notRecorded.map((n) => `${n.name} (${n.nodeId}): ${n.reason}`).join("; ") } : {}
959
989
  };
960
990
  mkdirSync(setDir, { recursive: true });
@@ -1071,6 +1101,10 @@ var init_session = __esm({
1071
1101
  * domains: the API must cover the lattice even where only a subset
1072
1102
  * is recorded. */
1073
1103
  latticeNames: z4.array(z4.string()).optional(),
1104
+ /** Which planning mode produced this queue. Absent = planned before
1105
+ * the full-matrix default (i.e. sampled) — resume uses this to
1106
+ * top-up rather than silently perpetuating a sampled queue. */
1107
+ planMode: z4.enum(["full", "sample"]).optional(),
1074
1108
  roles: z4.unknown().optional()
1075
1109
  });
1076
1110
  manifestPath = (setDir) => path.join(setDir, "recording-set.json");
@@ -1178,8 +1212,8 @@ var init_src = __esm({
1178
1212
  function variableNameToPath(name) {
1179
1213
  return name.split("/").map(canonicalCssIdentPart).filter((part) => part.length > 0);
1180
1214
  }
1181
- function tokenPathToCssVar(path33) {
1182
- return `--${path33.join("-")}`;
1215
+ function tokenPathToCssVar(path34) {
1216
+ return `--${path34.join("-")}`;
1183
1217
  }
1184
1218
  function toDtcgToken(variable, defaultMode) {
1185
1219
  const modes = Object.keys(variable.valuesByMode);
@@ -1223,11 +1257,11 @@ function toDtcgToken(variable, defaultMode) {
1223
1257
  }
1224
1258
  function mapVariablesToDtcg(variables, defaultMode = "light") {
1225
1259
  const entries = variables.map((variable) => {
1226
- const path33 = variableNameToPath(variable.name);
1227
- if (path33.length === 0) {
1260
+ const path34 = variableNameToPath(variable.name);
1261
+ if (path34.length === 0) {
1228
1262
  throw new DtcgMappingError("empty-name", `Figma variable ${variable.id} has an empty name`);
1229
1263
  }
1230
- return { variable, path: path33 };
1264
+ return { variable, path: path34 };
1231
1265
  });
1232
1266
  const groupPrefixes = /* @__PURE__ */ new Set();
1233
1267
  for (const e of entries) {
@@ -1248,21 +1282,21 @@ function mapVariablesToDtcg(variables, defaultMode = "light") {
1248
1282
  }
1249
1283
  const tokens = {};
1250
1284
  const flat = [];
1251
- for (const { variable, path: path33 } of entries) {
1285
+ for (const { variable, path: path34 } of entries) {
1252
1286
  const token = toDtcgToken(variable, defaultMode);
1253
1287
  let group = tokens;
1254
- for (const segment of path33.slice(0, -1)) {
1288
+ for (const segment of path34.slice(0, -1)) {
1255
1289
  const existing = group[segment];
1256
1290
  group = existing ?? (group[segment] = {});
1257
1291
  }
1258
- const leaf = path33[path33.length - 1];
1292
+ const leaf = path34[path34.length - 1];
1259
1293
  if (group[leaf] !== void 0) {
1260
- throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path33.join(".")}" (variable ${variable.id})`);
1294
+ throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path34.join(".")}" (variable ${variable.id})`);
1261
1295
  }
1262
1296
  group[leaf] = token;
1263
1297
  flat.push({
1264
- path: path33.join("."),
1265
- cssVar: tokenPathToCssVar(path33),
1298
+ path: path34.join("."),
1299
+ cssVar: tokenPathToCssVar(path34),
1266
1300
  type: token.$type,
1267
1301
  value: token.$value
1268
1302
  });
@@ -1451,9 +1485,9 @@ function boundId(value) {
1451
1485
  return isObject(value) && typeof value["boundVariableId"] === "string" ? value["boundVariableId"] : void 0;
1452
1486
  }
1453
1487
  function resolveBinding(ctx, id) {
1454
- const path33 = ctx.pathById.get(id);
1455
- if (path33 === void 0) ctx.unresolved.add(id);
1456
- return path33;
1488
+ const path34 = ctx.pathById.get(id);
1489
+ if (path34 === void 0) ctx.unresolved.add(id);
1490
+ return path34;
1457
1491
  }
1458
1492
  function parseVariantProps(name) {
1459
1493
  if (!name.includes("=")) return void 0;
@@ -1488,8 +1522,8 @@ function walk(ctx, raw) {
1488
1522
  if (!isObject(paint) || paint["visible"] === false) continue;
1489
1523
  const id = boundId(paint);
1490
1524
  if (id !== void 0) {
1491
- const path33 = resolveBinding(ctx, id);
1492
- if (path33 !== void 0) tokens.add(path33);
1525
+ const path34 = resolveBinding(ctx, id);
1526
+ if (path34 !== void 0) tokens.add(path34);
1493
1527
  } else if (typeof paint["color"] === "string") {
1494
1528
  ctx.hardcoded.push({ node: name, property, value: paint["color"] });
1495
1529
  }
@@ -1497,8 +1531,8 @@ function walk(ctx, raw) {
1497
1531
  }
1498
1532
  const radiusId = boundId(raw["cornerRadius"]);
1499
1533
  if (radiusId !== void 0) {
1500
- const path33 = resolveBinding(ctx, radiusId);
1501
- if (path33 !== void 0) tokens.add(path33);
1534
+ const path34 = resolveBinding(ctx, radiusId);
1535
+ if (path34 !== void 0) tokens.add(path34);
1502
1536
  } else if (typeof raw["cornerRadius"] === "number" && raw["cornerRadius"] !== 0) {
1503
1537
  ctx.hardcoded.push({ node: name, property: "border-radius", value: `${raw["cornerRadius"]}px` });
1504
1538
  }
@@ -1508,10 +1542,10 @@ function walk(ctx, raw) {
1508
1542
  layout = { mode: layoutMode === "HORIZONTAL" ? "flex-row" : "flex-column" };
1509
1543
  const gapId = boundId(raw["itemSpacing"]);
1510
1544
  if (gapId !== void 0) {
1511
- const path33 = resolveBinding(ctx, gapId);
1512
- if (path33 !== void 0) {
1513
- layout.gap = path33;
1514
- tokens.add(path33);
1545
+ const path34 = resolveBinding(ctx, gapId);
1546
+ if (path34 !== void 0) {
1547
+ layout.gap = path34;
1548
+ tokens.add(path34);
1515
1549
  }
1516
1550
  } else if (typeof raw["itemSpacing"] === "number" && raw["itemSpacing"] !== 0) {
1517
1551
  ctx.hardcoded.push({ node: name, property: "gap", value: `${raw["itemSpacing"]}px` });
@@ -1520,10 +1554,10 @@ function walk(ctx, raw) {
1520
1554
  for (const field of PADDING_FIELDS) {
1521
1555
  const id = boundId(raw[field]);
1522
1556
  if (id !== void 0) {
1523
- const path33 = resolveBinding(ctx, id);
1524
- if (path33 !== void 0) {
1525
- paddingPaths.push(path33);
1526
- tokens.add(path33);
1557
+ const path34 = resolveBinding(ctx, id);
1558
+ if (path34 !== void 0) {
1559
+ paddingPaths.push(path34);
1560
+ tokens.add(path34);
1527
1561
  }
1528
1562
  } else if (typeof raw[field] === "number" && raw[field] !== 0) {
1529
1563
  ctx.hardcoded.push({ node: name, property: field, value: `${raw[field]}px` });
@@ -1805,6 +1839,7 @@ var init_browser = __esm({
1805
1839
 
1806
1840
  // packages/verify/src/runtime.ts
1807
1841
  import { existsSync as existsSync4, mkdtempSync, symlinkSync } from "node:fs";
1842
+ import { createRequire } from "node:module";
1808
1843
  import os from "node:os";
1809
1844
  import path4 from "node:path";
1810
1845
  import { fileURLToPath } from "node:url";
@@ -1836,6 +1871,21 @@ function newScratchDir(prefix) {
1836
1871
  }
1837
1872
  return dir;
1838
1873
  }
1874
+ function reactPinPlugin() {
1875
+ const req = createRequire(path4.join(runtimePackageRoot(), "package.json"));
1876
+ return {
1877
+ name: "tendril-react-pin",
1878
+ setup(b) {
1879
+ b.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
1880
+ try {
1881
+ return { path: req.resolve(args.path) };
1882
+ } catch {
1883
+ return null;
1884
+ }
1885
+ });
1886
+ }
1887
+ };
1888
+ }
1839
1889
  var init_runtime = __esm({
1840
1890
  "packages/verify/src/runtime.ts"() {
1841
1891
  "use strict";
@@ -3544,6 +3594,7 @@ if (root && C) createRoot(root).render(createElement(C, cfg.props));
3544
3594
  const bundle = await build3({
3545
3595
  stdin: { contents: mountSrc, resolveDir: runtimePackageRoot(), loader: "tsx" },
3546
3596
  nodePaths: [runtimeNodeModules()],
3597
+ plugins: [reactPinPlugin()],
3547
3598
  bundle: true,
3548
3599
  write: false,
3549
3600
  format: "iife",
@@ -4136,6 +4187,7 @@ if (root && C) createRoot(root).render(createElement(C, cfg.props));
4136
4187
  const bundle = await build4({
4137
4188
  stdin: { contents: mountSrc, resolveDir: runtimePackageRoot(), loader: "tsx" },
4138
4189
  nodePaths: [runtimeNodeModules()],
4190
+ plugins: [reactPinPlugin()],
4139
4191
  bundle: true,
4140
4192
  write: false,
4141
4193
  format: "iife",
@@ -4317,7 +4369,7 @@ var init_prelude = __esm({
4317
4369
  - Component root: -webkit-font-smoothing: antialiased and -moz-osx-font-smoothing: grayscale (recorded rasterization); font-synthesis: none (a missing font weight must fall back visibly, never fake-bold); color-scheme MATCHING YOUR RECORDING (light for a light capture, dark for a dark one) and direction: ltr \u2014 pin them, so the render cannot follow the viewer OS preference and drift from the capture it is graded against; isolation: isolate (own stacking context; overlay z-indexes never fight the host).
4318
4370
  - Interactive controls (buttons, options): touch-action: manipulation and user-select: none. Text inputs stay selectable (never user-select: none on them).
4319
4371
  - Scrollable popovers/menus: overscroll-behavior: contain.
4320
- - Focus indicators bind to :focus-visible, never bare :focus. If the recording contains a focus pose, style the indicator from that recorded truth. If NO focus pose is recorded, do NOT invent ring colors/widths/offsets \u2014 an invented ring is unrecorded pixels; keep the browser's default focus indicator (leave outline in place on :focus-visible) and state the gap in your report. Text inputs match :focus-visible even on mouse click BY SPEC \u2014 that is platform-correct accessibility, never a bug to suppress with JS modality tracking.
4372
+ - Focus indicators bind to :focus-visible, never bare :focus. If the recording contains a focus pose, style the indicator from that recorded truth. If NO focus pose is recorded, do NOT invent ring colors/widths/offsets \u2014 an invented ring is unrecorded pixels; keep the browser's default indicator for keyboard focus and state the gap in your report. Text inputs match :focus-visible even on mouse click BY SPEC; the ONE permitted refinement is suppressing the indicator on a POSITIVELY OBSERVED pointer press, fail-safe toward showing it (programmatic focus, restored focus, and assistive tech all count as keyboard and keep the ring \u2014 WCAG 2.4.7 binds keyboard operation). Never suppress more broadly than an observed press, and never restyle what you kept.
4321
4373
  - Every animation wrapped in @media (prefers-reduced-motion: no-preference) or disabled under reduce.`;
4322
4374
  }
4323
4375
  });
@@ -4432,7 +4484,7 @@ var init_parity = __esm({
4432
4484
  });
4433
4485
 
4434
4486
  // packages/verify/src/composition.ts
4435
- import { createRequire } from "node:module";
4487
+ import { createRequire as createRequire2 } from "node:module";
4436
4488
  import { existsSync as existsSync12, readFileSync as readFileSync10 } from "node:fs";
4437
4489
  import path17 from "node:path";
4438
4490
  import { build as build6 } from "esbuild";
@@ -4444,7 +4496,7 @@ function getFontFaces4() {
4444
4496
  async function compileInstrumentedMount(task, bundleDir) {
4445
4497
  const entryTsx = path17.join(bundleDir, task.entry);
4446
4498
  if (!existsSync12(entryTsx)) return { error: `${task.entry} missing` };
4447
- const requireFromVerify = createRequire(path17.join(VERIFY_PKG_DIR, "package.json"));
4499
+ const requireFromVerify = createRequire2(path17.join(VERIFY_PKG_DIR, "package.json"));
4448
4500
  let realJsxPath;
4449
4501
  try {
4450
4502
  realJsxPath = requireFromVerify.resolve("react/jsx-runtime");
@@ -4485,7 +4537,10 @@ if (root && Main) createRoot(root).render(createElement(Main, cfg.props));
4485
4537
  b.onResolve({ filter: new RegExp(`^${REAL_JSX_SPEC}$`) }, () => ({ path: realJsxPath }));
4486
4538
  b.onLoad({ filter: /.*/, namespace: "tendril-shim" }, () => ({ contents: JSX_SHIM, resolveDir: runtimePackageRoot(), loader: "js" }));
4487
4539
  }
4488
- }
4540
+ },
4541
+ // AFTER the stamp shim: the shim must own react/jsx-runtime
4542
+ // interception; the pin covers every remaining react import.
4543
+ reactPinPlugin()
4489
4544
  ]
4490
4545
  });
4491
4546
  return bundle.outputFiles[0]?.text ?? "";
@@ -4741,6 +4796,7 @@ for (const id of ["first", "second"]) {
4741
4796
  const bundle = await build7({
4742
4797
  stdin: { contents: src, resolveDir: runtimePackageRoot(), loader: "tsx" },
4743
4798
  nodePaths: [runtimeNodeModules()],
4799
+ plugins: [reactPinPlugin()],
4744
4800
  bundle: true,
4745
4801
  write: false,
4746
4802
  format: "iife",
@@ -6087,10 +6143,10 @@ function buildTrustStatement(input) {
6087
6143
  const unrecorded = input.latticeConfigs === null ? null : Math.max(0, input.latticeConfigs - input.scored);
6088
6144
  const interaction = input.interactionChecks === 0 ? "interaction behaviors NONE VERIFIED (0 checks)" : `interaction behaviors ${input.interactionPassed}/${input.interactionChecks}`;
6089
6145
  const prelude = input.preludeChecks === 0 ? "" : `, page hygiene ${input.preludePassed}/${input.preludeChecks}`;
6090
- return `Verified against recorded truth: ${input.pass}/${input.scored} recorded configs at or above the pass bar (${input.certified} certified), ${interaction}${prelude}.` + (unrecorded === null ? "" : ` ${unrecorded} lattice configs are unrecorded and UNVERIFIED.`) + ` These numbers are claims: recompute them with \`tendril verify\` \u2014 certification authority lives in the CLI ruler, never in this file.`;
6146
+ return `Verified against recorded truth: ${input.pass}/${input.scored} recorded configs at or above the pass bar (${input.certified} certified), ${interaction}${prelude}.` + (unrecorded === null ? " Coverage denominator UNKNOWN (set predates lattice tracking): completeness is not established." : ` ${unrecorded} lattice configs are unrecorded and UNVERIFIED.`) + ` These numbers are claims: recompute them with \`tendril verify\` \u2014 certification authority lives in the CLI ruler, never in this file.`;
6091
6147
  }
6092
6148
  function cssProvenanceComment(input) {
6093
- const unrecorded = input.latticeConfigs === null ? "" : `; ${Math.max(0, input.latticeConfigs - input.scored)} lattice configs unverified`;
6149
+ const unrecorded = input.latticeConfigs === null ? "; coverage denominator unknown" : `; ${Math.max(0, input.latticeConfigs - input.scored)} lattice configs unverified`;
6094
6150
  return `/* tendril bundle v${BUNDLE_VERSION} \u2014 ${input.pass}/${input.scored} recorded configs \u2265 pass bar, ${input.certified} certified${unrecorded}. Non-authoritative claim AT STAMP TIME \u2014 any edit invalidates it; recompute with \`tendril verify\`. */`;
6095
6151
  }
6096
6152
  function hashRecordingSet(relPaths, readFile, sha256) {
@@ -6243,8 +6299,10 @@ function symbolsFromMetadataEnvelope(file, sourceFrame) {
6243
6299
  const next = node.type !== "COMPONENT" && node.name !== "" ? node.name : ancestor;
6244
6300
  for (const child of node.children) walk2(child, next);
6245
6301
  };
6246
- walk2(parseMetadataStructure(text), void 0);
6247
- return symbols;
6302
+ const forest = parseMetadataForest(text);
6303
+ for (const root of forest.roots) walk2(root, void 0);
6304
+ const seen = /* @__PURE__ */ new Set();
6305
+ return { symbols: symbols.filter((sym) => seen.has(sym.nodeId) ? false : (seen.add(sym.nodeId), true)), truncated: forest.truncated };
6248
6306
  }
6249
6307
  function instanceLeads(text) {
6250
6308
  const seen = /* @__PURE__ */ new Map();
@@ -6269,10 +6327,13 @@ function runRecordPlan(opts) {
6269
6327
  defaults[spec.slice(0, eq)] = spec.slice(eq + 1);
6270
6328
  }
6271
6329
  let symbols = [];
6330
+ let metadataTruncated = false;
6272
6331
  for (const spec of opts.metadataFiles) {
6273
6332
  const [file, frame] = spec.split("@");
6274
6333
  try {
6275
- symbols.push(...symbolsFromMetadataEnvelope(path24.resolve(file), frame));
6334
+ const parsed = symbolsFromMetadataEnvelope(path24.resolve(file), frame);
6335
+ symbols.push(...parsed.symbols);
6336
+ if (parsed.truncated) metadataTruncated = true;
6276
6337
  } catch (err) {
6277
6338
  fail(opts, ExitCode.InputValidation, {
6278
6339
  error: `could not read metadata envelope ${file}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -6337,7 +6398,10 @@ function runRecordPlan(opts) {
6337
6398
  }
6338
6399
  }
6339
6400
  }
6340
- const { manifest, plan, resumed } = planSet(opts.setDir, opts.component, symbols, Object.keys(defaults).length > 0 ? { defaults } : {});
6401
+ if (metadataTruncated) {
6402
+ warn(opts, "get_metadata response appears TRUNCATED (unclosed structure) \u2014 the variant list below may be incomplete. Cross-check variantsFound against the variant count Figma shows for this component set; re-fetch the metadata if lower.");
6403
+ }
6404
+ const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, { ...Object.keys(defaults).length > 0 ? { defaults } : {}, ...opts.sample === true ? { sample: true } : {} });
6341
6405
  const defaultReports = reportAxisDefaults(symbols, Object.keys(defaults).length > 0 ? defaults : void 0);
6342
6406
  const toConfirm = resumed ? [] : defaultReports.filter((r) => (r.rule === "non-interaction" || r.rule === "frequency") && r.domain.length > 1);
6343
6407
  const callLow = manifest.reps.length * 3;
@@ -6347,6 +6411,15 @@ function runRecordPlan(opts) {
6347
6411
  {
6348
6412
  resumed,
6349
6413
  reps: manifest.reps,
6414
+ // The honesty ledger: how many variants the metadata YIELDED vs
6415
+ // how many the queue records. An agent (or user) can hold
6416
+ // variantsFound against the count Figma's UI shows for the set —
6417
+ // the one check that catches parse/transfer losses this pipeline
6418
+ // cannot detect from the envelope alone.
6419
+ variantsFound: symbols.length,
6420
+ planMode: manifest.planMode ?? "sample",
6421
+ ...metadataTruncated ? { metadataTruncated: true } : {},
6422
+ ...toppedUp !== void 0 ? { toppedUp } : {},
6350
6423
  notRecorded: manifest.notRecorded ?? null,
6351
6424
  figmaCallEstimate: { reps: manifest.reps.length, calls: `~${callLow}\u2013${callHigh}` },
6352
6425
  ...toConfirm.length > 0 ? {
@@ -6362,8 +6435,18 @@ function runRecordPlan(opts) {
6362
6435
  },
6363
6436
  () => {
6364
6437
  if (resumed) {
6365
- process.stdout.write(`resumed existing plan (${manifest.reps.length} reps) \u2014 delete recording-set.json to re-plan
6438
+ if (opts.sample === true && (manifest.planMode ?? "sample") === "full") {
6439
+ process.stdout.write("NOTE: --sample has no effect on a resumed full-matrix set \u2014 delete recording-set.json to re-plan sampled (partial coverage is a deliberate choice)\n");
6440
+ }
6441
+ if (toppedUp !== void 0) {
6442
+ process.stdout.write(`resumed and TOPPED UP: ${toppedUp.length} lattice pose(s) this set was missing are now planned (full-matrix default reaches existing sets)
6366
6443
  `);
6444
+ for (const t of toppedUp) process.stdout.write(` added ${t.slug} (${t.nodeId})
6445
+ `);
6446
+ } else {
6447
+ process.stdout.write(`resumed existing plan (${manifest.reps.length} reps, ${manifest.planMode ?? "sample"} mode) \u2014 delete recording-set.json to re-plan
6448
+ `);
6449
+ }
6367
6450
  return;
6368
6451
  }
6369
6452
  for (const r of plan.reps) process.stdout.write(`planned ${r.slug.padEnd(24)} ${r.nodeId} (${r.tier})
@@ -6372,8 +6455,8 @@ function runRecordPlan(opts) {
6372
6455
  `);
6373
6456
  process.stdout.write(`estimated recording cost: ~${callLow}\u2013${callHigh} Figma calls for ${manifest.reps.length} reps
6374
6457
  `);
6375
- for (const q of toConfirm) {
6376
- process.stdout.write(`CONFIRM ${q.axis}: default resolved to "${q.value}" by heuristic (${q.rule}) \u2014 ask the user; change with --default before recording
6458
+ for (const q2 of toConfirm) {
6459
+ process.stdout.write(`CONFIRM ${q2.axis}: default resolved to "${q2.value}" by heuristic (${q2.rule}) \u2014 ask the user; change with --default before recording
6377
6460
  `);
6378
6461
  }
6379
6462
  }
@@ -7141,10 +7224,7 @@ function authorComponentApi(opts) {
7141
7224
  }
7142
7225
  const props = [];
7143
7226
  const forcedStates = [];
7144
- const propNameFor = (axis) => {
7145
- const name = camel(axis);
7146
- return RESERVED_PROPS.has(name.toLowerCase()) ? camel(`${opts.component} ${axis}`) : name;
7147
- };
7227
+ const propNameFor = (axis) => axisPropName(opts.component, axis);
7148
7228
  for (const key of axisKeys) {
7149
7229
  const domain = domains.get(key);
7150
7230
  const def = defaults.get(key);
@@ -7412,6 +7492,7 @@ PAINT & PLATFORM TRAPS (each measured on a paid run; every one passed typecheck,
7412
7492
  - A native <dialog> carries user-agent padding: 1em. Override every side, or the root grows past its recorded box and every band inside shifts.
7413
7493
  - THE PAGE CANVAS IS NOT YOURS. Never paint the recording's page background into the component \u2014 no canvas-coloured plates across the root box, no square shadow spread carrying the canvas past the frame. The mount composites your render over the recorded canvas, so transparency wherever the recording shows canvas is both correct and scores correctly; the score report's canvasCoupling counts canvas pixels your render refuses to let the page repaint, and a component that paints the canvas is wrong on every real page.
7414
7494
  - TOKENS SCOPE TO YOUR ROOT CLASS, NEVER :root. Bundles compose on real pages: token names on :root collide across independently generated components and the last stylesheet loaded silently rewrites the others (measured: two colliding names flipped certified surfaces translucent). Declare every token under the component's root class.
7495
+ - WHEN TWO AXES BOTH CONTROL PAINT, THEY COMPOSE THROUGH CUSTOM PROPERTIES \u2014 one axis SETS variables, the other CONSUMES them. Direct paint rules on both axes have equal specificity, so source order silently drops one axis for exactly the crossed poses (measured: variant \xD7 tone \u2014 primary+critical rendered dark neutral instead of the recorded red; the sampled scorer never sees crossed poses, so nothing catches it but pixels in use).
7415
7496
 
7416
7497
  INTERACTION-READY BY DEFAULT: components are real controls, never static lookalikes \u2014 use the native element matching the archetype (button, input[type=radio|checkbox], select\u2026), real handlers, keyboard operability, and real state (checked/disabled/:hover/:focus-visible). Behavioral checks fail statues. (The forcing-hook rule is specified once, in the prescribed API below.) Your file runs in a bare browser bundle: it must be fully self-contained. IMPORT EVERY React API you use explicitly \u2014 e.g. import { useState, useRef, useEffect } from "react" \u2014 nothing is provided globally; a missing import crashes the mount and every config scores 0.
7417
7498
 
@@ -7426,7 +7507,7 @@ ${PRELUDE_CONTRACT}
7426
7507
  ${opts.colorScheme === void 0 ? "" : `
7427
7508
  RESOLVED FOR THIS RECORDING: it is ${opts.colorScheme}-mode truth, so pin color-scheme: ${opts.colorScheme} on the root. A conditional rule you have to resolve yourself is a rule you will get wrong \u2014 this is the answer, not the question.`}`;
7428
7509
  }
7429
- var PoseCompletenessError, kebab3, camel, pascal, RESERVED_PROPS, isStateAxis, symbolName, STYLE_WEIGHTS2, styleWeight;
7510
+ var PoseCompletenessError, kebab3, camel, pascal, RESERVED_PROPS, axisPropName, isStateAxis, symbolName, STYLE_WEIGHTS2, styleWeight;
7430
7511
  var init_brief = __esm({
7431
7512
  "packages/generate/src/brief.ts"() {
7432
7513
  "use strict";
@@ -7450,6 +7531,10 @@ var init_brief = __esm({
7450
7531
  return c === "" ? c : c[0].toUpperCase() + c.slice(1);
7451
7532
  };
7452
7533
  RESERVED_PROPS = /* @__PURE__ */ new Set(["style", "classname", "children", "key", "ref", "id"]);
7534
+ axisPropName = (component, axis) => {
7535
+ const name = camel(axis);
7536
+ return RESERVED_PROPS.has(name.toLowerCase()) ? camel(`${component} ${axis}`) : name;
7537
+ };
7453
7538
  isStateAxis = (axis) => kebab3(axis) === "state";
7454
7539
  symbolName = (metaText) => {
7455
7540
  const raw = /name="([^"]*)"/.exec(metaText)?.[1];
@@ -8063,7 +8148,18 @@ function taskFromManifest(opts, manifest, setDir) {
8063
8148
  const unmapped = recordedSlugs.filter((s) => !adapterSlugs.includes(s));
8064
8149
  const adapterOnly = adapterSlugs.filter((s) => !recordedSlugs.includes(s));
8065
8150
  const registry = Object.values(TASKS).find((t) => path30.resolve(t.set) === path30.resolve(setDir));
8066
- const authored = registry === void 0 ? authorTaskFromSet(setDir) : void 0;
8151
+ const authored = (() => {
8152
+ if (registry !== void 0) return void 0;
8153
+ try {
8154
+ return authorTaskFromSet(setDir);
8155
+ } catch (err) {
8156
+ fail(opts, ExitCode.InputValidation, {
8157
+ error: `cannot author the verification task from ${setDir}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
8158
+ code: "AUTHORING_FAILED",
8159
+ remediation: "Fix the recording set (duplicate/colliding poses fail authoring by design \u2014 check `tendril record status` and the set's variant names)."
8160
+ });
8161
+ }
8162
+ })();
8067
8163
  const behaviorSource = registry ?? authored.task;
8068
8164
  const task = {
8069
8165
  set: setDir,
@@ -8245,6 +8341,23 @@ async function runVerify(opts) {
8245
8341
  scoredConfigs: statuses.length,
8246
8342
  certified,
8247
8343
  pass: statuses.filter((s) => s.status !== "fail").length,
8344
+ // LATTICE HONESTY (adversarial review, 2026-08-10): "verified"
8345
+ // must never quietly mean "the recorded subset matched". The
8346
+ // denominator is the set's own lattice; unrecorded poses are
8347
+ // named in the report and the human output, on the trust anchor
8348
+ // itself — not only in a long-gone plan output.
8349
+ ...(() => {
8350
+ try {
8351
+ const setManifest = loadManifest(task.set);
8352
+ const lattice = setManifest.latticeNames?.length;
8353
+ return {
8354
+ ...lattice !== void 0 ? { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - statuses.length) } : { latticeConfigs: null },
8355
+ ...setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {}
8356
+ };
8357
+ } catch {
8358
+ return { latticeConfigs: null };
8359
+ }
8360
+ })(),
8248
8361
  // Prelude checks are page-level style hygiene and say nothing
8249
8362
  // about whether the component WORKS. Reporting one merged
8250
8363
  // "behaviors 6/6" made a component with zero interaction coverage
@@ -8349,6 +8462,18 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
8349
8462
  `
8350
8463
  );
8351
8464
  }
8465
+ {
8466
+ const cov = report.coverage;
8467
+ if (typeof cov.latticeConfigs === "number" && (cov.unrecordedConfigs ?? 0) > 0) {
8468
+ process.stdout.write(
8469
+ `INCOMPLETE ${cov.unrecordedConfigs} of ${cov.latticeConfigs} lattice poses were NEVER RECORDED \u2014 nothing verifies them; any implementation of those poses is inference, not verified truth. Re-plan the set (full matrix is the default) and record the missing poses.
8470
+ `
8471
+ );
8472
+ } else if (cov.latticeConfigs === null) {
8473
+ process.stdout.write(`COVERAGE denominator unknown (set predates lattice tracking) \u2014 scored configs are verified; completeness is not established
8474
+ `);
8475
+ }
8476
+ }
8352
8477
  process.stdout.write(`environment: chrome=${report.environment.chromeVersion ?? report.environment.chrome} fonts=${report.environment.fontsManifestSha256 ?? "UNRESOLVED"}
8353
8478
  `);
8354
8479
  process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
@@ -8427,6 +8552,7 @@ function runEngineBrief(opts) {
8427
8552
  const unverified = notRecorded !== void 0 && notRecorded !== "" ? `
8428
8553
 
8429
8554
  === UNVERIFIED SURFACE (recorded-set disclosure \u2014 these poses were never recorded; nothing verifies them) ===
8555
+ DO NOT INVENT PIXELS FOR THESE POSES. Implement them ONLY as the composition of recorded per-axis truth (the custom-property composition rule: one paint axis sets variables, the other consumes) and list every such pose in your report as UNRECORDED-COMPOSED. Recording them is the real fix: re-plan the set (full matrix is the default) and record the missing poses.
8430
8556
  ${notRecorded}` : "";
8431
8557
  let fontProvisioning;
8432
8558
  if (existsSync25(manifestPath2)) {
@@ -8512,6 +8638,30 @@ async function runEngineScore(opts) {
8512
8638
  remediation: fontsUnprovenRemediation(task.set)
8513
8639
  });
8514
8640
  }
8641
+ if (opts.rebind !== true && existsSync25(path31.join(candidateDir, "component.json"))) {
8642
+ const prior = (() => {
8643
+ try {
8644
+ const read = readBundleManifest(readFileSync22(path31.join(candidateDir, "component.json"), "utf8"));
8645
+ return read.manifest === void 0 ? { unreadable: true } : { hash: read.manifest.provenance.recordingSet.hash, path: read.manifest.provenance.recordingSet.path };
8646
+ } catch {
8647
+ return { unreadable: true };
8648
+ }
8649
+ })();
8650
+ if (prior.unreadable === true) {
8651
+ fail(opts, ExitCode.InputValidation, {
8652
+ error: "this bundle carries a component.json whose recording-set binding cannot be read \u2014 refusing to overwrite an unverifiable identity",
8653
+ code: "set-binding-unreadable",
8654
+ remediation: "Fix or remove the bundle's component.json, or pass --rebind to overwrite it deliberately."
8655
+ });
8656
+ }
8657
+ if (prior.hash !== recordingSetHash(task.set, task.configs)) {
8658
+ fail(opts, ExitCode.InputValidation, {
8659
+ error: `this bundle is bound to recording set "${prior.path}" (content ${prior.hash.slice(0, 12)}\u2026), which does not match "${opts.taskOrSet}" as it exists now \u2014 scoring would rewrite the bundle's verification identity and its evidence images`,
8660
+ code: "set-rebind-refused",
8661
+ remediation: "If this is the SAME set and it simply gained recordings since the last score, pass --rebind once to re-stamp. If it is a DIFFERENT set, score against the bundle's own set \u2014 rebinding voids its previous verification claims."
8662
+ });
8663
+ }
8664
+ }
8515
8665
  const bar = BARS3[opts.bar];
8516
8666
  const evidenceDir = path31.join(candidateDir, "verify-evidence");
8517
8667
  const scores = await scoreBundleForTask(task, candidateDir, bar, { evidenceDir, onProgress: emitProgress });
@@ -8584,6 +8734,7 @@ var init_engine2 = __esm({
8584
8734
  "use strict";
8585
8735
  init_src3();
8586
8736
  init_src7();
8737
+ init_src6();
8587
8738
  init_environment();
8588
8739
  init_font_guidance();
8589
8740
  init_src4();
@@ -8596,6 +8747,182 @@ var init_engine2 = __esm({
8596
8747
  }
8597
8748
  });
8598
8749
 
8750
+ // packages/cli/src/commands/codeconnect.ts
8751
+ var codeconnect_exports = {};
8752
+ __export(codeconnect_exports, {
8753
+ runCodeConnect: () => runCodeConnect
8754
+ });
8755
+ import { existsSync as existsSync26, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
8756
+ import path32 from "node:path";
8757
+ function runCodeConnect(opts) {
8758
+ const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
8759
+ const bundleDir = path32.resolve(callerCwd, opts.bundleDir);
8760
+ let url;
8761
+ try {
8762
+ url = new URL(opts.figmaUrl);
8763
+ } catch {
8764
+ url = new URL("invalid://x");
8765
+ }
8766
+ if (/[\u0000-\u001f\u2028\u2029]/.test(opts.figmaUrl) || !(url.hostname === "figma.com" || url.hostname.endsWith(".figma.com")) || !/\/design\//.test(url.pathname) || url.searchParams.get("node-id") === null) {
8767
+ fail(opts, ExitCode.InputValidation, {
8768
+ error: `--figma-url must be a clean figma.com /design/ URL carrying the COMPONENT SET's node-id, got: ${JSON.stringify(opts.figmaUrl)}`,
8769
+ code: "codeconnect-bad-url",
8770
+ remediation: "In Figma, select the component set and copy its link (Copy link to selection)."
8771
+ });
8772
+ }
8773
+ let manifest;
8774
+ try {
8775
+ const read = readBundleManifest(readFileSync23(path32.join(bundleDir, "component.json"), "utf8"));
8776
+ if (read.manifest === void 0) throw new Error(read.issues.map((i) => i.message).join("; ") || "no component.json");
8777
+ manifest = read.manifest;
8778
+ } catch (err) {
8779
+ fail(opts, ExitCode.InputValidation, {
8780
+ error: `not a Tendril bundle (component.json unreadable): ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
8781
+ code: "codeconnect-no-bundle",
8782
+ remediation: "Point at a bundle produced by the Tendril pipeline (it carries component.json), and verify it first."
8783
+ });
8784
+ }
8785
+ const setDir = path32.resolve(callerCwd, opts.set ?? manifest.provenance.recordingSet.path);
8786
+ if (!existsSync26(path32.join(setDir, "recording-set.json"))) {
8787
+ fail(opts, ExitCode.InputValidation, {
8788
+ error: `recording set not found at ${setDir}`,
8789
+ code: "codeconnect-no-set",
8790
+ remediation: "Pass --set <recording-dir> (the bundle's provenance path did not resolve from this directory)."
8791
+ });
8792
+ }
8793
+ let authored;
8794
+ try {
8795
+ authored = authorTaskFromSet(setDir);
8796
+ } catch (err) {
8797
+ fail(opts, ExitCode.InputValidation, {
8798
+ error: `cannot author the API from ${setDir}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
8799
+ code: "codeconnect-authoring-failed",
8800
+ remediation: "The set must be a complete protocol recording (tendril record status)."
8801
+ });
8802
+ }
8803
+ const api = authored.api;
8804
+ const component = api.component;
8805
+ const recManifest = loadManifest(setDir);
8806
+ const poseNames = recManifest.latticeNames ?? recManifest.reps.map((r) => {
8807
+ const meta = path32.join(setDir, r.slug, "get_metadata.json");
8808
+ if (!existsSync26(meta)) return void 0;
8809
+ try {
8810
+ return /name="([^"]*)"/.exec(envelopeFirstTextPart(JSON.parse(readFileSync23(meta, "utf8"))))?.[1];
8811
+ } catch {
8812
+ return void 0;
8813
+ }
8814
+ }).filter((n) => n !== void 0);
8815
+ const axisDomains = mergeVariantAxes(poseNames) ?? {};
8816
+ if (Object.keys(axisDomains).length === 0 && api.props.length > 0) {
8817
+ fail(opts, ExitCode.InputValidation, {
8818
+ error: "no variant axes recoverable from the recording manifest (no latticeNames)",
8819
+ code: "codeconnect-no-axes",
8820
+ remediation: "Re-plan the set with a current Tendril (lattice names persist in the manifest), or record the set fresh."
8821
+ });
8822
+ }
8823
+ if (recordingSetHash(setDir, authored.task.configs) !== manifest.provenance.recordingSet.hash) {
8824
+ fail(opts, ExitCode.InputValidation, {
8825
+ error: "the recording set's content no longer matches the hash this bundle was scored against \u2014 its verification claims describe a different recording",
8826
+ code: "codeconnect-set-drift",
8827
+ remediation: "Re-run `tendril verify` (or engine score) against the current set, then re-emit."
8828
+ });
8829
+ }
8830
+ const boolProps = api.props.filter((p) => p.kind === "boolean");
8831
+ const pixelOnly = [];
8832
+ const axisLines = [];
8833
+ const fragmentVars = [];
8834
+ for (const [axis, values] of Object.entries(axisDomains)) {
8835
+ const varName = `frag${axisLines.length}`;
8836
+ const entries = [];
8837
+ const owner = api.props.find((p) => p.name === axisPropName(component, axis));
8838
+ const axisDefault = resolveAxisDefault(values, values, recManifest.defaults?.[axis]);
8839
+ for (const value of values) {
8840
+ const kv = kebab4(value);
8841
+ let fragment = null;
8842
+ if (owner?.kind === "union" && (owner.values ?? []).includes(kv)) {
8843
+ fragment = owner.default === kv ? "" : ` ${owner.name}="${kv}"`;
8844
+ } else if (owner?.kind === "boolean") {
8845
+ fragment = ["true", "on", "yes"].includes(kv) ? ` ${owner.name}` : "";
8846
+ } else if (api.forcedStates.includes(kv)) {
8847
+ fragment = ` data-tendril-state="${kv}"`;
8848
+ } else if (boolProps.some((p) => p.name === kv || kebab4(p.name) === kv)) {
8849
+ fragment = ` ${boolProps.find((p) => p.name === kv || kebab4(p.name) === kv).name}`;
8850
+ } else if (value === axisDefault) {
8851
+ fragment = "";
8852
+ } else if (api.unmappedInteractionEvidence.some((e) => kebab4(e) === kv || kebab4(e) === kebab4(`${axis} ${value}`))) {
8853
+ pixelOnly.push(`${axis}=${value}`);
8854
+ fragment = "";
8855
+ }
8856
+ if (fragment === null) {
8857
+ fail(opts, ExitCode.InputValidation, {
8858
+ error: `axis "${axis}" value "${value}" maps to nothing in the authored API \u2014 an unmapped value silently breaks the Dev Mode snippet`,
8859
+ code: "codeconnect-unmapped-value",
8860
+ remediation: "Record the missing pose (the full matrix is the default plan) so the authored API covers the full lattice, then re-emit."
8861
+ });
8862
+ }
8863
+ entries.push(`${q(value)}: ${q(fragment)}`);
8864
+ }
8865
+ axisLines.push(`const ${varName} = instance.getEnum(${q(axis)}, { ${entries.join(", ")} })`);
8866
+ fragmentVars.push(varName);
8867
+ }
8868
+ const entryRel = path32.relative(callerCwd, path32.join(bundleDir, manifest.entry));
8869
+ const trust = manifest.trustStatement.split("\n")[0] ?? "";
8870
+ const lines = [
8871
+ `// url=${opts.figmaUrl}`,
8872
+ `// source=${entryRel}`,
8873
+ `// component=${component}`,
8874
+ "// Generated by Tendril. Claims below are the bundle's self-reported verification \u2014 recompute them free and offline with: tendril verify",
8875
+ `// ${trust}`,
8876
+ ...pixelOnly.length > 0 ? [`// PIXEL-VERIFIED ONLY (no operable mapping): ${pixelOnly.join(", ")}`] : [],
8877
+ `import figma from 'figma'`,
8878
+ ``,
8879
+ `const instance = figma.selectedInstance`,
8880
+ ...axisLines,
8881
+ ``,
8882
+ `export default {`,
8883
+ ` example: figma.code\`<${component}${fragmentVars.map((v) => `\${${v}}`).join("")} />\`,`,
8884
+ ` imports: [${q(`import { ${component} } from "./${manifest.entry.replace(/\.tsx?$/, "")}" /* adjust to your project import path */`)}],`,
8885
+ ` metadata: { nestable: true },`,
8886
+ `}`,
8887
+ ``
8888
+ ].join("\n");
8889
+ const outFile = path32.resolve(callerCwd, opts.out ?? path32.join(bundleDir, `${component}.figma.ts`));
8890
+ writeFileSync12(outFile, lines);
8891
+ emitData(
8892
+ opts,
8893
+ {
8894
+ file: outFile,
8895
+ component,
8896
+ axes: Object.keys(axisDomains),
8897
+ ...pixelOnly.length > 0 ? { pixelOnly } : {},
8898
+ publish: {
8899
+ cli: `npx @figma/code-connect connect publish --file ${outFile} (requires a Figma Organization/Enterprise plan and a token with Code Connect Write scope)`,
8900
+ note: "Publishing is YOUR action with YOUR Figma access \u2014 Tendril never publishes to Figma."
8901
+ }
8902
+ },
8903
+ () => {
8904
+ process.stdout.write(`code connect template: ${outFile}
8905
+ `);
8906
+ if (pixelOnly.length > 0) warn(opts, `pixel-only poses mapped to the base pose in snippets: ${pixelOnly.join(", ")}`);
8907
+ process.stdout.write(`publish (your Figma token, Org/Enterprise plan): npx @figma/code-connect connect publish
8908
+ `);
8909
+ }
8910
+ );
8911
+ }
8912
+ var kebab4, q;
8913
+ var init_codeconnect = __esm({
8914
+ "packages/cli/src/commands/codeconnect.ts"() {
8915
+ "use strict";
8916
+ init_src3();
8917
+ init_src7();
8918
+ init_src();
8919
+ init_src6();
8920
+ init_output();
8921
+ kebab4 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
8922
+ q = (s) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}'`;
8923
+ }
8924
+ });
8925
+
8599
8926
  // packages/cli/src/commands/generate-route.ts
8600
8927
  var generate_route_exports = {};
8601
8928
  __export(generate_route_exports, {
@@ -8620,17 +8947,17 @@ __export(generate_recorded_exports, {
8620
8947
  runGenerateRecorded: () => runGenerateRecorded
8621
8948
  });
8622
8949
  import { confirm as confirm3, isCancel as isCancel3 } from "@clack/prompts";
8623
- import { existsSync as existsSync26 } from "node:fs";
8624
- import path32 from "node:path";
8950
+ import { existsSync as existsSync27, readFileSync as readFileSync24 } from "node:fs";
8951
+ import path33 from "node:path";
8625
8952
  async function runGenerateRecorded(opts) {
8626
8953
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
8627
- const outDirAbs = path32.resolve(callerCwd, opts.out);
8628
- const recordedAsPath = path32.resolve(callerCwd, opts.recorded);
8954
+ const outDirAbs = path33.resolve(callerCwd, opts.out);
8955
+ const recordedAsPath = path33.resolve(callerCwd, opts.recorded);
8629
8956
  let task;
8630
8957
  let taskName;
8631
8958
  let authoredApi;
8632
8959
  let composition;
8633
- const isSet = existsSync26(path32.join(recordedAsPath, "recording-set.json"));
8960
+ const isSet = existsSync27(path33.join(recordedAsPath, "recording-set.json"));
8634
8961
  const registry = TASKS[opts.recorded];
8635
8962
  if (registry !== void 0 && !isSet) {
8636
8963
  task = registry;
@@ -8639,7 +8966,7 @@ async function runGenerateRecorded(opts) {
8639
8966
  try {
8640
8967
  const authored = authorTaskFromSet(recordedAsPath);
8641
8968
  task = authored.task;
8642
- taskName = path32.basename(recordedAsPath);
8969
+ taskName = path33.basename(recordedAsPath);
8643
8970
  authoredApi = authored.api;
8644
8971
  const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
8645
8972
  if (roles.success) composition = roles.data;
@@ -8666,7 +8993,7 @@ async function runGenerateRecorded(opts) {
8666
8993
  });
8667
8994
  }
8668
8995
  const missing = task.configs.filter(
8669
- (c) => !existsSync26(path32.join(task.set, c.rep, "get_screenshot.json")) || !existsSync26(path32.join(task.set, c.rep, "get_metadata.json")) || !existsSync26(path32.join(task.set, c.rep, "get_design_context.json"))
8996
+ (c) => !existsSync27(path33.join(task.set, c.rep, "get_screenshot.json")) || !existsSync27(path33.join(task.set, c.rep, "get_metadata.json")) || !existsSync27(path33.join(task.set, c.rep, "get_design_context.json"))
8670
8997
  );
8671
8998
  if (missing.length > 0) {
8672
8999
  fail(opts, ExitCode.RecordingIncomplete, {
@@ -8736,8 +9063,8 @@ async function runGenerateRecorded(opts) {
8736
9063
  ` : `${line}
8737
9064
  `);
8738
9065
  if (opts.dryRun) {
8739
- emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path32.join(outDirAbs, taskName) }, () => {
8740
- process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path32.join(outDirAbs, taskName)})
9066
+ emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path33.join(outDirAbs, taskName) }, () => {
9067
+ process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path33.join(outDirAbs, taskName)})
8741
9068
  `);
8742
9069
  });
8743
9070
  return;
@@ -8760,7 +9087,21 @@ async function runGenerateRecorded(opts) {
8760
9087
  });
8761
9088
  }
8762
9089
  }
8763
- const bundleDir = path32.join(outDirAbs, taskName);
9090
+ const bundleDir = path33.join(outDirAbs, taskName);
9091
+ if (existsSync27(path33.join(bundleDir, "component.json"))) {
9092
+ try {
9093
+ const prior = readBundleManifest(readFileSync24(path33.join(bundleDir, "component.json"), "utf8")).manifest;
9094
+ if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
9095
+ fail(opts, ExitCode.InputValidation, {
9096
+ error: `${bundleDir} already holds a bundle bound to recording set "${prior.provenance.recordingSet.path}" \u2014 generating here against a different set would silently rewrite its verification identity`,
9097
+ code: "set-rebind-refused",
9098
+ remediation: "Point --out at a fresh directory, or delete the stale bundle deliberately."
9099
+ });
9100
+ }
9101
+ } catch (err) {
9102
+ if (err.code === void 0) throw err;
9103
+ }
9104
+ }
8764
9105
  const result = await runEngineLoop({
8765
9106
  engine,
8766
9107
  segments,
@@ -9858,7 +10199,7 @@ function buildProgram() {
9858
10199
  await runDoctor({ ...flags, mcpUrl: local["mcpUrl"] });
9859
10200
  });
9860
10201
  const record = program.command("record").description("Agent-driven recording protocol: plan the queue, get instructions, ingest verbatim envelopes, resume from disk.");
9861
- record.command("plan").requiredOption("--set <dir>", "recording set directory").requiredOption("--component <name>", "component/system name").requiredOption("--metadata <file...>", "verbatim get_metadata envelope(s), optionally <file>@<frameId>").option("--default <axis=value...>", "explicit axis default, e.g. --default State=Rest (repeatable; persisted to the manifest; may re-plan an unrecorded set)").option("--component-set <name>", "when the metadata holds several component sets, record only this one (name = the set label in the plan error)").action(async (_o, cmd) => {
10202
+ record.command("plan").requiredOption("--set <dir>", "recording set directory").requiredOption("--component <name>", "component/system name").requiredOption("--metadata <file...>", "verbatim get_metadata envelope(s), optionally <file>@<frameId>").option("--default <axis=value...>", "explicit axis default, e.g. --default State=Rest (repeatable; persisted to the manifest; may re-plan an unrecorded set)").option("--component-set <name>", "when the metadata holds several component sets, record only this one (name = the set label in the plan error)").option("--sample", "cost sampling: anchor + one-factor + conflict crosses only (the FULL variant matrix is the default; sampling is blind to multi-axis interactions)").action(async (_o, cmd) => {
9862
10203
  const flags = globalFlags(cmd.parent.parent);
9863
10204
  const local = cmd.opts();
9864
10205
  const { runRecordPlan: runRecordPlan2 } = await Promise.resolve().then(() => (init_record(), record_exports));
@@ -9867,6 +10208,7 @@ function buildProgram() {
9867
10208
  setDir: local["set"],
9868
10209
  component: local["component"],
9869
10210
  metadataFiles: local["metadata"],
10211
+ sample: local["sample"],
9870
10212
  ...local["default"] !== void 0 ? { defaultSpecs: local["default"] } : {},
9871
10213
  ...local["componentSet"] !== void 0 ? { componentSet: local["componentSet"] } : {}
9872
10214
  });
@@ -9957,7 +10299,7 @@ function buildProgram() {
9957
10299
  ...local["model"] !== void 0 ? { model: local["model"] } : {}
9958
10300
  });
9959
10301
  });
9960
- engine.command("score").argument("<taskOrSet>", "reference task name or recording-set directory").argument("<candidateDir>", "directory containing the proposed bundle files").option("--bar <bar>", "target bar: pass (0.95) or cert (0.97)", "pass").option("--host <name>", "self-reported host identity (recorded in provenance, labeled self-reported)").requiredOption("--model <id>", "proposer model, self-reported \u2014 required; a score without a declared model is not accepted").action(async (taskOrSet, candidateDir, _o, cmd) => {
10302
+ engine.command("score").argument("<taskOrSet>", "reference task name or recording-set directory").argument("<candidateDir>", "directory containing the proposed bundle files").option("--bar <bar>", "target bar: pass (0.95) or cert (0.97)", "pass").option("--host <name>", "self-reported host identity (recorded in provenance, labeled self-reported)").requiredOption("--model <id>", "proposer model, self-reported \u2014 required; a score without a declared model is not accepted").option("--rebind", "explicitly re-bind an already-bound bundle to a different recording set (refused otherwise \u2014 rebinding rewrites the bundle's verification identity)").action(async (taskOrSet, candidateDir, _o, cmd) => {
9961
10303
  const flags = globalFlags(cmd.parent.parent);
9962
10304
  const local = cmd.opts();
9963
10305
  const { runEngineScore: runEngineScore2 } = await Promise.resolve().then(() => (init_engine2(), engine_exports));
@@ -9967,7 +10309,20 @@ function buildProgram() {
9967
10309
  candidateDir,
9968
10310
  bar: local["bar"] === "cert" ? "cert" : "pass",
9969
10311
  ...local["host"] !== void 0 ? { host: local["host"] } : {},
9970
- model: local["model"]
10312
+ model: local["model"],
10313
+ rebind: local["rebind"]
10314
+ });
10315
+ });
10316
+ program.command("codeconnect").description("Emit a Figma Code Connect template (.figma.ts) for a certified bundle \u2014 every variant\u2192prop mapping from recorded truth, stamped with the trust statement. Publishing stays yours (figma connect publish; Org/Enterprise plan).").argument("<bundleDir>", "bundle directory (must carry component.json)").requiredOption("--figma-url <url>", "figma.com /design/ URL of the COMPONENT SET (Copy link to selection)").option("--set <dir>", "recording set override (default: the bundle's provenance path)").option("--out <file>", "output file (default: <bundle>/<Component>.figma.ts)").action(async (bundleDir, _o, cmd) => {
10317
+ const flags = globalFlags(cmd.parent);
10318
+ const local = cmd.opts();
10319
+ const { runCodeConnect: runCodeConnect2 } = await Promise.resolve().then(() => (init_codeconnect(), codeconnect_exports));
10320
+ runCodeConnect2({
10321
+ ...flags,
10322
+ bundleDir,
10323
+ figmaUrl: local["figmaUrl"],
10324
+ ...local["set"] !== void 0 ? { set: local["set"] } : {},
10325
+ ...local["out"] !== void 0 ? { out: local["out"] } : {}
9971
10326
  });
9972
10327
  });
9973
10328
  program.command("verify").description("Recompute verification for a generated bundle against its recording set (per-config status, behaviors, honest exit codes).").argument("<bundleDir>", "bundle directory to verify").option("--task <name>", "legacy task adapter for bundles WITHOUT component.json (bundle v1 carries its own prop manifest)").option("--set <dir>", "recording set directory (overrides the bundle's provenance path)").option("--bar <bar>", "target bar: pass (0.95) or cert (0.97)", "pass").action(async (bundleDir, _opts, cmd) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",