@tendrilapp/cli 0.1.24 → 0.1.26

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/tendril.js CHANGED
@@ -928,6 +928,74 @@ var init_plan = __esm({
928
928
  }
929
929
  });
930
930
 
931
+ // packages/figma/src/recording/envelope-content.ts
932
+ function envelopeParts(payload) {
933
+ const content = payload?.content;
934
+ if (!Array.isArray(content)) return null;
935
+ const blocks = content;
936
+ const text = blocks.map((b) => typeof b.text === "string" ? b.text : "").filter((t) => t !== "").join("\n");
937
+ const image = blocks.find((b) => b.type === "image" && typeof b.data === "string");
938
+ return image === void 0 ? { text } : { text, imageData: image.data };
939
+ }
940
+ function namedCause(text, table) {
941
+ return table.find((entry) => entry.pattern.test(text))?.cause;
942
+ }
943
+ function jsonObjectPayload(text) {
944
+ try {
945
+ const value = JSON.parse(text);
946
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
947
+ } catch {
948
+ return null;
949
+ }
950
+ }
951
+ function checkEnvelopeContent(tool, payload) {
952
+ const parts = envelopeParts(payload);
953
+ if (parts === null) return { ok: true };
954
+ const { text, imageData } = parts;
955
+ const serviceError = namedCause(text, SERVICE_ERROR_SIGNATURES);
956
+ if (serviceError !== void 0) return { ok: false, reason: serviceError };
957
+ const reject = (expected) => {
958
+ const hint = namedCause(text, FAILURE_HINTS);
959
+ return { ok: false, reason: hint === void 0 ? expected : `${hint} \u2014 ${expected}` };
960
+ };
961
+ switch (tool) {
962
+ case "get_metadata":
963
+ case "get_metadata_interior":
964
+ return NODE_MARKUP.test(text) ? { ok: true } : reject('no node markup in the response (a get_metadata response contains elements like <symbol id="\u2026"> or <frame id="\u2026">)');
965
+ case "get_design_context":
966
+ return CODE_DECLARATION.test(text) || ANY_MARKUP.test(text) ? { ok: true } : reject("no code emission or markup in the response (a get_design_context response contains a component's code, or node markup when the design is too large)");
967
+ case "get_variable_defs":
968
+ return jsonObjectPayload(text) === null ? reject("the response is not a JSON object of variable definitions") : { ok: true };
969
+ case "get_screenshot": {
970
+ if (imageData === void 0) return reject("no image block in the response (a get_screenshot envelope carries base64 PNG bytes)");
971
+ const png = checkPngPayload(imageData);
972
+ return png.ok ? { ok: true } : reject(png.reason);
973
+ }
974
+ }
975
+ }
976
+ var NODE_MARKUP, ANY_MARKUP, CODE_DECLARATION, SERVICE_ERROR_SIGNATURES, FAILURE_HINTS;
977
+ var init_envelope_content = __esm({
978
+ "packages/figma/src/recording/envelope-content.ts"() {
979
+ "use strict";
980
+ init_provided_recording();
981
+ NODE_MARKUP = /<[A-Za-z][\w:.-]*(?:\s[^>]*)?\sid="[^"]+"/;
982
+ ANY_MARKUP = /<[A-Za-z][\w:.-]*(?:\s[^>]*)?\/?>/;
983
+ CODE_DECLARATION = /(?:^|\n)[ \t]*(?:const|let|var|function|class|export|import|type|interface|async|@)\b/;
984
+ SERVICE_ERROR_SIGNATURES = [
985
+ { pattern: /reached the .{0,60}tool call limit/i, cause: "the Figma MCP reported that the account's tool-call limit is exhausted" },
986
+ { pattern: /tool call limit for your/i, cause: "the Figma MCP reported that the account's tool-call limit is exhausted" },
987
+ { pattern: /upgrade your (?:seat|plan)/i, cause: "the Figma MCP returned an upgrade/quota notice" }
988
+ ];
989
+ FAILURE_HINTS = [
990
+ { pattern: /\brate[- ]limit/i, cause: "the response looks like a rate-limit error" },
991
+ { pattern: /\b(?:429|too many requests)\b/i, cause: "the response looks like a rate-limit error" },
992
+ { pattern: /\b(?:quota|billing|subscription|seat)\b/i, cause: "the response looks like a quota/plan notice" },
993
+ { pattern: /\b(?:unauthorized|forbidden|not authorized|access denied|401|403)\b/i, cause: "the response looks like an authorization error" },
994
+ { pattern: /\b(?:error|failed|failure|timed out|timeout)\b/i, cause: "the response looks like an error message" }
995
+ ];
996
+ }
997
+ });
998
+
931
999
  // packages/figma/src/recording/session.ts
932
1000
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
933
1001
  import path from "node:path";
