figma-cache-toolchain 2.0.3 → 2.0.5

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 (35) hide show
  1. package/README.md +197 -170
  2. package/cursor-bootstrap/AGENT-SETUP-PROMPT.md +75 -43
  3. package/cursor-bootstrap/examples/README.md +26 -15
  4. package/cursor-bootstrap/examples/ui-adapter.contract.template.json +90 -0
  5. package/cursor-bootstrap/examples/ui-execution-template.fast.md +11 -0
  6. package/cursor-bootstrap/examples/ui-execution-template.strict.md +13 -0
  7. package/cursor-bootstrap/examples/ui-override.template.json +26 -0
  8. package/cursor-bootstrap/figma-cache.config.example.js +51 -9
  9. package/cursor-bootstrap/managed-files.json +40 -40
  10. package/cursor-bootstrap/skills/figma-ui-dual-mode-execution/SKILL.md +55 -37
  11. package/figma-cache/adapters/recipes/button.recipe.json +24 -0
  12. package/figma-cache/adapters/recipes/card.recipe.json +24 -0
  13. package/figma-cache/adapters/recipes/checkbox.recipe.json +24 -0
  14. package/figma-cache/adapters/recipes/input.recipe.json +24 -0
  15. package/figma-cache/adapters/recipes/modal.recipe.json +25 -0
  16. package/figma-cache/adapters/recipes/radio.recipe.json +23 -0
  17. package/figma-cache/adapters/recipes/select.recipe.json +24 -0
  18. package/figma-cache/adapters/recipes/table.recipe.json +25 -0
  19. package/figma-cache/adapters/recipes/tabs.recipe.json +24 -0
  20. package/figma-cache/adapters/recipes/tooltip.recipe.json +24 -0
  21. package/figma-cache/docs/README.md +323 -237
  22. package/figma-cache/docs/p0-ui-preflight-handoff.md +207 -0
  23. package/figma-cache/docs/ui-1to1-optimization-roadmap.md +182 -0
  24. package/figma-cache/docs/ui-1to1-report.schema.json +104 -0
  25. package/figma-cache/figma-cache.js +639 -562
  26. package/figma-cache/js/contract-check-cli.js +466 -0
  27. package/figma-cache/js/cursor-bootstrap-cli.js +22 -0
  28. package/figma-cache/js/ui-facts-normalizer.js +233 -0
  29. package/package.json +93 -73
  30. package/scripts/cross-project-e2e.js +594 -0
  31. package/scripts/ui-1to1-audit.js +431 -0
  32. package/scripts/ui-auto-acceptance.js +248 -0
  33. package/scripts/ui-preflight.js +289 -0
  34. package/scripts/ui-profile.js +46 -0
  35. package/scripts/ui-report-aggregate.js +124 -0
