@tendrilapp/cli 0.1.24 → 0.1.25
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 +52 -8
- package/dist/tendril-mcp.js +1 -1
- package/dist/tendril.js +305 -39
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -69,13 +69,29 @@ fidelity gap" was a missing font weight one check away; a stop rule is
|
|
|
69
69
|
for avoiding thrash, never for converting an unverified hypothesis into
|
|
70
70
|
a final answer.
|
|
71
71
|
|
|
72
|
+
Concurrency, whether that is several components in one session or one
|
|
73
|
+
large set:
|
|
74
|
+
- Every agent in flight spends ONE shared Figma budget, and Figma
|
|
75
|
+
meters per minute as well as per day, so parallelism sets the burst
|
|
76
|
+
rate. Scale recorders to the total pose count: up to ~30 poses,
|
|
77
|
+
four; ~30–100 poses, two; beyond ~100 poses, record serially and
|
|
78
|
+
pace it — a large set has no slack to burn on a per-minute limit.
|
|
79
|
+
A measured 8-wide batch hit its rate limit (per-piece resume
|
|
80
|
+
recovered, but the stall is avoidable).
|
|
81
|
+
- COUNT EVERY AGENT THAT CALLS FIGMA, not every agent you launched:
|
|
82
|
+
a recorder NEVER spawns its own recorders. It records the queue it
|
|
83
|
+
was handed and stops. A measured 4-wide run peaked at double the
|
|
84
|
+
intended rate because two recorders spawned children that kept
|
|
85
|
+
calling after their parents reported done — the parent's "done" is
|
|
86
|
+
not proof its calls have stopped. To split a large queue further,
|
|
87
|
+
hand out the extra slices yourself so the whole count stays in one
|
|
88
|
+
place.
|
|
89
|
+
|
|
72
90
|
Batch runs (several components in one session):
|
|
73
|
-
- Cap concurrent recorders at FOUR. All recorders share one Figma
|
|
74
|
-
desktop MCP server; a measured 8-wide batch hit its rate limit
|
|
75
|
-
(per-piece resume recovered, but the stall is avoidable).
|
|
76
91
|
- Batch the questions: plan ALL sets first, then put every
|
|
77
|
-
defaults-to-confirm
|
|
78
|
-
— never one dialog per
|
|
92
|
+
defaults-to-confirm, every missing-interaction-state disclosure and
|
|
93
|
+
the one model question to the user together — never one dialog per
|
|
94
|
+
component, and never mid-generation.
|
|
79
95
|
- Create candidate directories with a bare `mkdir -p <dir>` — no
|
|
80
96
|
`&&`-compounds. Compound variants each need their own permission
|
|
81
97
|
approval; the bare form is one grant for the whole batch.
|
|
@@ -110,8 +126,34 @@ Batch runs (several components in one session):
|
|
|
110
126
|
which X should it show?" — the heuristic's pick is Recommended;
|
|
111
127
|
a different answer re-plans via `defaults` (free until the first
|
|
112
128
|
envelope is ingested, frozen after).
|
|
113
|
-
|
|
114
|
-
|
|
129
|
+
- `interactionStatesToConfirm`: the component set has no hover,
|
|
130
|
+
focus or pressed variant, so nothing recorded shows how it looks
|
|
131
|
+
while someone is using it — and what is never recorded is never
|
|
132
|
+
checked. Say its `statement` and `designFix` as written. This one
|
|
133
|
+
is a DISCLOSURE, not a question: no answer is needed, nothing is
|
|
134
|
+
blocked, and the fix (if the user wants one) is a new variant in
|
|
135
|
+
Figma, not a change in the code. Say only what
|
|
136
|
+
`recordedVariants` contains — never guess from the component's
|
|
137
|
+
name what kind of control it is.
|
|
138
|
+
FEASIBILITY, before the first recording call: the plan output
|
|
139
|
+
states the call arithmetic for this queue (poses × calls per pose).
|
|
140
|
+
Turn it into a verdict with one free check — call the Figma
|
|
141
|
+
`whoami` tool, which names the seat and plan and is exempt from
|
|
142
|
+
Figma's tool-call limits, then divide the queue's call count by the
|
|
143
|
+
daily allowance that seat carries. Never quote an allowance from
|
|
144
|
+
memory; use the one whoami reports. If the set fits inside one
|
|
145
|
+
day, record and say nothing about cost — a set that comfortably
|
|
146
|
+
fits is not worth the user's attention. If it does NOT fit, stop
|
|
147
|
+
and tell the user in plain numbers before recording anything:
|
|
148
|
+
"336 poses × 3 = 1,008 Figma calls. Your Full seat on Professional
|
|
149
|
+
allows 200/day, so this set needs about 5 days." Then let them
|
|
150
|
+
choose: (a) pace it across days — a rate limit loses no recorded
|
|
151
|
+
work, the set persists after every pose and re-running the same
|
|
152
|
+
command resumes; (b) record fewer poses — theirs to decide alone,
|
|
153
|
+
because it buys calls with coverage and there is no tool for it:
|
|
154
|
+
they re-plan with `--sample` in their own terminal; (c) a seat or
|
|
155
|
+
plan with a larger daily allowance. Never sample on their behalf,
|
|
156
|
+
and never start a set you have computed cannot finish.
|
|
115
157
|
Later, `tendril_engine_brief` may carry `fontProvisioning` (the
|
|
116
158
|
design uses a family the local kit lacks). First run
|
|
117
159
|
`tendril fonts resolve --set <recording-dir>` yourself — it fetches
|
|
@@ -146,7 +188,9 @@ Batch runs (several components in one session):
|
|
|
146
188
|
from it. SPEED: after `plan` the whole queue is known and reps are
|
|
147
189
|
independent — fan out across parallel subagents in any order (use
|
|
148
190
|
the cheap `tendril-recorder` agent; recording is transcription,
|
|
149
|
-
not reasoning)
|
|
191
|
+
not reasoning), with HOW MANY set by the concurrency rule above:
|
|
192
|
+
it scales down as poses go up, and no recorder spawns recorders.
|
|
193
|
+
HOST-POLICY GATE (measured, run 6): many hosts
|
|
150
194
|
forbid spawning subagents unless the user requested it, and the
|
|
151
195
|
cost of not delegating is invisible until paid (12 hand-recorded
|
|
152
196
|
reps ≈ 45k main-context tokens that cheap recorders absorb at ~11k
|
package/dist/tendril-mcp.js
CHANGED
|
@@ -25,7 +25,7 @@ var optStr = (d) => z.string().optional().describe(d);
|
|
|
25
25
|
var TOOLS = [
|
|
26
26
|
{
|
|
27
27
|
name: "tendril_record_plan",
|
|
28
|
-
description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback.
|
|
28
|
+
description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback. It may also carry `interactionStatesToConfirm` \u2014 the recording holds no hover/focus/pressed state, so nothing shows how the component behaves when someone uses it: say its `statement` and `designFix` in that SAME one message (the fix is a Figma variant, not code). It is a disclosure, not a gate \u2014 no answer is required and recording proceeds regardless. The output also carries `feasibilityCheck`: the call arithmetic for this queue plus the free `whoami` check that turns it into a verdict \u2014 complete that handshake BEFORE the first recording call, and surface the verdict to the user when the set does not fit their daily allowance.",
|
|
29
29
|
schema: z.object({
|
|
30
30
|
setDir: str("recording set directory to create/resume"),
|
|
31
31
|
component: str("component/system name"),
|
package/dist/tendril.js
CHANGED
|
@@ -928,6 +928,74 @@ var init_plan = __esm({
|
|
|
928
928
|
}
|
|
929
929
|
});
|
|
930
930
|
|
|
931
|
+
// packages/figma/src/recording/envelope-content.ts
|
|
932
|
+
function envelopeParts(payload) {
|
|
933
|
+
const content = payload?.content;
|
|
934
|
+
if (!Array.isArray(content)) return null;
|
|
935
|
+
const blocks = content;
|
|
936
|
+
const text = blocks.map((b) => typeof b.text === "string" ? b.text : "").filter((t) => t !== "").join("\n");
|
|
937
|
+
const image = blocks.find((b) => b.type === "image" && typeof b.data === "string");
|
|
938
|
+
return image === void 0 ? { text } : { text, imageData: image.data };
|
|
939
|
+
}
|
|
940
|
+
function namedCause(text, table) {
|
|
941
|
+
return table.find((entry) => entry.pattern.test(text))?.cause;
|
|
942
|
+
}
|
|
943
|
+
function jsonObjectPayload(text) {
|
|
944
|
+
try {
|
|
945
|
+
const value = JSON.parse(text);
|
|
946
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function checkEnvelopeContent(tool, payload) {
|
|
952
|
+
const parts = envelopeParts(payload);
|
|
953
|
+
if (parts === null) return { ok: true };
|
|
954
|
+
const { text, imageData } = parts;
|
|
955
|
+
const serviceError = namedCause(text, SERVICE_ERROR_SIGNATURES);
|
|
956
|
+
if (serviceError !== void 0) return { ok: false, reason: serviceError };
|
|
957
|
+
const reject = (expected) => {
|
|
958
|
+
const hint = namedCause(text, FAILURE_HINTS);
|
|
959
|
+
return { ok: false, reason: hint === void 0 ? expected : `${hint} \u2014 ${expected}` };
|
|
960
|
+
};
|
|
961
|
+
switch (tool) {
|
|
962
|
+
case "get_metadata":
|
|
963
|
+
case "get_metadata_interior":
|
|
964
|
+
return NODE_MARKUP.test(text) ? { ok: true } : reject('no node markup in the response (a get_metadata response contains elements like <symbol id="\u2026"> or <frame id="\u2026">)');
|
|
965
|
+
case "get_design_context":
|
|
966
|
+
return CODE_DECLARATION.test(text) || ANY_MARKUP.test(text) ? { ok: true } : reject("no code emission or markup in the response (a get_design_context response contains a component's code, or node markup when the design is too large)");
|
|
967
|
+
case "get_variable_defs":
|
|
968
|
+
return jsonObjectPayload(text) === null ? reject("the response is not a JSON object of variable definitions") : { ok: true };
|
|
969
|
+
case "get_screenshot": {
|
|
970
|
+
if (imageData === void 0) return reject("no image block in the response (a get_screenshot envelope carries base64 PNG bytes)");
|
|
971
|
+
const png = checkPngPayload(imageData);
|
|
972
|
+
return png.ok ? { ok: true } : reject(png.reason);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
var NODE_MARKUP, ANY_MARKUP, CODE_DECLARATION, SERVICE_ERROR_SIGNATURES, FAILURE_HINTS;
|
|
977
|
+
var init_envelope_content = __esm({
|
|
978
|
+
"packages/figma/src/recording/envelope-content.ts"() {
|
|
979
|
+
"use strict";
|
|
980
|
+
init_provided_recording();
|
|
981
|
+
NODE_MARKUP = /<[A-Za-z][\w:.-]*(?:\s[^>]*)?\sid="[^"]+"/;
|
|
982
|
+
ANY_MARKUP = /<[A-Za-z][\w:.-]*(?:\s[^>]*)?\/?>/;
|
|
983
|
+
CODE_DECLARATION = /(?:^|\n)[ \t]*(?:const|let|var|function|class|export|import|type|interface|async|@)\b/;
|
|
984
|
+
SERVICE_ERROR_SIGNATURES = [
|
|
985
|
+
{ pattern: /reached the .{0,60}tool call limit/i, cause: "the Figma MCP reported that the account's tool-call limit is exhausted" },
|
|
986
|
+
{ pattern: /tool call limit for your/i, cause: "the Figma MCP reported that the account's tool-call limit is exhausted" },
|
|
987
|
+
{ pattern: /upgrade your (?:seat|plan)/i, cause: "the Figma MCP returned an upgrade/quota notice" }
|
|
988
|
+
];
|
|
989
|
+
FAILURE_HINTS = [
|
|
990
|
+
{ pattern: /\brate[- ]limit/i, cause: "the response looks like a rate-limit error" },
|
|
991
|
+
{ pattern: /\b(?:429|too many requests)\b/i, cause: "the response looks like a rate-limit error" },
|
|
992
|
+
{ pattern: /\b(?:quota|billing|subscription|seat)\b/i, cause: "the response looks like a quota/plan notice" },
|
|
993
|
+
{ pattern: /\b(?:unauthorized|forbidden|not authorized|access denied|401|403)\b/i, cause: "the response looks like an authorization error" },
|
|
994
|
+
{ pattern: /\b(?:error|failed|failure|timed out|timeout)\b/i, cause: "the response looks like an error message" }
|
|
995
|
+
];
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
931
999
|
// packages/figma/src/recording/session.ts
|
|
932
1000
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
933
1001
|
import path from "node:path";
|
|
@@ -1036,10 +1104,14 @@ function ingestEnvelope(setDir, slug, tool, payload) {
|
|
|
1036
1104
|
if (!RECORD_TOOLS.includes(tool)) {
|
|
1037
1105
|
throw new Error(`unknown tool "${tool}" \u2014 expected one of ${RECORD_TOOLS.join(", ")}`);
|
|
1038
1106
|
}
|
|
1107
|
+
const content = checkEnvelopeContent(tool, payload);
|
|
1108
|
+
if (!content.ok) {
|
|
1109
|
+
throw new Error(`${slug}/${tool} was NOT recorded \u2014 ${content.reason}. ${REINGEST_GUIDANCE}`);
|
|
1110
|
+
}
|
|
1039
1111
|
const schema = tool === "get_screenshot" ? ImageEnvelopeSchema : TextEnvelopeSchema;
|
|
1040
1112
|
const parsed = schema.safeParse(payload);
|
|
1041
1113
|
if (!parsed.success) {
|
|
1042
|
-
throw new Error(`${slug}/${tool}
|
|
1114
|
+
throw new Error(`${slug}/${tool} was NOT recorded \u2014 envelope rejected at the boundary: ${parsed.error.issues[0]?.message ?? "invalid"}. ${REINGEST_GUIDANCE}`);
|
|
1043
1115
|
}
|
|
1044
1116
|
const file = containedPath(setDir, slug, `${tool}.json`);
|
|
1045
1117
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -1076,12 +1148,13 @@ function ingestAsset(setDir, slug, name, content) {
|
|
|
1076
1148
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
1077
1149
|
writeFileSync(file, content);
|
|
1078
1150
|
}
|
|
1079
|
-
var RECORD_TOOLS, INTERIOR_TOOL, REQUIRED_TOOLS, SessionManifestSchema, manifestPath, PROTOCOL_ORDER, byProtocolOrder;
|
|
1151
|
+
var RECORD_TOOLS, INTERIOR_TOOL, REQUIRED_TOOLS, SessionManifestSchema, manifestPath, PROTOCOL_ORDER, byProtocolOrder, REINGEST_GUIDANCE;
|
|
1080
1152
|
var init_session = __esm({
|
|
1081
1153
|
"packages/figma/src/recording/session.ts"() {
|
|
1082
1154
|
"use strict";
|
|
1083
1155
|
init_svg_safety();
|
|
1084
1156
|
init_provided_recording();
|
|
1157
|
+
init_envelope_content();
|
|
1085
1158
|
init_plan();
|
|
1086
1159
|
RECORD_TOOLS = ["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "get_metadata_interior"];
|
|
1087
1160
|
INTERIOR_TOOL = "get_metadata_interior";
|
|
@@ -1120,6 +1193,7 @@ var init_session = __esm({
|
|
|
1120
1193
|
manifestPath = (setDir) => path.join(setDir, "recording-set.json");
|
|
1121
1194
|
PROTOCOL_ORDER = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_metadata_interior"];
|
|
1122
1195
|
byProtocolOrder = (tools) => [...tools].sort((a, b) => PROTOCOL_ORDER.indexOf(a) - PROTOCOL_ORDER.indexOf(b));
|
|
1196
|
+
REINGEST_GUIDANCE = "Nothing was written. Recording persists per rep and resumes from disk, so re-run the same command with the real response; if the Figma MCP reported a tool-call or rate limit, wait for the quota to reset first.";
|
|
1123
1197
|
}
|
|
1124
1198
|
});
|
|
1125
1199
|
|
|
@@ -1216,6 +1290,7 @@ var init_src = __esm({
|
|
|
1216
1290
|
init_axis_defaults();
|
|
1217
1291
|
init_plan();
|
|
1218
1292
|
init_session();
|
|
1293
|
+
init_envelope_content();
|
|
1219
1294
|
init_roles();
|
|
1220
1295
|
}
|
|
1221
1296
|
});
|
|
@@ -3740,7 +3815,7 @@ export function Button(props: {
|
|
|
3740
3815
|
[key: string]: unknown; // MUST spread unknown props (incl. data-*) onto the root element
|
|
3741
3816
|
})
|
|
3742
3817
|
|
|
3743
|
-
Rules: plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom. disabled buttons are unfocusable; the spinner honors prefers-reduced-motion (animation: none under the media query). Interactive states are REAL (:hover, :focus-visible) AND statically forceable via the data-tendril-state attribute ("hover" | "focus-visible") spread onto the root; forced and real selectors must share one declaration block, e.g. :is(:hover, [data-tendril-state~="hover"]).
|
|
3818
|
+
Rules: plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom. disabled buttons are unfocusable; the spinner honors prefers-reduced-motion (animation: none under the media query). Interactive states are REAL (:hover, :focus-visible) AND statically forceable via the data-tendril-state attribute ("hover" | "focus-visible") spread onto the root; forced and real selectors must share one declaration block, e.g. :is(:hover, [data-tendril-state~="hover"]). ROOT SIZE IS YOURS AND PIXELS DO NOT CHECK IT, IN EITHER DIRECTION: the mount floors your root to the recorded box (#root > *{min-width:<recorded w>px;min-height:<recorded h>px} \u2014 a floor, never a cap) and the image the scorer compares is a CROP of that box. So a button that computes NARROWER or SHORTER is stretched back and captures byte-identically to a correct one (measured on a real <button>), and an oversized one shows only where its own border, radius or shadow lands back inside the crop. Take both root dimensions from the recorded box, never from a score. Fonts: 'Inter' is provided. Inline all SVGs (loading spinner, icons).
|
|
3744
3819
|
|
|
3745
3820
|
BEHAVIORAL CONTRACT (machine-verified, gating): the root is a focusable <button> with cursor:pointer; real :hover visibly changes it; the loading spinner has a RUNNING CSS animation. Static lookalikes fail.`;
|
|
3746
3821
|
COMBO_FIX = {
|
|
@@ -4426,7 +4501,7 @@ function fontStackFindings(sheets, coverage) {
|
|
|
4426
4501
|
kind: "font-stack",
|
|
4427
4502
|
file: sheet.file,
|
|
4428
4503
|
line: stack.line,
|
|
4429
|
-
message: `font-family ${stack.text} binds to '${family}', which the font kit provides at ${has} \u2014 CSS matches the FAMILY first and picks a weight only inside it (a later family is never consulted for a missing weight, and the prelude's font-synthesis: none rules out faux-bold), so text at weight ${list} ${renders}, silently. ` + (rescue !== void 0 ? `'${rescue}' in this same stack provides ${list}: list it first.` : `No family in this stack provides ${list} \u2014 if the recording shows ${unserved.length === 1 ? "that weight" : "those weights"}, run \`tendril fonts resolve "${family}" --weights ${unserved.join(" ")}\` and re-score.`) + " ADVISORY:
|
|
4504
|
+
message: `font-family ${stack.text} binds to '${family}', which the font kit provides at ${has} \u2014 CSS matches the FAMILY first and picks a weight only inside it (a later family is never consulted for a missing weight, and the prelude's font-synthesis: none rules out faux-bold), so text at weight ${list} ${renders}, silently. ` + (rescue !== void 0 ? `'${rescue}' in this same stack provides ${list}: list it first.` : `No family in this stack provides ${list} \u2014 if the recording shows ${unserved.length === 1 ? "that weight" : "those weights"}, run \`tendril fonts resolve "${family}" --weights ${unserved.join(" ")}\` and re-score.`) + " ADVISORY: the finding gates nothing \u2014 but the wrong face changes pixels, and this fix alone took a measured run from 7 to 9 certified."
|
|
4430
4505
|
});
|
|
4431
4506
|
}
|
|
4432
4507
|
}
|
|
@@ -7250,10 +7325,12 @@ var init_activate = __esm({
|
|
|
7250
7325
|
var record_exports = {};
|
|
7251
7326
|
__export(record_exports, {
|
|
7252
7327
|
instanceLeads: () => instanceLeads,
|
|
7328
|
+
interactionDisclosure: () => interactionDisclosure,
|
|
7253
7329
|
isFigmaAssetUrl: () => isFigmaAssetUrl,
|
|
7254
7330
|
isLocalAssetUrl: () => isLocalAssetUrl,
|
|
7255
7331
|
narrowedRoles: () => narrowedRoles,
|
|
7256
7332
|
nextPayload: () => nextPayload,
|
|
7333
|
+
recordsInteractionState: () => recordsInteractionState,
|
|
7257
7334
|
runRecordAsset: () => runRecordAsset,
|
|
7258
7335
|
runRecordFetch: () => runRecordFetch,
|
|
7259
7336
|
runRecordFinish: () => runRecordFinish,
|
|
@@ -7267,6 +7344,26 @@ import { existsSync as existsSync20, mkdtempSync as mkdtempSync2, readFileSync a
|
|
|
7267
7344
|
import os5 from "node:os";
|
|
7268
7345
|
import path26 from "node:path";
|
|
7269
7346
|
import { writeFileSync as writeFileSync9 } from "node:fs";
|
|
7347
|
+
function recordsInteractionState(reports) {
|
|
7348
|
+
const evident = (s) => stateTokens(s).some((t) => INTERACTION_EVIDENCE_VALUES.has(t));
|
|
7349
|
+
return reports.some((r) => evident(r.axis) || r.domain.some(evident));
|
|
7350
|
+
}
|
|
7351
|
+
function interactionDisclosure(component, reports) {
|
|
7352
|
+
const recordedVariants = reports.map((r) => ({ axis: r.axis, values: r.domain }));
|
|
7353
|
+
const variantSummary = recordedVariants.length === 0 ? "" : recordedVariants.length === 1 ? `${recordedVariants[0].axis} variants for ${andList(recordedVariants[0].values)}` : `variants for ${andList(recordedVariants.map((v) => `${v.axis} (${andList(v.values)})`))}`;
|
|
7354
|
+
const has = recordedVariants.length > 0;
|
|
7355
|
+
const statement = `Your ${component} component ${has ? `has ${variantSummary} \u2014 but none of them is a hover, focus or pressed state` : "has no variants at all, so none of it is a hover, focus or pressed state"}. Nothing in the recording shows how ${component} looks when someone hovers it, tabs to it, or is using it, so Tendril has no picture of those to check the build against. Tendril still builds controls as real, working controls \u2014 never a static lookalike \u2014 and, with no focus state recorded, it uses the browser's own focus indicator rather than inventing a focus ring. You do not need to answer this: recording carries on either way.`;
|
|
7356
|
+
const designFix = `If ${component} should look different when it is focused or hovered, that change belongs in Figma rather than in the code: add a Focus (or Hover) variant to the ${component} component set, record it, and Tendril will match it exactly. If ${component} is not something people interact with, there is nothing to do.`;
|
|
7357
|
+
return {
|
|
7358
|
+
component,
|
|
7359
|
+
recordedVariants,
|
|
7360
|
+
variantSummary,
|
|
7361
|
+
statement,
|
|
7362
|
+
designFix,
|
|
7363
|
+
instruction: "DISCLOSURE, not a question. Say `statement` and `designFix` to a present user at PLAN time, in the SAME message as defaultsToConfirm and every other plan-time question \u2014 one message per session, never one per component and never mid-generation. No answer is required and nothing blocks; proceed with recording whatever they say. State ONLY what `recordedVariants` contains: never infer from the component's NAME what kind of control it is. Non-interactive runs: put the statement in your report.",
|
|
7364
|
+
blocking: false
|
|
7365
|
+
};
|
|
7366
|
+
}
|
|
7270
7367
|
function symbolsFromMetadataEnvelope(file, sourceFrame) {
|
|
7271
7368
|
const env = JSON.parse(readFileSync16(file, "utf8"));
|
|
7272
7369
|
const text = env.content.map((c) => c.text ?? "").join("\n");
|
|
@@ -7420,8 +7517,47 @@ function runRecordPlan(opts) {
|
|
|
7420
7517
|
const { manifest, plan, resumed, toppedUp } = planSet(opts.setDir, opts.component, symbols, { ...Object.keys(defaults).length > 0 ? { defaults } : {}, ...opts.sample === true ? { sample: true } : {} });
|
|
7421
7518
|
const defaultReports = reportAxisDefaults(symbols, Object.keys(defaults).length > 0 ? defaults : void 0);
|
|
7422
7519
|
const toConfirm = resumed ? [] : defaultReports.filter((r) => (r.rule === "non-interaction" || r.rule === "frequency") && r.domain.length > 1);
|
|
7423
|
-
const
|
|
7424
|
-
const
|
|
7520
|
+
const interactionStatesToConfirm = recordsInteractionState(defaultReports) ? void 0 : interactionDisclosure(manifest.component, defaultReports);
|
|
7521
|
+
const poses = manifest.reps.length;
|
|
7522
|
+
const callLow = poses * FIGMA_CALLS_PER_REP;
|
|
7523
|
+
const callHigh = poses * FIGMA_CALLS_PER_REP_WORST;
|
|
7524
|
+
const posesRemaining = sessionStatus(opts.setDir).reps.filter((r) => r.missing.length > 0).length;
|
|
7525
|
+
const callsNeeded = posesRemaining * FIGMA_CALLS_PER_REP;
|
|
7526
|
+
const callSentence = posesRemaining === poses ? `${poses} poses \xD7 ${FIGMA_CALLS_PER_REP} calls per pose = ${callsNeeded} Figma calls` : `${posesRemaining} poses still to record \xD7 ${FIGMA_CALLS_PER_REP} calls per pose = ${callsNeeded} Figma calls (${poses - posesRemaining} of ${poses} already on disk)`;
|
|
7527
|
+
const arithmetic = `${callSentence} (up to ${posesRemaining * FIGMA_CALLS_PER_REP_WORST} if screenshots or assets need a second fetch)`;
|
|
7528
|
+
const multiDay = callsNeeded > MULTI_DAY_CALL_THRESHOLD;
|
|
7529
|
+
const feasibilityCheck = {
|
|
7530
|
+
arithmetic,
|
|
7531
|
+
callsNeeded,
|
|
7532
|
+
callsWorstCase: posesRemaining * FIGMA_CALLS_PER_REP_WORST,
|
|
7533
|
+
posesToRecord: posesRemaining,
|
|
7534
|
+
callsPerPose: FIGMA_CALLS_PER_REP,
|
|
7535
|
+
instruction: "BEFORE the first recording call, call the Figma MCP `whoami` tool \u2014 it names the seat and plan and is EXEMPT from Figma's tool-call limits, so this check costs nothing and spends no quota. Read the daily tool-call allowance for that seat/plan and divide: callsNeeded \xF7 allowance = days this set needs. Tendril owns the multiplication above; the allowance is Figma's and is theirs to change, so take it from whoami and never from memory.",
|
|
7536
|
+
surface: multiDay ? "This set exceeds every daily allowance we have heard of, so it CANNOT finish in one day. State the verdict to the user in plain numbers BEFORE recording anything and let them choose from `options` \u2014 do not start on the assumption it will fit." : "Say nothing about cost if the division fits inside one day's allowance \u2014 a set that comfortably fits is not worth the user's attention. Surface the verdict and `options` only when it does not fit.",
|
|
7537
|
+
verdictTemplate: `${callSentence}. Your <seat> seat on <plan> allows <allowance>/day. This set needs about <days> day(s) at that rate.`,
|
|
7538
|
+
options: [
|
|
7539
|
+
{
|
|
7540
|
+
id: "pace",
|
|
7541
|
+
decidedBy: "user, and you can carry it out",
|
|
7542
|
+
text: "Spread the recording across days, staying inside the daily allowance. Nothing is lost at a limit: the set persists after every pose and re-running the same command resumes from disk, so a rate limit costs waiting, never recorded work."
|
|
7543
|
+
},
|
|
7544
|
+
{
|
|
7545
|
+
id: "sample",
|
|
7546
|
+
// Sampling trades away lattice coverage, so it stays a human
|
|
7547
|
+
// decision structurally, not by instruction: it exists only as
|
|
7548
|
+
// a CLI flag and the MCP surface has no parameter for it.
|
|
7549
|
+
decidedBy: "HUMAN ONLY \u2014 offer it, never choose it, and you cannot run it",
|
|
7550
|
+
text: "Record a reduced pose set (anchor + one-factor sweeps + conflict crosses) instead of the full variant matrix. This buys calls with coverage \u2014 sampling is blind to multi-axis interactions and the report discloses the poses it skipped \u2014 so only the user may accept that trade. There is no tool parameter for it; the user runs it themselves in their own terminal, before any pose is recorded.",
|
|
7551
|
+
userRuns: [`rm ${path26.join(opts.setDir, "recording-set.json")}`, `${tendrilCommand(`record plan --set ${opts.setDir} --component ${manifest.component}`)} --sample`]
|
|
7552
|
+
},
|
|
7553
|
+
{
|
|
7554
|
+
id: "larger-allowance",
|
|
7555
|
+
decidedBy: "user",
|
|
7556
|
+
text: "A seat or plan with a larger daily allowance, if their organisation allows it. Figma's terms, not ours \u2014 state it as an option, never as advice to spend money."
|
|
7557
|
+
}
|
|
7558
|
+
],
|
|
7559
|
+
limitHintIfWhoamiIsSilent: LIMIT_HINT
|
|
7560
|
+
};
|
|
7425
7561
|
emitData(
|
|
7426
7562
|
opts,
|
|
7427
7563
|
{
|
|
@@ -7437,11 +7573,19 @@ function runRecordPlan(opts) {
|
|
|
7437
7573
|
...metadataTruncated ? { metadataTruncated: true } : {},
|
|
7438
7574
|
...toppedUp !== void 0 ? { toppedUp } : {},
|
|
7439
7575
|
notRecorded: manifest.notRecorded ?? null,
|
|
7440
|
-
|
|
7576
|
+
// The whole-set ledger, numbers only — what recording this queue
|
|
7577
|
+
// from nothing costs. What it costs FROM HERE, and what to do
|
|
7578
|
+
// about it, is feasibilityCheck's job.
|
|
7579
|
+
figmaCallEstimate: { reps: poses, callsPerRep: FIGMA_CALLS_PER_REP, calls: `~${callLow}\u2013${callHigh}`, callsMin: callLow, callsMax: callHigh },
|
|
7580
|
+
// A sibling of defaultsToConfirm: a handshake the agent completes
|
|
7581
|
+
// with the user before the queue costs anything, not a field to
|
|
7582
|
+
// relay as duration.
|
|
7583
|
+
feasibilityCheck,
|
|
7441
7584
|
// Cold-start fix (run 7): the first instruction rides the plan
|
|
7442
7585
|
// response, so record_next is never needed to begin — it exists
|
|
7443
7586
|
// only for resuming.
|
|
7444
7587
|
next: nextPayload(opts.setDir),
|
|
7588
|
+
...interactionStatesToConfirm !== void 0 ? { interactionStatesToConfirm } : {},
|
|
7445
7589
|
...toConfirm.length > 0 ? {
|
|
7446
7590
|
defaultsToConfirm: {
|
|
7447
7591
|
instruction: "HEURISTIC defaults: the designer named no literal Default and no override was given, so the code guessed the component's zero point \u2014 the pose an empty-props mount shows and the pose behaviour checks aim at. When a USER is present, ask each question below BEFORE recording; if they pick a different value, re-run this exact plan command with --default <Axis>=<Value> (allowed until the first envelope is ingested; frozen with the recording after). Non-interactive: proceed with the resolved values and state them in your report.",
|
|
@@ -7454,6 +7598,42 @@ function runRecordPlan(opts) {
|
|
|
7454
7598
|
} : {}
|
|
7455
7599
|
},
|
|
7456
7600
|
() => {
|
|
7601
|
+
const writeFeasibility = () => {
|
|
7602
|
+
if (callsNeeded === 0) return;
|
|
7603
|
+
process.stdout.write(`recording cost: ${arithmetic}
|
|
7604
|
+
`);
|
|
7605
|
+
if (multiDay) {
|
|
7606
|
+
process.stdout.write(`FEASIBILITY: ${callsNeeded} calls exceeds every daily Figma allowance we have heard of \u2014 this CANNOT finish in one day.
|
|
7607
|
+
`);
|
|
7608
|
+
process.stdout.write(` ask Figma whoami (free \u2014 exempt from tool-call limits) for this seat's daily allowance, divide ${callsNeeded} by it, and say how many DAYS this needs before recording
|
|
7609
|
+
`);
|
|
7610
|
+
process.stdout.write(` (a) pace it across days \u2014 the set persists after every pose, so a rate limit costs waiting, never recorded work
|
|
7611
|
+
`);
|
|
7612
|
+
process.stdout.write(` (b) record fewer poses \u2014 YOUR call, not an agent's: delete recording-set.json and re-plan with --sample (buys calls with coverage)
|
|
7613
|
+
`);
|
|
7614
|
+
process.stdout.write(` (c) a seat or plan with a larger daily allowance
|
|
7615
|
+
`);
|
|
7616
|
+
} else {
|
|
7617
|
+
process.stdout.write(`FEASIBILITY: check ${callsNeeded} against this seat's daily Figma allowance (ask whoami \u2014 it is free) before recording; it only needs raising if it does not fit
|
|
7618
|
+
`);
|
|
7619
|
+
}
|
|
7620
|
+
};
|
|
7621
|
+
const writeInteraction = () => {
|
|
7622
|
+
if (interactionStatesToConfirm === void 0) return;
|
|
7623
|
+
const d = interactionStatesToConfirm;
|
|
7624
|
+
process.stdout.write(
|
|
7625
|
+
`CONFIRM states: ${d.component} records no hover, focus or pressed state${d.recordedVariants.length > 0 ? ` \u2014 it has ${d.variantSummary}` : " (it has no variants at all)"}
|
|
7626
|
+
`
|
|
7627
|
+
);
|
|
7628
|
+
process.stdout.write(` nothing in the recording shows how it looks when someone hovers it, tabs to it, or is using it
|
|
7629
|
+
`);
|
|
7630
|
+
process.stdout.write(` Tendril still builds controls as real controls \u2014 never a static lookalike \u2014 and uses the browser's own focus indicator instead of inventing a focus ring
|
|
7631
|
+
`);
|
|
7632
|
+
process.stdout.write(` want a focus ring of your own? add a Focus variant to the ${d.component} component set in Figma and record again \u2014 Tendril will match it exactly
|
|
7633
|
+
`);
|
|
7634
|
+
process.stdout.write(` nothing to answer \u2014 recording carries on either way
|
|
7635
|
+
`);
|
|
7636
|
+
};
|
|
7457
7637
|
if (resumed) {
|
|
7458
7638
|
if (opts.sample === true && (manifest.planMode ?? "sample") === "full") {
|
|
7459
7639
|
process.stdout.write("NOTE: --sample has no effect on a resumed full-matrix set \u2014 delete recording-set.json to re-plan sampled (partial coverage is a deliberate choice)\n");
|
|
@@ -7467,18 +7647,20 @@ function runRecordPlan(opts) {
|
|
|
7467
7647
|
process.stdout.write(`resumed existing plan (${manifest.reps.length} reps, ${manifest.planMode ?? "sample"} mode) \u2014 delete recording-set.json to re-plan
|
|
7468
7648
|
`);
|
|
7469
7649
|
}
|
|
7650
|
+
writeFeasibility();
|
|
7651
|
+
writeInteraction();
|
|
7470
7652
|
return;
|
|
7471
7653
|
}
|
|
7472
7654
|
for (const r of plan.reps) process.stdout.write(`planned ${r.slug.padEnd(24)} ${r.nodeId} (${r.tier})
|
|
7473
7655
|
`);
|
|
7474
7656
|
if (plan.notRecorded.length > 0) process.stdout.write(`not recorded (${plan.notRecorded.length}): disclosed in the manifest
|
|
7475
7657
|
`);
|
|
7476
|
-
|
|
7477
|
-
`);
|
|
7658
|
+
writeFeasibility();
|
|
7478
7659
|
for (const q2 of toConfirm) {
|
|
7479
7660
|
process.stdout.write(`CONFIRM ${q2.axis}: default resolved to "${q2.value}" by heuristic (${q2.rule}) \u2014 ask the user; change with --default before recording
|
|
7480
7661
|
`);
|
|
7481
7662
|
}
|
|
7663
|
+
writeInteraction();
|
|
7482
7664
|
}
|
|
7483
7665
|
);
|
|
7484
7666
|
}
|
|
@@ -7632,6 +7814,14 @@ async function runRecordIngest(opts) {
|
|
|
7632
7814
|
remediation: "Save the get_variable_defs response verbatim as a text envelope."
|
|
7633
7815
|
});
|
|
7634
7816
|
}
|
|
7817
|
+
const content = checkEnvelopeContent("get_variable_defs", payload);
|
|
7818
|
+
if (!content.ok) {
|
|
7819
|
+
fail(opts, ExitCode.InputValidation, {
|
|
7820
|
+
error: `set-level get_variable_defs was NOT recorded \u2014 ${content.reason}`,
|
|
7821
|
+
code: "envelope-rejected",
|
|
7822
|
+
remediation: REINGEST_GUIDANCE
|
|
7823
|
+
});
|
|
7824
|
+
}
|
|
7635
7825
|
writeFileSync9(path26.join(opts.setDir, "get_variable_defs.json"), `${JSON.stringify(payload, null, 1)}
|
|
7636
7826
|
`);
|
|
7637
7827
|
emitData(opts, { ingested: "get_variable_defs", rep: "__set__", setLevel: true, next: nextPayload(opts.setDir) }, () => {
|
|
@@ -7656,7 +7846,7 @@ async function runRecordIngest(opts) {
|
|
|
7656
7846
|
fail(opts, ExitCode.InputValidation, {
|
|
7657
7847
|
error: err instanceof Error ? err.message : String(err),
|
|
7658
7848
|
code: "envelope-rejected",
|
|
7659
|
-
remediation: "
|
|
7849
|
+
remediation: "Fix what the error names, then re-run this exact command \u2014 recording resumes from disk, so nothing already recorded is lost."
|
|
7660
7850
|
});
|
|
7661
7851
|
}
|
|
7662
7852
|
}
|
|
@@ -7891,7 +8081,7 @@ function runRecordFinish(opts) {
|
|
|
7891
8081
|
`);
|
|
7892
8082
|
}
|
|
7893
8083
|
}
|
|
7894
|
-
var ENVELOPE_HELP, isAutoFetchAssetUrl, describeRoleLoss;
|
|
8084
|
+
var FIGMA_CALLS_PER_REP, FIGMA_CALLS_PER_REP_WORST, MULTI_DAY_CALL_THRESHOLD, LIMIT_HINT, stateTokens, MAX_SPOKEN_VALUES, andList, ENVELOPE_HELP, isAutoFetchAssetUrl, describeRoleLoss;
|
|
7895
8085
|
var init_record = __esm({
|
|
7896
8086
|
"packages/cli/src/commands/record.ts"() {
|
|
7897
8087
|
"use strict";
|
|
@@ -7901,6 +8091,25 @@ var init_record = __esm({
|
|
|
7901
8091
|
init_output();
|
|
7902
8092
|
init_entitlement();
|
|
7903
8093
|
init_invocation();
|
|
8094
|
+
FIGMA_CALLS_PER_REP = 3;
|
|
8095
|
+
FIGMA_CALLS_PER_REP_WORST = 4;
|
|
8096
|
+
MULTI_DAY_CALL_THRESHOLD = 600;
|
|
8097
|
+
LIMIT_HINT = {
|
|
8098
|
+
status: "UNVERIFIED \u2014 recorded 2026-08-14 from Figma's published documentation and one field report; Figma changes these at will.",
|
|
8099
|
+
use: "Use ONLY if whoami states no allowance, and say it is unverified when you show it. The allowance whoami reports always wins.",
|
|
8100
|
+
perDayByPlan: {
|
|
8101
|
+
"Dev/Full seat, Professional": "~200 tool calls/day",
|
|
8102
|
+
"Dev/Full seat, Organization or Enterprise": "~600 tool calls/day",
|
|
8103
|
+
Starter: "~6 tool calls/month"
|
|
8104
|
+
},
|
|
8105
|
+
burst: "A per-minute ceiling is documented alongside the daily one, so parallel recorders can trip a limit long before the daily budget runs out."
|
|
8106
|
+
};
|
|
8107
|
+
stateTokens = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").split("-").filter((t) => t !== "");
|
|
8108
|
+
MAX_SPOKEN_VALUES = 6;
|
|
8109
|
+
andList = (items) => {
|
|
8110
|
+
const shown = items.length <= MAX_SPOKEN_VALUES ? items : [...items.slice(0, MAX_SPOKEN_VALUES), `${items.length - MAX_SPOKEN_VALUES} more`];
|
|
8111
|
+
return shown.length <= 1 ? shown[0] ?? "" : `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
|
|
8112
|
+
};
|
|
7904
8113
|
ENVELOPE_HELP = `Envelope format: text tools save {"content":[{"type":"text","text":"<VERBATIM response text incl. any Currently-selected-nodes block>"}]}; get_screenshot: do NOT download the image yourself \u2014 pass its image_url to \`${tendrilCommand("record fetch")}\` (MCP: tendril_record_fetch), which pulls the bytes to disk directly. That keeps the pixel ground truth out of your context and costs one approval instead of a shell command per asset. Assets are asset-<first-8-hex-of-figma-uuid>.<ext>; their URLs appear as const declarations inside the design-context text and also expire.`;
|
|
7905
8114
|
isAutoFetchAssetUrl = (url) => isFigmaAssetUrl(url) || isLocalAssetUrl(url);
|
|
7906
8115
|
describeRoleLoss = (loss) => loss.kind === "main" ? `drops main "${loss.main}"` : `stops "${loss.part}" being a part of main "${loss.main}"`;
|
|
@@ -8129,7 +8338,7 @@ ${preludeLines.join("\n")}` : ""}${absentLines.length > 0 ? `
|
|
|
8129
8338
|
MISSING FEATURES (absent-ink clusters \u2014 recorded marks your render leaves out or paints invisibly; GATING at the cert bar. Fix the named node's ink \u2014 a config carrying one cannot certify):
|
|
8130
8339
|
${absentLines.join("\n")}` : ""}
|
|
8131
8340
|
|
|
8132
|
-
Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Reading the pair: ink=1
|
|
8341
|
+
Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Reading the pair: ink credits a recorded pixel when ANY render ink lands within 1px of it, so ink=1 proves coverage \u2014 not presence, shape, or colour. A wrong-shaped mark over the right region, or a wrong-coloured one that is still ink, scores 1.000; and a recorded mark within ~30 channel-sum of the backdrop (#f6f6f6 on white is 27) is not ink at all, so it can be absent at ink 1.000 with no MISSING line. With ink=1 AND low sim, coverage held while pixels differ, so start with the TWO causes below \u2014 but never read ink=1 as "nothing is missing": confirm every faint or small recorded mark (hairlines, dividers, low-contrast controls) exists in your render. GEOMETRY: wrong position/size/radius; the diff shows shifted edges and bands. WRONG TEXT WEIGHT OR FACE: the diff shows a uniform haze over glyph runs; CSS binds the font FAMILY before the weight, a later family in the stack is never consulted for a weight the first one lacks, and font-synthesis: none rules out faux-bold \u2014 so a stack led by a family the kit holds at one weight renders every other weight in that face, silently. Check the font stack against the kit's cached faces before concluding geometry is at fault. LOW ink with high sim means recorded marks are absent or painted invisibly. Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
|
|
8133
8342
|
}
|
|
8134
8343
|
function archivePriorRun(outDir) {
|
|
8135
8344
|
if (!existsSync21(path27.join(outDir, "run-log.json")) && !existsSync21(path27.join(outDir, "loop-state.json"))) return void 0;
|
|
@@ -8493,7 +8702,7 @@ ${propLines.join("\n")}
|
|
|
8493
8702
|
|
|
8494
8703
|
${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.
|
|
8495
8704
|
` : ""}${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.
|
|
8496
|
-
` : ""}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}
|
|
8705
|
+
` : ""}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}ROOT SIZE IS YOURS AND PIXELS DO NOT CHECK IT, IN EITHER DIRECTION: the mount floors your root to the recorded box (#root > *{min-width:<recorded w>px;min-height:<recorded h>px} \u2014 a floor, never a cap), and the image the scorer compares is a CROP of that box, so paint outside it is never compared at all. Measured through the real scorer against a correct 120x40 root: an UNDERSIZED 80x28 root and OVERSIZED 160x52 and 124x44 roots ALL scored sim 1.000 / ink 1.000 with a byte-identical crop, as long as internals stay literal and top-left anchored. An oversized root shows only when its own border, radius or shadow \u2014 or a re-centred internal layout \u2014 lands back inside the crop. And some roots are not floored at all: a non-replaced INLINE root (a bare <label>, or an <a href> for a link variant) ignores width/height/min-width/min-height entirely and renders content-sized whatever you declare. So take both root dimensions from the recorded box in the payload, and give the root a display that honours them \u2014 no score, passing or failing, is evidence they are right. 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.`;
|
|
8497
8706
|
const mappedTokens = /* @__PURE__ */ new Set([...forcedStates, ...props.filter((p) => p.kind === "boolean").map((p) => p.name)]);
|
|
8498
8707
|
const unmappedInteractionEvidence = interactionEvidence.filter((e) => {
|
|
8499
8708
|
const sel = e.endsWith(" (selection axis)");
|
|
@@ -8891,7 +9100,7 @@ ALL prose instructions live ABOVE the task payload \u2014 the payload contains o
|
|
|
8891
9100
|
|
|
8892
9101
|
ASSETS: inline the SVG assets you RENDER byte-verbatim, unchanged \u2014 never redraw or approximate an icon. Recorded assets that no scored config displays may be omitted. Two techniques reconcile that rule with reuse (both measured at 1.000): a glyph recorded once but shown in several colors keeps its bytes (fill attribute included) and is repainted with a CSS fill rule \u2014 a CSS declaration outranks an SVG presentation attribute, so one verbatim copy serves every tone; and multi-part glyphs needing fractional placement can nest each verbatim asset as a child <svg x= y=> inside one integer-origin frame (SVG user-space coordinates are exact), an alternative to the transform: scale() pattern.
|
|
8893
9102
|
|
|
8894
|
-
GEOMETRY ARBITRATION (when emission styling and the recorded box disagree, the BOX wins): Figma strokes are INSIDE the box \u2014 border+padding sums that overshoot a recorded dimension mean use an inset box-shadow or subtract the border from the padding. That rule covers strokes AT the box edge only: when a config's reference PNG is LARGER than its recorded box, the recording itself proves an OUTWARD effect \u2014 a hover ring, glow, or shadow past the frame \u2014 and the outward part is drawn outward (box-shadow spread, outline), never forced inside. On such configs the score report carries a "seat" field ({x, y} \u2014 where the recorded box sits inside the larger reference), derived from the recording's own effect geometry (shadow offset/radius/spread) \u2014 informational, so an offset shadow's asymmetric bleed is not misread as a registration error. Following strokes-inside against a padded reference contradicts the recorded pad and cost a measured five configs a full round. The harness mounts each config at its recorded box (width \xD7 height,
|
|
9103
|
+
GEOMETRY ARBITRATION (when emission styling and the recorded box disagree, the BOX wins): Figma strokes are INSIDE the box \u2014 border+padding sums that overshoot a recorded dimension mean use an inset box-shadow or subtract the border from the padding. That rule covers strokes AT the box edge only: when a config's reference PNG is LARGER than its recorded box, the recording itself proves an OUTWARD effect \u2014 a hover ring, glow, or shadow past the frame \u2014 and the outward part is drawn outward (box-shadow spread, outline), never forced inside. On such configs the score report carries a "seat" field ({x, y} \u2014 where the recorded box sits inside the larger reference), derived from the recording's own effect geometry (shadow offset/radius/spread) \u2014 informational, so an offset shadow's asymmetric bleed is not misread as a registration error. Following strokes-inside against a padded reference contradicts the recorded pad and cost a measured five configs a full round. The harness mounts each config at its recorded box (width \xD7 height) and FLOORS your root to it, never caps it; build to those recorded dimensions, not to guessed viewports \u2014 the prescribed API below says what that floor hides. Known rasterizer delta: Chrome often seats small text ONE PIXEL HIGHER than Figma in an identically sized box. Correct it with a PAINT-ONLY offset on those text runs \u2014 position: relative with top: 1px \u2014 never with padding or margin, which would grow the recorded box this same paragraph calls truth. TREAT IT AS A MEASUREMENT, NOT A RULE: measured cases are 12px/16px and 14px/20px needing the nudge and 14px/18px not, so font size alone does not predict it and neither does any formula we can currently defend. RULE ORDER when both could apply (run 7: a config sat 0.0004 under the bar AND showed uniform spread \u2014 the rules pointed opposite ways): read the DIFF FIRST; the uniform-spread stop-rule below OUTRANKS the try-it rule here. Only when the diff shows a shifted band: apply the nudge, re-score, and keep it only if it helped. Four measured signatures, so do not expect one: on one kit it drove ink recall to 1.000; on another ink was already 1.000 and only similarity moved (mean 0.961 \u2192 0.976, four configs from 0.003 above the bar to 0.028); on a third it REGRESSED a passing bundle from 9/9 to 3/9 configs and had to be reverted; on a fourth it made every FAILING config worse too (tinted in-section surfaces: ink dropped ~0.011 across all eight configs and pushed a passing one under the bar). It is a hypothesis to score, never a default \u2014 NEVER apply it to a bundle that already passes, and when the failing family's diff shows a uniform low-density spread across the glyph band rather than a shifted band, the cause is rasterization weight, not seating: the nudge cannot help and a heavier hypothesis has no recorded truth to justify it \u2014 report the honest sub-bar result instead. Design-token NAMES in the payload are Figma names \u2014 canonicalize to valid CSS idents (lowercase kebab, e.g. "Text/text-primary" \u2192 --text-text-primary) if you emit tokens.css; literal values are equally acceptable. NEVER invent a token to satisfy a lint finding: quality findings are advisory, and a recorded value the kit has no token for is CORRECT as a literal \u2014 a made-up token name corrupts the tokens file as a record of the kit.
|
|
8895
9104
|
|
|
8896
9105
|
RECORDED BOXES ARE TRUTH even when inconsistent: the same string may
|
|
8897
9106
|
have different recorded widths across variants (designer resizing) \u2014 a
|
|
@@ -8908,13 +9117,13 @@ PAINT & PLATFORM TRAPS (each measured on a paid run; every one passed typecheck,
|
|
|
8908
9117
|
- A native <dialog> carries user-agent padding: 1em. Override every side, or the root grows past its recorded box and every band inside shifts.
|
|
8909
9118
|
- THE PAGE CANVAS IS NOT YOURS. Never paint the recording's page background into the component \u2014 no canvas-coloured plates across the root box, no square shadow spread carrying the canvas past the frame. The mount composites your render over the recorded canvas, so transparency wherever the recording shows canvas is both correct and scores correctly; the score report's canvasCoupling counts canvas pixels your render refuses to let the page repaint, and a component that paints the canvas is wrong on every real page.
|
|
8910
9119
|
- TOKENS SCOPE TO YOUR ROOT CLASS, NEVER :root. Bundles compose on real pages: token names on :root collide across independently generated components and the last stylesheet loaded silently rewrites the others (measured: two colliding names flipped certified surfaces translucent). Declare every token under the component's root class.
|
|
8911
|
-
- WHEN TWO AXES BOTH CONTROL PAINT, THEY COMPOSE THROUGH CUSTOM PROPERTIES \u2014 one axis SETS variables, the other CONSUMES them. Direct paint rules on both axes have equal specificity, so source order silently drops one axis for exactly the crossed poses (measured: variant \xD7 tone \u2014 primary+critical rendered dark neutral instead of the recorded red
|
|
9120
|
+
- WHEN TWO AXES BOTH CONTROL PAINT, THEY COMPOSE THROUGH CUSTOM PROPERTIES \u2014 one axis SETS variables, the other CONSUMES them. Direct paint rules on both axes have equal specificity, so source order silently drops one axis for exactly the crossed poses (measured: variant \xD7 tone \u2014 primary+critical rendered dark neutral instead of the recorded red). CROSSED POSES ARE SCORED: the harness mounts every RECORDED pose, crosses included \u2014 measured on a bundle passing 20/20 (13 single-axis configs, 7 crosses), adding one later same-specificity rule left every single-axis config the rule did not itself target byte-stable and dropped all five crossed configs it touched from ~0.99 to sim 0.09\u20130.16. Only a cross with NO recorded pose goes ungraded.
|
|
8912
9121
|
|
|
8913
9122
|
INTERACTION-READY BY DEFAULT: components are real controls, never static lookalikes \u2014 use the native element matching the archetype (button, input[type=radio|checkbox], select\u2026), real handlers, keyboard operability, and real state (checked/disabled/:hover/:focus-visible). Behavioral checks fail statues. (The forcing-hook rule is specified once, in the prescribed API below.) Your file runs in a bare browser bundle: it must be fully self-contained. IMPORT EVERY React API you use explicitly \u2014 e.g. import { useState, useRef, useEffect } from "react" \u2014 nothing is provided globally; a missing import crashes the mount and every config scores 0.
|
|
8914
9123
|
|
|
8915
9124
|
STATE SEMANTICS (prescribed \u2014 do not choose your own):
|
|
8916
9125
|
- The component OWNS its interaction state, seeded from the prop. A selection/checked prop supplies the INITIAL value; user interaction updates internal state and must visibly commit without any parent re-render. A purely controlled component that ignores its own clicks fails the behavioral checks. Call an on\u2026Change callback when one is in the prescribed API, but never depend on it to update your own paint.
|
|
8917
|
-
- Read-only means operable but not committable: the control stays focusable and in the accessibility tree with aria-readonly="true", and cancels commit on both pointer and keyboard. Do NOT
|
|
9126
|
+
- Read-only means operable but not committable: the control stays focusable and in the accessibility tree with aria-readonly="true", and cancels commit on both pointer and keyboard. Do NOT fake it with pointer-events:none or disabled. pointer-events:none removes only the POINTER path \u2014 the element stops being hit-tested, so clicks land on whatever is behind it \u2014 while Tab still reaches it and .focus() still lands: the control still takes focus and still commits from the KEYBOARD (measured in Chrome: Space checked a pointer-events:none checkbox). Of the two, ONLY disabled refuses focus, which is why reaching for pointer-events:none to make a pose unfocusable fails disabled-not-focusable with "disabled element took focus".
|
|
8918
9127
|
- GEOMETRY FOLLOWS PROPS; ONLY PAINT FOLLOWS LIVE STATE. Per-variant sizing must key off the pose props the caller passed, never off live interaction state \u2014 otherwise clicking resizes the component (a recorded selected/unselected width difference is a designer artifact, not a consequence of selection). The scorer renders static poses and structurally cannot see this, so it is on you.
|
|
8919
9128
|
|
|
8920
9129
|
${systemApi}
|
|
@@ -10120,6 +10329,8 @@ __export(verify_exports, {
|
|
|
10120
10329
|
foldConfigStatus: () => foldConfigStatus,
|
|
10121
10330
|
interactionCoverage: () => interactionCoverage,
|
|
10122
10331
|
occlusionReport: () => occlusionReport,
|
|
10332
|
+
operabilityLine: () => operabilityLine,
|
|
10333
|
+
operabilityReport: () => operabilityReport,
|
|
10123
10334
|
resolveComposition: () => resolveComposition,
|
|
10124
10335
|
runVerify: () => runVerify
|
|
10125
10336
|
});
|
|
@@ -10134,6 +10345,43 @@ function interactionCoverage(behaviors) {
|
|
|
10134
10345
|
operability: interaction.length > 0 && interaction.every((b) => b.pass) ? "verified" : "unverified"
|
|
10135
10346
|
};
|
|
10136
10347
|
}
|
|
10348
|
+
function operabilityReport(input) {
|
|
10349
|
+
const { interactionChecks, interactionPassed, preludeChecks } = interactionCoverage(input.behaviors);
|
|
10350
|
+
const prelude = ` The ${preludeChecks} behavior check(s) that did run are page-level prelude style hygiene, which says nothing about whether the component works.`;
|
|
10351
|
+
if (interactionChecks === 0) {
|
|
10352
|
+
const evidence = input.interactionEvidence;
|
|
10353
|
+
if (evidence === void 0) {
|
|
10354
|
+
return {
|
|
10355
|
+
short: "nothing authored; recorded poses never derived",
|
|
10356
|
+
unavailable: `no interaction behaviours were authored for this component, and this run never derived the recording's interactive poses \u2014 nothing about focus, typing, or keyboard operation was measured, and nothing here rules out a static lookalike.${prelude}`
|
|
10357
|
+
};
|
|
10358
|
+
}
|
|
10359
|
+
if (evidence.length > 0) {
|
|
10360
|
+
return {
|
|
10361
|
+
short: "interactive poses recorded, none authored",
|
|
10362
|
+
unavailable: `the recording PROVES interactive poses (${evidence.join(
|
|
10363
|
+
", "
|
|
10364
|
+
)}) and ZERO interaction behaviours were authored to cover them \u2014 nothing about focus, typing, or keyboard operation was measured; this is an instrument failure, not a working component.${prelude}`
|
|
10365
|
+
};
|
|
10366
|
+
}
|
|
10367
|
+
return { short: "no interactive poses recorded", unavailable: `${NO_INTERACTIVE_POSES}${prelude}` };
|
|
10368
|
+
}
|
|
10369
|
+
if (interactionPassed < interactionChecks) {
|
|
10370
|
+
const failed = interactionChecks - interactionPassed;
|
|
10371
|
+
return {
|
|
10372
|
+
checks: interactionChecks,
|
|
10373
|
+
passed: interactionPassed,
|
|
10374
|
+
short: `${failed} of ${interactionChecks} interaction check(s) failed`,
|
|
10375
|
+
unverified: `the interaction checks RAN and ${failed} of ${interactionChecks} failed (each named in the FAIL behavior rows) \u2014 this component WAS exercised and did not behave as the recording requires; unverified here means measured and wrong, not unmeasured.`
|
|
10376
|
+
};
|
|
10377
|
+
}
|
|
10378
|
+
return { checks: interactionChecks, passed: interactionPassed };
|
|
10379
|
+
}
|
|
10380
|
+
function operabilityLine(state) {
|
|
10381
|
+
if ("unavailable" in state) return `UNVERIFIED operability \u2014 ${state.unavailable}`;
|
|
10382
|
+
if ("unverified" in state) return `UNVERIFIED operability \u2014 ${state.unverified}`;
|
|
10383
|
+
return void 0;
|
|
10384
|
+
}
|
|
10137
10385
|
function foldConfigStatus(s, failDemotions, substitutedFamilies) {
|
|
10138
10386
|
const { exact: _exact, ...reported } = s;
|
|
10139
10387
|
let status = tierOf(s, BARS2.cert);
|
|
@@ -10174,7 +10422,8 @@ function checkSummarySegments(input) {
|
|
|
10174
10422
|
const composition = availability.unavailable !== void 0 ? `composition NOT CHECKED (${availability.short})` : `composition ${tally(input.structural)} structural, ${input.crops !== void 0 ? `${tally(input.crops)} crops` : "crops unavailable"} (roles: ${rolesSourceLabel(availability.roles)}${(availability.roles.narrowingAccepted ?? []).length > 0 ? `, ${(availability.roles.narrowingAccepted ?? []).length} narrowing(s) accepted: ${(availability.roles.narrowingAccepted ?? []).join(", ")}` : ""})`;
|
|
10175
10423
|
const occ = occlusionReport(input.occlusion);
|
|
10176
10424
|
const occlusion = "unavailable" in occ ? `occlusion not applicable (${NO_OVERLAY_DECLARED})` : `occlusion ${tally(input.occlusion)}`;
|
|
10177
|
-
|
|
10425
|
+
const operability = "short" in input.operability ? `operability UNVERIFIED (${input.operability.short})` : `operability ${input.operability.passed}/${input.operability.checks} interaction`;
|
|
10426
|
+
return ` \xB7 ${operability} \xB7 ${composition} \xB7 ${occlusion}`;
|
|
10178
10427
|
}
|
|
10179
10428
|
function occlusionReport(occlusion) {
|
|
10180
10429
|
if (occlusion.length === 0) {
|
|
@@ -10241,7 +10490,10 @@ function taskFromManifest(opts, manifest, setDir) {
|
|
|
10241
10490
|
task,
|
|
10242
10491
|
unmapped,
|
|
10243
10492
|
adapterOnly,
|
|
10244
|
-
|
|
10493
|
+
// Registry sets carry hand-declared behaviors and no derivation
|
|
10494
|
+
// runs, so their evidence is UNKNOWN, not empty: an empty list is
|
|
10495
|
+
// spent downstream as "the recording holds no interactive pose".
|
|
10496
|
+
interactionEvidence: authored?.api.interactionEvidence,
|
|
10245
10497
|
unmappedInteractionEvidence: authored?.api.unmappedInteractionEvidence ?? []
|
|
10246
10498
|
};
|
|
10247
10499
|
}
|
|
@@ -10271,7 +10523,7 @@ async function runVerify(opts) {
|
|
|
10271
10523
|
}
|
|
10272
10524
|
let task;
|
|
10273
10525
|
let unmapped = [];
|
|
10274
|
-
let interactionEvidence
|
|
10526
|
+
let interactionEvidence;
|
|
10275
10527
|
let unmappedInteractionEvidence = [];
|
|
10276
10528
|
let availability = ROLES_NOT_RESOLVED;
|
|
10277
10529
|
if (opts.task !== void 0) {
|
|
@@ -10408,7 +10660,8 @@ async function runVerify(opts) {
|
|
|
10408
10660
|
const certified = statuses.filter((s) => s.status === "certified").length;
|
|
10409
10661
|
const occlusionFailures = occlusion.filter((o) => !o.pass);
|
|
10410
10662
|
const coverage = interactionCoverage(behaviors);
|
|
10411
|
-
const
|
|
10663
|
+
const operability = operabilityReport({ behaviors, interactionEvidence });
|
|
10664
|
+
const evidenceUnverified = (interactionEvidence?.length ?? 0) > 0 && coverage.interactionChecks === 0;
|
|
10412
10665
|
const okExceptDemotion = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
|
|
10413
10666
|
const certBlockedByAbsentInk = opts.bar === "cert" ? absentInkDemoted : [];
|
|
10414
10667
|
const ok = okExceptDemotion && certBlockedByAbsentInk.length === 0;
|
|
@@ -10448,7 +10701,11 @@ async function runVerify(opts) {
|
|
|
10448
10701
|
// read exactly like a verified one — measured, a statue and a real
|
|
10449
10702
|
// control produced the same 6/6. The split is the honest form.
|
|
10450
10703
|
...coverage,
|
|
10451
|
-
...interactionEvidence.length > 0 ? { interactionEvidence } : {},
|
|
10704
|
+
...interactionEvidence !== void 0 && interactionEvidence.length > 0 ? { interactionEvidence } : {},
|
|
10705
|
+
// The one-word verdict's CAUSE, in the channel that has no summary
|
|
10706
|
+
// line to carry it. `operability` above keeps its key and values;
|
|
10707
|
+
// this says which of its meanings the run actually produced.
|
|
10708
|
+
operabilityCheck: operability,
|
|
10452
10709
|
// Poses the recording proves interactive that no operability
|
|
10453
10710
|
// check covers — pixel-verified only. The first cold kit shipped
|
|
10454
10711
|
// pressed/focus this way with nothing in the report saying so.
|
|
@@ -10509,11 +10766,10 @@ async function runVerify(opts) {
|
|
|
10509
10766
|
process.stdout.write(`UNAVAILABLE composition crops \u2014 ${regionsOut.unavailable}
|
|
10510
10767
|
`);
|
|
10511
10768
|
}
|
|
10512
|
-
const ic = interactionCoverage(behaviors);
|
|
10513
10769
|
process.stdout.write(
|
|
10514
10770
|
`
|
|
10515
10771
|
${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuses.length} \u2265 pass bar \xB7 behaviors ${behaviors.length - behaviorFailures.length}/${behaviors.length}${checkSummarySegments(
|
|
10516
|
-
{ availability, structural, crops, occlusion }
|
|
10772
|
+
{ availability, structural, crops, occlusion, operability }
|
|
10517
10773
|
)}
|
|
10518
10774
|
`
|
|
10519
10775
|
);
|
|
@@ -10542,14 +10798,13 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
10542
10798
|
}
|
|
10543
10799
|
if (evidenceUnverified) {
|
|
10544
10800
|
process.stdout.write(
|
|
10545
|
-
`FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component. Mappable today: ${[...INTERACTION_STATES, ...BOOLEAN_STATES].join("/")} \u2014 on an axis named State (checked/selected state axes are recorded as evidence but not yet authorable \u2014 widening is on the roadmap; this is not fixable from component code).
|
|
10546
|
-
`
|
|
10547
|
-
);
|
|
10548
|
-
} else if (ic.interactionChecks === 0) {
|
|
10549
|
-
process.stdout.write(
|
|
10550
|
-
`UNVERIFIED operability \u2014 0 interaction checks were authored for this component; the behaviors above are page-level style hygiene. Nothing here says the component responds to a user.
|
|
10801
|
+
`FAIL interaction-evidence \u2014 the recording proves interactive poses (${(interactionEvidence ?? []).join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component. Mappable today: ${[...INTERACTION_STATES, ...BOOLEAN_STATES].join("/")} \u2014 on an axis named State (checked/selected state axes are recorded as evidence but not yet authorable \u2014 widening is on the roadmap; this is not fixable from component code).
|
|
10551
10802
|
`
|
|
10552
10803
|
);
|
|
10804
|
+
} else {
|
|
10805
|
+
const line = operabilityLine(operability);
|
|
10806
|
+
if (line !== void 0) process.stdout.write(`${line}
|
|
10807
|
+
`);
|
|
10553
10808
|
}
|
|
10554
10809
|
if (unmappedInteractionEvidence.length > 0) {
|
|
10555
10810
|
process.stdout.write(
|
|
@@ -10624,7 +10879,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
10624
10879
|
process.exitCode = ExitCode.VerificationFailed;
|
|
10625
10880
|
}
|
|
10626
10881
|
}
|
|
10627
|
-
var BARS2, NO_ROLE_MANIFEST, ROLES_NOT_RESOLVED, UNSTAMPED_ROLES, NO_OVERLAY_DECLARED;
|
|
10882
|
+
var BARS2, NO_INTERACTIVE_POSES, NO_ROLE_MANIFEST, ROLES_NOT_RESOLVED, UNSTAMPED_ROLES, NO_OVERLAY_DECLARED;
|
|
10628
10883
|
var init_verify = __esm({
|
|
10629
10884
|
"packages/cli/src/commands/verify.ts"() {
|
|
10630
10885
|
"use strict";
|
|
@@ -10641,6 +10896,7 @@ var init_verify = __esm({
|
|
|
10641
10896
|
pass: { sim: 0.95, ink: 0.95 },
|
|
10642
10897
|
cert: { sim: 0.97, ink: 0.95 }
|
|
10643
10898
|
};
|
|
10899
|
+
NO_INTERACTIVE_POSES = "no interactive poses recorded \u2014 nothing about focus, typing, or keyboard operation was measured; a component that is not a control passes this field.";
|
|
10644
10900
|
NO_ROLE_MANIFEST = Object.freeze({
|
|
10645
10901
|
short: "no role manifest",
|
|
10646
10902
|
unavailable: "the recording set declares no role manifest \u2014 composition (structural + crop) was NEVER CHECKED; nothing here says the main renders the SHIPPED part modules rather than a pixel-identical re-implementation"
|
|
@@ -10666,7 +10922,7 @@ function resolveEngineTask(opts, callerCwd) {
|
|
|
10666
10922
|
const asPath = path34.resolve(callerCwd, opts.taskOrSet);
|
|
10667
10923
|
const isSet = existsSync27(path34.join(asPath, "recording-set.json"));
|
|
10668
10924
|
const registry = TASKS[opts.taskOrSet];
|
|
10669
|
-
if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, ref: opts.taskOrSet, disclosures: [], interactionEvidence:
|
|
10925
|
+
if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, ref: opts.taskOrSet, disclosures: [], interactionEvidence: void 0 };
|
|
10670
10926
|
if (isSet) {
|
|
10671
10927
|
try {
|
|
10672
10928
|
const authored = authorTaskFromSet(asPath);
|
|
@@ -10880,6 +11136,9 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
10880
11136
|
substitutedFamilies
|
|
10881
11137
|
});
|
|
10882
11138
|
appendScoreHistory(candidateDir, { event: "round-scored", bar: opts.bar, pass: obj[0], total, certified: certifiedReps.length, floor: obj[1], mean: obj[2] });
|
|
11139
|
+
const coverage = interactionCoverage(behaviors);
|
|
11140
|
+
const operability = operabilityReport({ behaviors, interactionEvidence });
|
|
11141
|
+
const evidenceUnverified = (interactionEvidence?.length ?? 0) > 0 && coverage.interactionChecks === 0;
|
|
10883
11142
|
emitData(
|
|
10884
11143
|
opts,
|
|
10885
11144
|
{
|
|
@@ -10893,7 +11152,14 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
10893
11152
|
// The loop's oracle must say what verify says: without this an
|
|
10894
11153
|
// MCP-driven agent literally cannot tell a hollow verdict (all
|
|
10895
11154
|
// behaviour passes are prelude hygiene) from a verified one.
|
|
10896
|
-
|
|
11155
|
+
//
|
|
11156
|
+
// `operability` alone is ONE WORD covering four different states,
|
|
11157
|
+
// and this is the channel where run 15's miss happened: the agent
|
|
11158
|
+
// driving the loop reads this JSON and nothing else. It gets the
|
|
11159
|
+
// same sentence verify's report carries, from the same function —
|
|
11160
|
+
// fixing verify's channel and not this one would have left the
|
|
11161
|
+
// reader who actually acts on it exactly where they were.
|
|
11162
|
+
coverage: { ...coverage, operabilityCheck: operability },
|
|
10897
11163
|
parityCoverage,
|
|
10898
11164
|
evidenceDir,
|
|
10899
11165
|
bundleManifest: emitted.written[0],
|
|
@@ -10904,7 +11170,7 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
10904
11170
|
// Run 11: generators read allPass:true and reported success on
|
|
10905
11171
|
// bundles verify then FAILED on the interaction-evidence gate —
|
|
10906
11172
|
// the oracle must say what verify will say, including this.
|
|
10907
|
-
...
|
|
11173
|
+
...evidenceUnverified ? { interactionEvidenceUnverified: true, verifyWillFail: "interaction-evidence \u2014 the recording proves interactive poses none of the authored behaviors cover; not fixable from component code; REPORT it, do not iterate on it" } : {},
|
|
10908
11174
|
certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity and absent-ink demotion; verify's composition checks can demote further" }
|
|
10909
11175
|
},
|
|
10910
11176
|
() => {
|
|
@@ -10915,12 +11181,12 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
10915
11181
|
process.stdout.write(`
|
|
10916
11182
|
${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${obj[1].toFixed(3)} \xB7 mean=${obj[2].toFixed(3)} \xB7 evidence: ${evidenceDir}
|
|
10917
11183
|
`);
|
|
10918
|
-
|
|
10919
|
-
|
|
10920
|
-
process.stdout.write(`INSTRUMENT GAP \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and zero interaction behaviors were authored; verify WILL fail this bundle (interaction-evidence). Not fixable from component code \u2014 report it, do not iterate on it.
|
|
11184
|
+
if (evidenceUnverified) {
|
|
11185
|
+
process.stdout.write(`INSTRUMENT GAP \u2014 the recording proves interactive poses (${(interactionEvidence ?? []).join(", ")}) and zero interaction behaviors were authored; verify WILL fail this bundle (interaction-evidence). Not fixable from component code \u2014 report it, do not iterate on it.
|
|
10921
11186
|
`);
|
|
10922
|
-
} else
|
|
10923
|
-
|
|
11187
|
+
} else {
|
|
11188
|
+
const line = operabilityLine(operability);
|
|
11189
|
+
if (line !== void 0) process.stdout.write(`${line}
|
|
10924
11190
|
`);
|
|
10925
11191
|
}
|
|
10926
11192
|
}
|
|
@@ -11171,7 +11437,7 @@ var init_server = __esm({
|
|
|
11171
11437
|
TOOLS = [
|
|
11172
11438
|
{
|
|
11173
11439
|
name: "tendril_record_plan",
|
|
11174
|
-
description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback.
|
|
11440
|
+
description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback. It may also carry `interactionStatesToConfirm` \u2014 the recording holds no hover/focus/pressed state, so nothing shows how the component behaves when someone uses it: say its `statement` and `designFix` in that SAME one message (the fix is a Figma variant, not code). It is a disclosure, not a gate \u2014 no answer is required and recording proceeds regardless. The output also carries `feasibilityCheck`: the call arithmetic for this queue plus the free `whoami` check that turns it into a verdict \u2014 complete that handshake BEFORE the first recording call, and surface the verdict to the user when the set does not fit their daily allowance.",
|
|
11175
11441
|
schema: z12.object({
|
|
11176
11442
|
setDir: str("recording set directory to create/resume"),
|
|
11177
11443
|
component: str("component/system name"),
|