@tendrilapp/cli 0.1.25 → 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 +9 -5
- package/dist/tendril.js +434 -113
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -137,11 +137,15 @@ Batch runs (several components in one session):
|
|
|
137
137
|
name what kind of control it is.
|
|
138
138
|
FEASIBILITY, before the first recording call: the plan output
|
|
139
139
|
states the call arithmetic for this queue (poses × calls per pose).
|
|
140
|
-
Turn it into a verdict with one free check —
|
|
141
|
-
`whoami` tool,
|
|
142
|
-
Figma's tool-call limits
|
|
143
|
-
daily allowance that seat carries. Never quote an
|
|
144
|
-
memory; use the one whoami reports.
|
|
140
|
+
Turn it into a verdict with one free check — IF your Figma server
|
|
141
|
+
exposes a `whoami` tool, call it: it names the seat and plan and is
|
|
142
|
+
exempt from Figma's tool-call limits. Then divide the queue's call
|
|
143
|
+
count by the daily allowance that seat carries. Never quote an
|
|
144
|
+
allowance from memory; use the one whoami reports. Not every
|
|
145
|
+
transport has it — the remote Figma MCP server does, the local Dev
|
|
146
|
+
Mode server does not — so if yours does not, say the allowance is
|
|
147
|
+
UNKNOWN and show the plan's own hint as the unverified figure it is
|
|
148
|
+
marked as, rather than presenting a guess as a checked number. If the set fits inside one
|
|
145
149
|
day, record and say nothing about cost — a set that comfortably
|
|
146
150
|
fits is not worth the user's attention. If it does NOT fit, stop
|
|
147
151
|
and tell the user in plain numbers before recording anything:
|
package/dist/tendril.js
CHANGED
|
@@ -1058,6 +1058,7 @@ function planSet(setDir, component, symbols, opts = {}) {
|
|
|
1058
1058
|
version: 1,
|
|
1059
1059
|
component,
|
|
1060
1060
|
...opts.sourceFrames !== void 0 ? { sourceFrames: opts.sourceFrames } : {},
|
|
1061
|
+
...opts.variantScope !== void 0 ? { variantScope: opts.variantScope } : {},
|
|
1061
1062
|
...opts.defaults !== void 0 && Object.keys(opts.defaults).length > 0 ? { defaults: opts.defaults } : {},
|
|
1062
1063
|
...(() => {
|
|
1063
1064
|
const variants = symbols.map((s) => s.name).filter((n) => n.includes("="));
|
|
@@ -1184,6 +1185,31 @@ var init_session = __esm({
|
|
|
1184
1185
|
* domains: the API must cover the lattice even where only a subset
|
|
1185
1186
|
* is recorded. */
|
|
1186
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(),
|
|
1187
1213
|
/** Which planning mode produced this queue. Absent = planned before
|
|
1188
1214
|
* the full-matrix default (i.e. sampled) — resume uses this to
|
|
1189
1215
|
* top-up rather than silently perpetuating a sampled queue. */
|
|
@@ -1894,6 +1920,10 @@ function tendrilInvocation() {
|
|
|
1894
1920
|
function tendrilCommand(args) {
|
|
1895
1921
|
return `${tendrilInvocation()} ${args}`;
|
|
1896
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
|
+
}
|
|
1897
1927
|
var NPX_INVOCATION, cached;
|
|
1898
1928
|
var init_invocation = __esm({
|
|
1899
1929
|
"packages/cli/src/invocation.ts"() {
|
|
@@ -3450,6 +3480,112 @@ var init_paths = __esm({
|
|
|
3450
3480
|
}
|
|
3451
3481
|
});
|
|
3452
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
|
+
|
|
3453
3589
|
// packages/verify/src/font-resolve.ts
|
|
3454
3590
|
import { createHash } from "node:crypto";
|
|
3455
3591
|
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
@@ -3538,18 +3674,33 @@ async function resolveFonts(family, weights, cacheDir = DEFAULT_FONT_CACHE) {
|
|
|
3538
3674
|
`);
|
|
3539
3675
|
return { resolved, failures };
|
|
3540
3676
|
}
|
|
3541
|
-
function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE) {
|
|
3677
|
+
function addLocalFont(family, weight, filePath, cacheDir = DEFAULT_FONT_CACHE, faceIndex) {
|
|
3542
3678
|
const src = path10.resolve(filePath);
|
|
3543
3679
|
if (!existsSync6(src)) throw new Error(`font file not found: ${src}`);
|
|
3544
3680
|
const ext = path10.extname(src).toLowerCase();
|
|
3545
|
-
if (![".woff2", ".woff", ".ttf", ".otf"].includes(ext)) {
|
|
3546
|
-
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`);
|
|
3547
3683
|
}
|
|
3548
|
-
|
|
3684
|
+
let bytes = new Uint8Array(readFileSync3(src));
|
|
3549
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
|
+
}
|
|
3550
3701
|
mkdirSync2(cacheDir, { recursive: true });
|
|
3551
3702
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
3552
|
-
const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${
|
|
3703
|
+
const file = path10.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${storedExt}`);
|
|
3553
3704
|
writeFileSync3(file, bytes);
|
|
3554
3705
|
const face = { family, weight, source: `local:${path10.basename(src)}`, sha256, file, license: "unknown" };
|
|
3555
3706
|
const mPath = path10.join(cacheDir, "manifest.json");
|
|
@@ -3639,6 +3790,7 @@ var DEFAULT_FONT_CACHE, UA, FONT_LICENSES, GOOGLE_LICENSE_IDS, GOOGLE_FONT_FILE_
|
|
|
3639
3790
|
var init_font_resolve = __esm({
|
|
3640
3791
|
"packages/verify/src/font-resolve.ts"() {
|
|
3641
3792
|
"use strict";
|
|
3793
|
+
init_font_collection();
|
|
3642
3794
|
DEFAULT_FONT_CACHE = fontCacheDir();
|
|
3643
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";
|
|
3644
3796
|
FONT_LICENSES = ["OFL-1.1", "Apache-2.0", "UFL-1.0", "proprietary", "unknown"];
|
|
@@ -4157,15 +4309,30 @@ async function runSteps(page, spec, renderPose) {
|
|
|
4157
4309
|
const verdict = await page.evaluate(
|
|
4158
4310
|
`(() => {
|
|
4159
4311
|
const needle = ${JSON.stringify(step.assertTextVisible)};
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
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) => {
|
|
4164
4318
|
const r = el.getBoundingClientRect();
|
|
4165
4319
|
const cs = getComputedStyle(el);
|
|
4166
|
-
|
|
4167
|
-
}
|
|
4168
|
-
|
|
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)';
|
|
4169
4336
|
})()`
|
|
4170
4337
|
);
|
|
4171
4338
|
if (verdict !== true) return { id: spec.id, pass: false, detail: `sentinel "${step.assertTextVisible}": ${String(verdict)}` };
|
|
@@ -4430,7 +4597,7 @@ function definedVars(tokensCss) {
|
|
|
4430
4597
|
for (const m of tokensCss.matchAll(/(--[\w-]+)\s*:/g)) names.add(m[1]);
|
|
4431
4598
|
return names;
|
|
4432
4599
|
}
|
|
4433
|
-
function
|
|
4600
|
+
function recordedTokenMapState(setDir, reps) {
|
|
4434
4601
|
const readMap = (file) => {
|
|
4435
4602
|
if (!existsSync10(file)) return void 0;
|
|
4436
4603
|
try {
|
|
@@ -4441,15 +4608,15 @@ function recordedTokenMapEmpty(setDir, reps) {
|
|
|
4441
4608
|
}
|
|
4442
4609
|
};
|
|
4443
4610
|
const setLevel = readMap(path15.join(setDir, "get_variable_defs.json"));
|
|
4444
|
-
if (setLevel !== void 0) return Object.keys(setLevel).length === 0;
|
|
4611
|
+
if (setLevel !== void 0) return Object.keys(setLevel).length === 0 ? "empty" : "populated";
|
|
4445
4612
|
let recorded = false;
|
|
4446
4613
|
for (const rep of reps) {
|
|
4447
4614
|
const m = readMap(path15.join(setDir, rep, "get_variable_defs.json"));
|
|
4448
4615
|
if (m === void 0) continue;
|
|
4449
4616
|
recorded = true;
|
|
4450
|
-
if (Object.keys(m).length > 0) return
|
|
4617
|
+
if (Object.keys(m).length > 0) return "populated";
|
|
4451
4618
|
}
|
|
4452
|
-
return recorded;
|
|
4619
|
+
return recorded ? "empty" : "never-recorded";
|
|
4453
4620
|
}
|
|
4454
4621
|
function scannable(css) {
|
|
4455
4622
|
const blank = (m) => m.replace(/[^\n]/g, " ");
|
|
@@ -4536,10 +4703,17 @@ async function checkBundleQuality(bundleDir, entry, set, fontManifest) {
|
|
|
4536
4703
|
}
|
|
4537
4704
|
}
|
|
4538
4705
|
if (css !== "") {
|
|
4539
|
-
const
|
|
4540
|
-
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) {
|
|
4541
4708
|
findings.push({ kind: "token-lint", file: "styles.css", line: v.line, message: v.message });
|
|
4542
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
|
+
}
|
|
4543
4717
|
}
|
|
4544
4718
|
return { findings, tokensAbsent: tokensCss === void 0 && !/var\(\s*--/.test(css) };
|
|
4545
4719
|
}
|
|
@@ -5519,6 +5693,7 @@ var init_src4 = __esm({
|
|
|
5519
5693
|
init_tasks();
|
|
5520
5694
|
init_prelude();
|
|
5521
5695
|
init_mount_limits();
|
|
5696
|
+
init_font_collection();
|
|
5522
5697
|
init_font_faces();
|
|
5523
5698
|
init_font_resolve();
|
|
5524
5699
|
init_paths();
|
|
@@ -5770,6 +5945,15 @@ import { spawnSync } from "node:child_process";
|
|
|
5770
5945
|
import { existsSync as existsSync18, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
|
|
5771
5946
|
import os4 from "node:os";
|
|
5772
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
|
+
}
|
|
5773
5957
|
async function runDoctorChecks(options) {
|
|
5774
5958
|
const checks = [];
|
|
5775
5959
|
const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
|
|
@@ -5778,19 +5962,26 @@ async function runDoctorChecks(options) {
|
|
|
5778
5962
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
5779
5963
|
});
|
|
5780
5964
|
try {
|
|
5781
|
-
const info = await client.initialize();
|
|
5782
|
-
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);
|
|
5783
5967
|
checks.push({
|
|
5784
5968
|
name: "figma-desktop-mcp",
|
|
5785
5969
|
ok: true,
|
|
5786
|
-
|
|
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.`
|
|
5787
5978
|
});
|
|
5788
5979
|
} catch (err) {
|
|
5789
5980
|
checks.push({
|
|
5790
5981
|
name: "figma-desktop-mcp",
|
|
5791
5982
|
ok: false,
|
|
5792
5983
|
detail: err instanceof Error ? err.message : String(err),
|
|
5793
|
-
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."
|
|
5794
5985
|
});
|
|
5795
5986
|
}
|
|
5796
5987
|
const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
|
|
@@ -5929,7 +6120,7 @@ async function runDoctor(flags) {
|
|
|
5929
6120
|
});
|
|
5930
6121
|
if (!report.ok) process.exit(1);
|
|
5931
6122
|
}
|
|
5932
|
-
var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION;
|
|
6123
|
+
var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION, MCP_PROBE_DEADLINE_MS, McpProbeTimeout;
|
|
5933
6124
|
var init_doctor = __esm({
|
|
5934
6125
|
"packages/cli/src/commands/doctor.ts"() {
|
|
5935
6126
|
"use strict";
|
|
@@ -5957,6 +6148,15 @@ var init_doctor = __esm({
|
|
|
5957
6148
|
exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
|
|
5958
6149
|
examples: ["tendril doctor", "tendril doctor --json"]
|
|
5959
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
|
+
};
|
|
5960
6160
|
}
|
|
5961
6161
|
});
|
|
5962
6162
|
|
|
@@ -7457,6 +7657,7 @@ function runRecordPlan(opts) {
|
|
|
7457
7657
|
}
|
|
7458
7658
|
}
|
|
7459
7659
|
const setNames = [...new Set(symbols.map((s) => s.setName).filter((n) => n !== void 0))];
|
|
7660
|
+
const variantScope = setNames.length > 0 ? "component-set" : "selection";
|
|
7460
7661
|
if (opts.componentSet !== void 0) {
|
|
7461
7662
|
const filtered = symbols.filter((s) => s.setName === opts.componentSet);
|
|
7462
7663
|
if (filtered.length === 0) {
|
|
@@ -7514,7 +7715,11 @@ function runRecordPlan(opts) {
|
|
|
7514
7715
|
if (metadataTruncated) {
|
|
7515
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.");
|
|
7516
7717
|
}
|
|
7517
|
-
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
|
+
});
|
|
7518
7723
|
const defaultReports = reportAxisDefaults(symbols, Object.keys(defaults).length > 0 ? defaults : void 0);
|
|
7519
7724
|
const toConfirm = resumed ? [] : defaultReports.filter((r) => (r.rule === "non-interaction" || r.rule === "frequency") && r.domain.length > 1);
|
|
7520
7725
|
const interactionStatesToConfirm = recordsInteractionState(defaultReports) ? void 0 : interactionDisclosure(manifest.component, defaultReports);
|
|
@@ -7532,7 +7737,7 @@ function runRecordPlan(opts) {
|
|
|
7532
7737
|
callsWorstCase: posesRemaining * FIGMA_CALLS_PER_REP_WORST,
|
|
7533
7738
|
posesToRecord: posesRemaining,
|
|
7534
7739
|
callsPerPose: FIGMA_CALLS_PER_REP,
|
|
7535
|
-
instruction: "BEFORE the first recording call,
|
|
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.",
|
|
7536
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.",
|
|
7537
7742
|
verdictTemplate: `${callSentence}. Your <seat> seat on <plan> allows <allowance>/day. This set needs about <days> day(s) at that rate.`,
|
|
7538
7743
|
options: [
|
|
@@ -7548,7 +7753,7 @@ function runRecordPlan(opts) {
|
|
|
7548
7753
|
// a CLI flag and the MCP surface has no parameter for it.
|
|
7549
7754
|
decidedBy: "HUMAN ONLY \u2014 offer it, never choose it, and you cannot run it",
|
|
7550
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.",
|
|
7551
|
-
userRuns: [`rm ${path26.join(opts.setDir, "recording-set.json")}`, `${tendrilCommand(`record plan --set ${opts.setDir} --component ${manifest.component}`)} --sample`]
|
|
7756
|
+
userRuns: [`rm ${quoteArg(path26.join(opts.setDir, "recording-set.json"))}`, `${tendrilCommand(`record plan --set ${quoteArg(opts.setDir)} --component ${quoteArg(manifest.component)}`)} --sample`]
|
|
7552
7757
|
},
|
|
7553
7758
|
{
|
|
7554
7759
|
id: "larger-allowance",
|
|
@@ -7569,6 +7774,13 @@ function runRecordPlan(opts) {
|
|
|
7569
7774
|
// the one check that catches parse/transfer losses this pipeline
|
|
7570
7775
|
// cannot detect from the envelope alone.
|
|
7571
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 } : {},
|
|
7572
7784
|
planMode: manifest.planMode ?? "sample",
|
|
7573
7785
|
...metadataTruncated ? { metadataTruncated: true } : {},
|
|
7574
7786
|
...toppedUp !== void 0 ? { toppedUp } : {},
|
|
@@ -7673,7 +7885,19 @@ function nextPayload(setDir) {
|
|
|
7673
7885
|
const frameNode = Object.keys(manifest.sourceFrames ?? {})[0] ?? manifest.reps[0]?.nodeId ?? "";
|
|
7674
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 };
|
|
7675
7887
|
}
|
|
7676
|
-
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
|
+
};
|
|
7677
7901
|
}
|
|
7678
7902
|
function runRecordNext(opts) {
|
|
7679
7903
|
const payload = nextPayload(opts.setDir);
|
|
@@ -8081,7 +8305,7 @@ function runRecordFinish(opts) {
|
|
|
8081
8305
|
`);
|
|
8082
8306
|
}
|
|
8083
8307
|
}
|
|
8084
|
-
var FIGMA_CALLS_PER_REP, FIGMA_CALLS_PER_REP_WORST, MULTI_DAY_CALL_THRESHOLD, LIMIT_HINT, stateTokens, MAX_SPOKEN_VALUES, andList, 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;
|
|
8085
8309
|
var init_record = __esm({
|
|
8086
8310
|
"packages/cli/src/commands/record.ts"() {
|
|
8087
8311
|
"use strict";
|
|
@@ -8094,6 +8318,7 @@ var init_record = __esm({
|
|
|
8094
8318
|
FIGMA_CALLS_PER_REP = 3;
|
|
8095
8319
|
FIGMA_CALLS_PER_REP_WORST = 4;
|
|
8096
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.";
|
|
8097
8322
|
LIMIT_HINT = {
|
|
8098
8323
|
status: "UNVERIFIED \u2014 recorded 2026-08-14 from Figma's published documentation and one field report; Figma changes these at will.",
|
|
8099
8324
|
use: "Use ONLY if whoami states no allowance, and say it is unverified when you show it. The allowance whoami reports always wins.",
|
|
@@ -8692,7 +8917,13 @@ function authorComponentApi(opts) {
|
|
|
8692
8917
|
const provided = opts.fonts ?? [];
|
|
8693
8918
|
const recorded = opts.recordedFonts ?? [];
|
|
8694
8919
|
const unprovided = recorded.filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
|
|
8695
|
-
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` : ""}. ` : "");
|
|
8696
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. ` : "";
|
|
8697
8928
|
const systemApi = `Prescribed API (the harness mounts exactly this; deviation scores 0):
|
|
8698
8929
|
export function ${componentIdent}(props: {
|
|
@@ -8992,8 +9223,8 @@ function recordedTextSlots(setDir, repSlugs) {
|
|
|
8992
9223
|
}
|
|
8993
9224
|
function authorTaskFromSet(setDir, opts = {}) {
|
|
8994
9225
|
if (opts.fonts === void 0) {
|
|
8995
|
-
const
|
|
8996
|
-
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] })) };
|
|
8997
9228
|
}
|
|
8998
9229
|
const manifest = loadManifest(setDir);
|
|
8999
9230
|
const poses = [];
|
|
@@ -9025,6 +9256,15 @@ function authorTaskFromSet(setDir, opts = {}) {
|
|
|
9025
9256
|
poses,
|
|
9026
9257
|
...latticeNames !== void 0 ? { latticeNames } : {},
|
|
9027
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
|
+
})(),
|
|
9028
9268
|
...recordedFonts.length > 0 ? { recordedFonts } : {},
|
|
9029
9269
|
...Object.keys(defaults).length > 0 ? { defaults } : {},
|
|
9030
9270
|
...textSlots.length > 0 ? { textSlots } : {},
|
|
@@ -9522,7 +9762,9 @@ function countLatticeSymbols(setDir) {
|
|
|
9522
9762
|
const manifestFile = path30.join(setDir, "recording-set.json");
|
|
9523
9763
|
if (existsSync24(manifestFile)) {
|
|
9524
9764
|
try {
|
|
9525
|
-
const
|
|
9765
|
+
const stored = JSON.parse(readFileSync20(manifestFile, "utf8"));
|
|
9766
|
+
if (stored.variantScope !== "component-set") return null;
|
|
9767
|
+
const lattice = stored.latticeNames;
|
|
9526
9768
|
if (lattice !== void 0 && lattice.length > 0) return lattice.length;
|
|
9527
9769
|
} catch {
|
|
9528
9770
|
}
|
|
@@ -10091,10 +10333,76 @@ var init_src7 = __esm({
|
|
|
10091
10333
|
}
|
|
10092
10334
|
});
|
|
10093
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
|
+
|
|
10094
10401
|
// packages/cli/src/commands/fonts.ts
|
|
10095
10402
|
var fonts_exports = {};
|
|
10096
10403
|
__export(fonts_exports, {
|
|
10097
10404
|
DEFAULT_FONT_CACHE: () => DEFAULT_FONT_CACHE,
|
|
10405
|
+
familyMismatch: () => familyMismatch,
|
|
10098
10406
|
runFontsAdd: () => runFontsAdd,
|
|
10099
10407
|
runFontsRequired: () => runFontsRequired,
|
|
10100
10408
|
runFontsResolve: () => runFontsResolve,
|
|
@@ -10102,7 +10410,7 @@ __export(fonts_exports, {
|
|
|
10102
10410
|
runFontsStatus: () => runFontsStatus
|
|
10103
10411
|
});
|
|
10104
10412
|
import { existsSync as existsSync25, readFileSync as readFileSync21 } from "node:fs";
|
|
10105
|
-
import
|
|
10413
|
+
import path32 from "node:path";
|
|
10106
10414
|
async function runFontsResolve(opts) {
|
|
10107
10415
|
const result = await resolveFonts(opts.family, opts.weights, opts.cacheDir);
|
|
10108
10416
|
emitData(opts, result, () => {
|
|
@@ -10117,7 +10425,7 @@ async function runFontsResolve(opts) {
|
|
|
10117
10425
|
}
|
|
10118
10426
|
}
|
|
10119
10427
|
async function runFontsResolveSet(opts) {
|
|
10120
|
-
const setDir =
|
|
10428
|
+
const setDir = path32.resolve(process.env["INIT_CWD"] ?? process.cwd(), opts.set);
|
|
10121
10429
|
let needs = [];
|
|
10122
10430
|
try {
|
|
10123
10431
|
needs = recordedFontNeeds(setDir);
|
|
@@ -10190,7 +10498,7 @@ async function runFontsResolveSet(opts) {
|
|
|
10190
10498
|
}
|
|
10191
10499
|
}
|
|
10192
10500
|
function runFontsStatus(opts) {
|
|
10193
|
-
const manifestPath2 =
|
|
10501
|
+
const manifestPath2 = path32.join(opts.cacheDir, "manifest.json");
|
|
10194
10502
|
if (!existsSync25(manifestPath2)) {
|
|
10195
10503
|
fail(opts, ExitCode.FontsUnproven, {
|
|
10196
10504
|
error: `no font cache at ${opts.cacheDir}`,
|
|
@@ -10199,7 +10507,7 @@ function runFontsStatus(opts) {
|
|
|
10199
10507
|
});
|
|
10200
10508
|
}
|
|
10201
10509
|
const faces = JSON.parse(readFileSync21(manifestPath2, "utf8"));
|
|
10202
|
-
const lockVerdicts = opts.lock !== void 0 ? checkFontLock(
|
|
10510
|
+
const lockVerdicts = opts.lock !== void 0 ? checkFontLock(path32.resolve(opts.lock), opts.cacheDir) : null;
|
|
10203
10511
|
emitData(opts, { faces, lockVerdicts }, () => {
|
|
10204
10512
|
for (const f of faces) process.stdout.write(`cached ${f.family} ${f.weight} (${f.sha256.slice(0, 12)}\u2026)
|
|
10205
10513
|
`);
|
|
@@ -10222,10 +10530,45 @@ function runFontsRequired(opts) {
|
|
|
10222
10530
|
});
|
|
10223
10531
|
if (missing.length > 0) process.exit(ExitCode.FontsUnproven);
|
|
10224
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
|
+
}
|
|
10225
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
|
+
}
|
|
10226
10569
|
let face;
|
|
10227
10570
|
try {
|
|
10228
|
-
face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir);
|
|
10571
|
+
face = addLocalFont(opts.family, opts.weight, opts.file, opts.cacheDir, opts.face);
|
|
10229
10572
|
} catch (err) {
|
|
10230
10573
|
fail(opts, ExitCode.InputValidation, {
|
|
10231
10574
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -10248,71 +10591,7 @@ var init_fonts = __esm({
|
|
|
10248
10591
|
init_src4();
|
|
10249
10592
|
init_src7();
|
|
10250
10593
|
init_output();
|
|
10251
|
-
|
|
10252
|
-
}
|
|
10253
|
-
});
|
|
10254
|
-
|
|
10255
|
-
// packages/cli/src/font-guidance.ts
|
|
10256
|
-
import path32 from "node:path";
|
|
10257
|
-
function fontsUnprovenRemediation(setDir) {
|
|
10258
|
-
const set = setDir === void 0 ? void 0 : path32.resolve(setDir);
|
|
10259
|
-
if (set !== void 0) {
|
|
10260
|
-
try {
|
|
10261
|
-
const needs = recordedFontNeeds(set);
|
|
10262
|
-
if (needs.length > 0) {
|
|
10263
|
-
const families = needs.map((n) => `"${n.family}"`).join(", ");
|
|
10264
|
-
return `Run \`${tendrilCommand(`fonts resolve --set ${set}`)}\` \u2014 the recording declares ${families}; faces land in ${DEFAULT_FONT_CACHE}.`;
|
|
10265
|
-
}
|
|
10266
|
-
} catch {
|
|
10267
|
-
}
|
|
10268
|
-
}
|
|
10269
|
-
const target = set ?? "<recording-dir>";
|
|
10270
|
-
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}.`;
|
|
10271
|
-
}
|
|
10272
|
-
function taskFontFamilies(setDir) {
|
|
10273
|
-
try {
|
|
10274
|
-
const families = recordedFontFamilies(setDir);
|
|
10275
|
-
return families.length > 0 ? families : null;
|
|
10276
|
-
} catch {
|
|
10277
|
-
return null;
|
|
10278
|
-
}
|
|
10279
|
-
}
|
|
10280
|
-
function unprovisionedFamilies(setDir, cacheDir) {
|
|
10281
|
-
const declared = taskFontFamilies(setDir);
|
|
10282
|
-
if (declared === null) return [];
|
|
10283
|
-
const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
|
|
10284
|
-
return declared.filter((f) => !provided.has(f.toLowerCase()));
|
|
10285
|
-
}
|
|
10286
|
-
function unprovisionedFaces(setDir, cacheDir) {
|
|
10287
|
-
return [
|
|
10288
|
-
...unprovisionedFamilies(setDir, cacheDir),
|
|
10289
|
-
...missingWeights(setDir, cacheDir).map((g) => {
|
|
10290
|
-
const missing = g.declared.filter((w) => !g.provided.includes(w));
|
|
10291
|
-
return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
|
|
10292
|
-
})
|
|
10293
|
-
];
|
|
10294
|
-
}
|
|
10295
|
-
function missingWeights(setDir, cacheDir) {
|
|
10296
|
-
let needs;
|
|
10297
|
-
try {
|
|
10298
|
-
needs = recordedFontNeeds(setDir, { pairedOnly: true });
|
|
10299
|
-
} catch {
|
|
10300
|
-
return [];
|
|
10301
|
-
}
|
|
10302
|
-
const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
|
|
10303
|
-
const gaps = [];
|
|
10304
|
-
for (const n of needs) {
|
|
10305
|
-
const have = provided.get(n.family.toLowerCase());
|
|
10306
|
-
if (have === void 0) continue;
|
|
10307
|
-
if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
|
|
10308
|
-
}
|
|
10309
|
-
return gaps;
|
|
10310
|
-
}
|
|
10311
|
-
var init_font_guidance = __esm({
|
|
10312
|
-
"packages/cli/src/font-guidance.ts"() {
|
|
10313
|
-
"use strict";
|
|
10314
|
-
init_src4();
|
|
10315
|
-
init_src7();
|
|
10594
|
+
init_font_guidance();
|
|
10316
10595
|
init_invocation();
|
|
10317
10596
|
}
|
|
10318
10597
|
});
|
|
@@ -10328,6 +10607,7 @@ __export(verify_exports, {
|
|
|
10328
10607
|
failureTally: () => failureTally,
|
|
10329
10608
|
foldConfigStatus: () => foldConfigStatus,
|
|
10330
10609
|
interactionCoverage: () => interactionCoverage,
|
|
10610
|
+
latticeCoverage: () => latticeCoverage,
|
|
10331
10611
|
occlusionReport: () => occlusionReport,
|
|
10332
10612
|
operabilityLine: () => operabilityLine,
|
|
10333
10613
|
operabilityReport: () => operabilityReport,
|
|
@@ -10425,6 +10705,21 @@ function checkSummarySegments(input) {
|
|
|
10425
10705
|
const operability = "short" in input.operability ? `operability UNVERIFIED (${input.operability.short})` : `operability ${input.operability.passed}/${input.operability.checks} interaction`;
|
|
10426
10706
|
return ` \xB7 ${operability} \xB7 ${composition} \xB7 ${occlusion}`;
|
|
10427
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
|
+
};
|
|
10722
|
+
}
|
|
10428
10723
|
function occlusionReport(occlusion) {
|
|
10429
10724
|
if (occlusion.length === 0) {
|
|
10430
10725
|
return {
|
|
@@ -10685,12 +10980,7 @@ async function runVerify(opts) {
|
|
|
10685
10980
|
// itself — not only in a long-gone plan output.
|
|
10686
10981
|
...(() => {
|
|
10687
10982
|
try {
|
|
10688
|
-
|
|
10689
|
-
const lattice = setManifest.latticeNames?.length;
|
|
10690
|
-
return {
|
|
10691
|
-
...lattice !== void 0 ? { latticeConfigs: lattice, unrecordedConfigs: Math.max(0, lattice - statuses.length) } : { latticeConfigs: null },
|
|
10692
|
-
...setManifest.notRecorded !== void 0 && setManifest.notRecorded !== "" ? { notRecorded: setManifest.notRecorded } : {}
|
|
10693
|
-
};
|
|
10983
|
+
return latticeCoverage(loadManifest(task.set), statuses.length);
|
|
10694
10984
|
} catch {
|
|
10695
10985
|
return { latticeConfigs: null };
|
|
10696
10986
|
}
|
|
@@ -10991,6 +11281,7 @@ ${notRecorded}` : "";
|
|
|
10991
11281
|
=== TASK PAYLOAD (recorded truth, verbatim) ===
|
|
10992
11282
|
${segments}`;
|
|
10993
11283
|
const payloadFile = path34.resolve(callerCwd, opts.out ?? `tendril-out/${name}-brief.md`);
|
|
11284
|
+
const candidateDirSuggestion = path34.resolve(callerCwd, `tendril-out/${name}-candidate`);
|
|
10994
11285
|
mkdirSync8(path34.dirname(payloadFile), { recursive: true });
|
|
10995
11286
|
writeFileSync12(payloadFile, payload);
|
|
10996
11287
|
emitData(
|
|
@@ -11019,8 +11310,16 @@ ${segments}`;
|
|
|
11019
11310
|
protocol: [
|
|
11020
11311
|
"Settle the proposer model (see modelSelection \u2014 ask the user when one is present), then:",
|
|
11021
11312
|
`Read ${payloadFile} completely \u2014 it is the system brief plus every recorded config's emission, box, and assets.`,
|
|
11022
|
-
`Write the complete bundle files (${task.entry}, styles.css, optional tokens.css) into a
|
|
11023
|
-
|
|
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.`,
|
|
11024
11323
|
"Apply the returned feedback and re-score. Stop when all checks pass or two consecutive scores fail to improve.",
|
|
11025
11324
|
"Never edit recording sets; never claim scores yourself \u2014 only the score command's output counts."
|
|
11026
11325
|
]
|
|
@@ -11144,7 +11443,21 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
11144
11443
|
{
|
|
11145
11444
|
task: name,
|
|
11146
11445
|
bar: opts.bar,
|
|
11147
|
-
|
|
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
|
+
},
|
|
11148
11461
|
scores,
|
|
11149
11462
|
behaviors,
|
|
11150
11463
|
feedback,
|
|
@@ -13278,11 +13591,19 @@ function buildProgram() {
|
|
|
13278
13591
|
await runFontsResolve2({ ...flags, family, weights: local["weights"].map(Number), cacheDir });
|
|
13279
13592
|
}
|
|
13280
13593
|
});
|
|
13281
|
-
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) => {
|
|
13282
13595
|
const flags = globalFlags(cmd.parent.parent);
|
|
13283
13596
|
const local = cmd.opts();
|
|
13284
13597
|
const { runFontsAdd: runFontsAdd2 } = await Promise.resolve().then(() => (init_fonts(), fonts_exports));
|
|
13285
|
-
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
|
+
});
|
|
13286
13607
|
});
|
|
13287
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) => {
|
|
13288
13609
|
const flags = globalFlags(cmd.parent.parent);
|