@tendrilapp/cli 0.1.20 → 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
@@ -39,16 +39,28 @@ user must reload the session to apply; say that out loud. Offer ONCE
39
39
  per session; never run it unoffered (it edits the user's settings),
40
40
  and if declined, proceed without mentioning it again.
41
41
 
42
- Long generator/score waits: silence is not a health signal. The scorer
43
- appends a timestamped line to `<candidateDir>/score-history.jsonl`
44
- after EVERY completed round the file APPEARS ONLY AFTER THE FIRST
45
- score, so its absence early in a run is normal, not frozen. Poll it
46
- (and the candidate dir's mtimes) about every 30 seconds and relay
47
- progress to the user ("round 3 scored: 15/21 at floor 0.885"). A
48
- healthy run can take 30+ minutes; never kill on elapsed time alone —
49
- count staleness from the LAST CHANGE to either signal, not from run
50
- start, and when past ~20 minutes of true staleness, ask the user
51
- before killing.
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.
52
64
 
53
65
  Batch runs (several components in one session):
54
66
  - Cap concurrent recorders at FOUR. All recorders share one Figma
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)");
@@ -9471,6 +9481,9 @@ async function runVerify(opts) {
9471
9481
  remediation: fontsUnprovenRemediation(task.set)
9472
9482
  });
9473
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
+ }
9474
9487
  const substitutedFamilies = unprovisionedFamilies(task.set);
9475
9488
  if (substitutedFamilies.length > 0) {
9476
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`);
@@ -9591,7 +9604,17 @@ async function runVerify(opts) {
9591
9604
  // with zero pixel/behavior/composition failures was only
9592
9605
  // explainable from stderr prose.
9593
9606
  ...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {},
9594
- ...weightGaps.length > 0 ? { fontWeightGaps: weightGaps } : {}
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
+ }
9595
9618
  };
9596
9619
  emitData(opts, report, () => {
9597
9620
  for (const s of statuses) {
@@ -9691,7 +9714,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9691
9714
  }
9692
9715
  process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
9693
9716
  `);
9694
- process.stdout.write(`eye check: tendril inspect ${opts.bundleDir} \u2014 magnified recorded-vs-rendered crops of every small node; scores cannot see shape
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)
9695
9718
  `);
9696
9719
  });
9697
9720
  if (opts.bar === "cert" && substitutedFamilies.length > 0) {
@@ -9883,6 +9906,9 @@ async function runEngineScore(opts) {
9883
9906
  remediation: fontsUnprovenRemediation(task.set)
9884
9907
  });
9885
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
+ }
9886
9912
  const substitutedFamilies = unprovisionedFamilies(task.set);
9887
9913
  if (substitutedFamilies.length > 0) {
9888
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`);
@@ -9982,6 +10008,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9982
10008
  bundleManifest: emitted.written[0],
9983
10009
  note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
9984
10010
  allPass,
10011
+ ...lcdTextDisabled() ? { environmentOverrides: ["disable-lcd-text"] } : {},
9985
10012
  // Run 11: generators read allPass:true and reported success on
9986
10013
  // bundles verify then FAILED on the interaction-evidence gate —
9987
10014
  // the oracle must say what verify will say, including this.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.20",
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",