@@ -990,6 +1058,7 @@ function planSet(setDir, component, symbols, opts = {}) {
990
1058
  version: 1,
991
1059
  component,
992
1060
  ...opts.sourceFrames !== void 0 ? { sourceFrames: opts.sourceFrames } : {},
1061
+ ...opts.variantScope !== void 0 ? { variantScope: opts.variantScope } : {},
993
1062
  ...opts.defaults !== void 0 && Object.keys(opts.defaults).length > 0 ? { defaults: opts.defaults } : {},
994
1063
  ...(() => {
995
1064
  const variants = symbols.map((s) => s.name).filter((n) => n.includes("="));
@@ -1036,10 +1105,14 @@ function ingestEnvelope(setDir, slug, tool, payload) {
1036
1105
  if (!RECORD_TOOLS.includes(tool)) {
1037
1106
  throw new Error(`unknown tool "${tool}" \u2014 expected one of ${RECORD_TOOLS.join(", ")}`);
1038
1107
  }
1108
+ const content = checkEnvelopeContent(tool, payload);
1109
+ if (!content.ok) {
1110
+ throw new Error(`${slug}/${tool} was NOT recorded \u2014 ${content.reason}. ${REINGEST_GUIDANCE}`);
1111
+ }
1039
1112
  const schema = tool === "get_screenshot" ? ImageEnvelopeSchema : TextEnvelopeSchema;
1040
1113
  const parsed = schema.safeParse(payload);
1041
1114
  if (!parsed.success) {
1042
- throw new Error(`${slug}/${tool}: envelope rejected at the boundary: ${parsed.error.issues[0]?.message ?? "invalid"}`);
1115
+ throw new Error(`${slug}/${tool} was NOT recorded \u2014 envelope rejected at the boundary: ${parsed.error.issues[0]?.message ?? "invalid"}. ${REINGEST_GUIDANCE}`);
1043
1116
  }
1044
1117
  const file = containedPath(setDir, slug, `${tool}.json`);
1045
1118
  mkdirSync(path.dirname(file), { recursive: true });
@@ -1076,12 +1149,13 @@ function ingestAsset(setDir, slug, name, content) {
1076
1149
  mkdirSync(path.dirname(file), { recursive: true });
1077
1150
  writeFileSync(file, content);
1078
1151
  }
1079
- var RECORD_TOOLS, INTERIOR_TOOL, REQUIRED_TOOLS, SessionManifestSchema, manifestPath, PROTOCOL_ORDER, byProtocolOrder;
1152
+ var RECORD_TOOLS, INTERIOR_TOOL, REQUIRED_TOOLS, SessionManifestSchema, manifestPath, PROTOCOL_ORDER, byProtocolOrder, REINGEST_GUIDANCE;
1080
1153
  var init_session = __esm({
1081
1154
  "packages/figma/src/recording/session.ts"() {
1082
1155
  "use strict";
1083
1156
  init_svg_safety();
1084
1157
  init_provided_recording();
1158
+ init_envelope_content();
1085
1159
  init_plan();
1086
1160
  RECORD_TOOLS = ["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "get_metadata_interior"];
1087
1161
  INTERIOR_TOOL = "get_metadata_interior";
@@ -1111,6 +1185,31 @@ var init_session = __esm({
1111
1185
  * domains: the API must cover the lattice even where only a subset
1112
1186
  * is recorded. */
1113
1187
  latticeNames: z4.array(z4.string()).optional(),
1188
+ /**
1189
+ * WHETHER `latticeNames` IS A DENOMINATOR AT ALL.
1190
+ *
1191
+ * The lattice is derived from the metadata handed to `record plan`,
1192
+ * so when that metadata is a bare list of variant nodes the count is
1193
+ * the SELECTION, not the component set. Field case (run 17): four
1194
+ * node ids that were 2 of 5 columns of a 5×2 set. Planning them would
1195
+ * have recorded a set silently missing three variants — and the
1196
+ * plan's own cross-check would have passed, because it validates
1197
+ * against the same metadata it was handed. Downstream that reads as
1198
+ * `latticeConfigs: 4 / unrecordedConfigs: 0`: an affirmative COMPLETE
1199
+ * claim over 40% of a missing axis, on the trust anchor's own
1200
+ * coverage disclosure.
1201
+ *
1202
+ * The signal is whether the metadata carried ENCLOSING CONTEXT: a
1203
+ * per-variant `get_metadata` returns the `<symbol>` as the root with
1204
+ * no ancestor, while a page- or set-level call nests the variants
1205
+ * under the named component set. "component-set" therefore means the
1206
+ * planner saw the set's own children; "selection" means it saw what
1207
+ * it was given and cannot know what else exists.
1208
+ *
1209
+ * Absent on sets planned before this field — treated as unknown, not
1210
+ * as complete, because that is what it was.
1211
+ */
1212
+ variantScope: z4.enum(["component-set", "selection"]).optional(),
1114
1213
  /** Which planning mode produced this queue. Absent = planned before
1115
1214
  * the full-matrix default (i.e. sampled) — resume uses this to
1116
1215
  * top-up rather than silently perpetuating a sampled queue. */
@@ -1120,6 +1219,7 @@ var init_session = __esm({
1120
1219
  manifestPath = (setDir) => path.join(setDir, "recording-set.json");
1121
1220
  PROTOCOL_ORDER = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_metadata_interior"];
1122
1221
  byProtocolOrder = (tools) => [...tools].sort((a, b) => PROTOCOL_ORDER.indexOf(a) - PROTOCOL_ORDER.indexOf(b));
1222
+ REINGEST_GUIDANCE = "Nothing was written. Recording persists per rep and resumes from disk, so re-run the same command with the real response; if the Figma MCP reported a tool-call or rate limit, wait for the quota to reset first.";
1123
1223
  }
1124
1224
  });
1125
1225
 
@@ -1216,6 +1316,7 @@ var init_src = __esm({
1216
1316
  init_axis_defaults();
1217
1317
  init_plan();
1218
1318
  init_session();
1319
+ init_envelope_content();
1219
1320
  init_roles();
1220
1321
  }
1221
1322
  });
@@ -1819,6 +1920,10 @@ function tendrilInvocation() {
1819
1920
  function tendrilCommand(args) {
1820
1921
  return `${tendrilInvocation()} ${args}`;
1821
1922
  }
1923
+ function quoteArg(value) {
1924
+ if (/["`$\\]/.test(value)) throw new Error(`refusing to emit a shell argument containing quote/backslash/backtick/$: ${value}`);
1925
+ return /[\s'*?[\]()&;|<>#~]/.test(value) ? `"${value}"` : value;
1926
+ }
1822
1927
  var NPX_INVOCATION, cached;
1823
1928
  var init_invocation = __esm({
1824
1929
  "packages/cli/src/invocation.ts"() {
@@ -3375,6 +3480,112 @@ var init_paths = __esm({
3375
3480
  }
3376
3481
  });
3377
3482
 
3483
+ // packages/verify/src/font-collection.ts
3484
+ function isCollection(bytes) {
3485
+ return bytes.length >= 12 && new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0) === TTC_TAG;
3486
+ }
3487
+ function nameTableStrings(view, bytes, nameOffset, nameLength) {
3488
+ const out = /* @__PURE__ */ new Map();
3489
+ if (nameOffset + 6 > bytes.length) return out;
3490
+ const count = view.getUint16(nameOffset + 2);
3491
+ const stringOffset = nameOffset + view.getUint16(nameOffset + 4);
3492
+ for (let i = 0; i < count; i++) {
3493
+ const rec = nameOffset + 6 + i * 12;
3494
+ if (rec + 12 > bytes.length) break;
3495
+ const platformId = view.getUint16(rec);
3496
+ const encodingId = view.getUint16(rec + 2);
3497
+ const nameId = view.getUint16(rec + 6);
3498
+ const length = view.getUint16(rec + 8);
3499
+ const offset = stringOffset + view.getUint16(rec + 10);
3500
+ if (offset + length > bytes.length || offset + length > nameOffset + nameLength) continue;
3501
+ const slice = bytes.subarray(offset, offset + length);
3502
+ const isUtf16 = platformId === 3 || platformId === 0 || encodingId === 1;
3503
+ let value = "";
3504
+ if (isUtf16) {
3505
+ for (let j = 0; j + 1 < slice.length; j += 2) value += String.fromCharCode(slice[j] << 8 | slice[j + 1]);
3506
+ } else {
3507
+ for (const b of slice) value += String.fromCharCode(b);
3508
+ }
3509
+ if (value !== "" && !out.has(nameId)) out.set(nameId, value);
3510
+ }
3511
+ return out;
3512
+ }
3513
+ function listCollectionFaces(bytes) {
3514
+ if (!isCollection(bytes)) return [];
3515
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3516
+ const numFonts = view.getUint32(8);
3517
+ const faces = [];
3518
+ for (let i = 0; i < numFonts; i++) {
3519
+ const dirOffset = view.getUint32(12 + i * 4);
3520
+ if (dirOffset + SFNT_HEADER_SIZE > bytes.length) continue;
3521
+ const numTables = view.getUint16(dirOffset + 4);
3522
+ let names = /* @__PURE__ */ new Map();
3523
+ for (let t = 0; t < numTables; t++) {
3524
+ const rec = dirOffset + SFNT_HEADER_SIZE + t * TABLE_RECORD_SIZE;
3525
+ if (rec + TABLE_RECORD_SIZE > bytes.length) break;
3526
+ const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
3527
+ if (tag !== "name") continue;
3528
+ names = nameTableStrings(view, bytes, view.getUint32(rec + 8), view.getUint32(rec + 12));
3529
+ break;
3530
+ }
3531
+ const family = names.get(1);
3532
+ const subfamily = names.get(2);
3533
+ faces.push({ index: i, ...family !== void 0 ? { family } : {}, ...subfamily !== void 0 ? { subfamily } : {} });
3534
+ }
3535
+ return faces;
3536
+ }
3537
+ function extractCollectionFace(bytes, index) {
3538
+ if (!isCollection(bytes)) throw new Error("not a TrueType Collection");
3539
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3540
+ const numFonts = view.getUint32(8);
3541
+ if (index < 0 || index >= numFonts) throw new Error(`face index ${index} out of range \u2014 the collection holds ${numFonts}`);
3542
+ const dirOffset = view.getUint32(12 + index * 4);
3543
+ const numTables = view.getUint16(dirOffset + 4);
3544
+ const records = [];
3545
+ for (let t = 0; t < numTables; t++) {
3546
+ const rec = dirOffset + SFNT_HEADER_SIZE + t * TABLE_RECORD_SIZE;
3547
+ const checksum = view.getUint32(rec + 4);
3548
+ const offset = view.getUint32(rec + 8);
3549
+ const length = view.getUint32(rec + 12);
3550
+ if (offset + length > bytes.length) throw new Error(`collection table ${t} runs past the end of the file`);
3551
+ records.push({ tag: bytes.subarray(rec, rec + 4), checksum, data: bytes.subarray(offset, offset + length) });
3552
+ }
3553
+ const aligned = (n) => n + 3 & ~3;
3554
+ const bodySize = records.reduce((sum, r) => sum + aligned(r.data.length), 0);
3555
+ const out = new Uint8Array(SFNT_HEADER_SIZE + records.length * TABLE_RECORD_SIZE + bodySize);
3556
+ const outView = new DataView(out.buffer);
3557
+ outView.setUint32(0, view.getUint32(dirOffset));
3558
+ outView.setUint16(4, records.length);
3559
+ const pow2 = Math.floor(Math.log2(Math.max(1, records.length)));
3560
+ outView.setUint16(6, 16 * 2 ** pow2);
3561
+ outView.setUint16(8, pow2);
3562
+ outView.setUint16(10, records.length * 16 - 16 * 2 ** pow2);
3563
+ let cursor = SFNT_HEADER_SIZE + records.length * TABLE_RECORD_SIZE;
3564
+ records.forEach((r, i) => {
3565
+ const rec = SFNT_HEADER_SIZE + i * TABLE_RECORD_SIZE;
3566
+ out.set(r.tag, rec);
3567
+ outView.setUint32(rec + 4, r.checksum);
3568
+ outView.setUint32(rec + 8, cursor);
3569
+ outView.setUint32(rec + 12, r.data.length);
3570
+ out.set(r.data, cursor);
3571
+ cursor += aligned(r.data.length);
3572
+ });
3573
+ return out;
3574
+ }
3575
+ function facesForFamily(bytes, family) {
3576
+ const want = family.toLowerCase().replace(/\s+/g, "");
3577
+ return listCollectionFaces(bytes).filter((f) => (f.family ?? "").toLowerCase().replace(/\s+/g, "") === want);
3578
+ }
3579
+ var TTC_TAG, TABLE_RECORD_SIZE, SFNT_HEADER_SIZE;
3580
+ var init_font_collection = __esm({
3581
+ "packages/verify/src/font-collection.ts"() {
3582
+ "use strict";
3583
+ TTC_TAG = 1953784678;
3584
+ TABLE_RECORD_SIZE = 16;
3585
+ SFNT_HEADER_SIZE = 12;
3586
+ }
3587
+ });
3588
+
3378
3589
  // packages/verify/src/font-resolve.ts
3379
3590
  import { createHash } from "node:crypto";
3380
3591
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
@@ -3463,18 +3674,33 @@ async function resolveFonts(family, weights, cacheDir = DEFAULT_FONT_CACHE) {
3463
3674
  `);
3464
3675
  return { resolved, failures };
3465
3676
  }
3466
- function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE) {
3677
+ function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE, faceIndex) {
3467
3678
  const src = path10.resolve(filePath);
3468
3679
  if (!existsSync6(src)) throw new Error(`font file not found: ${src}`);
3469
3680
  const ext = path10.extname(src).toLowerCase();
3470
- if (![".woff2", ".woff", ".ttf", ".otf"].includes(ext)) {
3471
- throw new Error(`unsupported font file "${ext}" \u2014 use .woff2 (preferred), .woff, .ttf or .otf`);
3681
+ if (![".woff2", ".woff", ".ttf", ".otf", ".ttc"].includes(ext)) {
3682
+ throw new Error(`unsupported font file "${ext}" \u2014 use .woff2 (preferred), .woff, .ttf, .otf or .ttc`);
3472
3683
  }
3473
- const bytes = new Uint8Array(readFileSync3(src));
3684
+ let bytes = new Uint8Array(readFileSync3(src));
3474
3685
  if (bytes.length === 0) throw new Error(`font file is empty: ${src}`);
3686
+ let storedExt = ext;
3687
+ if (isCollection(bytes)) {
3688
+ const candidates = facesForFamily(bytes, family);
3689
+ const chosen = faceIndex ?? (candidates.length === 1 ? candidates[0].index : void 0);
3690
+ if (chosen === void 0) {
3691
+ const all = listCollectionFaces(bytes);
3692
+ const shown = (candidates.length > 0 ? candidates : all).map((f) => ` --face ${f.index} ${f.family ?? "(unnamed)"}${f.subfamily !== void 0 ? ` ${f.subfamily}` : ""}`).join("\n");
3693
+ throw new Error(
3694
+ `${path10.basename(src)} is a collection of ${all.length} faces and ${candidates.length === 0 ? `none is named "${family}"` : `${candidates.length} match "${family}"`} \u2014 name the one you mean with --face <index>:
3695
+ ${shown}`
3696
+ );
3697
+ }
3698
+ bytes = new Uint8Array(extractCollectionFace(bytes, chosen));
3699
+ storedExt = ".ttf";
3700
+ }
3475
3701
  mkdirSync2(cacheDir, { recursive: true });
3476
3702
  const sha256 = createHash("sha256").update(bytes).digest("hex");
3477
- const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${ext}`);
3703
+ const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${storedExt}`);
3478
3704
  writeFileSync3(file, bytes);
3479
3705
  const face = { family, weight, source: `local:${path10.basename(src)}`, sha256, file, license: "unknown" };
3480
3706
  const mPath = path10.join(cacheDir, "manifest.json");
@@ -3564,6 +3790,7 @@ var DEFAULT_FONT_CACHE, UA, FONT_LICENSES, GOOGLE_LICENSE_IDS, GOOGLE_FONT_FILE_
3564
3790
  var init_font_resolve = __esm({
3565
3791
  "packages/verify/src/font-resolve.ts"() {
3566
3792
  "use strict";
3793
+ init_font_collection();
3567
3794
  DEFAULT_FONT_CACHE = fontCacheDir();
3568
3795
  UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36";
3569
3796
  FONT_LICENSES = ["OFL-1.1", "Apache-2.0", "UFL-1.0", "proprietary", "unknown"];
@@ -3740,7 +3967,7 @@ export function Button(props: {
3740
3967
  [key: string]: unknown; // MUST spread unknown props (incl. data-*) onto the root element
3741
3968
  })
3742
3969
 
3743
- Rules: plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom. disabled buttons are unfocusable; the spinner honors prefers-reduced-motion (animation: none under the media query). Interactive states are REAL (:hover, :focus-visible) AND statically forceable via the data-tendril-state attribute ("hover" | "focus-visible") spread onto the root; forced and real selectors must share one declaration block, e.g. :is(:hover, [data-tendril-state~="hover"]). The host sizes nothing: the button is its natural recorded size. Fonts: 'Inter' is provided. Inline all SVGs (loading spinner, icons).
3970
+ Rules: plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom. disabled buttons are unfocusable; the spinner honors prefers-reduced-motion (animation: none under the media query). Interactive states are REAL (:hover, :focus-visible) AND statically forceable via the data-tendril-state attribute ("hover" | "focus-visible") spread onto the root; forced and real selectors must share one declaration block, e.g. :is(:hover, [data-tendril-state~="hover"]). ROOT SIZE IS YOURS AND PIXELS DO NOT CHECK IT, IN EITHER DIRECTION: the mount floors your root to the recorded box (#root > *{min-width:<recorded w>px;min-height:<recorded h>px} \u2014 a floor, never a cap) and the image the scorer compares is a CROP of that box. So a button that computes NARROWER or SHORTER is stretched back and captures byte-identically to a correct one (measured on a real <button>), and an oversized one shows only where its own border, radius or shadow lands back inside the crop. Take both root dimensions from the recorded box, never from a score. Fonts: 'Inter' is provided. Inline all SVGs (loading spinner, icons).
3744
3971
 
3745
3972
  BEHAVIORAL CONTRACT (machine-verified, gating): the root is a focusable <button> with cursor:pointer; real :hover visibly changes it; the loading spinner has a RUNNING CSS animation. Static lookalikes fail.`;
3746
3973
  COMBO_FIX = {
@@ -4082,15 +4309,30 @@ async function runSteps(page, spec, renderPose) {
4082
4309
  const verdict = await page.evaluate(
4083
4310
  `(() => {
4084
4311
  const needle = ${JSON.stringify(step.assertTextVisible)};
4085
- const els = [...document.querySelectorAll('#root *')];
4086
- const holders = els.filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent ?? '').includes(needle)));
4087
- if (holders.length === 0) return 'text not in the DOM at all';
4088
- for (const el of holders) {
4312
+ // Attributes a user actually perceives, whether by eye or
4313
+ // through assistive tech. Deliberately NOT every attribute:
4314
+ // a data-* or a name= is a place to park a string, which is
4315
+ // the dodge this check exists to catch.
4316
+ const PERCEIVED = ['placeholder', 'alt', 'aria-label', 'title', 'value'];
4317
+ const visible = (el) => {
4089
4318
  const r = el.getBoundingClientRect();
4090
4319
  const cs = getComputedStyle(el);
4091
- if (r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0) return true;
4092
- }
4093
- return 'text present but not visibly rendered (hidden/zero-size/transparent node)';
4320
+ return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0;
4321
+ };
4322
+ const els = [...document.querySelectorAll('#root *')];
4323
+ const textHolders = els.filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent ?? '').includes(needle)));
4324
+ for (const el of textHolders) if (visible(el)) return true;
4325
+ const attrHolders = els.filter((el) =>
4326
+ PERCEIVED.some((a) => {
4327
+ // The LIVE value for form controls: React's defaultValue
4328
+ // sets the property, and the attribute may never appear.
4329
+ const v = a === 'value' && 'value' in el ? el.value : el.getAttribute(a);
4330
+ return typeof v === 'string' && v.includes(needle);
4331
+ }),
4332
+ );
4333
+ for (const el of attrHolders) if (visible(el)) return true;
4334
+ if (textHolders.length === 0 && attrHolders.length === 0) return 'text not in the DOM at all, and not in a placeholder/alt/aria-label/title/value either';
4335
+ return 'present but not visibly rendered (hidden/zero-size/transparent node)';
4094
4336
  })()`
4095
4337
  );
4096
4338
  if (verdict !== true) return { id: spec.id, pass: false, detail: `sentinel "${step.assertTextVisible}": ${String(verdict)}` };
@@ -4355,7 +4597,7 @@ function definedVars(tokensCss) {
4355
4597
  for (const m of tokensCss.matchAll(/(--[\w-]+)\s*:/g)) names.add(m[1]);
4356
4598
  return names;
4357
4599
  }
4358
- function recordedTokenMapEmpty(setDir, reps) {
4600
+ function recordedTokenMapState(setDir, reps) {
4359
4601
  const readMap = (file) => {
4360
4602
  if (!existsSync10(file)) return void 0;
4361
4603
  try {
@@ -4366,15 +4608,15 @@ function recordedTokenMapEmpty(setDir, reps) {
4366
4608
  }
4367
4609
  };
4368
4610
  const setLevel = readMap(path15.join(setDir, "get_variable_defs.json"));
4369
- if (setLevel !== void 0) return Object.keys(setLevel).length === 0;
4611
+ if (setLevel !== void 0) return Object.keys(setLevel).length === 0 ? "empty" : "populated";
4370
4612
  let recorded = false;
4371
4613
  for (const rep of reps) {
4372
4614
  const m = readMap(path15.join(setDir, rep, "get_variable_defs.json"));
4373
4615
  if (m === void 0) continue;
4374
4616
  recorded = true;
4375
- if (Object.keys(m).length > 0) return false;
4617
+ if (Object.keys(m).length > 0) return "populated";
4376
4618
  }
4377
- return recorded;
4619
+ return recorded ? "empty" : "never-recorded";
4378
4620
  }
4379
4621
  function scannable(css) {
4380
4622
  const blank = (m) => m.replace(/[^\n]/g, " ");
@@ -4426,7 +4668,7 @@ function fontStackFindings(sheets, coverage) {
4426
4668
  kind: "font-stack",
4427
4669
  file: sheet.file,
4428
4670
  line: stack.line,
4429
- message: `font-family ${stack.text} binds to '${family}', which the font kit provides at ${has} \u2014 CSS matches the FAMILY first and picks a weight only inside it (a later family is never consulted for a missing weight, and the prelude's font-synthesis: none rules out faux-bold), so text at weight ${list} ${renders}, silently. ` + (rescue !== void 0 ? `'${rescue}' in this same stack provides ${list}: list it first.` : `No family in this stack provides ${list} \u2014 if the recording shows ${unserved.length === 1 ? "that weight" : "those weights"}, run \`tendril fonts resolve "${family}" --weights ${unserved.join(" ")}\` and re-score.`) + " ADVISORY: quality feedback only \u2014 it never moves a score, a tier, or the verdict."
4671
+ message: `font-family ${stack.text} binds to '${family}', which the font kit provides at ${has} \u2014 CSS matches the FAMILY first and picks a weight only inside it (a later family is never consulted for a missing weight, and the prelude's font-synthesis: none rules out faux-bold), so text at weight ${list} ${renders}, silently. ` + (rescue !== void 0 ? `'${rescue}' in this same stack provides ${list}: list it first.` : `No family in this stack provides ${list} \u2014 if the recording shows ${unserved.length === 1 ? "that weight" : "those weights"}, run \`tendril fonts resolve "${family}" --weights ${unserved.join(" ")}\` and re-score.`) + " ADVISORY: the finding gates nothing \u2014 but the wrong face changes pixels, and this fix alone took a measured run from 7 to 9 certified."
4430
4672
  });
4431
4673
  }
4432
4674
  }
@@ -4461,10 +4703,17 @@ async function checkBundleQuality(bundleDir, entry, set, fontManifest) {
4461
4703
  }
4462
4704
  }
4463
4705
  if (css !== "") {
4464
- const recordedEmpty = set !== void 0 && recordedTokenMapEmpty(set.dir, set.reps);
4465
- for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), recordedEmpty)).violations) {
4706
+ const mapState = set === void 0 ? void 0 : recordedTokenMapState(set.dir, set.reps);
4707
+ for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), mapState !== void 0 && mapState !== "populated")).violations) {
4466
4708
  findings.push({ kind: "token-lint", file: "styles.css", line: v.line, message: v.message });
4467
4709
  }
4710
+ if (mapState === "never-recorded") {
4711
+ findings.push({
4712
+ kind: "token-lint",
4713
+ file: "styles.css",
4714
+ message: "the recording set has NO design-token map (no get_variable_defs envelope at set or rep level), so whether these literals have kit tokens is UNKNOWN, not answered \u2014 the brief's empty token table means 'never recorded', not 'this kit has none'. Record it with `record next` on the set (it asks for get_variable_defs once, at set level) and re-run to get real token guidance."
4715
+ });
4716
+ }
4468
4717
  }
4469
4718
  return { findings, tokensAbsent: tokensCss === void 0 && !/var\(\s*--/.test(css) };
4470
4719
  }
@@ -5444,6 +5693,7 @@ var init_src4 = __esm({
5444
5693
  init_tasks();
5445
5694
  init_prelude();
5446
5695
  init_mount_limits();
5696
+ init_font_collection();
5447
5697
  init_font_faces();
5448
5698
  init_font_resolve();
5449
5699
  init_paths();
@@ -5695,6 +5945,15 @@ import { spawnSync } from "node:child_process";
5695
5945
  import { existsSync as existsSync18, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
5696
5946
  import os4 from "node:os";
5697
5947
  import path23 from "node:path";
5948
+ function withDeadline(work, ms) {
5949
+ return Promise.race([
5950
+ work,
5951
+ new Promise((_resolve, reject) => {
5952
+ const timer = setTimeout(() => reject(new McpProbeTimeout(ms)), ms);
5953
+ timer.unref?.();
5954
+ })
5955
+ ]);
5956
+ }
5698
5957
  async function runDoctorChecks(options) {
5699
5958
  const checks = [];
5700
5959
  const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
@@ -5703,19 +5962,26 @@ async function runDoctorChecks(options) {
5703
5962
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
5704
5963
  });
5705
5964
  try {
5706
- const info = await client.initialize();
5707
- const tools = await client.listTools();
5965
+ const info = await withDeadline(client.initialize(), MCP_PROBE_DEADLINE_MS);
5966
+ const tools = await withDeadline(client.listTools(), MCP_PROBE_DEADLINE_MS);
5708
5967
  checks.push({
5709
5968
  name: "figma-desktop-mcp",
5710
5969
  ok: true,
5711
- detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
5970
+ // WHOSE capability this tested. Run 16 read a green tick here as
5971
+ // permission to start recording, and could not: the CLI reaches
5972
+ // this server with its own localhost client, while the AGENT held
5973
+ // no Figma MCP tools at all, and `record` is agent-driven with no
5974
+ // CLI path. The operator wrote a 100-line MCP client in the first
5975
+ // ten minutes of a paid run. A check consumed by one party about
5976
+ // another party's capability has to say so out loud.
5977
+ detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")} \u2014 this is THIS CLI's own connection. It does NOT test whether your agent session holds Figma MCP tools, and recording needs the AGENT to make those calls: confirm with one real get_metadata call before planning.`
5712
5978
  });
5713
5979
  } catch (err) {
5714
5980
  checks.push({
5715
5981
  name: "figma-desktop-mcp",
5716
5982
  ok: false,
5717
5983
  detail: err instanceof Error ? err.message : String(err),
5718
- remediation: err instanceof McpUnavailableError ? "Open the Figma desktop app, sign in, then Figma menu \u2192 Preferences \u2192 Enable local MCP server (Dev or Full seat on a paid plan)." : "The server answered but the exchange failed \u2014 check the signed-in account's seat, then retry."
5984
+ remediation: err instanceof McpUnavailableError ? "Open the Figma desktop app, sign in, then Figma menu \u2192 Preferences \u2192 Enable local MCP server (Dev or Full seat on a paid plan)." : err instanceof McpProbeTimeout ? "Quit and reopen the Figma desktop app, then re-run doctor. If it answers again, nothing else is wrong \u2014 the server was reachable but stalled." : "The server answered but the exchange failed \u2014 check the signed-in account's seat, then retry."
5719
5985
  });
5720
5986
  }
5721
5987
  const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
@@ -5854,7 +6120,7 @@ async function runDoctor(flags) {
5854
6120
  });
5855
6121
  if (!report.ok) process.exit(1);
5856
6122
  }
5857
- var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION;
6123
+ var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION, MCP_PROBE_DEADLINE_MS, McpProbeTimeout;
5858
6124
  var init_doctor = __esm({
5859
6125
  "packages/cli/src/commands/doctor.ts"() {
5860
6126
  "use strict";
@@ -5882,6 +6148,15 @@ var init_doctor = __esm({
5882
6148
  exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
5883
6149
  examples: ["tendril doctor", "tendril doctor --json"]
5884
6150
  };
6151
+ MCP_PROBE_DEADLINE_MS = 1e4;
6152
+ McpProbeTimeout = class extends Error {
6153
+ constructor(ms) {
6154
+ super(
6155
+ `the Figma MCP server accepted the connection but did not answer within ${ms / 1e3}s. It is reachable and not responding \u2014 usually a Figma desktop app that needs restarting (this state has been seen after an in-place app update).`
6156
+ );
6157
+ this.name = "McpProbeTimeout";
6158
+ }
6159
+ };
5885
6160
  }
5886
6161
  });
5887
6162
 
@@ -7250,10 +7525,12 @@ var init_activate = __esm({
7250
7525
  var record_exports = {};
7251
7526
  __export(record_exports, {
7252
7527
  instanceLeads: () => instanceLeads,
7528
+ interactionDisclosure: () => interactionDisclosure,
7253
7529
  isFigmaAssetUrl: () => isFigmaAssetUrl,
7254
7530
  isLocalAssetUrl: () => isLocalAssetUrl,
7255
7531
  narrowedRoles: () => narrowedRoles,
7256
7532
  nextPayload: () => nextPayload,
7533
+ recordsInteractionState: () => recordsInteractionState,
7257
7534
  runRecordAsset: () => runRecordAsset,
7258
7535
  runRecordFetch: () => runRecordFetch,
7259
7536
  runRecordFinish: () => runRecordFinish,
@@ -7267,6 +7544,26 @@ import { existsSync as existsSync20, mkdtempSync as mkdtempSync2, readFileSync a
7267
7544
  import os5 from "node:os";
7268
7545
  import path26 from "node:path";
7269
7546
  import { writeFileSync as writeFileSync9 } from "node:fs";
7547
+ function recordsInteractionState(reports) {
7548
+ const evident = (s) => stateTokens(s).some((t) => INTERACTION_EVIDENCE_VALUES.has(t));
7549
+ return reports.some((r) => evident(r.axis) || r.domain.some(evident));
7550
+ }
7551
+ function interactionDisclosure(component, reports) {
7552
+ const recordedVariants = reports.map((r) => ({ axis: r.axis, values: r.domain }));
7553
+ const variantSummary = recordedVariants.length === 0 ? "" : recordedVariants.length === 1 ? `${recordedVariants[0].axis} variants for ${andList(recordedVariants[0].values)}` : `variants for ${andList(recordedVariants.map((v) => `${v.axis} (${andList(v.values)})`))}`;
7554
+ const has = recordedVariants.length > 0;
7555
+ const statement = `Your ${component} component ${has ? `has ${variantSummary} \u2014 but none of them is a hover, focus or pressed state` : "has no variants at all, so none of it is a hover, focus or pressed state"}. Nothing in the recording shows how ${component} looks when someone hovers it, tabs to it, or is using it, so Tendril has no picture of those to check the build against. Tendril still builds controls as real, working controls \u2014 never a static lookalike \u2014 and, with no focus state recorded, it uses the browser's own focus indicator rather than inventing a focus ring. You do not need to answer this: recording carries on either way.`;
7556
+ const designFix = `If ${component} should look different when it is focused or hovered, that change belongs in Figma rather than in the code: add a Focus (or Hover) variant to the ${component} component set, record it, and Tendril will match it exactly. If ${component} is not something people interact with, there is nothing to do.`;
7557
+ return {
7558
+ component,
7559
+ recordedVariants,
7560
+ variantSummary,
7561
+ statement,
7562
+ designFix,
7563
+ instruction: "DISCLOSURE, not a question. Say `statement` and `designFix` to a present user at PLAN time, in the SAME message as defaultsToConfirm and every other plan-time question \u2014 one message per session, never one per component and never mid-generation. No answer is required and nothing blocks; proceed with recording whatever they say. State ONLY what `recordedVariants` contains: never infer from the component's NAME what kind of control it is. Non-interactive runs: put the statement in your report.",
7564
+ blocking: false
7565
+ };
7566
+ }
7270
7567
  function symbolsFromMetadataEnvelope(file, sourceFrame) {
7271
7568
  const env = JSON.parse(readFileSync16(file, "utf8"));
7272
7569
  const text = env.content.map((c) => c.text ?? "").join("\n");
@@ -7360,6 +7657,7 @@ function runRecordPlan(opts) {
7360
7657
  }
7361
7658
  }
7362
7659
  const setNames = [...new Set(symbols.map((s) => s.setName).filter((n) => n !== void 0))];
7660
+ const variantScope = setNames.length > 0 ? "component-set" : "selection";
7363
7661
  if (opts.componentSet !== void 0) {
7364
7662
  const filtered = symbols.filter((s) => s.setName === opts.componentSet);
7365
7663
  if (filtered.length === 0) {
@@ -7417,11 +7715,54 @@ function runRecordPlan(opts) {
7417
7715
  if (metadataTruncated) {
7418
7716
  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.");
7419
7717
  }
7420
- const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, { ...Object.keys(defaults).length > 0 ? { defaults } : {}, ...opts.sample === true ? { sample: true } : {} });
7718
+ const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, {
7719
+ ...Object.keys(defaults).length > 0 ? { defaults } : {},
7720
+ ...opts.sample === true ? { sample: true } : {},
7721
+ variantScope
7722
+ });
7421
7723
  const defaultReports = reportAxisDefaults(symbols, Object.keys(defaults).length > 0 ? defaults : void 0);
7422
7724
  const toConfirm = resumed ? [] : defaultReports.filter((r) => (r.rule === "non-interaction" || r.rule === "frequency") && r.domain.length > 1);
7423
- const callLow = manifest.reps.length * 3;
7424
- const callHigh = manifest.reps.length * 4;
7725
+ const interactionStatesToConfirm = recordsInteractionState(defaultReports) ? void 0 : interactionDisclosure(manifest.component, defaultReports);
7726
+ const poses = manifest.reps.length;
7727
+ const callLow = poses * FIGMA_CALLS_PER_REP;
7728
+ const callHigh = poses * FIGMA_CALLS_PER_REP_WORST;
7729
+ const posesRemaining = sessionStatus(opts.setDir).reps.filter((r) => r.missing.length > 0).length;
7730
+ const callsNeeded = posesRemaining * FIGMA_CALLS_PER_REP;
7731
+ const callSentence = posesRemaining === poses ? `${poses} poses \xD7 ${FIGMA_CALLS_PER_REP} calls per pose = ${callsNeeded} Figma calls` : `${posesRemaining} poses still to record \xD7 ${FIGMA_CALLS_PER_REP} calls per pose = ${callsNeeded} Figma calls (${poses - posesRemaining} of ${poses} already on disk)`;
7732
+ const arithmetic = `${callSentence} (up to ${posesRemaining * FIGMA_CALLS_PER_REP_WORST} if screenshots or assets need a second fetch)`;
7733
+ const multiDay = callsNeeded > MULTI_DAY_CALL_THRESHOLD;
7734
+ const feasibilityCheck = {
7735
+ arithmetic,
7736
+ callsNeeded,
7737
+ callsWorstCase: posesRemaining * FIGMA_CALLS_PER_REP_WORST,
7738
+ posesToRecord: posesRemaining,
7739
+ callsPerPose: FIGMA_CALLS_PER_REP,
7740
+ instruction: "BEFORE the first recording call, IF your Figma server exposes a `whoami` tool, call it \u2014 it names the seat and plan and is EXEMPT from Figma's tool-call limits, so the check costs nothing and spends no quota. Read the daily tool-call allowance for that seat/plan and divide: callsNeeded \xF7 allowance = days this set needs. Tendril owns the multiplication above; the allowance is Figma's and theirs to change, so take it from whoami and never from memory. NOT EVERY TRANSPORT HAS IT: the remote Figma MCP server does; the local Dev Mode server exposes six tools and `whoami` is not among them (measured 2026-08-14). If yours does not, say so plainly, treat the allowance as UNKNOWN, and use limitHintIfWhoamiIsSilent below \u2014 which is unverified and dated, and must be shown as such. Do not report a guessed allowance as a checked one.",
7741
+ surface: multiDay ? "This set exceeds every daily allowance we have heard of, so it CANNOT finish in one day. State the verdict to the user in plain numbers BEFORE recording anything and let them choose from `options` \u2014 do not start on the assumption it will fit." : "Say nothing about cost if the division fits inside one day's allowance \u2014 a set that comfortably fits is not worth the user's attention. Surface the verdict and `options` only when it does not fit.",
7742
+ verdictTemplate: `${callSentence}. Your <seat> seat on <plan> allows <allowance>/day. This set needs about <days> day(s) at that rate.`,
7743
+ options: [
7744
+ {
7745
+ id: "pace",
7746
+ decidedBy: "user, and you can carry it out",
7747
+ text: "Spread the recording across days, staying inside the daily allowance. Nothing is lost at a limit: the set persists after every pose and re-running the same command resumes from disk, so a rate limit costs waiting, never recorded work."
7748
+ },
7749
+ {
7750
+ id: "sample",
7751
+ // Sampling trades away lattice coverage, so it stays a human
7752
+ // decision structurally, not by instruction: it exists only as
7753
+ // a CLI flag and the MCP surface has no parameter for it.
7754
+ decidedBy: "HUMAN ONLY \u2014 offer it, never choose it, and you cannot run it",
7755
+ text: "Record a reduced pose set (anchor + one-factor sweeps + conflict crosses) instead of the full variant matrix. This buys calls with coverage \u2014 sampling is blind to multi-axis interactions and the report discloses the poses it skipped \u2014 so only the user may accept that trade. There is no tool parameter for it; the user runs it themselves in their own terminal, before any pose is recorded.",
7756
+ userRuns: [`rm ${quoteArg(path26.join(opts.setDir, "recording-set.json"))}`, `${tendrilCommand(`record plan --set ${quoteArg(opts.setDir)} --component ${quoteArg(manifest.component)}`)} --sample`]
7757
+ },
7758
+ {
7759
+ id: "larger-allowance",
7760
+ decidedBy: "user",
7761
+ text: "A seat or plan with a larger daily allowance, if their organisation allows it. Figma's terms, not ours \u2014 state it as an option, never as advice to spend money."
7762
+ }
7763
+ ],
7764
+ limitHintIfWhoamiIsSilent: LIMIT_HINT
7765
+ };
7425
7766
  emitData(
7426
7767
  opts,
7427
7768
  {
@@ -7433,15 +7774,30 @@ function runRecordPlan(opts) {
7433
7774
  // the one check that catches parse/transfer losses this pipeline
7434
7775
  // cannot detect from the envelope alone.
7435
7776
  variantsFound: symbols.length,
7777
+ // WHAT variantsFound IS A COUNT OF. The comment above tells the
7778
+ // reader to hold it against Figma's own variant count — a check
7779
+ // run 17 proved an agent cannot perform, because it cannot see
7780
+ // the user's screen. So the pipeline must say when the number is
7781
+ // merely the selection it was handed.
7782
+ variantScope,
7783
+ ...variantScope === "selection" ? { variantCoverageUnknown: SELECTION_SCOPE_DISCLOSURE } : {},
7436
7784
  planMode: manifest.planMode ?? "sample",
7437
7785
  ...metadataTruncated ? { metadataTruncated: true } : {},
7438
7786
  ...toppedUp !== void 0 ? { toppedUp } : {},
7439
7787
  notRecorded: manifest.notRecorded ?? null,
7440
- figmaCallEstimate: { reps: manifest.reps.length, calls: `~${callLow}\u2013${callHigh}` },
7788
+ // The whole-set ledger, numbers only — what recording this queue
7789
+ // from nothing costs. What it costs FROM HERE, and what to do
7790
+ // about it, is feasibilityCheck's job.
7791
+ figmaCallEstimate: { reps: poses, callsPerRep: FIGMA_CALLS_PER_REP, calls: `~${callLow}\u2013${callHigh}`, callsMin: callLow, callsMax: callHigh },
7792
+ // A sibling of defaultsToConfirm: a handshake the agent completes
7793
+ // with the user before the queue costs anything, not a field to
7794
+ // relay as duration.
7795
+ feasibilityCheck,
7441
7796
  // Cold-start fix (run 7): the first instruction rides the plan
7442
7797
  // response, so record_next is never needed to begin — it exists
7443
7798
  // only for resuming.
7444
7799
  next: nextPayload(opts.setDir),
7800
+ ...interactionStatesToConfirm !== void 0 ? { interactionStatesToConfirm } : {},
7445
7801
  ...toConfirm.length > 0 ? {
7446
7802
  defaultsToConfirm: {
7447
7803
  instruction: "HEURISTIC defaults: the designer named no literal Default and no override was given, so the code guessed the component's zero point \u2014 the pose an empty-props mount shows and the pose behaviour checks aim at. When a USER is present, ask each question below BEFORE recording; if they pick a different value, re-run this exact plan command with --default <Axis>=<Value> (allowed until the first envelope is ingested; frozen with the recording after). Non-interactive: proceed with the resolved values and state them in your report.",
@@ -7454,6 +7810,42 @@ function runRecordPlan(opts) {
7454
7810
  } : {}
7455
7811
  },
7456
7812
  () => {
7813
+ const writeFeasibility = () => {
7814
+ if (callsNeeded === 0) return;
7815
+ process.stdout.write(`recording cost: ${arithmetic}
7816
+ `);
7817
+ if (multiDay) {
7818
+ process.stdout.write(`FEASIBILITY: ${callsNeeded} calls exceeds every daily Figma allowance we have heard of \u2014 this CANNOT finish in one day.
7819
+ `);
7820
+ process.stdout.write(` ask Figma whoami (free \u2014 exempt from tool-call limits) for this seat's daily allowance, divide ${callsNeeded} by it, and say how many DAYS this needs before recording
7821
+ `);
7822
+ process.stdout.write(` (a) pace it across days \u2014 the set persists after every pose, so a rate limit costs waiting, never recorded work
7823
+ `);
7824
+ process.stdout.write(` (b) record fewer poses \u2014 YOUR call, not an agent's: delete recording-set.json and re-plan with --sample (buys calls with coverage)
7825
+ `);
7826
+ process.stdout.write(` (c) a seat or plan with a larger daily allowance
7827
+ `);
7828
+ } else {
7829
+ process.stdout.write(`FEASIBILITY: check ${callsNeeded} against this seat's daily Figma allowance (ask whoami \u2014 it is free) before recording; it only needs raising if it does not fit
7830
+ `);
7831
+ }
7832
+ };
7833
+ const writeInteraction = () => {
7834
+ if (interactionStatesToConfirm === void 0) return;
7835
+ const d = interactionStatesToConfirm;
7836
+ process.stdout.write(
7837
+ `CONFIRM states: ${d.component} records no hover, focus or pressed state${d.recordedVariants.length > 0 ? ` \u2014 it has ${d.variantSummary}` : " (it has no variants at all)"}
7838
+ `
7839
+ );
7840
+ process.stdout.write(` nothing in the recording shows how it looks when someone hovers it, tabs to it, or is using it
7841
+ `);
7842
+ process.stdout.write(` Tendril still builds controls as real controls \u2014 never a static lookalike \u2014 and uses the browser's own focus indicator instead of inventing a focus ring
7843
+ `);
7844
+ process.stdout.write(` want a focus ring of your own? add a Focus variant to the ${d.component} component set in Figma and record again \u2014 Tendril will match it exactly
7845
+ `);
7846
+ process.stdout.write(` nothing to answer \u2014 recording carries on either way
7847
+ `);
7848
+ };
7457
7849
  if (resumed) {
7458
7850
  if (opts.sample === true && (manifest.planMode ?? "sample") === "full") {
7459
7851
  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");
@@ -7467,18 +7859,20 @@ function runRecordPlan(opts) {
7467
7859
  process.stdout.write(`resumed existing plan (${manifest.reps.length} reps, ${manifest.planMode ?? "sample"} mode) \u2014 delete recording-set.json to re-plan
7468
7860
  `);
7469
7861
  }
7862
+ writeFeasibility();
7863
+ writeInteraction();
7470
7864
  return;
7471
7865
  }
7472
7866
  for (const r of plan.reps) process.stdout.write(`planned ${r.slug.padEnd(24)} ${r.nodeId} (${r.tier})
7473
7867
  `);
7474
7868
  if (plan.notRecorded.length > 0) process.stdout.write(`not recorded (${plan.notRecorded.length}): disclosed in the manifest
7475
7869
  `);
7476
- process.stdout.write(`estimated recording cost: ~${callLow}\u2013${callHigh} Figma calls for ${manifest.reps.length} reps
7477
- `);
7870
+ writeFeasibility();
7478
7871
  for (const q2 of toConfirm) {
7479
7872
  process.stdout.write(`CONFIRM ${q2.axis}: default resolved to "${q2.value}" by heuristic (${q2.rule}) \u2014 ask the user; change with --default before recording
7480
7873
  `);
7481
7874
  }
7875
+ writeInteraction();
7482
7876
  }
7483
7877
  );
7484
7878
  }
@@ -7491,7 +7885,19 @@ function nextPayload(setDir) {
7491
7885
  const frameNode = Object.keys(manifest.sourceFrames ?? {})[0] ?? manifest.reps[0]?.nodeId ?? "";
7492
7886
  return { slug: "__set__", nodeId: frameNode, tool: "get_variable_defs", note: `SET-LEVEL: call get_variable_defs on the component frame and ingest with --rep __set__. ${ENVELOPE_HELP}`, progress };
7493
7887
  }
7494
- return instruction === null ? { complete: true, progress } : { ...instruction, note: `${instruction.note}. ${ENVELOPE_HELP}`, progress };
7888
+ return instruction === null ? { complete: true, progress } : {
7889
+ ...instruction,
7890
+ note: `${instruction.note}. ${ENVELOPE_HELP}`,
7891
+ // RESUME POINTER, NOT A WORK ASSIGNMENT. This is the globally
7892
+ // first unrecorded rep, so under parallel recorders it hands the
7893
+ // same rep to everyone: run 18 measured 8 of 10 ingests pointing
7894
+ // a recorder at `anchor`, which was another agent's job. Correct
7895
+ // for picking a set back up, wrong as a loop driver, and it was
7896
+ // labelled as neither.
7897
+ scope: "resume-pointer",
7898
+ parallelWarning: "This is the first unrecorded rep in the WHOLE set, not your next one. If several recorders are working this set at once, ignore it and follow your own assigned rep list \u2014 otherwise every recorder converges on the same rep.",
7899
+ progress
7900
+ };
7495
7901
  }
7496
7902
  function runRecordNext(opts) {
7497
7903
  const payload = nextPayload(opts.setDir);
@@ -7632,6 +8038,14 @@ async function runRecordIngest(opts) {
7632
8038
  remediation: "Save the get_variable_defs response verbatim as a text envelope."
7633
8039
  });
7634
8040
  }
8041
+ const content = checkEnvelopeContent("get_variable_defs", payload);
8042
+ if (!content.ok) {
8043
+ fail(opts, ExitCode.InputValidation, {
8044
+ error: `set-level get_variable_defs was NOT recorded \u2014 ${content.reason}`,
8045
+ code: "envelope-rejected",
8046
+ remediation: REINGEST_GUIDANCE
8047
+ });
8048
+ }
7635
8049
  writeFileSync9(path26.join(opts.setDir, "get_variable_defs.json"), `${JSON.stringify(payload, null, 1)}
7636
8050
  `);
7637
8051
  emitData(opts, { ingested: "get_variable_defs", rep: "__set__", setLevel: true, next: nextPayload(opts.setDir) }, () => {
@@ -7656,7 +8070,7 @@ async function runRecordIngest(opts) {
7656
8070
  fail(opts, ExitCode.InputValidation, {
7657
8071
  error: err instanceof Error ? err.message : String(err),
7658
8072
  code: "envelope-rejected",
7659
- remediation: "The envelope failed boundary validation \u2014 record the tool response again, verbatim, and re-ingest."
8073
+ remediation: "Fix what the error names, then re-run this exact command \u2014 recording resumes from disk, so nothing already recorded is lost."
7660
8074
  });
7661
8075
  }
7662
8076
  }
@@ -7891,7 +8305,7 @@ function runRecordFinish(opts) {
7891
8305
  `);
7892
8306
  }
7893
8307
  }
7894
- var ENVELOPE_HELP, isAutoFetchAssetUrl, describeRoleLoss;
8308
+ var FIGMA_CALLS_PER_REP, FIGMA_CALLS_PER_REP_WORST, MULTI_DAY_CALL_THRESHOLD, SELECTION_SCOPE_DISCLOSURE, LIMIT_HINT, stateTokens, MAX_SPOKEN_VALUES, andList, ENVELOPE_HELP, isAutoFetchAssetUrl, describeRoleLoss;
7895
8309
  var init_record = __esm({
7896
8310
  "packages/cli/src/commands/record.ts"() {
7897
8311
  "use strict";
@@ -7901,6 +8315,26 @@ var init_record = __esm({
7901
8315
  init_output();
7902
8316
  init_entitlement();
7903
8317
  init_invocation();
8318
+ FIGMA_CALLS_PER_REP = 3;
8319
+ FIGMA_CALLS_PER_REP_WORST = 4;
8320
+ MULTI_DAY_CALL_THRESHOLD = 600;
8321
+ SELECTION_SCOPE_DISCLOSURE = "variantsFound counts the nodes you passed, NOT the component set: this metadata carried no enclosing set, so nothing here can tell whether the set holds more variants. Do NOT report the lattice as complete on this basis. To establish the real denominator, call get_metadata WITHOUT a nodeId (a page-level listing), find the component set these nodes sit under, and re-run plan against it \u2014 re-planning is free until the first envelope is ingested. If you proceed as-is, say plainly that coverage is unestablished.";
8322
+ LIMIT_HINT = {
8323
+ status: "UNVERIFIED \u2014 recorded 2026-08-14 from Figma's published documentation and one field report; Figma changes these at will.",
8324
+ use: "Use ONLY if whoami states no allowance, and say it is unverified when you show it. The allowance whoami reports always wins.",
8325
+ perDayByPlan: {
8326
+ "Dev/Full seat, Professional": "~200 tool calls/day",
8327
+ "Dev/Full seat, Organization or Enterprise": "~600 tool calls/day",
8328
+ Starter: "~6 tool calls/month"
8329
+ },
8330
+ burst: "A per-minute ceiling is documented alongside the daily one, so parallel recorders can trip a limit long before the daily budget runs out."
8331
+ };
8332
+ stateTokens = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").split("-").filter((t) => t !== "");
8333
+ MAX_SPOKEN_VALUES = 6;
8334
+ andList = (items) => {
8335
+ const shown = items.length <= MAX_SPOKEN_VALUES ? items : [...items.slice(0, MAX_SPOKEN_VALUES), `${items.length - MAX_SPOKEN_VALUES} more`];
8336
+ return shown.length <= 1 ? shown[0] ?? "" : `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
8337
+ };
7904
8338
  ENVELOPE_HELP = `Envelope format: text tools save {"content":[{"type":"text","text":"<VERBATIM response text incl. any Currently-selected-nodes block>"}]}; get_screenshot: 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.`;
7905
8339
  isAutoFetchAssetUrl = (url) => isFigmaAssetUrl(url) || isLocalAssetUrl(url);
7906
8340
  describeRoleLoss = (loss) => loss.kind === "main" ? `drops main "${loss.main}"` : `stops "${loss.part}" being a part of main "${loss.main}"`;
@@ -8129,7 +8563,7 @@ ${preludeLines.join("\n")}` : ""}${absentLines.length > 0 ? `
8129
8563
  MISSING FEATURES (absent-ink clusters \u2014 recorded marks your render leaves out or paints invisibly; GATING at the cert bar. Fix the named node's ink \u2014 a config carrying one cannot certify):
8130
8564
  ${absentLines.join("\n")}` : ""}
8131
8565
 
8132
- Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Reading the pair: ink=1 with LOW sim means nothing is MISSING (do not chase missing ink) \u2014 the same pixels are being painted wrong, and there are TWO causes. GEOMETRY: wrong position/size/radius; the diff shows shifted edges and bands. WRONG TEXT WEIGHT OR FACE: the diff shows a uniform haze over glyph runs; CSS binds the font FAMILY before the weight, a later family in the stack is never consulted for a weight the first one lacks, and font-synthesis: none rules out faux-bold \u2014 so a stack led by a family the kit holds at one weight renders every other weight in that face, silently. Check the font stack against the kit's cached faces before concluding geometry is at fault. LOW ink with high sim means recorded marks are absent or painted invisibly. Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
8566
+ Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Reading the pair: ink credits a recorded pixel when ANY render ink lands within 1px of it, so ink=1 proves coverage \u2014 not presence, shape, or colour. A wrong-shaped mark over the right region, or a wrong-coloured one that is still ink, scores 1.000; and a recorded mark within ~30 channel-sum of the backdrop (#f6f6f6 on white is 27) is not ink at all, so it can be absent at ink 1.000 with no MISSING line. With ink=1 AND low sim, coverage held while pixels differ, so start with the TWO causes below \u2014 but never read ink=1 as "nothing is missing": confirm every faint or small recorded mark (hairlines, dividers, low-contrast controls) exists in your render. GEOMETRY: wrong position/size/radius; the diff shows shifted edges and bands. WRONG TEXT WEIGHT OR FACE: the diff shows a uniform haze over glyph runs; CSS binds the font FAMILY before the weight, a later family in the stack is never consulted for a weight the first one lacks, and font-synthesis: none rules out faux-bold \u2014 so a stack led by a family the kit holds at one weight renders every other weight in that face, silently. Check the font stack against the kit's cached faces before concluding geometry is at fault. LOW ink with high sim means recorded marks are absent or painted invisibly. Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
8133
8567
  }
8134
8568
  function archivePriorRun(outDir) {
8135
8569
  if (!existsSync21(path27.join(outDir, "run-log.json")) && !existsSync21(path27.join(outDir, "loop-state.json"))) return void 0;
@@ -8483,7 +8917,13 @@ function authorComponentApi(opts) {
8483
8917
  const provided = opts.fonts ?? [];
8484
8918
  const recorded = opts.recordedFonts ?? [];
8485
8919
  const unprovided = recorded.filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
8486
- const fontsLine = (provided.length > 0 ? `Fonts provided by the harness: ${provided.map((f) => `'${f}'`).join(", ")}. ` : "") + (recorded.length > 0 ? `The recording's declared famil${recorded.length === 1 ? "y is" : "ies are"} ${recorded.map((f) => `'${f}'`).join(", ")}${unprovided.length > 0 ? ` \u2014 ${unprovided.map((f) => `'${f}'`).join(", ")} ${unprovided.length === 1 ? "is" : "are"} NOT provided: the mount will substitute a provided face, scoring reflects the recorded one, and the residual glyph delta is not closable from CSS \u2014 do not chase it` : ""}. ` : "");
8920
+ const faceGaps = (opts.providedFaces ?? []).map((f) => {
8921
+ const need = (opts.recordedFaces ?? []).find((r) => r.family.toLowerCase() === f.family.toLowerCase());
8922
+ const missing = (need?.weights ?? []).filter((w) => !f.weights.includes(w));
8923
+ return { family: f.family, has: f.weights, missing };
8924
+ }).filter((f) => f.missing.length > 0);
8925
+ const weightsLine = (opts.providedFaces ?? []).length > 0 ? `Weights the harness can actually serve: ${(opts.providedFaces ?? []).map((f) => `'${f.family}' (${[...f.weights].sort((a, b) => a - b).join(", ")})`).join("; ")}. ` + (faceGaps.length > 0 ? `WEIGHT GAP \u2014 ${faceGaps.map((g) => `'${g.family}' is recorded at ${g.missing.join(", ")} which the cache does NOT hold`).join("; ")}. CSS binds the FAMILY before the weight and font-synthesis: none blocks the fall-through, so text at a missing weight renders in a weight this family DOES have, silently and at full score-cost. Do not lead a font stack with a family that cannot serve the weight you are setting; put a family that has it first, or report the gap rather than chasing the difference as geometry. ` : "") : "";
8926
+ const fontsLine = weightsLine + (provided.length > 0 ? `Fonts provided by the harness: ${provided.map((f) => `'${f}'`).join(", ")}. ` : "") + (recorded.length > 0 ? `The recording's declared famil${recorded.length === 1 ? "y is" : "ies are"} ${recorded.map((f) => `'${f}'`).join(", ")}${unprovided.length > 0 ? ` \u2014 ${unprovided.map((f) => `'${f}'`).join(", ")} ${unprovided.length === 1 ? "is" : "are"} NOT provided: the mount will substitute a provided face, scoring reflects the recorded one, and the residual glyph delta is not closable from CSS \u2014 do not chase it` : ""}. ` : "");
8487
8927
  const forcingCanon = forcedStates.length > 0 ? `Interactive states are REAL (:hover, :focus-visible) AND statically forceable via the data-tendril-state attribute (a DOCUMENTED token-list attribute; tokens: ${forcedStates.map((t) => `"${t}"`).join(" | ")}) spread onto the root; forced and real selectors must share ONE declaration block, e.g. :is(:hover, [data-tendril-state~="hover"]) \u2014 match with ~= so compound states ("hover selected") work. ` : "";
8488
8928
  const systemApi = `Prescribed API (the harness mounts exactly this; deviation scores 0):
8489
8929
  export function ${componentIdent}(props: {
@@ -8493,7 +8933,7 @@ ${propLines.join("\n")}
8493
8933
 
8494
8934
  ${syntheticCombos.length > 0 ? `UNRECORDED REACHABLE COMBINATIONS: the design's exclusive axis cannot express ${syntheticCombos.join(", ")} \u2014 the API split makes them reachable with NO recorded truth. Compose them from the recorded per-axis truth (paint variables: one axis sets, the other consumes), never invent a bespoke look, and list them in your report.
8495
8935
  ` : ""}${slots.length > 0 ? `CONTENT PROPS: every string prop defaults to its RECORDED text \u2014 render the PROP, never a hardcoded literal. Configs pass per-pose recorded strings wherever the recording varies, and pixels enforce them: a hardcoded string fails those configs. Content presence (a heading that only exists in some poses) follows the AXIS props; the string prop only supplies the text. RICH-TEXT DEFAULTS: when the recorded content is a multi-run rich node (mixed weights, an underlined link span) whose concatenation is the prop's default string, a plain-string default would flatten the recorded formatting \u2014 default the parameter to undefined and render the recorded rich markup when the prop is absent; a caller-supplied string then renders plainly. That mirrors the design tool's own emission logic and keeps every recorded pose pixel-exact.
8496
- ` : ""}Rules: generated element ids come from React's useId() \u2014 never Math.random() in render/state init (SSR hydration hazard; measured: two same-day bundles disagreed). Plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom, and NEVER import the stylesheet from the entry module: the harness injects tokens.css and styles.css itself, and an entry that imports CSS does not compile here (measured cost: one full round, every config 0). The component renders the RECORDED content as its defaults \u2014 reproduce it from the emissions. ${forcingCanon}${fontsLine}The host sizes nothing: the component is its natural recorded size. FLUIDITY AFFORDANCE (emit it always): every ROOT width declaration rides width: var(--tendril-root-width, <recorded>px) with that pose's recorded width as the fallback \u2014 per-variant root widths reuse the SAME property name with their own recorded fallbacks. With the property unset this is byte-equivalent truth: identical pixels, identical operability measurement (measured: flipping roots to a bare 100% held 20/20 pixels but made a commit check unmeasurable \u2014 the recorded pin carries real signal). A consumer makes an instance fluid by setting --tendril-root-width (e.g. 100%) on a wrapper, no edit to certified CSS. Widths only: recorded heights and internal geometry stay literal \u2014 fixed heights are often genuine design intent.`;
8936
+ ` : ""}Rules: generated element ids come from React's useId() \u2014 never Math.random() in render/state init (SSR hydration hazard; measured: two same-day bundles disagreed). Plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom, and NEVER import the stylesheet from the entry module: the harness injects tokens.css and styles.css itself, and an entry that imports CSS does not compile here (measured cost: one full round, every config 0). The component renders the RECORDED content as its defaults \u2014 reproduce it from the emissions. ${forcingCanon}${fontsLine}ROOT SIZE IS YOURS AND PIXELS DO NOT CHECK IT, IN EITHER DIRECTION: the mount floors your root to the recorded box (#root > *{min-width:<recorded w>px;min-height:<recorded h>px} \u2014 a floor, never a cap), and the image the scorer compares is a CROP of that box, so paint outside it is never compared at all. Measured through the real scorer against a correct 120x40 root: an UNDERSIZED 80x28 root and OVERSIZED 160x52 and 124x44 roots ALL scored sim 1.000 / ink 1.000 with a byte-identical crop, as long as internals stay literal and top-left anchored. An oversized root shows only when its own border, radius or shadow \u2014 or a re-centred internal layout \u2014 lands back inside the crop. And some roots are not floored at all: a non-replaced INLINE root (a bare <label>, or an <a href> for a link variant) ignores width/height/min-width/min-height entirely and renders content-sized whatever you declare. So take both root dimensions from the recorded box in the payload, and give the root a display that honours them \u2014 no score, passing or failing, is evidence they are right. FLUIDITY AFFORDANCE (emit it always): every ROOT width declaration rides width: var(--tendril-root-width, <recorded>px) with that pose's recorded width as the fallback \u2014 per-variant root widths reuse the SAME property name with their own recorded fallbacks. With the property unset this is byte-equivalent truth: identical pixels, identical operability measurement (measured: flipping roots to a bare 100% held 20/20 pixels but made a commit check unmeasurable \u2014 the recorded pin carries real signal). A consumer makes an instance fluid by setting --tendril-root-width (e.g. 100%) on a wrapper, no edit to certified CSS. Widths only: recorded heights and internal geometry stay literal \u2014 fixed heights are often genuine design intent.`;
8497
8937
  const mappedTokens = /* @__PURE__ */ new Set([...forcedStates, ...props.filter((p) => p.kind === "boolean").map((p) => p.name)]);
8498
8938
  const unmappedInteractionEvidence = interactionEvidence.filter((e) => {
8499
8939
  const sel = e.endsWith(" (selection axis)");
@@ -8783,8 +9223,8 @@ function recordedTextSlots(setDir, repSlugs) {
8783
9223
  }
8784
9224
  function authorTaskFromSet(setDir, opts = {}) {
8785
9225
  if (opts.fonts === void 0) {
8786
- const resolved = resolvedFontFamilies().map((f) => f.family);
8787
- if (resolved.length > 0) opts = { ...opts, fonts: resolved };
9226
+ const resolvedFaces = resolvedFontFamilies();
9227
+ if (resolvedFaces.length > 0) opts = { ...opts, fonts: resolvedFaces.map((f) => f.family), providedFaces: resolvedFaces.map((f) => ({ family: f.family, weights: [...f.weights] })) };
8788
9228
  }
8789
9229
  const manifest = loadManifest(setDir);
8790
9230
  const poses = [];
@@ -8816,6 +9256,15 @@ function authorTaskFromSet(setDir, opts = {}) {
8816
9256
  poses,
8817
9257
  ...latticeNames !== void 0 ? { latticeNames } : {},
8818
9258
  ...opts.fonts !== void 0 ? { fonts: opts.fonts } : {},
9259
+ ...opts.providedFaces !== void 0 ? { providedFaces: opts.providedFaces } : {},
9260
+ ...(() => {
9261
+ try {
9262
+ const needs = recordedFontNeeds(setDir);
9263
+ return needs.length > 0 ? { recordedFaces: needs } : {};
9264
+ } catch {
9265
+ return {};
9266
+ }
9267
+ })(),
8819
9268
  ...recordedFonts.length > 0 ? { recordedFonts } : {},
8820
9269
  ...Object.keys(defaults).length > 0 ? { defaults } : {},
8821
9270
  ...textSlots.length > 0 ? { textSlots } : {},
@@ -8891,7 +9340,7 @@ ALL prose instructions live ABOVE the task payload \u2014 the payload contains o
8891
9340
 
8892
9341
  ASSETS: inline the SVG assets you RENDER byte-verbatim, unchanged \u2014 never redraw or approximate an icon. Recorded assets that no scored config displays may be omitted. Two techniques reconcile that rule with reuse (both measured at 1.000): a glyph recorded once but shown in several colors keeps its bytes (fill attribute included) and is repainted with a CSS fill rule \u2014 a CSS declaration outranks an SVG presentation attribute, so one verbatim copy serves every tone; and multi-part glyphs needing fractional placement can nest each verbatim asset as a child <svg x= y=> inside one integer-origin frame (SVG user-space coordinates are exact), an alternative to the transform: scale() pattern.
8893
9342
 
8894
- GEOMETRY ARBITRATION (when emission styling and the recorded box disagree, the BOX wins): Figma strokes are INSIDE the box \u2014 border+padding sums that overshoot a recorded dimension mean use an inset box-shadow or subtract the border from the padding. That rule covers strokes AT the box edge only: when a config's reference PNG is LARGER than its recorded box, the recording itself proves an OUTWARD effect \u2014 a hover ring, glow, or shadow past the frame \u2014 and the outward part is drawn outward (box-shadow spread, outline), never forced inside. On such configs the score report carries a "seat" field ({x, y} \u2014 where the recorded box sits inside the larger reference), derived from the recording's own effect geometry (shadow offset/radius/spread) \u2014 informational, so an offset shadow's asymmetric bleed is not misread as a registration error. Following strokes-inside against a padded reference contradicts the recorded pad and cost a measured five configs a full round. The harness mounts each config at its recorded box (width \xD7 height, floors not clamps); build to those dimensions, not to guessed viewports. Known rasterizer delta: Chrome often seats small text ONE PIXEL HIGHER than Figma in an identically sized box. Correct it with a PAINT-ONLY offset on those text runs \u2014 position: relative with top: 1px \u2014 never with padding or margin, which would grow the recorded box this same paragraph calls truth. TREAT IT AS A MEASUREMENT, NOT A RULE: measured cases are 12px/16px and 14px/20px needing the nudge and 14px/18px not, so font size alone does not predict it and neither does any formula we can currently defend. RULE ORDER when both could apply (run 7: a config sat 0.0004 under the bar AND showed uniform spread \u2014 the rules pointed opposite ways): read the DIFF FIRST; the uniform-spread stop-rule below OUTRANKS the try-it rule here. Only when the diff shows a shifted band: apply the nudge, re-score, and keep it only if it helped. Four measured signatures, so do not expect one: on one kit it drove ink recall to 1.000; on another ink was already 1.000 and only similarity moved (mean 0.961 \u2192 0.976, four configs from 0.003 above the bar to 0.028); on a third it REGRESSED a passing bundle from 9/9 to 3/9 configs and had to be reverted; on a fourth it made every FAILING config worse too (tinted in-section surfaces: ink dropped ~0.011 across all eight configs and pushed a passing one under the bar). It is a hypothesis to score, never a default \u2014 NEVER apply it to a bundle that already passes, and when the failing family's diff shows a uniform low-density spread across the glyph band rather than a shifted band, the cause is rasterization weight, not seating: the nudge cannot help and a heavier hypothesis has no recorded truth to justify it \u2014 report the honest sub-bar result instead. Design-token NAMES in the payload are Figma names \u2014 canonicalize to valid CSS idents (lowercase kebab, e.g. "Text/text-primary" \u2192 --text-text-primary) if you emit tokens.css; literal values are equally acceptable. NEVER invent a token to satisfy a lint finding: quality findings are advisory, and a recorded value the kit has no token for is CORRECT as a literal \u2014 a made-up token name corrupts the tokens file as a record of the kit.
9343
+ GEOMETRY ARBITRATION (when emission styling and the recorded box disagree, the BOX wins): Figma strokes are INSIDE the box \u2014 border+padding sums that overshoot a recorded dimension mean use an inset box-shadow or subtract the border from the padding. That rule covers strokes AT the box edge only: when a config's reference PNG is LARGER than its recorded box, the recording itself proves an OUTWARD effect \u2014 a hover ring, glow, or shadow past the frame \u2014 and the outward part is drawn outward (box-shadow spread, outline), never forced inside. On such configs the score report carries a "seat" field ({x, y} \u2014 where the recorded box sits inside the larger reference), derived from the recording's own effect geometry (shadow offset/radius/spread) \u2014 informational, so an offset shadow's asymmetric bleed is not misread as a registration error. Following strokes-inside against a padded reference contradicts the recorded pad and cost a measured five configs a full round. The harness mounts each config at its recorded box (width \xD7 height) and FLOORS your root to it, never caps it; build to those recorded dimensions, not to guessed viewports \u2014 the prescribed API below says what that floor hides. Known rasterizer delta: Chrome often seats small text ONE PIXEL HIGHER than Figma in an identically sized box. Correct it with a PAINT-ONLY offset on those text runs \u2014 position: relative with top: 1px \u2014 never with padding or margin, which would grow the recorded box this same paragraph calls truth. TREAT IT AS A MEASUREMENT, NOT A RULE: measured cases are 12px/16px and 14px/20px needing the nudge and 14px/18px not, so font size alone does not predict it and neither does any formula we can currently defend. RULE ORDER when both could apply (run 7: a config sat 0.0004 under the bar AND showed uniform spread \u2014 the rules pointed opposite ways): read the DIFF FIRST; the uniform-spread stop-rule below OUTRANKS the try-it rule here. Only when the diff shows a shifted band: apply the nudge, re-score, and keep it only if it helped. Four measured signatures, so do not expect one: on one kit it drove ink recall to 1.000; on another ink was already 1.000 and only similarity moved (mean 0.961 \u2192 0.976, four configs from 0.003 above the bar to 0.028); on a third it REGRESSED a passing bundle from 9/9 to 3/9 configs and had to be reverted; on a fourth it made every FAILING config worse too (tinted in-section surfaces: ink dropped ~0.011 across all eight configs and pushed a passing one under the bar). It is a hypothesis to score, never a default \u2014 NEVER apply it to a bundle that already passes, and when the failing family's diff shows a uniform low-density spread across the glyph band rather than a shifted band, the cause is rasterization weight, not seating: the nudge cannot help and a heavier hypothesis has no recorded truth to justify it \u2014 report the honest sub-bar result instead. Design-token NAMES in the payload are Figma names \u2014 canonicalize to valid CSS idents (lowercase kebab, e.g. "Text/text-primary" \u2192 --text-text-primary) if you emit tokens.css; literal values are equally acceptable. NEVER invent a token to satisfy a lint finding: quality findings are advisory, and a recorded value the kit has no token for is CORRECT as a literal \u2014 a made-up token name corrupts the tokens file as a record of the kit.
8895
9344
 
8896
9345
  RECORDED BOXES ARE TRUTH even when inconsistent: the same string may
8897
9346
  have different recorded widths across variants (designer resizing) \u2014 a
@@ -8908,13 +9357,13 @@ PAINT & PLATFORM TRAPS (each measured on a paid run; every one passed typecheck,
8908
9357
  - A native <dialog> carries user-agent padding: 1em. Override every side, or the root grows past its recorded box and every band inside shifts.
8909
9358
  - 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.
8910
9359
  - 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.
8911
- - 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).
9360
+ - 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). CROSSED POSES ARE SCORED: the harness mounts every RECORDED pose, crosses included \u2014 measured on a bundle passing 20/20 (13 single-axis configs, 7 crosses), adding one later same-specificity rule left every single-axis config the rule did not itself target byte-stable and dropped all five crossed configs it touched from ~0.99 to sim 0.09\u20130.16. Only a cross with NO recorded pose goes ungraded.
8912
9361
 
8913
9362
  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.
8914
9363
 
8915
9364
  STATE SEMANTICS (prescribed \u2014 do not choose your own):
8916
9365
  - The component OWNS its interaction state, seeded from the prop. A selection/checked prop supplies the INITIAL value; user interaction updates internal state and must visibly commit without any parent re-render. A purely controlled component that ignores its own clicks fails the behavioral checks. Call an on\u2026Change callback when one is in the prescribed API, but never depend on it to update your own paint.
8917
- - Read-only means operable but not committable: the control stays focusable and in the accessibility tree with aria-readonly="true", and cancels commit on both pointer and keyboard. Do NOT use pointer-events:none or disabled to fake it \u2014 both destroy focusability, and disabled means something different.
9366
+ - Read-only means operable but not committable: the control stays focusable and in the accessibility tree with aria-readonly="true", and cancels commit on both pointer and keyboard. Do NOT fake it with pointer-events:none or disabled. pointer-events:none removes only the POINTER path \u2014 the element stops being hit-tested, so clicks land on whatever is behind it \u2014 while Tab still reaches it and .focus() still lands: the control still takes focus and still commits from the KEYBOARD (measured in Chrome: Space checked a pointer-events:none checkbox). Of the two, ONLY disabled refuses focus, which is why reaching for pointer-events:none to make a pose unfocusable fails disabled-not-focusable with "disabled element took focus".
8918
9367
  - GEOMETRY FOLLOWS PROPS; ONLY PAINT FOLLOWS LIVE STATE. Per-variant sizing must key off the pose props the caller passed, never off live interaction state \u2014 otherwise clicking resizes the component (a recorded selected/unselected width difference is a designer artifact, not a consequence of selection). The scorer renders static poses and structurally cannot see this, so it is on you.
8919
9368
 
8920
9369
  ${systemApi}
@@ -9313,7 +9762,9 @@ function countLatticeSymbols(setDir) {
9313
9762
  const manifestFile = path30.join(setDir, "recording-set.json");
9314
9763
  if (existsSync24(manifestFile)) {
9315
9764
  try {
9316
- const lattice = JSON.parse(readFileSync20(manifestFile, "utf8")).latticeNames;
9765
+ const stored = JSON.parse(readFileSync20(manifestFile, "utf8"));
9766
+ if (stored.variantScope !== "component-set") return null;
9767
+ const lattice = stored.latticeNames;
9317
9768
  if (lattice !== void 0 && lattice.length > 0) return lattice.length;
9318
9769
  } catch {
9319
9770
  }
@@ -9882,10 +10333,76 @@ var init_src7 = __esm({
9882
10333
  }
9883
10334
  });
9884
10335
 
10336
+ // packages/cli/src/font-guidance.ts
10337
+ import path31 from "node:path";
10338
+ function fontsUnprovenRemediation(setDir) {
10339
+ const set = setDir === void 0 ? void 0 : path31.resolve(setDir);
10340
+ if (set !== void 0) {
10341
+ try {
10342
+ const needs = recordedFontNeeds(set);
10343
+ if (needs.length > 0) {
10344
+ const families = needs.map((n) => `"${n.family}"`).join(", ");
10345
+ return `Run \`${tendrilCommand(`fonts resolve --set ${set}`)}\` \u2014 the recording declares ${families}; faces land in ${DEFAULT_FONT_CACHE}.`;
10346
+ }
10347
+ } catch {
10348
+ }
10349
+ }
10350
+ const target = set ?? "<recording-dir>";
10351
+ return `Run \`${tendrilCommand(`fonts resolve --set ${target}`)}\` (or \`${tendrilCommand('fonts resolve "<Family>" --weights 400 500 600')}\`) to populate the font cache \u2014 the cache read is ${DEFAULT_FONT_CACHE}.`;
10352
+ }
10353
+ function taskFontFamilies(setDir) {
10354
+ try {
10355
+ const families = recordedFontFamilies(setDir);
10356
+ return families.length > 0 ? families : null;
10357
+ } catch {
10358
+ return null;
10359
+ }
10360
+ }
10361
+ function unprovisionedFamilies(setDir, cacheDir) {
10362
+ const declared = taskFontFamilies(setDir);
10363
+ if (declared === null) return [];
10364
+ const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
10365
+ return declared.filter((f) => !provided.has(f.toLowerCase()));
10366
+ }
10367
+ function unprovisionedFaces(setDir, cacheDir) {
10368
+ return [
10369
+ ...unprovisionedFamilies(setDir, cacheDir),
10370
+ ...missingWeights(setDir, cacheDir).map((g) => {
10371
+ const missing = g.declared.filter((w) => !g.provided.includes(w));
10372
+ return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
10373
+ })
10374
+ ];
10375
+ }
10376
+ function missingWeights(setDir, cacheDir) {
10377
+ let needs;
10378
+ try {
10379
+ needs = recordedFontNeeds(setDir, { pairedOnly: true });
10380
+ } catch {
10381
+ return [];
10382
+ }
10383
+ const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
10384
+ const gaps = [];
10385
+ for (const n of needs) {
10386
+ const have = provided.get(n.family.toLowerCase());
10387
+ if (have === void 0) continue;
10388
+ if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
10389
+ }
10390
+ return gaps;
10391
+ }
10392
+ var init_font_guidance = __esm({
10393
+ "packages/cli/src/font-guidance.ts"() {
10394
+ "use strict";
10395
+ init_src4();
10396
+ init_src7();
10397
+ init_invocation();
10398
+ }
10399
+ });
10400
+
9885
10401
  // packages/cli/src/commands/fonts.ts
9886
10402
  var fonts_exports = {};
9887
10403
  __export(fonts_exports, {
9888
10404
  DEFAULT_FONT_CACHE: () => DEFAULT_FONT_CACHE,
10405
+ familyMismatch: () => familyMismatch,
9889
10406
  runFontsAdd: () => runFontsAdd,
9890
10407
  runFontsRequired: () => runFontsRequired,
9891
10408
  runFontsResolve: () => runFontsResolve,
@@ -9893,7 +10410,7 @@ __export(fonts_exports, {
9893
10410
  runFontsStatus: () => runFontsStatus
9894
10411
  });
9895
10412
  import { existsSync as existsSync25, readFileSync as readFileSync21 } from "node:fs";
9896
- import path31 from "node:path";
10413
+ import path32 from "node:path";
9897
10414
  async function runFontsResolve(opts) {
9898
10415
  const result = await resolveFonts(opts.family, opts.weights, opts.cacheDir);
9899
10416
  emitData(opts, result, () => {
@@ -9908,7 +10425,7 @@ async function runFontsResolve(opts) {
9908
10425
  }
9909
10426
  }
9910
10427
  async function runFontsResolveSet(opts) {
9911
- const setDir = path31.resolve(process.env["INIT_CWD"] ?? process.cwd(), opts.set);
10428
+ const setDir = path32.resolve(process.env["INIT_CWD"] ?? process.cwd(), opts.set);
9912
10429
  let needs = [];
9913
10430
  try {
9914
10431
  needs = recordedFontNeeds(setDir);
@@ -9981,7 +10498,7 @@ async function runFontsResolveSet(opts) {
9981
10498
  }
9982
10499
  }
9983
10500
  function runFontsStatus(opts) {
9984
- const manifestPath2 = path31.join(opts.cacheDir, "manifest.json");
10501
+ const manifestPath2 = path32.join(opts.cacheDir, "manifest.json");
9985
10502
  if (!existsSync25(manifestPath2)) {
9986
10503
  fail(opts, ExitCode.FontsUnproven, {
9987
10504
  error: `no font cache at ${opts.cacheDir}`,
@@ -9990,7 +10507,7 @@ function runFontsStatus(opts) {
9990
10507
  });
9991
10508
  }
9992
10509
  const faces = JSON.parse(readFileSync21(manifestPath2, "utf8"));
9993
- const lockVerdicts = opts.lock !== void 0 ? checkFontLock(path31.resolve(opts.lock), opts.cacheDir) : null;
10510
+ const lockVerdicts = opts.lock !== void 0 ? checkFontLock(path32.resolve(opts.lock), opts.cacheDir) : null;
9994
10511
  emitData(opts, { faces, lockVerdicts }, () => {
9995
10512
  for (const f of faces) process.stdout.write(`cached ${f.family} ${f.weight} (${f.sha256.slice(0, 12)}\u2026)
9996
10513
  `);
@@ -10013,10 +10530,45 @@ function runFontsRequired(opts) {
10013
10530
  });
10014
10531
  if (missing.length > 0) process.exit(ExitCode.FontsUnproven);
10015
10532
  }
10533
+ function editDistance(a, b) {
10534
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
10535
+ const row = new Array(b.length + 1).fill(0);
10536
+ for (let i = 1; i <= a.length; i++) {
10537
+ row[0] = i;
10538
+ for (let j = 1; j <= b.length; j++) {
10539
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
10540
+ }
10541
+ for (let j = 0; j <= b.length; j++) prev[j] = row[j];
10542
+ }
10543
+ return prev[b.length];
10544
+ }
10545
+ function familyMismatch(family, declared) {
10546
+ if (declared.length === 0) return void 0;
10547
+ if (declared.some((d) => d.toLowerCase() === family.toLowerCase())) return void 0;
10548
+ const ranked = declared.map((d) => ({ d, score: editDistance(family.toLowerCase(), d.toLowerCase()) })).sort((a, b) => a.score - b.score);
10549
+ const best = ranked[0];
10550
+ return best.score <= Math.max(3, Math.floor(family.length / 3)) ? { nearest: best.d } : {};
10551
+ }
10016
10552
  function runFontsAdd(opts) {
10553
+ if (opts.set !== void 0) {
10554
+ const declared = taskFontFamilies(path32.resolve(opts.set)) ?? [];
10555
+ const mismatch = familyMismatch(opts.family, declared);
10556
+ if (mismatch !== void 0) {
10557
+ fail(opts, ExitCode.InputValidation, {
10558
+ error: `the recording set does not declare a family named "${opts.family}" \u2014 it declares ${declared.map((d) => `"${d}"`).join(", ")}. Adding it under this name would cache a face the mount never matches, and scoring would keep refusing for the family that is still missing.`,
10559
+ code: "font-family-not-declared",
10560
+ remediation: mismatch.nearest !== void 0 ? `Did you mean "${mismatch.nearest}"? ${tendrilCommand(`fonts add "${mismatch.nearest}" ${opts.weight} ${opts.file} --set ${path32.resolve(opts.set)}`)}` : `Use one of the declared families exactly as the recording spells it, or drop --set to add a family this set does not declare.`
10561
+ });
10562
+ }
10563
+ } else {
10564
+ warn(
10565
+ opts,
10566
+ `family "${opts.family}" was NOT checked against a recording \u2014 pass --set <recording-dir> to have the spelling verified, since a face cached under a name the mount does not match leaves scoring refusing for a family that still looks provided`
10567
+ );
10568
+ }
10017
10569
  let face;
10018
10570
  try {
10019
- face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir);
10571
+ face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir, opts.face);
10020
10572
  } catch (err) {
10021
10573
  fail(opts, ExitCode.InputValidation, {
10022
10574
  error: err instanceof Error ? err.message : String(err),
@@ -10039,71 +10591,7 @@ var init_fonts = __esm({
10039
10591
  init_src4();
10040
10592
  init_src7();
10041
10593
  init_output();
10042
- init_invocation();
10043
- }
10044
- });
10045
-
10046
- // packages/cli/src/font-guidance.ts
10047
- import path32 from "node:path";
10048
- function fontsUnprovenRemediation(setDir) {
10049
- const set = setDir === void 0 ? void 0 : path32.resolve(setDir);
10050
- if (set !== void 0) {
10051
- try {
10052
- const needs = recordedFontNeeds(set);
10053
- if (needs.length > 0) {
10054
- const families = needs.map((n) => `"${n.family}"`).join(", ");
10055
- return `Run \`${tendrilCommand(`fonts resolve --set ${set}`)}\` \u2014 the recording declares ${families}; faces land in ${DEFAULT_FONT_CACHE}.`;
10056
- }
10057
- } catch {
10058
- }
10059
- }
10060
- const target = set ?? "<recording-dir>";
10061
- return `Run \`${tendrilCommand(`fonts resolve --set ${target}`)}\` (or \`${tendrilCommand('fonts resolve "<Family>" --weights 400 500 600')}\`) to populate the font cache \u2014 the cache read is ${DEFAULT_FONT_CACHE}.`;
10062
- }
10063
- function taskFontFamilies(setDir) {
10064
- try {
10065
- const families = recordedFontFamilies(setDir);
10066
- return families.length > 0 ? families : null;
10067
- } catch {
10068
- return null;
10069
- }
10070
- }
10071
- function unprovisionedFamilies(setDir, cacheDir) {
10072
- const declared = taskFontFamilies(setDir);
10073
- if (declared === null) return [];
10074
- const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
10075
- return declared.filter((f) => !provided.has(f.toLowerCase()));
10076
- }
10077
- function unprovisionedFaces(setDir, cacheDir) {
10078
- return [
10079
- ...unprovisionedFamilies(setDir, cacheDir),
10080
- ...missingWeights(setDir, cacheDir).map((g) => {
10081
- const missing = g.declared.filter((w) => !g.provided.includes(w));
10082
- return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
10083
- })
10084
- ];
10085
- }
10086
- function missingWeights(setDir, cacheDir) {
10087
- let needs;
10088
- try {
10089
- needs = recordedFontNeeds(setDir, { pairedOnly: true });
10090
- } catch {
10091
- return [];
10092
- }
10093
- const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
10094
- const gaps = [];
10095
- for (const n of needs) {
10096
- const have = provided.get(n.family.toLowerCase());
10097
- if (have === void 0) continue;
10098
- if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
10099
- }
10100
- return gaps;
10101
- }
10102
- var init_font_guidance = __esm({
10103
- "packages/cli/src/font-guidance.ts"() {
10104
- "use strict";
10105
- init_src4();
10106
- init_src7();
10594
+ init_font_guidance();
10107
10595
  init_invocation();
10108
10596
  }
10109
10597
  });
@@ -10119,7 +10607,10 @@ __export(verify_exports, {
10119
10607
  failureTally: () => failureTally,
10120
10608
  foldConfigStatus: () => foldConfigStatus,
10121
10609
  interactionCoverage: () => interactionCoverage,
10610
+ latticeCoverage: () => latticeCoverage,
10122
10611
  occlusionReport: () => occlusionReport,
10612
+ operabilityLine: () => operabilityLine,
10613
+ operabilityReport: () => operabilityReport,
10123
10614
  resolveComposition: () => resolveComposition,
10124
10615
  runVerify: () => runVerify
10125
10616
  });
@@ -10134,6 +10625,43 @@ function interactionCoverage(behaviors) {
10134
10625
  operability: interaction.length > 0 && interaction.every((b) => b.pass) ? "verified" : "unverified"
10135
10626
  };
10136
10627
  }
10628
+ function operabilityReport(input) {
10629
+ const { interactionChecks, interactionPassed, preludeChecks } = interactionCoverage(input.behaviors);
10630
+ const prelude = ` The ${preludeChecks} behavior check(s) that did run are page-level prelude style hygiene, which says nothing about whether the component works.`;
10631
+ if (interactionChecks === 0) {
10632
+ const evidence = input.interactionEvidence;
10633
+ if (evidence === void 0) {
10634
+ return {
10635
+ short: "nothing authored; recorded poses never derived",
10636
+ unavailable: `no interaction behaviours were authored for this component, and this run never derived the recording's interactive poses \u2014 nothing about focus, typing, or keyboard operation was measured, and nothing here rules out a static lookalike.${prelude}`
10637
+ };
10638
+ }
10639
+ if (evidence.length > 0) {
10640
+ return {
10641
+ short: "interactive poses recorded, none authored",
10642
+ unavailable: `the recording PROVES interactive poses (${evidence.join(
10643
+ ", "
10644
+ )}) and ZERO interaction behaviours were authored to cover them \u2014 nothing about focus, typing, or keyboard operation was measured; this is an instrument failure, not a working component.${prelude}`
10645
+ };
10646
+ }
10647
+ return { short: "no interactive poses recorded", unavailable: `${NO_INTERACTIVE_POSES}${prelude}` };
10648
+ }
10649
+ if (interactionPassed < interactionChecks) {
10650
+ const failed = interactionChecks - interactionPassed;
10651
+ return {
10652
+ checks: interactionChecks,
10653
+ passed: interactionPassed,
10654
+ short: `${failed} of ${interactionChecks} interaction check(s) failed`,
10655
+ unverified: `the interaction checks RAN and ${failed} of ${interactionChecks} failed (each named in the FAIL behavior rows) \u2014 this component WAS exercised and did not behave as the recording requires; unverified here means measured and wrong, not unmeasured.`
10656
+ };
10657
+ }
10658
+ return { checks: interactionChecks, passed: interactionPassed };
10659
+ }
10660
+ function operabilityLine(state) {
10661
+ if ("unavailable" in state) return `UNVERIFIED operability \u2014 ${state.unavailable}`;
10662
+ if ("unverified" in state) return `UNVERIFIED operability \u2014 ${state.unverified}`;
10663
+ return void 0;
10664
+ }
10137
10665
  function foldConfigStatus(s, failDemotions, substitutedFamilies) {
10138
10666
  const { exact: _exact, ...reported } = s;
10139
10667
  let status = tierOf(s, BARS2.cert);
@@ -10174,7 +10702,23 @@ function checkSummarySegments(input) {
10174
10702
  const composition = availability.unavailable !== void 0 ? `composition NOT CHECKED (${availability.short})` : `composition ${tally(input.structural)} structural, ${input.crops !== void 0 ? `${tally(input.crops)} crops` : "crops unavailable"} (roles: ${rolesSourceLabel(availability.roles)}${(availability.roles.narrowingAccepted ?? []).length > 0 ? `, ${(availability.roles.narrowingAccepted ?? []).length} narrowing(s) accepted: ${(availability.roles.narrowingAccepted ?? []).join(", ")}` : ""})`;
10175
10703
  const occ = occlusionReport(input.occlusion);
10176
10704
  const occlusion = "unavailable" in occ ? `occlusion not applicable (${NO_OVERLAY_DECLARED})` : `occlusion ${tally(input.occlusion)}`;
10177
- return ` \xB7 ${composition} \xB7 ${occlusion}`;
10705
+ const operability = "short" in input.operability ? `operability UNVERIFIED (${input.operability.short})` : `operability ${input.operability.passed}/${input.operability.checks} interaction`;
10706
+ return ` \xB7 ${operability} \xB7 ${composition} \xB7 ${occlusion}`;
10707
+ }
10708
+ function latticeCoverage(setManifest, scoredConfigs) {
10709
+ const lattice = setManifest.latticeNames?.length;
10710
+ const established = setManifest.variantScope === "component-set";
10711
+ const notRecorded = setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {};
10712
+ if (lattice === void 0) return { latticeConfigs: null, ...notRecorded };
10713
+ if (established) return { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - scoredConfigs), ...notRecorded };
10714
+ return {
10715
+ latticeConfigs: null,
10716
+ // The count is still worth reporting — it is just not a
10717
+ // denominator, and the name has to stop implying that it is.
10718
+ variantsPlanned: lattice,
10719
+ latticeUnestablished: setManifest.variantScope === "selection" ? "the recording was planned from a node SELECTION, not from a component set \u2014 the plan counted the nodes it was handed and could not see whether the set holds more variants, so the number of unrecorded poses is UNKNOWN, not zero" : "this set was planned before variant scope was recorded, so whether its lattice came from a component set or from a handed-in selection was never established \u2014 the number of unrecorded poses is unknown, not zero",
10720
+ ...notRecorded
10721
+ };
10178
10722
  }
10179
10723
  function occlusionReport(occlusion) {
10180
10724
  if (occlusion.length === 0) {
@@ -10241,7 +10785,10 @@ function taskFromManifest(opts, manifest, setDir) {
10241
10785
  task,
10242
10786
  unmapped,
10243
10787
  adapterOnly,
10244
- interactionEvidence: authored?.api.interactionEvidence ?? [],
10788
+ // Registry sets carry hand-declared behaviors and no derivation
10789
+ // runs, so their evidence is UNKNOWN, not empty: an empty list is
10790
+ // spent downstream as "the recording holds no interactive pose".
10791
+ interactionEvidence: authored?.api.interactionEvidence,
10245
10792
  unmappedInteractionEvidence: authored?.api.unmappedInteractionEvidence ?? []
10246
10793
  };
10247
10794
  }
@@ -10271,7 +10818,7 @@ async function runVerify(opts) {
10271
10818
  }
10272
10819
  let task;
10273
10820
  let unmapped = [];
10274
- let interactionEvidence = [];
10821
+ let interactionEvidence;
10275
10822
  let unmappedInteractionEvidence = [];
10276
10823
  let availability = ROLES_NOT_RESOLVED;
10277
10824
  if (opts.task !== void 0) {
@@ -10408,7 +10955,8 @@ async function runVerify(opts) {
10408
10955
  const certified = statuses.filter((s) => s.status === "certified").length;
10409
10956
  const occlusionFailures = occlusion.filter((o) => !o.pass);
10410
10957
  const coverage = interactionCoverage(behaviors);
10411
- const evidenceUnverified = interactionEvidence.length > 0 && coverage.interactionChecks === 0;
10958
+ const operability = operabilityReport({ behaviors, interactionEvidence });
10959
+ const evidenceUnverified = (interactionEvidence?.length ?? 0) > 0 && coverage.interactionChecks === 0;
10412
10960
  const okExceptDemotion = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
10413
10961
  const certBlockedByAbsentInk = opts.bar === "cert" ? absentInkDemoted : [];
10414
10962
  const ok = okExceptDemotion && certBlockedByAbsentInk.length === 0;
@@ -10432,12 +10980,7 @@ async function runVerify(opts) {
10432
10980
  // itself — not only in a long-gone plan output.
10433
10981
  ...(() => {
10434
10982
  try {
10435
- const setManifest = loadManifest(task.set);
10436
- const lattice = setManifest.latticeNames?.length;
10437
- return {
10438
- ...lattice !== void 0 ? { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - statuses.length) } : { latticeConfigs: null },
10439
- ...setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {}
10440
- };
10983
+ return latticeCoverage(loadManifest(task.set), statuses.length);
10441
10984
  } catch {
10442
10985
  return { latticeConfigs: null };
10443
10986
  }
@@ -10448,7 +10991,11 @@ async function runVerify(opts) {
10448
10991
  // read exactly like a verified one — measured, a statue and a real
10449
10992
  // control produced the same 6/6. The split is the honest form.
10450
10993
  ...coverage,
10451
- ...interactionEvidence.length > 0 ? { interactionEvidence } : {},
10994
+ ...interactionEvidence !== void 0 && interactionEvidence.length > 0 ? { interactionEvidence } : {},
10995
+ // The one-word verdict's CAUSE, in the channel that has no summary
10996
+ // line to carry it. `operability` above keeps its key and values;
10997
+ // this says which of its meanings the run actually produced.
10998
+ operabilityCheck: operability,
10452
10999
  // Poses the recording proves interactive that no operability
10453
11000
  // check covers — pixel-verified only. The first cold kit shipped
10454
11001
  // pressed/focus this way with nothing in the report saying so.
@@ -10509,11 +11056,10 @@ async function runVerify(opts) {
10509
11056
  process.stdout.write(`UNAVAILABLE composition crops \u2014 ${regionsOut.unavailable}
10510
11057
  `);
10511
11058
  }
10512
- const ic = interactionCoverage(behaviors);
10513
11059
  process.stdout.write(
10514
11060
  `
10515
11061
  ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuses.length} \u2265 pass bar \xB7 behaviors ${behaviors.length - behaviorFailures.length}/${behaviors.length}${checkSummarySegments(
10516
- { availability, structural, crops, occlusion }
11062
+ { availability, structural, crops, occlusion, operability }
10517
11063
  )}
10518
11064
  `
10519
11065
  );
@@ -10542,14 +11088,13 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
10542
11088
  }
10543
11089
  if (evidenceUnverified) {
10544
11090
  process.stdout.write(
10545
- `FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component. Mappable today: ${[...INTERACTION_STATES, ...BOOLEAN_STATES].join("/")} \u2014 on an axis named State (checked/selected state axes are recorded as evidence but not yet authorable \u2014 widening is on the roadmap; this is not fixable from component code).
10546
- `
10547
- );
10548
- } else if (ic.interactionChecks === 0) {
10549
- process.stdout.write(
10550
- `UNVERIFIED operability \u2014 0 interaction checks were authored for this component; the behaviors above are page-level style hygiene. Nothing here says the component responds to a user.
11091
+ `FAIL interaction-evidence \u2014 the recording proves interactive poses (${(interactionEvidence ?? []).join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component. Mappable today: ${[...INTERACTION_STATES, ...BOOLEAN_STATES].join("/")} \u2014 on an axis named State (checked/selected state axes are recorded as evidence but not yet authorable \u2014 widening is on the roadmap; this is not fixable from component code).
10551
11092
  `
10552
11093
  );
11094
+ } else {
11095
+ const line = operabilityLine(operability);
11096
+ if (line !== void 0) process.stdout.write(`${line}
11097
+ `);
10553
11098
  }
10554
11099
  if (unmappedInteractionEvidence.length > 0) {
10555
11100
  process.stdout.write(
@@ -10624,7 +11169,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
10624
11169
  process.exitCode = ExitCode.VerificationFailed;
10625
11170
  }
10626
11171
  }
10627
- var BARS2, NO_ROLE_MANIFEST, ROLES_NOT_RESOLVED, UNSTAMPED_ROLES, NO_OVERLAY_DECLARED;
11172
+ var BARS2, NO_INTERACTIVE_POSES, NO_ROLE_MANIFEST, ROLES_NOT_RESOLVED, UNSTAMPED_ROLES, NO_OVERLAY_DECLARED;
10628
11173
  var init_verify = __esm({
10629
11174
  "packages/cli/src/commands/verify.ts"() {
10630
11175
  "use strict";
@@ -10641,6 +11186,7 @@ var init_verify = __esm({
10641
11186
  pass: { sim: 0.95, ink: 0.95 },
10642
11187
  cert: { sim: 0.97, ink: 0.95 }
10643
11188
  };
11189
+ NO_INTERACTIVE_POSES = "no interactive poses recorded \u2014 nothing about focus, typing, or keyboard operation was measured; a component that is not a control passes this field.";
10644
11190
  NO_ROLE_MANIFEST = Object.freeze({
10645
11191
  short: "no role manifest",
10646
11192
  unavailable: "the recording set declares no role manifest \u2014 composition (structural + crop) was NEVER CHECKED; nothing here says the main renders the SHIPPED part modules rather than a pixel-identical re-implementation"
@@ -10666,7 +11212,7 @@ function resolveEngineTask(opts, callerCwd) {
10666
11212
  const asPath = path34.resolve(callerCwd, opts.taskOrSet);
10667
11213
  const isSet = existsSync27(path34.join(asPath, "recording-set.json"));
10668
11214
  const registry = TASKS[opts.taskOrSet];
10669
- if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, ref: opts.taskOrSet, disclosures: [], interactionEvidence: [] };
11215
+ if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, ref: opts.taskOrSet, disclosures: [], interactionEvidence: void 0 };
10670
11216
  if (isSet) {
10671
11217
  try {
10672
11218
  const authored = authorTaskFromSet(asPath);
@@ -10735,6 +11281,7 @@ ${notRecorded}` : "";
10735
11281
  === TASK PAYLOAD (recorded truth, verbatim) ===
10736
11282
  ${segments}`;
10737
11283
  const payloadFile = path34.resolve(callerCwd, opts.out ?? `tendril-out/${name}-brief.md`);
11284
+ const candidateDirSuggestion = path34.resolve(callerCwd, `tendril-out/${name}-candidate`);
10738
11285
  mkdirSync8(path34.dirname(payloadFile), { recursive: true });
10739
11286
  writeFileSync12(payloadFile, payload);
10740
11287
  emitData(
@@ -10763,8 +11310,16 @@ ${segments}`;
10763
11310
  protocol: [
10764
11311
  "Settle the proposer model (see modelSelection \u2014 ask the user when one is present), then:",
10765
11312
  `Read ${payloadFile} completely \u2014 it is the system brief plus every recorded config's emission, box, and assets.`,
10766
- `Write the complete bundle files (${task.entry}, styles.css, optional tokens.css) into a candidate directory.`,
10767
- `Run \`${tendrilCommand(`engine score ${ref} <candidateDir> --bar ${opts.bar} --model ${opts.model ?? "<declared-model>"} --json`)}\` \u2014 the CLI is the sole judge, and it refuses to score an undeclared model.`,
11313
+ `Write the complete bundle files (${task.entry}, styles.css, optional tokens.css) into ${quoteArg(candidateDirSuggestion)} (create it with a bare \`mkdir -p\`), or into another directory if you were handed one.`,
11314
+ // Both paths quoted, and the candidate directory NAMED rather
11315
+ // than left as `<candidateDir>`: run 18 worked in a folder
11316
+ // called "Test 2", where the unquoted form split into two
11317
+ // arguments, and a placeholder is the other half of the same
11318
+ // defect — a command the reader has to finish is one they can
11319
+ // finish wrongly.
11320
+ `Run \`${tendrilCommand(
11321
+ `engine score ${quoteArg(ref)} ${quoteArg(candidateDirSuggestion)} --bar ${opts.bar} --model ${opts.model ?? "<declared-model>"} --json`
11322
+ )}\` \u2014 the CLI is the sole judge, and it refuses to score an undeclared model. If you wrote the bundle elsewhere, pass that directory instead.`,
10768
11323
  "Apply the returned feedback and re-score. Stop when all checks pass or two consecutive scores fail to improve.",
10769
11324
  "Never edit recording sets; never claim scores yourself \u2014 only the score command's output counts."
10770
11325
  ]
@@ -10880,12 +11435,29 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
10880
11435
  substitutedFamilies
10881
11436
  });
10882
11437
  appendScoreHistory(candidateDir, { event: "round-scored", bar: opts.bar, pass: obj[0], total, certified: certifiedReps.length, floor: obj[1], mean: obj[2] });
11438
+ const coverage = interactionCoverage(behaviors);
11439
+ const operability = operabilityReport({ behaviors, interactionEvidence });
11440
+ const evidenceUnverified = (interactionEvidence?.length ?? 0) > 0 && coverage.interactionChecks === 0;
10883
11441
  emitData(
10884
11442
  opts,
10885
11443
  {
10886
11444
  task: name,
10887
11445
  bar: opts.bar,
10888
- objective: { passCount: obj[0], total, floor: obj[1], mean: obj[2] },
11446
+ // `total` is pixel configs PLUS behaviour checks, and nothing said
11447
+ // so: run 18's operator relayed "6/16" for two rounds without
11448
+ // knowing it meant ZERO configs passing — materially worse than it
11449
+ // sounds, and the number a human decides whether to keep paying on.
11450
+ objective: {
11451
+ passCount: obj[0],
11452
+ total,
11453
+ pixelConfigs: scores.length,
11454
+ pixelConfigsPassing: scores.filter((s) => s.pass).length,
11455
+ behaviorChecks: behaviors.length,
11456
+ behaviorChecksPassing: behaviors.filter((b) => b.pass).length,
11457
+ note: `passCount/total counts ${scores.length} pixel config(s) AND ${behaviors.length} behaviour check(s) together \u2014 read pixelConfigsPassing to see how many CONFIGS pass`,
11458
+ floor: obj[1],
11459
+ mean: obj[2]
11460
+ },
10889
11461
  scores,
10890
11462
  behaviors,
10891
11463
  feedback,
@@ -10893,7 +11465,14 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
10893
11465
  // The loop's oracle must say what verify says: without this an
10894
11466
  // MCP-driven agent literally cannot tell a hollow verdict (all
10895
11467
  // behaviour passes are prelude hygiene) from a verified one.
10896
- coverage: interactionCoverage(behaviors),
11468
+ //
11469
+ // `operability` alone is ONE WORD covering four different states,
11470
+ // and this is the channel where run 15's miss happened: the agent
11471
+ // driving the loop reads this JSON and nothing else. It gets the
11472
+ // same sentence verify's report carries, from the same function —
11473
+ // fixing verify's channel and not this one would have left the
11474
+ // reader who actually acts on it exactly where they were.
11475
+ coverage: { ...coverage, operabilityCheck: operability },
10897
11476
  parityCoverage,
10898
11477
  evidenceDir,
10899
11478
  bundleManifest: emitted.written[0],
@@ -10904,7 +11483,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
10904
11483
  // Run 11: generators read allPass:true and reported success on
10905
11484
  // bundles verify then FAILED on the interaction-evidence gate —
10906
11485
  // the oracle must say what verify will say, including this.
10907
- ...interactionEvidence.length > 0 && interactionCoverage(behaviors).interactionChecks === 0 ? { interactionEvidenceUnverified: true, verifyWillFail: "interaction-evidence \u2014 the recording proves interactive poses none of the authored behaviors cover; not fixable from component code; REPORT it, do not iterate on it" } : {},
11486
+ ...evidenceUnverified ? { interactionEvidenceUnverified: true, verifyWillFail: "interaction-evidence \u2014 the recording proves interactive poses none of the authored behaviors cover; not fixable from component code; REPORT it, do not iterate on it" } : {},
10908
11487
  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" }
10909
11488
  },
10910
11489
  () => {
@@ -10915,12 +11494,12 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
10915
11494
  process.stdout.write(`
10916
11495
  ${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${obj[1].toFixed(3)} \xB7 mean=${obj[2].toFixed(3)} \xB7 evidence: ${evidenceDir}
10917
11496
  `);
10918
- const ic = interactionCoverage(behaviors);
10919
- if (interactionEvidence.length > 0 && ic.interactionChecks === 0) {
10920
- process.stdout.write(`INSTRUMENT GAP \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and zero interaction behaviors were authored; verify WILL fail this bundle (interaction-evidence). Not fixable from component code \u2014 report it, do not iterate on it.
11497
+ if (evidenceUnverified) {
11498
+ process.stdout.write(`INSTRUMENT GAP \u2014 the recording proves interactive poses (${(interactionEvidence ?? []).join(", ")}) and zero interaction behaviors were authored; verify WILL fail this bundle (interaction-evidence). Not fixable from component code \u2014 report it, do not iterate on it.
10921
11499
  `);
10922
- } else if (ic.interactionChecks === 0) {
10923
- process.stdout.write(`UNVERIFIED operability \u2014 0 interaction checks; the behaviour passes above are page-level style hygiene
11500
+ } else {
11501
+ const line = operabilityLine(operability);
11502
+ if (line !== void 0) process.stdout.write(`${line}
10924
11503
  `);
10925
11504
  }
10926
11505
  }
@@ -11171,7 +11750,7 @@ var init_server = __esm({
11171
11750
  TOOLS = [
11172
11751
  {
11173
11752
  name: "tendril_record_plan",
11174
- description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback. Recording cost is stated in figmaCallEstimate, never asked about \u2014 proceed with what the user provided.",
11753
+ description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback. It may also carry `interactionStatesToConfirm` \u2014 the recording holds no hover/focus/pressed state, so nothing shows how the component behaves when someone uses it: say its `statement` and `designFix` in that SAME one message (the fix is a Figma variant, not code). It is a disclosure, not a gate \u2014 no answer is required and recording proceeds regardless. The output also carries `feasibilityCheck`: the call arithmetic for this queue plus the free `whoami` check that turns it into a verdict \u2014 complete that handshake BEFORE the first recording call, and surface the verdict to the user when the set does not fit their daily allowance.",
11175
11754
  schema: z12.object({
11176
11755
  setDir: str("recording set directory to create/resume"),
11177
11756
  component: str("component/system name"),
@@ -13012,11 +13591,19 @@ function buildProgram() {
13012
13591
  await runFontsResolve2({ ...flags, family, weights: local["weights"].map(Number), cacheDir });
13013
13592
  }
13014
13593
  });
13015
- fonts.command("add").description("Register a font file you already license, so a kit using a proprietary face can be scored.").argument("<family>", 'font family name as the design uses it, e.g. "S\xF6hne"').argument("<weight>", "numeric weight, e.g. 400").argument("<file>", "path to a .woff2/.woff/.ttf/.otf you are licensed to use").option("--cache <dir>", "cache directory (default: the per-user font cache)").action(async (family, weight, file, _o, cmd) => {
13594
+ fonts.command("add").description("Register a font file you already license, so a kit using a proprietary face can be scored.").argument("<family>", 'font family name as the design uses it, e.g. "S\xF6hne"').argument("<weight>", "numeric weight, e.g. 400").argument("<file>", "path to a .woff2/.woff/.ttf/.otf you are licensed to use").option("--cache <dir>", "cache directory (default: the per-user font cache)").option("--set <dir>", "recording set to verify the family spelling against \u2014 a face cached under a name the mount never matches leaves scoring refusing").option("--face <index>", "which face to lift out of a .ttc collection (the error lists them)").action(async (family, weight, file, _o, cmd) => {
13016
13595
  const flags = globalFlags(cmd.parent.parent);
13017
13596
  const local = cmd.opts();
13018
13597
  const { runFontsAdd: runFontsAdd2 } = await Promise.resolve().then(() => (init_fonts(), fonts_exports));
13019
- runFontsAdd2({ ...flags, family, weight: Number(weight), file, cacheDir: local["cache"] ?? DEFAULT_FONT_CACHE });
13598
+ runFontsAdd2({
13599
+ ...flags,
13600
+ family,
13601
+ weight: Number(weight),
13602
+ file,
13603
+ cacheDir: local["cache"] ?? DEFAULT_FONT_CACHE,
13604
+ ...local["set"] !== void 0 ? { set: local["set"] } : {},
13605
+ ...local["face"] !== void 0 ? { face: Number(local["face"]) } : {}
13606
+ });
13020
13607
  });
13021
13608
  fonts.command("status").option("--lock <file>", "hash lock to verify against (e.g. packages/verify/fixtures/fonts.lock.json)").option("--cache <dir>", "cache directory (default: the per-user font cache)").action(async (_o, cmd) => {
13022
13609
  const flags = globalFlags(cmd.parent.parent);