@tendrilapp/cli 0.1.7 → 0.1.9

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.
Files changed (2) hide show
  1. package/dist/tendril.js +113 -8
  2. package/package.json +1 -1
package/dist/tendril.js CHANGED
@@ -1839,6 +1839,7 @@ var init_browser = __esm({
1839
1839
 
1840
1840
  // packages/verify/src/runtime.ts
1841
1841
  import { existsSync as existsSync4, mkdtempSync, symlinkSync } from "node:fs";
1842
+ import { createRequire } from "node:module";
1842
1843
  import os from "node:os";
1843
1844
  import path4 from "node:path";
1844
1845
  import { fileURLToPath } from "node:url";
@@ -1870,6 +1871,21 @@ function newScratchDir(prefix) {
1870
1871
  }
1871
1872
  return dir;
1872
1873
  }
1874
+ function reactPinPlugin() {
1875
+ const req = createRequire(path4.join(runtimePackageRoot(), "package.json"));
1876
+ return {
1877
+ name: "tendril-react-pin",
1878
+ setup(b) {
1879
+ b.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
1880
+ try {
1881
+ return { path: req.resolve(args.path) };
1882
+ } catch {
1883
+ return null;
1884
+ }
1885
+ });
1886
+ }
1887
+ };
1888
+ }
1873
1889
  var init_runtime = __esm({
1874
1890
  "packages/verify/src/runtime.ts"() {
1875
1891
  "use strict";
@@ -3578,6 +3594,7 @@ if (root && C) createRoot(root).render(createElement(C, cfg.props));
3578
3594
  const bundle = await build3({
3579
3595
  stdin: { contents: mountSrc, resolveDir: runtimePackageRoot(), loader: "tsx" },
3580
3596
  nodePaths: [runtimeNodeModules()],
3597
+ plugins: [reactPinPlugin()],
3581
3598
  bundle: true,
3582
3599
  write: false,
3583
3600
  format: "iife",
@@ -4170,6 +4187,7 @@ if (root && C) createRoot(root).render(createElement(C, cfg.props));
4170
4187
  const bundle = await build4({
4171
4188
  stdin: { contents: mountSrc, resolveDir: runtimePackageRoot(), loader: "tsx" },
4172
4189
  nodePaths: [runtimeNodeModules()],
4190
+ plugins: [reactPinPlugin()],
4173
4191
  bundle: true,
4174
4192
  write: false,
4175
4193
  format: "iife",
@@ -4351,7 +4369,7 @@ var init_prelude = __esm({
4351
4369
  - Component root: -webkit-font-smoothing: antialiased and -moz-osx-font-smoothing: grayscale (recorded rasterization); font-synthesis: none (a missing font weight must fall back visibly, never fake-bold); color-scheme MATCHING YOUR RECORDING (light for a light capture, dark for a dark one) and direction: ltr \u2014 pin them, so the render cannot follow the viewer OS preference and drift from the capture it is graded against; isolation: isolate (own stacking context; overlay z-indexes never fight the host).
4352
4370
  - Interactive controls (buttons, options): touch-action: manipulation and user-select: none. Text inputs stay selectable (never user-select: none on them).
4353
4371
  - Scrollable popovers/menus: overscroll-behavior: contain.
4354
- - Focus indicators bind to :focus-visible, never bare :focus. If the recording contains a focus pose, style the indicator from that recorded truth. If NO focus pose is recorded, do NOT invent ring colors/widths/offsets \u2014 an invented ring is unrecorded pixels; keep the browser's default focus indicator (leave outline in place on :focus-visible) and state the gap in your report. Text inputs match :focus-visible even on mouse click BY SPEC \u2014 that is platform-correct accessibility, never a bug to suppress with JS modality tracking.
4372
+ - Focus indicators bind to :focus-visible, never bare :focus. If the recording contains a focus pose, style the indicator from that recorded truth. If NO focus pose is recorded, do NOT invent ring colors/widths/offsets \u2014 an invented ring is unrecorded pixels; keep the browser's default indicator for keyboard focus and state the gap in your report. Text inputs match :focus-visible even on mouse click BY SPEC; the ONE permitted refinement is suppressing the indicator on a POSITIVELY OBSERVED pointer press, fail-safe toward showing it (programmatic focus, restored focus, and assistive tech all count as keyboard and keep the ring \u2014 WCAG 2.4.7 binds keyboard operation). Never suppress more broadly than an observed press, and never restyle what you kept.
4355
4373
  - Every animation wrapped in @media (prefers-reduced-motion: no-preference) or disabled under reduce.`;
4356
4374
  }
4357
4375
  });
@@ -4466,7 +4484,7 @@ var init_parity = __esm({
4466
4484
  });
4467
4485
 
4468
4486
  // packages/verify/src/composition.ts
4469
- import { createRequire } from "node:module";
4487
+ import { createRequire as createRequire2 } from "node:module";
4470
4488
  import { existsSync as existsSync12, readFileSync as readFileSync10 } from "node:fs";
4471
4489
  import path17 from "node:path";
4472
4490
  import { build as build6 } from "esbuild";
@@ -4478,7 +4496,7 @@ function getFontFaces4() {
4478
4496
  async function compileInstrumentedMount(task, bundleDir) {
4479
4497
  const entryTsx = path17.join(bundleDir, task.entry);
4480
4498
  if (!existsSync12(entryTsx)) return { error: `${task.entry} missing` };
4481
- const requireFromVerify = createRequire(path17.join(VERIFY_PKG_DIR, "package.json"));
4499
+ const requireFromVerify = createRequire2(path17.join(VERIFY_PKG_DIR, "package.json"));
4482
4500
  let realJsxPath;
4483
4501
  try {
4484
4502
  realJsxPath = requireFromVerify.resolve("react/jsx-runtime");
@@ -4519,7 +4537,10 @@ if (root && Main) createRoot(root).render(createElement(Main, cfg.props));
4519
4537
  b.onResolve({ filter: new RegExp(`^${REAL_JSX_SPEC}$`) }, () => ({ path: realJsxPath }));
4520
4538
  b.onLoad({ filter: /.*/, namespace: "tendril-shim" }, () => ({ contents: JSX_SHIM, resolveDir: runtimePackageRoot(), loader: "js" }));
4521
4539
  }
4522
- }
4540
+ },
4541
+ // AFTER the stamp shim: the shim must own react/jsx-runtime
4542
+ // interception; the pin covers every remaining react import.
4543
+ reactPinPlugin()
4523
4544
  ]
4524
4545
  });
4525
4546
  return bundle.outputFiles[0]?.text ?? "";
@@ -4775,6 +4796,7 @@ for (const id of ["first", "second"]) {
4775
4796
  const bundle = await build7({
4776
4797
  stdin: { contents: src, resolveDir: runtimePackageRoot(), loader: "tsx" },
4777
4798
  nodePaths: [runtimeNodeModules()],
4799
+ plugins: [reactPinPlugin()],
4778
4800
  bundle: true,
4779
4801
  write: false,
4780
4802
  format: "iife",
@@ -7229,6 +7251,14 @@ function authorComponentApi(opts) {
7229
7251
  props.push({ name: propNameFor(key), kind: "union", values: ordered.map(kebab3), default: kebab3(def) });
7230
7252
  }
7231
7253
  }
7254
+ const slots = (opts.textSlots ?? []).map((slot) => {
7255
+ let name = slot.prop;
7256
+ if (name !== "children" && (props.some((pr) => pr.name === name) || RESERVED_PROPS.has(name.toLowerCase()))) name = `${name}Text`;
7257
+ let n = 2;
7258
+ while (props.some((pr) => pr.name === name)) name = `${slot.prop}Text${n++}`;
7259
+ return { ...slot, prop: name };
7260
+ });
7261
+ for (const slot of slots) props.push({ name: slot.prop, kind: "string", default: slot.default });
7232
7262
  const configs = [];
7233
7263
  const seenAssignments = /* @__PURE__ */ new Map();
7234
7264
  const componentIdent = pascal(opts.component);
@@ -7250,6 +7280,10 @@ function authorComponentApi(opts) {
7250
7280
  }
7251
7281
  }
7252
7282
  if (tokens.length > 0) assignment["data-tendril-state"] = tokens.join(" ");
7283
+ for (const slot of slots) {
7284
+ const v = slot.overrides[p.slug];
7285
+ if (v !== void 0) assignment[slot.prop] = v;
7286
+ }
7253
7287
  const canonical = JSON.stringify(Object.fromEntries(Object.entries(assignment).sort(([a], [b]) => a.localeCompare(b))));
7254
7288
  const clash = seenAssignments.get(canonical);
7255
7289
  if (clash !== void 0) {
@@ -7268,7 +7302,7 @@ function authorComponentApi(opts) {
7268
7302
  name: componentIdent,
7269
7303
  props: props.map((pr) => ({
7270
7304
  name: pr.name,
7271
- type: pr.kind === "boolean" ? "boolean" : pr.values.map((v) => `"${v}"`).join(" | "),
7305
+ type: pr.kind === "boolean" ? "boolean" : pr.kind === "string" ? "string" : pr.values.map((v) => `"${v}"`).join(" | "),
7272
7306
  required: false,
7273
7307
  ...pr.default !== void 0 ? { default: pr.default } : {}
7274
7308
  })),
@@ -7276,7 +7310,7 @@ function authorComponentApi(opts) {
7276
7310
  poseCompleteness: { recordedPoses: opts.poses.length, expressible: configs.length }
7277
7311
  };
7278
7312
  const propLines = props.map(
7279
- (pr) => pr.kind === "boolean" ? ` ${pr.name}?: boolean;` : ` ${pr.name}?: ${pr.values.map((v) => `"${v}"`).join(" | ")}; // default "${pr.default}"`
7313
+ (pr) => pr.kind === "boolean" ? ` ${pr.name}?: boolean;` : pr.kind === "string" ? ` ${pr.name}?: string; // CONTENT \u2014 recorded default ${JSON.stringify(pr.default)}` : ` ${pr.name}?: ${pr.values.map((v) => `"${v}"`).join(" | ")}; // default "${pr.default}"`
7280
7314
  );
7281
7315
  const provided = opts.fonts ?? [];
7282
7316
  const recorded = opts.recordedFonts ?? [];
@@ -7289,7 +7323,8 @@ ${propLines.join("\n")}
7289
7323
  [key: string]: unknown; // MUST spread unknown props (incl. data-*) onto the root element
7290
7324
  })
7291
7325
 
7292
- 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.`;
7326
+ ${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.
7327
+ ` : ""}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.`;
7293
7328
  const mappedTokens = /* @__PURE__ */ new Set([...forcedStates, ...props.filter((p) => p.kind === "boolean").map((p) => p.name)]);
7294
7329
  const unmappedInteractionEvidence = interactionEvidence.filter((e) => {
7295
7330
  const sel = e.endsWith(" (selection axis)");
@@ -7399,6 +7434,67 @@ function recordedFontNeeds(setDir) {
7399
7434
  function recordedFontFamilies(setDir) {
7400
7435
  return recordedFontNeeds(setDir).map((n) => n.family);
7401
7436
  }
7437
+ function recordedTextSlots(setDir, repSlugs) {
7438
+ const perRep = [];
7439
+ for (const slug of repSlugs) {
7440
+ const f = path26.join(setDir, slug, "get_design_context.json");
7441
+ if (!existsSync20(f)) continue;
7442
+ const code = envelopeText(f);
7443
+ const texts = [];
7444
+ for (const m of code.matchAll(/>([^<>{}]+)</g)) {
7445
+ const t = decodeXmlEntities(m[1]).trim();
7446
+ if (t === "" || !/[A-Za-z0-9]/.test(t)) continue;
7447
+ texts.push(t);
7448
+ }
7449
+ perRep.push({ slug, texts });
7450
+ }
7451
+ if (perRep.length === 0) return [];
7452
+ const slots = [];
7453
+ for (const rep of perRep) {
7454
+ const used = /* @__PURE__ */ new Set();
7455
+ const unmatched = [];
7456
+ for (const t of rep.texts) {
7457
+ const i = slots.findIndex((sl, idx) => !used.has(idx) && [...sl.values.values()].includes(t));
7458
+ if (i >= 0) {
7459
+ used.add(i);
7460
+ slots[i].values.set(rep.slug, t);
7461
+ } else {
7462
+ unmatched.push(t);
7463
+ }
7464
+ }
7465
+ for (const t of unmatched) {
7466
+ const i = slots.findIndex((_, idx) => !used.has(idx) && !slots[idx].values.has(rep.slug));
7467
+ if (i >= 0) {
7468
+ used.add(i);
7469
+ slots[i].values.set(rep.slug, t);
7470
+ } else {
7471
+ const sl = { values: /* @__PURE__ */ new Map() };
7472
+ sl.values.set(rep.slug, t);
7473
+ slots.push(sl);
7474
+ used.add(slots.length - 1);
7475
+ }
7476
+ }
7477
+ }
7478
+ const usedNames = /* @__PURE__ */ new Set();
7479
+ return slots.map((sl, idx) => {
7480
+ const counts = /* @__PURE__ */ new Map();
7481
+ for (const v of sl.values.values()) counts.set(v, (counts.get(v) ?? 0) + 1);
7482
+ const def = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
7483
+ let prop;
7484
+ if (slots.length === 1) {
7485
+ prop = "children";
7486
+ } else {
7487
+ const word = /[A-Za-z][A-Za-z0-9]*/.exec(def)?.[0]?.toLowerCase();
7488
+ prop = word !== void 0 && word.length >= 2 ? word : `text${idx + 1}`;
7489
+ let n = 2;
7490
+ while (usedNames.has(prop)) prop = `${prop}${n++}`;
7491
+ }
7492
+ usedNames.add(prop);
7493
+ const overrides = {};
7494
+ for (const [slug, v] of sl.values) if (v !== def) overrides[slug] = v;
7495
+ return { prop, default: def, overrides, varies: Object.keys(overrides).length > 0 };
7496
+ });
7497
+ }
7402
7498
  function authorTaskFromSet(setDir, opts = {}) {
7403
7499
  if (opts.fonts === void 0) {
7404
7500
  const resolved = resolvedFontFamilies().map((f) => f.family);
@@ -7427,15 +7523,24 @@ function authorTaskFromSet(setDir, opts = {}) {
7427
7523
  const latticeNames = manifest.latticeNames ?? (existsSync20(setMeta) ? [...envelopeText(setMeta).matchAll(/name="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1])) : void 0);
7428
7524
  const defaults = { ...manifest.defaults ?? {}, ...opts.defaults ?? {} };
7429
7525
  const recordedFonts = recordedFontFamilies(setDir);
7526
+ const textSlots = recordedTextSlots(setDir, manifest.reps.map((r) => r.slug));
7430
7527
  const api = authorComponentApi({
7431
7528
  component: manifest.component,
7432
7529
  poses,
7433
7530
  ...latticeNames !== void 0 ? { latticeNames } : {},
7434
7531
  ...opts.fonts !== void 0 ? { fonts: opts.fonts } : {},
7435
7532
  ...recordedFonts.length > 0 ? { recordedFonts } : {},
7436
- ...Object.keys(defaults).length > 0 ? { defaults } : {}
7533
+ ...Object.keys(defaults).length > 0 ? { defaults } : {},
7534
+ ...textSlots.length > 0 ? { textSlots } : {}
7437
7535
  });
7438
7536
  const { behaviors, prelude, disclosures } = authorBehaviors(api);
7537
+ for (const slot of textSlots) {
7538
+ if (!slot.varies) {
7539
+ disclosures.push(
7540
+ `content prop from recorded text is UNEXERCISED: the recording shows one constant string (${JSON.stringify(slot.default)}) for it, so no config pixel-verifies that the prop reaches the render \u2014 wire it anyway; a sentinel check is future work`
7541
+ );
7542
+ }
7543
+ }
7439
7544
  if (manifest.notRecorded !== void 0) disclosures.push(`not recorded (from the completeness manifest): ${manifest.notRecorded}`);
7440
7545
  const task = {
7441
7546
  set: setDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",