@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 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
- First-run friction: if tendril/Figma tool calls are hitting permission
24
- prompts, tell the user ONE approval can replace them all and offer
25
- `tendril_permissions` with `write: true` it merges the pipeline's
26
- per-tool allowlist into the project's `.claude/settings.local.json`
27
- (idempotent, touches nothing else; the user reloads the session to
28
- apply). Never run it unoffered: it edits the user's settings.
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
@@ -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. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
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: [MEMORY_CAP_ARG] });
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: [MEMORY_CAP_ARG] });
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: [MEMORY_CAP_ARG] });
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: [MEMORY_CAP_ARG] });
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: [MEMORY_CAP_ARG] });
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: [MEMORY_CAP_ARG] });
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
- return { chrome, chromeVersion: chrome === "unavailable" ? null : chromeVersion(), fontsManifestSha256: fontsHash };
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: 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.`;
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
- for (const g of missingWeights(task.set)) {
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 provided = verifiedFontFamilies().map((f) => f.family);
9784
- const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
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. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
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
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",