@@ -0,0 +1,466 @@
1
+ /* eslint-disable no-console */
2
+ "use strict";
3
+
4
+ function normalizeHexColor(value) {
5
+ if (typeof value !== "string") {
6
+ return "";
7
+ }
8
+ const raw = value.trim();
9
+ const match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/);
10
+ if (!match) {
11
+ return "";
12
+ }
13
+ const body = match[1].toUpperCase();
14
+ if (body.length === 3) {
15
+ return `#${body
16
+ .split("")
17
+ .map((ch) => `${ch}${ch}`)
18
+ .join("")}`;
19
+ }
20
+ return `#${body}`;
21
+ }
22
+
23
+ function extractTokenFactsFromSpec(specText) {
24
+ const output = [];
25
+ const regex = /-\s*([^:\n]+?)\s*:\s*(#[0-9a-fA-F]{3,8})/g;
26
+ let match = null;
27
+ while ((match = regex.exec(specText))) {
28
+ const tokenName = String(match[1] || "").trim();
29
+ const tokenValue = normalizeHexColor(String(match[2] || ""));
30
+ if (!tokenName && !tokenValue) {
31
+ continue;
32
+ }
33
+ output.push({
34
+ tokenName,
35
+ tokenValue,
36
+ source: "spec.md",
37
+ });
38
+ }
39
+ return output;
40
+ }
41
+
42
+ function collectTokenFactsFromVariableDefs(value, pathPrefix, output) {
43
+ if (value == null) {
44
+ return;
45
+ }
46
+
47
+ if (typeof value === "string") {
48
+ const hex = normalizeHexColor(value);
49
+ if (hex) {
50
+ output.push({
51
+ tokenName: pathPrefix,
52
+ tokenValue: hex,
53
+ source: "mcp-raw-get-variable-defs",
54
+ });
55
+ }
56
+ return;
57
+ }
58
+
59
+ if (Array.isArray(value)) {
60
+ value.forEach((entry, index) => {
61
+ const nextPrefix = pathPrefix ? `${pathPrefix}[${index}]` : `[${index}]`;
62
+ collectTokenFactsFromVariableDefs(entry, nextPrefix, output);
63
+ });
64
+ return;
65
+ }
66
+
67
+ if (typeof value === "object") {
68
+ Object.entries(value).forEach(([key, entry]) => {
69
+ const nextPrefix = pathPrefix ? `${pathPrefix}.${key}` : key;
70
+ collectTokenFactsFromVariableDefs(entry, nextPrefix, output);
71
+ });
72
+ }
73
+ }
74
+
75
+ function isPlaceholderCell(input) {
76
+ const value = String(input || "").trim().toLowerCase();
77
+ if (!value || /^-+$/.test(value)) {
78
+ return true;
79
+ }
80
+ return /^(todo|tbd|n\/?a|none|\(none\)|待补充|待完善|待确认|占位|补充)/i.test(value);
81
+ }
82
+
83
+ function extractStatesFromStateMap(stateMapText) {
84
+ const states = new Set();
85
+ const lines = stateMapText.split(/\r?\n/);
86
+ let inStatesSection = false;
87
+
88
+ lines.forEach((line) => {
89
+ const trimmed = line.trim();
90
+ if (/^##\s+States\b/i.test(trimmed)) {
91
+ inStatesSection = true;
92
+ return;
93
+ }
94
+ if (/^##\s+/i.test(trimmed) && !/^##\s+States\b/i.test(trimmed)) {
95
+ inStatesSection = false;
96
+ return;
97
+ }
98
+ if (!inStatesSection || !trimmed.startsWith("|")) {
99
+ return;
100
+ }
101
+
102
+ const cells = trimmed
103
+ .split("|")
104
+ .slice(1, -1)
105
+ .map((cell) => cell.trim());
106
+ if (!cells.length) {
107
+ return;
108
+ }
109
+
110
+ const first = String(cells[0] || "").trim().toLowerCase();
111
+ if (!first || first === "state" || /^-+$/.test(first)) {
112
+ return;
113
+ }
114
+
115
+ // Ignore scaffold rows where visual/data columns are still placeholders.
116
+ const visualCell = cells.length > 1 ? cells[1] : "";
117
+ const dataCell = cells.length > 2 ? cells[2] : "";
118
+ if (isPlaceholderCell(visualCell) && isPlaceholderCell(dataCell)) {
119
+ return;
120
+ }
121
+
122
+ states.add(first);
123
+ });
124
+
125
+ return [...states];
126
+ }
127
+ function dedupeTokenFacts(facts) {
128
+ const seen = new Set();
129
+ const output = [];
130
+ facts.forEach((fact) => {
131
+ const name = String(fact.tokenName || "").trim().toLowerCase();
132
+ const value = normalizeHexColor(String(fact.tokenValue || ""));
133
+ const key = `${name}@@${value}`;
134
+ if (seen.has(key)) {
135
+ return;
136
+ }
137
+ seen.add(key);
138
+ output.push({
139
+ tokenName: fact.tokenName,
140
+ tokenValue: value,
141
+ source: fact.source,
142
+ });
143
+ });
144
+ return output;
145
+ }
146
+
147
+ function mergeContractWithOverride(contract, override, hardErrors, cacheKey) {
148
+ const base = contract && typeof contract === "object" ? contract : {};
149
+ const nodeOverride = override && typeof override === "object" ? override : {};
150
+ const merged = {
151
+ ...base,
152
+ tokenMappings: [...(Array.isArray(base.tokenMappings) ? base.tokenMappings : [])],
153
+ stateMappings: {
154
+ ...(base.stateMappings && typeof base.stateMappings === "object" ? base.stateMappings : {}),
155
+ },
156
+ layoutRules: [...(Array.isArray(base.layoutRules) ? base.layoutRules : [])],
157
+ typographyRules: [...(Array.isArray(base.typographyRules) ? base.typographyRules : [])],
158
+ interactionRules: [...(Array.isArray(base.interactionRules) ? base.interactionRules : [])],
159
+ };
160
+
161
+ if (!nodeOverride || Object.keys(nodeOverride).length === 0) {
162
+ return merged;
163
+ }
164
+
165
+ const overrideTokenMappings = Array.isArray(nodeOverride.tokenMappings)
166
+ ? nodeOverride.tokenMappings
167
+ : [];
168
+ overrideTokenMappings.forEach((overrideMapping) => {
169
+ const tokenName = String(overrideMapping.figmaToken || "")
170
+ .trim()
171
+ .toLowerCase();
172
+ const global = merged.tokenMappings.find(
173
+ (entry) => String(entry.figmaToken || "").trim().toLowerCase() === tokenName
174
+ );
175
+ if (
176
+ global &&
177
+ global.projectBinding &&
178
+ overrideMapping.projectBinding &&
179
+ String(global.projectBinding.value || "") !== String(overrideMapping.projectBinding.value || "")
180
+ ) {
181
+ hardErrors.push(
182
+ `node override conflict: ${cacheKey} token '${overrideMapping.figmaToken}' projectBinding differs from global contract`
183
+ );
184
+ }
185
+ });
186
+ merged.tokenMappings.push(...overrideTokenMappings);
187
+
188
+ const overrideStateMappings =
189
+ nodeOverride.stateMappings && typeof nodeOverride.stateMappings === "object"
190
+ ? nodeOverride.stateMappings
191
+ : {};
192
+ Object.entries(overrideStateMappings).forEach(([key, value]) => {
193
+ const global = merged.stateMappings[key];
194
+ if (global && Array.isArray(global.requiredStates) && value && Array.isArray(value.requiredStates)) {
195
+ const missing = global.requiredStates.filter(
196
+ (state) =>
197
+ !value.requiredStates.map((v) => String(v || "").toLowerCase()).includes(String(state || "").toLowerCase())
198
+ );
199
+ if (missing.length) {
200
+ hardErrors.push(
201
+ `node override conflict: ${cacheKey} stateMappings.${key} misses global requiredStates: ${missing.join(", ")}`
202
+ );
203
+ }
204
+ }
205
+ merged.stateMappings[key] = value;
206
+ });
207
+
208
+ if (Array.isArray(nodeOverride.layoutRules)) {
209
+ merged.layoutRules.push(...nodeOverride.layoutRules);
210
+ }
211
+ if (Array.isArray(nodeOverride.typographyRules)) {
212
+ merged.typographyRules.push(...nodeOverride.typographyRules);
213
+ }
214
+ if (Array.isArray(nodeOverride.interactionRules)) {
215
+ merged.interactionRules.push(...nodeOverride.interactionRules);
216
+ }
217
+
218
+ return merged;
219
+ }
220
+
221
+ function evaluateRuleSet(ruleSet, sourceText, cacheKey, hardErrors, warnings) {
222
+ if (!Array.isArray(ruleSet)) {
223
+ return;
224
+ }
225
+ ruleSet.forEach((rule, indexNo) => {
226
+ if (!rule || typeof rule !== "object") {
227
+ hardErrors.push(`rule[${indexNo}] is not object`);
228
+ return;
229
+ }
230
+ const pattern = String(rule.pattern || "").trim();
231
+ const ruleId = String(rule.id || `rule-${indexNo}`);
232
+ const required = rule.required !== false;
233
+ if (!pattern) {
234
+ hardErrors.push(`${ruleId}: pattern missing`);
235
+ return;
236
+ }
237
+ let matched = false;
238
+ try {
239
+ matched = new RegExp(pattern, "i").test(sourceText);
240
+ } catch {
241
+ hardErrors.push(`${ruleId}: invalid regex pattern '${pattern}'`);
242
+ return;
243
+ }
244
+ if (!matched && required) {
245
+ hardErrors.push(`${cacheKey}: missing required rule '${ruleId}'`);
246
+ return;
247
+ }
248
+ if (!matched && !required) {
249
+ warnings.push(`${cacheKey}: optional rule not matched '${ruleId}'`);
250
+ }
251
+ });
252
+ }
253
+
254
+ function buildContractCheckReport(options, deps) {
255
+ const {
256
+ cacheKey = "",
257
+ warnUnmappedTokens = false,
258
+ warnUnmappedStates = false,
259
+ } = options || {};
260
+
261
+ const {
262
+ index,
263
+ contract,
264
+ readTextOrEmpty,
265
+ readJsonOrNull,
266
+ resolveMaybeAbsolutePath,
267
+ normalizeSlash,
268
+ } = deps;
269
+
270
+ const hardErrors = [];
271
+ const warnings = [];
272
+
273
+ if (!index || typeof index !== "object") {
274
+ return {
275
+ ok: false,
276
+ hardErrors: ["index not found or invalid JSON"],
277
+ warnings: [],
278
+ checkedItems: 0,
279
+ checkedCacheKeys: [],
280
+ };
281
+ }
282
+
283
+ if (!contract || typeof contract !== "object") {
284
+ return {
285
+ ok: false,
286
+ hardErrors: ["adapter contract not found or invalid JSON"],
287
+ warnings: [],
288
+ checkedItems: 0,
289
+ checkedCacheKeys: [],
290
+ };
291
+ }
292
+
293
+ const items = index.items && typeof index.items === "object" ? index.items : {};
294
+ const keys = Object.keys(items);
295
+ const targetKeys = cacheKey
296
+ ? keys.filter((key) => key === cacheKey)
297
+ : keys;
298
+
299
+ if (cacheKey && !targetKeys.length) {
300
+ hardErrors.push(`cacheKey not found: ${cacheKey}`);
301
+ }
302
+
303
+ const missingTokenMappings = [];
304
+ const missingStateMappings = [];
305
+
306
+ targetKeys.forEach((key) => {
307
+ const item = items[key];
308
+ const completeness = Array.isArray(item.completeness) ? item.completeness : [];
309
+
310
+ if (!item || !item.paths || !item.paths.meta) {
311
+ return;
312
+ }
313
+
314
+ const metaAbs = resolveMaybeAbsolutePath(item.paths.meta);
315
+ const nodeDir = require("path").dirname(metaAbs);
316
+
317
+ const specText = item.paths && item.paths.spec
318
+ ? readTextOrEmpty(resolveMaybeAbsolutePath(item.paths.spec))
319
+ : "";
320
+ const stateMapText = item.paths && item.paths.stateMap
321
+ ? readTextOrEmpty(resolveMaybeAbsolutePath(item.paths.stateMap))
322
+ : "";
323
+ const rawText = item.paths && item.paths.raw
324
+ ? readTextOrEmpty(resolveMaybeAbsolutePath(item.paths.raw))
325
+ : "";
326
+ const overridePath = require("path").join(nodeDir, "ui-override.json");
327
+ const nodeOverride = readJsonOrNull(overridePath);
328
+ const targetContract = mergeContractWithOverride(contract, nodeOverride, hardErrors, key);
329
+ const tokenMappings = Array.isArray(targetContract.tokenMappings)
330
+ ? targetContract.tokenMappings
331
+ : [];
332
+ const stateMappings =
333
+ targetContract.stateMappings && typeof targetContract.stateMappings === "object"
334
+ ? targetContract.stateMappings
335
+ : {};
336
+
337
+ const mappedTokenNames = new Set();
338
+ const mappedTokenValues = new Set();
339
+ const mappedStates = new Set();
340
+ tokenMappings.forEach((mapping, indexNo) => {
341
+ if (!mapping || typeof mapping !== "object") {
342
+ hardErrors.push(`tokenMappings[${indexNo}] is not an object`);
343
+ return;
344
+ }
345
+ const tokenName = String(mapping.figmaToken || "").trim().toLowerCase();
346
+ const tokenValue = normalizeHexColor(String(mapping.figmaValue || ""));
347
+ if (tokenName) {
348
+ mappedTokenNames.add(tokenName);
349
+ }
350
+ if (tokenValue) {
351
+ mappedTokenValues.add(tokenValue);
352
+ }
353
+ if (mapping.required === true) {
354
+ const binding = mapping.projectBinding;
355
+ if (!binding || typeof binding !== "object") {
356
+ hardErrors.push(`required mapping '${mapping.id || indexNo}' missing projectBinding`);
357
+ }
358
+ }
359
+ });
360
+ Object.values(stateMappings).forEach((entry) => {
361
+ if (!entry || typeof entry !== "object") {
362
+ return;
363
+ }
364
+ const requiredStates = Array.isArray(entry.requiredStates) ? entry.requiredStates : [];
365
+ requiredStates.forEach((state) => {
366
+ const normalized = String(state || "").trim().toLowerCase();
367
+ if (normalized) {
368
+ mappedStates.add(normalized);
369
+ }
370
+ });
371
+ });
372
+ if (!tokenMappings.length) {
373
+ hardErrors.push("tokenMappings is empty");
374
+ }
375
+ if (!Object.keys(stateMappings).length) {
376
+ hardErrors.push("stateMappings is empty");
377
+ }
378
+
379
+ const manifestPath = require("path").join(nodeDir, "mcp-raw", "mcp-raw-manifest.json");
380
+ const manifest = readJsonOrNull(manifestPath);
381
+ let variableFacts = [];
382
+ if (
383
+ manifest &&
384
+ manifest.files &&
385
+ typeof manifest.files === "object" &&
386
+ manifest.files.get_variable_defs
387
+ ) {
388
+ const variableDefsPath = require("path").join(
389
+ nodeDir,
390
+ "mcp-raw",
391
+ String(manifest.files.get_variable_defs)
392
+ );
393
+ const variableDefs = readJsonOrNull(variableDefsPath);
394
+ if (variableDefs && typeof variableDefs === "object") {
395
+ const output = [];
396
+ collectTokenFactsFromVariableDefs(variableDefs, "", output);
397
+ variableFacts = output;
398
+ }
399
+ }
400
+
401
+ const tokenFacts = dedupeTokenFacts([
402
+ ...extractTokenFactsFromSpec(specText),
403
+ ...variableFacts,
404
+ ]);
405
+
406
+ if (completeness.includes("tokens") || tokenFacts.length) {
407
+ tokenFacts.forEach((fact) => {
408
+ const name = String(fact.tokenName || "").trim().toLowerCase();
409
+ const value = normalizeHexColor(String(fact.tokenValue || ""));
410
+ const matchedByName = name && mappedTokenNames.has(name);
411
+ const matchedByValue = value && mappedTokenValues.has(value);
412
+
413
+ if (!matchedByName && !matchedByValue) {
414
+ missingTokenMappings.push(
415
+ `token unmapped: ${key} :: ${fact.tokenName || "(empty)"} :: ${
416
+ value || "(none)"
417
+ } (${fact.source})`
418
+ );
419
+ }
420
+ });
421
+ }
422
+
423
+ if (completeness.includes("states") || stateMapText) {
424
+ const states = extractStatesFromStateMap(stateMapText);
425
+ states.forEach((state) => {
426
+ if (!mappedStates.has(state)) {
427
+ missingStateMappings.push(`state unmapped: ${key} :: ${state} (state-map.md)`);
428
+ }
429
+ });
430
+ }
431
+
432
+ const ruleSource = `${specText}\n${stateMapText}\n${rawText}`;
433
+ evaluateRuleSet(targetContract.layoutRules, ruleSource, key, hardErrors, warnings);
434
+ evaluateRuleSet(targetContract.typographyRules, ruleSource, key, hardErrors, warnings);
435
+ evaluateRuleSet(targetContract.interactionRules, ruleSource, key, hardErrors, warnings);
436
+ });
437
+
438
+ if (missingTokenMappings.length) {
439
+ if (warnUnmappedTokens) {
440
+ warnings.push(...missingTokenMappings);
441
+ } else {
442
+ hardErrors.push(...missingTokenMappings);
443
+ }
444
+ }
445
+
446
+ if (missingStateMappings.length) {
447
+ if (warnUnmappedStates) {
448
+ warnings.push(...missingStateMappings);
449
+ } else {
450
+ hardErrors.push(...missingStateMappings);
451
+ }
452
+ }
453
+
454
+ return {
455
+ ok: hardErrors.length === 0,
456
+ hardErrors,
457
+ warnings,
458
+ checkedItems: targetKeys.length,
459
+ checkedCacheKeys: targetKeys,
460
+ contract: normalizeSlash(String(options.contractPath || "")),
461
+ };
462
+ }
463
+
464
+ module.exports = {
465
+ buildContractCheckReport,
466
+ };
@@ -89,6 +89,28 @@ function copyCursorBootstrap(options, deps) {
89
89
  copied += 1;
90
90
  });
91
91
 
92
+ const requiredExampleTemplates = [
93
+ "examples/ui-adapter.contract.template.json",
94
+ "examples/ui-1to1-preflight.template.md",
95
+ "examples/ui-override.template.json",
96
+ "examples/ui-execution-template.fast.md",
97
+ "examples/ui-execution-template.strict.md",
98
+ ];
99
+ requiredExampleTemplates.forEach((relPath) => {
100
+ const absFrom = path.join(CURSOR_BOOTSTRAP_DIR, relPath);
101
+ const absTo = path.join(ROOT, "cursor-bootstrap", relPath);
102
+ if (!fs.existsSync(absFrom)) {
103
+ console.error(`[figma-cache] missing template file: ${normalizeSlash(absFrom)}`);
104
+ process.exit(1);
105
+ }
106
+ fs.mkdirSync(path.dirname(absTo), { recursive: true });
107
+ if (fs.existsSync(absTo) && !overwrite) {
108
+ skipped += 1;
109
+ return;
110
+ }
111
+ fs.copyFileSync(absFrom, absTo);
112
+ copied += 1;
113
+ });
92
114
  const retiredDeleted = retired
93
115
  .map((relPath) => {
94
116
  const abs = path.join(ROOT, relPath);