@design-twin/dtwin 0.1.0-alpha.0 → 0.1.0
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/README.md +13 -3
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +5 -1
- package/src/figma-mcp.ts +16 -14
- package/src/init.ts +6 -6
|
@@ -0,0 +1,2746 @@
|
|
|
1
|
+
// GENERATED from design-to-code/ by claude-plugin/build-scripts.js — edit the source, then rebuild.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// design-to-code/cross-check.ts
|
|
5
|
+
import fs8 from "node:fs";
|
|
6
|
+
import path7 from "node:path";
|
|
7
|
+
|
|
8
|
+
// design-to-code/screen-inputs.ts
|
|
9
|
+
import fs7 from "node:fs";
|
|
10
|
+
import path5 from "node:path";
|
|
11
|
+
|
|
12
|
+
// bridge/src/project-layout.ts
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
var DESIGN_DIR = "design";
|
|
16
|
+
var EXPORT_SUBDIR = "export";
|
|
17
|
+
var EXPORT_DIR = path.join(DESIGN_DIR, EXPORT_SUBDIR);
|
|
18
|
+
var TARGET_FILE = path.join(DESIGN_DIR, "target.json");
|
|
19
|
+
var MAP_FILE = path.join(DESIGN_DIR, "codeconnect.local.json");
|
|
20
|
+
var LEGACY_MAP_FILE = "codeconnect.local.json";
|
|
21
|
+
var PLAN_DIR = path.join(DESIGN_DIR, "plan");
|
|
22
|
+
var AUDIT_DIR = path.join(DESIGN_DIR, "audit");
|
|
23
|
+
var VERIFY_DIR = path.join(DESIGN_DIR, "verify");
|
|
24
|
+
var SNAPSHOT_DIR = path.join(DESIGN_DIR, ".sync");
|
|
25
|
+
var MERGED_VARIABLES_FILE = "variables.json";
|
|
26
|
+
var TAILWIND_SOURCE_NOT_NOTE = `Tailwind v4 scans every file git does not ignore, ${DESIGN_DIR}/ included, so class names quoted in ${DESIGN_DIR}/ notes, audits and plans end up in your CSS. Next to \`@import "tailwindcss";\` in your CSS entry, add \`@source not "<path from that CSS file to ${DESIGN_DIR}/>";\` (e.g. \`@source not "../${DESIGN_DIR}";\` for src/app.css) \u2014 Tailwind v4.1+`;
|
|
27
|
+
var VITE_WATCH_IGNORED_NOTE = `With Tailwind v4's automatic source detection, rewriting an existing text file under ${DESIGN_DIR}/ (a re-export, a verify report) makes Vite fully reload the open page. Either add \`server: { watch: { ignored: ['**/${DESIGN_DIR}/**'] } }\` in vite.config (merge it with any existing \`server.watch\` options), or the Tailwind \`@source not\` above \u2014 both stop it`;
|
|
28
|
+
var slashed = (p) => p.split(path.sep).join("/");
|
|
29
|
+
var VERIFY_GITIGNORE_NOTE = `${slashed(VERIFY_DIR)}/ is regenerated on every verify run (measurements, screenshots, reports) \u2014 consider adding \`${slashed(VERIFY_DIR)}/\` to .gitignore; decisions live in ${slashed(PLAN_DIR)}/ and are not affected`;
|
|
30
|
+
var EXPORT_MARKERS = ["pages", "design-system", "design-system.json", MERGED_VARIABLES_FILE, "assets", "libraries"];
|
|
31
|
+
function looksLikeExportDir(dir) {
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.statSync(dir).isDirectory()) return false;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return EXPORT_MARKERS.some((m) => fs.existsSync(path.join(dir, m)));
|
|
38
|
+
}
|
|
39
|
+
function findExportDir(cwd) {
|
|
40
|
+
const modern = path.join(cwd, EXPORT_DIR);
|
|
41
|
+
const legacy2 = path.join(cwd, DESIGN_DIR);
|
|
42
|
+
const modernExists = looksLikeExportDir(modern);
|
|
43
|
+
const legacyExists = looksLikeExportDir(legacy2);
|
|
44
|
+
if (modernExists) return { dir: modern, layout: "export-subdir", rel: EXPORT_DIR, parallelLegacy: legacyExists };
|
|
45
|
+
if (legacyExists) return { dir: legacy2, layout: "legacy-flat", rel: DESIGN_DIR };
|
|
46
|
+
return { dir: modern, layout: "none", rel: EXPORT_DIR };
|
|
47
|
+
}
|
|
48
|
+
function findMapFile(cwd) {
|
|
49
|
+
const modern = path.join(cwd, MAP_FILE);
|
|
50
|
+
if (fs.existsSync(modern)) return { file: modern, rel: MAP_FILE, legacy: false };
|
|
51
|
+
const legacy2 = path.join(cwd, LEGACY_MAP_FILE);
|
|
52
|
+
if (fs.existsSync(legacy2)) return { file: legacy2, rel: LEGACY_MAP_FILE, legacy: true };
|
|
53
|
+
return { file: modern, rel: MAP_FILE, legacy: false, missing: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// bridge/src/json-util.ts
|
|
57
|
+
function nullProto() {
|
|
58
|
+
return /* @__PURE__ */ Object.create(null);
|
|
59
|
+
}
|
|
60
|
+
function isRecord(x) {
|
|
61
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
62
|
+
}
|
|
63
|
+
function isStringArray(x) {
|
|
64
|
+
return Array.isArray(x) && x.every((v) => typeof v === "string");
|
|
65
|
+
}
|
|
66
|
+
function ifDefined(key, v) {
|
|
67
|
+
const o = {};
|
|
68
|
+
if (v !== void 0) o[key] = v;
|
|
69
|
+
return o;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// bridge/src/pages-layout.ts
|
|
73
|
+
var isObjectLike = (x) => !!x && typeof x === "object";
|
|
74
|
+
var samePage = (l, entry) => !!entry.pageId && l.pageId === entry.pageId && (l.page ?? null) === (entry.page ?? null);
|
|
75
|
+
var sameFigmaFile = (l, entry) => {
|
|
76
|
+
if (l.sourceFile && entry.sourceFile) {
|
|
77
|
+
return l.sourceFileKey && entry.sourceFileKey ? l.sourceFileKey === entry.sourceFileKey : l.sourceFile === entry.sourceFile;
|
|
78
|
+
}
|
|
79
|
+
if (entry.sourceFile) return !!entry.pageId && l.pageId === entry.pageId;
|
|
80
|
+
return samePage(l, entry);
|
|
81
|
+
};
|
|
82
|
+
var notSameRow = (entry) => (l) => {
|
|
83
|
+
if (!isObjectLike(l)) return !!l;
|
|
84
|
+
if (l.file === entry.file) return false;
|
|
85
|
+
if (l.id !== entry.id) return true;
|
|
86
|
+
return !sameFigmaFile(l, entry);
|
|
87
|
+
};
|
|
88
|
+
var sameScreenRow = (older, newer) => isObjectLike(older) && !notSameRow(newer)(older);
|
|
89
|
+
|
|
90
|
+
// bridge/src/design-system-layout.ts
|
|
91
|
+
var DIR = "design-system";
|
|
92
|
+
var COMPONENTS_DIR = "components";
|
|
93
|
+
var TOKENS = "tokens.json";
|
|
94
|
+
var COMPONENTS_LOCAL = "components.local.json";
|
|
95
|
+
var COMPONENTS_LIBRARY = "components.library.json";
|
|
96
|
+
var HYGIENE = "hygiene.json";
|
|
97
|
+
var MANIFEST = "design-system.json";
|
|
98
|
+
var STYLE_KIND_ROWS = {
|
|
99
|
+
paint: { kind: "paint", file: "styles.paint.json", key: "stylesPaint" },
|
|
100
|
+
text: { kind: "text", file: "styles.text.json", key: "stylesText" },
|
|
101
|
+
effect: { kind: "effect", file: "styles.effect.json", key: "stylesEffect" },
|
|
102
|
+
grid: { kind: "grid", file: "styles.grid.json", key: "stylesGrid" },
|
|
103
|
+
animation: { kind: "animation", file: "styles.animation.json", key: "stylesAnimation" }
|
|
104
|
+
// local custom animation styles
|
|
105
|
+
};
|
|
106
|
+
var STYLE_KINDS = Object.values(STYLE_KIND_ROWS);
|
|
107
|
+
var DESIGN_SYSTEM_DIR = DIR;
|
|
108
|
+
var DESIGN_SYSTEM_FILES = { TOKENS, COMPONENTS_LOCAL, COMPONENTS_LIBRARY, COMPONENTS_DIR, HYGIENE, MANIFEST };
|
|
109
|
+
var DESIGN_SYSTEM_FILE_NAMES = [TOKENS, ...STYLE_KINDS.map((row) => row.file), COMPONENTS_LOCAL, COMPONENTS_LIBRARY, HYGIENE, MANIFEST];
|
|
110
|
+
|
|
111
|
+
// design-to-code/catalog-input.ts
|
|
112
|
+
import fs3 from "node:fs";
|
|
113
|
+
|
|
114
|
+
// design-to-code/types.ts
|
|
115
|
+
var PLAN_VERIFICATION_RECORD_SCHEMA = "designtwin/plan-verification-record@2";
|
|
116
|
+
function isJsonObject(x) {
|
|
117
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// bridge/src/read-json.ts
|
|
121
|
+
import fs2 from "node:fs";
|
|
122
|
+
|
|
123
|
+
// bridge/src/errmsg.ts
|
|
124
|
+
var errMsg = (e) => typeof e === "string" ? e : String(e && e.message || e);
|
|
125
|
+
var errCode = (e) => e && typeof e === "object" && "code" in e && typeof e.code === "string" ? e.code : void 0;
|
|
126
|
+
|
|
127
|
+
// bridge/src/read-json.ts
|
|
128
|
+
function readFailure(e) {
|
|
129
|
+
const code = e && typeof e === "object" && "code" in e ? e.code : void 0;
|
|
130
|
+
if (code === "ENOENT") return { error: "does not exist", missing: true };
|
|
131
|
+
return {
|
|
132
|
+
error: code === "EISDIR" ? "is a directory, not a file" : code === "EACCES" ? "is not readable (permission denied)" : `could not be read (${String(code || e)})`
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function readJson(file, guard) {
|
|
136
|
+
let buf;
|
|
137
|
+
try {
|
|
138
|
+
buf = fs2.readFileSync(file);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return readFailure(e);
|
|
141
|
+
}
|
|
142
|
+
if (buf.length >= 2 && (buf[0] === 255 && buf[1] === 254 || buf[0] === 254 && buf[1] === 255)) {
|
|
143
|
+
return { error: "is UTF-16, not UTF-8 \u2014 re-save it as UTF-8" };
|
|
144
|
+
}
|
|
145
|
+
let raw = buf.toString("utf8");
|
|
146
|
+
if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
const value = JSON.parse(raw);
|
|
150
|
+
parsed = value;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return { error: `is not valid JSON \u2014 ${errMsg(e)}` };
|
|
153
|
+
}
|
|
154
|
+
if (!guard(parsed)) return { error: `is not ${guard.expected || "the expected kind of document"}` };
|
|
155
|
+
return { doc: parsed };
|
|
156
|
+
}
|
|
157
|
+
function readJsonOrNull(file, guard) {
|
|
158
|
+
const r = readJson(file, guard);
|
|
159
|
+
return "doc" in r ? r.doc : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// bridge/src/stdio.ts
|
|
163
|
+
import { format, inspect } from "node:util";
|
|
164
|
+
function installQueuedConsole(o = {}) {
|
|
165
|
+
trackWrites(process.stdout);
|
|
166
|
+
trackWrites(process.stderr);
|
|
167
|
+
const out = o.logTo === "stderr" ? process.stderr : process.stdout;
|
|
168
|
+
const err = process.stderr;
|
|
169
|
+
let indent = "";
|
|
170
|
+
const write = (s, text) => {
|
|
171
|
+
s.write((indent ? text.replace(/^/gm, indent) : text) + "\n");
|
|
172
|
+
};
|
|
173
|
+
const line = (s) => (...a) => {
|
|
174
|
+
write(s, format(...a));
|
|
175
|
+
};
|
|
176
|
+
console.log = console.info = console.debug = line(out);
|
|
177
|
+
console.warn = console.error = line(err);
|
|
178
|
+
console.dir = (item, options) => {
|
|
179
|
+
write(out, inspect(item, { customInspect: false, ...options }));
|
|
180
|
+
};
|
|
181
|
+
console.dirxml = line(out);
|
|
182
|
+
console.table = (data) => {
|
|
183
|
+
write(out, inspect(data));
|
|
184
|
+
};
|
|
185
|
+
console.write = (...data) => {
|
|
186
|
+
let n = 0;
|
|
187
|
+
for (const d of data) {
|
|
188
|
+
const b = typeof d === "string" ? Buffer.from(d) : d instanceof ArrayBuffer ? Buffer.from(d) : Buffer.from(d.buffer, d.byteOffset, d.byteLength);
|
|
189
|
+
if (b.length) {
|
|
190
|
+
out.write(b);
|
|
191
|
+
n += b.length;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return n;
|
|
195
|
+
};
|
|
196
|
+
console.clear = () => {
|
|
197
|
+
if (out.isTTY) out.write("\x1B[1;1H\x1B[0J");
|
|
198
|
+
};
|
|
199
|
+
console.trace = (...a) => {
|
|
200
|
+
const e = new Error(format(...a));
|
|
201
|
+
e.name = "Trace";
|
|
202
|
+
Error.captureStackTrace(e, console.trace);
|
|
203
|
+
write(err, e.stack ?? `Trace: ${e.message}`);
|
|
204
|
+
};
|
|
205
|
+
console.assert = (cond, ...a) => {
|
|
206
|
+
if (!cond) write(err, a.length ? "Assertion failed: " + format(...a) : "Assertion failed");
|
|
207
|
+
};
|
|
208
|
+
console.group = console.groupCollapsed = (...a) => {
|
|
209
|
+
if (a.length) write(out, format(...a));
|
|
210
|
+
indent += " ";
|
|
211
|
+
};
|
|
212
|
+
console.groupEnd = () => {
|
|
213
|
+
indent = indent.slice(0, -2);
|
|
214
|
+
};
|
|
215
|
+
const counts = /* @__PURE__ */ new Map();
|
|
216
|
+
console.count = (label = "default") => {
|
|
217
|
+
const n = (counts.get(label) ?? 0) + 1;
|
|
218
|
+
counts.set(label, n);
|
|
219
|
+
write(out, `${label}: ${n}`);
|
|
220
|
+
};
|
|
221
|
+
console.countReset = (label = "default") => {
|
|
222
|
+
counts.delete(label);
|
|
223
|
+
};
|
|
224
|
+
const timers = /* @__PURE__ */ new Map();
|
|
225
|
+
const elapsed = (label) => {
|
|
226
|
+
const t = timers.get(label);
|
|
227
|
+
return t === void 0 ? null : `${label}: ${(performance.now() - t).toFixed(3)}ms`;
|
|
228
|
+
};
|
|
229
|
+
console.time = (label = "default") => {
|
|
230
|
+
timers.set(label, performance.now());
|
|
231
|
+
};
|
|
232
|
+
console.timeLog = (label = "default", ...a) => {
|
|
233
|
+
const e = elapsed(label);
|
|
234
|
+
if (e === null) write(err, `Warning: No such label '${label}' for console.timeLog()`);
|
|
235
|
+
else write(out, [e, ...a.map((x) => format(x))].join(" "));
|
|
236
|
+
};
|
|
237
|
+
console.timeEnd = (label = "default") => {
|
|
238
|
+
const e = elapsed(label);
|
|
239
|
+
timers.delete(label);
|
|
240
|
+
write(e === null ? err : out, e ?? `Warning: No such label '${label}' for console.timeEnd()`);
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
var inFlight = /* @__PURE__ */ new Map();
|
|
244
|
+
function trackWrites(s) {
|
|
245
|
+
if (inFlight.has(s)) return;
|
|
246
|
+
const st = { n: 0, idle: [] };
|
|
247
|
+
inFlight.set(s, st);
|
|
248
|
+
const write = s.write.bind(s);
|
|
249
|
+
const settle = () => {
|
|
250
|
+
if (--st.n === 0) for (const r of st.idle.splice(0)) r();
|
|
251
|
+
};
|
|
252
|
+
s.write = (chunk, encoding, cb) => {
|
|
253
|
+
const done = typeof encoding === "function" ? encoding : cb;
|
|
254
|
+
const enc = typeof encoding === "function" ? void 0 : encoding;
|
|
255
|
+
if (chunk.length === 0) return write(chunk, enc, done);
|
|
256
|
+
st.n++;
|
|
257
|
+
let called = false;
|
|
258
|
+
return write(chunk, enc, (err) => {
|
|
259
|
+
if (!called) {
|
|
260
|
+
called = true;
|
|
261
|
+
settle();
|
|
262
|
+
}
|
|
263
|
+
done?.(err);
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
var CliExit = class extends Error {
|
|
268
|
+
code;
|
|
269
|
+
constructor(code) {
|
|
270
|
+
super(`exit ${code}`);
|
|
271
|
+
this.name = "CliExit";
|
|
272
|
+
this.code = code;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
var cliDepth = 0;
|
|
276
|
+
function exitCli(code) {
|
|
277
|
+
if (cliDepth > 0) throw new CliExit(code);
|
|
278
|
+
process.exit(code);
|
|
279
|
+
}
|
|
280
|
+
function runCli(main2, o, argv = process.argv.slice(2)) {
|
|
281
|
+
installQueuedConsole();
|
|
282
|
+
const done = o.done ?? ((code) => {
|
|
283
|
+
process.exitCode = code;
|
|
284
|
+
});
|
|
285
|
+
const fail = (e) => {
|
|
286
|
+
if (e instanceof CliExit) return done(e.code);
|
|
287
|
+
console.error(o.errorLine ? o.errorLine(e) : `${o.name}: ${e instanceof Error && e.stack || String(e)}`);
|
|
288
|
+
done(o.errorCode ?? 1);
|
|
289
|
+
};
|
|
290
|
+
cliDepth++;
|
|
291
|
+
let settled = false;
|
|
292
|
+
const leave = () => {
|
|
293
|
+
if (!settled) {
|
|
294
|
+
settled = true;
|
|
295
|
+
cliDepth--;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
try {
|
|
299
|
+
const r = main2(argv);
|
|
300
|
+
if (typeof r === "number") {
|
|
301
|
+
leave();
|
|
302
|
+
done(r);
|
|
303
|
+
} else r.then((c) => {
|
|
304
|
+
leave();
|
|
305
|
+
done(c);
|
|
306
|
+
}, (e) => {
|
|
307
|
+
leave();
|
|
308
|
+
fail(e);
|
|
309
|
+
});
|
|
310
|
+
} catch (e) {
|
|
311
|
+
leave();
|
|
312
|
+
fail(e);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// design-to-code/proc.ts
|
|
317
|
+
var pause = new Int32Array(new SharedArrayBuffer(4));
|
|
318
|
+
|
|
319
|
+
// design-to-code/catalog-input.ts
|
|
320
|
+
function readDocFile(file, what, guard, hint) {
|
|
321
|
+
return docOrExit(loadDocFile(file, what, guard, hint));
|
|
322
|
+
}
|
|
323
|
+
function loadDocFile(file, what, guard, hint, readAt = file) {
|
|
324
|
+
const r = readJson(readAt, guard);
|
|
325
|
+
if ("doc" in r) return r;
|
|
326
|
+
const ioFailure = r.missing || /^(is a directory|is not readable|could not be read)/.test(r.error);
|
|
327
|
+
return { error: `${what}: '${file}' ${r.error}${ioFailure ? "." : ""}` + (ioFailure && hint ? `
|
|
328
|
+
${hint}` : "") };
|
|
329
|
+
}
|
|
330
|
+
function docOrExit(r) {
|
|
331
|
+
if ("doc" in r) return r.doc;
|
|
332
|
+
console.error(`error ${r.error}`);
|
|
333
|
+
exitCli(2);
|
|
334
|
+
}
|
|
335
|
+
function readOptionalDoc(file, what, guard) {
|
|
336
|
+
return fs3.existsSync(file) ? readDocFile(file, what, guard) : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// design-to-code/design-system-dir.ts
|
|
340
|
+
import fs5 from "node:fs";
|
|
341
|
+
import path3 from "node:path";
|
|
342
|
+
|
|
343
|
+
// design-to-code/export-shape.ts
|
|
344
|
+
function isIrNode(x) {
|
|
345
|
+
return isJsonObject(x) && typeof x.id === "string" && typeof x.type === "string" && (x.children === void 0 || Array.isArray(x.children));
|
|
346
|
+
}
|
|
347
|
+
function isScreenExport(x) {
|
|
348
|
+
return isJsonObject(x) && Array.isArray(x.nodes) && x.nodes.every(isIrNode);
|
|
349
|
+
}
|
|
350
|
+
function isLayerFile(x) {
|
|
351
|
+
return isJsonObject(x) && !Array.isArray(x.nodes) && isIrNode(x.tree);
|
|
352
|
+
}
|
|
353
|
+
function isScreenDoc(x) {
|
|
354
|
+
return isScreenExport(x) || isLayerFile(x) || isIrNode(x);
|
|
355
|
+
}
|
|
356
|
+
isScreenDoc.expected = "a screen export: {nodes:[\u2026]} whose every node has a string id and type, a layer file {tree: node}, or a bare node {id, type, \u2026}";
|
|
357
|
+
function screenRoots(doc) {
|
|
358
|
+
if (isScreenExport(doc)) return doc.nodes;
|
|
359
|
+
if (isLayerFile(doc)) return [doc.tree];
|
|
360
|
+
if (isIrNode(doc)) return [doc];
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
function screenExportOf(doc) {
|
|
364
|
+
return isScreenExport(doc) ? doc : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// bridge/src/hex-color.ts
|
|
368
|
+
function formatHex(c) {
|
|
369
|
+
const to = (x) => Math.round(Math.min(255, Math.max(0, x))).toString(16).padStart(2, "0");
|
|
370
|
+
const a = Math.round(c.a * 255);
|
|
371
|
+
return "#" + to(c.r) + to(c.g) + to(c.b) + (a < 255 ? to(a) : "");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// design-to-code/color.ts
|
|
375
|
+
var HEX = /^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
376
|
+
function normHex(v) {
|
|
377
|
+
if (typeof v !== "string") return null;
|
|
378
|
+
const g = HEX.exec(v.trim())?.[1];
|
|
379
|
+
if (g === void 0) return null;
|
|
380
|
+
const h = g.toLowerCase();
|
|
381
|
+
return "#" + (h.length <= 4 ? h.split("").map((c) => c + c).join("") : h);
|
|
382
|
+
}
|
|
383
|
+
function colorKey(v) {
|
|
384
|
+
const h = normHex(v);
|
|
385
|
+
return h === null ? null : h.length === 7 ? h + "ff" : h;
|
|
386
|
+
}
|
|
387
|
+
function parseHex(v) {
|
|
388
|
+
const k = colorKey(v);
|
|
389
|
+
if (k === null) return null;
|
|
390
|
+
const n = (i) => parseInt(k.slice(i, i + 2), 16);
|
|
391
|
+
return { r: n(1), g: n(3), b: n(5), a: n(7) / 255 };
|
|
392
|
+
}
|
|
393
|
+
var NUM = "[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:e[+-]?\\d+)?";
|
|
394
|
+
var PCT = `${NUM}%`;
|
|
395
|
+
var CH = `${NUM}%?`;
|
|
396
|
+
var CH_OR_NONE = `(?:${CH}|none)`;
|
|
397
|
+
var legacy = (ch) => `\\(\\s*(${ch})\\s*,\\s*(${ch})\\s*,\\s*(${ch})\\s*(?:,\\s*(${CH})\\s*)?\\)`;
|
|
398
|
+
var RGB_LEGACY = new RegExp(`^rgba?(?:${legacy(PCT)}|${legacy(NUM)})$`);
|
|
399
|
+
var RGB_MODERN = new RegExp(`^rgba?\\(\\s*(${CH_OR_NONE})\\s+(${CH_OR_NONE})\\s+(${CH_OR_NONE})\\s*(?:/\\s*(${CH_OR_NONE})\\s*)?\\)$`);
|
|
400
|
+
var OK = new RegExp(`^(oklab|oklch)\\(\\s*(${CH_OR_NONE})\\s+(${CH_OR_NONE})\\s+(${CH_OR_NONE})\\s*(?:/\\s*(${CH_OR_NONE})\\s*)?\\)$`);
|
|
401
|
+
var SRGB = new RegExp(`^color\\(\\s*srgb\\s+(${CH_OR_NONE})\\s+(${CH_OR_NONE})\\s+(${CH_OR_NONE})\\s*(?:/\\s*(${CH_OR_NONE})\\s*)?\\)$`);
|
|
402
|
+
function clampOpacityPct(n) {
|
|
403
|
+
return Math.min(100, Math.max(0, n));
|
|
404
|
+
}
|
|
405
|
+
function composeAlpha(alpha, opacityPct) {
|
|
406
|
+
return alpha * (clampOpacityPct(opacityPct) / 100);
|
|
407
|
+
}
|
|
408
|
+
function compositeOver(fg, bg) {
|
|
409
|
+
return {
|
|
410
|
+
r: fg.r * fg.a + bg.r * (1 - fg.a),
|
|
411
|
+
g: fg.g * fg.a + bg.g * (1 - fg.a),
|
|
412
|
+
b: fg.b * fg.a + bg.b * (1 - fg.a),
|
|
413
|
+
a: 1
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function luminance(c) {
|
|
417
|
+
const ch = (v) => {
|
|
418
|
+
v /= 255;
|
|
419
|
+
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
420
|
+
};
|
|
421
|
+
return 0.2126 * ch(c.r) + 0.7152 * ch(c.g) + 0.0722 * ch(c.b);
|
|
422
|
+
}
|
|
423
|
+
function contrastRatio(a, b) {
|
|
424
|
+
const la = luminance(a), lb = luminance(b);
|
|
425
|
+
const hi = Math.max(la, lb), lo = Math.min(la, lb);
|
|
426
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// design-to-code/visible-tree.ts
|
|
430
|
+
var hiddenSelf = (node) => !!(node && typeof node === "object" && "hidden" in node && node.hidden);
|
|
431
|
+
var isHidden = (node, ancestorHidden) => !!ancestorHidden || hiddenSelf(node);
|
|
432
|
+
var WHITE = { r: 255, g: 255, b: 255, a: 1 };
|
|
433
|
+
var NOTHING = { color: null, complex: false, token: null };
|
|
434
|
+
var paintToken = (p) => {
|
|
435
|
+
const t = p.tokens && p.tokens.color;
|
|
436
|
+
const s = Array.isArray(t) ? t[0] : t;
|
|
437
|
+
return typeof s === "string" && s ? s : null;
|
|
438
|
+
};
|
|
439
|
+
function paintOver(base, n) {
|
|
440
|
+
if (n.type === "TEXT") return base;
|
|
441
|
+
const fills = (Array.isArray(n.fills) ? n.fills : []).filter(Boolean);
|
|
442
|
+
const nodeTok = n.tokens && typeof n.tokens.fills === "string" && n.tokens.fills ? n.tokens.fills : null;
|
|
443
|
+
if (!fills.length) return nodeTok ? { ...base, token: nodeTok } : base;
|
|
444
|
+
let { color, complex, token } = base;
|
|
445
|
+
for (const f of fills) {
|
|
446
|
+
if (f.opacity === 0 || f.visible === false) continue;
|
|
447
|
+
if (f.type !== "solid") {
|
|
448
|
+
complex = true;
|
|
449
|
+
token = null;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const c = parseHex(f.color);
|
|
453
|
+
const tok = paintToken(f) ?? (fills.length === 1 ? nodeTok : null);
|
|
454
|
+
if (!c) {
|
|
455
|
+
token = tok;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (c.a <= 0) continue;
|
|
459
|
+
color = color ? compositeOver(c, color) : c.a >= 1 ? c : compositeOver(c, WHITE);
|
|
460
|
+
if (c.a >= 1) {
|
|
461
|
+
complex = false;
|
|
462
|
+
token = tok;
|
|
463
|
+
} else token = null;
|
|
464
|
+
}
|
|
465
|
+
return { color, complex, token };
|
|
466
|
+
}
|
|
467
|
+
function paintedOver(under, layers, tokenColor) {
|
|
468
|
+
let color = under, complex = false, painted = false;
|
|
469
|
+
for (const n of layers) {
|
|
470
|
+
if (n.type === "TEXT") continue;
|
|
471
|
+
const fills = (Array.isArray(n.fills) ? n.fills : []).filter(Boolean);
|
|
472
|
+
const nodeTok = n.tokens && typeof n.tokens.fills === "string" && n.tokens.fills ? n.tokens.fills : null;
|
|
473
|
+
const lt = !fills.length && nodeTok && tokenColor ? tokenColor(nodeTok) : null;
|
|
474
|
+
if (lt && lt.a > 0) {
|
|
475
|
+
color = compositeOver(lt, color);
|
|
476
|
+
painted = true;
|
|
477
|
+
if (lt.a >= 1) complex = false;
|
|
478
|
+
}
|
|
479
|
+
for (const f of fills) {
|
|
480
|
+
if (f.opacity === 0 || f.visible === false) continue;
|
|
481
|
+
if (f.type !== "solid") {
|
|
482
|
+
complex = true;
|
|
483
|
+
painted = true;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const c = parseHex(f.color);
|
|
487
|
+
if (!c || c.a <= 0) continue;
|
|
488
|
+
color = compositeOver(c, color);
|
|
489
|
+
painted = true;
|
|
490
|
+
if (c.a >= 1) complex = false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return painted ? { color, complex } : null;
|
|
494
|
+
}
|
|
495
|
+
var INK_TYPES = /* @__PURE__ */ new Set(["VECTOR", "BOOLEAN_OPERATION", "LINE", "FRAME", "INSTANCE", "GROUP", "COMPONENT"]);
|
|
496
|
+
function isIconInk(n) {
|
|
497
|
+
if (!(n.asset || n.geometry || n.assetSkipped) || !INK_TYPES.has(String(n.type))) return false;
|
|
498
|
+
return !(Array.isArray(n.fills) && n.fills.some((f) => f.type === "image" || f.type === "gradient"));
|
|
499
|
+
}
|
|
500
|
+
function overlaps(a, b) {
|
|
501
|
+
if (!a || !b || typeof a.x !== "number" || typeof a.y !== "number" || typeof b.x !== "number" || typeof b.y !== "number") return true;
|
|
502
|
+
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
503
|
+
}
|
|
504
|
+
var paintsSurface = (n) => !hiddenSelf(n) && n.type !== "TEXT" && !isIconInk(n) && Array.isArray(n.fills) && n.fills.length > 0;
|
|
505
|
+
function beneathOf(parent, kids, i) {
|
|
506
|
+
const child = kids[i];
|
|
507
|
+
if (!child) return [];
|
|
508
|
+
const stacks = !parent.layout || parent.layout.mode === "absolute";
|
|
509
|
+
if (!stacks && !child.absolute) return [];
|
|
510
|
+
return kids.slice(0, i).filter((s) => s && typeof s === "object" && paintsSurface(s) && overlaps(s.box, child.box));
|
|
511
|
+
}
|
|
512
|
+
function memo(fn) {
|
|
513
|
+
let cell = null;
|
|
514
|
+
return () => (cell ??= { v: fn() }).v;
|
|
515
|
+
}
|
|
516
|
+
function walk(root, fn, prune, opts) {
|
|
517
|
+
const pathOf = opts && opts.pathOf || ((n, i) => n.name || n.type || String(i));
|
|
518
|
+
(function go(node, parentHidden, path8, ancestors, under, beneath) {
|
|
519
|
+
if (!node || typeof node !== "object") return;
|
|
520
|
+
const hidden = isHidden(node, parentHidden);
|
|
521
|
+
if (hidden && prune) return;
|
|
522
|
+
const backdrop = memo(() => beneath().reduce(paintOver, under()));
|
|
523
|
+
const parentFrame = ancestors[ancestors.length - 1];
|
|
524
|
+
const frame = { node, hidden, get backdrop() {
|
|
525
|
+
return backdrop();
|
|
526
|
+
}, get beneath() {
|
|
527
|
+
return beneath();
|
|
528
|
+
} };
|
|
529
|
+
fn(node, {
|
|
530
|
+
hidden,
|
|
531
|
+
parentHidden: !!parentHidden,
|
|
532
|
+
path: path8,
|
|
533
|
+
parent: parentFrame ? parentFrame.node : null,
|
|
534
|
+
depth: ancestors.length,
|
|
535
|
+
ancestors,
|
|
536
|
+
get backdrop() {
|
|
537
|
+
return backdrop();
|
|
538
|
+
},
|
|
539
|
+
get beneath() {
|
|
540
|
+
return beneath();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
const kids = Array.isArray(node.children) ? node.children : [];
|
|
544
|
+
if (!kids.length) return;
|
|
545
|
+
const inside2 = memo(() => paintOver(backdrop(), node));
|
|
546
|
+
const chain = [...ancestors, frame];
|
|
547
|
+
for (const [i, kid] of kids.entries()) go(kid, hidden, (path8 ? path8 + " > " : "") + pathOf(kid, i), chain, inside2, memo(() => beneathOf(node, kids, i)));
|
|
548
|
+
})(root, false, root ? pathOf(root, 0) : "", [], () => NOTHING, () => []);
|
|
549
|
+
}
|
|
550
|
+
function walkVisible(root, fn, opts) {
|
|
551
|
+
walk(root, fn, true, opts);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// design-to-code/doc-guards.ts
|
|
555
|
+
function optArrayOf(x, each) {
|
|
556
|
+
return x === void 0 || Array.isArray(x) && x.every(each);
|
|
557
|
+
}
|
|
558
|
+
var optObj = (x) => x === void 0 || isJsonObject(x);
|
|
559
|
+
var optStr = (x) => x === void 0 || typeof x === "string";
|
|
560
|
+
var anyObject = (x) => isJsonObject(x);
|
|
561
|
+
var isAlias = (v) => !!v && typeof v === "object" && "aliasOf" in v && typeof v.aliasOf === "string";
|
|
562
|
+
var isComposed = (v) => {
|
|
563
|
+
if (!v || typeof v !== "object" || !("composed" in v)) return false;
|
|
564
|
+
const c = v.composed;
|
|
565
|
+
if (!c || typeof c !== "object" || !("color" in c) || !("opacity" in c)) return false;
|
|
566
|
+
const colorOk = typeof c.color === "string" || isAlias(c.color);
|
|
567
|
+
const opacityOk = typeof c.opacity === "number" || isAlias(c.opacity);
|
|
568
|
+
return colorOk && opacityOk && (isAlias(c.color) || isAlias(c.opacity));
|
|
569
|
+
};
|
|
570
|
+
var NUMERIC_TEXT = /^-?\d+(?:\.\d+)?$/;
|
|
571
|
+
function isVariable(x) {
|
|
572
|
+
return isJsonObject(x) && typeof x.name === "string" && typeof x.type === "string" && isJsonObject(x.values) && optStr(x.collection);
|
|
573
|
+
}
|
|
574
|
+
function isVariableCollection(x) {
|
|
575
|
+
return isJsonObject(x) && typeof x.name === "string" && isStringArray(x.modes);
|
|
576
|
+
}
|
|
577
|
+
function isTokensDoc(x) {
|
|
578
|
+
return isJsonObject(x) && optArrayOf(x.variables, isVariable) && optArrayOf(x.collections, isVariableCollection) && optArrayOf(x._slices, anyObject) && optArrayOf(x._conflicts, anyObject) && (x.hygiene === void 0 || isStringArray(x.hygiene));
|
|
579
|
+
}
|
|
580
|
+
isTokensDoc.expected = "a token catalog: an object whose `variables` (each {name, type, values}) and `collections` (each {name, modes[]}), when present, are arrays";
|
|
581
|
+
function isCatalogComponent(x) {
|
|
582
|
+
return isJsonObject(x) && typeof x.name === "string" && optStr(x.key) && optStr(x.id) && optObj(x.props) && optArrayOf(x.variants, anyObject);
|
|
583
|
+
}
|
|
584
|
+
function isComponentsCatalog(x) {
|
|
585
|
+
return isJsonObject(x) && Array.isArray(x.components) && x.components.every(isCatalogComponent);
|
|
586
|
+
}
|
|
587
|
+
isComponentsCatalog.expected = "a component catalog: an object with a `components` array of {name, type, key?, id?, props?}";
|
|
588
|
+
function isComponentDetailFile(x) {
|
|
589
|
+
return isJsonObject(x) && typeof x.name === "string" && optArrayOf(x.variants, anyObject) && optObj(x.node);
|
|
590
|
+
}
|
|
591
|
+
isComponentDetailFile.expected = "a component detail file: an object with a `name` and `variants[]` or `node`";
|
|
592
|
+
function isTextStylesDoc(x) {
|
|
593
|
+
return isJsonObject(x) && Array.isArray(x.styles) && x.styles.every((s) => isJsonObject(s) && typeof s.name === "string");
|
|
594
|
+
}
|
|
595
|
+
isTextStylesDoc.expected = "a text-style sheet: an object with a `styles` array of {name, \u2026}";
|
|
596
|
+
function isScreenAssetsDoc(x) {
|
|
597
|
+
return isJsonObject(x) && optArrayOf(x.heavy, (h) => isJsonObject(h) && typeof h.file === "string" && typeof h.bytes === "number" && (h.paths === void 0 || typeof h.paths === "number") && (h.embeddedRaster === void 0 || typeof h.embeddedRaster === "number")) && optArrayOf(x.files, (f) => isJsonObject(f) && typeof f.file === "string" && optStr(f.node));
|
|
598
|
+
}
|
|
599
|
+
isScreenAssetsDoc.expected = "a screen asset manifest: an object whose `heavy` ({file, bytes}) and `files` ({file, node?}), when present, are arrays";
|
|
600
|
+
function isLibrariesIndex(x) {
|
|
601
|
+
return isJsonObject(x) && Array.isArray(x.libraries) && x.libraries.every((r) => isJsonObject(r) && typeof r.dir === "string" && optStr(r.libraryName) && (r.collectionKeys === void 0 || isStringArray(r.collectionKeys)));
|
|
602
|
+
}
|
|
603
|
+
isLibrariesIndex.expected = "a library index: an object with a `libraries` array of {dir, libraryName?, collectionKeys?}";
|
|
604
|
+
var SEVERITIES = ["blocker", "warning", "info"];
|
|
605
|
+
function isAuditOverridesDoc(x) {
|
|
606
|
+
return isJsonObject(x) && Array.isArray(x.overrides) && x.overrides.every((o) => isJsonObject(o) && typeof o.code === "string" && typeof o.severity === "string" && SEVERITIES.includes(o.severity) && typeof o.reason === "string" && o.reason.trim() !== "" && ["nodeId", "token", "component", "collection", "mode", "category", "state", "screen", "decidedBy", "decidedAt"].every((k) => optStr(o[k])));
|
|
607
|
+
}
|
|
608
|
+
isAuditOverridesDoc.expected = "an audit overrides file: { overrides: [{ code, severity: blocker|warning|info, reason (non-empty), nodeId?, token?, component?, collection?, mode?, category?, state?, screen?, decidedBy?, decidedAt? }] }";
|
|
609
|
+
function isAuditReport(x) {
|
|
610
|
+
return isJsonObject(x) && isJsonObject(x.summary) && Array.isArray(x.findings) && x.findings.every((f) => isJsonObject(f) && typeof f.severity === "string" && typeof f.code === "string");
|
|
611
|
+
}
|
|
612
|
+
isAuditReport.expected = "an audit report (written by the audit script's --out): an object with `summary` and a `findings` array of {severity, code, message}";
|
|
613
|
+
function isProposal(x) {
|
|
614
|
+
return isJsonObject(x) && typeof x.name === "string";
|
|
615
|
+
}
|
|
616
|
+
function isProposalList(x) {
|
|
617
|
+
return Array.isArray(x) && x.every(isProposal);
|
|
618
|
+
}
|
|
619
|
+
isProposalList.expected = "a list of component proposals: an array of {name, catalog, confirmed, \u2026}";
|
|
620
|
+
function isVerifyExpectation(x) {
|
|
621
|
+
return isJsonObject(x) && isJsonObject(x.frame) && Array.isArray(x.nodes) && x.nodes.every((n) => isJsonObject(n) && typeof n.nodeId === "string") && optArrayOf(x.instances, anyObject) && optArrayOf(x.interactions, anyObject) && optArrayOf(x.notComparable, anyObject);
|
|
622
|
+
}
|
|
623
|
+
isVerifyExpectation.expected = "a verify expectation (the verify-screen script's --expect output): an object with `frame` and a `nodes` array of {nodeId, \u2026}";
|
|
624
|
+
var isNameVersion = (x) => isJsonObject(x) && typeof x.version === "string" && (typeof x.package === "string" || typeof x.name === "string");
|
|
625
|
+
function isProbeIdentity(x) {
|
|
626
|
+
return isJsonObject(x) && typeof x.name === "string" && (x.version === null || typeof x.version === "string") && typeof x.sha256 === "string" && isNameVersion(x.playwright) && isNameVersion(x.browser);
|
|
627
|
+
}
|
|
628
|
+
function isBuildIdentity(x) {
|
|
629
|
+
return isJsonObject(x) && typeof x.url === "string" && (x.mode === "vite-dev" || x.mode === "static" || x.mode === "unknown") && typeof x.assets === "number" && typeof x.assetsSha256 === "string" && (x.unhashed === void 0 || typeof x.unhashed === "number") && (x.gitHead === null || typeof x.gitHead === "string") && (x.gitDirty === null || typeof x.gitDirty === "boolean");
|
|
630
|
+
}
|
|
631
|
+
var isProbeFrame = (x) => isJsonObject(x) && typeof x.nodeId === "string" && typeof x.selector === "string" && typeof x.via === "string" && isJsonObject(x.rect);
|
|
632
|
+
var isCountMap = (x) => isJsonObject(x) && Object.values(x).every((v) => typeof v === "number");
|
|
633
|
+
var isNavigation = (x) => isJsonObject(x) && Array.isArray(x.events) && typeof x.afterInitialLoad === "number" && typeof x.reruns === "number";
|
|
634
|
+
var isTagsNotInExpectation = (x) => isJsonObject(x) && typeof x.count === "number" && Array.isArray(x.ids) && x.ids.every((r) => isJsonObject(r) && typeof r.id === "string" && typeof r.elements === "number");
|
|
635
|
+
var isReasonMap = (x) => isJsonObject(x) && Object.values(x).every((v) => typeof v === "string");
|
|
636
|
+
var isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
637
|
+
function isProbeReach(x) {
|
|
638
|
+
return isJsonObject(x) && Array.isArray(x.steps) && x.steps.every(isJsonObject) && typeof x.sha256 === "string" && typeof x.source === "string" && typeof x.url === "string";
|
|
639
|
+
}
|
|
640
|
+
function isPageOverflow(x) {
|
|
641
|
+
return isJsonObject(x) && isJsonObject(x.viewport) && isNum(x.viewport.w) && isNum(x.viewport.h) && isNum(x.scrollWidth) && isNum(x.clientWidth) && typeof x.overflowX === "string" && typeof x.scrollable === "boolean" && Array.isArray(x.offenders) && x.offenders.every((o) => isJsonObject(o) && typeof o.path === "string" && (o.dt === null || typeof o.dt === "string") && isNum(o.right)) && optStr(x.compatMode);
|
|
642
|
+
}
|
|
643
|
+
var BEHAVIOUR_STATUSES = ["pass", "fail", "warn", "not-run", "unsupported"];
|
|
644
|
+
var isBehaviourStatus = (x) => typeof x === "string" && BEHAVIOUR_STATUSES.some((s) => s === x);
|
|
645
|
+
function isBehaviourCheck(x) {
|
|
646
|
+
return isJsonObject(x) && typeof x.id === "string" && isBehaviourStatus(x.status) && typeof x.detail === "string";
|
|
647
|
+
}
|
|
648
|
+
function isMeasuredBehaviour(x) {
|
|
649
|
+
return isJsonObject(x) && x.version === 1 && typeof x.ran === "boolean" && (x.ran ? Array.isArray(x.checks) && x.checks.every(isBehaviourCheck) : typeof x.why === "string");
|
|
650
|
+
}
|
|
651
|
+
function isMeasuredVisual(x) {
|
|
652
|
+
return isJsonObject(x) && x.version === 1 && typeof x.ran === "boolean" && (x.ran ? isNum(x.differingPct) && isNum(x.shiftTolerantPct) && Array.isArray(x.regions) : typeof x.why === "string");
|
|
653
|
+
}
|
|
654
|
+
var MEASURED_EXTRAS = [
|
|
655
|
+
["probe", isProbeIdentity, "the shipped probe's identity {name, version, sha256, playwright:{package, version}, browser:{name, version}} \u2014 read as probe: unknown"],
|
|
656
|
+
["frame", isProbeFrame, "a probe frame {nodeId, selector, via, rect}"],
|
|
657
|
+
["frames", (x) => Array.isArray(x) && x.every(isProbeFrame), "a list of probe frames {nodeId, selector, via, rect}"],
|
|
658
|
+
["navigation", isNavigation, "a navigation log {events[], afterInitialLoad, reruns}"],
|
|
659
|
+
["matchedByCensus", isCountMap, "a {rule: count} map"],
|
|
660
|
+
["notMeasured", Array.isArray, "a list \u2014 the probe's reasons for unmatched nodes are not used"],
|
|
661
|
+
// the run it belongs to and the build it was served
|
|
662
|
+
["runId", (x) => typeof x === "string" && x !== "", "a run id (string) \u2014 the measurement is tied to no verify run"],
|
|
663
|
+
["build", isBuildIdentity, "a build identity {url, mode: vite-dev|static|unknown, assets, assetsSha256, gitHead, gitDirty} \u2014 read as build: unknown"],
|
|
664
|
+
// the shipped probe's foreign tags
|
|
665
|
+
["tagsNotInExpectation", isTagsNotInExpectation, "a foreign-tag list {count, ids: [{id, elements}]}"],
|
|
666
|
+
// the steps replayed, the page's overflow, the behaviour/a11y block
|
|
667
|
+
["reach", isProbeReach, "the probe's steps {steps[], sha256, source, url}"],
|
|
668
|
+
["page", isPageOverflow, "a page overflow {viewport:{w,h}, scrollWidth, clientWidth, overflowX, scrollable, offenders[]} \u2014 page overflow not measured"],
|
|
669
|
+
["behaviour", isMeasuredBehaviour, "a behaviour block {version: 1, ran: true, checks: [{id, status: pass|fail|warn|not-run|unsupported, detail}], \u2026} or {version: 1, ran: false, why} \u2014 behaviour/a11y not reported"],
|
|
670
|
+
// the visual diff (informational)
|
|
671
|
+
["visual", isMeasuredVisual, "a visual block {version: 1, ran: true, differingPct, shiftTolerantPct, regions: [\u2026], \u2026} or {version: 1, ran: false, why} \u2014 the visual diff not reported"]
|
|
672
|
+
];
|
|
673
|
+
function isMeasuredCore(x) {
|
|
674
|
+
return isJsonObject(x) && optArrayOf(x.nodes, (n) => isJsonObject(n) && typeof n.nodeId === "string") && optArrayOf(x.components, anyObject) && optArrayOf(x.interactions, anyObject) && (x.artifacts === void 0 || Array.isArray(x.artifacts)) && optStr(x.mode) && optStr(x.expectationSha256);
|
|
675
|
+
}
|
|
676
|
+
function isVerifyMeasured(x) {
|
|
677
|
+
return isMeasuredCore(x) && MEASURED_EXTRAS.every(([k, ok]) => x[k] === void 0 || ok(x[k])) && (x.nodes === void 0 || Array.isArray(x.nodes) && x.nodes.every((n) => !isJsonObject(n) || n.unmeasured === void 0 || isReasonMap(n.unmeasured)));
|
|
678
|
+
}
|
|
679
|
+
isVerifyMeasured.expected = "probe measurements: an object whose `nodes` (each {nodeId, styles}), `components`, `interactions` and `artifacts`, when present, are arrays";
|
|
680
|
+
function isEvidence(x) {
|
|
681
|
+
return isJsonObject(x) && typeof x.nodeId === "string";
|
|
682
|
+
}
|
|
683
|
+
function isInteractionEvidenceList(x) {
|
|
684
|
+
return Array.isArray(x) && x.every(isEvidence);
|
|
685
|
+
}
|
|
686
|
+
isInteractionEvidenceList.expected = "interaction evidence: a JSON array of {nodeId, trigger, ok, selector, selectorCount, detail}";
|
|
687
|
+
function isMeasuredComponentList(x) {
|
|
688
|
+
return Array.isArray(x) && x.every((c) => isJsonObject(c) && optStr(c.setName) && optStr(c.name) && optStr(c.nodeId) && (c.present === void 0 || typeof c.present === "boolean"));
|
|
689
|
+
}
|
|
690
|
+
isMeasuredComponentList.expected = "component evidence: a JSON array of {setName|nodeId, present: true|false}";
|
|
691
|
+
function isVerifyReport(x) {
|
|
692
|
+
return isJsonObject(x) && optStr(x.schema) && optStr(x.verdict) && optStr(x.screen) && optStr(x.nodeId) && optStr(x.headline) && (x.why === void 0 || isStringArray(x.why)) && (x.integrity === void 0 || isStringArray(x.integrity)) && optArrayOf(x.deltas, anyObject) && optObj(x.inputs);
|
|
693
|
+
}
|
|
694
|
+
isVerifyReport.expected = "a verify report (the verify-screen script's --compare output): an object with `verdict`, `why[]`, `deltas[]`, `inputs`";
|
|
695
|
+
var PLAN_ARRAYS = ["files", "tokens", "components", "hidden", "anchorsSuggested", "deviations", "allowedLiterals", "waivers", "descopes"];
|
|
696
|
+
var PLAN_OBJECTS = ["anchors", "verification", "counts"];
|
|
697
|
+
var PLAN_STRINGS = ["schema", "screen", "screenName", "nodeId", "route", "file", "exportedAt", "status"];
|
|
698
|
+
function planProblem(x) {
|
|
699
|
+
if (!isJsonObject(x)) return "is not a plan (the file holds " + (Array.isArray(x) ? "an array" : x === null ? "null" : typeof x) + ", not an object)";
|
|
700
|
+
for (const k of PLAN_ARRAYS) if (x[k] !== void 0 && !Array.isArray(x[k])) return `is not a valid plan: \`${k}\` must be an array`;
|
|
701
|
+
for (const k of PLAN_OBJECTS) if (x[k] !== void 0 && !isJsonObject(x[k])) return `is not a valid plan: \`${k}\` must be an object`;
|
|
702
|
+
for (const k of PLAN_STRINGS) if (x[k] !== void 0 && x[k] !== null && typeof x[k] !== "string") return `is not a valid plan: \`${k}\` must be a string`;
|
|
703
|
+
if (x.files !== void 0 && !isStringArray(x.files)) return "is not a valid plan: `files` must be an array of paths (strings)";
|
|
704
|
+
for (const k of ["tokens", "components", "allowedLiterals", "deviations", "hidden", "anchorsSuggested", "waivers", "descopes"]) {
|
|
705
|
+
const list = x[k];
|
|
706
|
+
if (Array.isArray(list) && !list.every(isJsonObject)) return `is not a valid plan: every \`${k}\` entry must be an object`;
|
|
707
|
+
}
|
|
708
|
+
if (Array.isArray(x.tokens) && !x.tokens.every((t) => isJsonObject(t) && (t.figmaName === null || optStr(t.figmaName)))) return "is not a valid plan: a `tokens` row's `figmaName` must be a string";
|
|
709
|
+
if (Array.isArray(x.components) && !x.components.every((c) => isJsonObject(c) && typeof c.name === "string")) return "is not a valid plan: every `components` row needs its `name`";
|
|
710
|
+
if (isJsonObject(x.anchors) && !Object.values(x.anchors).every(isJsonObject)) return "is not a valid plan: every `anchors` entry must be an object";
|
|
711
|
+
if (x.auditGate !== void 0 && x.auditGate !== null && !isJsonObject(x.auditGate)) return "is not a valid plan: `auditGate` must be an object or null";
|
|
712
|
+
if (x.target !== void 0 && x.target !== null && typeof x.target !== "string" && !isJsonObject(x.target)) return "is not a valid plan: `target` must be a profile name, an object or null";
|
|
713
|
+
if (x.tagging !== void 0 && x.tagging !== null && !(isJsonObject(x.tagging) && (x.tagging.off === void 0 || typeof x.tagging.off === "boolean") && optStr(x.tagging.reason))) return 'is not a valid plan: `tagging` must be {"off": true, "reason": "\u2026"}';
|
|
714
|
+
if (Array.isArray(x.tokens) && !x.tokens.every((t) => isJsonObject(t) && optStr(t.acknowledged))) return "is not a valid plan: a `tokens` row's `acknowledged` must be a string (the reason)";
|
|
715
|
+
if (isJsonObject(x.verification) && x.verification.hook !== void 0 && !isJsonObject(x.verification.hook)) return "is not a valid plan: `verification.hook` must be an object";
|
|
716
|
+
if (isJsonObject(x.verification) && x.verification.recorded !== void 0 && !isJsonObject(x.verification.recorded)) return "is not a valid plan: `verification.recorded` must be an object";
|
|
717
|
+
for (const k of ["navigate", "interactions"]) if (x[k] !== void 0 && !Array.isArray(x[k])) return `is not a valid plan: \`${k}\` must be an array`;
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
function isPlan(x) {
|
|
721
|
+
return planProblem(x) === null;
|
|
722
|
+
}
|
|
723
|
+
isPlan.expected = "a plan (started by the plan-skeleton script): an object whose files/tokens/components/deviations are arrays of objects and whose anchors/verification are objects";
|
|
724
|
+
var recIsCount = (x) => typeof x === "number" && Number.isFinite(x);
|
|
725
|
+
var recIsStr = (v) => typeof v === "string";
|
|
726
|
+
var recNeed = (ok) => ({ need: true, ok });
|
|
727
|
+
var recOpt = (ok) => ({ need: false, ok });
|
|
728
|
+
var RECORD_COUNTS = Object.fromEntries(["high", "medium", "low", "accepted", "notMeasured", "inferred"].map((k) => [k, recNeed(recIsCount)]));
|
|
729
|
+
var RECORD_DELTA = { nodeId: recNeed(recIsStr), field: recNeed(recIsStr), severity: recNeed((v) => v === "high" || v === "medium"), expected: recOpt(() => true), actual: recOpt(() => true) };
|
|
730
|
+
var RECORD_A11Y = { tool: recNeed(recIsStr), violations: recNeed(recIsCount), warnings: recNeed(recIsCount), report: recNeed(recIsStr) };
|
|
731
|
+
var RECORD_FIELDS = {
|
|
732
|
+
schema: recNeed((v) => v === PLAN_VERIFICATION_RECORD_SCHEMA),
|
|
733
|
+
by: recNeed(recIsStr),
|
|
734
|
+
at: recNeed(recIsStr),
|
|
735
|
+
report: recNeed(recIsStr),
|
|
736
|
+
reportSha256: recNeed(recIsStr),
|
|
737
|
+
verdict: recNeed(recIsStr),
|
|
738
|
+
headline: recNeed(recIsStr),
|
|
739
|
+
behaviourHeadline: recOpt(recIsStr),
|
|
740
|
+
counts: recNeed(isJsonObject),
|
|
741
|
+
mode: recNeed((v) => v === "rendered" || v === "static-only"),
|
|
742
|
+
reason: recOpt(recIsStr),
|
|
743
|
+
renderer: recOpt(recIsStr),
|
|
744
|
+
artifacts: recNeed(isStringArray),
|
|
745
|
+
deltas: recNeed(Array.isArray),
|
|
746
|
+
a11y: recOpt(isJsonObject)
|
|
747
|
+
};
|
|
748
|
+
var RECORD_KEYS = Object.keys(RECORD_FIELDS);
|
|
749
|
+
var reqStr = (v) => typeof v === "string" && v.trim() !== "";
|
|
750
|
+
function isPlanWaiver(x) {
|
|
751
|
+
return isJsonObject(x) && reqStr(x.nodeId) && reqStr(x.field) && x.designed !== void 0 && x.built !== void 0 && reqStr(x.exportContentSha256) && reqStr(x.reason) && reqStr(x.decidedBy) && reqStr(x.decidedAt) && (x.tolerance === void 0 || typeof x.tolerance === "number" && x.tolerance >= 0) && optStr(x.cause);
|
|
752
|
+
}
|
|
753
|
+
isPlanWaiver.expected = "a plan waiver {nodeId, field, designed, built, exportContentSha256, reason, decidedBy, decidedAt, tolerance?, cause?}";
|
|
754
|
+
function isPlanDescope(x) {
|
|
755
|
+
return isJsonObject(x) && reqStr(x.nodeId) && reqStr(x.trigger) && optStr(x.destinationId) && reqStr(x.exportContentSha256) && reqStr(x.reason) && reqStr(x.decidedBy) && reqStr(x.decidedAt);
|
|
756
|
+
}
|
|
757
|
+
isPlanDescope.expected = "a plan descope {nodeId, trigger, destinationId?, exportContentSha256, reason, decidedBy, decidedAt}";
|
|
758
|
+
function isStringRecord(x) {
|
|
759
|
+
return isJsonObject(x) && Object.values(x).every((v) => typeof v === "string");
|
|
760
|
+
}
|
|
761
|
+
isStringRecord.expected = "an object of strings";
|
|
762
|
+
|
|
763
|
+
// bridge/src/export-reader.ts
|
|
764
|
+
import fs4 from "node:fs";
|
|
765
|
+
import path2 from "node:path";
|
|
766
|
+
var PAGES = "pages";
|
|
767
|
+
var INDEX = "index.json";
|
|
768
|
+
function resolveInside(base, rel, within = base) {
|
|
769
|
+
const root = path2.resolve(within), file = path2.resolve(base, ...rel.split("/"));
|
|
770
|
+
return file.startsWith(root + path2.sep) ? file : null;
|
|
771
|
+
}
|
|
772
|
+
var inside = (root, rel) => typeof rel === "string" && rel && !path2.isAbsolute(rel) ? resolveInside(root, rel) : null;
|
|
773
|
+
var relOf = (root, file) => path2.relative(root, file).split(path2.sep).join("/");
|
|
774
|
+
var problemOf = (root, file, read) => "error" in read && !read.missing ? { file: relOf(root, file), error: read.error } : null;
|
|
775
|
+
var optStr2 = (x) => x === void 0 || typeof x === "string";
|
|
776
|
+
var isIndexRowLike = (x) => isRecord(x) && typeof x.id === "string" && typeof x.name === "string";
|
|
777
|
+
var isRowList = (x) => Array.isArray(x) && x.every((r) => r === null || isIndexRowLike(r));
|
|
778
|
+
var isPagesRootIndex = /* @__PURE__ */ Object.assign(
|
|
779
|
+
(x) => isRecord(x) && Array.isArray(x.pageDirs) && x.pageDirs.every((d) => isRecord(d) && optStr2(d.dir) && optStr2(d.index)) && (x.layers === void 0 || isRowList(x.layers)) && optStr2(x.exportedAt),
|
|
780
|
+
{ expected: "the export's pages/index.json: an object with a `pageDirs` array (and `layers`, when present, an array of {id, name, file})" }
|
|
781
|
+
);
|
|
782
|
+
var isPageIndex = /* @__PURE__ */ Object.assign(
|
|
783
|
+
(x) => isRecord(x) && isRowList(x.layers),
|
|
784
|
+
{ expected: "a page index (pages/<Page>/index.json): an object with a `layers` array of {id, name, file}" }
|
|
785
|
+
);
|
|
786
|
+
function screenSiblingFile(screenFile, kind) {
|
|
787
|
+
return screenFile.replace(/\.json$/i, "") + "." + kind + ".json";
|
|
788
|
+
}
|
|
789
|
+
var rootIndexFile = (exportRoot) => path2.join(exportRoot, PAGES, INDEX);
|
|
790
|
+
var hasExportIndex = (exportRoot) => fs4.existsSync(rootIndexFile(exportRoot));
|
|
791
|
+
function pagesRootOfScreen(screenFile) {
|
|
792
|
+
const pages = path2.dirname(path2.dirname(path2.resolve(screenFile)));
|
|
793
|
+
return path2.basename(pages) === PAGES ? path2.dirname(pages) : null;
|
|
794
|
+
}
|
|
795
|
+
function exportRootOfScreen(screenFile) {
|
|
796
|
+
const root = pagesRootOfScreen(screenFile);
|
|
797
|
+
return root !== null && hasExportIndex(root) ? root : null;
|
|
798
|
+
}
|
|
799
|
+
function pageIndexFiles(root, pageDirs) {
|
|
800
|
+
const files = [];
|
|
801
|
+
const add = (f) => {
|
|
802
|
+
if (f !== null && !files.includes(path2.resolve(f))) files.push(path2.resolve(f));
|
|
803
|
+
};
|
|
804
|
+
const ofDir = (dir) => dir && !/[\\/]/.test(dir) && dir !== ".." && dir !== "." ? path2.join(root, PAGES, dir, INDEX) : null;
|
|
805
|
+
for (const pd of pageDirs) add(pd.index ? inside(root, pd.index) : ofDir(pd.dir));
|
|
806
|
+
let dirs = [];
|
|
807
|
+
try {
|
|
808
|
+
dirs = fs4.readdirSync(path2.join(root, PAGES), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
809
|
+
} catch {
|
|
810
|
+
}
|
|
811
|
+
for (const d of dirs) add(ofDir(d));
|
|
812
|
+
return files;
|
|
813
|
+
}
|
|
814
|
+
function readExportIndex(exportRoot) {
|
|
815
|
+
const root = exportRoot;
|
|
816
|
+
const rootFile = rootIndexFile(root);
|
|
817
|
+
const read = readJson(rootFile, isPagesRootIndex);
|
|
818
|
+
const rootDoc = "doc" in read ? read.doc : null;
|
|
819
|
+
const problems = [];
|
|
820
|
+
const rootProblem = problemOf(root, rootFile, read);
|
|
821
|
+
if (rootProblem) problems.push(rootProblem);
|
|
822
|
+
const rows = [];
|
|
823
|
+
const byId = /* @__PURE__ */ new Map();
|
|
824
|
+
const files = /* @__PURE__ */ new Set();
|
|
825
|
+
const fromRoot = /* @__PURE__ */ new Set();
|
|
826
|
+
const written = /* @__PURE__ */ new Map();
|
|
827
|
+
const writtenAt = (row) => {
|
|
828
|
+
let t = written.get(row);
|
|
829
|
+
if (t === void 0) {
|
|
830
|
+
const f = inside(root, row.file);
|
|
831
|
+
try {
|
|
832
|
+
t = f === null ? -1 : fs4.statSync(f).mtimeMs;
|
|
833
|
+
} catch {
|
|
834
|
+
t = -1;
|
|
835
|
+
}
|
|
836
|
+
written.set(row, t);
|
|
837
|
+
}
|
|
838
|
+
return t;
|
|
839
|
+
};
|
|
840
|
+
const hasFile = (row) => {
|
|
841
|
+
const file = row.file;
|
|
842
|
+
return typeof file === "string" && file !== "";
|
|
843
|
+
};
|
|
844
|
+
const probeOf = (row) => hasFile(row) ? row : { ...row, file: "\0no file " + rows.length };
|
|
845
|
+
const sameScreen = (older, newer) => hasFile(older) && older.file === newer.file || sameScreenRow(probeOf(older), newer);
|
|
846
|
+
const keep = (row, isRoot) => {
|
|
847
|
+
rows.push(row);
|
|
848
|
+
const bucket = byId.get(row.id);
|
|
849
|
+
if (bucket) bucket.push(row);
|
|
850
|
+
else byId.set(row.id, [row]);
|
|
851
|
+
if (hasFile(row)) files.add(row.file);
|
|
852
|
+
if (isRoot) fromRoot.add(row);
|
|
853
|
+
};
|
|
854
|
+
const take = (row, isRoot) => {
|
|
855
|
+
const candidates = [...byId.get(row.id) ?? [], ...hasFile(row) && files.has(row.file) ? rows.filter((k) => k.file === row.file && k.id !== row.id) : []];
|
|
856
|
+
if (isRoot) {
|
|
857
|
+
if (!candidates.some((k) => sameScreen(row, k))) keep(row, true);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const newer = (k) => writtenAt(row) > writtenAt(k);
|
|
861
|
+
const conflicts = candidates.filter((k) => fromRoot.has(k) || !newer(k) ? sameScreen(row, k) : sameScreen(k, row));
|
|
862
|
+
if (!conflicts.length) {
|
|
863
|
+
keep(row, false);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (conflicts.some((k) => fromRoot.has(k) || !newer(k))) return;
|
|
867
|
+
const at = Math.min(...conflicts.map((k) => rows.indexOf(k)));
|
|
868
|
+
for (const k of conflicts) {
|
|
869
|
+
rows.splice(rows.indexOf(k), 1);
|
|
870
|
+
const bucket = byId.get(k.id) ?? [];
|
|
871
|
+
bucket.splice(bucket.indexOf(k), 1);
|
|
872
|
+
if (!bucket.length) byId.delete(k.id);
|
|
873
|
+
if (hasFile(k) && !rows.some((r) => r.file === k.file)) files.delete(k.file);
|
|
874
|
+
}
|
|
875
|
+
keep(row, false);
|
|
876
|
+
rows.splice(rows.length - 1, 1);
|
|
877
|
+
rows.splice(at, 0, row);
|
|
878
|
+
};
|
|
879
|
+
for (const row of rootDoc?.layers ?? []) if (row) take(row, true);
|
|
880
|
+
for (const f of pageIndexFiles(root, rootDoc?.pageDirs ?? [])) {
|
|
881
|
+
const page = readJson(f, isPageIndex);
|
|
882
|
+
const problem = problemOf(root, f, page);
|
|
883
|
+
if (problem) problems.push(problem);
|
|
884
|
+
if ("doc" in page) {
|
|
885
|
+
for (const row of page.doc.layers) if (row) take(row, false);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const rootStamp = rootDoc?.exportedAt || void 0;
|
|
889
|
+
const label = (v) => typeof v === "string" && v ? v : void 0;
|
|
890
|
+
const rootLabel = rootDoc ? label(rootDoc.file) ?? label(rootDoc.sourceFile) : void 0;
|
|
891
|
+
return {
|
|
892
|
+
root,
|
|
893
|
+
indexed: rootDoc !== null,
|
|
894
|
+
rootProblem: rootProblem && rootProblem.error,
|
|
895
|
+
problems,
|
|
896
|
+
rows,
|
|
897
|
+
rootExportedAt: rootStamp,
|
|
898
|
+
rootSourceFile: rootLabel,
|
|
899
|
+
isExported: (id) => byId.has(id),
|
|
900
|
+
fileOf: (row) => inside(root, row.file),
|
|
901
|
+
exportedAtOf: (row) => typeof row.exportedAt === "string" && row.exportedAt || (fromRoot.has(row) ? rootStamp : void 0)
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// bridge/src/library-layout.ts
|
|
906
|
+
var ROOT = "libraries";
|
|
907
|
+
var INDEX2 = "index.json";
|
|
908
|
+
var { TOKENS: TOKENS2, HYGIENE: HYGIENE2 } = DESIGN_SYSTEM_FILES;
|
|
909
|
+
var COMPONENTS = "components.json";
|
|
910
|
+
var LIBRARY_FILE_NAMES = [TOKENS2, ...STYLE_KINDS.map((row) => row.file), COMPONENTS, HYGIENE2, INDEX2];
|
|
911
|
+
|
|
912
|
+
// design-to-code/design-system-dir.ts
|
|
913
|
+
var { TOKENS: TOKENS3, COMPONENTS_LOCAL: COMPONENTS_LOCAL2, COMPONENTS_LIBRARY: COMPONENTS_LIBRARY2 } = DESIGN_SYSTEM_FILES;
|
|
914
|
+
var STYLES_TEXT = STYLE_KIND_ROWS.text.file;
|
|
915
|
+
function designSystemFiles(dir) {
|
|
916
|
+
const local = path3.join(dir, COMPONENTS_LOCAL2);
|
|
917
|
+
const isLibrary = !fs5.existsSync(local) && fs5.existsSync(path3.join(dir, COMPONENTS));
|
|
918
|
+
return {
|
|
919
|
+
tokens: path3.join(dir, TOKENS3),
|
|
920
|
+
components: isLibrary ? path3.join(dir, COMPONENTS) : local,
|
|
921
|
+
componentsLibrary: path3.join(dir, COMPONENTS_LIBRARY2),
|
|
922
|
+
stylesText: path3.join(dir, STYLES_TEXT),
|
|
923
|
+
componentsFile: isLibrary ? COMPONENTS : COMPONENTS_LOCAL2,
|
|
924
|
+
isLibrary
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function readDesignSystemDir(dir) {
|
|
928
|
+
const f = designSystemFiles(dir);
|
|
929
|
+
return {
|
|
930
|
+
tokens: readOptionalDoc(f.tokens, "design-system tokens", isTokensDoc),
|
|
931
|
+
components: readOptionalDoc(f.components, "component catalog", isComponentsCatalog),
|
|
932
|
+
componentsLibrary: readOptionalDoc(f.componentsLibrary, "library component catalog", isComponentsCatalog),
|
|
933
|
+
stylesText: readOptionalDoc(f.stylesText, "text styles", isTextStylesDoc),
|
|
934
|
+
componentsFile: f.componentsFile,
|
|
935
|
+
isLibrary: f.isLibrary
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function exportRootOf(screenFile, dsDir) {
|
|
939
|
+
const roots = [];
|
|
940
|
+
if (screenFile) {
|
|
941
|
+
const pages = path3.dirname(path3.dirname(screenFile));
|
|
942
|
+
if (path3.basename(pages) === "pages") roots.push(path3.dirname(pages));
|
|
943
|
+
}
|
|
944
|
+
if (dsDir) {
|
|
945
|
+
const parent = path3.dirname(path3.normalize(dsDir));
|
|
946
|
+
roots.push(path3.basename(parent) === ROOT ? path3.dirname(parent) : parent);
|
|
947
|
+
}
|
|
948
|
+
return roots.find((r) => fs5.existsSync(path3.join(r, ROOT, INDEX2))) ?? null;
|
|
949
|
+
}
|
|
950
|
+
function findLibraryExports(screenFile, dsDir) {
|
|
951
|
+
const root = exportRootOf(screenFile, dsDir);
|
|
952
|
+
if (!root) return [];
|
|
953
|
+
const index = readJsonOrNull(path3.join(root, ROOT, INDEX2), isLibrariesIndex);
|
|
954
|
+
if (!index) return [];
|
|
955
|
+
return index.libraries.filter((r) => r.dir && r.dir !== "." && r.dir !== ".." && !/[\\/]/.test(r.dir)).map((r) => {
|
|
956
|
+
const rel = path3.join(root, ROOT, r.dir).split(path3.sep).join("/");
|
|
957
|
+
return { rel, name: r.libraryName || r.dir, collectionKeys: r.collectionKeys || [], components: readJsonOrNull(path3.join(rel, COMPONENTS), isComponentsCatalog) };
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// design-to-code/map-validate-core.ts
|
|
962
|
+
var STATUSES = ["active", "deprecated", "needs-review"];
|
|
963
|
+
var KEYS = {
|
|
964
|
+
root: ["version", "figmaFileKey", "components"],
|
|
965
|
+
entry: ["figma", "code", "props", "variantOverrides", "childrenByLayer", "status", "note"],
|
|
966
|
+
figma: ["key", "id", "name", "unstable"],
|
|
967
|
+
code: ["module", "export", "targets"],
|
|
968
|
+
target: ["module", "export"],
|
|
969
|
+
vo: ["when", "code"],
|
|
970
|
+
voCode: ["module", "export"],
|
|
971
|
+
children: ["layerNamePattern", "slot"],
|
|
972
|
+
prop: Object.assign(nullProto(), {
|
|
973
|
+
enum: ["kind", "codeProp", "values", "default", "omitDefault"],
|
|
974
|
+
boolean: ["kind", "codeProp", "default", "omitDefault"],
|
|
975
|
+
string: ["kind", "codeProp"],
|
|
976
|
+
instance: ["kind", "codeProp", "slot"]
|
|
977
|
+
})
|
|
978
|
+
};
|
|
979
|
+
var PROP_KINDS = Object.keys(KEYS.prop);
|
|
980
|
+
var isStr = (v) => typeof v === "string";
|
|
981
|
+
var isBool = (v) => typeof v === "boolean";
|
|
982
|
+
var noExtra = (obj, allowed, at, err) => {
|
|
983
|
+
if (!Array.isArray(allowed)) return;
|
|
984
|
+
for (const k of Object.keys(obj)) if (!allowed.includes(k)) err(`${at}.${k}`, "unknown property (additionalProperties:false)");
|
|
985
|
+
};
|
|
986
|
+
var optStrings = (obj, keys, at, err) => {
|
|
987
|
+
for (const k of keys) if (obj[k] !== void 0 && !isStr(obj[k])) err(`${at}.${k}`, "must be a string");
|
|
988
|
+
};
|
|
989
|
+
function validateMap(map) {
|
|
990
|
+
const errors = [];
|
|
991
|
+
const err = (path8, message) => errors.push({ path: path8, message });
|
|
992
|
+
if (!isRecord(map)) return { ok: false, errors: [{ path: "", message: "map must be an object" }] };
|
|
993
|
+
noExtra(map, KEYS.root, "", err);
|
|
994
|
+
if (map.version !== 1) err("version", "must be 1");
|
|
995
|
+
if (map.figmaFileKey !== void 0 && !isStr(map.figmaFileKey)) err("figmaFileKey", "must be a string");
|
|
996
|
+
if (!isRecord(map.components)) {
|
|
997
|
+
err("components", "must be an object");
|
|
998
|
+
return { ok: false, errors };
|
|
999
|
+
}
|
|
1000
|
+
for (const key of Object.keys(map.components)) {
|
|
1001
|
+
const e = map.components[key];
|
|
1002
|
+
const at = `components.${key}`;
|
|
1003
|
+
if (!isRecord(e)) {
|
|
1004
|
+
err(at, "entry must be an object");
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
noExtra(e, KEYS.entry, at, err);
|
|
1008
|
+
if (!isRecord(e.figma)) err(`${at}.figma`, "required object");
|
|
1009
|
+
else {
|
|
1010
|
+
noExtra(e.figma, KEYS.figma, `${at}.figma`, err);
|
|
1011
|
+
if (!isStr(e.figma.name)) err(`${at}.figma.name`, "required string");
|
|
1012
|
+
optStrings(e.figma, ["key", "id"], `${at}.figma`, err);
|
|
1013
|
+
if (e.figma.unstable !== void 0 && !isBool(e.figma.unstable)) err(`${at}.figma.unstable`, "must be a boolean");
|
|
1014
|
+
}
|
|
1015
|
+
if (!isRecord(e.code)) err(`${at}.code`, "required object");
|
|
1016
|
+
else {
|
|
1017
|
+
noExtra(e.code, KEYS.code, `${at}.code`, err);
|
|
1018
|
+
if (!isStr(e.code.module)) err(`${at}.code.module`, "required string");
|
|
1019
|
+
if (!isStr(e.code.export)) err(`${at}.code.export`, "required string");
|
|
1020
|
+
if (e.code.targets !== void 0) {
|
|
1021
|
+
if (!isRecord(e.code.targets)) err(`${at}.code.targets`, "must be an object");
|
|
1022
|
+
else for (const t of Object.keys(e.code.targets)) {
|
|
1023
|
+
const tv = e.code.targets[t], ta = `${at}.code.targets.${t}`;
|
|
1024
|
+
if (!isRecord(tv)) err(ta, "must be an object");
|
|
1025
|
+
else {
|
|
1026
|
+
noExtra(tv, KEYS.target, ta, err);
|
|
1027
|
+
optStrings(tv, ["module", "export"], ta, err);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (e.status !== void 0 && (!isStr(e.status) || !STATUSES.includes(e.status))) err(`${at}.status`, `must be one of ${STATUSES.join("|")}`);
|
|
1033
|
+
optStrings(e, ["note"], at, err);
|
|
1034
|
+
if (e.props !== void 0) {
|
|
1035
|
+
if (!isRecord(e.props)) err(`${at}.props`, "must be an object");
|
|
1036
|
+
else for (const pn of Object.keys(e.props)) validateProp(e.props[pn], `${at}.props.${pn}`, err);
|
|
1037
|
+
}
|
|
1038
|
+
if (e.variantOverrides !== void 0) {
|
|
1039
|
+
if (!Array.isArray(e.variantOverrides)) err(`${at}.variantOverrides`, "must be an array");
|
|
1040
|
+
else e.variantOverrides.forEach((vo, i) => {
|
|
1041
|
+
const va = `${at}.variantOverrides[${i}]`;
|
|
1042
|
+
if (!isRecord(vo)) {
|
|
1043
|
+
err(va, "must be an object");
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
noExtra(vo, KEYS.vo, va, err);
|
|
1047
|
+
if (!isRecord(vo.when)) err(`${va}.when`, "required object");
|
|
1048
|
+
else for (const wk of Object.keys(vo.when)) if (!isStr(vo.when[wk])) err(`${va}.when.${wk}`, "value must be a string");
|
|
1049
|
+
if (!isRecord(vo.code)) err(`${va}.code`, "required object");
|
|
1050
|
+
else {
|
|
1051
|
+
noExtra(vo.code, KEYS.voCode, `${va}.code`, err);
|
|
1052
|
+
if (!isStr(vo.code.module)) err(`${va}.code.module`, "required string");
|
|
1053
|
+
if (!isStr(vo.code.export)) err(`${va}.code.export`, "required string");
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
if (e.childrenByLayer !== void 0) {
|
|
1058
|
+
if (!isRecord(e.childrenByLayer)) err(`${at}.childrenByLayer`, "must be an object");
|
|
1059
|
+
else {
|
|
1060
|
+
noExtra(e.childrenByLayer, KEYS.children, `${at}.childrenByLayer`, err);
|
|
1061
|
+
optStrings(e.childrenByLayer, ["layerNamePattern", "slot"], `${at}.childrenByLayer`, err);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return { ok: errors.length === 0, errors };
|
|
1066
|
+
}
|
|
1067
|
+
function validateProp(p, at, err) {
|
|
1068
|
+
if (!isRecord(p)) {
|
|
1069
|
+
err(at, "prop must be an object");
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const allowed = typeof p.kind === "string" ? KEYS.prop[p.kind] : void 0;
|
|
1073
|
+
if (!Array.isArray(allowed)) {
|
|
1074
|
+
err(`${at}.kind`, `must be one of ${PROP_KINDS.join("|")}`);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
noExtra(p, allowed, at, err);
|
|
1078
|
+
if (p.kind !== "instance" && !isStr(p.codeProp)) err(`${at}.codeProp`, "required string");
|
|
1079
|
+
if (p.kind === "instance" && p.codeProp !== void 0 && !isStr(p.codeProp)) err(`${at}.codeProp`, "must be a string");
|
|
1080
|
+
if (p.kind === "instance" && p.slot !== void 0 && !isStr(p.slot)) err(`${at}.slot`, "must be a string");
|
|
1081
|
+
if (p.kind === "enum") {
|
|
1082
|
+
if (p.values !== void 0 && !isRecord(p.values)) err(`${at}.values`, "must be an object (VARIANT option -> code value)");
|
|
1083
|
+
else if (isRecord(p.values)) for (const k of Object.keys(p.values)) {
|
|
1084
|
+
const t = typeof p.values[k];
|
|
1085
|
+
if (p.values[k] !== null && !["string", "number", "boolean"].includes(t)) err(`${at}.values.${k}`, "value must be string|number|boolean|null");
|
|
1086
|
+
}
|
|
1087
|
+
if (p.default !== void 0 && !["string", "number", "boolean"].includes(typeof p.default)) err(`${at}.default`, "must be string|number|boolean");
|
|
1088
|
+
}
|
|
1089
|
+
if (p.kind === "boolean" && p.default !== void 0 && !isBool(p.default)) err(`${at}.default`, "must be a boolean");
|
|
1090
|
+
if ((p.kind === "enum" || p.kind === "boolean") && p.omitDefault !== void 0 && !isBool(p.omitDefault)) err(`${at}.omitDefault`, "must be a boolean");
|
|
1091
|
+
}
|
|
1092
|
+
function isCodeConnectMap(x) {
|
|
1093
|
+
return validateMap(x).ok;
|
|
1094
|
+
}
|
|
1095
|
+
isCodeConnectMap.expected = "a valid component map (the map-validate script lists what is wrong with it)";
|
|
1096
|
+
|
|
1097
|
+
// design-to-code/slice-sources.ts
|
|
1098
|
+
import fs6 from "node:fs";
|
|
1099
|
+
import path4 from "node:path";
|
|
1100
|
+
function sourcesOf(doc, docPath) {
|
|
1101
|
+
const out = /* @__PURE__ */ new Map();
|
|
1102
|
+
const add = (key, screen) => {
|
|
1103
|
+
if (typeof key !== "string" || !key || !screen) return;
|
|
1104
|
+
const list = out.get(key) || [];
|
|
1105
|
+
if (!list.includes(screen)) list.push(screen);
|
|
1106
|
+
out.set(key, list);
|
|
1107
|
+
};
|
|
1108
|
+
const base = docPath ? path4.dirname(docPath) : ".";
|
|
1109
|
+
for (const sl of doc && Array.isArray(doc._slices) ? doc._slices : []) {
|
|
1110
|
+
if (!sl) continue;
|
|
1111
|
+
let read = false;
|
|
1112
|
+
if (typeof sl.file === "string") {
|
|
1113
|
+
const slice = readJsonOrNull(path4.join(base, sl.file.replace(/\.json$/, ".vars.json")), isTokensDoc);
|
|
1114
|
+
if (slice) {
|
|
1115
|
+
for (const v of slice.variables || []) add(v.key, sl.screen);
|
|
1116
|
+
read = true;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (!read) for (const k of Array.isArray(sl.keys) ? sl.keys : []) add(k, sl.screen);
|
|
1120
|
+
}
|
|
1121
|
+
for (const c of doc && Array.isArray(doc._conflicts) ? doc._conflicts : []) {
|
|
1122
|
+
for (const vr of c && "variants" in c && c.variants || []) for (const sc of vr.screens || []) add(vr.key, sc);
|
|
1123
|
+
}
|
|
1124
|
+
if (!out.size && docPath && /\.vars\.json$/.test(docPath)) {
|
|
1125
|
+
for (const v of doc && doc.variables || []) add(v.key, path4.basename(docPath, ".vars.json"));
|
|
1126
|
+
}
|
|
1127
|
+
return out;
|
|
1128
|
+
}
|
|
1129
|
+
function variablesContext(screenFiles, varsFile, opts) {
|
|
1130
|
+
const invalid = [];
|
|
1131
|
+
const readTokens = (f) => {
|
|
1132
|
+
if (!f) return null;
|
|
1133
|
+
const r = readJson(f, isTokensDoc);
|
|
1134
|
+
if ("doc" in r) return r.doc;
|
|
1135
|
+
if (!r.missing) invalid.push({ file: f, error: r.error });
|
|
1136
|
+
return null;
|
|
1137
|
+
};
|
|
1138
|
+
const files = screenFiles || [];
|
|
1139
|
+
const own = files.map((f) => readTokens(screenSiblingFile(String(f), "vars")));
|
|
1140
|
+
let variablesPath = varsFile || null;
|
|
1141
|
+
const firstFile = files[0];
|
|
1142
|
+
if (!variablesPath && firstFile !== void 0) {
|
|
1143
|
+
const exportRoot = path4.resolve(path4.dirname(firstFile), "..", "..");
|
|
1144
|
+
const rootVars = path4.join(exportRoot, MERGED_VARIABLES_FILE);
|
|
1145
|
+
const sibling = path4.join(path4.dirname(firstFile), MERGED_VARIABLES_FILE);
|
|
1146
|
+
if (fs6.existsSync(rootVars)) variablesPath = rootVars;
|
|
1147
|
+
else if (fs6.existsSync(sibling)) variablesPath = sibling;
|
|
1148
|
+
else if (opts && opts.sliceFallback && files.length === 1 && own[0]) variablesPath = screenSiblingFile(String(firstFile), "vars");
|
|
1149
|
+
}
|
|
1150
|
+
const variablesDoc = readTokens(variablesPath);
|
|
1151
|
+
let staleLegacy = null;
|
|
1152
|
+
if (firstFile !== void 0) {
|
|
1153
|
+
const legacy2 = path4.join(path4.resolve(path4.dirname(firstFile), "..", ".."), "..", MERGED_VARIABLES_FILE);
|
|
1154
|
+
if (fs6.existsSync(legacy2) && path4.resolve(legacy2) !== path4.resolve(variablesPath || "")) staleLegacy = legacy2;
|
|
1155
|
+
}
|
|
1156
|
+
return { own, variablesPath, variablesDoc, sliceSources: variablesDoc ? sourcesOf(variablesDoc, variablesPath) : null, staleLegacy, invalid };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// design-to-code/screen-inputs.ts
|
|
1160
|
+
function exportSiblings(files) {
|
|
1161
|
+
const checked = new Set(files.map((f) => path5.resolve(f)));
|
|
1162
|
+
const roots = /* @__PURE__ */ new Set();
|
|
1163
|
+
for (const f of checked) {
|
|
1164
|
+
const root = exportRootOfScreen(f);
|
|
1165
|
+
if (root !== null) roots.add(root);
|
|
1166
|
+
}
|
|
1167
|
+
if (!roots.size) return null;
|
|
1168
|
+
return () => {
|
|
1169
|
+
const out = [];
|
|
1170
|
+
const seen = new Set(checked);
|
|
1171
|
+
for (const root of roots) {
|
|
1172
|
+
const ix = readExportIndex(root);
|
|
1173
|
+
for (const row of ix.rows) {
|
|
1174
|
+
const file = ix.fileOf(row);
|
|
1175
|
+
if (file === null || seen.has(file)) continue;
|
|
1176
|
+
seen.add(file);
|
|
1177
|
+
const doc = readJsonOrNull(file, isScreenDoc);
|
|
1178
|
+
if (doc) out.push({ doc, label: path5.basename(file, ".json") });
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return out;
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
function designSystemDirOf(firstFile, named) {
|
|
1185
|
+
if (named) return { dir: named, given: true };
|
|
1186
|
+
const shown = (abs) => {
|
|
1187
|
+
const rel = path5.relative(process.cwd(), abs);
|
|
1188
|
+
return rel && !rel.startsWith("..") && !path5.isAbsolute(rel) ? rel.split(path5.sep).join("/") : abs;
|
|
1189
|
+
};
|
|
1190
|
+
const root = firstFile === void 0 ? null : pagesRootOfScreen(firstFile);
|
|
1191
|
+
const dir = root !== null ? path5.join(root, DESIGN_SYSTEM_DIR) : path5.resolve(findExportDir(process.cwd()).rel, DESIGN_SYSTEM_DIR);
|
|
1192
|
+
return fs7.existsSync(dir) && fs7.statSync(dir).isDirectory() ? { dir: shown(dir), given: false } : null;
|
|
1193
|
+
}
|
|
1194
|
+
function readScreenInputs(files, flags = {}) {
|
|
1195
|
+
const ctx = variablesContext(files, flags.variables, { sliceFallback: true });
|
|
1196
|
+
if (ctx.invalid.length) return { errors: ctx.invalid.map((bad) => `error variables: '${bad.file}' ${bad.error}`) };
|
|
1197
|
+
const notes = [];
|
|
1198
|
+
if (ctx.variablesPath) notes.push(`variables: ${ctx.variablesPath}`);
|
|
1199
|
+
else notes.push("note no variables: no variables.json at the export root or beside the screen (pass --variables)");
|
|
1200
|
+
if (ctx.staleLegacy) notes.push(`warn ${ctx.staleLegacy} also exists and was NOT used (stale sibling of design/export/) \u2014 remove it or re-pull into design/export/.`);
|
|
1201
|
+
const screens = files.map((f, i) => ({
|
|
1202
|
+
file: f,
|
|
1203
|
+
doc: readDocFile(f, "screen export", isScreenDoc),
|
|
1204
|
+
label: path5.basename(f, ".json"),
|
|
1205
|
+
...ctx.own[i] ? { vars: ctx.own[i] } : {},
|
|
1206
|
+
assets: readJsonOrNull(screenSiblingFile(f, "assets"), isScreenAssetsDoc)
|
|
1207
|
+
}));
|
|
1208
|
+
const firstFile = files[0];
|
|
1209
|
+
const at = designSystemDirOf(firstFile, flags.designSystem);
|
|
1210
|
+
const designSystem = at === null ? null : { ...readDesignSystemDir(at.dir), ...at };
|
|
1211
|
+
if (at === null) notes.push(`note no design system: none given, and no ${DESIGN_SYSTEM_DIR}/ beside the export (pass --design-system)`);
|
|
1212
|
+
else if (at.given && !fs7.existsSync(at.dir)) notes.push(`note --design-system ${at.dir} does not exist \u2014 no design system was read`);
|
|
1213
|
+
else notes.push(`design system: ${at.dir}${at.given ? "" : " (found beside the export; pass --design-system to choose another)"}`);
|
|
1214
|
+
const libraries = findLibraryExports(firstFile, at?.dir);
|
|
1215
|
+
let map = null, mapFile = null;
|
|
1216
|
+
if (flags.map) {
|
|
1217
|
+
map = readDocFile(flags.map, "component map", isCodeConnectMap);
|
|
1218
|
+
mapFile = flags.map;
|
|
1219
|
+
} else {
|
|
1220
|
+
const found = findMapFile(process.cwd());
|
|
1221
|
+
const r = found.missing ? null : readJson(found.rel, isCodeConnectMap);
|
|
1222
|
+
if (r && "doc" in r) {
|
|
1223
|
+
map = r.doc;
|
|
1224
|
+
mapFile = found.rel;
|
|
1225
|
+
notes.push(`map: ${found.rel}`);
|
|
1226
|
+
} else if (r) notes.push(`warn ${found.rel} ${r.error} \u2014 proposals are not labelled alreadyMapped${flags.mapFlag ? " (pass --map to fail on it)" : ""}`);
|
|
1227
|
+
}
|
|
1228
|
+
return {
|
|
1229
|
+
inputs: {
|
|
1230
|
+
screens,
|
|
1231
|
+
variables: ctx.variablesDoc,
|
|
1232
|
+
variablesPath: ctx.variablesPath,
|
|
1233
|
+
sliceSources: ctx.sliceSources,
|
|
1234
|
+
designSystem,
|
|
1235
|
+
libraries,
|
|
1236
|
+
map,
|
|
1237
|
+
mapFile,
|
|
1238
|
+
siblings: exportSiblings(files),
|
|
1239
|
+
notes
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
function crossCheckInputOf(c, fallback = {}) {
|
|
1244
|
+
const ds = c.designSystem;
|
|
1245
|
+
return {
|
|
1246
|
+
screens: c.screens,
|
|
1247
|
+
variables: c.variables ?? null,
|
|
1248
|
+
variablesPath: c.variablesPath ?? null,
|
|
1249
|
+
sliceSources: c.sliceSources ?? null,
|
|
1250
|
+
tokens: ds?.tokens ?? null,
|
|
1251
|
+
components: ds?.components || fallback.components || null,
|
|
1252
|
+
componentsLibrary: ds?.componentsLibrary ?? null,
|
|
1253
|
+
stylesText: ds?.stylesText ?? null,
|
|
1254
|
+
...ds?.componentsFile !== void 0 ? { componentsFile: ds.componentsFile } : {},
|
|
1255
|
+
...ds?.isLibrary !== void 0 ? { designSystemIsLibrary: ds.isLibrary } : {},
|
|
1256
|
+
map: c.map ?? null,
|
|
1257
|
+
siblings: c.siblings ?? null
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// design-to-code/cli-args.ts
|
|
1262
|
+
import path6 from "node:path";
|
|
1263
|
+
import { fileURLToPath } from "node:url";
|
|
1264
|
+
import { parseArgs } from "node:util";
|
|
1265
|
+
var SELF = fileURLToPath(import.meta.url);
|
|
1266
|
+
var shellQuote = (p) => /["$`\\!]/.test(p) ? `'${p.replaceAll("'", `'\\''`)}'` : `"${p}"`;
|
|
1267
|
+
var SCRIPT_RUNNER = "bun --no-install --no-env-file";
|
|
1268
|
+
var scriptCmd = (name) => `${SCRIPT_RUNNER} ${shellQuote(path6.join(path6.dirname(SELF), name + path6.extname(SELF)))}`;
|
|
1269
|
+
function joinNegativeValues(argv, options) {
|
|
1270
|
+
const out = [];
|
|
1271
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1272
|
+
const tok = argv[i], next = argv[i + 1];
|
|
1273
|
+
if (tok === void 0) continue;
|
|
1274
|
+
if (tok === "--") {
|
|
1275
|
+
out.push(...argv.slice(i));
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
const name = tok.startsWith("--") ? tok.slice(2) : void 0;
|
|
1279
|
+
if (name !== void 0 && next !== void 0 && options[name]?.type === "string" && /^-\d/.test(next)) {
|
|
1280
|
+
out.push(`${tok}=${next}`);
|
|
1281
|
+
i++;
|
|
1282
|
+
} else out.push(tok);
|
|
1283
|
+
}
|
|
1284
|
+
return out;
|
|
1285
|
+
}
|
|
1286
|
+
function badShortWords(args, options) {
|
|
1287
|
+
const shorts = /* @__PURE__ */ new Map();
|
|
1288
|
+
for (const o of Object.values(options)) if (o.short) shorts.set(o.short, o.type);
|
|
1289
|
+
const bad = [];
|
|
1290
|
+
let takesNext = false;
|
|
1291
|
+
for (const a of args) {
|
|
1292
|
+
if (a === "--") break;
|
|
1293
|
+
if (takesNext) {
|
|
1294
|
+
takesNext = false;
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
if (a.startsWith("--")) {
|
|
1298
|
+
takesNext = !a.includes("=") && options[a.slice(2)]?.type === "string";
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (!/^-[^-]/.test(a)) continue;
|
|
1302
|
+
let ok = true;
|
|
1303
|
+
for (const [i, c] of [...a.slice(1)].entries()) {
|
|
1304
|
+
const type = shorts.get(c);
|
|
1305
|
+
if (type === void 0) {
|
|
1306
|
+
ok = false;
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
1309
|
+
if (type === "string") {
|
|
1310
|
+
takesNext = i === a.length - 2;
|
|
1311
|
+
break;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (!ok) bad.push(a);
|
|
1315
|
+
}
|
|
1316
|
+
return bad;
|
|
1317
|
+
}
|
|
1318
|
+
function cliParse(tool, argv, options, usage, exitCode, parse) {
|
|
1319
|
+
const args = joinNegativeValues(argv, options);
|
|
1320
|
+
const unknownFlags = () => {
|
|
1321
|
+
const { tokens } = parseArgs({ args, options, strict: false, allowPositionals: true, tokens: true });
|
|
1322
|
+
const long = tokens.flatMap((t) => t.kind === "option" && t.rawName.startsWith("--") && args[t.index]?.startsWith("--") && !(t.name in options) ? [{ name: t.rawName, at: t.index }] : []);
|
|
1323
|
+
const short = badShortWords(args, options);
|
|
1324
|
+
const hint = short.some((w) => w.length > 2) ? ' (a value starting with "-": put -- before it, or use --flag=value)' : "";
|
|
1325
|
+
const named = [...long, ...short.map((w) => ({ name: w, at: args.indexOf(w) }))].sort((a, b) => a.at - b.at).map((n) => n.name);
|
|
1326
|
+
return `${tool}: unknown flag ${[...new Set(named)].join(", ")}${hint}
|
|
1327
|
+
${usage}`;
|
|
1328
|
+
};
|
|
1329
|
+
let failure;
|
|
1330
|
+
try {
|
|
1331
|
+
const parsed = parse(args);
|
|
1332
|
+
if (!badShortWords(args, options).length) return parsed;
|
|
1333
|
+
failure = unknownFlags();
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
const code = errCode(e);
|
|
1336
|
+
if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
1337
|
+
failure = unknownFlags();
|
|
1338
|
+
} else if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
|
|
1339
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1340
|
+
const names = /Option '([^']*)'/.exec(msg)?.[1]?.match(/--?[\w-]+/g) ?? [];
|
|
1341
|
+
const typed = names.find((n) => args.some((a) => a === n || a.startsWith(n + "="))) ?? names.at(-1);
|
|
1342
|
+
failure = `${tool}: ${typed ?? "an option"} ${/does not take an argument/.test(msg) ? "takes no value" : "needs a value"}
|
|
1343
|
+
${usage}`;
|
|
1344
|
+
} else {
|
|
1345
|
+
failure = `${tool}: ${errMsg(e)}
|
|
1346
|
+
${usage}`;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
console.error(failure);
|
|
1350
|
+
exitCli(exitCode);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// design-to-code/cross-check.ts
|
|
1354
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
1355
|
+
|
|
1356
|
+
// design-to-code/map-util.ts
|
|
1357
|
+
var alnumKey = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
1358
|
+
function getOrInit(m, k, init) {
|
|
1359
|
+
const have = m.get(k);
|
|
1360
|
+
if (have !== void 0) return have;
|
|
1361
|
+
const made = init();
|
|
1362
|
+
m.set(k, made);
|
|
1363
|
+
return made;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// design-to-code/component-match.ts
|
|
1367
|
+
function visibleInstances(doc, label) {
|
|
1368
|
+
const out = [];
|
|
1369
|
+
const visit = (n) => {
|
|
1370
|
+
if (n.type === "INSTANCE" && n.mainComponent) {
|
|
1371
|
+
const mc = n.mainComponent;
|
|
1372
|
+
out.push({
|
|
1373
|
+
screen: label,
|
|
1374
|
+
nodeId: n.id,
|
|
1375
|
+
layer: n.name,
|
|
1376
|
+
name: mc.setName || mc.name || n.name,
|
|
1377
|
+
...ifDefined("key", mc.key),
|
|
1378
|
+
...ifDefined("setKey", mc.setKey),
|
|
1379
|
+
remote: mc.remote === true,
|
|
1380
|
+
variant: parseVariant(n.component) || parseVariant(mc.variant),
|
|
1381
|
+
props: n.props && typeof n.props === "object" ? n.props : {}
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
for (const r of screenRoots(doc)) walkVisible(r, visit);
|
|
1386
|
+
return out;
|
|
1387
|
+
}
|
|
1388
|
+
function parseVariant(s) {
|
|
1389
|
+
if (isJsonObject(s)) {
|
|
1390
|
+
const out2 = Object.fromEntries(Object.entries(s).filter((e) => typeof e[1] === "string"));
|
|
1391
|
+
return Object.keys(out2).length ? out2 : null;
|
|
1392
|
+
}
|
|
1393
|
+
if (!s || typeof s !== "string" || !s.includes("=")) return null;
|
|
1394
|
+
const out = {};
|
|
1395
|
+
for (const part of s.split(/,\s*(?=[^,=]+=)/)) {
|
|
1396
|
+
const i = part.indexOf("=");
|
|
1397
|
+
if (i < 0) continue;
|
|
1398
|
+
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
1399
|
+
}
|
|
1400
|
+
return Object.keys(out).length ? out : null;
|
|
1401
|
+
}
|
|
1402
|
+
var baseProp = (p) => String(p).split("#")[0] ?? "";
|
|
1403
|
+
var SHARED_PAGE = /shared|style ?guide|foundation|core|global|design.?system|librar|banner|badge/i;
|
|
1404
|
+
function signature(inst, cand) {
|
|
1405
|
+
const props = {};
|
|
1406
|
+
for (const [k, v] of Object.entries(cand.props || {})) props[baseProp(k)] = v;
|
|
1407
|
+
const reasons = [];
|
|
1408
|
+
let score = 40, verified = true;
|
|
1409
|
+
reasons.push("name matches catalog entry verbatim");
|
|
1410
|
+
const variant = inst.variant;
|
|
1411
|
+
if (variant) {
|
|
1412
|
+
const axes = Object.keys(variant);
|
|
1413
|
+
const known = axes.filter((k) => props[k] && props[k].type === "VARIANT");
|
|
1414
|
+
if (known.length === axes.length) {
|
|
1415
|
+
score += 25;
|
|
1416
|
+
reasons.push(`all ${axes.length} variant prop name(s) exist as VARIANT props`);
|
|
1417
|
+
} else {
|
|
1418
|
+
verified = false;
|
|
1419
|
+
score += known.length ? 10 : -15;
|
|
1420
|
+
reasons.push(known.length ? `${known.length}/${axes.length} variant prop names exist` : "variant prop names are NOT on this candidate");
|
|
1421
|
+
}
|
|
1422
|
+
const bad = Object.entries(variant).filter(([k, val]) => {
|
|
1423
|
+
const d = props[k];
|
|
1424
|
+
return d && d.type === "VARIANT" && !(d.options || []).includes(val);
|
|
1425
|
+
}).map(([k]) => k);
|
|
1426
|
+
if (known.length && !bad.length) {
|
|
1427
|
+
score += 25;
|
|
1428
|
+
reasons.push(`variant value(s) ${known.map((k) => `${k}=${variant[k]}`).join(", ")} are in the option list`);
|
|
1429
|
+
} else if (bad.length) {
|
|
1430
|
+
verified = false;
|
|
1431
|
+
score -= 20;
|
|
1432
|
+
reasons.push(`variant value(s) not in option list (${bad.map((k) => `${k}=${variant[k]}`).join(", ")})`);
|
|
1433
|
+
}
|
|
1434
|
+
} else if (cand.type === "COMPONENT") {
|
|
1435
|
+
score += 15;
|
|
1436
|
+
reasons.push("instance has no variant string and the catalog entry is a plain COMPONENT");
|
|
1437
|
+
} else {
|
|
1438
|
+
verified = false;
|
|
1439
|
+
reasons.push(`instance sets no variant but the catalog entry is a ${cand.type}`);
|
|
1440
|
+
}
|
|
1441
|
+
const own = Object.keys(inst.props || {}).filter((k) => !variant || !(baseProp(k) in variant));
|
|
1442
|
+
if (own.length) {
|
|
1443
|
+
const typed = own.filter((k) => {
|
|
1444
|
+
const d = props[baseProp(k)], v = inst.props[k];
|
|
1445
|
+
if (!d) return false;
|
|
1446
|
+
if (typeof v === "boolean") return d.type === "BOOLEAN";
|
|
1447
|
+
return d.type === "TEXT" || d.type === "INSTANCE_SWAP" || d.type === "VARIANT";
|
|
1448
|
+
});
|
|
1449
|
+
if (typed.length === own.length) {
|
|
1450
|
+
score += 15;
|
|
1451
|
+
reasons.push(`all ${own.length} non-variant prop name(s) exist on the definition`);
|
|
1452
|
+
} else {
|
|
1453
|
+
verified = false;
|
|
1454
|
+
score += typed.length ? 5 : -10;
|
|
1455
|
+
reasons.push(typed.length ? `${typed.length}/${own.length} non-variant prop names exist with a matching type` : "none of the instance prop names exist on this definition");
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
if (SHARED_PAGE.test(String(cand.page || ""))) {
|
|
1459
|
+
score += 5;
|
|
1460
|
+
reasons.push(`lives on a shared page (${cand.page})`);
|
|
1461
|
+
}
|
|
1462
|
+
return { verified, score, reasons };
|
|
1463
|
+
}
|
|
1464
|
+
var sigOf = (c) => JSON.stringify(Object.entries(c.props || {}).map(([k, v]) => [baseProp(k), v.type, v.options || null]).sort());
|
|
1465
|
+
function matchByNameAndSignature(instances, catalog, library) {
|
|
1466
|
+
const comps = catalog && catalog.components || [];
|
|
1467
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1468
|
+
comps.forEach((c, i) => {
|
|
1469
|
+
getOrInit(byName, c.name, () => []).push(Object.assign({ _order: i }, c));
|
|
1470
|
+
});
|
|
1471
|
+
const catKeys = new Set(comps.map((c) => c.key).filter((k) => !!k));
|
|
1472
|
+
const libKeys = new Set((library && library.components || []).map((c) => c.key).filter((k) => !!k));
|
|
1473
|
+
const libNames = new Set((library && library.components || []).map((c) => c.name));
|
|
1474
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1475
|
+
for (const inst of instances) {
|
|
1476
|
+
const group = groups.get(inst.name);
|
|
1477
|
+
if (group) group.push(inst);
|
|
1478
|
+
else groups.set(inst.name, [inst]);
|
|
1479
|
+
}
|
|
1480
|
+
const rows = [];
|
|
1481
|
+
for (const [name, list] of groups) {
|
|
1482
|
+
const byKey = list.some((i) => catKeys.has(i.key ?? "") || catKeys.has(i.setKey ?? ""));
|
|
1483
|
+
const row = {
|
|
1484
|
+
name,
|
|
1485
|
+
instances: list.length,
|
|
1486
|
+
nodeIds: list.map((i) => i.nodeId),
|
|
1487
|
+
screens: [...new Set(list.map((i) => i.screen).filter((s) => !!s))],
|
|
1488
|
+
instanceKeys: [...new Set(list.map((i) => i.setKey || i.key).filter((k) => !!k))],
|
|
1489
|
+
remote: list.every((i) => i.remote),
|
|
1490
|
+
byKey,
|
|
1491
|
+
match: null,
|
|
1492
|
+
alternatives: [],
|
|
1493
|
+
reasons: []
|
|
1494
|
+
};
|
|
1495
|
+
const cands = byName.get(name) || [];
|
|
1496
|
+
if (!cands.length) {
|
|
1497
|
+
const inLibrary = list.some((i) => libKeys.has(i.key ?? "") || libKeys.has(i.setKey ?? ""));
|
|
1498
|
+
row.reasons.push(`no catalog entry named ${JSON.stringify(name)}`);
|
|
1499
|
+
row.reasons.push(inLibrary ? "its key IS in components.library.json \u2014 a third-party library both files consume, not the design system's own component" : libNames.has(name) ? "the name appears in components.library.json \u2014 a third-party library the design system consumes, not one of its own components" : "not a component of the exported design system (typically an external icon library) \u2014 expected, not a miss");
|
|
1500
|
+
rows.push(row);
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
if (cands.length > 1) row.candidates = cands.map((c) => ({ ...ifDefined("id", c.id), ...ifDefined("key", c.key), name: c.name }));
|
|
1504
|
+
const [firstInst, ...otherInsts] = list;
|
|
1505
|
+
const scored = cands.map((c) => {
|
|
1506
|
+
const first = signature(firstInst, c);
|
|
1507
|
+
const per = [first, ...otherInsts.map((i) => signature(i, c))];
|
|
1508
|
+
return { c, verified: per.every((p) => p.verified), score: Math.min(...per.map((p) => p.score)), reasons: first.reasons, failing: per.find((p) => !p.verified) };
|
|
1509
|
+
}).sort((a, b) => Number(b.verified) - Number(a.verified) || b.score - a.score || a.c._order - b.c._order);
|
|
1510
|
+
const best = scored[0];
|
|
1511
|
+
if (best === void 0) throw new Error("component-match: a non-empty candidate list scored to nothing");
|
|
1512
|
+
if (!best.verified) {
|
|
1513
|
+
row.reasons.push(`${cands.length} catalog entr${cands.length === 1 ? "y is" : "ies are"} named ${JSON.stringify(name)}, but no prop signature agrees: ` + (best.failing ? best.failing.reasons.filter((r) => /NOT|not in|none of|\d+\/\d+|sets no variant/.test(r)).join("; ") : "signature mismatch") + " \u2014 a name alone is not a match");
|
|
1514
|
+
rows.push(row);
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
const verifiedOnes = scored.filter((s) => s.verified);
|
|
1518
|
+
const runnerUp = verifiedOnes[1];
|
|
1519
|
+
const ties = verifiedOnes.filter((s) => s.score === best.score && s !== best);
|
|
1520
|
+
row.match = { ...ifDefined("id", best.c.id), ...ifDefined("key", best.c.key), name: best.c.name, type: best.c.type, ...ifDefined("page", best.c.page) };
|
|
1521
|
+
row.evidence = list.some((i) => i.variant || Object.keys(i.props || {}).length) ? "name+signature" : "name+no-props";
|
|
1522
|
+
row.reasons = best.reasons.slice();
|
|
1523
|
+
if (ties.length) {
|
|
1524
|
+
const identical = ties.every((t) => sigOf(t.c) === sigOf(best.c));
|
|
1525
|
+
row.reasons.push(identical ? `${ties.length + 1} catalog entries named "${name}" score identically \u2014 they are duplicates of one definition (same props, same variants); the first in the catalog is used` : `${ties.length + 1} catalog entries named "${name}" score identically with DIFFERENT signatures \u2014 the first in the catalog is used; confirm which one`);
|
|
1526
|
+
row.tie = identical ? "duplicate-definitions" : "different-signatures";
|
|
1527
|
+
} else if (scored.length > 1) {
|
|
1528
|
+
row.reasons.push(`beat ${scored.length - 1} same-named candidate(s)${runnerUp ? ` by ${best.score - runnerUp.score} pts` : " (their prop signatures do not agree)"}`);
|
|
1529
|
+
}
|
|
1530
|
+
row.alternatives = verifiedOnes.filter((s) => s !== best).map((s) => ({ ...ifDefined("id", s.c.id), ...ifDefined("key", s.c.key), ...ifDefined("page", s.c.page), score: s.score }));
|
|
1531
|
+
row.reasons.push("key lookup: " + (byKey ? "matched" : "NO MATCH (the instance's key is not in the catalog \u2014 re-keyed)"));
|
|
1532
|
+
rows.push(row);
|
|
1533
|
+
}
|
|
1534
|
+
const proposals = rows.filter((r) => !!r.match && !r.byKey);
|
|
1535
|
+
return {
|
|
1536
|
+
rows,
|
|
1537
|
+
proposals,
|
|
1538
|
+
summary: {
|
|
1539
|
+
instances: instances.length,
|
|
1540
|
+
names: rows.length,
|
|
1541
|
+
byKey: rows.filter((r) => r.byKey).length,
|
|
1542
|
+
proposed: proposals.length,
|
|
1543
|
+
proposedWithSignature: proposals.filter((r) => r.evidence === "name+signature").length,
|
|
1544
|
+
withCandidates: rows.filter((r) => (byName.get(r.name) || []).length).length,
|
|
1545
|
+
unmatched: rows.filter((r) => !r.match).length,
|
|
1546
|
+
remote: instances.filter((i) => i.remote).length
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
function nameVerdict(row) {
|
|
1551
|
+
if (row.match && row.tie === "different-signatures") {
|
|
1552
|
+
return { status: "ambiguous", reason: `several catalog entries named ${JSON.stringify(row.name)} agree with the instance equally but have DIFFERENT signatures \u2014 confirm which one` };
|
|
1553
|
+
}
|
|
1554
|
+
if (row.match) return { status: "matched", reason: `name + ${row.evidence === "name+no-props" ? "no props to check (a plain component)" : "prop signature"}${row.tie === "duplicate-definitions" ? "; same-named duplicates of one definition" : ""}` };
|
|
1555
|
+
const n = (row.candidates || []).length;
|
|
1556
|
+
if (n > 1) return { status: "ambiguous", reason: `${n} catalog entries are named ${JSON.stringify(row.name)} and none agrees with the instance's prop signature \u2014 confirm which one, if any` };
|
|
1557
|
+
const why = (row.reasons || []).find((r) => /no catalog entry named|no prop signature agrees/.test(r));
|
|
1558
|
+
return { status: "unmatched", reason: why || `no catalog entry named ${JSON.stringify(row.name)} agrees with the instance` };
|
|
1559
|
+
}
|
|
1560
|
+
var REKEY_MIN_PROPOSALS = 3;
|
|
1561
|
+
var REKEY_MIN_SHARE = 0.5;
|
|
1562
|
+
function isRekeyed(result) {
|
|
1563
|
+
const s = result.summary;
|
|
1564
|
+
return s.names > 0 && s.byKey / s.names <= 0.05 && s.proposedWithSignature >= REKEY_MIN_PROPOSALS && s.withCandidates > 0 && s.proposedWithSignature / s.withCandidates >= REKEY_MIN_SHARE;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// design-to-code/finding-id.ts
|
|
1568
|
+
function findingIds(findings) {
|
|
1569
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1570
|
+
return findings.map((f) => {
|
|
1571
|
+
const base = f.nodeId ? `${f.code}@${f.nodeId}` : f.code;
|
|
1572
|
+
const n = (seen.get(base) || 0) + 1;
|
|
1573
|
+
seen.set(base, n);
|
|
1574
|
+
return n === 1 ? base : `${base}~${n}`;
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// design-to-code/control-kind.ts
|
|
1579
|
+
var TOGGLE_WORD = /\b(checkbox|check box|radio|switch|toggle)\b/i;
|
|
1580
|
+
function controlKind(name) {
|
|
1581
|
+
const s = String(name || "");
|
|
1582
|
+
const toggle = TOGGLE_WORD.test(s);
|
|
1583
|
+
if (!toggle && /\b(select|dropdown|drop ?down|combo ?box|multi-?select|picker)\b/i.test(s.replace(/\bselect ?all\b/gi, ""))) return "select";
|
|
1584
|
+
if (/\b(input|text ?field|textfield|search|textarea)\b/i.test(s)) return "input";
|
|
1585
|
+
if (toggle) return "toggle";
|
|
1586
|
+
if (/\b(tab|tabs|segmented|nav ?item|navigation item)\b/i.test(s)) return "tab";
|
|
1587
|
+
if (/\blink\b/i.test(s)) return "link";
|
|
1588
|
+
if (/\b(button|btn|cta|icon ?button|chip)\b/i.test(s)) return "button";
|
|
1589
|
+
return null;
|
|
1590
|
+
}
|
|
1591
|
+
var isBoundaryKind = (a) => [a.name, a.mainComponent && a.mainComponent.setName, a.component].some((l) => {
|
|
1592
|
+
const k = controlKind(l);
|
|
1593
|
+
return k === "input" || k === "select" || k === "toggle";
|
|
1594
|
+
});
|
|
1595
|
+
function outermostControl(near) {
|
|
1596
|
+
for (let i = 0; i < near.length; i++) {
|
|
1597
|
+
const a = near[i];
|
|
1598
|
+
if (a && a.type === "INSTANCE" && isBoundaryKind(a)) return i;
|
|
1599
|
+
}
|
|
1600
|
+
for (let i = near.length - 1; i >= 0; i--) {
|
|
1601
|
+
const a = near[i];
|
|
1602
|
+
if (a && isBoundaryKind(a)) return i;
|
|
1603
|
+
}
|
|
1604
|
+
return -1;
|
|
1605
|
+
}
|
|
1606
|
+
var DISABLED_WORD = /^(disabled|inactive|is ?disabled)$/i;
|
|
1607
|
+
var ON_VALUE = /^(true|yes|on)$/i;
|
|
1608
|
+
function isDisabledLayer(layer) {
|
|
1609
|
+
for (const part of String(layer.mainComponent && layer.mainComponent.name || "").split(",")) {
|
|
1610
|
+
const [k = "", v = ""] = part.split("=").map((x) => x.trim());
|
|
1611
|
+
if (DISABLED_WORD.test(v) || DISABLED_WORD.test(k) && ON_VALUE.test(v)) return true;
|
|
1612
|
+
}
|
|
1613
|
+
for (const [k, v] of Object.entries(layer.props || {})) {
|
|
1614
|
+
if (DISABLED_WORD.test(k.trim()) && (v === true || typeof v === "string" && ON_VALUE.test(v.trim()))) return true;
|
|
1615
|
+
if (!layer.mainComponent && typeof v === "string" && /^disabled$/i.test(v.trim())) return true;
|
|
1616
|
+
}
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// design-to-code/variable-resolve.ts
|
|
1621
|
+
var cmpStr = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
1622
|
+
var definedModes = (v) => {
|
|
1623
|
+
const vals = v.values || {};
|
|
1624
|
+
return Object.keys(vals).filter((m) => vals[m] !== void 0);
|
|
1625
|
+
};
|
|
1626
|
+
var sameVariable = (a, b) => a === b || !!a.key && a.key === b.key;
|
|
1627
|
+
var sameCollection = (a, b) => a.collectionKey && b.collectionKey ? a.collectionKey === b.collectionKey : a.collection !== void 0 && a.collection === b.collection;
|
|
1628
|
+
function defaultAmong(v, modes, collections) {
|
|
1629
|
+
const hasDefault = (c) => c.default !== void 0 && modes.includes(c.default);
|
|
1630
|
+
const byKey = v.collectionKey ? collections.find((c) => c.key === v.collectionKey && hasDefault(c)) : void 0;
|
|
1631
|
+
return (byKey ?? (v.collection === void 0 ? void 0 : collections.find((c) => c.name === v.collection && hasDefault(c))))?.default;
|
|
1632
|
+
}
|
|
1633
|
+
var isLegacyRgba = (x) => !!x && typeof x === "object" && "r" in x && "g" in x && "b" in x && typeof x.r === "number" && typeof x.g === "number" && typeof x.b === "number";
|
|
1634
|
+
function readColour(raw) {
|
|
1635
|
+
if (typeof raw === "string") return parseHex(raw);
|
|
1636
|
+
if (!isLegacyRgba(raw)) return null;
|
|
1637
|
+
const a = raw.a === void 0 ? 1 : Number(raw.a);
|
|
1638
|
+
return { r: raw.r * 255, g: raw.g * 255, b: raw.b * 255, a: Number.isNaN(a) ? 1 : a };
|
|
1639
|
+
}
|
|
1640
|
+
var readNumber = (raw) => {
|
|
1641
|
+
const n = typeof raw === "number" ? raw : typeof raw === "string" && NUMERIC_TEXT.test(raw) ? Number(raw) : NaN;
|
|
1642
|
+
return Number.isFinite(n) ? n : null;
|
|
1643
|
+
};
|
|
1644
|
+
var isSourceList = (x) => Array.isArray(x);
|
|
1645
|
+
var colourOf = (r) => r.fold ?? readColour(r.value);
|
|
1646
|
+
function variableResolver(input) {
|
|
1647
|
+
const sources = isSourceList(input) ? input : [input];
|
|
1648
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1649
|
+
const collections = [];
|
|
1650
|
+
let order = 0;
|
|
1651
|
+
for (const [source, src] of sources.entries()) {
|
|
1652
|
+
for (const v of src && src.variables || []) {
|
|
1653
|
+
if (!v || typeof v.name !== "string") continue;
|
|
1654
|
+
const list = byName.get(v.name);
|
|
1655
|
+
const entry = { v, source, order: order++ };
|
|
1656
|
+
if (list) list.push(entry);
|
|
1657
|
+
else byName.set(v.name, [entry]);
|
|
1658
|
+
}
|
|
1659
|
+
for (const c of src && src.collections || []) if (c) collections.push(c);
|
|
1660
|
+
}
|
|
1661
|
+
const entries = (name, referrer) => (byName.get(name) || []).filter((e) => !referrer || !sameVariable(e.v, referrer));
|
|
1662
|
+
function target(name, referrer) {
|
|
1663
|
+
const cands = entries(name, referrer);
|
|
1664
|
+
if (cands.length <= 1) return cands[0]?.v ?? null;
|
|
1665
|
+
const own = (e) => referrer && sameCollection(e.v, referrer) ? 0 : 1;
|
|
1666
|
+
return cands.slice().sort((a, b) => own(a) - own(b) || a.source - b.source || cmpStr(a.v.key || "", b.v.key || "") || a.order - b.order)[0]?.v ?? null;
|
|
1667
|
+
}
|
|
1668
|
+
function modeOf(v, req = {}) {
|
|
1669
|
+
const modes = definedModes(v);
|
|
1670
|
+
const fromFrame = v.collection === void 0 ? void 0 : req.byCollection?.[v.collection];
|
|
1671
|
+
for (const want of [fromFrame, req.mode]) {
|
|
1672
|
+
if (want === void 0) continue;
|
|
1673
|
+
if (modes.includes(want)) return want;
|
|
1674
|
+
const lower = want.toLowerCase();
|
|
1675
|
+
const ci = modes.find((m) => m.toLowerCase() === lower);
|
|
1676
|
+
if (ci !== void 0) return ci;
|
|
1677
|
+
}
|
|
1678
|
+
if (modes.length === 1) return modes[0];
|
|
1679
|
+
const fallback = req.fallback ?? "first";
|
|
1680
|
+
if (fallback === "none") return void 0;
|
|
1681
|
+
const def = defaultAmong(v, modes, collections);
|
|
1682
|
+
if (def !== void 0 || fallback === "default") return def;
|
|
1683
|
+
return modes[0];
|
|
1684
|
+
}
|
|
1685
|
+
function walk2(v, req, seen) {
|
|
1686
|
+
const none = (mode2) => ({ value: void 0, variable: null, mode: mode2, via: [] });
|
|
1687
|
+
if (!v || seen.has(v)) return none();
|
|
1688
|
+
const mode = modeOf(v, req);
|
|
1689
|
+
if (mode === void 0) return none();
|
|
1690
|
+
const raw = (v.values || {})[mode];
|
|
1691
|
+
const next = new Set(seen).add(v);
|
|
1692
|
+
if (isAlias(raw)) {
|
|
1693
|
+
const r = walk2(target(raw.aliasOf, v), req, next);
|
|
1694
|
+
return { ...r, mode, via: [raw.aliasOf, ...r.via] };
|
|
1695
|
+
}
|
|
1696
|
+
if (isComposed(raw)) {
|
|
1697
|
+
const c = composed(v, raw.composed, req, next);
|
|
1698
|
+
return c ? { value: formatHex(c), variable: v, mode, via: [], fold: c } : none(mode);
|
|
1699
|
+
}
|
|
1700
|
+
if (raw === void 0 || !!raw && typeof raw === "object" && "composed" in raw) return none(mode);
|
|
1701
|
+
return { value: raw, variable: v, mode, via: [] };
|
|
1702
|
+
}
|
|
1703
|
+
function composed(owner, half, req, seen) {
|
|
1704
|
+
const c = isAlias(half.color) ? colourOf(walk2(target(half.color.aliasOf, owner), req, seen)) : readColour(half.color);
|
|
1705
|
+
const o = isAlias(half.opacity) ? readNumber(walk2(target(half.opacity.aliasOf, owner), req, seen).value) : readNumber(half.opacity);
|
|
1706
|
+
return c && o !== null ? { ...c, a: composeAlpha(c.a, o) } : null;
|
|
1707
|
+
}
|
|
1708
|
+
function closure(v) {
|
|
1709
|
+
const vars = [];
|
|
1710
|
+
let complete = true;
|
|
1711
|
+
const done = /* @__PURE__ */ new Set();
|
|
1712
|
+
const path8 = /* @__PURE__ */ new Set();
|
|
1713
|
+
const visit = (x) => {
|
|
1714
|
+
if (path8.has(x)) {
|
|
1715
|
+
complete = false;
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
if (done.has(x)) return;
|
|
1719
|
+
vars.push(x);
|
|
1720
|
+
path8.add(x);
|
|
1721
|
+
for (const val of Object.values(x.values || {})) {
|
|
1722
|
+
const names = isAlias(val) ? [val.aliasOf] : isComposed(val) ? [val.composed.color, val.composed.opacity].filter(isAlias).map((a) => a.aliasOf) : [];
|
|
1723
|
+
for (const n of names) {
|
|
1724
|
+
const t = target(n, x);
|
|
1725
|
+
if (t) visit(t);
|
|
1726
|
+
else complete = false;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
path8.delete(x);
|
|
1730
|
+
done.add(x);
|
|
1731
|
+
};
|
|
1732
|
+
visit(v);
|
|
1733
|
+
return { vars, complete };
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
candidates: (name, referrer) => entries(name, referrer).map((e) => e.v),
|
|
1737
|
+
target,
|
|
1738
|
+
modeOf,
|
|
1739
|
+
resolve: (v, req = {}) => {
|
|
1740
|
+
const { value, variable, mode, via } = walk2(v, req, /* @__PURE__ */ new Set());
|
|
1741
|
+
return { value, variable, mode, via };
|
|
1742
|
+
},
|
|
1743
|
+
colour: (v, req = {}) => colourOf(walk2(v, req, /* @__PURE__ */ new Set())),
|
|
1744
|
+
number: (v, req = {}) => readNumber(walk2(v, req, /* @__PURE__ */ new Set()).value),
|
|
1745
|
+
closure
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// design-to-code/cross-check-core.ts
|
|
1750
|
+
var SEVERITY_ORDER = { blocker: 0, warning: 1, info: 2 };
|
|
1751
|
+
var ABSURD_NUMBER = 1e4;
|
|
1752
|
+
var WRONG_CATALOG_PCT = 5;
|
|
1753
|
+
function crossCheck(input) {
|
|
1754
|
+
const screens = input.screens || [];
|
|
1755
|
+
const variables = input.variables || null;
|
|
1756
|
+
const tokens = input.tokens || null;
|
|
1757
|
+
const components = input.components || null;
|
|
1758
|
+
const componentsLibrary = input.componentsLibrary || null;
|
|
1759
|
+
const stylesText = input.stylesText || null;
|
|
1760
|
+
const catFile = input.componentsFile || "components.local.json";
|
|
1761
|
+
const LIST_LIBRARIES = "`dtwin list libraries --client <the screen's file>` (MCP: `figma_list_libraries`)";
|
|
1762
|
+
const EXPORT_LIBRARY = input.designSystemIsLibrary ? 'check that the library export you passed is the one the screen consumes (a DUPLICATED library file re-keys everything \u2014 re-export the original with `dtwin pull --as-library "<name>"`, CLI only)' : 'export it with `dtwin pull --as-library "<name>"` (CLI only \u2014 the MCP server has no library export)';
|
|
1763
|
+
const findings = [];
|
|
1764
|
+
const notChecked = [];
|
|
1765
|
+
const push = (severity, code, message, extra) => {
|
|
1766
|
+
findings.push(Object.assign({ severity, code, message }, extra || {}));
|
|
1767
|
+
};
|
|
1768
|
+
const usage = screenUsage(screens);
|
|
1769
|
+
const cc = { input, screens, variables, tokens, components, componentsLibrary, stylesText, catFile, LIST_LIBRARIES, EXPORT_LIBRARY, findings, notChecked, push, ...usage };
|
|
1770
|
+
const prov = collectionProvenance(cc);
|
|
1771
|
+
const sv = screenVariables(cc);
|
|
1772
|
+
foreignNameMap(cc, prov, sv);
|
|
1773
|
+
nameCollisions(cc, sv);
|
|
1774
|
+
danglingTokens(cc, sv);
|
|
1775
|
+
const { coverage, rekey, proposals } = catalogCoverage(cc);
|
|
1776
|
+
fontStrays(cc);
|
|
1777
|
+
textStyleStrays(cc);
|
|
1778
|
+
sentinelValues(cc);
|
|
1779
|
+
singleModeExport(cc, prov.screenColls);
|
|
1780
|
+
mixedModeBindings(screens, variables, tokens, push);
|
|
1781
|
+
contrastPerMode(screens, variables, tokens, push, cc.resolvedModes);
|
|
1782
|
+
contrastRendered(screens, variables, tokens, push);
|
|
1783
|
+
findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.code.localeCompare(b.code));
|
|
1784
|
+
const ids = findingIds(findings);
|
|
1785
|
+
findings.forEach((f, i) => {
|
|
1786
|
+
const id = ids[i];
|
|
1787
|
+
if (id !== void 0) f.id = id;
|
|
1788
|
+
});
|
|
1789
|
+
const count = (s) => findings.filter((f) => f.severity === s).length;
|
|
1790
|
+
return {
|
|
1791
|
+
summary: { blockers: count("blocker"), warnings: count("warning"), info: count("info") },
|
|
1792
|
+
coverage,
|
|
1793
|
+
// The confirmation list (catalog-rekeyed). Every entry starts unconfirmed; map-bootstrap.ts
|
|
1794
|
+
// --from-proposals stubs only the ones a person set "confirmed": true on.
|
|
1795
|
+
// Unconfirmed first (shared chrome before screen-only), already-mapped last.
|
|
1796
|
+
componentProposals: proposals,
|
|
1797
|
+
componentResidual: rekey && coverage.rekey && coverage.rekey.rekeyed ? rekey.rows.filter((r) => !r.match).map((r) => ({ name: r.name, instances: r.instances, reasons: r.reasons })) : [],
|
|
1798
|
+
findings,
|
|
1799
|
+
notChecked,
|
|
1800
|
+
inputs: {
|
|
1801
|
+
screens: screens.map((s) => s.label),
|
|
1802
|
+
variables: !!variables,
|
|
1803
|
+
tokens: !!tokens,
|
|
1804
|
+
components: !!components,
|
|
1805
|
+
stylesText: !!stylesText
|
|
1806
|
+
}
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
function screenUsage(screens) {
|
|
1810
|
+
const usedTokenNames = /* @__PURE__ */ new Map();
|
|
1811
|
+
const instances = [];
|
|
1812
|
+
const fonts = /* @__PURE__ */ new Map();
|
|
1813
|
+
const textStyles = /* @__PURE__ */ new Map();
|
|
1814
|
+
const resolvedModes = /* @__PURE__ */ new Map();
|
|
1815
|
+
for (const s of screens) {
|
|
1816
|
+
const label = s.label || screenExportOf(s.doc)?.screen || "screen";
|
|
1817
|
+
for (const root of screenRoots(s.doc)) {
|
|
1818
|
+
if (root && root.resolvedModes) {
|
|
1819
|
+
for (const [coll, mode] of Object.entries(root.resolvedModes)) {
|
|
1820
|
+
getOrInit(resolvedModes, coll, () => /* @__PURE__ */ new Set()).add(mode);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
walkVisible(root, (n) => {
|
|
1824
|
+
for (const [field, name] of Object.entries(n.tokens || {})) {
|
|
1825
|
+
if (typeof name !== "string") continue;
|
|
1826
|
+
getOrInit(usedTokenNames, name, () => []).push({ screen: label, nodeId: n.id, field });
|
|
1827
|
+
}
|
|
1828
|
+
for (const f of n.fills || []) {
|
|
1829
|
+
const t = f && f.tokens && f.tokens.color;
|
|
1830
|
+
if (typeof t === "string") {
|
|
1831
|
+
getOrInit(usedTokenNames, t, () => []).push({ screen: label, nodeId: n.id, field: "fills" });
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
if (n.font && n.font.family) fonts.set(n.font.family, (fonts.get(n.font.family) || 0) + 1);
|
|
1835
|
+
const ts = n.styles && n.styles.text;
|
|
1836
|
+
if (ts) textStyles.set(ts, (textStyles.get(ts) || 0) + 1);
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const visible = [];
|
|
1841
|
+
screens.forEach((s) => {
|
|
1842
|
+
const label = s.label || screenExportOf(s.doc)?.screen || "screen";
|
|
1843
|
+
for (const i of visibleInstances(s.doc, label)) {
|
|
1844
|
+
visible.push(i);
|
|
1845
|
+
instances.push({ screen: label, nodeId: i.nodeId, name: i.layer, ...ifDefined("key", i.key), ...ifDefined("setKey", i.setKey), setName: i.name, propNames: Object.keys(i.props || {}) });
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
return { usedTokenNames, instances, fonts, textStyles, resolvedModes, visible };
|
|
1849
|
+
}
|
|
1850
|
+
function collectionProvenance(cc) {
|
|
1851
|
+
const { input, tokens, variables, findings, notChecked, push, LIST_LIBRARIES, EXPORT_LIBRARY } = cc;
|
|
1852
|
+
const dsCollByKey = /* @__PURE__ */ new Map();
|
|
1853
|
+
const dsCollByName = /* @__PURE__ */ new Map();
|
|
1854
|
+
for (const c of tokens && tokens.collections || []) {
|
|
1855
|
+
if (c.key) dsCollByKey.set(c.key, c);
|
|
1856
|
+
if (c.name) dsCollByName.set(alnumKey(c.name), c);
|
|
1857
|
+
}
|
|
1858
|
+
const screenColls = variables && variables.collections || [];
|
|
1859
|
+
let foreignPatch = null;
|
|
1860
|
+
if (!tokens) {
|
|
1861
|
+
notChecked.push(
|
|
1862
|
+
"collection provenance \u2014 no design-system tokens.json was given, so nothing could verify that the screen's variables come from the design system you exported. Run `dtwin pull --design-system` and pass it."
|
|
1863
|
+
);
|
|
1864
|
+
} else if (!screenColls.length) {
|
|
1865
|
+
notChecked.push(
|
|
1866
|
+
input.variablesPath ? `collection provenance \u2014 ${input.variablesPath} was checked but carries no collections for this screen, so its own token library is unknown.` : `collection provenance \u2014 no variables.json was found (looked in design/export/variables.json), so the screen's own token library is unknown.`
|
|
1867
|
+
);
|
|
1868
|
+
} else {
|
|
1869
|
+
const foreign = [];
|
|
1870
|
+
for (const c of screenColls) {
|
|
1871
|
+
if (c.key && dsCollByKey.has(c.key)) continue;
|
|
1872
|
+
const twin = dsCollByName.get(alnumKey(c.name));
|
|
1873
|
+
foreign.push({ name: c.name, ...ifDefined("key", c.key), ...ifDefined("twinKey", twin && twin.key), ...ifDefined("twinName", twin && twin.name) });
|
|
1874
|
+
}
|
|
1875
|
+
if (foreign.length) {
|
|
1876
|
+
const sameNameDifferentKey = foreign.filter((f) => f.twinKey);
|
|
1877
|
+
const all = foreign.length === screenColls.length;
|
|
1878
|
+
foreignPatch = {
|
|
1879
|
+
foreign,
|
|
1880
|
+
head: `${foreign.length} of ${screenColls.length} variable collection(s) the screen binds are NOT in the design-system export \u2014 the screen consumes a DIFFERENT library than the one you pulled. ` + (sameNameDifferentKey.length ? `${sameNameDifferentKey.length} of them carry a design-system collection's name under a different key (${sameNameDifferentKey.slice(0, 3).map((f) => `'${f.name}'`).join(", ")}), which is the signature of a DUPLICATED Figma file: duplicating a library re-keys everything while leaving names and values identical. ` : "") + `Names and values may still line up (check the collisions below), but nothing here is the same variable. `,
|
|
1881
|
+
tail: `Only to find the owning file: run ${LIST_LIBRARIES} \u2014 variable collections are the ONE thing Figma attributes to a library by name \u2014 then open that file and ${EXPORT_LIBRARY}.`,
|
|
1882
|
+
at: findings.length
|
|
1883
|
+
};
|
|
1884
|
+
push(
|
|
1885
|
+
"warning",
|
|
1886
|
+
"foreign-token-library",
|
|
1887
|
+
foreignPatch.head + foreignPatch.tail,
|
|
1888
|
+
{ collections: foreign, confirm: `${all ? "None" : `Only ${screenColls.length - foreign.length} of ${screenColls.length}`} of the screen's variable collections are in the design system you passed \u2014 is it the library this screen uses? Until confirmed, values come from the screen's own .vars.json.` }
|
|
1889
|
+
);
|
|
1890
|
+
} else {
|
|
1891
|
+
push("info", "token-library-matches", `all ${screenColls.length} variable collection(s) the screen binds resolve to the design-system export by key.`, {});
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return { dsCollByKey, screenColls, foreignPatch };
|
|
1895
|
+
}
|
|
1896
|
+
function screenVariables(cc) {
|
|
1897
|
+
const { input, screens, tokens, variables } = cc;
|
|
1898
|
+
const dsVarByName = /* @__PURE__ */ new Map();
|
|
1899
|
+
for (const v of tokens && tokens.variables || []) if (v.name) dsVarByName.set(v.name, v);
|
|
1900
|
+
const sliceSources = input.sliceSources || null;
|
|
1901
|
+
const labels = screens.map((s) => s.label || screenExportOf(s.doc)?.screen || "screen");
|
|
1902
|
+
const own = screens.map((s) => s && s.vars && Array.isArray(s.vars.variables) ? s.vars.variables : null);
|
|
1903
|
+
let varScope = "own";
|
|
1904
|
+
let screenVars = [];
|
|
1905
|
+
const none = [];
|
|
1906
|
+
if (own.length && own.every((o) => !!o)) {
|
|
1907
|
+
screenVars = none.concat(...own);
|
|
1908
|
+
} else if (variables && sliceSources && sliceSources.size) {
|
|
1909
|
+
varScope = "union-by-source";
|
|
1910
|
+
screenVars = (variables && variables.variables || []).filter((v) => {
|
|
1911
|
+
const from = v.key ? sliceSources.get(v.key) : void 0;
|
|
1912
|
+
return !from || from.some((sc) => labels.includes(sc));
|
|
1913
|
+
});
|
|
1914
|
+
} else {
|
|
1915
|
+
varScope = "union";
|
|
1916
|
+
screenVars = variables && variables.variables || [];
|
|
1917
|
+
}
|
|
1918
|
+
const screenVarsByName = /* @__PURE__ */ new Map();
|
|
1919
|
+
for (const v of screenVars) {
|
|
1920
|
+
if (!v || !v.name) continue;
|
|
1921
|
+
const list = screenVarsByName.get(v.name);
|
|
1922
|
+
if (!list) screenVarsByName.set(v.name, [v]);
|
|
1923
|
+
else if (!list.some((x) => x.key && x.key === v.key || !x.key && !v.key && x.collection === v.collection)) list.push(v);
|
|
1924
|
+
}
|
|
1925
|
+
const screenVarByName = new Map([...screenVarsByName].map(([n, l]) => [n, l[0]]));
|
|
1926
|
+
const shortKey = (v) => v && typeof v.key === "string" && v.key ? v.key.slice(0, 8) + "\u2026" : null;
|
|
1927
|
+
const fromWhere = (v) => {
|
|
1928
|
+
const sc = v && v.key && sliceSources && sliceSources.get(v.key);
|
|
1929
|
+
return sc && sc.length ? ` (from ${sc.join(", ")})` : "";
|
|
1930
|
+
};
|
|
1931
|
+
return { dsVarByName, sliceSources, varScope, screenVars, screenVarsByName, screenVarByName, shortKey, fromWhere };
|
|
1932
|
+
}
|
|
1933
|
+
function flatten(v) {
|
|
1934
|
+
const out = {};
|
|
1935
|
+
for (const [mode, val] of Object.entries(v && v.values || {})) {
|
|
1936
|
+
out[mode] = val && typeof val === "object" ? "aliasOf" in val ? "-> " + val.aliasOf : JSON.stringify(val) : String(val);
|
|
1937
|
+
}
|
|
1938
|
+
return out;
|
|
1939
|
+
}
|
|
1940
|
+
function valueSet(v) {
|
|
1941
|
+
return new Set(Object.values(flatten(v)));
|
|
1942
|
+
}
|
|
1943
|
+
function sameResolution(a, b) {
|
|
1944
|
+
const fa = flatten(a), fb = flatten(b);
|
|
1945
|
+
const shared = Object.keys(fa).filter((m) => m in fb);
|
|
1946
|
+
if (shared.length) return shared.every((m) => fa[m] === fb[m]);
|
|
1947
|
+
const sa = valueSet(a), sb = valueSet(b);
|
|
1948
|
+
if (!sa.size || !sb.size) return null;
|
|
1949
|
+
for (const x of sa) if (sb.has(x)) return null;
|
|
1950
|
+
return false;
|
|
1951
|
+
}
|
|
1952
|
+
function foreignNameMap(cc, { dsCollByKey, screenColls, foreignPatch }, { screenVars, dsVarByName }) {
|
|
1953
|
+
const { findings } = cc;
|
|
1954
|
+
if (foreignPatch) {
|
|
1955
|
+
const fp = foreignPatch;
|
|
1956
|
+
const foreignNames = new Set(fp.foreign.map((f2) => f2.name));
|
|
1957
|
+
const foreignKeys = new Set(fp.foreign.map((f2) => f2.key).filter((k) => !!k));
|
|
1958
|
+
const screenKeys = new Set(screenColls.map((c) => c.key).filter((k) => !!k));
|
|
1959
|
+
const sharedNames = new Set(screenColls.filter((c) => c.key && dsCollByKey.has(c.key) && foreignNames.has(c.name)).map((c) => c.name));
|
|
1960
|
+
const nameMap = { agree: 0, differ: 0, undecidable: 0, absent: 0 };
|
|
1961
|
+
let ambiguous = 0;
|
|
1962
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1963
|
+
for (const v of screenVars) {
|
|
1964
|
+
if (!v || !v.name || !v.collection) continue;
|
|
1965
|
+
const byKey = !!v.collectionKey && screenKeys.has(v.collectionKey);
|
|
1966
|
+
if (byKey ? !foreignKeys.has(v.collectionKey ?? "") : !foreignNames.has(v.collection)) continue;
|
|
1967
|
+
const id = v.key || `${v.collection}\0${v.name}`;
|
|
1968
|
+
if (seen.has(id)) continue;
|
|
1969
|
+
seen.add(id);
|
|
1970
|
+
if (!byKey && sharedNames.has(v.collection)) ambiguous++;
|
|
1971
|
+
const dv = dsVarByName.get(v.name);
|
|
1972
|
+
if (!dv) {
|
|
1973
|
+
nameMap.absent++;
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
const r = sameResolution(v, dv);
|
|
1977
|
+
if (r === true) nameMap.agree++;
|
|
1978
|
+
else if (r === false) nameMap.differ++;
|
|
1979
|
+
else nameMap.undecidable++;
|
|
1980
|
+
}
|
|
1981
|
+
const n = seen.size;
|
|
1982
|
+
let offline = "";
|
|
1983
|
+
if (n) {
|
|
1984
|
+
offline = `Offline check (no Figma needed): of the ${n} variables in those collections, ${nameMap.agree} have a design-system variable of the same name resolving the same in every shared mode, ${nameMap.differ} resolve differently (listed as token-name-collision), ${nameMap.undecidable} cannot be compared (no shared mode), ${nameMap.absent} have no same-name variable. ` + (nameMap.differ === 0 && nameMap.agree > 0 ? `Mapping by NAME is safe for the ${nameMap.agree}: the screen's own .vars.json is the ground truth for names and values. ` : "") + (nameMap.agree === 0 && nameMap.absent === n ? `None of them exists here by name \u2014 this is not the screen's library; pull the one it uses. ` : "") + (ambiguous ? `(${ambiguous} of them come from a collection whose name a design-system collection also has \u2014 this export predates collection keys on variables; re-pull to tell them apart.) ` : "");
|
|
1985
|
+
}
|
|
1986
|
+
const f = findings[fp.at];
|
|
1987
|
+
if (f) {
|
|
1988
|
+
f.message = fp.head + offline + fp.tail;
|
|
1989
|
+
f.nameMap = ambiguous ? { ...nameMap, ambiguous } : nameMap;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
function nameCollisions(cc, vars) {
|
|
1994
|
+
const { tokens, usedTokenNames, push } = cc;
|
|
1995
|
+
const { dsVarByName, varScope, screenVarsByName, screenVarByName, shortKey, fromWhere } = vars;
|
|
1996
|
+
if (tokens && screenVarByName.size) {
|
|
1997
|
+
const collisions = [], missing = [];
|
|
1998
|
+
for (const [name, list] of screenVarsByName) {
|
|
1999
|
+
const dv = dsVarByName.get(name);
|
|
2000
|
+
if (!dv) {
|
|
2001
|
+
let near = null;
|
|
2002
|
+
for (const dname of dsVarByName.keys()) if (alnumKey(dname) === alnumKey(name) && dname !== name) {
|
|
2003
|
+
near = dname;
|
|
2004
|
+
break;
|
|
2005
|
+
}
|
|
2006
|
+
if (usedTokenNames.has(name) || near) missing.push({ name, near });
|
|
2007
|
+
continue;
|
|
2008
|
+
}
|
|
2009
|
+
for (const sv of list) {
|
|
2010
|
+
if (sameResolution(sv, dv) === false) {
|
|
2011
|
+
collisions.push({ name, ...ifDefined("key", sv.key), twins: list, sv, screen: flatten(sv), designSystem: flatten(dv), usedAt: (usedTokenNames.get(name) || []).slice(0, 3) });
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
const dsByNorm = /* @__PURE__ */ new Map();
|
|
2016
|
+
for (const [dname, dv] of dsVarByName) {
|
|
2017
|
+
const k = alnumKey(dname);
|
|
2018
|
+
getOrInit(dsByNorm, k, () => []).push({ name: dname, v: dv });
|
|
2019
|
+
}
|
|
2020
|
+
for (const [name, list] of screenVarsByName) {
|
|
2021
|
+
if (dsVarByName.has(name)) continue;
|
|
2022
|
+
for (const cand of dsByNorm.get(alnumKey(name)) || []) {
|
|
2023
|
+
for (const sv of list) {
|
|
2024
|
+
if (sameResolution(sv, cand.v) === false) {
|
|
2025
|
+
collisions.push({ name, ...ifDefined("key", sv.key), twins: list, sv, alsoKnownAs: cand.name, screen: flatten(sv), designSystem: flatten(cand.v), usedAt: (usedTokenNames.get(name) || []).slice(0, 3) });
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
const scopeNote = varScope === "own" ? "" : varScope === "union-by-source" ? " (read from the merged variables.json, restricted to the variables its slice records for this screen \u2014 pass the screen's .vars.json to be exact)" : " (read from the MERGED variables.json: this screen's own .vars.json was not available, so this may be another screen's variable \u2014 check its key)";
|
|
2031
|
+
for (const c of collisions) {
|
|
2032
|
+
const twinNote = c.twins.length > 1 ? ` This screen's own variables include ${c.twins.length} DIFFERENT variables called '${c.name}': ` + c.twins.map((t) => `${shortKey(t) ? "key " + shortKey(t) : "'" + (t.collection || "") + "'"} = ${JSON.stringify(flatten(t))}${fromWhere(t)}`).join(" vs ") + ` \u2014 only the one(s) listed as colliding differ from the design system.` : "";
|
|
2033
|
+
const used = c.usedAt.length > 0;
|
|
2034
|
+
const message = `The screen's '${c.name}'${shortKey(c.sv) ? ` (key ${shortKey(c.sv)})` : ""} and the design system's '${c.alsoKnownAs || c.name}' ${c.alsoKnownAs ? "differ only by case or punctuation" : "share a name"} but resolve DIFFERENTLY: screen ${JSON.stringify(c.screen)} vs design system ${JSON.stringify(c.designSystem)}` + (Object.keys(c.screen).some((m) => m in c.designSystem) ? ". " : " \u2014 no mode name is shared and the two value sets are disjoint. ") + `Slugging the name onto the existing token would silently apply the wrong value \u2014 namespace the screen's copy, or confirm which library is authoritative.` + twinNote + scopeNote;
|
|
2035
|
+
const extra = { token: c.name, ...ifDefined("key", c.key), ...ifDefined("alsoKnownAs", c.alsoKnownAs), screenValue: c.screen, designSystemValue: c.designSystem, usedAt: c.usedAt, scope: varScope };
|
|
2036
|
+
if (used) push("blocker", "token-name-collision", message, extra);
|
|
2037
|
+
else push("warning", "token-name-collision", message, { ...extra, confirm: `'${c.name}' has different values here and in the design system, but no visible layer on this screen binds it \u2014 which one is authoritative if it is used later?` });
|
|
2038
|
+
}
|
|
2039
|
+
collisionsElsewhere(cc, vars);
|
|
2040
|
+
if (missing.length) {
|
|
2041
|
+
push(
|
|
2042
|
+
"warning",
|
|
2043
|
+
"token-absent-from-design-system",
|
|
2044
|
+
`${missing.length} token name(s) the screen binds do not exist in the design-system export` + (missing.some((m) => m.near) ? ` (${missing.filter((m) => m.near).length} differ from a design-system name only by case or punctuation \u2014 e.g. ` + missing.filter((m) => m.near).slice(0, 2).map((m) => `'${m.name}' vs '${m.near}'`).join(", ") + ")" : "") + `: ${missing.slice(0, 8).map((m) => "'" + m.name + "'").join(", ")}${missing.length > 8 ? ", \u2026" : ""}. Build these as literals with a recorded decision \u2014 do not invent a design-system token for them.`,
|
|
2045
|
+
{ tokens: missing }
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
function collisionsElsewhere(cc, sv) {
|
|
2051
|
+
const { variables, push } = cc;
|
|
2052
|
+
const { sliceSources, varScope, screenVars, shortKey, fromWhere } = sv;
|
|
2053
|
+
if (variables && varScope !== "union") {
|
|
2054
|
+
const mine = new Set(screenVars.map((v) => v.key).filter(Boolean));
|
|
2055
|
+
const unionByName = /* @__PURE__ */ new Map();
|
|
2056
|
+
for (const v of variables && variables.variables || []) {
|
|
2057
|
+
if (!v || !v.name || !v.key) continue;
|
|
2058
|
+
getOrInit(unionByName, v.name, () => /* @__PURE__ */ new Map()).set(v.key, v);
|
|
2059
|
+
}
|
|
2060
|
+
for (const [name, byKey] of unionByName) {
|
|
2061
|
+
if (byKey.size < 2) continue;
|
|
2062
|
+
const all = [...byKey.values()];
|
|
2063
|
+
const others = all.filter((v) => !mine.has(v.key));
|
|
2064
|
+
if (!others.length) continue;
|
|
2065
|
+
if (new Set(all.flatMap((v) => Object.values(flatten(v)))).size <= 1) continue;
|
|
2066
|
+
if (all.every((v) => JSON.stringify(flatten(v)) === JSON.stringify(flatten(all[0])))) continue;
|
|
2067
|
+
const ours = all.filter((v) => mine.has(v.key));
|
|
2068
|
+
push(
|
|
2069
|
+
"info",
|
|
2070
|
+
"token-name-collision-elsewhere",
|
|
2071
|
+
`'${name}' is ${all.length} different variables in the merged variables.json \u2014 ` + all.map((v) => `key ${shortKey(v)} = ${JSON.stringify(flatten(v))}${fromWhere(v)}`).join(" vs ") + ". " + (ours.length ? `THIS screen's own variables carry only key ${ours.map(shortKey).join(", ")}, so the ambiguity belongs to ${[...new Set(others.flatMap((v) => sliceSources && v.key && sliceSources.get(v.key) || []))].join(", ") || "another screen"} \u2014 do not change this screen's value to match it.` : `THIS screen carries none of them.`) + ` Generate this screen's theme from its own .vars.json (or design-system/tokens.json), not from the union.`,
|
|
2072
|
+
{ token: name, keys: all.map((v) => v.key), mine: ours.map((v) => v.key) }
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
function danglingTokens(cc, { dsVarByName, varScope, screenVarByName }) {
|
|
2078
|
+
const { variables, usedTokenNames, push } = cc;
|
|
2079
|
+
if (variables || varScope === "own") {
|
|
2080
|
+
const dangling = [];
|
|
2081
|
+
for (const name of usedTokenNames.keys()) {
|
|
2082
|
+
if (screenVarByName.has(name) || dsVarByName.has(name)) continue;
|
|
2083
|
+
dangling.push(name);
|
|
2084
|
+
}
|
|
2085
|
+
if (dangling.length) {
|
|
2086
|
+
push(
|
|
2087
|
+
"warning",
|
|
2088
|
+
"unresolvable-token",
|
|
2089
|
+
`${dangling.length} token name(s) are bound by a node but defined in NEITHER design/export/variables.json nor the design-system export: ${dangling.slice(0, 8).map((n) => "'" + n + "'").join(", ")}${dangling.length > 8 ? ", \u2026" : ""}. Re-pull the screen (variables.json now merges, so nothing is lost) or treat the node's raw value as authoritative.`,
|
|
2090
|
+
{ tokens: dangling }
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
function catalogCoverage(cc) {
|
|
2096
|
+
const { input, components, componentsLibrary, catFile, EXPORT_LIBRARY, visible, instances, notChecked, push } = cc;
|
|
2097
|
+
let rekey = null;
|
|
2098
|
+
let proposals = [];
|
|
2099
|
+
const coverage = { instances: instances.length, distinct: 0, matchedByKey: 0, matchedByLocalKey: 0, matchedByName: 0, ambiguousName: 0, unmatched: 0, pct: null, localPct: null, entries: [] };
|
|
2100
|
+
const localComps = components && components.components || [];
|
|
2101
|
+
const localKeys = new Set(localComps.map((c) => c.key).filter((k) => !!k));
|
|
2102
|
+
const catalog = localComps.concat(componentsLibrary && componentsLibrary.components || []);
|
|
2103
|
+
if (!catalog.length) {
|
|
2104
|
+
notChecked.push(`component coverage \u2014 no ${catFile} was given, so every instance counts as new by default.`);
|
|
2105
|
+
} else if (!instances.length) {
|
|
2106
|
+
notChecked.push("component coverage \u2014 the screen export contains no INSTANCE nodes to compare.");
|
|
2107
|
+
} else {
|
|
2108
|
+
rekey = coverageEntries(cc, coverage, localComps, localKeys, catalog);
|
|
2109
|
+
coverage.pct = Math.round(coverage.matchedByKey / coverage.distinct * 100);
|
|
2110
|
+
coverage.localPct = Math.round(coverage.matchedByLocalKey / coverage.distinct * 100);
|
|
2111
|
+
const rekeyedBy = rekey && coverage.localPct <= WRONG_CATALOG_PCT && isRekeyed(rekey) ? rekey : null;
|
|
2112
|
+
const rekeyed = !!rekeyedBy;
|
|
2113
|
+
coverage.rekey = rekey ? Object.assign({ rekeyed }, rekey.summary) : null;
|
|
2114
|
+
const proposedNames = new Set(rekeyedBy ? rekeyedBy.proposals.map((r) => r.name) : []);
|
|
2115
|
+
const buckets = { localKey: 0, libraryKey: 0, proposed: 0, nameOnly: 0, ambiguous: 0, newWork: 0 };
|
|
2116
|
+
for (const e of coverage.entries) {
|
|
2117
|
+
const b = e.matchedBy === "key" ? e.scope === "local" ? "localKey" : "libraryKey" : proposedNames.has(e.setName) ? "proposed" : e.matchedBy === "name" ? "nameOnly" : e.ambiguous ? "ambiguous" : "newWork";
|
|
2118
|
+
e.bucket = b;
|
|
2119
|
+
buckets[b]++;
|
|
2120
|
+
}
|
|
2121
|
+
coverage.buckets = buckets;
|
|
2122
|
+
const hiddenOnly = hiddenOnlyComponents(cc);
|
|
2123
|
+
coverage.hiddenOnly = hiddenOnly.size;
|
|
2124
|
+
if (rekeyedBy) {
|
|
2125
|
+
const s = rekeyedBy.summary;
|
|
2126
|
+
proposals = labelProposals(rekeyedBy, visible, input.map || null, input.siblings || null);
|
|
2127
|
+
const props = proposals;
|
|
2128
|
+
const mapped = props.filter((p) => p.alreadyMapped).length, toConfirm = props.length - mapped;
|
|
2129
|
+
const shared = props.filter((p) => !p.alreadyMapped && (p.sharedWith || 0) > 0).length;
|
|
2130
|
+
const residual = rekeyedBy.rows.filter((r) => !r.match);
|
|
2131
|
+
push(
|
|
2132
|
+
"warning",
|
|
2133
|
+
"catalog-rekeyed",
|
|
2134
|
+
`0 of ${s.names} component(s) on this screen resolve to ${catFile} by key, but ${s.proposed} of the ${s.withCandidates} whose NAME is in the catalog also match it by prop signature (variant axes + values, prop names + types)${s.remote ? `, and ${s.remote} of the ${s.instances} visible instance(s) say remote:true` : ""}. That is not a foreign library \u2014 it is the SAME components under new keys: one or both Figma files are duplicates (duplicating a file re-mints every component key), or the library was re-published. Proposed matches (confirm each before reuse \u2014 nothing is auto-accepted): ` + props.slice(0, 12).map((r) => `'${r.name}' \u2192 ${r.catalog ? r.catalog.id : "?"}${r.evidence === "name+no-props" ? " (no props to compare \u2014 weaker)" : ""}${r.tie === "duplicate-definitions" ? " (duplicate definitions, harmless tie)" : ""}${r.alreadyMapped ? " (already mapped)" : ""}`).join(", ") + (props.length > 12 ? `, \u2026 (${props.length} in all \u2014 see componentProposals)` : "") + `. ` + // Labels only — what is already in the map, and what other exported screens share
|
|
2135
|
+
(mapped ? toConfirm ? `${props.length} proposals, ${mapped} already mapped in the component map \u2014 confirm only the other ${toConfirm}. ` : `All ${props.length} proposals are already mapped in the component map \u2014 nothing is left to confirm. ` : "") + (shared ? `${shared} of the proposals to confirm are on other exported screens too (shared chrome) \u2014 confirm those once. ` : "") + `${residual.length} name(s) are not in ${catFile}` + (buckets.libraryKey ? ` \u2014 of the table's rows, ${buckets.libraryKey} are third-party components.library.json key matches and ${buckets.newWork} new work` : ` and stay new work`) + (residual.length ? ` (${residual.slice(0, 5).map((r) => `'${r.name}'`).join(", ")}${residual.length > 5 ? ", \u2026" : ""})` : "") + `. To use them: show the user the list, set "confirmed": true on each accepted entry of componentProposals in this report's JSON, then run the map-bootstrap script (\`<${catFile}> --out design/codeconnect.local.json --from-proposals <this report>.json\`) \u2014 it stubs ONLY the confirmed ones, keyed by the screen's own instance key.`,
|
|
2136
|
+
{ rekey: s, proposals: props.length, confirm: `${toConfirm} component(s) match the catalog by name and prop signature but not by key (a duplicated or re-published file)${mapped ? ` \u2014 ${mapped} more are already mapped` : ""} \u2014 confirm the proposed matches before reusing them as mappings.` }
|
|
2137
|
+
);
|
|
2138
|
+
} else if (coverage.localPct <= WRONG_CATALOG_PCT) {
|
|
2139
|
+
push(
|
|
2140
|
+
"warning",
|
|
2141
|
+
"catalog-covers-nothing",
|
|
2142
|
+
`${coverage.matchedByLocalKey} of ${coverage.distinct} component(s) used on this screen (${coverage.localPct}%) are in ${catFile} by key \u2014 the catalog you exported is not the library this screen is built from. ` + (coverage.matchedByKey > coverage.matchedByLocalKey ? `${coverage.matchedByKey - coverage.matchedByLocalKey} more match components.library.json, which only means both files consume the same third-party set. ` : "") + (coverage.matchedByName ? `${coverage.matchedByName} match BY NAME only and are marked unverified: a lead, not a mapping. ` : "") + (coverage.ambiguousName ? `${coverage.ambiguousName} more share a name with SEVERAL catalog entries ('Component 1'-class names) and are deliberately left unmatched. ` : "") + `A "318/318 mapped" count measures the catalog against itself and means nothing here. To find the owning library: open any instance in Figma and use right-click > "Go to main component" \u2014 it jumps to the file that defines it. Then connect that file and ${EXPORT_LIBRARY}. Until then every instance is correctly a \`verdict:"new"\` build, not a port of the catalog.` + (rekey && rekey.summary.withCandidates ? ` (Checked for the duplicated-file case too: only ${rekey.summary.proposedWithSignature} of the ${rekey.summary.withCandidates} name twin(s) also agree on prop signature \u2014 too few to call it the same library under new keys.)` : ""),
|
|
2143
|
+
{ coverage: { distinct: coverage.distinct, byLocalKey: coverage.matchedByLocalKey, byKey: coverage.matchedByKey, byName: coverage.matchedByName, ambiguousName: coverage.ambiguousName, localPct: coverage.localPct }, confirm: `Only ${coverage.localPct}% of this screen's components are in ${catFile} by key \u2014 is that the component library this screen is built from? Until confirmed, every instance is new work.` }
|
|
2144
|
+
);
|
|
2145
|
+
} else if (coverage.localPct < 100) {
|
|
2146
|
+
push(
|
|
2147
|
+
"warning",
|
|
2148
|
+
"partial-catalog-coverage",
|
|
2149
|
+
`${coverage.matchedByLocalKey} of ${coverage.distinct} component(s) on this screen (${coverage.localPct}%) resolve to ${catFile} by key` + (coverage.matchedByName ? `, ${coverage.matchedByName} more by name only (unverified)` : "") + `. The rest are new work: ${coverage.entries.filter((e) => !e.matchedBy).slice(0, 6).map((e) => "'" + e.setName + "'").join(", ")}.`,
|
|
2150
|
+
{ coverage: { distinct: coverage.distinct, byLocalKey: coverage.matchedByLocalKey, byKey: coverage.matchedByKey, byName: coverage.matchedByName, localPct: coverage.localPct } }
|
|
2151
|
+
);
|
|
2152
|
+
} else {
|
|
2153
|
+
push("info", "catalog-covers-screen", `all ${coverage.distinct} component(s) on this screen resolve to ${catFile} by key.`, {});
|
|
2154
|
+
}
|
|
2155
|
+
nameMatchFindings(cc, coverage, proposedNames);
|
|
2156
|
+
}
|
|
2157
|
+
return { coverage, rekey, proposals };
|
|
2158
|
+
}
|
|
2159
|
+
function coverageEntries(cc, coverage, localComps, localKeys, catalog) {
|
|
2160
|
+
const { components, componentsLibrary, visible, instances, catFile } = cc;
|
|
2161
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2162
|
+
for (const c of catalog) if (c.key) byKey.set(c.key, c);
|
|
2163
|
+
const rekey = localComps.length ? matchByNameAndSignature(visible, components, componentsLibrary) : null;
|
|
2164
|
+
const rowByName = new Map((rekey ? rekey.rows : []).map((r) => [r.name, r]));
|
|
2165
|
+
const distinct = /* @__PURE__ */ new Map();
|
|
2166
|
+
for (const i of instances) {
|
|
2167
|
+
const id = i.setKey || i.key || "name:" + alnumKey(i.setName);
|
|
2168
|
+
getOrInit(distinct, id, () => Object.assign({ count: 0 }, i)).count++;
|
|
2169
|
+
}
|
|
2170
|
+
coverage.distinct = distinct.size;
|
|
2171
|
+
for (const i of distinct.values()) {
|
|
2172
|
+
const byKeyHit = i.setKey && byKey.get(i.setKey) || i.key && byKey.get(i.key);
|
|
2173
|
+
if (byKeyHit) {
|
|
2174
|
+
coverage.matchedByKey++;
|
|
2175
|
+
const local = !!byKeyHit.key && localKeys.has(byKeyHit.key);
|
|
2176
|
+
if (local) coverage.matchedByLocalKey++;
|
|
2177
|
+
coverage.entries.push({ setName: i.setName, ...ifDefined("key", i.setKey || i.key), matchedBy: "key", scope: local ? "local" : "library", verified: true, catalogName: byKeyHit.name, instances: i.count });
|
|
2178
|
+
continue;
|
|
2179
|
+
}
|
|
2180
|
+
const row = rowByName.get(i.setName);
|
|
2181
|
+
const verdict = row ? nameVerdict(row) : null;
|
|
2182
|
+
const matched = row && verdict && verdict.status === "matched" ? row.match : null;
|
|
2183
|
+
if (row && matched) {
|
|
2184
|
+
coverage.matchedByName++;
|
|
2185
|
+
const cat = localComps.find((c) => matched.key ? c.key === matched.key : matched.id !== void 0 && c.id === matched.id);
|
|
2186
|
+
const props = Object.keys(cat && cat.props || {}).map((p) => alnumKey(String(p).split("#")[0]));
|
|
2187
|
+
const hit = i.propNames.filter((p) => props.includes(alnumKey(String(p).split("#")[0]))).length;
|
|
2188
|
+
coverage.entries.push({
|
|
2189
|
+
setName: i.setName,
|
|
2190
|
+
...ifDefined("key", i.setKey || i.key),
|
|
2191
|
+
matchedBy: "name",
|
|
2192
|
+
verified: false,
|
|
2193
|
+
catalogName: matched.name,
|
|
2194
|
+
...ifDefined("catalogKey", matched.key),
|
|
2195
|
+
...ifDefined("evidence", row.evidence),
|
|
2196
|
+
propOverlap: `${hit}/${i.propNames.length}`,
|
|
2197
|
+
instances: i.count
|
|
2198
|
+
});
|
|
2199
|
+
} else if (row && verdict && verdict.status === "ambiguous") {
|
|
2200
|
+
coverage.ambiguousName++;
|
|
2201
|
+
coverage.entries.push({ setName: i.setName, ...ifDefined("key", i.setKey || i.key), matchedBy: null, ambiguous: true, candidates: (row.candidates || []).length, reason: verdict.reason, instances: i.count });
|
|
2202
|
+
} else {
|
|
2203
|
+
coverage.unmatched++;
|
|
2204
|
+
coverage.entries.push({
|
|
2205
|
+
setName: i.setName,
|
|
2206
|
+
...ifDefined("key", i.setKey || i.key),
|
|
2207
|
+
matchedBy: null,
|
|
2208
|
+
verified: false,
|
|
2209
|
+
reason: verdict ? verdict.reason : `no ${catFile} to match names against (a components.library.json name alone is not a match)`,
|
|
2210
|
+
instances: i.count
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
return rekey;
|
|
2215
|
+
}
|
|
2216
|
+
function hiddenOnlyComponents(cc) {
|
|
2217
|
+
const { screens, instances } = cc;
|
|
2218
|
+
const visibleSets = new Set(instances.map((i) => i.setKey || i.key || "name:" + alnumKey(i.setName)));
|
|
2219
|
+
const hiddenOnly = /* @__PURE__ */ new Set();
|
|
2220
|
+
const everyInstance = (n) => {
|
|
2221
|
+
if (!n || typeof n !== "object") return;
|
|
2222
|
+
if (n.type === "INSTANCE" && n.mainComponent) {
|
|
2223
|
+
const mc = n.mainComponent, id = mc.setKey || mc.key || "name:" + alnumKey(mc.setName || mc.name);
|
|
2224
|
+
if (!visibleSets.has(id)) hiddenOnly.add(id);
|
|
2225
|
+
}
|
|
2226
|
+
for (const c of n.children || []) everyInstance(c);
|
|
2227
|
+
};
|
|
2228
|
+
for (const s of screens) for (const root of screenRoots(s.doc)) everyInstance(root);
|
|
2229
|
+
return hiddenOnly;
|
|
2230
|
+
}
|
|
2231
|
+
function nameMatchFindings(cc, coverage, proposedNames) {
|
|
2232
|
+
const { push } = cc;
|
|
2233
|
+
const named = coverage.entries.filter((e) => e.matchedBy === "name" && !proposedNames.has(e.setName));
|
|
2234
|
+
if (named.length) {
|
|
2235
|
+
push(
|
|
2236
|
+
"info",
|
|
2237
|
+
"name-matched-components",
|
|
2238
|
+
`${named.length} component(s) have no key in the catalog but DO have an exact name twin there whose prop signature agrees: ` + named.slice(0, 10).map((e) => `'${e.setName}' (${e.evidence === "name+no-props" ? "no props to compare" : `props ${e.propOverlap}`})`).join(", ") + (named.length > 10 ? `, \u2026` : "") + `. Mapped BY NAME, UNVERIFIED \u2014 confirm one with "Go to main component" before reusing any of their code; if that one instance points at the catalog's file, the rest almost certainly do too.`,
|
|
2239
|
+
{ components: named.map((e) => ({ setName: e.setName, ...ifDefined("catalogName", e.catalogName), ...ifDefined("catalogKey", e.catalogKey), ...ifDefined("propOverlap", e.propOverlap), verified: false })) }
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
const amb = coverage.entries.filter((e) => e.ambiguous && !proposedNames.has(e.setName));
|
|
2243
|
+
const firstAmb = amb[0];
|
|
2244
|
+
if (firstAmb) {
|
|
2245
|
+
push(
|
|
2246
|
+
"warning",
|
|
2247
|
+
"ambiguous-component-name",
|
|
2248
|
+
`${amb.length} component(s) share their name with SEVERAL catalog entries, and no prop signature picks one of them, so they were left unmatched on purpose: ` + amb.slice(0, 8).map((e) => `'${e.setName}' (${e.candidates} candidates)`).join(", ") + (amb.length > 8 ? ", \u2026" : "") + `. Generic names like these are what the design system's own hygiene report flags as duplicated/unnamed \u2014 binding code to one of them by name would be a guess with a 1-in-${firstAmb.candidates} chance.`,
|
|
2249
|
+
{ components: amb.map((e) => ({ setName: e.setName, ...ifDefined("candidates", e.candidates) })) }
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
function fontStrays(cc) {
|
|
2254
|
+
const { stylesText, fonts, push } = cc;
|
|
2255
|
+
const sorted = [...fonts.entries()].sort((a, b) => b[1] - a[1]);
|
|
2256
|
+
const [main2, ...strays] = sorted;
|
|
2257
|
+
if (fonts.size > 1 && main2) {
|
|
2258
|
+
const [mainFamily, mainCount] = main2;
|
|
2259
|
+
const total = sorted.reduce((n, [, c]) => n + c, 0);
|
|
2260
|
+
push(
|
|
2261
|
+
"warning",
|
|
2262
|
+
"font-family-stray",
|
|
2263
|
+
`${total} text node(s) use ${sorted.length} different font families: ${sorted.map(([f, c]) => `${f} (${c})`).join(", ")}. '${mainFamily}' carries ${mainCount}; the rest are strays \u2014 usually a starter-kit style (Figma's Material 3 kit leaks \`material-theme/*\`) left on a layer. Ask the designer before shipping a second webfont for ${strays.reduce((n, [, c]) => n + c, 0)} node(s).`,
|
|
2264
|
+
{ families: sorted.map(([family, count]) => ({ family, count })) }
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
if (stylesText && fonts.size) {
|
|
2268
|
+
const dsFamilies = new Set(stylesText.styles.map((s) => s.font).filter((f) => !!f));
|
|
2269
|
+
const foreign = [...fonts.keys()].filter((f) => dsFamilies.size && !dsFamilies.has(f));
|
|
2270
|
+
if (foreign.length) {
|
|
2271
|
+
push(
|
|
2272
|
+
"warning",
|
|
2273
|
+
"font-not-in-design-system",
|
|
2274
|
+
`font ${foreign.map((f) => `'${f}'`).join(", ")} appears on the screen but NO design-system text style uses it (the design system's text styles are all ${[...dsFamilies].join(", ")}). Nothing in the export declares a font file or a webfont for either, so "is it installed and licensed" is a question only the designer can answer.`,
|
|
2275
|
+
{ fonts: foreign, designSystemFonts: [...dsFamilies] }
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
function textStyleStrays(cc) {
|
|
2281
|
+
const { stylesText, textStyles, push } = cc;
|
|
2282
|
+
if (stylesText && stylesText.styles && textStyles.size) {
|
|
2283
|
+
const dsByName = new Map((stylesText.styles || []).map((s) => [s.name, s]));
|
|
2284
|
+
const dsNorm = /* @__PURE__ */ new Map();
|
|
2285
|
+
for (const s of stylesText.styles || []) dsNorm.set(alnumKey(s.name), s.name);
|
|
2286
|
+
const absent = [], nearMiss = [];
|
|
2287
|
+
for (const name of textStyles.keys()) {
|
|
2288
|
+
if (dsByName.has(name)) continue;
|
|
2289
|
+
const near = dsNorm.get(alnumKey(name));
|
|
2290
|
+
if (near) nearMiss.push({ name, near });
|
|
2291
|
+
else absent.push(name);
|
|
2292
|
+
}
|
|
2293
|
+
if (nearMiss.length) {
|
|
2294
|
+
push(
|
|
2295
|
+
"warning",
|
|
2296
|
+
"text-style-near-miss",
|
|
2297
|
+
`${nearMiss.length} text style name(s) differ from a design-system style ONLY by case or punctuation: ` + nearMiss.map((n) => `'${n.name}' vs '${n.near}'`).join(", ") + `. That is the exact near-miss a name-based mapping binds wrongly and silently. Confirm they are the same style before reusing it.`,
|
|
2298
|
+
{ styles: nearMiss, confirm: `${nearMiss.map((n) => `'${n.name}' = '${n.near}'`).join(", ")} \u2014 the same style? Until confirmed, use the screen's own text values, not the design-system style.` }
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
if (absent.length) {
|
|
2302
|
+
push(
|
|
2303
|
+
"warning",
|
|
2304
|
+
"text-style-absent",
|
|
2305
|
+
`${absent.length} of ${textStyles.size} text style(s) used on the screen are not in the design-system export: ` + absent.slice(0, 8).map((n) => "'" + n + "'").join(", ") + (absent.length > 8 ? ", \u2026" : "") + ".",
|
|
2306
|
+
{ styles: absent }
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
function sentinelValues(cc) {
|
|
2312
|
+
const { tokens, variables, push } = cc;
|
|
2313
|
+
const absurd = [];
|
|
2314
|
+
const seenAbsurd = /* @__PURE__ */ new Set();
|
|
2315
|
+
const allVars = (tokens && tokens.variables || []).concat(variables && variables.variables || []);
|
|
2316
|
+
for (const v of allVars) {
|
|
2317
|
+
for (const [mode, val] of Object.entries(v && v.values || {})) {
|
|
2318
|
+
const n = typeof val === "number" ? val : val && typeof val === "object" && "value" in val && typeof val.value === "number" ? val.value : null;
|
|
2319
|
+
if (n == null || Math.abs(n) < ABSURD_NUMBER) continue;
|
|
2320
|
+
const id = JSON.stringify([v.collection ?? null, v.name, mode]);
|
|
2321
|
+
if (seenAbsurd.has(id)) continue;
|
|
2322
|
+
seenAbsurd.add(id);
|
|
2323
|
+
absurd.push({ name: v.name, collection: v.collection, mode, value: n });
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
const firstAbsurd = absurd[0];
|
|
2327
|
+
if (firstAbsurd) {
|
|
2328
|
+
push(
|
|
2329
|
+
"warning",
|
|
2330
|
+
"sentinel-token-value",
|
|
2331
|
+
`${absurd.length} token value(s) are sentinels, not measurements: ` + absurd.slice(0, 4).map((a) => `'${a.name}' = ${a.value} (${a.mode})`).join(", ") + (absurd.length > 4 ? ", \u2026" : "") + `. Figma's "fully rounded" corner exports as a literal 1e9. Emit these as the platform's own idiom (CSS 9999px or 50%, SwiftUI .infinity, Compose CircleShape) \u2014 never as \`${firstAbsurd.value}px\`.`,
|
|
2332
|
+
{ tokens: absurd }
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
function singleModeExport(cc, screenColls) {
|
|
2337
|
+
const { tokens, variables, resolvedModes, push } = cc;
|
|
2338
|
+
const multiModeColls = [];
|
|
2339
|
+
const seenColl = /* @__PURE__ */ new Set();
|
|
2340
|
+
const allColls = (tokens && tokens.collections || []).concat(screenColls);
|
|
2341
|
+
for (const c of allColls) {
|
|
2342
|
+
if (!Array.isArray(c.modes) || c.modes.length <= 1) continue;
|
|
2343
|
+
if (seenColl.has(c.name)) continue;
|
|
2344
|
+
seenColl.add(c.name);
|
|
2345
|
+
multiModeColls.push(c);
|
|
2346
|
+
}
|
|
2347
|
+
for (const c of multiModeColls) {
|
|
2348
|
+
const seen = resolvedModes.get(c.name);
|
|
2349
|
+
if (!seen || !seen.size) continue;
|
|
2350
|
+
const only = seen.size === 1 ? [...seen][0] : void 0;
|
|
2351
|
+
if (only !== void 0 && c.modes.length > 1) {
|
|
2352
|
+
const rest = c.modes.filter((m) => m !== only);
|
|
2353
|
+
const colour = [...tokens && tokens.variables || [], ...variables && variables.variables || []].some((v) => v.collection === c.name && v.type === "COLOR");
|
|
2354
|
+
const example = colour ? "a dark surface token that stays dark in Light" : `a size or spacing value that should differ in '${rest[0] ?? "another mode"}' but keeps its '${only}' value`;
|
|
2355
|
+
push(
|
|
2356
|
+
"warning",
|
|
2357
|
+
"single-mode-export",
|
|
2358
|
+
`every exported frame resolved collection '${c.name}' in mode '${only}', but it defines ${c.modes.length} modes (${c.modes.join(", ")}). The other ${rest.length} mode(s) are DERIVED from variable values, never seen rendered \u2014 so any element whose ${only}-mode token has no sensible counterpart (${example}) will be mechanically correct and visually broken. Export a frame in ${rest.map((m) => `'${m}'`).join(" / ")} too, or have the designer confirm the derivation before it ships.`,
|
|
2359
|
+
{ collection: c.name, exportedMode: only, modes: c.modes }
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
function labelProposals(rekey, visible, map, siblings) {
|
|
2365
|
+
const mapKeys = /* @__PURE__ */ new Set();
|
|
2366
|
+
for (const [k, e] of Object.entries(map && map.components || {})) {
|
|
2367
|
+
mapKeys.add(k);
|
|
2368
|
+
if (e && e.figma && e.figma.key) mapKeys.add(e.figma.key);
|
|
2369
|
+
}
|
|
2370
|
+
const keysOf = /* @__PURE__ */ new Map();
|
|
2371
|
+
for (const i of visible) {
|
|
2372
|
+
const ks = getOrInit(keysOf, i.name, () => /* @__PURE__ */ new Set());
|
|
2373
|
+
if (i.key) ks.add(i.key);
|
|
2374
|
+
if (i.setKey) ks.add(i.setKey);
|
|
2375
|
+
}
|
|
2376
|
+
const others = rekey.proposals.length && siblings ? siblings().map((s) => {
|
|
2377
|
+
const ks = /* @__PURE__ */ new Set();
|
|
2378
|
+
for (const i of visibleInstances(s.doc, s.label)) {
|
|
2379
|
+
if (i.key) ks.add(i.key);
|
|
2380
|
+
if (i.setKey) ks.add(i.setKey);
|
|
2381
|
+
}
|
|
2382
|
+
return ks;
|
|
2383
|
+
}) : null;
|
|
2384
|
+
const out = rekey.proposals.map((r) => {
|
|
2385
|
+
const keys = keysOf.get(r.name) || new Set(r.instanceKeys);
|
|
2386
|
+
const mapped = [...keys].some((k) => mapKeys.has(k));
|
|
2387
|
+
return {
|
|
2388
|
+
name: r.name,
|
|
2389
|
+
instances: r.instances,
|
|
2390
|
+
screens: r.screens,
|
|
2391
|
+
instanceKeys: r.instanceKeys,
|
|
2392
|
+
remote: r.remote,
|
|
2393
|
+
catalog: r.match,
|
|
2394
|
+
...ifDefined("evidence", r.evidence),
|
|
2395
|
+
tie: r.tie || null,
|
|
2396
|
+
alternatives: r.alternatives,
|
|
2397
|
+
reasons: r.reasons,
|
|
2398
|
+
confirmed: false,
|
|
2399
|
+
...mapped ? { alreadyMapped: true } : {},
|
|
2400
|
+
...ifDefined("sharedWith", others ? others.filter((o) => [...keys].some((k) => o.has(k))).length : void 0)
|
|
2401
|
+
};
|
|
2402
|
+
});
|
|
2403
|
+
const rank = (p) => p.alreadyMapped ? 2 : (p.sharedWith || 0) > 0 ? 0 : 1;
|
|
2404
|
+
return out.map((p, i) => ({ p, i })).sort((a, b) => rank(a.p) - rank(b.p) || (b.p.sharedWith || 0) - (a.p.sharedWith || 0) || a.i - b.i).map((x) => x.p);
|
|
2405
|
+
}
|
|
2406
|
+
var MIN_CONTRAST = 4.5;
|
|
2407
|
+
function contrastPerMode(screens, variables, tokens, push, resolvedModes) {
|
|
2408
|
+
if (![variables, tokens].some((d) => (d && d.variables || []).length)) return;
|
|
2409
|
+
const vars = variableResolver([variables, tokens]);
|
|
2410
|
+
const resolve = (name, mode) => vars.colour(vars.target(name), { mode, fallback: "none" });
|
|
2411
|
+
const modes = /* @__PURE__ */ new Set();
|
|
2412
|
+
const allColls = (tokens && tokens.collections || []).concat(variables && variables.collections || []);
|
|
2413
|
+
for (const c of allColls) {
|
|
2414
|
+
for (const m of c && c.modes || []) modes.add(m);
|
|
2415
|
+
}
|
|
2416
|
+
const rendered = /* @__PURE__ */ new Set();
|
|
2417
|
+
for (const set of (resolvedModes || /* @__PURE__ */ new Map()).values()) for (const m of set) rendered.add(m);
|
|
2418
|
+
for (const m of rendered) modes.delete(m);
|
|
2419
|
+
if (!modes.size) return;
|
|
2420
|
+
if (!rendered.size) return;
|
|
2421
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
2422
|
+
for (const s of screens) {
|
|
2423
|
+
for (const root of screenRoots(s.doc)) {
|
|
2424
|
+
walkVisible(root, (n, ctx) => {
|
|
2425
|
+
const bgToken = ctx.backdrop.token;
|
|
2426
|
+
if (n.type !== "TEXT" || !bgToken) return;
|
|
2427
|
+
const fg = n.tokens && (n.tokens.fills || n.tokens.textRangeFills) || (n.fills || []).map((f) => f && f.tokens && f.tokens.color).find(Boolean);
|
|
2428
|
+
if (!fg || typeof fg !== "string") return;
|
|
2429
|
+
const key = fg + "|" + bgToken;
|
|
2430
|
+
const pair = getOrInit(pairs, key, () => ({ fg, bg: bgToken, nodes: [], sample: n.name }));
|
|
2431
|
+
if (pair.nodes.length < 4) pair.nodes.push(n.id);
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
if (!pairs.size) return;
|
|
2436
|
+
const failures = [];
|
|
2437
|
+
for (const p of pairs.values()) {
|
|
2438
|
+
for (const mode of modes) {
|
|
2439
|
+
const fg = resolve(p.fg, mode);
|
|
2440
|
+
const bg = resolve(p.bg, mode);
|
|
2441
|
+
if (!fg || !bg) continue;
|
|
2442
|
+
const r = contrastRatio(fg.a < 1 ? compositeOver(fg, bg) : fg, bg);
|
|
2443
|
+
if (r >= MIN_CONTRAST) continue;
|
|
2444
|
+
failures.push({ mode, fg: p.fg, bg: p.bg, ratio: Number(r.toFixed(2)), nodes: p.nodes, sample: p.sample });
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
if (!failures.length) return;
|
|
2448
|
+
const byMode = /* @__PURE__ */ new Map();
|
|
2449
|
+
for (const f of failures) {
|
|
2450
|
+
getOrInit(byMode, f.mode, () => []).push(f);
|
|
2451
|
+
}
|
|
2452
|
+
for (const [mode, list] of byMode) {
|
|
2453
|
+
push(
|
|
2454
|
+
"warning",
|
|
2455
|
+
"derived-mode-contrast",
|
|
2456
|
+
`in mode '${mode}', ${list.length} text/background token pair(s) fall below WCAG AA (${MIN_CONTRAST}:1): ` + list.slice(0, 4).map((f) => `'${f.fg}' on '${f.bg}' = ${f.ratio}:1 (e.g. ${f.sample})`).join("; ") + (list.length > 4 ? ", \u2026" : "") + `. No frame was exported in '${mode}', so this mode is DERIVED from variable values and nobody has ever seen it rendered \u2014 the derivation is mechanically correct and visually broken. Export a '${mode}' frame, or get the designer to say which token each of these should use there. Do not invent an override and call it done.`,
|
|
2457
|
+
{ mode, pairs: list, confirm: `Mode '${mode}' was never drawn and ${list.length} derived text/background pair(s) in it fail contrast \u2014 ship '${mode}' as derived, or will the designer supply it?` }
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
function fillToken(n) {
|
|
2462
|
+
const own = n.tokens && n.tokens.fills || (n.fills || []).map((f) => f && f.tokens && f.tokens.color).find(Boolean);
|
|
2463
|
+
return own && typeof own === "string" ? own : null;
|
|
2464
|
+
}
|
|
2465
|
+
function textToken(n) {
|
|
2466
|
+
const t = n.tokens || {}, tt = n.textTokens || {};
|
|
2467
|
+
for (const x of [t.fills, t.textRangeFills, fillToken(n), tt.fills]) if (typeof x === "string" && x) return x;
|
|
2468
|
+
return null;
|
|
2469
|
+
}
|
|
2470
|
+
function colourBindings(n) {
|
|
2471
|
+
const out = [];
|
|
2472
|
+
const add = (x) => {
|
|
2473
|
+
for (const s of Array.isArray(x) ? x : [x]) if (typeof s === "string" && s && !out.includes(s)) out.push(s);
|
|
2474
|
+
};
|
|
2475
|
+
for (const [k, v] of Object.entries(n.tokens || {})) if (/fill|stroke/i.test(k)) add(v);
|
|
2476
|
+
for (const f of n.fills || []) add(f && f.tokens && f.tokens.color);
|
|
2477
|
+
add((n.textTokens || {}).fills);
|
|
2478
|
+
return out;
|
|
2479
|
+
}
|
|
2480
|
+
function mixedModeBindings(screens, variables, tokens, push) {
|
|
2481
|
+
for (const s of screens) {
|
|
2482
|
+
const label = s.label || screenExportOf(s.doc)?.screen || "screen";
|
|
2483
|
+
const defs = /* @__PURE__ */ new Map();
|
|
2484
|
+
for (const doc of [tokens, variables, s.vars || null]) for (const v of doc && doc.variables || []) if (v.name) defs.set(v.name, v);
|
|
2485
|
+
const colls = [...s.vars && s.vars.collections || [], ...variables && variables.collections || [], ...tokens && tokens.collections || []];
|
|
2486
|
+
const collOf = (v) => {
|
|
2487
|
+
const keyed = v.collectionKey ? colls.find((c) => c.key === v.collectionKey && Array.isArray(c.modes)) : void 0;
|
|
2488
|
+
if (keyed) return keyed;
|
|
2489
|
+
const named = colls.filter((c) => c.name === v.collection && Array.isArray(c.modes));
|
|
2490
|
+
const modes = Object.keys(v.values || {});
|
|
2491
|
+
return named.find((c) => modes.every((m) => c.modes.includes(m))) ?? named[0];
|
|
2492
|
+
};
|
|
2493
|
+
for (const root of screenRoots(s.doc)) {
|
|
2494
|
+
const rm = root.resolvedModes;
|
|
2495
|
+
if (!rm) continue;
|
|
2496
|
+
const use = /* @__PURE__ */ new Map();
|
|
2497
|
+
walkVisible(root, (n) => {
|
|
2498
|
+
for (const name of colourBindings(n)) {
|
|
2499
|
+
const v = defs.get(name);
|
|
2500
|
+
if (!v || v.type !== "COLOR") continue;
|
|
2501
|
+
const c = collOf(v);
|
|
2502
|
+
const mode = c ? rm[c.name] : void 0;
|
|
2503
|
+
if (!c || c.modes.length < 2 || mode === void 0) continue;
|
|
2504
|
+
const u = getOrInit(use, c.name, () => ({ coll: c, mode, count: 0, tokens: [], nodes: [], samples: [] }));
|
|
2505
|
+
u.count++;
|
|
2506
|
+
if (!u.tokens.includes(name) && u.tokens.length < 20) u.tokens.push(name);
|
|
2507
|
+
if (!u.nodes.includes(n.id) && u.nodes.length < 8) u.nodes.push(n.id);
|
|
2508
|
+
if (u.samples.length < 3) u.samples.push(`'${name}' on '${n.name}'`);
|
|
2509
|
+
}
|
|
2510
|
+
});
|
|
2511
|
+
const groups = [];
|
|
2512
|
+
for (const u of use.values()) {
|
|
2513
|
+
const joined = groups.filter((g) => g.some((o) => o.coll.modes.some((m) => u.coll.modes.includes(m))));
|
|
2514
|
+
const merged = [...joined.flat(), u];
|
|
2515
|
+
for (const g of joined) groups.splice(groups.indexOf(g), 1);
|
|
2516
|
+
groups.push(merged);
|
|
2517
|
+
}
|
|
2518
|
+
for (const group of groups) {
|
|
2519
|
+
const perMode = /* @__PURE__ */ new Map();
|
|
2520
|
+
for (const u of group) perMode.set(u.mode, (perMode.get(u.mode) || 0) + u.count);
|
|
2521
|
+
if (perMode.size < 2) continue;
|
|
2522
|
+
const [major] = [...perMode].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] ?? [];
|
|
2523
|
+
if (major === void 0) continue;
|
|
2524
|
+
const majority = group.filter((u) => u.mode === major);
|
|
2525
|
+
const minority = group.filter((u) => u.mode !== major && u.coll.modes.includes(major));
|
|
2526
|
+
const first = minority[0];
|
|
2527
|
+
if (!first) continue;
|
|
2528
|
+
const names = (list) => list.map((u) => `'${u.coll.name}'`).join(", ");
|
|
2529
|
+
const minModes = [...new Set(minority.map((u) => `'${u.mode}'`))].join("/");
|
|
2530
|
+
push(
|
|
2531
|
+
"warning",
|
|
2532
|
+
"mixed-mode-bindings",
|
|
2533
|
+
`'${label}' renders ${names(majority)} in '${major}' (\xD7${perMode.get(major)} bindings) but ` + minority.map((u) => `'${u.coll.name}' in '${u.mode}' (\xD7${u.count}: ${u.samples.join(", ")}${u.count > u.samples.length ? " \u2026" : ""})`).join(", ") + ` \u2014 ${minority.length > 1 ? "tokens from these collections show their" : `a token from '${first.coll.name}' shows its`} ${minModes} value in a '${major}' screen. Pin ${names(minority)} to '${major}' in Figma (the frame's variable mode), or alias these tokens under a role name in code \u2014 a theme built from one mode per collection gives them their '${major}' value, which nobody drew.`,
|
|
2534
|
+
{
|
|
2535
|
+
...ifDefined("nodeId", first.nodes[0]),
|
|
2536
|
+
// the first node binding a minority-mode token
|
|
2537
|
+
bindings: [...majority, ...minority].map((u) => ({ collection: u.coll.name, mode: u.mode, tokens: u.tokens, nodes: u.nodes })),
|
|
2538
|
+
confirm: `'${label}' binds ${minority.reduce((k, u) => k + u.count, 0)} colour token(s) from ${names(minority)} in ${minModes} while the rest of the screen is '${major}' \u2014 intended, or should they follow '${major}'? Until confirmed, build what was drawn (the ${minModes} values).`
|
|
2539
|
+
}
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
var LARGE_TEXT_PX = 24;
|
|
2546
|
+
var LARGE_BOLD_TEXT_PX = 18.66;
|
|
2547
|
+
var NON_TEXT_CONTRAST = 3;
|
|
2548
|
+
function isLargeText(n) {
|
|
2549
|
+
const f = n.font;
|
|
2550
|
+
const size = f && typeof f.size === "number" ? f.size : 0;
|
|
2551
|
+
const bold = !!f && (typeof f.weightValue === "number" ? f.weightValue >= 700 : /(^|\s)(extra\s*)?(bold|black|heavy)/i.test(f.weight || "") && !/semi|demi/i.test(f.weight || ""));
|
|
2552
|
+
return size >= LARGE_TEXT_PX || size >= LARGE_BOLD_TEXT_PX && bold;
|
|
2553
|
+
}
|
|
2554
|
+
var isDisabledVariant = (n) => n.type === "INSTANCE" && isDisabledLayer(n);
|
|
2555
|
+
function contrastRendered(screens, variables, tokens, push) {
|
|
2556
|
+
const rows = /* @__PURE__ */ new Map();
|
|
2557
|
+
for (const s of screens) {
|
|
2558
|
+
const label = s.label || screenExportOf(s.doc)?.screen || "screen";
|
|
2559
|
+
const docs = [s.vars || null, variables, tokens];
|
|
2560
|
+
if (!docs.some((d) => (d && d.variables || []).length)) continue;
|
|
2561
|
+
const vars = variableResolver(docs);
|
|
2562
|
+
for (const root of screenRoots(s.doc)) {
|
|
2563
|
+
const req = { byCollection: root.resolvedModes || {}, fallback: "default" };
|
|
2564
|
+
const resolve = (name) => vars.colour(vars.target(name), req);
|
|
2565
|
+
const modeLabel = (...names) => {
|
|
2566
|
+
const ms = [];
|
|
2567
|
+
for (const nm of names) {
|
|
2568
|
+
const v = vars.target(nm);
|
|
2569
|
+
const m = v && Object.keys(v.values || {}).length > 1 ? vars.modeOf(v, req) : void 0;
|
|
2570
|
+
if (m !== void 0 && !ms.includes(m)) ms.push(m);
|
|
2571
|
+
}
|
|
2572
|
+
return ms.join("/") || "single mode";
|
|
2573
|
+
};
|
|
2574
|
+
const check = (kind, fgTok, bgTok, required, nodeId) => {
|
|
2575
|
+
const fg = resolve(fgTok), bg = resolve(bgTok);
|
|
2576
|
+
if (!fg || !bg) return;
|
|
2577
|
+
if (kind === "non-text" && fg.a <= 0) return;
|
|
2578
|
+
const r = contrastRatio(fg.a < 1 ? compositeOver(fg, bg) : fg, bg);
|
|
2579
|
+
if (r >= required) return;
|
|
2580
|
+
const mode = modeLabel(fgTok, bgTok);
|
|
2581
|
+
const row = getOrInit(rows, [kind, fgTok, bgTok, mode].join("\0"), () => ({ kind, fg: fgTok, bg: bgTok, mode, ratio: Number(r.toFixed(2)), required, screens: [], nodes: [] }));
|
|
2582
|
+
if (!row.screens.includes(label)) row.screens.push(label);
|
|
2583
|
+
if (row.nodes.length < 4 && !row.nodes.includes(nodeId)) row.nodes.push(nodeId);
|
|
2584
|
+
};
|
|
2585
|
+
walkVisible(root, (n, ctx) => {
|
|
2586
|
+
if (isDisabledVariant(n) || ctx.ancestors.some((a) => isDisabledVariant(a.node))) return;
|
|
2587
|
+
const above = ctx.backdrop.token;
|
|
2588
|
+
if (n.type === "TEXT") {
|
|
2589
|
+
const fg = textToken(n);
|
|
2590
|
+
if (fg && above) check("text", fg, above, isLargeText(n) ? NON_TEXT_CONTRAST : MIN_CONTRAST, n.id);
|
|
2591
|
+
} else if (n.type !== "LINE") {
|
|
2592
|
+
const st = n.tokens && n.tokens.strokes;
|
|
2593
|
+
const w = n.strokes ? Math.max(n.strokes.weight || 0, ...Object.values(n.strokes.weights || {}).map((x) => x || 0)) : 0;
|
|
2594
|
+
const colours = n.strokes && n.strokes.colors || [];
|
|
2595
|
+
const painted = !colours.length || colours.some((c) => {
|
|
2596
|
+
const x = parseHex(c);
|
|
2597
|
+
return !x || x.a > 0;
|
|
2598
|
+
});
|
|
2599
|
+
const frames = [...ctx.ancestors, { node: n, hidden: false, backdrop: ctx.backdrop, beneath: ctx.beneath }];
|
|
2600
|
+
const outer = typeof st === "string" && st && w > 0 && painted ? outermostControl(frames.slice(-4).reverse().map((f) => f.node)) : -1;
|
|
2601
|
+
const inside2 = outer < 0 ? [] : frames.slice(frames.length - 1 - outer);
|
|
2602
|
+
const outside = inside2[0]?.backdrop.token ?? null;
|
|
2603
|
+
const bg = outside ? resolve(outside) : null;
|
|
2604
|
+
if (typeof st === "string" && outside && bg) {
|
|
2605
|
+
const inner = paintedOver(bg, inside2.flatMap((f, i) => [...i > 0 ? f.beneath : [], f.node]), resolve);
|
|
2606
|
+
const rescued = !!inner && contrastRatio(inner.color, bg) >= NON_TEXT_CONTRAST;
|
|
2607
|
+
if (!rescued && !(inner && inner.complex)) check("non-text", st, outside, NON_TEXT_CONTRAST, n.id);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
if (!rows.size) return;
|
|
2614
|
+
const list = [...rows.values()].sort((a, b) => b.screens.length - a.screens.length || a.ratio - b.ratio || a.fg.localeCompare(b.fg));
|
|
2615
|
+
push(
|
|
2616
|
+
"info",
|
|
2617
|
+
"token-pair-contrast",
|
|
2618
|
+
`${list.length} token pair(s) fail WCAG in the rendered mode(s) \u2014 ask the designer once per pair: ` + list.slice(0, 8).map((p) => `${p.kind === "text" ? "" : "stroke "}'${p.fg}' on '${p.bg}' (${p.mode}) ${p.ratio}:1 < ${p.required}:1 \u2014 ${p.screens.length} screen(s)`).join("; ") + (list.length > 8 ? `; \u2026 (${list.length} in all \u2014 see tokenPairs)` : "") + `. Token-level: the token-bound background (a gradient / image / raw or translucent fill in between is not judged), no compositing \u2014 the per-screen audit's low-contrast / non-text-contrast / contrast-manual measure what renders.`,
|
|
2619
|
+
{ tokenPairs: list }
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
function toMarkdown(res) {
|
|
2623
|
+
const L = [];
|
|
2624
|
+
L.push(`# Cross-file check \u2014 ${res.inputs.screens.join(", ") || "(no screens)"}`, "");
|
|
2625
|
+
L.push(
|
|
2626
|
+
`**${res.summary.blockers} blocker(s)**, ${res.summary.warnings} warning(s), ${res.summary.info} info. Every check here is a JOIN between the screen and the design system \u2014 none of it is visible from either file alone.`,
|
|
2627
|
+
""
|
|
2628
|
+
);
|
|
2629
|
+
const c = res.coverage;
|
|
2630
|
+
if (c && c.distinct) {
|
|
2631
|
+
L.push("## Component coverage of THIS screen", "");
|
|
2632
|
+
L.push(`| | count |`, `|---|---|`);
|
|
2633
|
+
L.push(`| visible instances on the screen | ${c.instances} |`);
|
|
2634
|
+
L.push(`| **distinct components** (each lands in exactly one row below) | **${c.distinct}** |`);
|
|
2635
|
+
const b = c.buckets || {};
|
|
2636
|
+
L.push(`| in **the component catalog** by key (verified) | ${b.localKey || 0} (${c.localPct}%) |`);
|
|
2637
|
+
L.push(`| in components.library.json by key (a shared third-party set) | ${b.libraryKey || 0} |`);
|
|
2638
|
+
if (c.rekey && c.rekey.rekeyed) L.push(`| same name **and** prop signature as a catalog entry \u2014 proposed, confirm below | ${b.proposed || 0} |`);
|
|
2639
|
+
L.push(`| exact name twin only (**unverified**) | ${b.nameOnly || 0} |`);
|
|
2640
|
+
L.push(`| name shared with several catalog entries \u2014 left unmatched | ${b.ambiguous || 0} |`);
|
|
2641
|
+
L.push(`| no match at all \u2014 new work | ${b.newWork || 0} |`);
|
|
2642
|
+
if (c.hiddenOnly) L.push(`| *(not counted: components used only on hidden layers \u2014 not built)* | ${c.hiddenOnly} |`);
|
|
2643
|
+
L.push("");
|
|
2644
|
+
}
|
|
2645
|
+
if (res.componentProposals && res.componentProposals.length) {
|
|
2646
|
+
L.push("## Proposed component matches \u2014 confirm before reuse", "");
|
|
2647
|
+
L.push(
|
|
2648
|
+
"*Matched by name + prop signature because the keys were re-minted (a duplicated file or a re-published library).",
|
|
2649
|
+
'Nothing here is accepted until a person sets `"confirmed": true` on the entry in the JSON report.*',
|
|
2650
|
+
""
|
|
2651
|
+
);
|
|
2652
|
+
const all = res.componentProposals;
|
|
2653
|
+
const groups = [
|
|
2654
|
+
["Shared with other exported screens \u2014 confirm once", all.filter((p) => !p.alreadyMapped && (p.sharedWith || 0) > 0)],
|
|
2655
|
+
[all.some((p) => p.sharedWith !== void 0) ? "This screen only" : "To confirm", all.filter((p) => !p.alreadyMapped && !((p.sharedWith || 0) > 0))],
|
|
2656
|
+
["Already in the component map \u2014 nothing to confirm", all.filter((p) => p.alreadyMapped)]
|
|
2657
|
+
];
|
|
2658
|
+
for (const [title, list] of groups) {
|
|
2659
|
+
if (!list.length) continue;
|
|
2660
|
+
L.push(`### ${title} (${list.length})`, "");
|
|
2661
|
+
L.push("| instance name | \xD7 | \u2192 catalog | id | page | evidence | shared with | why |", "|---|--:|---|---|---|---|--:|---|");
|
|
2662
|
+
for (const p of list) {
|
|
2663
|
+
const cat = p.catalog;
|
|
2664
|
+
if (!cat) continue;
|
|
2665
|
+
L.push(`| \`${p.name}\` | ${p.instances} | \`${cat.name}\` | ${cat.id} | ${cat.page || ""} | ${p.evidence}${p.tie ? ` (${p.tie})` : ""} | ${p.sharedWith === void 0 ? "?" : `${p.sharedWith} other screen(s)`} | ${p.reasons.join("; ")} |`);
|
|
2666
|
+
}
|
|
2667
|
+
L.push("");
|
|
2668
|
+
}
|
|
2669
|
+
if (res.componentResidual && res.componentResidual.length) {
|
|
2670
|
+
const twin = new Set((c && c.entries ? c.entries : []).filter((e) => e.bucket === "libraryKey").map((e) => e.setName));
|
|
2671
|
+
const fresh = res.componentResidual.filter((r) => !twin.has(r.name)), known = res.componentResidual.filter((r) => twin.has(r.name));
|
|
2672
|
+
L.push(`**Not in the component catalog (${res.componentResidual.length}):** ` + (known.length ? `${known.length} are in components.library.json by key \u2014 third-party, see the table: ${known.map((r) => `\`${r.name}\``).join(", ")}. ` : "") + `${fresh.length} are new work${fresh.length ? ": " + fresh.map((r) => `\`${r.name}\``).join(", ") : ""}.`, "");
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
for (const sev of ["blocker", "warning", "info"]) {
|
|
2676
|
+
const fs9 = res.findings.filter((f) => f.severity === sev);
|
|
2677
|
+
if (!fs9.length) continue;
|
|
2678
|
+
L.push(`## ${sev === "blocker" ? "Blockers" : sev === "warning" ? "Warnings" : "Info"} (${fs9.length})`, "");
|
|
2679
|
+
for (const f of fs9) L.push(`- \`${f.code}\` ${f.message}`);
|
|
2680
|
+
L.push("");
|
|
2681
|
+
}
|
|
2682
|
+
const pairs = res.findings.find((f) => f.code === "token-pair-contrast")?.tokenPairs || [];
|
|
2683
|
+
if (pairs.length) {
|
|
2684
|
+
L.push("## Token pairs below WCAG in the rendered modes \u2014 ask once per pair", "");
|
|
2685
|
+
L.push("| kind | foreground | background | mode | ratio | needs | screens | nodes |", "|---|---|---|---|--:|--:|---|---|");
|
|
2686
|
+
for (const p of pairs) {
|
|
2687
|
+
L.push(`| ${p.kind} | \`${p.fg}\` | \`${p.bg}\` | ${p.mode} | ${p.ratio}:1 | ${p.required}:1 | ${p.screens.length}: ${p.screens.slice(0, 4).join(", ")}${p.screens.length > 4 ? ", \u2026" : ""} | ${p.nodes.join(", ")} |`);
|
|
2688
|
+
}
|
|
2689
|
+
L.push("");
|
|
2690
|
+
}
|
|
2691
|
+
if (res.notChecked.length) {
|
|
2692
|
+
L.push("## Not checked", "", "*These are gaps in the INPUT, not clean results:*", "");
|
|
2693
|
+
for (const n of res.notChecked) L.push(`- ${n}`);
|
|
2694
|
+
L.push("");
|
|
2695
|
+
}
|
|
2696
|
+
return L.join("\n") + "\n";
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
// design-to-code/cross-check.ts
|
|
2700
|
+
function main(argv) {
|
|
2701
|
+
const USAGE = `usage: ${scriptCmd("cross-check")} <screen.json>... [--design-system design/export/design-system | design/export/libraries/<dir>] [--variables design/export/variables.json] [--map design/codeconnect.local.json] [--out design/audit/<screen>.cross] [--json] [--gate]`;
|
|
2702
|
+
const OPTIONS = {
|
|
2703
|
+
"design-system": { type: "string" },
|
|
2704
|
+
variables: { type: "string" },
|
|
2705
|
+
map: { type: "string" },
|
|
2706
|
+
out: { type: "string" },
|
|
2707
|
+
json: { type: "boolean" },
|
|
2708
|
+
gate: { type: "boolean" },
|
|
2709
|
+
help: { type: "boolean", short: "h" }
|
|
2710
|
+
};
|
|
2711
|
+
const { values: flags, positionals: files } = cliParse("cross-check", argv, OPTIONS, USAGE, 2, (args) => parseArgs2({ args, options: OPTIONS, allowPositionals: true }));
|
|
2712
|
+
if (flags.help) {
|
|
2713
|
+
console.log(USAGE);
|
|
2714
|
+
return 0;
|
|
2715
|
+
}
|
|
2716
|
+
if (!files.length) {
|
|
2717
|
+
console.error(USAGE);
|
|
2718
|
+
return 2;
|
|
2719
|
+
}
|
|
2720
|
+
const { "design-system": dsDir, variables: varsFile, map: mapFlag, out } = flags;
|
|
2721
|
+
const jsonOnly = !!flags.json, gate = !!flags.gate;
|
|
2722
|
+
const read = readScreenInputs(files, { variables: varsFile, designSystem: dsDir, map: mapFlag, mapFlag: true });
|
|
2723
|
+
if ("errors" in read) {
|
|
2724
|
+
for (const e of read.errors) console.error(e);
|
|
2725
|
+
return 2;
|
|
2726
|
+
}
|
|
2727
|
+
for (const n of read.inputs.notes) console.error(n);
|
|
2728
|
+
const res = crossCheck(crossCheckInputOf(read.inputs));
|
|
2729
|
+
if (jsonOnly) {
|
|
2730
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
2731
|
+
} else if (out) {
|
|
2732
|
+
fs8.mkdirSync(path7.dirname(out), { recursive: true });
|
|
2733
|
+
fs8.writeFileSync(out + ".json", JSON.stringify(res, null, 2) + "\n");
|
|
2734
|
+
fs8.writeFileSync(out + ".md", toMarkdown(res));
|
|
2735
|
+
console.error(`wrote ${out}.json and ${out}.md`);
|
|
2736
|
+
} else {
|
|
2737
|
+
process.stdout.write(toMarkdown(res));
|
|
2738
|
+
}
|
|
2739
|
+
console.error(`${res.summary.blockers} blocker(s), ${res.summary.warnings} warning(s), ${res.summary.info} info`);
|
|
2740
|
+
for (const n of res.notChecked) console.error(`note not checked: ${n}`);
|
|
2741
|
+
return gate && res.summary.blockers > 0 ? 1 : 0;
|
|
2742
|
+
}
|
|
2743
|
+
if (import.meta.main) runCli(main, { name: "cross-check" });
|
|
2744
|
+
export {
|
|
2745
|
+
main
|
|
2746
|
+
};
|