@tendrilapp/cli 0.1.19 → 0.1.21
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 +41 -6
- package/dist/tendril-mcp.js +1 -1
- package/dist/tendril.js +87 -42
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -20,12 +20,47 @@ Non-negotiables (the CLI enforces these; do not fight them):
|
|
|
20
20
|
- Sub-bar results are honest, not failures to hide: exit 5 ships the
|
|
21
21
|
bundle with real scores. Report them as they are.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
Permissions, FIRST THING in every pipeline run (the trigger is a FILE
|
|
24
|
+
CHECK, not prompt-watching — you cannot observe permission prompts, so
|
|
25
|
+
never condition on them; a measured session full of prompts had a
|
|
26
|
+
prompt-conditioned offer never fire): check whether ANY Claude
|
|
27
|
+
settings file the host merges — the project's
|
|
28
|
+
`.claude/settings.local.json` or `.claude/settings.json`, or the
|
|
29
|
+
user's `~/.claude/settings.json` — contains tendril MCP entries
|
|
30
|
+
(plugin installs use `mcp__plugin_tendril_tendril__*`; direct
|
|
31
|
+
`claude mcp add` installs use `mcp__<server-name>__*`). If none does,
|
|
32
|
+
BEFORE the first recording call, tell the user one approval can
|
|
33
|
+
replace every pipeline prompt and offer `tendril_permissions` with
|
|
34
|
+
`write: true` — it merges the per-tool allowlist into the project's
|
|
35
|
+
settings.local.json (idempotent, touches nothing else; note it writes
|
|
36
|
+
PLUGIN-prefixed names — a direct-install user should run
|
|
37
|
+
`tendril permissions --claude` and adapt the prefix instead) and the
|
|
38
|
+
user must reload the session to apply; say that out loud. Offer ONCE
|
|
39
|
+
per session; never run it unoffered (it edits the user's settings),
|
|
40
|
+
and if declined, proceed without mentioning it again.
|
|
41
|
+
|
|
42
|
+
Long generator/score waits: silence is not a health signal, and POLL
|
|
43
|
+
cadence is not RELAY cadence (run 13: rounds landed 6–11 minutes apart,
|
|
44
|
+
so a correct 30s poll still left the user in 9-minute silences they
|
|
45
|
+
flagged as unacceptable). Two duties: relay each round's result the
|
|
46
|
+
moment `<candidateDir>/score-history.jsonl` gains a line ("round 3
|
|
47
|
+
scored: 15/21 at floor 0.885"), AND when more than ~60 seconds pass
|
|
48
|
+
with no round, emit a liveness line from the candidate dir's file
|
|
49
|
+
mtimes, which move while the generator is WRITING (thinking pauses are quiet — that is normal, not frozen) ("still
|
|
50
|
+
working — styles.css touched 20s ago"). The history file APPEARS ONLY
|
|
51
|
+
AFTER THE FIRST completed score, so its absence early is normal, not
|
|
52
|
+
frozen. A healthy run can take 30+ minutes; never kill on elapsed time
|
|
53
|
+
alone — count staleness from the LAST CHANGE to any signal, and when
|
|
54
|
+
past ~20 minutes of true staleness, ask the user before killing.
|
|
55
|
+
|
|
56
|
+
Before concluding "inherent limitation" on a stuck score: enumerate the
|
|
57
|
+
rendering inputs you have NOT verified — are the declared font faces
|
|
58
|
+
actually resolved (the kit's cached faces and weights vs what the CSS
|
|
59
|
+
asks for), computed styles vs authored styles, and
|
|
60
|
+
platform text antialiasing — and check them first. Run 13's "inherent
|
|
61
|
+
fidelity gap" was a missing font weight one check away; a stop rule is
|
|
62
|
+
for avoiding thrash, never for converting an unverified hypothesis into
|
|
63
|
+
a final answer.
|
|
29
64
|
|
|
30
65
|
Batch runs (several components in one session):
|
|
31
66
|
- Cap concurrent recorders at FOUR. All recorders share one Figma
|
package/dist/tendril-mcp.js
CHANGED
|
@@ -66,7 +66,7 @@ var TOOLS = [
|
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
name: "tendril_permissions",
|
|
69
|
-
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing.
|
|
69
|
+
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. OFFER THIS AT PIPELINE START whenever NO merged Claude settings file (project .claude/settings.local.json or .claude/settings.json, or user ~/.claude/settings.json) contains tendril MCP entries \u2014 plugin installs use mcp__plugin_tendril_tendril__*, direct claude-mcp-add installs use mcp__<server>__* (for those, write:true installs PLUGIN-prefixed names that will not match: list without writing and adapt the prefix instead). A FILE check, never prompt-watching \u2014 agents cannot observe permission prompts. ONE approval here replaces a prompt per pipeline call. Never run it unoffered; the user must reload the session for new settings to apply \u2014 say so.",
|
|
70
70
|
schema: z.object({
|
|
71
71
|
write: z.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
|
|
72
72
|
}),
|
package/dist/tendril.js
CHANGED
|
@@ -2274,6 +2274,31 @@ var init_report = __esm({
|
|
|
2274
2274
|
}
|
|
2275
2275
|
});
|
|
2276
2276
|
|
|
2277
|
+
// packages/verify/src/mount-limits.ts
|
|
2278
|
+
function lcdTextDisabled() {
|
|
2279
|
+
return process.env["TENDRIL_DISABLE_LCD_TEXT"] === "1";
|
|
2280
|
+
}
|
|
2281
|
+
function mountArgs() {
|
|
2282
|
+
return [MEMORY_CAP_ARG, ...lcdTextDisabled() ? ["--disable-lcd-text"] : []];
|
|
2283
|
+
}
|
|
2284
|
+
function raceMountDeadline(work, deadlineMs, onDeadline) {
|
|
2285
|
+
return Promise.race([
|
|
2286
|
+
work,
|
|
2287
|
+
new Promise((resolve) => {
|
|
2288
|
+
const timer = setTimeout(() => resolve(onDeadline()), deadlineMs);
|
|
2289
|
+
timer.unref();
|
|
2290
|
+
})
|
|
2291
|
+
]);
|
|
2292
|
+
}
|
|
2293
|
+
var MEMORY_CAP_ARG, ENGINE_MOUNT_DEADLINE_MS;
|
|
2294
|
+
var init_mount_limits = __esm({
|
|
2295
|
+
"packages/verify/src/mount-limits.ts"() {
|
|
2296
|
+
"use strict";
|
|
2297
|
+
MEMORY_CAP_ARG = "--js-flags=--max-old-space-size=512";
|
|
2298
|
+
ENGINE_MOUNT_DEADLINE_MS = 3e4;
|
|
2299
|
+
}
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2277
2302
|
// packages/verify/src/image-diff.ts
|
|
2278
2303
|
import { PNG } from "pngjs";
|
|
2279
2304
|
import pixelmatch from "pixelmatch";
|
|
@@ -3030,7 +3055,7 @@ ${input.tokensCss}
|
|
|
3030
3055
|
${input.files.css}
|
|
3031
3056
|
#root { display: inline-block; }
|
|
3032
3057
|
</style></head><body><div id="root"></div><script>${js}</script></body></html>`;
|
|
3033
|
-
const browser = await chromium.launch({ executablePath, headless: true });
|
|
3058
|
+
const browser = await chromium.launch({ executablePath, headless: true, args: mountArgs() });
|
|
3034
3059
|
try {
|
|
3035
3060
|
const page = await browser.newPage({ viewport: { width: 800, height: 600 } });
|
|
3036
3061
|
await page.setContent(html, { waitUntil: "load" });
|
|
@@ -3268,6 +3293,7 @@ var init_visual_facts = __esm({
|
|
|
3268
3293
|
"packages/verify/src/visual-facts.ts"() {
|
|
3269
3294
|
"use strict";
|
|
3270
3295
|
init_browser();
|
|
3296
|
+
init_mount_limits();
|
|
3271
3297
|
init_image_diff();
|
|
3272
3298
|
RESOLVE_DIR = path7.resolve(path7.dirname(fileURLToPath2(import.meta.url)), "..");
|
|
3273
3299
|
TOLERANCE_PX = 2;
|
|
@@ -3485,25 +3511,6 @@ var init_font_faces = __esm({
|
|
|
3485
3511
|
}
|
|
3486
3512
|
});
|
|
3487
3513
|
|
|
3488
|
-
// packages/verify/src/mount-limits.ts
|
|
3489
|
-
function raceMountDeadline(work, deadlineMs, onDeadline) {
|
|
3490
|
-
return Promise.race([
|
|
3491
|
-
work,
|
|
3492
|
-
new Promise((resolve) => {
|
|
3493
|
-
const timer = setTimeout(() => resolve(onDeadline()), deadlineMs);
|
|
3494
|
-
timer.unref();
|
|
3495
|
-
})
|
|
3496
|
-
]);
|
|
3497
|
-
}
|
|
3498
|
-
var MEMORY_CAP_ARG, ENGINE_MOUNT_DEADLINE_MS;
|
|
3499
|
-
var init_mount_limits = __esm({
|
|
3500
|
-
"packages/verify/src/mount-limits.ts"() {
|
|
3501
|
-
"use strict";
|
|
3502
|
-
MEMORY_CAP_ARG = "--js-flags=--max-old-space-size=512";
|
|
3503
|
-
ENGINE_MOUNT_DEADLINE_MS = 3e4;
|
|
3504
|
-
}
|
|
3505
|
-
});
|
|
3506
|
-
|
|
3507
3514
|
// packages/verify/src/admission.ts
|
|
3508
3515
|
import { readFileSync as readFileSync5, readdirSync as readdirSync2, existsSync as existsSync7, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3509
3516
|
import path11 from "node:path";
|
|
@@ -4080,7 +4087,7 @@ async function checkBehaviors(task, bundleDir, opts = {}) {
|
|
|
4080
4087
|
const js = await compileMount(task, bundleDir);
|
|
4081
4088
|
if (typeof js !== "string") return task.behaviors.map((b) => ({ id: b.id, pass: false, detail: js.error }));
|
|
4082
4089
|
const css = ["tokens.css", "styles.css"].map((f) => path13.join(bundleDir, f)).filter((f) => existsSync8(f)).map((f) => readFileSync6(f, "utf8")).join("\n");
|
|
4083
|
-
const server = await chromium3.launchServer({ executablePath: resolveChrome(), headless: true, args:
|
|
4090
|
+
const server = await chromium3.launchServer({ executablePath: resolveChrome(), headless: true, args: mountArgs() });
|
|
4084
4091
|
const browser = await chromium3.connect(server.wsEndpoint());
|
|
4085
4092
|
const results = [];
|
|
4086
4093
|
let deadlined = false;
|
|
@@ -4512,7 +4519,7 @@ if (root && C) createRoot(root).render(createElement(C, cfg.props));
|
|
|
4512
4519
|
}
|
|
4513
4520
|
return detectBackdrop(ref);
|
|
4514
4521
|
};
|
|
4515
|
-
const server = await chromium4.launchServer({ executablePath: resolveChrome(), headless: true, args:
|
|
4522
|
+
const server = await chromium4.launchServer({ executablePath: resolveChrome(), headless: true, args: mountArgs() });
|
|
4516
4523
|
const browser = await chromium4.connect(server.wsEndpoint());
|
|
4517
4524
|
const scores = [];
|
|
4518
4525
|
try {
|
|
@@ -4705,7 +4712,7 @@ async function checkHoverParity(task, bundleDir, opts = {}) {
|
|
|
4705
4712
|
const js = await compileMount(task, bundleDir);
|
|
4706
4713
|
if (typeof js !== "string") return configs.map((c) => ({ id: `parity:${c.rep}`, pass: false, detail: js.error }));
|
|
4707
4714
|
const css = ["tokens.css", "styles.css"].map((f) => path16.join(bundleDir, f)).filter((f) => existsSync11(f)).map((f) => readFileSync9(f, "utf8")).join("\n");
|
|
4708
|
-
const server = await chromium6.launchServer({ executablePath: resolveChrome(), headless: true, args:
|
|
4715
|
+
const server = await chromium6.launchServer({ executablePath: resolveChrome(), headless: true, args: mountArgs() });
|
|
4709
4716
|
const browser = await chromium6.connect(server.wsEndpoint());
|
|
4710
4717
|
const results = [];
|
|
4711
4718
|
try {
|
|
@@ -4879,7 +4886,7 @@ async function checkCropComposition(task, bundleDir, regions, opts = {}) {
|
|
|
4879
4886
|
return regions.map((r) => ({ id: cropId(r), pass: false, similarity: 0, inkRecall: 0, detail: js.error }));
|
|
4880
4887
|
}
|
|
4881
4888
|
const css = ["tokens.css", "styles.css"].map((f) => path17.join(bundleDir, f)).filter((f) => existsSync12(f)).map((f) => readFileSync10(f, "utf8")).join("\n");
|
|
4882
|
-
const server = await chromium7.launchServer({ executablePath: resolveChrome(), headless: true, args:
|
|
4889
|
+
const server = await chromium7.launchServer({ executablePath: resolveChrome(), headless: true, args: mountArgs() });
|
|
4883
4890
|
const browser = await chromium7.connect(server.wsEndpoint());
|
|
4884
4891
|
const PAD = 4;
|
|
4885
4892
|
try {
|
|
@@ -4984,7 +4991,7 @@ async function checkStructuralComposition(task, bundleDir, roles, opts = {}) {
|
|
|
4984
4991
|
const js = await compileInstrumentedMount(task, bundleDir);
|
|
4985
4992
|
if (typeof js !== "string") return [...results, ...mains.map((m) => ({ id: `composition:${m}`, pass: false, detail: js.error }))];
|
|
4986
4993
|
const css = ["tokens.css", "styles.css"].map((f) => path17.join(bundleDir, f)).filter((f) => existsSync12(f)).map((f) => readFileSync10(f, "utf8")).join("\n");
|
|
4987
|
-
const server = await chromium7.launchServer({ executablePath: resolveChrome(), headless: true, args:
|
|
4994
|
+
const server = await chromium7.launchServer({ executablePath: resolveChrome(), headless: true, args: mountArgs() });
|
|
4988
4995
|
const browser = await chromium7.connect(server.wsEndpoint());
|
|
4989
4996
|
try {
|
|
4990
4997
|
for (const mainSlug of mains) {
|
|
@@ -5124,7 +5131,7 @@ async function checkSiblingOcclusion(task, bundleDir, css, opts = {}) {
|
|
|
5124
5131
|
const js = await compileTwoUp(task, bundleDir);
|
|
5125
5132
|
if (typeof js !== "string") return [{ id: "sibling-overlay-hit-testable", pass: false, detail: js.error }];
|
|
5126
5133
|
const timeoutMs = opts.mountDeadlineMs ?? ENGINE_MOUNT_DEADLINE_MS;
|
|
5127
|
-
const server = await chromium8.launchServer({ executablePath: resolveChrome(), headless: true, args:
|
|
5134
|
+
const server = await chromium8.launchServer({ executablePath: resolveChrome(), headless: true, args: mountArgs() });
|
|
5128
5135
|
const browser = await chromium8.connect(server.wsEndpoint());
|
|
5129
5136
|
try {
|
|
5130
5137
|
const work = (async () => {
|
|
@@ -5250,6 +5257,7 @@ var init_src4 = __esm({
|
|
|
5250
5257
|
init_containment();
|
|
5251
5258
|
init_tasks();
|
|
5252
5259
|
init_prelude();
|
|
5260
|
+
init_mount_limits();
|
|
5253
5261
|
init_font_faces();
|
|
5254
5262
|
init_font_resolve();
|
|
5255
5263
|
init_paths();
|
|
@@ -5289,7 +5297,9 @@ function environmentStamp(taskFamilies) {
|
|
|
5289
5297
|
chrome = resolveChrome();
|
|
5290
5298
|
} catch {
|
|
5291
5299
|
}
|
|
5292
|
-
|
|
5300
|
+
const ver = chrome === "unavailable" ? null : chromeVersion();
|
|
5301
|
+
const lcdOff = lcdTextDisabled() ? " (lcd-text-disabled)" : "";
|
|
5302
|
+
return { chrome, chromeVersion: ver === null ? null : ver + lcdOff, fontsManifestSha256: fontsHash };
|
|
5293
5303
|
}
|
|
5294
5304
|
var init_environment = __esm({
|
|
5295
5305
|
"packages/cli/src/environment.ts"() {
|
|
@@ -5532,7 +5542,7 @@ async function runDoctorChecks(options) {
|
|
|
5532
5542
|
);
|
|
5533
5543
|
try {
|
|
5534
5544
|
const chrome = resolveChrome();
|
|
5535
|
-
checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"} at ${chrome}` });
|
|
5545
|
+
checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"}${lcdTextDisabled() ? " \u2014 EXPERIMENT override active (lcd-text-disabled): scores not comparable to default runs" : ""} at ${chrome}` });
|
|
5536
5546
|
} catch (err) {
|
|
5537
5547
|
checks.push({
|
|
5538
5548
|
name: "browser",
|
|
@@ -8164,7 +8174,7 @@ ${propLines.join("\n")}
|
|
|
8164
8174
|
|
|
8165
8175
|
${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.
|
|
8166
8176
|
` : ""}${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.
|
|
8167
|
-
` : ""}Rules:
|
|
8177
|
+
` : ""}Rules: generated element ids come from React's useId() \u2014 never Math.random() in render/state init (SSR hydration hazard; measured: two same-day bundles disagreed). Plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom, and NEVER import the stylesheet from the entry module: the harness injects tokens.css and styles.css itself, and an entry that imports CSS does not compile here (measured cost: one full round, every config 0). The component renders the RECORDED content as its defaults \u2014 reproduce it from the emissions. ${forcingCanon}${fontsLine}The host sizes nothing: the component is its natural recorded size. FLUIDITY AFFORDANCE (emit it always): every ROOT width declaration rides width: var(--tendril-root-width, <recorded>px) with that pose's recorded width as the fallback \u2014 per-variant root widths reuse the SAME property name with their own recorded fallbacks. With the property unset this is byte-equivalent truth: identical pixels, identical operability measurement (measured: flipping roots to a bare 100% held 20/20 pixels but made a commit check unmeasurable \u2014 the recorded pin carries real signal). A consumer makes an instance fluid by setting --tendril-root-width (e.g. 100%) on a wrapper, no edit to certified CSS. Widths only: recorded heights and internal geometry stay literal \u2014 fixed heights are often genuine design intent.`;
|
|
8168
8178
|
const mappedTokens = /* @__PURE__ */ new Set([...forcedStates, ...props.filter((p) => p.kind === "boolean").map((p) => p.name)]);
|
|
8169
8179
|
const unmappedInteractionEvidence = interactionEvidence.filter((e) => {
|
|
8170
8180
|
const sel = e.endsWith(" (selection axis)");
|
|
@@ -8256,7 +8266,7 @@ function dismissEvidence(setDir, repSlugs) {
|
|
|
8256
8266
|
}
|
|
8257
8267
|
return void 0;
|
|
8258
8268
|
}
|
|
8259
|
-
function recordedFontNeeds(setDir) {
|
|
8269
|
+
function recordedFontNeeds(setDir, opts = {}) {
|
|
8260
8270
|
const byFamily = /* @__PURE__ */ new Map();
|
|
8261
8271
|
const unpaired = /* @__PURE__ */ new Set();
|
|
8262
8272
|
const famAdd = (raw, weight) => {
|
|
@@ -8298,8 +8308,8 @@ function recordedFontNeeds(setDir) {
|
|
|
8298
8308
|
if (existsSync21(defs)) fromDefs(envelopeText(defs));
|
|
8299
8309
|
}
|
|
8300
8310
|
return [...byFamily.entries()].map(([family, paired]) => {
|
|
8301
|
-
const weights = /* @__PURE__ */ new Set([...paired, ...unpaired]);
|
|
8302
|
-
return { family, weights: weights.size === 0 ? [400] : [...weights].sort((a, b) => a - b) };
|
|
8311
|
+
const weights = /* @__PURE__ */ new Set([...paired, ...opts.pairedOnly === true ? [] : unpaired]);
|
|
8312
|
+
return { family, weights: weights.size === 0 ? opts.pairedOnly === true ? [] : [400] : [...weights].sort((a, b) => a - b) };
|
|
8303
8313
|
});
|
|
8304
8314
|
}
|
|
8305
8315
|
function symbolFontGlyphCount(setDir, reps) {
|
|
@@ -9261,10 +9271,19 @@ function unprovisionedFamilies(setDir, cacheDir) {
|
|
|
9261
9271
|
const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
|
|
9262
9272
|
return declared.filter((f) => !provided.has(f.toLowerCase()));
|
|
9263
9273
|
}
|
|
9274
|
+
function unprovisionedFaces(setDir, cacheDir) {
|
|
9275
|
+
return [
|
|
9276
|
+
...unprovisionedFamilies(setDir, cacheDir),
|
|
9277
|
+
...missingWeights(setDir, cacheDir).map((g) => {
|
|
9278
|
+
const missing = g.declared.filter((w) => !g.provided.includes(w));
|
|
9279
|
+
return `${g.family} (${missing.length === 1 ? "weight" : "weights"} ${missing.join(", ")})`;
|
|
9280
|
+
})
|
|
9281
|
+
];
|
|
9282
|
+
}
|
|
9264
9283
|
function missingWeights(setDir, cacheDir) {
|
|
9265
9284
|
let needs;
|
|
9266
9285
|
try {
|
|
9267
|
-
needs = recordedFontNeeds(setDir);
|
|
9286
|
+
needs = recordedFontNeeds(setDir, { pairedOnly: true });
|
|
9268
9287
|
} catch {
|
|
9269
9288
|
return [];
|
|
9270
9289
|
}
|
|
@@ -9462,13 +9481,17 @@ async function runVerify(opts) {
|
|
|
9462
9481
|
remediation: fontsUnprovenRemediation(task.set)
|
|
9463
9482
|
});
|
|
9464
9483
|
}
|
|
9484
|
+
if (lcdTextDisabled()) {
|
|
9485
|
+
warn(opts, "EXPERIMENT override active (TENDRIL_DISABLE_LCD_TEXT=1): text renders greyscale \u2014 scores are NOT comparable to default runs; the environment stamp and report are marked");
|
|
9486
|
+
}
|
|
9465
9487
|
const substitutedFamilies = unprovisionedFamilies(task.set);
|
|
9466
9488
|
if (substitutedFamilies.length > 0) {
|
|
9467
9489
|
warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
|
|
9468
9490
|
} else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
|
|
9469
9491
|
warn(opts, "family coverage could not be established from this recording (no declared families) \u2014 the certification gate covers only cache non-emptiness here");
|
|
9470
9492
|
}
|
|
9471
|
-
|
|
9493
|
+
const weightGaps = missingWeights(task.set);
|
|
9494
|
+
for (const g of weightGaps) {
|
|
9472
9495
|
warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
|
|
9473
9496
|
}
|
|
9474
9497
|
const missing = task.configs.filter(
|
|
@@ -9580,7 +9603,18 @@ async function runVerify(opts) {
|
|
|
9580
9603
|
// Machine-readable cause (adversarial review): a cert-bar failure
|
|
9581
9604
|
// with zero pixel/behavior/composition failures was only
|
|
9582
9605
|
// explainable from stderr prose.
|
|
9583
|
-
...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {}
|
|
9606
|
+
...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {},
|
|
9607
|
+
...weightGaps.length > 0 ? { fontWeightGaps: weightGaps } : {},
|
|
9608
|
+
...lcdTextDisabled() ? { environmentOverrides: ["disable-lcd-text"] } : {},
|
|
9609
|
+
// Run 13 finding 1: the eye-check nudge lived only in the human
|
|
9610
|
+
// footer, so MCP-driven agents — the documented path — were never
|
|
9611
|
+
// told the check exists and shipped without ever opening a sheet.
|
|
9612
|
+
// Structured here so every consumer sees it.
|
|
9613
|
+
eyeCheck: {
|
|
9614
|
+
command: `tendril inspect "${opts.bundleDir}"`,
|
|
9615
|
+
sheetPath: path31.join(opts.bundleDir, "verify-evidence", "inspect.html"),
|
|
9616
|
+
note: "magnified recorded-vs-rendered crops of every small node; scores cannot see shape. The command WRITES/refreshes the sheet at sheetPath \u2014 re-run it after every verify; an existing sheet may show stale crops."
|
|
9617
|
+
}
|
|
9584
9618
|
};
|
|
9585
9619
|
emitData(opts, report, () => {
|
|
9586
9620
|
for (const s of statuses) {
|
|
@@ -9672,9 +9706,15 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
9672
9706
|
const shippedFonts = requiredFontsManifest(cssFontFamilies(["styles.css", "tokens.css"].map((f) => path31.join(opts.bundleDir, f)).filter((f) => existsSync25(f)).map((f) => readFileSync22(f, "utf8")).join("\n")));
|
|
9673
9707
|
if (shippedFonts.length > 0 && substitutedFamilies.length === 0) {
|
|
9674
9708
|
process.stdout.write(`fonts: scored with Tendril-cache faces \u2014 a consuming app must provision the same families (the bundle ships fonts.css when faces are shippable; sha-pinned list in component.json requiredFonts)
|
|
9709
|
+
`);
|
|
9710
|
+
}
|
|
9711
|
+
for (const g of weightGaps) {
|
|
9712
|
+
process.stdout.write(`fonts (weight gap): "${g.family}" declares ${g.declared.join(", ")} \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (advisory, never a gate). Fix: tendril fonts resolve --set ${task.set}
|
|
9675
9713
|
`);
|
|
9676
9714
|
}
|
|
9677
9715
|
process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
|
|
9716
|
+
`);
|
|
9717
|
+
process.stdout.write(`eye check: tendril inspect "${opts.bundleDir}" \u2014 magnified recorded-vs-rendered crops of every small node; scores cannot see shape (re-run after every verify)
|
|
9678
9718
|
`);
|
|
9679
9719
|
});
|
|
9680
9720
|
if (opts.bar === "cert" && substitutedFamilies.length > 0) {
|
|
@@ -9780,8 +9820,9 @@ DO NOT INVENT PIXELS FOR THESE POSES. Implement them ONLY as the composition of
|
|
|
9780
9820
|
${notRecorded}` : "";
|
|
9781
9821
|
let fontProvisioning;
|
|
9782
9822
|
if (existsSync26(manifestPath2)) {
|
|
9783
|
-
const
|
|
9784
|
-
const unprovided =
|
|
9823
|
+
const missingFams = unprovisionedFamilies(task.set);
|
|
9824
|
+
const unprovided = unprovisionedFaces(task.set);
|
|
9825
|
+
const weightOnly = missingFams.length === 0;
|
|
9785
9826
|
if (unprovided.length > 0) {
|
|
9786
9827
|
const names = unprovided.map((f) => `'${f}'`).join(", ");
|
|
9787
9828
|
const PROPRIETARY = /^(sf pro|sf compact|sf mono|new york|pingfang|segoe ui|helvetica neue|proxima nova|avenir)/i;
|
|
@@ -9789,13 +9830,13 @@ ${notRecorded}` : "";
|
|
|
9789
9830
|
fontProvisioning = {
|
|
9790
9831
|
unprovided,
|
|
9791
9832
|
question: {
|
|
9792
|
-
prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
|
|
9833
|
+
prompt: weightOnly ? `This design implies font ${unprovided.length === 1 ? "weight" : "weights"} the local kit lacks: ${names} (the ${missingFams.length === 0 && unprovided.length === 1 ? "family itself is" : "families themselves are"} provisioned). How should it be handled?` : `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
|
|
9793
9834
|
options: [
|
|
9794
|
-
allProprietary ? `(Recommended) ${names} ${unprovided.length === 1 ? "is a proprietary face" : "are proprietary faces"} \u2014 Google Fonts cannot serve ${unprovided.length === 1 ? "it" : "them"}, so skip \`fonts resolve\`: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold a licence for (the file never leaves this machine), then re-run this brief \u2014 exact text fidelity.` : `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
|
|
9795
|
-
'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
|
|
9835
|
+
allProprietary && !weightOnly ? `(Recommended) ${names} ${unprovided.length === 1 ? "is a proprietary face" : "are proprietary faces"} \u2014 Google Fonts cannot serve ${unprovided.length === 1 ? "it" : "them"}, so skip \`fonts resolve\`: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold a licence for (the file never leaves this machine), then re-run this brief \u2014 exact text fidelity.` : `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
|
|
9836
|
+
weightOnly ? "Continue with nearest-weight rendering: the family is provisioned, certification remains possible, and the gap rides the report as an advisory (fontWeightGaps); resolving removes it." : 'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
|
|
9796
9837
|
]
|
|
9797
9838
|
},
|
|
9798
|
-
nonInteractive: opts.bar === "cert" ? `Resolve the families first (\`tendril fonts resolve --set ${task.set}\`) \u2014 this loop targets the cert bar, and scoring exits fonts-unproven under a substitute, so continuing without resolving is a dead end.` : "Continue with the substitute and state the substitution in your report."
|
|
9839
|
+
nonInteractive: weightOnly ? `Run \`tendril fonts resolve --set ${task.set}\` if quick, or continue \u2014 the weight gap is an advisory, never a gate.` : opts.bar === "cert" ? `Resolve the families first (\`tendril fonts resolve --set ${task.set}\`) \u2014 this loop targets the cert bar, and scoring exits fonts-unproven under a substitute, so continuing without resolving is a dead end.` : "Continue with the substitute and state the substitution in your report."
|
|
9799
9840
|
};
|
|
9800
9841
|
}
|
|
9801
9842
|
}
|
|
@@ -9865,6 +9906,9 @@ async function runEngineScore(opts) {
|
|
|
9865
9906
|
remediation: fontsUnprovenRemediation(task.set)
|
|
9866
9907
|
});
|
|
9867
9908
|
}
|
|
9909
|
+
if (lcdTextDisabled()) {
|
|
9910
|
+
warn(opts, "EXPERIMENT override active (TENDRIL_DISABLE_LCD_TEXT=1): text renders greyscale \u2014 scores are NOT comparable to default runs; the environment stamp and report are marked");
|
|
9911
|
+
}
|
|
9868
9912
|
const substitutedFamilies = unprovisionedFamilies(task.set);
|
|
9869
9913
|
if (substitutedFamilies.length > 0) {
|
|
9870
9914
|
warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
|
|
@@ -9964,6 +10008,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
9964
10008
|
bundleManifest: emitted.written[0],
|
|
9965
10009
|
note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
|
|
9966
10010
|
allPass,
|
|
10011
|
+
...lcdTextDisabled() ? { environmentOverrides: ["disable-lcd-text"] } : {},
|
|
9967
10012
|
// Run 11: generators read allPass:true and reported success on
|
|
9968
10013
|
// bundles verify then FAILED on the interaction-evidence gate —
|
|
9969
10014
|
// the oracle must say what verify will say, including this.
|
|
@@ -10273,7 +10318,7 @@ var init_server = __esm({
|
|
|
10273
10318
|
},
|
|
10274
10319
|
{
|
|
10275
10320
|
name: "tendril_permissions",
|
|
10276
|
-
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing.
|
|
10321
|
+
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. OFFER THIS AT PIPELINE START whenever NO merged Claude settings file (project .claude/settings.local.json or .claude/settings.json, or user ~/.claude/settings.json) contains tendril MCP entries \u2014 plugin installs use mcp__plugin_tendril_tendril__*, direct claude-mcp-add installs use mcp__<server>__* (for those, write:true installs PLUGIN-prefixed names that will not match: list without writing and adapt the prefix instead). A FILE check, never prompt-watching \u2014 agents cannot observe permission prompts. ONE approval here replaces a prompt per pipeline call. Never run it unoffered; the user must reload the session for new settings to apply \u2014 say so.",
|
|
10277
10322
|
schema: z12.object({
|
|
10278
10323
|
write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
|
|
10279
10324
|
}),
|