@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/SKILL.md +56 -8
- package/dist/tendril-mcp.js +1 -1
- package/dist/tendril.js +736 -149
- package/package.json +1 -1
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}
|
|
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 .
|
|
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
|
-
|
|
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}${
|
|
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"]).
|
|
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
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
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
|
-
|
|
4092
|
-
}
|
|
4093
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
4465
|
-
for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss),
|
|
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
|
-
|
|
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, {
|
|
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
|
|
7424
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 } : {
|
|
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: "
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
8787
|
-
if (
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
10767
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
10919
|
-
|
|
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
|
|
10923
|
-
|
|
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.
|
|
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({
|
|
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);
|