@flashdsm/genome 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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/chunk-6OCQZNS7.js +851 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +3775 -0
- package/dist/smoke.js +41 -0
- package/fixtures/brand-dna.json +12 -0
- package/fixtures/components.json +26 -0
- package/fixtures/design-tokens.json +61 -0
- package/fixtures/flash-ds.config.json +12 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3775 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
GENOME_CONFIG_FILENAME,
|
|
4
|
+
GenomeConfigSchema,
|
|
5
|
+
LEGACY_CONFIG_FILENAME,
|
|
6
|
+
TokenCategorySchema,
|
|
7
|
+
TokenSchema,
|
|
8
|
+
TokenTypeSchema,
|
|
9
|
+
configCandidates,
|
|
10
|
+
getDesignTokens,
|
|
11
|
+
getImpact,
|
|
12
|
+
getModule,
|
|
13
|
+
getRelations,
|
|
14
|
+
listModules,
|
|
15
|
+
loadTokensFromFile,
|
|
16
|
+
makeChangePlan,
|
|
17
|
+
normalizeTokenDocument,
|
|
18
|
+
parseCategoryInput,
|
|
19
|
+
parseFlashAtomicDocument,
|
|
20
|
+
resolveTokenFilePath,
|
|
21
|
+
tokenChangeToPlanEntry
|
|
22
|
+
} from "./chunk-6OCQZNS7.js";
|
|
23
|
+
|
|
24
|
+
// src/index.ts
|
|
25
|
+
import { resolve as resolve11 } from "path";
|
|
26
|
+
import { fileURLToPath } from "url";
|
|
27
|
+
import {
|
|
28
|
+
McpServer
|
|
29
|
+
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
30
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
31
|
+
import { z as z2 } from "zod";
|
|
32
|
+
import { readFileSync, realpathSync } from "fs";
|
|
33
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
34
|
+
|
|
35
|
+
// src/api.ts
|
|
36
|
+
var ApiError = class extends Error {
|
|
37
|
+
constructor(message, status, body) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.body = body;
|
|
41
|
+
this.name = "ApiError";
|
|
42
|
+
}
|
|
43
|
+
status;
|
|
44
|
+
body;
|
|
45
|
+
};
|
|
46
|
+
function createApiClient(opts) {
|
|
47
|
+
const baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
48
|
+
async function request(path, init) {
|
|
49
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
50
|
+
if (opts.accessToken) {
|
|
51
|
+
headers.set("Authorization", `Bearer ${opts.accessToken}`);
|
|
52
|
+
}
|
|
53
|
+
const res = await fetch(`${baseUrl}${path}`, { ...init, headers });
|
|
54
|
+
const text = await res.text();
|
|
55
|
+
let body;
|
|
56
|
+
if (text) {
|
|
57
|
+
try {
|
|
58
|
+
body = JSON.parse(text);
|
|
59
|
+
} catch {
|
|
60
|
+
body = text;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const msg = typeof body === "object" && body && "detail" in body && typeof body.detail === "string" ? body.detail : `HTTP ${res.status}`;
|
|
65
|
+
throw new ApiError(msg, res.status, body);
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
health: () => request("/health"),
|
|
71
|
+
listDesignSystems: () => request("/api/v1/design-systems/"),
|
|
72
|
+
createFromCodeScan: (body) => request("/api/v1/design-systems/from-code-scan", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
body: JSON.stringify(body)
|
|
75
|
+
}),
|
|
76
|
+
getDesignSystem: (id) => request(`/api/v1/design-systems/${id}`)
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/load-components.ts
|
|
81
|
+
import { readFile } from "fs/promises";
|
|
82
|
+
import { dirname, isAbsolute, resolve } from "path";
|
|
83
|
+
function isRecord(value) {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
function parseComponent(value) {
|
|
87
|
+
if (!isRecord(value) || typeof value.name !== "string") return null;
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function normalizeComponentDocument(raw) {
|
|
91
|
+
if (Array.isArray(raw)) {
|
|
92
|
+
return {
|
|
93
|
+
components: raw.map(parseComponent).filter((c) => c !== null)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (!isRecord(raw)) {
|
|
97
|
+
return { components: [] };
|
|
98
|
+
}
|
|
99
|
+
const name = typeof raw.name === "string" ? raw.name : void 0;
|
|
100
|
+
const list = Array.isArray(raw.components) ? raw.components : [];
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
components: list.map(parseComponent).filter((c) => c !== null)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function readJsonObject(path) {
|
|
107
|
+
try {
|
|
108
|
+
const text = await readFile(path, "utf8");
|
|
109
|
+
const parsed = JSON.parse(text);
|
|
110
|
+
return isRecord(parsed) ? parsed : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function resolveComponentsFilePath(options) {
|
|
116
|
+
const cwd = options.cwd ?? process.cwd();
|
|
117
|
+
if (options.componentsPath) {
|
|
118
|
+
return isAbsolute(options.componentsPath) ? options.componentsPath : resolve(cwd, options.componentsPath);
|
|
119
|
+
}
|
|
120
|
+
for (const configPath of configCandidates(options.configPath, cwd)) {
|
|
121
|
+
const raw = await readJsonObject(configPath);
|
|
122
|
+
if (!raw) continue;
|
|
123
|
+
const base = dirname(configPath);
|
|
124
|
+
if (raw.version === 2) {
|
|
125
|
+
const parsed = GenomeConfigSchema.safeParse(raw);
|
|
126
|
+
if (!parsed.success) continue;
|
|
127
|
+
const dir = parsed.data.outputs.genomeDir.replace(/\/+$/, "");
|
|
128
|
+
const named = `${dir}/components.json`;
|
|
129
|
+
return isAbsolute(named) ? named : resolve(base, named);
|
|
130
|
+
}
|
|
131
|
+
if (typeof raw.componentsFile !== "string") continue;
|
|
132
|
+
return isAbsolute(raw.componentsFile) ? raw.componentsFile : resolve(base, raw.componentsFile);
|
|
133
|
+
}
|
|
134
|
+
if (options.tokenFile) {
|
|
135
|
+
return resolve(dirname(options.tokenFile), "components.json");
|
|
136
|
+
}
|
|
137
|
+
return resolve(cwd, "components.json");
|
|
138
|
+
}
|
|
139
|
+
async function loadComponentsFromFile(componentsFile) {
|
|
140
|
+
const text = await readFile(componentsFile, "utf8");
|
|
141
|
+
const raw = JSON.parse(text);
|
|
142
|
+
const { components, name } = normalizeComponentDocument(raw);
|
|
143
|
+
return { source: componentsFile, components, name };
|
|
144
|
+
}
|
|
145
|
+
function filterComponentsByCategory(components, category) {
|
|
146
|
+
if (!category) return components;
|
|
147
|
+
const needle = category.toLowerCase();
|
|
148
|
+
return components.filter((c) => (c.category ?? "").toLowerCase() === needle);
|
|
149
|
+
}
|
|
150
|
+
function findComponentByName(components, name) {
|
|
151
|
+
const needle = name.toLowerCase();
|
|
152
|
+
return components.find((c) => c.name.toLowerCase() === needle);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ../codegen/dist/shared/colorMath.js
|
|
156
|
+
function normalizeHex(value) {
|
|
157
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value.trim());
|
|
158
|
+
if (!m)
|
|
159
|
+
return null;
|
|
160
|
+
let hex = m[1].toLowerCase();
|
|
161
|
+
if (hex.length === 3)
|
|
162
|
+
hex = hex.split("").map((c) => c + c).join("");
|
|
163
|
+
return `#${hex.slice(0, 6)}`;
|
|
164
|
+
}
|
|
165
|
+
function srgbToLinear(c) {
|
|
166
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
167
|
+
}
|
|
168
|
+
function hexToLinearRgb(hex) {
|
|
169
|
+
const normalized = normalizeHex(hex);
|
|
170
|
+
if (!normalized)
|
|
171
|
+
return null;
|
|
172
|
+
return [
|
|
173
|
+
srgbToLinear(parseInt(normalized.slice(1, 3), 16) / 255),
|
|
174
|
+
srgbToLinear(parseInt(normalized.slice(3, 5), 16) / 255),
|
|
175
|
+
srgbToLinear(parseInt(normalized.slice(5, 7), 16) / 255)
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
function relativeLuminance(hex) {
|
|
179
|
+
const rgb = hexToLinearRgb(hex);
|
|
180
|
+
if (!rgb)
|
|
181
|
+
return null;
|
|
182
|
+
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
|
183
|
+
}
|
|
184
|
+
function contrastRatio(a, b) {
|
|
185
|
+
const la = relativeLuminance(a);
|
|
186
|
+
const lb = relativeLuminance(b);
|
|
187
|
+
if (la === null || lb === null)
|
|
188
|
+
return null;
|
|
189
|
+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
190
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
191
|
+
}
|
|
192
|
+
function hexToOklab(hex) {
|
|
193
|
+
const rgb = hexToLinearRgb(hex);
|
|
194
|
+
if (!rgb)
|
|
195
|
+
return null;
|
|
196
|
+
const [r, g, b] = rgb;
|
|
197
|
+
const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
198
|
+
const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
199
|
+
const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
200
|
+
return {
|
|
201
|
+
L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
|
|
202
|
+
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
|
|
203
|
+
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function hexToOklch(hex) {
|
|
207
|
+
const lab = hexToOklab(hex);
|
|
208
|
+
if (!lab)
|
|
209
|
+
return null;
|
|
210
|
+
return {
|
|
211
|
+
l: lab.L,
|
|
212
|
+
c: Math.sqrt(lab.a * lab.a + lab.b * lab.b),
|
|
213
|
+
h: (Math.atan2(lab.b, lab.a) * 180 / Math.PI + 360) % 360
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function hexToRgba(value) {
|
|
217
|
+
const raw = value.trim();
|
|
218
|
+
const m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(raw);
|
|
219
|
+
if (!m)
|
|
220
|
+
return null;
|
|
221
|
+
let hex = m[1];
|
|
222
|
+
if (hex.length <= 4) {
|
|
223
|
+
hex = hex.split("").map((c) => c + c).join("");
|
|
224
|
+
}
|
|
225
|
+
const n = (i) => parseInt(hex.slice(i, i + 2), 16);
|
|
226
|
+
return {
|
|
227
|
+
r: n(0),
|
|
228
|
+
g: n(2),
|
|
229
|
+
b: n(4),
|
|
230
|
+
a: hex.length === 8 ? Math.round(n(6) / 255 * 1e3) / 1e3 : 1
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function round(n, places) {
|
|
234
|
+
const f = 10 ** places;
|
|
235
|
+
return Math.round(n * f) / f;
|
|
236
|
+
}
|
|
237
|
+
function formatColor(value, format) {
|
|
238
|
+
if (format === "hex")
|
|
239
|
+
return value;
|
|
240
|
+
const rgba = hexToRgba(value);
|
|
241
|
+
if (!rgba)
|
|
242
|
+
return value;
|
|
243
|
+
const { r, g, b, a } = rgba;
|
|
244
|
+
const alpha = a < 1 ? `, ${a}` : "";
|
|
245
|
+
if (format === "rgba") {
|
|
246
|
+
return a < 1 ? `rgba(${r}, ${g}, ${b}${alpha})` : `rgb(${r}, ${g}, ${b})`;
|
|
247
|
+
}
|
|
248
|
+
if (format === "hsl") {
|
|
249
|
+
const rn = r / 255;
|
|
250
|
+
const gn = g / 255;
|
|
251
|
+
const bn = b / 255;
|
|
252
|
+
const max = Math.max(rn, gn, bn);
|
|
253
|
+
const min = Math.min(rn, gn, bn);
|
|
254
|
+
const l = (max + min) / 2;
|
|
255
|
+
let h = 0;
|
|
256
|
+
let s = 0;
|
|
257
|
+
if (max !== min) {
|
|
258
|
+
const d = max - min;
|
|
259
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
260
|
+
if (max === rn)
|
|
261
|
+
h = (gn - bn) / d + (gn < bn ? 6 : 0);
|
|
262
|
+
else if (max === gn)
|
|
263
|
+
h = (bn - rn) / d + 2;
|
|
264
|
+
else
|
|
265
|
+
h = (rn - gn) / d + 4;
|
|
266
|
+
h *= 60;
|
|
267
|
+
}
|
|
268
|
+
const body2 = `${round(h, 1)}, ${round(s * 100, 1)}%, ${round(l * 100, 1)}%`;
|
|
269
|
+
return a < 1 ? `hsla(${body2}${alpha})` : `hsl(${body2})`;
|
|
270
|
+
}
|
|
271
|
+
const solid = `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
272
|
+
const lch = hexToOklch(solid);
|
|
273
|
+
if (!lch)
|
|
274
|
+
return value;
|
|
275
|
+
const body = `${round(lch.l * 100, 2)}% ${round(lch.c, 4)} ${round(lch.c < 5e-4 ? 0 : lch.h, 2)}`;
|
|
276
|
+
return a < 1 ? `oklch(${body} / ${a})` : `oklch(${body})`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ../codegen/dist/shared/appearances.js
|
|
280
|
+
var CONTRAST_ENHANCED = 7;
|
|
281
|
+
var PAIRED_ROLES = [
|
|
282
|
+
"primary",
|
|
283
|
+
"secondary",
|
|
284
|
+
"accent",
|
|
285
|
+
"muted",
|
|
286
|
+
"control",
|
|
287
|
+
"destructive",
|
|
288
|
+
"success",
|
|
289
|
+
"warning",
|
|
290
|
+
"info"
|
|
291
|
+
];
|
|
292
|
+
function semanticPairs(names) {
|
|
293
|
+
const pairs = [];
|
|
294
|
+
if (names.has("background") && names.has("foreground")) {
|
|
295
|
+
pairs.push(["background", "foreground"]);
|
|
296
|
+
}
|
|
297
|
+
for (const role of PAIRED_ROLES) {
|
|
298
|
+
if (names.has(role) && names.has(`${role}-foreground`)) {
|
|
299
|
+
pairs.push([role, `${role}-foreground`]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return pairs;
|
|
303
|
+
}
|
|
304
|
+
function splitRamp(name) {
|
|
305
|
+
const m = /^(.+?)-(\d{1,4})$/.exec(name);
|
|
306
|
+
if (!m)
|
|
307
|
+
return null;
|
|
308
|
+
const step = Number(m[2]);
|
|
309
|
+
return Number.isFinite(step) ? { family: m[1], step } : null;
|
|
310
|
+
}
|
|
311
|
+
function resolveToken(byName, name) {
|
|
312
|
+
let current = byName.get(name);
|
|
313
|
+
const seen = /* @__PURE__ */ new Set();
|
|
314
|
+
for (let depth = 0; current && depth < 10; depth++) {
|
|
315
|
+
if (!(current.layer === "alias" && current.referencedTokenId))
|
|
316
|
+
return current;
|
|
317
|
+
if (seen.has(current.name))
|
|
318
|
+
return void 0;
|
|
319
|
+
seen.add(current.name);
|
|
320
|
+
current = byName.get(current.referencedTokenId);
|
|
321
|
+
}
|
|
322
|
+
return void 0;
|
|
323
|
+
}
|
|
324
|
+
function resolve2(byName, name) {
|
|
325
|
+
return resolveToken(byName, name)?.value ?? "";
|
|
326
|
+
}
|
|
327
|
+
function highContrastOverrides(palette) {
|
|
328
|
+
const byName = /* @__PURE__ */ new Map();
|
|
329
|
+
for (const t of palette)
|
|
330
|
+
if (t?.name)
|
|
331
|
+
byName.set(t.name, t);
|
|
332
|
+
const ramps = /* @__PURE__ */ new Map();
|
|
333
|
+
for (const t of byName.values()) {
|
|
334
|
+
if (t.layer === "alias" || t.referencedTokenId)
|
|
335
|
+
continue;
|
|
336
|
+
const split = splitRamp(t.name);
|
|
337
|
+
if (!split || !normalizeHex(t.value ?? ""))
|
|
338
|
+
continue;
|
|
339
|
+
const list = ramps.get(split.family);
|
|
340
|
+
if (list)
|
|
341
|
+
list.push(t);
|
|
342
|
+
else
|
|
343
|
+
ramps.set(split.family, [t]);
|
|
344
|
+
}
|
|
345
|
+
const overrides = [];
|
|
346
|
+
for (const [surfaceName, foregroundName] of semanticPairs(new Set(byName.keys()))) {
|
|
347
|
+
const surface = resolve2(byName, surfaceName);
|
|
348
|
+
const foreground = resolve2(byName, foregroundName);
|
|
349
|
+
const current = contrastRatio(surface, foreground);
|
|
350
|
+
if (current === null || current >= CONTRAST_ENHANCED)
|
|
351
|
+
continue;
|
|
352
|
+
const via = resolveToken(byName, foregroundName);
|
|
353
|
+
const family = via ? splitRamp(via.name)?.family : void 0;
|
|
354
|
+
const steps = family ? ramps.get(family) ?? [] : [];
|
|
355
|
+
let best = null;
|
|
356
|
+
for (const step of steps) {
|
|
357
|
+
const ratio = contrastRatio(surface, step.value);
|
|
358
|
+
if (ratio === null || ratio < CONTRAST_ENHANCED)
|
|
359
|
+
continue;
|
|
360
|
+
if (!best || ratio < best.ratio)
|
|
361
|
+
best = { value: step.value, ratio };
|
|
362
|
+
}
|
|
363
|
+
if (!best)
|
|
364
|
+
continue;
|
|
365
|
+
overrides.push({
|
|
366
|
+
name: foregroundName,
|
|
367
|
+
value: best.value,
|
|
368
|
+
because: `${foregroundName} on ${surfaceName}: ${current.toFixed(2)}:1 becomes ${best.ratio.toFixed(2)}:1`
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return overrides;
|
|
372
|
+
}
|
|
373
|
+
function resolvedValue(palette, name) {
|
|
374
|
+
const byName = /* @__PURE__ */ new Map();
|
|
375
|
+
for (const t of palette)
|
|
376
|
+
if (t?.name)
|
|
377
|
+
byName.set(t.name, t);
|
|
378
|
+
const value = resolve2(byName, name);
|
|
379
|
+
return normalizeHex(value) ? value : null;
|
|
380
|
+
}
|
|
381
|
+
var DENSITY_SCALES = {
|
|
382
|
+
compact: 0.85,
|
|
383
|
+
comfortable: 1.15
|
|
384
|
+
};
|
|
385
|
+
var LENGTH = /^(-?\d*\.?\d+)(px|rem|em)$/;
|
|
386
|
+
function densityOverrides(spacing, scale) {
|
|
387
|
+
const out = [];
|
|
388
|
+
for (const t of spacing) {
|
|
389
|
+
if (!t?.name || t.layer === "alias" || t.referencedTokenId)
|
|
390
|
+
continue;
|
|
391
|
+
const m = LENGTH.exec((t.value ?? "").trim());
|
|
392
|
+
if (!m)
|
|
393
|
+
continue;
|
|
394
|
+
const scaled = Number(m[1]) * scale;
|
|
395
|
+
if (!Number.isFinite(scaled) || scaled === 0)
|
|
396
|
+
continue;
|
|
397
|
+
const rounded = Number(scaled.toPrecision(6));
|
|
398
|
+
if (rounded === Number(m[1]) || rounded === 0)
|
|
399
|
+
continue;
|
|
400
|
+
out.push({
|
|
401
|
+
name: t.name,
|
|
402
|
+
value: `${rounded}${m[2]}`,
|
|
403
|
+
because: `${t.value} at ${scale}x`
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ../codegen/dist/tokens.js
|
|
410
|
+
function tokensToCssVarName(name) {
|
|
411
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
412
|
+
}
|
|
413
|
+
function resolveAliasLiteral(byName, t) {
|
|
414
|
+
let current = t;
|
|
415
|
+
const seen = /* @__PURE__ */ new Set();
|
|
416
|
+
for (let depth = 0; current && depth < 10; depth++) {
|
|
417
|
+
if (!(current.layer === "alias" && current.referencedTokenId)) {
|
|
418
|
+
return current.value;
|
|
419
|
+
}
|
|
420
|
+
if (seen.has(current.name))
|
|
421
|
+
return void 0;
|
|
422
|
+
seen.add(current.name);
|
|
423
|
+
current = byName.get(current.referencedTokenId);
|
|
424
|
+
}
|
|
425
|
+
return void 0;
|
|
426
|
+
}
|
|
427
|
+
function resolveTokenValue(t) {
|
|
428
|
+
if (t.layer === "alias" && t.referencedTokenId) {
|
|
429
|
+
return `var(--${tokensToCssVarName(t.referencedTokenId)})`;
|
|
430
|
+
}
|
|
431
|
+
return t.value;
|
|
432
|
+
}
|
|
433
|
+
function emitColorVars(colors, lines, elementSemantics = [], colorFormat = "hex") {
|
|
434
|
+
const primitives = colors.filter((t) => t.layer !== "alias");
|
|
435
|
+
const aliases = colors.filter((t) => t.layer === "alias");
|
|
436
|
+
if (primitives.length) {
|
|
437
|
+
lines.push(" /* Color Primitives */");
|
|
438
|
+
for (const t of primitives) {
|
|
439
|
+
lines.push(` --${tokensToCssVarName(t.name)}: ${formatColor(t.value, colorFormat)};`);
|
|
440
|
+
}
|
|
441
|
+
lines.push("");
|
|
442
|
+
}
|
|
443
|
+
if (aliases.length || elementSemantics.length) {
|
|
444
|
+
lines.push(" /* Semantics */");
|
|
445
|
+
for (const t of aliases) {
|
|
446
|
+
lines.push(` --${tokensToCssVarName(t.name)}: ${resolveTokenValue(t)};`);
|
|
447
|
+
}
|
|
448
|
+
for (const s of elementSemantics) {
|
|
449
|
+
lines.push(` --${tokensToCssVarName(s.name)}: var(--${tokensToCssVarName(s.tokenName)});`);
|
|
450
|
+
}
|
|
451
|
+
lines.push("");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function unchangedPrimitiveNames(colors, lightColors) {
|
|
455
|
+
const dark = /* @__PURE__ */ new Map();
|
|
456
|
+
for (const t of colors) {
|
|
457
|
+
if (t.layer !== "alias" && !t.referencedTokenId)
|
|
458
|
+
dark.set(t.name, t.value ?? "");
|
|
459
|
+
}
|
|
460
|
+
const same2 = /* @__PURE__ */ new Set();
|
|
461
|
+
for (const t of lightColors) {
|
|
462
|
+
if (t.layer !== "alias" && !t.referencedTokenId && dark.get(t.name) === (t.value ?? "")) {
|
|
463
|
+
same2.add(t.name);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return same2;
|
|
467
|
+
}
|
|
468
|
+
function sharedAliasNames(colors, lightColors) {
|
|
469
|
+
const darkRefs = /* @__PURE__ */ new Map();
|
|
470
|
+
for (const t of colors) {
|
|
471
|
+
if (t.layer === "alias" && t.referencedTokenId) {
|
|
472
|
+
darkRefs.set(t.name, t.referencedTokenId);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const shared = /* @__PURE__ */ new Set();
|
|
476
|
+
for (const t of lightColors) {
|
|
477
|
+
if (t.layer === "alias" && t.referencedTokenId && darkRefs.get(t.name) === t.referencedTokenId) {
|
|
478
|
+
shared.add(t.name);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return shared;
|
|
482
|
+
}
|
|
483
|
+
function singleThemePalette(tokens) {
|
|
484
|
+
return tokens.colors.length ? tokens.colors : tokens.lightColors;
|
|
485
|
+
}
|
|
486
|
+
function motionTokens(tokens) {
|
|
487
|
+
return tokens.motion ?? [];
|
|
488
|
+
}
|
|
489
|
+
var NAMED_EASINGS = {
|
|
490
|
+
linear: [0, 0, 1, 1],
|
|
491
|
+
ease: [0.25, 0.1, 0.25, 1],
|
|
492
|
+
"ease-in": [0.42, 0, 1, 1],
|
|
493
|
+
"ease-out": [0, 0, 0.58, 1],
|
|
494
|
+
"ease-in-out": [0.42, 0, 0.58, 1]
|
|
495
|
+
};
|
|
496
|
+
function parseDuration(css) {
|
|
497
|
+
const m = /^\s*(-?[\d.]+)\s*(ms|s)\s*$/i.exec(css);
|
|
498
|
+
if (!m)
|
|
499
|
+
return null;
|
|
500
|
+
const value = Number(m[1]);
|
|
501
|
+
if (!Number.isFinite(value))
|
|
502
|
+
return null;
|
|
503
|
+
return { value, unit: m[2].toLowerCase() };
|
|
504
|
+
}
|
|
505
|
+
function parseCubicBezier(css) {
|
|
506
|
+
const trimmed = css.trim();
|
|
507
|
+
const named = NAMED_EASINGS[trimmed.toLowerCase()];
|
|
508
|
+
if (named)
|
|
509
|
+
return [...named];
|
|
510
|
+
const m = /^cubic-bezier\(([^)]+)\)$/i.exec(trimmed);
|
|
511
|
+
if (!m)
|
|
512
|
+
return null;
|
|
513
|
+
const parts = m[1].split(",").map((p) => Number(p.trim()));
|
|
514
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n)))
|
|
515
|
+
return null;
|
|
516
|
+
return parts;
|
|
517
|
+
}
|
|
518
|
+
function motionTokenKind(t) {
|
|
519
|
+
if (t.type === "duration" || t.type === "cubicBezier" || t.type === "transition")
|
|
520
|
+
return t.type;
|
|
521
|
+
const value = t.value.trim();
|
|
522
|
+
if (parseDuration(value))
|
|
523
|
+
return "duration";
|
|
524
|
+
if (parseCubicBezier(value) || /^linear\(/i.test(value))
|
|
525
|
+
return "cubicBezier";
|
|
526
|
+
const n = t.name.toLowerCase();
|
|
527
|
+
if (n.startsWith("duration-"))
|
|
528
|
+
return "duration";
|
|
529
|
+
if (n.startsWith("ease-") || n.startsWith("spring-"))
|
|
530
|
+
return "cubicBezier";
|
|
531
|
+
if (/\s/.test(value))
|
|
532
|
+
return "transition";
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
function splitTopLevel(css) {
|
|
536
|
+
const out = [];
|
|
537
|
+
let depth = 0;
|
|
538
|
+
let cur = "";
|
|
539
|
+
for (const ch of css.trim()) {
|
|
540
|
+
if (ch === "(")
|
|
541
|
+
depth++;
|
|
542
|
+
else if (ch === ")")
|
|
543
|
+
depth--;
|
|
544
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
545
|
+
if (cur)
|
|
546
|
+
out.push(cur);
|
|
547
|
+
cur = "";
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
cur += ch;
|
|
551
|
+
}
|
|
552
|
+
if (cur)
|
|
553
|
+
out.push(cur);
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
function decomposeTransition(css) {
|
|
557
|
+
const parts = splitTopLevel(css);
|
|
558
|
+
if (parts.length < 2 || parts.length > 3)
|
|
559
|
+
return null;
|
|
560
|
+
const isTime = (p) => parseDuration(p) !== null || /duration|delay/i.test(p);
|
|
561
|
+
const isEase = (p) => parseCubicBezier(p) !== null || /linear\(|ease|spring|cubic-bezier/i.test(p);
|
|
562
|
+
const duration = parts.find(isTime);
|
|
563
|
+
const timingFunction = parts.find((p) => p !== duration && isEase(p));
|
|
564
|
+
if (!duration || !timingFunction)
|
|
565
|
+
return null;
|
|
566
|
+
const delay = parts.find((p) => p !== duration && p !== timingFunction && isTime(p)) ?? "0ms";
|
|
567
|
+
return { duration, timingFunction, delay };
|
|
568
|
+
}
|
|
569
|
+
function motionAlias(css) {
|
|
570
|
+
const m = /^var\(\s*--([a-z0-9-]+)\s*(?:,[^)]*)?\)$/i.exec(css.trim());
|
|
571
|
+
return m ? `{motion.${m[1]}}` : null;
|
|
572
|
+
}
|
|
573
|
+
var FLASH_MODES_EXTENSION = "com.flashdsm.modes";
|
|
574
|
+
function motionDtcgEntry(t, legacyDurationStrings = false) {
|
|
575
|
+
const base = t.description ? { $description: t.description } : {};
|
|
576
|
+
const encodeTime = (css) => {
|
|
577
|
+
const alias = motionAlias(css);
|
|
578
|
+
if (alias)
|
|
579
|
+
return alias;
|
|
580
|
+
const parsed = parseDuration(css);
|
|
581
|
+
if (!parsed)
|
|
582
|
+
return css;
|
|
583
|
+
return legacyDurationStrings ? `${parsed.value}${parsed.unit}` : parsed;
|
|
584
|
+
};
|
|
585
|
+
switch (motionTokenKind(t)) {
|
|
586
|
+
case "duration": {
|
|
587
|
+
const parsed = parseDuration(t.value);
|
|
588
|
+
if (parsed) {
|
|
589
|
+
return {
|
|
590
|
+
$type: "duration",
|
|
591
|
+
$value: legacyDurationStrings ? `${parsed.value}${parsed.unit}` : parsed,
|
|
592
|
+
...base
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
case "cubicBezier": {
|
|
598
|
+
const isLinearFn = /^linear\(/i.test(t.value.trim());
|
|
599
|
+
const curve = parseCubicBezier(isLinearFn ? t.fallbackValue ?? "" : t.value);
|
|
600
|
+
if (curve) {
|
|
601
|
+
return {
|
|
602
|
+
$type: "cubicBezier",
|
|
603
|
+
$value: curve,
|
|
604
|
+
...base,
|
|
605
|
+
...isLinearFn ? { $extensions: { "com.flashdsm.motion": { easing: t.value } } } : {}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
case "transition": {
|
|
611
|
+
const parts = decomposeTransition(t.value);
|
|
612
|
+
if (parts) {
|
|
613
|
+
return {
|
|
614
|
+
$type: "transition",
|
|
615
|
+
$value: {
|
|
616
|
+
duration: encodeTime(parts.duration),
|
|
617
|
+
delay: encodeTime(parts.delay),
|
|
618
|
+
timingFunction: motionAlias(parts.timingFunction) ?? parseCubicBezier(parts.timingFunction) ?? parts.timingFunction
|
|
619
|
+
},
|
|
620
|
+
...base
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return { $type: t.type || "motion", $value: t.value, ...base };
|
|
627
|
+
}
|
|
628
|
+
function emitReducedMotion(motion, lines) {
|
|
629
|
+
const durations = motion.filter((t) => motionTokenKind(t) === "duration");
|
|
630
|
+
const distances = motion.filter((t) => tokensToCssVarName(t.name).startsWith("motion-distance"));
|
|
631
|
+
const scale = motion.find((t) => tokensToCssVarName(t.name) === "motion-scale");
|
|
632
|
+
if (!durations.length && !distances.length && !scale)
|
|
633
|
+
return;
|
|
634
|
+
const off = [];
|
|
635
|
+
const on = [];
|
|
636
|
+
if (scale) {
|
|
637
|
+
off.push(` --${tokensToCssVarName(scale.name)}: 0.001;`);
|
|
638
|
+
on.push(` --${tokensToCssVarName(scale.name)}: 1;`);
|
|
639
|
+
}
|
|
640
|
+
for (const t of durations) {
|
|
641
|
+
off.push(` --${tokensToCssVarName(t.name)}: 0.001ms;`);
|
|
642
|
+
on.push(` --${tokensToCssVarName(t.name)}: ${t.value};`);
|
|
643
|
+
}
|
|
644
|
+
for (const t of distances) {
|
|
645
|
+
off.push(` --${tokensToCssVarName(t.name)}: 0px;`);
|
|
646
|
+
on.push(` --${tokensToCssVarName(t.name)}: ${t.value};`);
|
|
647
|
+
}
|
|
648
|
+
lines.push("");
|
|
649
|
+
lines.push("/* Reduced motion - reduce, don't remove. */");
|
|
650
|
+
lines.push("@media (prefers-reduced-motion: reduce) {");
|
|
651
|
+
lines.push(' :root:not([data-motion="full"]) {');
|
|
652
|
+
for (const l of off)
|
|
653
|
+
lines.push(` ${l}`);
|
|
654
|
+
lines.push(" }");
|
|
655
|
+
lines.push("}");
|
|
656
|
+
lines.push("");
|
|
657
|
+
lines.push("/* In-app override - wins over the OS setting, both directions. */");
|
|
658
|
+
lines.push('[data-motion="reduced"] {');
|
|
659
|
+
for (const l of off)
|
|
660
|
+
lines.push(l);
|
|
661
|
+
lines.push("}");
|
|
662
|
+
lines.push("");
|
|
663
|
+
lines.push('[data-motion="full"] {');
|
|
664
|
+
for (const l of on)
|
|
665
|
+
lines.push(l);
|
|
666
|
+
lines.push("}");
|
|
667
|
+
}
|
|
668
|
+
function emitOverrides(overrides, selectors, indent, lines, withReasons = false) {
|
|
669
|
+
selectors.forEach((selector, i) => {
|
|
670
|
+
const last = i === selectors.length - 1;
|
|
671
|
+
lines.push(`${indent}${selector}${last ? " {" : ","}`);
|
|
672
|
+
});
|
|
673
|
+
for (const o of overrides) {
|
|
674
|
+
const reason = withReasons ? ` /* ${o.because} */` : "";
|
|
675
|
+
lines.push(`${indent} --${tokensToCssVarName(o.name)}: ${o.value};${reason}`);
|
|
676
|
+
}
|
|
677
|
+
lines.push(`${indent}}`);
|
|
678
|
+
}
|
|
679
|
+
function emitHighContrast(tokens, lines, colorFormat = "hex") {
|
|
680
|
+
const hasLight = tokens.lightColors.length > 0;
|
|
681
|
+
const hasDark = tokens.colors.length > 0;
|
|
682
|
+
const bothThemes = hasLight && hasDark;
|
|
683
|
+
const defaultPalette = hasDark ? tokens.colors : tokens.lightColors;
|
|
684
|
+
const otherPalette = bothThemes ? tokens.lightColors : [];
|
|
685
|
+
const defaults = highContrastOverrides(defaultPalette);
|
|
686
|
+
const others = bothThemes ? highContrastOverrides(otherPalette) : [];
|
|
687
|
+
if (!defaults.length && !others.length)
|
|
688
|
+
return;
|
|
689
|
+
const blockFor = (own, otherNames, palette) => {
|
|
690
|
+
const byName = new Map(own.map((o) => [o.name, o]));
|
|
691
|
+
for (const name of otherNames) {
|
|
692
|
+
if (byName.has(name))
|
|
693
|
+
continue;
|
|
694
|
+
const held = resolvedValue(palette, name);
|
|
695
|
+
if (!held)
|
|
696
|
+
continue;
|
|
697
|
+
byName.set(name, {
|
|
698
|
+
name,
|
|
699
|
+
value: held,
|
|
700
|
+
because: `unchanged here; the other theme moves it`
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return [...byName.values()];
|
|
704
|
+
};
|
|
705
|
+
const spelled = (block) => block.map((o) => ({ ...o, value: formatColor(o.value, colorFormat) }));
|
|
706
|
+
const defaultBlock = spelled(blockFor(defaults, others.map((o) => o.name), defaultPalette));
|
|
707
|
+
const otherBlock = bothThemes ? spelled(blockFor(others, defaults.map((o) => o.name), otherPalette)) : [];
|
|
708
|
+
lines.push("");
|
|
709
|
+
lines.push("/* High contrast: only the foregrounds move, each to a step of its own ramp that reaches 7:1 */");
|
|
710
|
+
if (defaultBlock.length) {
|
|
711
|
+
lines.push("@media (prefers-contrast: more) {");
|
|
712
|
+
emitOverrides(defaultBlock, [":root"], " ", lines, true);
|
|
713
|
+
lines.push("}");
|
|
714
|
+
emitOverrides(defaultBlock, [':root[data-contrast="more"]'], "", lines);
|
|
715
|
+
}
|
|
716
|
+
if (otherBlock.length) {
|
|
717
|
+
lines.push("");
|
|
718
|
+
lines.push("@media (prefers-contrast: more) {");
|
|
719
|
+
emitOverrides(otherBlock, ['[data-theme="light"]', ".light"], " ", lines, true);
|
|
720
|
+
lines.push("}");
|
|
721
|
+
lines.push("@media (prefers-contrast: more) and (prefers-color-scheme: light) {");
|
|
722
|
+
emitOverrides(otherBlock, [':root:not([data-theme="dark"])'], " ", lines);
|
|
723
|
+
lines.push("}");
|
|
724
|
+
emitOverrides(otherBlock, [
|
|
725
|
+
'[data-theme="light"][data-contrast="more"]',
|
|
726
|
+
'.light[data-contrast="more"]',
|
|
727
|
+
'[data-contrast="more"] [data-theme="light"]',
|
|
728
|
+
'[data-contrast="more"] .light'
|
|
729
|
+
], "", lines);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function emitDensity(tokens, lines) {
|
|
733
|
+
for (const [name, scale] of Object.entries(DENSITY_SCALES)) {
|
|
734
|
+
const overrides = densityOverrides(tokens.spacing, scale);
|
|
735
|
+
if (!overrides.length)
|
|
736
|
+
continue;
|
|
737
|
+
lines.push("");
|
|
738
|
+
lines.push(`/* Density: ${name}, spacing at ${scale}x */`);
|
|
739
|
+
emitOverrides(overrides, [`[data-density="${name}"]`], "", lines);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function generateCSS(tokens, attribution = false, appearances = {}) {
|
|
743
|
+
const hasLightColors = tokens.lightColors.length > 0;
|
|
744
|
+
const hasDarkColors = tokens.colors.length > 0;
|
|
745
|
+
const hasBothThemes = hasLightColors && hasDarkColors;
|
|
746
|
+
const colorFormat = appearances.colorFormat ?? "hex";
|
|
747
|
+
const lines = [];
|
|
748
|
+
if (attribution) {
|
|
749
|
+
lines.push("/*");
|
|
750
|
+
lines.push(" * Design tokens generated by Flash DSM - https://flashdsm.com");
|
|
751
|
+
lines.push(" * Upgrade to a paid plan to remove this attribution.");
|
|
752
|
+
lines.push(" */");
|
|
753
|
+
lines.push("");
|
|
754
|
+
}
|
|
755
|
+
const knownColorVars = new Set([...tokens.colors, ...tokens.lightColors].map((t) => tokensToCssVarName(t.name)));
|
|
756
|
+
const allDeclaredVars = new Set([
|
|
757
|
+
...knownColorVars,
|
|
758
|
+
...tokens.spacing,
|
|
759
|
+
...tokens.typography,
|
|
760
|
+
...tokens.radius,
|
|
761
|
+
...tokens.shadows,
|
|
762
|
+
...tokens.border,
|
|
763
|
+
...tokens.effects,
|
|
764
|
+
...tokens.icons,
|
|
765
|
+
...motionTokens(tokens)
|
|
766
|
+
].map((t) => typeof t === "string" ? t : tokensToCssVarName(t.name)));
|
|
767
|
+
const takenSemanticVars = /* @__PURE__ */ new Set();
|
|
768
|
+
const semantics = (tokens.usageSemantics ?? []).filter((s) => {
|
|
769
|
+
const name = tokensToCssVarName(s.name);
|
|
770
|
+
if (!knownColorVars.has(tokensToCssVarName(s.tokenName)))
|
|
771
|
+
return false;
|
|
772
|
+
if (allDeclaredVars.has(name) || takenSemanticVars.has(name))
|
|
773
|
+
return false;
|
|
774
|
+
takenSemanticVars.add(name);
|
|
775
|
+
return true;
|
|
776
|
+
});
|
|
777
|
+
if (hasBothThemes) {
|
|
778
|
+
lines.push("/* Dark mode (default) */");
|
|
779
|
+
lines.push(":root {");
|
|
780
|
+
emitColorVars(tokens.colors, lines, semantics, colorFormat);
|
|
781
|
+
} else {
|
|
782
|
+
lines.push(":root {");
|
|
783
|
+
if (hasDarkColors)
|
|
784
|
+
emitColorVars(tokens.colors, lines, semantics, colorFormat);
|
|
785
|
+
else if (hasLightColors)
|
|
786
|
+
emitColorVars(tokens.lightColors, lines, semantics, colorFormat);
|
|
787
|
+
}
|
|
788
|
+
const nonColor = [
|
|
789
|
+
["/* Spacing */", tokens.spacing],
|
|
790
|
+
["/* Typography */", tokens.typography],
|
|
791
|
+
["/* Border Radius */", tokens.radius],
|
|
792
|
+
["/* Shadows */", tokens.shadows],
|
|
793
|
+
["/* Border */", tokens.border],
|
|
794
|
+
["/* Effects */", tokens.effects],
|
|
795
|
+
["/* Icons */", tokens.icons],
|
|
796
|
+
["/* Motion */", motionTokens(tokens)]
|
|
797
|
+
];
|
|
798
|
+
for (const [c, group] of nonColor) {
|
|
799
|
+
if (!group.length)
|
|
800
|
+
continue;
|
|
801
|
+
lines.push(` ${c}`);
|
|
802
|
+
for (const t of group) {
|
|
803
|
+
lines.push(` --${tokensToCssVarName(t.name)}: ${resolveTokenValue(t)};`);
|
|
804
|
+
}
|
|
805
|
+
lines.push("");
|
|
806
|
+
}
|
|
807
|
+
lines.push("}");
|
|
808
|
+
if (hasBothThemes) {
|
|
809
|
+
const shared = sharedAliasNames(tokens.colors, tokens.lightColors);
|
|
810
|
+
const unchanged = unchangedPrimitiveNames(tokens.colors, tokens.lightColors);
|
|
811
|
+
const lightOnly = tokens.lightColors.filter((t) => t.layer === "alias" || t.referencedTokenId ? !shared.has(t.name) : !unchanged.has(t.name));
|
|
812
|
+
lines.push("");
|
|
813
|
+
lines.push("/* Light mode - explicit opt-in */");
|
|
814
|
+
if (shared.size) {
|
|
815
|
+
lines.push("/* Semantic aliases are declared once in :root; themes swap the values under them. */");
|
|
816
|
+
}
|
|
817
|
+
lines.push('[data-theme="light"],');
|
|
818
|
+
lines.push(".light {");
|
|
819
|
+
emitColorVars(lightOnly, lines, [], colorFormat);
|
|
820
|
+
lines.push("}");
|
|
821
|
+
lines.push("");
|
|
822
|
+
lines.push("/* Light mode - respects OS preference unless explicitly overridden */");
|
|
823
|
+
lines.push("@media (prefers-color-scheme: light) {");
|
|
824
|
+
lines.push(' :root:not([data-theme="dark"]) {');
|
|
825
|
+
const lp = lightOnly.filter((t) => t.layer !== "alias");
|
|
826
|
+
const la = lightOnly.filter((t) => t.layer === "alias");
|
|
827
|
+
for (const t of lp) {
|
|
828
|
+
lines.push(` --${tokensToCssVarName(t.name)}: ${formatColor(t.value, colorFormat)};`);
|
|
829
|
+
}
|
|
830
|
+
for (const t of la) {
|
|
831
|
+
lines.push(` --${tokensToCssVarName(t.name)}: ${resolveTokenValue(t)};`);
|
|
832
|
+
}
|
|
833
|
+
lines.push(" }");
|
|
834
|
+
lines.push("}");
|
|
835
|
+
}
|
|
836
|
+
if (appearances.highContrast)
|
|
837
|
+
emitHighContrast(tokens, lines, colorFormat);
|
|
838
|
+
if (appearances.density)
|
|
839
|
+
emitDensity(tokens, lines);
|
|
840
|
+
emitReducedMotion(motionTokens(tokens), lines);
|
|
841
|
+
return lines.join("\n");
|
|
842
|
+
}
|
|
843
|
+
function generateJSON(tokens, options = {}) {
|
|
844
|
+
const result = {};
|
|
845
|
+
const palette = singleThemePalette(tokens);
|
|
846
|
+
if (palette.length) {
|
|
847
|
+
result["color"] = {};
|
|
848
|
+
const defaultTheme = palette === tokens.colors ? "dark" : "light";
|
|
849
|
+
const otherTheme = defaultTheme === "dark" ? "light" : "dark";
|
|
850
|
+
const other = defaultTheme === "dark" ? tokens.lightColors : tokens.colors;
|
|
851
|
+
const keyOf = (t) => tokensToCssVarName(t.name);
|
|
852
|
+
const otherByKey = new Map(other.map((t) => [keyOf(t), t]));
|
|
853
|
+
const valueIn = (list) => {
|
|
854
|
+
const byName = new Map(list.map((t) => [t.name, t]));
|
|
855
|
+
return (t) => {
|
|
856
|
+
if (!(t.layer === "alias" && t.referencedTokenId))
|
|
857
|
+
return t.value;
|
|
858
|
+
const reference = `{color.${tokensToCssVarName(t.referencedTokenId)}}`;
|
|
859
|
+
if (!options.resolved)
|
|
860
|
+
return reference;
|
|
861
|
+
return resolveAliasLiteral(byName, t) ?? reference;
|
|
862
|
+
};
|
|
863
|
+
};
|
|
864
|
+
const defaultValue = valueIn(palette);
|
|
865
|
+
const otherValue = valueIn(other);
|
|
866
|
+
const entryFor = (t, $value) => ({
|
|
867
|
+
$value,
|
|
868
|
+
$type: "color",
|
|
869
|
+
...t.description ? { $description: t.description } : {}
|
|
870
|
+
});
|
|
871
|
+
const modes = (dark, light) => ({
|
|
872
|
+
[FLASH_MODES_EXTENSION]: {
|
|
873
|
+
...dark !== null ? { dark } : {},
|
|
874
|
+
...light !== null ? { light } : {}
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
const oneThemeOnly = (theme, value) => theme === "dark" ? modes(value, null) : modes(null, value);
|
|
878
|
+
for (const t of palette) {
|
|
879
|
+
const $value = defaultValue(t);
|
|
880
|
+
const entry = entryFor(t, $value);
|
|
881
|
+
const twin = otherByKey.get(keyOf(t));
|
|
882
|
+
if (twin) {
|
|
883
|
+
const twinValue = otherValue(twin);
|
|
884
|
+
if (twinValue !== $value) {
|
|
885
|
+
entry.$extensions = defaultTheme === "dark" ? modes($value, twinValue) : modes(twinValue, $value);
|
|
886
|
+
}
|
|
887
|
+
} else if (other.length) {
|
|
888
|
+
entry.$extensions = oneThemeOnly(defaultTheme, $value);
|
|
889
|
+
}
|
|
890
|
+
result["color"][keyOf(t)] = entry;
|
|
891
|
+
}
|
|
892
|
+
const defaultKeys = new Set(palette.map(keyOf));
|
|
893
|
+
for (const t of other) {
|
|
894
|
+
if (defaultKeys.has(keyOf(t)))
|
|
895
|
+
continue;
|
|
896
|
+
const value = otherValue(t);
|
|
897
|
+
const entry = entryFor(t, value);
|
|
898
|
+
entry.$extensions = oneThemeOnly(otherTheme, value);
|
|
899
|
+
result["color"][keyOf(t)] = entry;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const sections = [
|
|
903
|
+
["spacing", tokens.spacing],
|
|
904
|
+
["typography", tokens.typography],
|
|
905
|
+
["borderRadius", tokens.radius],
|
|
906
|
+
["shadow", tokens.shadows]
|
|
907
|
+
];
|
|
908
|
+
for (const [group, items] of sections) {
|
|
909
|
+
if (!items.length)
|
|
910
|
+
continue;
|
|
911
|
+
result[group] = {};
|
|
912
|
+
const keyFor = (name) => tokensToCssVarName(name).replace(new RegExp(`^${group}-?`), "") || name;
|
|
913
|
+
for (const t of items) {
|
|
914
|
+
const $value = t.layer === "alias" && t.referencedTokenId ? `{${group}.${keyFor(t.referencedTokenId)}}` : t.value;
|
|
915
|
+
result[group][keyFor(t.name)] = {
|
|
916
|
+
$value,
|
|
917
|
+
$type: t.type || group,
|
|
918
|
+
...t.description ? { $description: t.description } : {}
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const motion = motionTokens(tokens);
|
|
923
|
+
if (motion.length) {
|
|
924
|
+
result["motion"] = {};
|
|
925
|
+
for (const t of motion) {
|
|
926
|
+
result["motion"][tokensToCssVarName(t.name)] = motionDtcgEntry(t, options.legacyDurationStrings);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return JSON.stringify(result, null, 2);
|
|
930
|
+
}
|
|
931
|
+
var SD_LENGTH_RE = /^-?\d*\.?\d+(px|rem|em|pt)$/i;
|
|
932
|
+
function styleDictionaryType(group, t, resolved = t.value) {
|
|
933
|
+
const value = (resolved ?? "").trim();
|
|
934
|
+
const isLength = SD_LENGTH_RE.test(value);
|
|
935
|
+
if (group === "color")
|
|
936
|
+
return "color";
|
|
937
|
+
if (group === "motion") {
|
|
938
|
+
return motionTokenKind(t) === "duration" ? "duration" : "cubicBezier";
|
|
939
|
+
}
|
|
940
|
+
if (group === "shadow")
|
|
941
|
+
return t.type || group;
|
|
942
|
+
if (group === "font") {
|
|
943
|
+
const name = tokensToCssVarName(t.name);
|
|
944
|
+
if (/famil|typeface|stack/.test(name))
|
|
945
|
+
return "fontFamily";
|
|
946
|
+
if (/weight/.test(name))
|
|
947
|
+
return "fontWeight";
|
|
948
|
+
if (/line-height|leading/.test(name))
|
|
949
|
+
return isLength ? "size" : "number";
|
|
950
|
+
}
|
|
951
|
+
return isLength ? "size" : t.type || group;
|
|
952
|
+
}
|
|
953
|
+
function generateStyleDictionary(tokens) {
|
|
954
|
+
const result = {};
|
|
955
|
+
const write = (group, items, keyFor) => {
|
|
956
|
+
if (!items.length)
|
|
957
|
+
return;
|
|
958
|
+
const byName = new Map(items.map((t) => [t.name, t]));
|
|
959
|
+
const resolveValue = (t) => resolveAliasLiteral(byName, t);
|
|
960
|
+
const bucket = {};
|
|
961
|
+
for (const t of items) {
|
|
962
|
+
const value = t.layer === "alias" && t.referencedTokenId ? `{${group}.${keyFor(t.referencedTokenId)}.value}` : t.value;
|
|
963
|
+
bucket[keyFor(t.name)] = {
|
|
964
|
+
value,
|
|
965
|
+
type: styleDictionaryType(group, t, resolveValue(t)),
|
|
966
|
+
...t.description ? { comment: t.description } : {}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
result[group] = bucket;
|
|
970
|
+
};
|
|
971
|
+
const stripped = (...prefixes) => (name) => {
|
|
972
|
+
let key = tokensToCssVarName(name);
|
|
973
|
+
for (const prefix of prefixes) {
|
|
974
|
+
key = key.replace(new RegExp(`^${prefix}-?`), "");
|
|
975
|
+
}
|
|
976
|
+
return key || tokensToCssVarName(name);
|
|
977
|
+
};
|
|
978
|
+
write("color", singleThemePalette(tokens), stripped());
|
|
979
|
+
write("size", tokens.spacing, stripped("spacing"));
|
|
980
|
+
write("radius", tokens.radius, stripped("radius"));
|
|
981
|
+
write("font", tokens.typography, stripped("typography", "font"));
|
|
982
|
+
write("shadow", tokens.shadows, stripped("shadow"));
|
|
983
|
+
write("border", tokens.border, stripped("border"));
|
|
984
|
+
write("motion", motionTokens(tokens), stripped());
|
|
985
|
+
return JSON.stringify(result, null, 2);
|
|
986
|
+
}
|
|
987
|
+
function generateStyleDictionaryConfig() {
|
|
988
|
+
const platform = (name, destination, format, options) => ({
|
|
989
|
+
transformGroup: name,
|
|
990
|
+
buildPath: `build/${name}/`,
|
|
991
|
+
files: [{ destination, format, ...options ? { options } : {} }]
|
|
992
|
+
});
|
|
993
|
+
return JSON.stringify({
|
|
994
|
+
source: ["tokens/style-dictionary.tokens.json"],
|
|
995
|
+
platforms: {
|
|
996
|
+
css: platform("css", "variables.css", "css/variables"),
|
|
997
|
+
scss: platform("scss", "_variables.scss", "scss/variables"),
|
|
998
|
+
"ios-swift": {
|
|
999
|
+
...platform("ios-swift", "Tokens.swift", "ios-swift/class.swift", {
|
|
1000
|
+
className: "Tokens"
|
|
1001
|
+
})
|
|
1002
|
+
},
|
|
1003
|
+
android: platform("android", "tokens.xml", "android/resources"),
|
|
1004
|
+
compose: platform("compose", "Tokens.kt", "compose/object", {
|
|
1005
|
+
className: "Tokens",
|
|
1006
|
+
packageName: "tokens"
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
}, null, 2);
|
|
1010
|
+
}
|
|
1011
|
+
function shortHash(input) {
|
|
1012
|
+
let h = 5381;
|
|
1013
|
+
for (let i = 0; i < input.length; i++) {
|
|
1014
|
+
h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
1015
|
+
}
|
|
1016
|
+
return (h >>> 0).toString(16);
|
|
1017
|
+
}
|
|
1018
|
+
function tokenHashes(tokens) {
|
|
1019
|
+
const out = {};
|
|
1020
|
+
const all = [
|
|
1021
|
+
["colors", tokens.colors],
|
|
1022
|
+
["lightColors", tokens.lightColors],
|
|
1023
|
+
["spacing", tokens.spacing],
|
|
1024
|
+
["typography", tokens.typography],
|
|
1025
|
+
["radius", tokens.radius],
|
|
1026
|
+
["shadows", tokens.shadows],
|
|
1027
|
+
["border", tokens.border],
|
|
1028
|
+
["effects", tokens.effects],
|
|
1029
|
+
["icons", tokens.icons],
|
|
1030
|
+
["motion", motionTokens(tokens)]
|
|
1031
|
+
];
|
|
1032
|
+
for (const [group, items] of all) {
|
|
1033
|
+
for (const t of items) {
|
|
1034
|
+
out[`${group}.${tokensToCssVarName(t.name)}`] = shortHash(`${t.value}|${t.layer || ""}|${t.referencedTokenId || ""}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return out;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ../codegen/dist/from-atomic-document.js
|
|
1041
|
+
function atomicTokenToDesign(t, value = t.value) {
|
|
1042
|
+
return {
|
|
1043
|
+
name: t.name,
|
|
1044
|
+
value,
|
|
1045
|
+
type: t.type,
|
|
1046
|
+
category: t.category,
|
|
1047
|
+
...t.description ? { description: t.description } : {},
|
|
1048
|
+
...t.layer ? { layer: t.layer } : {},
|
|
1049
|
+
...t.referencedTokenId ? { referencedTokenId: t.referencedTokenId } : {},
|
|
1050
|
+
...t.colorGroup ? { colorGroup: t.colorGroup } : {},
|
|
1051
|
+
...t.fallbackValue ? { fallbackValue: t.fallbackValue } : {},
|
|
1052
|
+
...t.metadata ? { metadata: t.metadata } : {}
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function atomicThemeValues(t) {
|
|
1056
|
+
const meta = t.metadata ?? {};
|
|
1057
|
+
const dark = meta.dark || meta.darkValue;
|
|
1058
|
+
const light = meta.light || meta.lightValue;
|
|
1059
|
+
if (!dark && !light)
|
|
1060
|
+
return [t.value, t.value];
|
|
1061
|
+
return [dark ? String(dark) : null, light ? String(light) : null];
|
|
1062
|
+
}
|
|
1063
|
+
function fromAtomicDocument(doc) {
|
|
1064
|
+
const f = doc.foundations;
|
|
1065
|
+
const colors = [];
|
|
1066
|
+
const lightColors = [];
|
|
1067
|
+
const palette = [
|
|
1068
|
+
...f.colors ?? [],
|
|
1069
|
+
...(doc.semantics ?? []).map((t) => ({ ...t, layer: t.layer ?? "alias" }))
|
|
1070
|
+
];
|
|
1071
|
+
for (const t of palette) {
|
|
1072
|
+
const [dark, light] = atomicThemeValues(t);
|
|
1073
|
+
if (dark !== null)
|
|
1074
|
+
colors.push(atomicTokenToDesign(t, dark));
|
|
1075
|
+
if (light !== null)
|
|
1076
|
+
lightColors.push(atomicTokenToDesign(t, light));
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
colors,
|
|
1080
|
+
lightColors,
|
|
1081
|
+
spacing: (f.spacing ?? []).map((t) => atomicTokenToDesign(t)),
|
|
1082
|
+
typography: (f.typography ?? []).map((t) => atomicTokenToDesign(t)),
|
|
1083
|
+
radius: (f.radius ?? []).map((t) => atomicTokenToDesign(t)),
|
|
1084
|
+
shadows: (f.shadows ?? []).map((t) => atomicTokenToDesign(t)),
|
|
1085
|
+
border: (f.border ?? []).map((t) => atomicTokenToDesign(t)),
|
|
1086
|
+
motion: (f.motion ?? []).map((t) => atomicTokenToDesign(t)),
|
|
1087
|
+
effects: (f.effects ?? []).map((t) => atomicTokenToDesign(t)),
|
|
1088
|
+
icons: (f.icons ?? []).map((t) => atomicTokenToDesign(t))
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function defaultStateBindings(component) {
|
|
1092
|
+
const defaultBindings = component.bindings.filter((b) => (b.stateId ?? "default") === "default" && !b.variantId);
|
|
1093
|
+
if (defaultBindings.length)
|
|
1094
|
+
return defaultBindings;
|
|
1095
|
+
const primaryVariant = component.variants[0]?.id;
|
|
1096
|
+
if (!primaryVariant)
|
|
1097
|
+
return component.bindings;
|
|
1098
|
+
return component.bindings.filter((b) => (b.stateId ?? "default") === "default" && b.variantId === primaryVariant);
|
|
1099
|
+
}
|
|
1100
|
+
function slotToTailwind(slot, tokenId) {
|
|
1101
|
+
const cssVar = `var(--${tokensToCssVarName(tokenId)})`;
|
|
1102
|
+
switch (slot) {
|
|
1103
|
+
case "background":
|
|
1104
|
+
return `bg-[${cssVar}]`;
|
|
1105
|
+
case "foreground":
|
|
1106
|
+
case "color":
|
|
1107
|
+
return `text-[${cssVar}]`;
|
|
1108
|
+
case "accent":
|
|
1109
|
+
return `accent-[${cssVar}]`;
|
|
1110
|
+
case "border":
|
|
1111
|
+
return `border border-[${cssVar}]`;
|
|
1112
|
+
case "padding":
|
|
1113
|
+
return `p-[${cssVar}]`;
|
|
1114
|
+
case "margin":
|
|
1115
|
+
return `m-[${cssVar}]`;
|
|
1116
|
+
case "gap":
|
|
1117
|
+
return `gap-[${cssVar}]`;
|
|
1118
|
+
case "borderRadius":
|
|
1119
|
+
return `rounded-[${cssVar}]`;
|
|
1120
|
+
case "fontSize":
|
|
1121
|
+
return `text-[${cssVar}]`;
|
|
1122
|
+
case "fontWeight":
|
|
1123
|
+
return `font-[${cssVar}]`;
|
|
1124
|
+
case "lineHeight":
|
|
1125
|
+
return `leading-[${cssVar}]`;
|
|
1126
|
+
case "letterSpacing":
|
|
1127
|
+
return `tracking-[${cssVar}]`;
|
|
1128
|
+
case "boxShadow":
|
|
1129
|
+
return `shadow-[${cssVar}]`;
|
|
1130
|
+
case "minHeight":
|
|
1131
|
+
return `min-h-[${cssVar}]`;
|
|
1132
|
+
case "transition":
|
|
1133
|
+
return `transition-[${cssVar}]`;
|
|
1134
|
+
default:
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function rootTagForComponent(id) {
|
|
1139
|
+
switch (id) {
|
|
1140
|
+
case "button":
|
|
1141
|
+
case "icon-button":
|
|
1142
|
+
return "button";
|
|
1143
|
+
case "input":
|
|
1144
|
+
case "search-input":
|
|
1145
|
+
case "password-input":
|
|
1146
|
+
return "input";
|
|
1147
|
+
case "textarea":
|
|
1148
|
+
return "textarea";
|
|
1149
|
+
case "select":
|
|
1150
|
+
return "select";
|
|
1151
|
+
case "checkbox":
|
|
1152
|
+
case "radio":
|
|
1153
|
+
case "switch":
|
|
1154
|
+
return "input";
|
|
1155
|
+
case "a":
|
|
1156
|
+
case "link":
|
|
1157
|
+
return "a";
|
|
1158
|
+
default:
|
|
1159
|
+
return "div";
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
function atomicComponentsToExportSpecs(doc) {
|
|
1163
|
+
return doc.components.map((component) => {
|
|
1164
|
+
const classes = defaultStateBindings(component).map((b) => slotToTailwind(String(b.slot), b.tokenId)).filter((c) => Boolean(c));
|
|
1165
|
+
const tag = rootTagForComponent(component.id);
|
|
1166
|
+
const classAttr = classes.length > 0 ? ` className="${classes.join(" ")}"` : "";
|
|
1167
|
+
const selfClosing = tag === "input";
|
|
1168
|
+
const code = selfClosing ? `<${tag}${classAttr} />` : `<${tag}${classAttr}>${component.name}</${tag}>`;
|
|
1169
|
+
return {
|
|
1170
|
+
name: component.id,
|
|
1171
|
+
category: component.category,
|
|
1172
|
+
description: component.description,
|
|
1173
|
+
code
|
|
1174
|
+
};
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ../codegen/dist/shared/componentAliases.js
|
|
1179
|
+
var COMPONENT_ALIASES = {
|
|
1180
|
+
TextField: ["input"],
|
|
1181
|
+
TextInput: ["input"],
|
|
1182
|
+
InputField: ["input"],
|
|
1183
|
+
FormField: ["input"],
|
|
1184
|
+
Field: ["input"],
|
|
1185
|
+
Entry: ["input"],
|
|
1186
|
+
TextBox: ["textarea", "input"],
|
|
1187
|
+
MultilineInput: ["textarea"],
|
|
1188
|
+
PasswordField: ["password-input", "input"],
|
|
1189
|
+
PasswordInput: ["password-input", "input"],
|
|
1190
|
+
NumberField: ["number-input", "input"],
|
|
1191
|
+
NumberInput: ["number-input", "input"],
|
|
1192
|
+
SpinButton: ["number-input", "input"],
|
|
1193
|
+
SearchBar: ["search-input", "input"],
|
|
1194
|
+
SearchField: ["search-input", "input"],
|
|
1195
|
+
SearchBox: ["search-input", "input"],
|
|
1196
|
+
OTP: ["otp-input", "input"],
|
|
1197
|
+
PinInput: ["otp-input", "input"],
|
|
1198
|
+
VerificationCode: ["otp-input", "input"],
|
|
1199
|
+
Combobox: ["select", "dropdown-list"],
|
|
1200
|
+
Autocomplete: ["select", "dropdown-list"],
|
|
1201
|
+
Listbox: ["select", "dropdown-list"],
|
|
1202
|
+
Picker: ["select", "dropdown-list"],
|
|
1203
|
+
Menu: ["dropdown-list", "select"],
|
|
1204
|
+
MenuList: ["dropdown-list", "select"],
|
|
1205
|
+
ContextMenu: ["dropdown-list"],
|
|
1206
|
+
TokenInput: ["multi-select", "select"],
|
|
1207
|
+
TagsInput: ["multi-select", "select"],
|
|
1208
|
+
CheckboxGroup: ["checkbox"],
|
|
1209
|
+
Tickbox: ["checkbox"],
|
|
1210
|
+
RadioGroup: ["radio"],
|
|
1211
|
+
RadioButton: ["radio"],
|
|
1212
|
+
OptionButton: ["radio"],
|
|
1213
|
+
ToggleSwitch: ["switch", "toggle"],
|
|
1214
|
+
ToggleButton: ["toggle", "switch"],
|
|
1215
|
+
SegmentedControl: ["toggle", "tabs"],
|
|
1216
|
+
RangeSlider: ["slider"],
|
|
1217
|
+
Range: ["slider"],
|
|
1218
|
+
Dialog: ["modal"],
|
|
1219
|
+
AlertDialog: ["modal"],
|
|
1220
|
+
Sheet: ["modal", "drawer"],
|
|
1221
|
+
Popup: ["modal", "popover"],
|
|
1222
|
+
Lightbox: ["modal"],
|
|
1223
|
+
SideSheet: ["drawer", "sidebar"],
|
|
1224
|
+
SidePanel: ["drawer", "sidebar"],
|
|
1225
|
+
Offcanvas: ["drawer", "sidebar"],
|
|
1226
|
+
Flyout: ["popover", "dropdown-list"],
|
|
1227
|
+
HoverCard: ["popover", "tooltip"],
|
|
1228
|
+
Hint: ["tooltip"],
|
|
1229
|
+
Infotip: ["tooltip"],
|
|
1230
|
+
Popper: ["tooltip", "popover"],
|
|
1231
|
+
Snackbar: ["toast", "alert"],
|
|
1232
|
+
Notification: ["toast", "alert"],
|
|
1233
|
+
Notice: ["alert", "toast"],
|
|
1234
|
+
Banner: ["alert"],
|
|
1235
|
+
Callout: ["alert"],
|
|
1236
|
+
InlineMessage: ["alert"],
|
|
1237
|
+
InfoBox: ["alert"],
|
|
1238
|
+
Tag: ["chip", "badge"],
|
|
1239
|
+
Pill: ["chip", "badge"],
|
|
1240
|
+
StatusBadge: ["badge", "chip"],
|
|
1241
|
+
Counter: ["badge", "chip"],
|
|
1242
|
+
DataGrid: ["data-table"],
|
|
1243
|
+
Table: ["data-table"],
|
|
1244
|
+
DataTable: ["data-table"],
|
|
1245
|
+
Calendar: ["date-picker"],
|
|
1246
|
+
DateField: ["date-picker"],
|
|
1247
|
+
DateRangePicker: ["date-picker"],
|
|
1248
|
+
ListView: ["list"],
|
|
1249
|
+
ItemList: ["list"],
|
|
1250
|
+
Repeater: ["list"],
|
|
1251
|
+
CircularProgress: ["progress-indicator", "progress", "spinner"],
|
|
1252
|
+
ProgressCircle: ["progress-indicator", "progress"],
|
|
1253
|
+
ProgressBar: ["progress"],
|
|
1254
|
+
LinearProgress: ["progress"],
|
|
1255
|
+
Meter: ["progress"],
|
|
1256
|
+
Gauge: ["progress"],
|
|
1257
|
+
Loader: ["spinner"],
|
|
1258
|
+
Loading: ["spinner"],
|
|
1259
|
+
LoadingIndicator: ["spinner", "progress-indicator"],
|
|
1260
|
+
ActivityIndicator: ["spinner"],
|
|
1261
|
+
Throbber: ["spinner"],
|
|
1262
|
+
Placeholder: ["skeleton"],
|
|
1263
|
+
Shimmer: ["skeleton"],
|
|
1264
|
+
LoadingSkeleton: ["skeleton"],
|
|
1265
|
+
AppBar: ["navbar"],
|
|
1266
|
+
TopBar: ["navbar"],
|
|
1267
|
+
Toolbar: ["navbar"],
|
|
1268
|
+
NavigationBar: ["navbar"],
|
|
1269
|
+
Nav: ["navbar"],
|
|
1270
|
+
SideNav: ["sidebar", "navbar"],
|
|
1271
|
+
NavigationRail: ["sidebar", "navbar"],
|
|
1272
|
+
TabBar: ["tabs"],
|
|
1273
|
+
TabList: ["tabs"],
|
|
1274
|
+
TabPanel: ["tabs"],
|
|
1275
|
+
Breadcrumbs: ["breadcrumb"],
|
|
1276
|
+
Crumbs: ["breadcrumb"],
|
|
1277
|
+
Paginator: ["pagination"],
|
|
1278
|
+
Pager: ["pagination"],
|
|
1279
|
+
Stepper: ["pagination"],
|
|
1280
|
+
Anchor: ["link"],
|
|
1281
|
+
Hyperlink: ["link"],
|
|
1282
|
+
NavLink: ["link"],
|
|
1283
|
+
Panel: ["card"],
|
|
1284
|
+
Tile: ["card"],
|
|
1285
|
+
Collapse: ["accordion"],
|
|
1286
|
+
Collapsible: ["accordion"],
|
|
1287
|
+
Disclosure: ["accordion"],
|
|
1288
|
+
Expander: ["accordion"],
|
|
1289
|
+
Details: ["accordion"],
|
|
1290
|
+
Separator: ["divider"],
|
|
1291
|
+
Rule: ["divider"],
|
|
1292
|
+
PageTitle: ["page-header"],
|
|
1293
|
+
Masthead: ["page-header"],
|
|
1294
|
+
TitleBar: ["page-header"],
|
|
1295
|
+
Hero: ["page-header"],
|
|
1296
|
+
BlankSlate: ["empty-state"],
|
|
1297
|
+
NoData: ["empty-state"],
|
|
1298
|
+
ZeroState: ["empty-state"],
|
|
1299
|
+
ProfilePicture: ["avatar"],
|
|
1300
|
+
UserPic: ["avatar"],
|
|
1301
|
+
Initials: ["avatar"],
|
|
1302
|
+
Img: ["image"],
|
|
1303
|
+
Picture: ["image"],
|
|
1304
|
+
Thumbnail: ["image"],
|
|
1305
|
+
Glyph: ["icon"],
|
|
1306
|
+
Symbol: ["icon"],
|
|
1307
|
+
FAB: ["icon-button", "button"],
|
|
1308
|
+
FloatingActionButton: ["icon-button", "button"],
|
|
1309
|
+
ActionButton: ["icon-button", "button"],
|
|
1310
|
+
DropdownButton: ["split-button", "button"],
|
|
1311
|
+
ButtonGroup: ["split-button", "button"],
|
|
1312
|
+
Btn: ["button"],
|
|
1313
|
+
CTA: ["button"],
|
|
1314
|
+
Submit: ["button"],
|
|
1315
|
+
Video: ["video-frame"],
|
|
1316
|
+
Player: ["video-frame"],
|
|
1317
|
+
MediaFrame: ["video-frame"]
|
|
1318
|
+
};
|
|
1319
|
+
function aliasKey(name) {
|
|
1320
|
+
return (name ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1321
|
+
}
|
|
1322
|
+
var ALIAS_INDEX = Object.fromEntries(Object.entries(COMPONENT_ALIASES).map(([alias, candidates]) => [
|
|
1323
|
+
aliasKey(alias),
|
|
1324
|
+
candidates
|
|
1325
|
+
]));
|
|
1326
|
+
function resolveComponentName(wanted, available) {
|
|
1327
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
1328
|
+
for (const name of available) {
|
|
1329
|
+
if (typeof name !== "string" || !name.trim())
|
|
1330
|
+
continue;
|
|
1331
|
+
const key2 = aliasKey(name);
|
|
1332
|
+
if (!lookup.has(key2))
|
|
1333
|
+
lookup.set(key2, name);
|
|
1334
|
+
}
|
|
1335
|
+
const key = aliasKey(wanted);
|
|
1336
|
+
if (!key)
|
|
1337
|
+
return null;
|
|
1338
|
+
const direct = lookup.get(key);
|
|
1339
|
+
if (direct !== void 0)
|
|
1340
|
+
return direct;
|
|
1341
|
+
for (const candidate of ALIAS_INDEX[key] ?? []) {
|
|
1342
|
+
const found = lookup.get(aliasKey(candidate));
|
|
1343
|
+
if (found !== void 0)
|
|
1344
|
+
return found;
|
|
1345
|
+
}
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
function aliasesFor(canonical, available) {
|
|
1349
|
+
const key = aliasKey(canonical);
|
|
1350
|
+
const ranked = [];
|
|
1351
|
+
for (const [alias, candidates] of Object.entries(COMPONENT_ALIASES)) {
|
|
1352
|
+
const keys = candidates.map(aliasKey);
|
|
1353
|
+
const rank = keys.indexOf(key);
|
|
1354
|
+
if (rank < 0)
|
|
1355
|
+
continue;
|
|
1356
|
+
if (aliasKey(alias) === key)
|
|
1357
|
+
continue;
|
|
1358
|
+
if (aliasKey(resolveComponentName(alias, available) ?? "") === key) {
|
|
1359
|
+
ranked.push([rank, alias]);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
return ranked.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1])).map(([, alias]) => alias);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// ../codegen/dist/shared/tactileKit.js
|
|
1366
|
+
var TACTILE_CSS_PATH = "tokens/tactile.css";
|
|
1367
|
+
var TACTILE_COMPONENT_NAMES = /* @__PURE__ */ new Set([
|
|
1368
|
+
"button",
|
|
1369
|
+
"icon-button",
|
|
1370
|
+
"switch",
|
|
1371
|
+
"checkbox",
|
|
1372
|
+
"slider",
|
|
1373
|
+
"toggle"
|
|
1374
|
+
]);
|
|
1375
|
+
var HEADER = `Generated by Flash DSM Maker - tactile kit. Styles live in ${TACTILE_CSS_PATH}.`;
|
|
1376
|
+
|
|
1377
|
+
// ../codegen/dist/shared/agentsMd.js
|
|
1378
|
+
var MAX_ALIASES_PER_COMPONENT = 8;
|
|
1379
|
+
var NAMED_PRIMITIVES = ["Box", "Stack", "Flex", "Grid", "Container"];
|
|
1380
|
+
function semanticRoles(tokens) {
|
|
1381
|
+
const all = [
|
|
1382
|
+
...tokens.colors ?? [],
|
|
1383
|
+
...tokens.lightColors ?? []
|
|
1384
|
+
];
|
|
1385
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1386
|
+
for (const token of all) {
|
|
1387
|
+
if (token.name && !byId.has(token.name))
|
|
1388
|
+
byId.set(token.name, token);
|
|
1389
|
+
}
|
|
1390
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1391
|
+
const out = [];
|
|
1392
|
+
for (const token of all) {
|
|
1393
|
+
const ref = token.referencedTokenId;
|
|
1394
|
+
if (!ref || !token.name || seen.has(token.name))
|
|
1395
|
+
continue;
|
|
1396
|
+
seen.add(token.name);
|
|
1397
|
+
const target = byId.get(ref);
|
|
1398
|
+
out.push([
|
|
1399
|
+
tokensToCssVarName(token.name),
|
|
1400
|
+
tokensToCssVarName(target?.name ?? ref),
|
|
1401
|
+
target?.value ?? token.value ?? ""
|
|
1402
|
+
]);
|
|
1403
|
+
}
|
|
1404
|
+
return out;
|
|
1405
|
+
}
|
|
1406
|
+
function componentLines(components) {
|
|
1407
|
+
const names = components.map((c) => c.name).filter(Boolean);
|
|
1408
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
1409
|
+
const lines = [];
|
|
1410
|
+
for (const name of [...names].sort()) {
|
|
1411
|
+
const spec = byName.get(name);
|
|
1412
|
+
let line = `- \`${name}\``;
|
|
1413
|
+
if (spec?.description?.trim())
|
|
1414
|
+
line += ` - ${spec.description.trim()}`;
|
|
1415
|
+
lines.push(line);
|
|
1416
|
+
const aliases = aliasesFor(name, names);
|
|
1417
|
+
if (aliases.length) {
|
|
1418
|
+
const shown = aliases.slice(0, MAX_ALIASES_PER_COMPONENT).join(", ");
|
|
1419
|
+
lines.push(` - Also known as: ${shown}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return lines;
|
|
1423
|
+
}
|
|
1424
|
+
function interactionsBlock(components) {
|
|
1425
|
+
const kit = components.map((c) => c.name).filter((name) => TACTILE_COMPONENT_NAMES.has(name)).sort();
|
|
1426
|
+
const which = kit.length ? `the ${kit.map((n) => `\`${n}\``).join(", ")} components` : "the kit's controls";
|
|
1427
|
+
return `
|
|
1428
|
+
## Interactions
|
|
1429
|
+
|
|
1430
|
+
This system uses the tactile kit. \`${TACTILE_CSS_PATH}\` gives ${which}
|
|
1431
|
+
their look and motion: pill surfaces, ink for selected states, a 90ms press
|
|
1432
|
+
and a small spring on release.
|
|
1433
|
+
|
|
1434
|
+
- Use these components as they are. Do not restyle their \`tk-*\` classes,
|
|
1435
|
+
add transitions to them, or rebuild them with utility classes.
|
|
1436
|
+
- Keep the native input inside the switch, checkbox, segmented control and
|
|
1437
|
+
slider. Their state lives there (\`:checked\`, \`:has()\`), so swapping it
|
|
1438
|
+
for a \`div\` breaks the behaviour and the accessibility together.
|
|
1439
|
+
- To change the motion, change the tokens (\`--ease-spring\`,
|
|
1440
|
+
\`--ease-bounce\`, \`--duration-press\`), not the stylesheet.
|
|
1441
|
+
`;
|
|
1442
|
+
}
|
|
1443
|
+
function generateAgentsMd(options) {
|
|
1444
|
+
const { projectName, tokens, components, library, interactionKit } = options;
|
|
1445
|
+
const lines = componentLines(components);
|
|
1446
|
+
const componentsBlock = lines.length ? `Build with these - they live in \`components/\`. Do not create a second
|
|
1447
|
+
version of one that already exists, and do not import a component from
|
|
1448
|
+
another UI library to fill a gap.
|
|
1449
|
+
|
|
1450
|
+
${lines.join("\n")}` : `This export ships no components. Style plain elements with the tokens
|
|
1451
|
+
in \`tokens/tokens.css\`.`;
|
|
1452
|
+
const roles = semanticRoles(tokens);
|
|
1453
|
+
const rolesBlock = roles.length ? `Style with the role, never the primitive underneath it.
|
|
1454
|
+
\`var(--primary)\` survives a rebrand; \`var(--primary-500)\` and a raw hex
|
|
1455
|
+
do not.
|
|
1456
|
+
|
|
1457
|
+
${roles.map(([role, ref, value]) => `- \`--${role}\` \u2192 \`var(--${ref})\`${value ? ` (${value})` : ""}`).join("\n")}` : `This export has no semantic colour layer - use the named tokens in
|
|
1458
|
+
\`tokens/tokens.css\`.`;
|
|
1459
|
+
const libraryNote = library ? `
|
|
1460
|
+
This project is themed for **${library}**. Its theme file under
|
|
1461
|
+
\`theme/\` is generated from the same tokens - change tokens, not the theme.
|
|
1462
|
+
` : "";
|
|
1463
|
+
return `# ${projectName}
|
|
1464
|
+
|
|
1465
|
+
Instructions for AI coding agents working in this project. Its UI is built
|
|
1466
|
+
on a design system exported from Flash DSM.
|
|
1467
|
+
|
|
1468
|
+
Token reference: \`tokens/tokens.css\` (CSS custom properties) and
|
|
1469
|
+
\`tokens/tokens.json\` (DTCG).
|
|
1470
|
+
${libraryNote}
|
|
1471
|
+
## Rules
|
|
1472
|
+
|
|
1473
|
+
1. Use a component from the catalogue below before writing a new one. If
|
|
1474
|
+
nothing fits, compose from existing components rather than inventing a
|
|
1475
|
+
new primitive.
|
|
1476
|
+
2. Style only with the tokens this system defines. No hex codes, no
|
|
1477
|
+
arbitrary Tailwind values (\`bg-[#1e293b]\`), no inline pixel sizes.
|
|
1478
|
+
3. Prefer the semantic colour roles over the primitives they point at.
|
|
1479
|
+
4. Keep the foreground/background pairs as defined - they are tuned for
|
|
1480
|
+
WCAG AA contrast. Do not mix a foreground from one pair with a
|
|
1481
|
+
background from another.
|
|
1482
|
+
5. If a value you need has no token, say so in your response instead of
|
|
1483
|
+
hardcoding it. A flagged gap is fixable; a hardcoded hex is invisible.
|
|
1484
|
+
|
|
1485
|
+
## Components
|
|
1486
|
+
|
|
1487
|
+
${componentsBlock}
|
|
1488
|
+
${interactionKit === "tactile" ? interactionsBlock(components) : ""}
|
|
1489
|
+
## Layout
|
|
1490
|
+
|
|
1491
|
+
${NAMED_PRIMITIVES.map((p) => `\`${p}\``).join(", ")}
|
|
1492
|
+
are not components in this system - they are layout primitives from other
|
|
1493
|
+
libraries. Use plain elements (\`div\`, \`section\`, \`ul\`) with this
|
|
1494
|
+
system's spacing tokens instead of reaching for a component that does not
|
|
1495
|
+
exist here.
|
|
1496
|
+
|
|
1497
|
+
## Colour roles
|
|
1498
|
+
|
|
1499
|
+
${rolesBlock}
|
|
1500
|
+
|
|
1501
|
+
## Do not
|
|
1502
|
+
|
|
1503
|
+
- Do not install or import another UI library (MUI, Chakra, Ant, Radix,
|
|
1504
|
+
Bootstrap) to satisfy a requirement. If this system cannot express it,
|
|
1505
|
+
say so.
|
|
1506
|
+
- Do not invent a component name. Every name you import must appear in the
|
|
1507
|
+
catalogue above.
|
|
1508
|
+
- Do not hardcode a colour, spacing, radius, shadow or font size that has
|
|
1509
|
+
a token.
|
|
1510
|
+
- Do not restyle a component inline to make it look like a different
|
|
1511
|
+
variant. Use the variant the system defines.
|
|
1512
|
+
`;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// ../codegen/dist/shared/motionPrune.js
|
|
1516
|
+
var CORE_MOTION = /* @__PURE__ */ new Set([
|
|
1517
|
+
"duration-fast",
|
|
1518
|
+
"duration-normal",
|
|
1519
|
+
"duration-slow",
|
|
1520
|
+
"ease-in",
|
|
1521
|
+
"ease-out",
|
|
1522
|
+
"ease-in-out",
|
|
1523
|
+
"motion-enter",
|
|
1524
|
+
"motion-exit",
|
|
1525
|
+
"motion-transition",
|
|
1526
|
+
"motion-scale",
|
|
1527
|
+
"motion-distance-sm",
|
|
1528
|
+
"motion-distance-md",
|
|
1529
|
+
"motion-distance-lg"
|
|
1530
|
+
]);
|
|
1531
|
+
var VAR_REF_RE = /var\(\s*--([a-z0-9-]+)/gi;
|
|
1532
|
+
function collectVarRefs(text, into) {
|
|
1533
|
+
for (const match of text.matchAll(VAR_REF_RE)) {
|
|
1534
|
+
into.add(match[1].toLowerCase());
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
function pruneUnusedMotionTokens(tokens, components = []) {
|
|
1538
|
+
const motion = tokens.motion ?? [];
|
|
1539
|
+
if (!motion.length)
|
|
1540
|
+
return tokens;
|
|
1541
|
+
const motionNames = /* @__PURE__ */ new Map();
|
|
1542
|
+
for (const t of motion) {
|
|
1543
|
+
if (t?.name)
|
|
1544
|
+
motionNames.set(t.name.toLowerCase(), t);
|
|
1545
|
+
}
|
|
1546
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
1547
|
+
const otherCategories = [
|
|
1548
|
+
tokens.colors,
|
|
1549
|
+
tokens.lightColors,
|
|
1550
|
+
tokens.spacing,
|
|
1551
|
+
tokens.typography,
|
|
1552
|
+
tokens.radius,
|
|
1553
|
+
tokens.shadows,
|
|
1554
|
+
tokens.border,
|
|
1555
|
+
tokens.effects,
|
|
1556
|
+
tokens.icons
|
|
1557
|
+
];
|
|
1558
|
+
for (const list of otherCategories) {
|
|
1559
|
+
for (const t of list ?? [])
|
|
1560
|
+
collectVarRefs(t?.value ?? "", referenced);
|
|
1561
|
+
}
|
|
1562
|
+
for (const c of components)
|
|
1563
|
+
collectVarRefs(c?.code ?? "", referenced);
|
|
1564
|
+
const keep = /* @__PURE__ */ new Set();
|
|
1565
|
+
const queue = [];
|
|
1566
|
+
for (const name of motionNames.keys()) {
|
|
1567
|
+
if (CORE_MOTION.has(name) || referenced.has(name)) {
|
|
1568
|
+
keep.add(name);
|
|
1569
|
+
queue.push(name);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
while (queue.length) {
|
|
1573
|
+
const token = motionNames.get(queue.pop());
|
|
1574
|
+
if (!token)
|
|
1575
|
+
continue;
|
|
1576
|
+
const refs = /* @__PURE__ */ new Set();
|
|
1577
|
+
collectVarRefs(token.value ?? "", refs);
|
|
1578
|
+
if (token.referencedTokenId)
|
|
1579
|
+
refs.add(token.referencedTokenId.toLowerCase());
|
|
1580
|
+
for (const ref of refs) {
|
|
1581
|
+
if (motionNames.has(ref) && !keep.has(ref)) {
|
|
1582
|
+
keep.add(ref);
|
|
1583
|
+
queue.push(ref);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
if (keep.size === motionNames.size)
|
|
1588
|
+
return tokens;
|
|
1589
|
+
return {
|
|
1590
|
+
...tokens,
|
|
1591
|
+
motion: motion.filter((t) => keep.has((t?.name ?? "").toLowerCase()))
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// ../codegen/dist/repo-token-diff.js
|
|
1596
|
+
var GROUP_SECTIONS = {
|
|
1597
|
+
spacing: "spacing",
|
|
1598
|
+
typography: "typography",
|
|
1599
|
+
borderRadius: "radius",
|
|
1600
|
+
shadow: "shadows"
|
|
1601
|
+
};
|
|
1602
|
+
var BUNDLE_SECTION = {
|
|
1603
|
+
colors: "design-tokens-dark-colors",
|
|
1604
|
+
lightColors: "design-tokens-light-colors",
|
|
1605
|
+
spacing: "design-tokens-spacing",
|
|
1606
|
+
typography: "design-tokens-typography",
|
|
1607
|
+
radius: "design-tokens-radius",
|
|
1608
|
+
shadows: "design-tokens-shadow"
|
|
1609
|
+
};
|
|
1610
|
+
function groupKey(group, name) {
|
|
1611
|
+
return tokensToCssVarName(name).replace(new RegExp(`^${group}-?`), "") || name;
|
|
1612
|
+
}
|
|
1613
|
+
var HEX = /^#[0-9a-f]{3,8}$/i;
|
|
1614
|
+
function same(a, b) {
|
|
1615
|
+
const x = a.trim();
|
|
1616
|
+
const y = b.trim();
|
|
1617
|
+
return HEX.test(x) && HEX.test(y) ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
1618
|
+
}
|
|
1619
|
+
var isReference = (value) => /^\{[^}]+\}$/.test(value.trim());
|
|
1620
|
+
function modesOf(entry) {
|
|
1621
|
+
const modes = entry.$extensions?.[FLASH_MODES_EXTENSION];
|
|
1622
|
+
return modes && typeof modes === "object" ? modes : null;
|
|
1623
|
+
}
|
|
1624
|
+
function themeValue(entry, theme, defaultTheme, web) {
|
|
1625
|
+
const value = typeof entry.$value === "string" ? entry.$value : void 0;
|
|
1626
|
+
if (theme === defaultTheme && web && value !== void 0 && typeof web.$value === "string" && !same(value, web.$value)) {
|
|
1627
|
+
return value;
|
|
1628
|
+
}
|
|
1629
|
+
const modes = modesOf(entry);
|
|
1630
|
+
if (modes) {
|
|
1631
|
+
const own = modes[theme];
|
|
1632
|
+
return typeof own === "string" ? own : void 0;
|
|
1633
|
+
}
|
|
1634
|
+
return value;
|
|
1635
|
+
}
|
|
1636
|
+
function parseTree(text) {
|
|
1637
|
+
try {
|
|
1638
|
+
const parsed = JSON.parse(text);
|
|
1639
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1640
|
+
} catch {
|
|
1641
|
+
return null;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
function diffRepoTokens(doc, repoDtcg) {
|
|
1645
|
+
const tokens = fromAtomicDocument(doc);
|
|
1646
|
+
const web = JSON.parse(generateJSON(tokens));
|
|
1647
|
+
const repo = parseTree(repoDtcg);
|
|
1648
|
+
const edits = [];
|
|
1649
|
+
const findings = [];
|
|
1650
|
+
if (!repo) {
|
|
1651
|
+
findings.push({
|
|
1652
|
+
path: "(file)",
|
|
1653
|
+
code: "unreadable",
|
|
1654
|
+
reason: "The tokens file is not a JSON object any more. Fix it, or pull again to restore it."
|
|
1655
|
+
});
|
|
1656
|
+
return { edits, findings };
|
|
1657
|
+
}
|
|
1658
|
+
const compare = (path, section, name, before, after, theme) => {
|
|
1659
|
+
if (before === void 0 || after === void 0)
|
|
1660
|
+
return;
|
|
1661
|
+
if (same(before, after))
|
|
1662
|
+
return;
|
|
1663
|
+
if (isReference(before) || isReference(after)) {
|
|
1664
|
+
findings.push({
|
|
1665
|
+
path,
|
|
1666
|
+
code: "alias",
|
|
1667
|
+
before,
|
|
1668
|
+
after,
|
|
1669
|
+
reason: "This token is an alias, or became one. Re-point aliases in Flash; a push only carries literal values."
|
|
1670
|
+
});
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
edits.push({
|
|
1674
|
+
path,
|
|
1675
|
+
section,
|
|
1676
|
+
name,
|
|
1677
|
+
...theme ? { theme } : {},
|
|
1678
|
+
before,
|
|
1679
|
+
after
|
|
1680
|
+
});
|
|
1681
|
+
};
|
|
1682
|
+
const presence = (group, webGroup) => {
|
|
1683
|
+
const repoGroup = repo[group] ?? {};
|
|
1684
|
+
for (const key of Object.keys(repoGroup)) {
|
|
1685
|
+
if (key.startsWith("$") || key in webGroup)
|
|
1686
|
+
continue;
|
|
1687
|
+
findings.push({
|
|
1688
|
+
path: `${group}.${key}`,
|
|
1689
|
+
code: "added-in-repo",
|
|
1690
|
+
reason: "Not in the design system. Add it in Flash, then pull; a push does not create tokens."
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
for (const key of Object.keys(webGroup)) {
|
|
1694
|
+
if (key in repoGroup)
|
|
1695
|
+
continue;
|
|
1696
|
+
findings.push({
|
|
1697
|
+
path: `${group}.${key}`,
|
|
1698
|
+
code: "removed-in-repo",
|
|
1699
|
+
reason: "Deleted from the file but still in the design system. A push does not remove tokens; delete it in Flash if that is the intent."
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
1703
|
+
const webColors = web.color ?? {};
|
|
1704
|
+
presence("color", webColors);
|
|
1705
|
+
const palette = singleThemePalette(tokens);
|
|
1706
|
+
const defaultTheme = palette === tokens.colors ? "dark" : "light";
|
|
1707
|
+
const names = {
|
|
1708
|
+
dark: new Map(tokens.colors.map((t) => [tokensToCssVarName(t.name), t])),
|
|
1709
|
+
light: new Map(tokens.lightColors.map((t) => [tokensToCssVarName(t.name), t]))
|
|
1710
|
+
};
|
|
1711
|
+
for (const [key, webEntry] of Object.entries(webColors)) {
|
|
1712
|
+
const repoEntry = repo.color?.[key];
|
|
1713
|
+
if (!repoEntry)
|
|
1714
|
+
continue;
|
|
1715
|
+
for (const theme of ["dark", "light"]) {
|
|
1716
|
+
const token = names[theme].get(key);
|
|
1717
|
+
if (!token)
|
|
1718
|
+
continue;
|
|
1719
|
+
compare(`color.${key}`, BUNDLE_SECTION[theme === "dark" ? "colors" : "lightColors"], token.name, themeValue(webEntry, theme, defaultTheme), themeValue(repoEntry, theme, defaultTheme, webEntry), theme);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
for (const [group, field] of Object.entries(GROUP_SECTIONS)) {
|
|
1723
|
+
const webGroup = web[group];
|
|
1724
|
+
if (!webGroup)
|
|
1725
|
+
continue;
|
|
1726
|
+
presence(group, webGroup);
|
|
1727
|
+
const byKey = new Map(tokens[field].map((t) => [groupKey(group, t.name), t]));
|
|
1728
|
+
for (const [key, webEntry] of Object.entries(webGroup)) {
|
|
1729
|
+
const repoEntry = repo[group]?.[key];
|
|
1730
|
+
const token = byKey.get(key);
|
|
1731
|
+
if (!repoEntry || !token)
|
|
1732
|
+
continue;
|
|
1733
|
+
if (typeof webEntry.$value !== "string")
|
|
1734
|
+
continue;
|
|
1735
|
+
if (typeof repoEntry.$value !== "string")
|
|
1736
|
+
continue;
|
|
1737
|
+
compare(`${group}.${key}`, BUNDLE_SECTION[field], token.name, webEntry.$value, repoEntry.$value);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return { edits, findings };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// ../codegen/dist/repo-bundle.js
|
|
1744
|
+
var DEFAULT_REPO_BUNDLE_PATHS = {
|
|
1745
|
+
tokensDtcg: "design-tokens.json",
|
|
1746
|
+
tokensCss: "tokens.css",
|
|
1747
|
+
styleDictionary: null,
|
|
1748
|
+
styleDictionaryConfig: null,
|
|
1749
|
+
agentsMd: "AGENTS.md"
|
|
1750
|
+
};
|
|
1751
|
+
function hashRepoFiles(files) {
|
|
1752
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
1753
|
+
const framed = sorted.map((f) => `${f.path.length}:${f.path}${f.content.length}:${f.content}`);
|
|
1754
|
+
return shortHash(`${framed.length}:${framed.join("")}`);
|
|
1755
|
+
}
|
|
1756
|
+
function documentToRepoBundle(doc, options = {}) {
|
|
1757
|
+
const paths = {
|
|
1758
|
+
...DEFAULT_REPO_BUNDLE_PATHS,
|
|
1759
|
+
...options.paths
|
|
1760
|
+
};
|
|
1761
|
+
const components = atomicComponentsToExportSpecs(doc);
|
|
1762
|
+
const tokens = pruneUnusedMotionTokens(fromAtomicDocument(doc), components);
|
|
1763
|
+
const projectName = options.projectName ?? doc.name;
|
|
1764
|
+
const files = [];
|
|
1765
|
+
if (paths.tokensDtcg) {
|
|
1766
|
+
files.push({ path: paths.tokensDtcg, content: generateJSON(tokens) });
|
|
1767
|
+
}
|
|
1768
|
+
if (paths.tokensCss) {
|
|
1769
|
+
files.push({
|
|
1770
|
+
path: paths.tokensCss,
|
|
1771
|
+
content: generateCSS(tokens, options.attribution === true)
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
if (paths.styleDictionary) {
|
|
1775
|
+
files.push({
|
|
1776
|
+
path: paths.styleDictionary,
|
|
1777
|
+
content: generateStyleDictionary(tokens)
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
if (paths.styleDictionaryConfig) {
|
|
1781
|
+
files.push({
|
|
1782
|
+
path: paths.styleDictionaryConfig,
|
|
1783
|
+
content: generateStyleDictionaryConfig()
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
if (paths.agentsMd) {
|
|
1787
|
+
files.push({
|
|
1788
|
+
path: paths.agentsMd,
|
|
1789
|
+
content: generateAgentsMd({
|
|
1790
|
+
projectName,
|
|
1791
|
+
tokens,
|
|
1792
|
+
components,
|
|
1793
|
+
...options.library ? { library: options.library } : {}
|
|
1794
|
+
})
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
return {
|
|
1798
|
+
files,
|
|
1799
|
+
hashes: tokenHashes(tokens),
|
|
1800
|
+
contentHash: hashRepoFiles(files)
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// src/document.ts
|
|
1805
|
+
var TYPES = new Set(TokenTypeSchema.options);
|
|
1806
|
+
var CATEGORIES = new Set(TokenCategorySchema.options);
|
|
1807
|
+
var CATEGORY_ALIASES = {
|
|
1808
|
+
color: "colors",
|
|
1809
|
+
shadows: "shadow",
|
|
1810
|
+
effect: "effects",
|
|
1811
|
+
icon: "icons",
|
|
1812
|
+
borders: "border",
|
|
1813
|
+
font: "typography",
|
|
1814
|
+
fonts: "typography"
|
|
1815
|
+
};
|
|
1816
|
+
var TYPE_FOR_CATEGORY = {
|
|
1817
|
+
colors: "color",
|
|
1818
|
+
spacing: "size",
|
|
1819
|
+
typography: "font",
|
|
1820
|
+
radius: "radius",
|
|
1821
|
+
shadow: "shadow",
|
|
1822
|
+
motion: "motion",
|
|
1823
|
+
border: "border",
|
|
1824
|
+
effects: "effect",
|
|
1825
|
+
icons: "icon"
|
|
1826
|
+
};
|
|
1827
|
+
function normaliseToken(token, fallbackCategory) {
|
|
1828
|
+
if (!token || typeof token !== "object" || Array.isArray(token)) return token;
|
|
1829
|
+
const t = { ...token };
|
|
1830
|
+
if (typeof t.category === "string" && !CATEGORIES.has(t.category)) {
|
|
1831
|
+
t.category = CATEGORY_ALIASES[t.category] ?? fallbackCategory ?? t.category;
|
|
1832
|
+
}
|
|
1833
|
+
if (typeof t.type === "string" && !TYPES.has(t.type)) {
|
|
1834
|
+
const category = typeof t.category === "string" && CATEGORIES.has(t.category) ? t.category : CATEGORY_ALIASES[t.type] ?? t.type;
|
|
1835
|
+
t.type = TYPE_FOR_CATEGORY[category] ?? t.type;
|
|
1836
|
+
}
|
|
1837
|
+
return t;
|
|
1838
|
+
}
|
|
1839
|
+
var FOUNDATION_CATEGORY = {
|
|
1840
|
+
colors: "colors",
|
|
1841
|
+
typography: "typography",
|
|
1842
|
+
spacing: "spacing",
|
|
1843
|
+
radius: "radius",
|
|
1844
|
+
shadows: "shadow",
|
|
1845
|
+
motion: "motion",
|
|
1846
|
+
border: "border",
|
|
1847
|
+
effects: "effects",
|
|
1848
|
+
icons: "icons"
|
|
1849
|
+
};
|
|
1850
|
+
function normaliseServerDocument(input) {
|
|
1851
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
|
|
1852
|
+
const doc = { ...input };
|
|
1853
|
+
const foundations = doc.foundations;
|
|
1854
|
+
if (foundations && typeof foundations === "object" && !Array.isArray(foundations)) {
|
|
1855
|
+
const next = {};
|
|
1856
|
+
for (const [key, list] of Object.entries(foundations)) {
|
|
1857
|
+
next[key] = Array.isArray(list) ? list.map((t) => normaliseToken(t, FOUNDATION_CATEGORY[key])) : list;
|
|
1858
|
+
}
|
|
1859
|
+
doc.foundations = next;
|
|
1860
|
+
}
|
|
1861
|
+
if (Array.isArray(doc.semantics)) {
|
|
1862
|
+
doc.semantics = doc.semantics.map((t) => normaliseToken(t));
|
|
1863
|
+
}
|
|
1864
|
+
return doc;
|
|
1865
|
+
}
|
|
1866
|
+
function parseServerDocument(input) {
|
|
1867
|
+
return parseFlashAtomicDocument(normaliseServerDocument(input));
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/tools/pull-from-web.ts
|
|
1871
|
+
import { join as join2, resolve as resolve4 } from "path";
|
|
1872
|
+
|
|
1873
|
+
// src/lock.ts
|
|
1874
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
1875
|
+
import { z } from "zod";
|
|
1876
|
+
var GENOME_LOCK_BASENAME = "genome.lock";
|
|
1877
|
+
var GenomeLockSchema = z.object({
|
|
1878
|
+
version: z.literal(1),
|
|
1879
|
+
genomeId: z.number().int().positive(),
|
|
1880
|
+
/** The web revision these files were projected from. */
|
|
1881
|
+
revision: z.union([z.string(), z.number()]),
|
|
1882
|
+
/** `documentToRepoBundle`'s hash over the files it wrote. */
|
|
1883
|
+
contentHash: z.string().min(1),
|
|
1884
|
+
/** ISO timestamp of the pull, for a human reading the file. */
|
|
1885
|
+
pulledAt: z.string().min(1),
|
|
1886
|
+
/** The paths the pull wrote, so a later drift check knows what to hash. */
|
|
1887
|
+
files: z.array(z.string().min(1)).default([]),
|
|
1888
|
+
/**
|
|
1889
|
+
* Each written file's own hash, so a drift check can say *which* files
|
|
1890
|
+
* moved rather than only that something did. Empty in locks written before
|
|
1891
|
+
* it existed; those still answer the yes-or-no question through
|
|
1892
|
+
* `contentHash`.
|
|
1893
|
+
*/
|
|
1894
|
+
fileHashes: z.record(z.string(), z.string()).default({})
|
|
1895
|
+
});
|
|
1896
|
+
function renderLock(lock) {
|
|
1897
|
+
return `${JSON.stringify(lock, null, 2)}
|
|
1898
|
+
`;
|
|
1899
|
+
}
|
|
1900
|
+
async function readLock(path) {
|
|
1901
|
+
const raw = await readFile2(path, "utf8").catch(() => null);
|
|
1902
|
+
if (!raw) return null;
|
|
1903
|
+
try {
|
|
1904
|
+
const parsed = GenomeLockSchema.safeParse(JSON.parse(raw));
|
|
1905
|
+
return parsed.success ? parsed.data : null;
|
|
1906
|
+
} catch {
|
|
1907
|
+
return null;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// src/write-plan.ts
|
|
1912
|
+
import { mkdir, readFile as readFile3, realpath, writeFile } from "fs/promises";
|
|
1913
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join, relative, resolve as resolve3 } from "path";
|
|
1914
|
+
var MAX_HUNKS = 20;
|
|
1915
|
+
function hunks(before, after) {
|
|
1916
|
+
const a = before.split("\n");
|
|
1917
|
+
const b = after.split("\n");
|
|
1918
|
+
let start = 0;
|
|
1919
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
1920
|
+
let endA = a.length;
|
|
1921
|
+
let endB = b.length;
|
|
1922
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
1923
|
+
endA--;
|
|
1924
|
+
endB--;
|
|
1925
|
+
}
|
|
1926
|
+
const removed = a.slice(start, endA);
|
|
1927
|
+
const added = b.slice(start, endB);
|
|
1928
|
+
const out = [];
|
|
1929
|
+
for (const line of removed.slice(0, MAX_HUNKS)) out.push(`- ${line}`);
|
|
1930
|
+
if (removed.length > MAX_HUNKS) {
|
|
1931
|
+
out.push(`\u2026 ${removed.length - MAX_HUNKS} more removed`);
|
|
1932
|
+
}
|
|
1933
|
+
for (const line of added.slice(0, MAX_HUNKS)) out.push(`+ ${line}`);
|
|
1934
|
+
if (added.length > MAX_HUNKS) {
|
|
1935
|
+
out.push(`\u2026 ${added.length - MAX_HUNKS} more added`);
|
|
1936
|
+
}
|
|
1937
|
+
return out.join("\n");
|
|
1938
|
+
}
|
|
1939
|
+
function safeJoin(root, path) {
|
|
1940
|
+
if (isAbsolute2(path)) return null;
|
|
1941
|
+
const full = resolve3(join(root, path));
|
|
1942
|
+
const rel = relative(resolve3(root), full);
|
|
1943
|
+
if (rel.startsWith("..") || isAbsolute2(rel)) return null;
|
|
1944
|
+
return full;
|
|
1945
|
+
}
|
|
1946
|
+
async function containedAfterLinks(root, full) {
|
|
1947
|
+
let dir = dirname2(full);
|
|
1948
|
+
let realDir = null;
|
|
1949
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
1950
|
+
realDir = await realpath(dir).catch(() => null);
|
|
1951
|
+
if (realDir) break;
|
|
1952
|
+
const parent = dirname2(dir);
|
|
1953
|
+
if (parent === dir) break;
|
|
1954
|
+
dir = parent;
|
|
1955
|
+
}
|
|
1956
|
+
if (!realDir) return true;
|
|
1957
|
+
const realRoot = await realpath(root).catch(() => null) ?? resolve3(root);
|
|
1958
|
+
const rel = relative(realRoot, join(realDir, relative(dir, full)));
|
|
1959
|
+
return !rel.startsWith("..") && !isAbsolute2(rel);
|
|
1960
|
+
}
|
|
1961
|
+
async function writeFiles(files, opts) {
|
|
1962
|
+
const root = resolve3(opts.workspaceRoot);
|
|
1963
|
+
const confirm = opts.confirm === true;
|
|
1964
|
+
const entries = [];
|
|
1965
|
+
const written = [];
|
|
1966
|
+
for (const file of files) {
|
|
1967
|
+
const full = safeJoin(root, file.path);
|
|
1968
|
+
if (full === null) {
|
|
1969
|
+
entries.push({
|
|
1970
|
+
action: "flag",
|
|
1971
|
+
targetKind: "file",
|
|
1972
|
+
path: file.path,
|
|
1973
|
+
code: "outside-workspace",
|
|
1974
|
+
reason: "This path leaves the workspace root, so Genome will not write it. Fix the outputs entry in genome.config.json."
|
|
1975
|
+
});
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
if (!await containedAfterLinks(root, full)) {
|
|
1979
|
+
entries.push({
|
|
1980
|
+
action: "flag",
|
|
1981
|
+
targetKind: "file",
|
|
1982
|
+
path: file.path,
|
|
1983
|
+
code: "outside-workspace",
|
|
1984
|
+
reason: "A directory on this path is a link out of the workspace, so Genome will not write through it."
|
|
1985
|
+
});
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
const before = await readFile3(full, "utf8").catch(() => null);
|
|
1989
|
+
if (before !== null && file.createOnly) {
|
|
1990
|
+
entries.push({
|
|
1991
|
+
action: "skip",
|
|
1992
|
+
targetKind: "file",
|
|
1993
|
+
path: file.path,
|
|
1994
|
+
reason: "Written once, then yours to edit. Genome leaves it alone."
|
|
1995
|
+
});
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
if (before === file.content) {
|
|
1999
|
+
entries.push({ action: "skip", targetKind: "file", path: file.path });
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
entries.push({
|
|
2003
|
+
action: before === null ? "create" : "update",
|
|
2004
|
+
targetKind: "file",
|
|
2005
|
+
path: file.path,
|
|
2006
|
+
...before === null ? {} : { reason: hunks(before, file.content) }
|
|
2007
|
+
});
|
|
2008
|
+
if (!confirm) continue;
|
|
2009
|
+
try {
|
|
2010
|
+
await mkdir(dirname2(full), { recursive: true });
|
|
2011
|
+
await writeFile(full, file.content, "utf8");
|
|
2012
|
+
written.push(file.path);
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
entries[entries.length - 1] = {
|
|
2015
|
+
action: "flag",
|
|
2016
|
+
targetKind: "file",
|
|
2017
|
+
path: file.path,
|
|
2018
|
+
code: "write-failed",
|
|
2019
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return { ...makeChangePlan(entries, !confirm), written };
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// src/tools/pull-from-web.ts
|
|
2027
|
+
function inDir(dir, name) {
|
|
2028
|
+
return `${dir.replace(/\/+$/, "")}/${name}`;
|
|
2029
|
+
}
|
|
2030
|
+
function parseIconFiles(text) {
|
|
2031
|
+
if (!text?.trim()) return {};
|
|
2032
|
+
const parsed = JSON.parse(text);
|
|
2033
|
+
const files = parsed?.files;
|
|
2034
|
+
if (!files || typeof files !== "object" || Array.isArray(files)) return {};
|
|
2035
|
+
const out = {};
|
|
2036
|
+
for (const [path, content] of Object.entries(files)) {
|
|
2037
|
+
if (typeof content === "string") out[path] = content;
|
|
2038
|
+
}
|
|
2039
|
+
return out;
|
|
2040
|
+
}
|
|
2041
|
+
function iconFilePath(iconsDir, serverPath) {
|
|
2042
|
+
const relative2 = serverPath.replace(/^icons\//, "");
|
|
2043
|
+
if (!relative2 || relative2.startsWith("/") || relative2.includes("\\") || relative2.split("/").some((segment) => segment === ".." || segment === "")) {
|
|
2044
|
+
return null;
|
|
2045
|
+
}
|
|
2046
|
+
return inDir(iconsDir, relative2);
|
|
2047
|
+
}
|
|
2048
|
+
async function pullFromWeb(opts) {
|
|
2049
|
+
const envelope = await opts.client.getGenome();
|
|
2050
|
+
if (!envelope?.document) {
|
|
2051
|
+
throw new Error(
|
|
2052
|
+
"This design system has no v2 document to project. Open it in Flash and save once, then pull again."
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
if (envelope.id !== opts.config.genomeId) {
|
|
2056
|
+
throw new Error(
|
|
2057
|
+
`This token belongs to design system #${envelope.id}, but genome.config.json names #${opts.config.genomeId}. Run link again, or use the token for #${opts.config.genomeId}.`
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
const doc = parseServerDocument(envelope.document);
|
|
2061
|
+
const { outputs } = opts.config;
|
|
2062
|
+
const bundle = documentToRepoBundle(doc, {
|
|
2063
|
+
projectName: envelope.name,
|
|
2064
|
+
paths: {
|
|
2065
|
+
tokensDtcg: outputs.tokensDtcg,
|
|
2066
|
+
tokensCss: outputs.tokensCss,
|
|
2067
|
+
// The Style Dictionary source and AGENTS.md are not part of the token
|
|
2068
|
+
// projection: the first is opt-in, and the second comes from the
|
|
2069
|
+
// server so the repo and the hosted kit say the same thing.
|
|
2070
|
+
styleDictionary: null,
|
|
2071
|
+
styleDictionaryConfig: null,
|
|
2072
|
+
agentsMd: null
|
|
2073
|
+
}
|
|
2074
|
+
});
|
|
2075
|
+
const files = bundle.files.map((f) => ({
|
|
2076
|
+
path: f.path,
|
|
2077
|
+
content: f.content
|
|
2078
|
+
}));
|
|
2079
|
+
const dir = outputs.genomeDir;
|
|
2080
|
+
files.push({
|
|
2081
|
+
path: inDir(dir, "components.json"),
|
|
2082
|
+
content: `${JSON.stringify(
|
|
2083
|
+
doc.components.map((c) => ({
|
|
2084
|
+
id: c.id,
|
|
2085
|
+
name: c.name,
|
|
2086
|
+
category: c.category,
|
|
2087
|
+
...c.description ? { description: c.description } : {},
|
|
2088
|
+
variants: c.variants.map((v) => v.name),
|
|
2089
|
+
states: c.states
|
|
2090
|
+
})),
|
|
2091
|
+
null,
|
|
2092
|
+
2
|
|
2093
|
+
)}
|
|
2094
|
+
`
|
|
2095
|
+
});
|
|
2096
|
+
const kit = await opts.client.readResource("flash://design-system/kit.md").catch(() => null);
|
|
2097
|
+
if (kit) files.push({ path: inDir(dir, "kit.md"), content: kit });
|
|
2098
|
+
const componentDocs = await opts.client.readResource("flash://design-system/components.md").catch(() => null);
|
|
2099
|
+
if (componentDocs && componentDocs.trim()) {
|
|
2100
|
+
files.push({ path: inDir(dir, "components.md"), content: componentDocs });
|
|
2101
|
+
}
|
|
2102
|
+
const iconFiles = await opts.client.readResource("flash://design-system/icon-files.json").then(parseIconFiles).catch(() => ({}));
|
|
2103
|
+
const iconsDir = opts.config.outputs.icons ?? inDir(dir, "icons");
|
|
2104
|
+
for (const [serverPath, content] of Object.entries(iconFiles)) {
|
|
2105
|
+
const local = iconFilePath(iconsDir, serverPath);
|
|
2106
|
+
if (local) files.push({ path: local, content });
|
|
2107
|
+
}
|
|
2108
|
+
const lockPath = inDir(dir, GENOME_LOCK_BASENAME);
|
|
2109
|
+
const previous = await readLock(join2(resolve4(opts.workspaceRoot), lockPath));
|
|
2110
|
+
const contentHash = hashRepoFiles(
|
|
2111
|
+
files.map((f) => ({ path: f.path, content: f.content }))
|
|
2112
|
+
);
|
|
2113
|
+
const result = await writeFiles(files, {
|
|
2114
|
+
workspaceRoot: opts.workspaceRoot,
|
|
2115
|
+
confirm: opts.confirm
|
|
2116
|
+
});
|
|
2117
|
+
const accepted = new Set(
|
|
2118
|
+
result.entries.filter((e) => e.action !== "flag").map((e) => e.path)
|
|
2119
|
+
);
|
|
2120
|
+
const sameFiles = previous !== null && previous.files.length === accepted.size && previous.files.every((f) => accepted.has(f));
|
|
2121
|
+
const unchanged = sameFiles && previous.contentHash === contentHash && String(previous.revision) === String(envelope.revision);
|
|
2122
|
+
const fileHashes = {};
|
|
2123
|
+
for (const f of files) {
|
|
2124
|
+
if (accepted.has(f.path)) {
|
|
2125
|
+
fileHashes[f.path] = hashRepoFiles([
|
|
2126
|
+
{ path: f.path, content: f.content }
|
|
2127
|
+
]);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
const lock = {
|
|
2131
|
+
version: 1,
|
|
2132
|
+
genomeId: envelope.id,
|
|
2133
|
+
revision: envelope.revision,
|
|
2134
|
+
contentHash,
|
|
2135
|
+
pulledAt: unchanged ? previous.pulledAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
2136
|
+
files: [...accepted],
|
|
2137
|
+
fileHashes
|
|
2138
|
+
};
|
|
2139
|
+
const lockResult = await writeFiles(
|
|
2140
|
+
[{ path: lockPath, content: renderLock(lock) }],
|
|
2141
|
+
{
|
|
2142
|
+
workspaceRoot: opts.workspaceRoot,
|
|
2143
|
+
confirm: opts.confirm
|
|
2144
|
+
}
|
|
2145
|
+
);
|
|
2146
|
+
let recorded = false;
|
|
2147
|
+
const codebase = opts.config.codebase;
|
|
2148
|
+
if (opts.confirm === true && codebase) {
|
|
2149
|
+
const tokenFilesLanded = bundle.files.some((f) => accepted.has(f.path));
|
|
2150
|
+
const componentsLanded = accepted.has(inDir(dir, "components.json"));
|
|
2151
|
+
const items = [
|
|
2152
|
+
...componentsLanded ? [
|
|
2153
|
+
...doc.components.map((c) => ({
|
|
2154
|
+
type: "component",
|
|
2155
|
+
id: c.id,
|
|
2156
|
+
name: c.name
|
|
2157
|
+
})),
|
|
2158
|
+
...doc.modules.map((m) => ({
|
|
2159
|
+
type: "module",
|
|
2160
|
+
id: m.id,
|
|
2161
|
+
name: m.name
|
|
2162
|
+
}))
|
|
2163
|
+
] : [],
|
|
2164
|
+
// The token projection is pulled whole; one event stands for it.
|
|
2165
|
+
...tokenFilesLanded ? [{ type: "token", name: "all" }] : []
|
|
2166
|
+
];
|
|
2167
|
+
if (items.length) {
|
|
2168
|
+
try {
|
|
2169
|
+
await opts.client.callTool("record_pull", {
|
|
2170
|
+
codebase_id: codebase.id,
|
|
2171
|
+
codebase_name: codebase.name,
|
|
2172
|
+
revision: envelope.revision,
|
|
2173
|
+
items
|
|
2174
|
+
});
|
|
2175
|
+
recorded = true;
|
|
2176
|
+
} catch {
|
|
2177
|
+
recorded = false;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
const entries = [...result.entries, ...lockResult.entries];
|
|
2182
|
+
return {
|
|
2183
|
+
...makeChangePlan(entries, opts.confirm !== true),
|
|
2184
|
+
written: [...result.written, ...lockResult.written],
|
|
2185
|
+
genomeId: envelope.id,
|
|
2186
|
+
name: envelope.name,
|
|
2187
|
+
revision: envelope.revision,
|
|
2188
|
+
contentHash,
|
|
2189
|
+
recorded
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// src/tools/detect-drift.ts
|
|
2194
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2195
|
+
import { join as join4, resolve as resolve6 } from "path";
|
|
2196
|
+
|
|
2197
|
+
// src/tools/repo-state.ts
|
|
2198
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
2199
|
+
import { join as join3, resolve as resolve5 } from "path";
|
|
2200
|
+
function lockPathFor(config) {
|
|
2201
|
+
return `${config.outputs.genomeDir.replace(/\/+$/, "")}/${GENOME_LOCK_BASENAME}`;
|
|
2202
|
+
}
|
|
2203
|
+
async function readRepoState(opts) {
|
|
2204
|
+
const envelope = await opts.client.getGenome();
|
|
2205
|
+
if (!envelope?.document) {
|
|
2206
|
+
throw new Error(
|
|
2207
|
+
"This design system has no v2 document to compare against. Open it in Flash and save once, then try again."
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
if (envelope.id !== opts.config.genomeId) {
|
|
2211
|
+
throw new Error(
|
|
2212
|
+
`This token belongs to design system #${envelope.id}, but genome.config.json names #${opts.config.genomeId}. Run link again, or use the token for #${opts.config.genomeId}.`
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
const root = resolve5(opts.workspaceRoot);
|
|
2216
|
+
const lock = await readLock(join3(root, lockPathFor(opts.config)));
|
|
2217
|
+
const tokensPath = opts.config.outputs.tokensDtcg;
|
|
2218
|
+
const tokensText = await readFile4(join3(root, tokensPath), "utf8").catch(
|
|
2219
|
+
() => null
|
|
2220
|
+
);
|
|
2221
|
+
return {
|
|
2222
|
+
envelope,
|
|
2223
|
+
doc: parseServerDocument(envelope.document),
|
|
2224
|
+
lock,
|
|
2225
|
+
tokensPath,
|
|
2226
|
+
tokensText
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// src/tools/detect-drift.ts
|
|
2231
|
+
async function detectDrift(opts) {
|
|
2232
|
+
const state = await readRepoState(opts);
|
|
2233
|
+
const { envelope, lock } = state;
|
|
2234
|
+
if (!lock) {
|
|
2235
|
+
return {
|
|
2236
|
+
...makeChangePlan([], true),
|
|
2237
|
+
genomeId: envelope.id,
|
|
2238
|
+
local: { drifted: false, files: [], missing: [] },
|
|
2239
|
+
remote: {
|
|
2240
|
+
drifted: false,
|
|
2241
|
+
pulledRevision: null,
|
|
2242
|
+
revision: envelope.revision
|
|
2243
|
+
},
|
|
2244
|
+
pushable: 0,
|
|
2245
|
+
summary: "This repo has never pulled, so there is nothing to drift from. Run pull_from_web first."
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
const root = resolve6(opts.workspaceRoot);
|
|
2249
|
+
const current = [];
|
|
2250
|
+
const moved = [];
|
|
2251
|
+
const missing = [];
|
|
2252
|
+
for (const path of lock.files) {
|
|
2253
|
+
const content = await readFile5(join4(root, path), "utf8").catch(() => null);
|
|
2254
|
+
if (content === null) {
|
|
2255
|
+
missing.push(path);
|
|
2256
|
+
continue;
|
|
2257
|
+
}
|
|
2258
|
+
current.push({ path, content });
|
|
2259
|
+
const recorded = lock.fileHashes[path];
|
|
2260
|
+
if (recorded && recorded !== hashRepoFiles([{ path, content }])) {
|
|
2261
|
+
moved.push(path);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
const localDrifted = missing.length > 0 || hashRepoFiles(current) !== lock.contentHash;
|
|
2265
|
+
const remoteDrifted = String(lock.revision) !== String(envelope.revision);
|
|
2266
|
+
const entries = [];
|
|
2267
|
+
let pushable = 0;
|
|
2268
|
+
if (state.tokensText !== null) {
|
|
2269
|
+
const diff = diffRepoTokens(state.doc, state.tokensText);
|
|
2270
|
+
pushable = diff.edits.length;
|
|
2271
|
+
for (const edit of diff.edits) {
|
|
2272
|
+
entries.push({
|
|
2273
|
+
action: "flag",
|
|
2274
|
+
targetKind: "token",
|
|
2275
|
+
path: edit.theme ? `${edit.path} (${edit.theme})` : edit.path,
|
|
2276
|
+
before: edit.before,
|
|
2277
|
+
after: edit.after,
|
|
2278
|
+
code: "repo-edit",
|
|
2279
|
+
reason: "Changed in the repo. push_to_web proposes it for review in Flash."
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
for (const finding of diff.findings) {
|
|
2283
|
+
entries.push({
|
|
2284
|
+
action: "flag",
|
|
2285
|
+
targetKind: "token",
|
|
2286
|
+
path: finding.path,
|
|
2287
|
+
before: finding.before ?? null,
|
|
2288
|
+
after: finding.after ?? null,
|
|
2289
|
+
code: finding.code,
|
|
2290
|
+
reason: finding.reason
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
let summary;
|
|
2295
|
+
if (!localDrifted && !remoteDrifted) {
|
|
2296
|
+
summary = "In step: the repo holds exactly what it pulled, and Flash has not moved.";
|
|
2297
|
+
} else if (remoteDrifted && localDrifted) {
|
|
2298
|
+
summary = `Both moved: Flash is at revision ${envelope.revision}, the repo pulled ${lock.revision}, and the repo has edits of its own. A push is refused while Flash is ahead, because it cannot tell your edits from Flash's. Keep a copy of the flagged values, pull, re-apply them, then push.`;
|
|
2299
|
+
} else if (remoteDrifted) {
|
|
2300
|
+
summary = `Flash moved from revision ${lock.revision} to ${envelope.revision}. Run pull_from_web to bring the repo up to date.`;
|
|
2301
|
+
} else if (pushable > 0) {
|
|
2302
|
+
summary = `The repo changed ${pushable} token value${pushable === 1 ? "" : "s"}. Run push_to_web to propose ${pushable === 1 ? "it" : "them"} for review in Flash.`;
|
|
2303
|
+
} else {
|
|
2304
|
+
summary = "The repo's files changed, but no token value a push can carry. See the flagged entries.";
|
|
2305
|
+
}
|
|
2306
|
+
return {
|
|
2307
|
+
...makeChangePlan(entries, true),
|
|
2308
|
+
genomeId: envelope.id,
|
|
2309
|
+
local: { drifted: localDrifted, files: moved, missing },
|
|
2310
|
+
remote: {
|
|
2311
|
+
drifted: remoteDrifted,
|
|
2312
|
+
pulledRevision: lock.revision,
|
|
2313
|
+
revision: envelope.revision
|
|
2314
|
+
},
|
|
2315
|
+
pushable,
|
|
2316
|
+
summary
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// src/tools/push-to-web.ts
|
|
2321
|
+
function findingEntry(finding) {
|
|
2322
|
+
return {
|
|
2323
|
+
action: "flag",
|
|
2324
|
+
targetKind: "token",
|
|
2325
|
+
path: finding.path,
|
|
2326
|
+
before: finding.before ?? null,
|
|
2327
|
+
after: finding.after ?? null,
|
|
2328
|
+
code: finding.code,
|
|
2329
|
+
reason: finding.reason
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
async function pushToWeb(opts) {
|
|
2333
|
+
const state = await readRepoState(opts);
|
|
2334
|
+
const { envelope, lock } = state;
|
|
2335
|
+
if (!lock) {
|
|
2336
|
+
throw new Error(
|
|
2337
|
+
"This repo has never pulled, so there is no baseline to push from. Run pull_from_web first."
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
if (String(lock.revision) !== String(envelope.revision)) {
|
|
2341
|
+
throw new Error(
|
|
2342
|
+
`Flash has moved on since this repo pulled (revision ${lock.revision}, now ${envelope.revision}). A push now could undo those changes. Keep a copy of your edits, pull, re-apply them, then push.`
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2345
|
+
if (state.tokensText === null) {
|
|
2346
|
+
throw new Error(
|
|
2347
|
+
`${state.tokensPath} is missing, so there is nothing to push. Pull again to restore it.`
|
|
2348
|
+
);
|
|
2349
|
+
}
|
|
2350
|
+
const { edits, findings } = diffRepoTokens(state.doc, state.tokensText);
|
|
2351
|
+
const flagged = findings.map(findingEntry);
|
|
2352
|
+
const base = {
|
|
2353
|
+
genomeId: envelope.id,
|
|
2354
|
+
revision: envelope.revision,
|
|
2355
|
+
findings
|
|
2356
|
+
};
|
|
2357
|
+
if (!edits.length) {
|
|
2358
|
+
return {
|
|
2359
|
+
...makeChangePlan(flagged, true),
|
|
2360
|
+
...base,
|
|
2361
|
+
status: "no_changes",
|
|
2362
|
+
changeSetId: null,
|
|
2363
|
+
message: findings.length ? "No token value changed. The flagged entries are things a push does not carry." : "No token value changed since the last pull."
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
const planned = edits.map((edit) => ({
|
|
2367
|
+
action: "update",
|
|
2368
|
+
targetKind: "token",
|
|
2369
|
+
path: edit.theme ? `${edit.path} (${edit.theme})` : edit.path,
|
|
2370
|
+
before: edit.before,
|
|
2371
|
+
after: edit.after
|
|
2372
|
+
}));
|
|
2373
|
+
if (opts.confirm !== true) {
|
|
2374
|
+
return {
|
|
2375
|
+
...makeChangePlan([...planned, ...flagged], true),
|
|
2376
|
+
...base,
|
|
2377
|
+
status: "dry_run",
|
|
2378
|
+
changeSetId: null,
|
|
2379
|
+
message: `${edits.length} token value${edits.length === 1 ? "" : "s"} to propose for review. Run again with confirm to open the change set in Flash.`
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
const codebase = opts.config.codebase?.name ?? opts.config.name;
|
|
2383
|
+
const response = await opts.client.callTool(
|
|
2384
|
+
"propose_change_set",
|
|
2385
|
+
{
|
|
2386
|
+
base_revision: envelope.revision,
|
|
2387
|
+
source_label: `Genome \xB7 ${codebase}`,
|
|
2388
|
+
changes: edits.map((edit) => ({
|
|
2389
|
+
section: edit.section,
|
|
2390
|
+
name: edit.name,
|
|
2391
|
+
value: edit.after
|
|
2392
|
+
}))
|
|
2393
|
+
}
|
|
2394
|
+
);
|
|
2395
|
+
const skipped = (response.skipped ?? []).map((s) => ({
|
|
2396
|
+
action: "flag",
|
|
2397
|
+
targetKind: "token",
|
|
2398
|
+
path: `${s.section}/${s.name}`,
|
|
2399
|
+
code: "skipped-by-server",
|
|
2400
|
+
reason: s.reason
|
|
2401
|
+
}));
|
|
2402
|
+
const proposed = (response.diff ?? []).map(tokenChangeToPlanEntry);
|
|
2403
|
+
return {
|
|
2404
|
+
...makeChangePlan([...proposed, ...skipped, ...flagged], false),
|
|
2405
|
+
...base,
|
|
2406
|
+
status: response.no_changes ? "no_changes" : "proposed",
|
|
2407
|
+
changeSetId: response.change_set_id ?? null,
|
|
2408
|
+
message: response.message ?? (response.no_changes ? "Flash already has these values, or the same proposal is waiting for review." : "Proposed for review. Open Sync in Flash to apply or discard it.")
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// src/tools/graph.ts
|
|
2413
|
+
async function graphDocument(client) {
|
|
2414
|
+
const envelope = await client.getGenome();
|
|
2415
|
+
if (!envelope?.document) {
|
|
2416
|
+
throw new Error(
|
|
2417
|
+
"These tools read the v2 document, and this system is not on schema v2 yet. Use get_tokens instead."
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
return normaliseServerDocument(envelope.document);
|
|
2421
|
+
}
|
|
2422
|
+
async function runGraphTool(client, tool, args) {
|
|
2423
|
+
const doc = await graphDocument(client);
|
|
2424
|
+
switch (tool) {
|
|
2425
|
+
case "list_modules":
|
|
2426
|
+
return { modules: listModules(doc) };
|
|
2427
|
+
case "get_module": {
|
|
2428
|
+
const wanted = args.module?.trim();
|
|
2429
|
+
if (!wanted) throw new Error("Missing required argument: module");
|
|
2430
|
+
const found = getModule(doc, wanted);
|
|
2431
|
+
if (!found.found) {
|
|
2432
|
+
const shown = found.available.slice(0, 30).join(", ") || "none";
|
|
2433
|
+
throw new Error(`Module '${wanted}' not found. Available: ${shown}`);
|
|
2434
|
+
}
|
|
2435
|
+
return found.module;
|
|
2436
|
+
}
|
|
2437
|
+
case "get_relations": {
|
|
2438
|
+
const ref = args.token?.trim() || void 0;
|
|
2439
|
+
const relations = getRelations(doc, ref);
|
|
2440
|
+
if (ref !== void 0 && "found" in relations && !relations.found) {
|
|
2441
|
+
throw new Error(`Token '${ref}' not found in this system.`);
|
|
2442
|
+
}
|
|
2443
|
+
return relations;
|
|
2444
|
+
}
|
|
2445
|
+
case "get_impact": {
|
|
2446
|
+
const wanted = args.token?.trim();
|
|
2447
|
+
if (!wanted) throw new Error("Missing required argument: token");
|
|
2448
|
+
const impact = getImpact(doc, wanted);
|
|
2449
|
+
if (!impact.found) {
|
|
2450
|
+
throw new Error(`Token '${wanted}' not found in this system.`);
|
|
2451
|
+
}
|
|
2452
|
+
return impact;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
// src/tools/sync-context.ts
|
|
2458
|
+
var CREATE_ONLY = true;
|
|
2459
|
+
async function syncContext(opts) {
|
|
2460
|
+
const { outputs } = opts.config;
|
|
2461
|
+
const files = [];
|
|
2462
|
+
const agents = await opts.client.readResource("flash://design-system/AGENTS.md").catch(() => null);
|
|
2463
|
+
if (agents) {
|
|
2464
|
+
files.push({
|
|
2465
|
+
path: outputs.agentsMd,
|
|
2466
|
+
content: agents,
|
|
2467
|
+
createOnly: CREATE_ONLY
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
const rules = await opts.client.callTool("get_agent_rules").catch(() => null);
|
|
2471
|
+
if (typeof rules === "string" && rules.trim()) {
|
|
2472
|
+
files.push({
|
|
2473
|
+
path: outputs.cursorRules,
|
|
2474
|
+
content: ensureTrailingNewline(rules)
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
const skill = await opts.client.readResource("flash://design-system/SKILL.md").catch(() => null);
|
|
2478
|
+
if (skill) {
|
|
2479
|
+
files.push({
|
|
2480
|
+
path: `${outputs.genomeDir.replace(/\/+$/, "")}/SKILL.md`,
|
|
2481
|
+
content: skill
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
if (!files.length) {
|
|
2485
|
+
throw new Error(
|
|
2486
|
+
"The server returned no agent context to write. Check the token is still valid and the design system has been saved at least once."
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
return writeFiles(files, {
|
|
2490
|
+
workspaceRoot: opts.workspaceRoot,
|
|
2491
|
+
confirm: opts.confirm
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
function ensureTrailingNewline(text) {
|
|
2495
|
+
return text.endsWith("\n") ? text : `${text}
|
|
2496
|
+
`;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// src/workspace.ts
|
|
2500
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
2501
|
+
import { resolve as resolve7 } from "path";
|
|
2502
|
+
|
|
2503
|
+
// src/rpc.ts
|
|
2504
|
+
var TOOL_ERROR = -32e3;
|
|
2505
|
+
var RpcError = class extends Error {
|
|
2506
|
+
constructor(message, code, data) {
|
|
2507
|
+
super(message);
|
|
2508
|
+
this.code = code;
|
|
2509
|
+
this.data = data;
|
|
2510
|
+
this.name = "RpcError";
|
|
2511
|
+
}
|
|
2512
|
+
code;
|
|
2513
|
+
data;
|
|
2514
|
+
};
|
|
2515
|
+
function unwrapToolResult(result) {
|
|
2516
|
+
if (typeof result !== "object" || result === null) return result;
|
|
2517
|
+
const envelope = result;
|
|
2518
|
+
const text = envelope.content?.find((c) => c.type === "text")?.text;
|
|
2519
|
+
if (text === void 0) return result;
|
|
2520
|
+
if (envelope.isError) throw new RpcError(text, TOOL_ERROR);
|
|
2521
|
+
try {
|
|
2522
|
+
return JSON.parse(text);
|
|
2523
|
+
} catch {
|
|
2524
|
+
return text;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
function createRpcClient(opts) {
|
|
2528
|
+
const baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
2529
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
2530
|
+
let nextId = 1;
|
|
2531
|
+
async function rpc(method, params = {}) {
|
|
2532
|
+
const res = await doFetch(`${baseUrl}/api/v1/mcp`, {
|
|
2533
|
+
method: "POST",
|
|
2534
|
+
headers: {
|
|
2535
|
+
"Content-Type": "application/json",
|
|
2536
|
+
Accept: "application/json, text/event-stream",
|
|
2537
|
+
Authorization: `Bearer ${opts.token}`
|
|
2538
|
+
},
|
|
2539
|
+
body: JSON.stringify({
|
|
2540
|
+
jsonrpc: "2.0",
|
|
2541
|
+
id: nextId++,
|
|
2542
|
+
method,
|
|
2543
|
+
params
|
|
2544
|
+
})
|
|
2545
|
+
});
|
|
2546
|
+
const text = await res.text();
|
|
2547
|
+
if (!res.ok) {
|
|
2548
|
+
const hint = res.status === 401 ? "The MCP token was rejected. Mint a new one in Flash under Connect, and set FLASH_GENOME_TOKEN." : `HTTP ${res.status}`;
|
|
2549
|
+
throw new RpcError(hint, res.status, text.slice(0, 500));
|
|
2550
|
+
}
|
|
2551
|
+
let body;
|
|
2552
|
+
try {
|
|
2553
|
+
body = JSON.parse(text);
|
|
2554
|
+
} catch {
|
|
2555
|
+
throw new RpcError(
|
|
2556
|
+
"The server did not answer with JSON-RPC",
|
|
2557
|
+
-32700,
|
|
2558
|
+
text.slice(0, 500)
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
if (body.error) {
|
|
2562
|
+
throw new RpcError(body.error.message, body.error.code, body.error.data);
|
|
2563
|
+
}
|
|
2564
|
+
return body.result;
|
|
2565
|
+
}
|
|
2566
|
+
async function callTool(name, args = {}) {
|
|
2567
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
2568
|
+
return unwrapToolResult(result);
|
|
2569
|
+
}
|
|
2570
|
+
async function readResource(uri) {
|
|
2571
|
+
const result = await rpc("resources/read", { uri });
|
|
2572
|
+
const text = result?.contents?.[0]?.text;
|
|
2573
|
+
if (typeof text !== "string") {
|
|
2574
|
+
throw new RpcError(`Resource ${uri} came back with no text`, TOOL_ERROR);
|
|
2575
|
+
}
|
|
2576
|
+
return text;
|
|
2577
|
+
}
|
|
2578
|
+
async function getGenome() {
|
|
2579
|
+
try {
|
|
2580
|
+
return await callTool("get_design_system");
|
|
2581
|
+
} catch (err) {
|
|
2582
|
+
if (!(err instanceof RpcError) || err.code !== TOOL_ERROR) throw err;
|
|
2583
|
+
try {
|
|
2584
|
+
return await callTool("get_tokens");
|
|
2585
|
+
} catch {
|
|
2586
|
+
throw err;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
return { callTool, readResource, getGenome };
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/workspace.ts
|
|
2594
|
+
function genomeToken(explicit) {
|
|
2595
|
+
const token = explicit || process.env.FLASH_GENOME_TOKEN || "";
|
|
2596
|
+
if (!token) {
|
|
2597
|
+
throw new Error(
|
|
2598
|
+
"No MCP token. Mint one in Flash under Connect and set FLASH_GENOME_TOKEN, or start Genome with --token."
|
|
2599
|
+
);
|
|
2600
|
+
}
|
|
2601
|
+
return token;
|
|
2602
|
+
}
|
|
2603
|
+
async function readGenomeConfig(workspaceRoot, configPath) {
|
|
2604
|
+
let legacy = false;
|
|
2605
|
+
for (const path of configCandidates(configPath, workspaceRoot)) {
|
|
2606
|
+
const raw = await readFile6(path, "utf8").catch(() => null);
|
|
2607
|
+
if (!raw) continue;
|
|
2608
|
+
if (path.endsWith(LEGACY_CONFIG_FILENAME)) legacy = true;
|
|
2609
|
+
let parsed;
|
|
2610
|
+
try {
|
|
2611
|
+
parsed = GenomeConfigSchema.safeParse(JSON.parse(raw));
|
|
2612
|
+
} catch {
|
|
2613
|
+
throw new Error(
|
|
2614
|
+
`${path} is not valid JSON. Fix or delete it, then run flash-genome link.`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
if (parsed.success) return parsed.data;
|
|
2618
|
+
if (JSON.parse(raw).version === 2) {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`${path} does not match the genome.config.json schema. Run flash-genome link to rewrite it.`
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
throw new Error(
|
|
2625
|
+
legacy ? `${GENOME_CONFIG_FILENAME} is missing; this repo still has the older ${LEGACY_CONFIG_FILENAME}. Run flash-genome link to write one.` : `No ${GENOME_CONFIG_FILENAME} here. Run flash-genome link in this repository first.`
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
async function workspaceContext(opts) {
|
|
2629
|
+
const workspaceRoot = resolve7(process.cwd(), opts.workspace ?? ".");
|
|
2630
|
+
const config = await readGenomeConfig(workspaceRoot, opts.configPath);
|
|
2631
|
+
const client = createRpcClient({
|
|
2632
|
+
// Already resolved by the caller in the documented order: the flag, then
|
|
2633
|
+
// the environment, then this repo's config, then the hosted default.
|
|
2634
|
+
// Preferring the config here would make the flag and the variable
|
|
2635
|
+
// unusable, since the config always has a url.
|
|
2636
|
+
baseUrl: opts.apiUrl,
|
|
2637
|
+
token: genomeToken(opts.token)
|
|
2638
|
+
});
|
|
2639
|
+
return { config, client, workspaceRoot };
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// src/tools/link.ts
|
|
2643
|
+
import { randomBytes } from "crypto";
|
|
2644
|
+
import { readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
|
|
2645
|
+
import { basename, join as join5, resolve as resolve8 } from "path";
|
|
2646
|
+
var DEFAULT_INPUTS = {
|
|
2647
|
+
tokens: ["design-tokens.json", "tokens.css"],
|
|
2648
|
+
components: []
|
|
2649
|
+
};
|
|
2650
|
+
function codebaseIdentity(previous, workspaceRoot) {
|
|
2651
|
+
if (previous) return previous;
|
|
2652
|
+
return {
|
|
2653
|
+
id: `cb_${randomBytes(12).toString("hex")}`,
|
|
2654
|
+
name: basename(workspaceRoot) || "codebase"
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
async function linkGenome(opts) {
|
|
2658
|
+
const envelope = await opts.client.getGenome();
|
|
2659
|
+
if (!envelope || typeof envelope.id !== "number") {
|
|
2660
|
+
throw new Error(
|
|
2661
|
+
"The server did not say which design system this token belongs to."
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
const root = resolve8(opts.workspaceRoot);
|
|
2665
|
+
const path = join5(root, GENOME_CONFIG_FILENAME);
|
|
2666
|
+
const before = await readFile7(path, "utf8").catch(() => null);
|
|
2667
|
+
let previous = null;
|
|
2668
|
+
let unreadable = false;
|
|
2669
|
+
if (before) {
|
|
2670
|
+
try {
|
|
2671
|
+
const parsed = GenomeConfigSchema.safeParse(JSON.parse(before));
|
|
2672
|
+
previous = parsed.success ? parsed.data : null;
|
|
2673
|
+
unreadable = !parsed.success;
|
|
2674
|
+
} catch {
|
|
2675
|
+
unreadable = true;
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
if (unreadable) {
|
|
2679
|
+
throw new Error(
|
|
2680
|
+
`${GENOME_CONFIG_FILENAME} exists but is not a valid config. Fix or delete it, then run link again - overwriting it would lose the paths it names.`
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
const config = GenomeConfigSchema.parse({
|
|
2684
|
+
version: 2,
|
|
2685
|
+
genomeId: envelope.id,
|
|
2686
|
+
name: envelope.name,
|
|
2687
|
+
apiUrl: opts.apiUrl,
|
|
2688
|
+
revision: envelope.revision,
|
|
2689
|
+
codebase: codebaseIdentity(previous?.codebase, root),
|
|
2690
|
+
// Keep whatever the repo had configured: a re-link is usually a token
|
|
2691
|
+
// rotation, and rewriting someone's paths is not what they asked for.
|
|
2692
|
+
...previous ? { inputs: previous.inputs, outputs: previous.outputs } : { inputs: DEFAULT_INPUTS },
|
|
2693
|
+
...opts.framework ? { framework: opts.framework } : {},
|
|
2694
|
+
...previous?.framework && !opts.framework ? { framework: previous.framework } : {}
|
|
2695
|
+
});
|
|
2696
|
+
const body = `${JSON.stringify(config, null, 2)}
|
|
2697
|
+
`;
|
|
2698
|
+
const unchanged = before === body;
|
|
2699
|
+
if (!unchanged) await writeFile2(path, body, "utf8");
|
|
2700
|
+
return {
|
|
2701
|
+
path,
|
|
2702
|
+
config,
|
|
2703
|
+
written: !unchanged,
|
|
2704
|
+
rebound: Boolean(previous && previous.genomeId !== config.genomeId),
|
|
2705
|
+
...previous ? { previousGenomeId: previous.genomeId } : {}
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function describeLink(result) {
|
|
2709
|
+
const lines = [
|
|
2710
|
+
result.written ? `Wrote ${GENOME_CONFIG_FILENAME}` : `${GENOME_CONFIG_FILENAME} already matched`,
|
|
2711
|
+
` design system: ${result.config.name} (#${result.config.genomeId})`,
|
|
2712
|
+
` revision: ${result.config.revision ?? "unknown"}`,
|
|
2713
|
+
` api: ${result.config.apiUrl}`,
|
|
2714
|
+
...result.config.codebase ? [
|
|
2715
|
+
` codebase: ${result.config.codebase.name} (${result.config.codebase.id})`
|
|
2716
|
+
] : []
|
|
2717
|
+
];
|
|
2718
|
+
if (result.rebound) {
|
|
2719
|
+
lines.push(
|
|
2720
|
+
` note: this repo was linked to #${result.previousGenomeId}; it now points at #${result.config.genomeId}`
|
|
2721
|
+
);
|
|
2722
|
+
}
|
|
2723
|
+
lines.push(
|
|
2724
|
+
"",
|
|
2725
|
+
"The token stays out of this file. Keep it in FLASH_GENOME_TOKEN."
|
|
2726
|
+
);
|
|
2727
|
+
return lines.join("\n");
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// src/tools/apply-token-patch.ts
|
|
2731
|
+
import { readFile as readFile8, writeFile as writeFile3 } from "fs/promises";
|
|
2732
|
+
function isRecord2(value) {
|
|
2733
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2734
|
+
}
|
|
2735
|
+
async function applyTokenPatch(tokenFile, patch, options = {}) {
|
|
2736
|
+
const confirm = options.confirm === true;
|
|
2737
|
+
const text = await readFile8(tokenFile, "utf8");
|
|
2738
|
+
const raw = JSON.parse(text);
|
|
2739
|
+
const { tokens, name } = normalizeTokenDocument(raw);
|
|
2740
|
+
const beforeCount = tokens.length;
|
|
2741
|
+
const byName = new Map(tokens.map((t) => [t.name, t]));
|
|
2742
|
+
const changes = [];
|
|
2743
|
+
for (const step of patch) {
|
|
2744
|
+
if (step.op === "remove") {
|
|
2745
|
+
if (byName.delete(step.token.name)) {
|
|
2746
|
+
changes.push({ op: "remove", name: step.token.name });
|
|
2747
|
+
}
|
|
2748
|
+
continue;
|
|
2749
|
+
}
|
|
2750
|
+
const existing = byName.get(step.token.name);
|
|
2751
|
+
const candidate = {
|
|
2752
|
+
name: step.token.name,
|
|
2753
|
+
value: step.token.value ?? existing?.value ?? "",
|
|
2754
|
+
type: step.token.type ?? existing?.type ?? "color",
|
|
2755
|
+
category: step.token.category ?? existing?.category ?? "colors",
|
|
2756
|
+
description: step.token.description ?? existing?.description
|
|
2757
|
+
};
|
|
2758
|
+
const parsed = TokenSchema.safeParse(candidate);
|
|
2759
|
+
if (!parsed.success) {
|
|
2760
|
+
throw new Error(
|
|
2761
|
+
`Invalid token patch for "${step.token.name}": ${parsed.error.message}`
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
byName.set(parsed.data.name, parsed.data);
|
|
2765
|
+
changes.push({
|
|
2766
|
+
op: existing ? "update" : "insert",
|
|
2767
|
+
name: parsed.data.name
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
const next = [...byName.values()];
|
|
2771
|
+
const result = {
|
|
2772
|
+
source: tokenFile,
|
|
2773
|
+
dryRun: !confirm,
|
|
2774
|
+
applied: false,
|
|
2775
|
+
beforeCount,
|
|
2776
|
+
afterCount: next.length,
|
|
2777
|
+
changes
|
|
2778
|
+
};
|
|
2779
|
+
if (!confirm) {
|
|
2780
|
+
result.tokens = next;
|
|
2781
|
+
return result;
|
|
2782
|
+
}
|
|
2783
|
+
let out;
|
|
2784
|
+
if (Array.isArray(raw)) {
|
|
2785
|
+
out = next;
|
|
2786
|
+
} else if (isRecord2(raw) && Array.isArray(raw.tokens)) {
|
|
2787
|
+
out = { ...raw, name: name ?? raw.name, tokens: next };
|
|
2788
|
+
} else {
|
|
2789
|
+
out = { name: name ?? "Design System", version: "0.1.0", tokens: next };
|
|
2790
|
+
}
|
|
2791
|
+
await writeFile3(tokenFile, `${JSON.stringify(out, null, 2)}
|
|
2792
|
+
`, "utf8");
|
|
2793
|
+
result.applied = true;
|
|
2794
|
+
result.tokens = next;
|
|
2795
|
+
return result;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// src/tools/brand-dna.ts
|
|
2799
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
2800
|
+
import { dirname as dirname3, resolve as resolve9 } from "path";
|
|
2801
|
+
function isRecord3(value) {
|
|
2802
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2803
|
+
}
|
|
2804
|
+
async function getBrandDna(options) {
|
|
2805
|
+
if (options.brandDnaFile) {
|
|
2806
|
+
const text2 = await readFile9(options.brandDnaFile, "utf8");
|
|
2807
|
+
const raw2 = JSON.parse(text2);
|
|
2808
|
+
if (!isRecord3(raw2)) {
|
|
2809
|
+
throw new Error("brand-dna file must be a JSON object");
|
|
2810
|
+
}
|
|
2811
|
+
return { source: options.brandDnaFile, brandDna: raw2 };
|
|
2812
|
+
}
|
|
2813
|
+
const sibling = resolve9(dirname3(options.tokenFile), "brand-dna.json");
|
|
2814
|
+
try {
|
|
2815
|
+
const text2 = await readFile9(sibling, "utf8");
|
|
2816
|
+
const raw2 = JSON.parse(text2);
|
|
2817
|
+
if (isRecord3(raw2)) {
|
|
2818
|
+
return { source: sibling, brandDna: raw2 };
|
|
2819
|
+
}
|
|
2820
|
+
} catch {
|
|
2821
|
+
}
|
|
2822
|
+
const text = await readFile9(options.tokenFile, "utf8");
|
|
2823
|
+
const raw = JSON.parse(text);
|
|
2824
|
+
if (!isRecord3(raw)) {
|
|
2825
|
+
throw new Error(
|
|
2826
|
+
"Brand DNA not found (no brand-dna.json and tokens is not an object)"
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
for (const key of ["dsm-brand-dna", "brandDna", "brand_dna"]) {
|
|
2830
|
+
const value = raw[key];
|
|
2831
|
+
if (isRecord3(value)) {
|
|
2832
|
+
return { source: `${options.tokenFile}#${key}`, brandDna: value };
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
throw new Error(
|
|
2836
|
+
"Brand DNA not found. Add fixtures/brand-dna.json or tokens['dsm-brand-dna']."
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// src/tools/components.ts
|
|
2841
|
+
async function listComponents(componentsFile, category) {
|
|
2842
|
+
const loaded = await loadComponentsFromFile(componentsFile);
|
|
2843
|
+
const components = filterComponentsByCategory(loaded.components, category);
|
|
2844
|
+
return {
|
|
2845
|
+
source: loaded.source,
|
|
2846
|
+
name: loaded.name,
|
|
2847
|
+
category,
|
|
2848
|
+
count: components.length,
|
|
2849
|
+
components
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
async function getComponent(componentsFile, name) {
|
|
2853
|
+
const loaded = await loadComponentsFromFile(componentsFile);
|
|
2854
|
+
const component = findComponentByName(loaded.components, name);
|
|
2855
|
+
if (!component) {
|
|
2856
|
+
const available = loaded.components.map((c) => c.name).join(", ");
|
|
2857
|
+
throw new Error(
|
|
2858
|
+
`Component "${name}" not found. Available: ${available || "(none)"}`
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
return { source: loaded.source, component };
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// src/tools/generate-component-prompt.ts
|
|
2865
|
+
async function generateComponentPrompt(componentsFile, tokenFile, name) {
|
|
2866
|
+
const { source: componentSource, component } = await getComponent(
|
|
2867
|
+
componentsFile,
|
|
2868
|
+
name
|
|
2869
|
+
);
|
|
2870
|
+
const tokens = await getDesignTokens(tokenFile);
|
|
2871
|
+
const related = (component.tokensUsed ?? []).map((tokenName) => tokens.tokens.find((t) => t.name === tokenName)).filter((t) => Boolean(t));
|
|
2872
|
+
const tokenBlock = related.length > 0 ? related.map((t) => `- ${t.name}: ${t.value} (${t.category})`).join("\n") : tokens.tokens.slice(0, 12).map((t) => `- ${t.name}: ${t.value} (${t.category})`).join("\n");
|
|
2873
|
+
const variants = component.variants?.length ? component.variants.join(", ") : "default";
|
|
2874
|
+
const prompt = [
|
|
2875
|
+
`Implement the Flash DSM "${component.name}" component.`,
|
|
2876
|
+
component.description ? `Purpose: ${component.description}` : null,
|
|
2877
|
+
`Category: ${component.category ?? "uncategorized"}`,
|
|
2878
|
+
`Variants: ${variants}`,
|
|
2879
|
+
"",
|
|
2880
|
+
"Use these design tokens (do not hardcode raw values when a token exists):",
|
|
2881
|
+
tokenBlock,
|
|
2882
|
+
"",
|
|
2883
|
+
"Constraints:",
|
|
2884
|
+
"- Prefer design tokens over hard-coded colors/spacing",
|
|
2885
|
+
"- Keep accessibility (labels, focus, contrast) intact",
|
|
2886
|
+
"- Match existing project component patterns"
|
|
2887
|
+
].filter((line) => line !== null).join("\n");
|
|
2888
|
+
return {
|
|
2889
|
+
name: component.name,
|
|
2890
|
+
prompt,
|
|
2891
|
+
componentSource,
|
|
2892
|
+
tokenSource: tokens.source
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// src/tools/list-violations.ts
|
|
2897
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
2898
|
+
var HEX_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
|
2899
|
+
var ARBITRARY_TW_RE = /\b(?:bg|text|border|from|to|via|ring)-\[#[0-9a-fA-F]{3,8}\]/g;
|
|
2900
|
+
var ARBITRARY_PX_RE = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|space-[xy])-\[(\d+)px\]/g;
|
|
2901
|
+
function normalizeHex2(hex) {
|
|
2902
|
+
let h = hex.slice(1).toLowerCase();
|
|
2903
|
+
if (h.length === 3 || h.length === 4) {
|
|
2904
|
+
h = h.split("").map((c) => c + c).join("").slice(0, 6);
|
|
2905
|
+
}
|
|
2906
|
+
return `#${h.slice(0, 6)}`;
|
|
2907
|
+
}
|
|
2908
|
+
function colorTokenSet(tokens) {
|
|
2909
|
+
const map = /* @__PURE__ */ new Map();
|
|
2910
|
+
for (const t of tokens) {
|
|
2911
|
+
if (t.type === "color" || t.category === "colors") {
|
|
2912
|
+
if (t.value.startsWith("#")) {
|
|
2913
|
+
map.set(normalizeHex2(t.value), t.name);
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return map;
|
|
2918
|
+
}
|
|
2919
|
+
function spacingTokenSet(tokens) {
|
|
2920
|
+
const map = /* @__PURE__ */ new Map();
|
|
2921
|
+
for (const t of tokens) {
|
|
2922
|
+
if (t.category === "spacing" || t.type === "size") {
|
|
2923
|
+
map.set(t.value.toLowerCase(), t.name);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
return map;
|
|
2927
|
+
}
|
|
2928
|
+
function lineCol(source, index) {
|
|
2929
|
+
const upto = source.slice(0, index);
|
|
2930
|
+
const lines = upto.split("\n");
|
|
2931
|
+
return {
|
|
2932
|
+
line: lines.length,
|
|
2933
|
+
column: (lines[lines.length - 1]?.length ?? 0) + 1
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
async function listViolations(tokenFile, filePath) {
|
|
2937
|
+
const loaded = await loadTokensFromFile(tokenFile);
|
|
2938
|
+
const source = await readFile10(filePath, "utf8");
|
|
2939
|
+
const colors = colorTokenSet(loaded.tokens);
|
|
2940
|
+
const spacing = spacingTokenSet(loaded.tokens);
|
|
2941
|
+
const violations = [];
|
|
2942
|
+
for (const match of source.matchAll(HEX_RE)) {
|
|
2943
|
+
const hex = match[0];
|
|
2944
|
+
const idx = match.index ?? 0;
|
|
2945
|
+
const { line, column } = lineCol(source, idx);
|
|
2946
|
+
const tokenName = colors.get(normalizeHex2(hex));
|
|
2947
|
+
if (tokenName) {
|
|
2948
|
+
violations.push({
|
|
2949
|
+
rule: "prefer-token",
|
|
2950
|
+
message: `Hardcoded color ${hex} matches token ${tokenName} - prefer the token`,
|
|
2951
|
+
match: hex,
|
|
2952
|
+
line,
|
|
2953
|
+
column,
|
|
2954
|
+
suggestion: tokenName
|
|
2955
|
+
});
|
|
2956
|
+
} else {
|
|
2957
|
+
violations.push({
|
|
2958
|
+
rule: "unknown-color",
|
|
2959
|
+
message: `Hardcoded color ${hex} is not in the design token palette`,
|
|
2960
|
+
match: hex,
|
|
2961
|
+
line,
|
|
2962
|
+
column
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
for (const match of source.matchAll(ARBITRARY_TW_RE)) {
|
|
2967
|
+
const cls = match[0];
|
|
2968
|
+
const idx = match.index ?? 0;
|
|
2969
|
+
const { line, column } = lineCol(source, idx);
|
|
2970
|
+
violations.push({
|
|
2971
|
+
rule: "arbitrary-tailwind-color",
|
|
2972
|
+
message: `Arbitrary Tailwind color ${cls} bypasses design tokens`,
|
|
2973
|
+
match: cls,
|
|
2974
|
+
line,
|
|
2975
|
+
column
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
for (const match of source.matchAll(ARBITRARY_PX_RE)) {
|
|
2979
|
+
const cls = match[0];
|
|
2980
|
+
const px = `${match[1]}px`;
|
|
2981
|
+
const idx = match.index ?? 0;
|
|
2982
|
+
const { line, column } = lineCol(source, idx);
|
|
2983
|
+
const tokenName = spacing.get(px.toLowerCase());
|
|
2984
|
+
violations.push({
|
|
2985
|
+
rule: "arbitrary-tailwind-spacing",
|
|
2986
|
+
message: tokenName ? `Arbitrary spacing ${cls} matches token ${tokenName}` : `Arbitrary spacing ${cls} is not in spacing tokens`,
|
|
2987
|
+
match: cls,
|
|
2988
|
+
line,
|
|
2989
|
+
column,
|
|
2990
|
+
suggestion: tokenName
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
return {
|
|
2994
|
+
file: filePath,
|
|
2995
|
+
tokenSource: loaded.source,
|
|
2996
|
+
count: violations.length,
|
|
2997
|
+
violations
|
|
2998
|
+
};
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
// src/tools/scan-workspace.ts
|
|
3002
|
+
import { readFile as readFile11, readdir } from "fs/promises";
|
|
3003
|
+
import { basename as basename2, join as join6, resolve as resolve10 } from "path";
|
|
3004
|
+
var MODES_EXTENSION = "com.flashdsm.modes";
|
|
3005
|
+
function walkDtcg(node, path, visit) {
|
|
3006
|
+
for (const [k, v] of Object.entries(node)) {
|
|
3007
|
+
if (k.startsWith("$") || typeof v !== "object" || !v) continue;
|
|
3008
|
+
const child = v;
|
|
3009
|
+
if ("$value" in child || "value" in child) {
|
|
3010
|
+
visit([...path, k].join("-"), child);
|
|
3011
|
+
} else {
|
|
3012
|
+
walkDtcg(child, [...path, k], visit);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
var TOKEN_FILES = ["design-tokens.json", "tokens.json"];
|
|
3017
|
+
var CSS_RE = /tokens?\.css$/i;
|
|
3018
|
+
var TW_RE = /^tailwind\.config\.(js|cjs|mjs|ts)$/i;
|
|
3019
|
+
var SKIP = /node_modules|dist|build|\.git/i;
|
|
3020
|
+
var CSS_VAR = /--([a-z0-9-_]+)\s*:\s*([^;{}]+);/gi;
|
|
3021
|
+
function classify(name, value) {
|
|
3022
|
+
const v = value.trim();
|
|
3023
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(v))
|
|
3024
|
+
return { category: "colors", type: "color" };
|
|
3025
|
+
if (/^-?\d*\.?\d+(px|rem|em|%)$/.test(v)) {
|
|
3026
|
+
return /radius/i.test(name) ? { category: "radius", type: "radius" } : { category: "spacing", type: "size" };
|
|
3027
|
+
}
|
|
3028
|
+
return null;
|
|
3029
|
+
}
|
|
3030
|
+
async function walk(root, dir, out) {
|
|
3031
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
3032
|
+
for (const e of entries) {
|
|
3033
|
+
const full = join6(dir, e.name);
|
|
3034
|
+
if (e.isDirectory()) {
|
|
3035
|
+
if (!SKIP.test(e.name)) await walk(root, full, out);
|
|
3036
|
+
} else out.push(full.slice(root.length + 1));
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
async function scanWorkspace(workspaceRoot) {
|
|
3040
|
+
const root = resolve10(workspaceRoot);
|
|
3041
|
+
const paths = [];
|
|
3042
|
+
await walk(root, root, paths);
|
|
3043
|
+
const tokens = {};
|
|
3044
|
+
const sources = /* @__PURE__ */ new Set();
|
|
3045
|
+
let scanned = 0;
|
|
3046
|
+
for (const rel of paths) {
|
|
3047
|
+
const base = basename2(rel);
|
|
3048
|
+
if (!TOKEN_FILES.includes(base) && !CSS_RE.test(base) && !TW_RE.test(base))
|
|
3049
|
+
continue;
|
|
3050
|
+
const text = await readFile11(join6(root, rel), "utf8").catch(() => null);
|
|
3051
|
+
if (!text) continue;
|
|
3052
|
+
scanned += 1;
|
|
3053
|
+
if (base.endsWith(".json")) {
|
|
3054
|
+
try {
|
|
3055
|
+
const raw = JSON.parse(text);
|
|
3056
|
+
walkDtcg(raw, [], (name, leaf) => {
|
|
3057
|
+
const val = leaf.$value ?? leaf.value;
|
|
3058
|
+
if (typeof val !== "string") return;
|
|
3059
|
+
const c = classify(name, val);
|
|
3060
|
+
if (!c) return;
|
|
3061
|
+
const ext = leaf.$extensions;
|
|
3062
|
+
const modes = ext?.[MODES_EXTENSION];
|
|
3063
|
+
const token = {
|
|
3064
|
+
name,
|
|
3065
|
+
value: val,
|
|
3066
|
+
type: c.type,
|
|
3067
|
+
category: c.category
|
|
3068
|
+
};
|
|
3069
|
+
if (modes && typeof modes === "object") {
|
|
3070
|
+
const clean = {};
|
|
3071
|
+
for (const [theme, v] of Object.entries(modes)) {
|
|
3072
|
+
if (typeof v === "string") clean[theme] = v;
|
|
3073
|
+
}
|
|
3074
|
+
if (Object.keys(clean).length) token.modes = clean;
|
|
3075
|
+
}
|
|
3076
|
+
tokens[c.category] ??= [];
|
|
3077
|
+
tokens[c.category].push(token);
|
|
3078
|
+
sources.add(rel);
|
|
3079
|
+
});
|
|
3080
|
+
} catch {
|
|
3081
|
+
}
|
|
3082
|
+
} else if (/\.css$/i.test(rel)) {
|
|
3083
|
+
for (const m of text.matchAll(CSS_VAR)) {
|
|
3084
|
+
const c = classify(m[1], m[2]);
|
|
3085
|
+
if (!c) continue;
|
|
3086
|
+
tokens[c.category] ??= [];
|
|
3087
|
+
tokens[c.category].push({
|
|
3088
|
+
name: m[1].toLowerCase(),
|
|
3089
|
+
value: m[2].trim(),
|
|
3090
|
+
type: c.type,
|
|
3091
|
+
category: c.category
|
|
3092
|
+
});
|
|
3093
|
+
sources.add(rel);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
return {
|
|
3098
|
+
workspaceRoot: root,
|
|
3099
|
+
tokens,
|
|
3100
|
+
components: [],
|
|
3101
|
+
sources: [...sources],
|
|
3102
|
+
stats: {
|
|
3103
|
+
filesScanned: scanned,
|
|
3104
|
+
colors: tokens.colors?.length ?? 0,
|
|
3105
|
+
spacing: tokens.spacing?.length ?? 0,
|
|
3106
|
+
typography: tokens.typography?.length ?? 0,
|
|
3107
|
+
radius: tokens.radius?.length ?? 0,
|
|
3108
|
+
shadows: tokens.shadows?.length ?? 0,
|
|
3109
|
+
components: 0
|
|
3110
|
+
}
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// src/tools/suggest-token-usage.ts
|
|
3115
|
+
var HEX_RE2 = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
|
3116
|
+
var PX_RE = /\b(\d+(?:\.\d+)?)px\b/g;
|
|
3117
|
+
function normalizeHex3(hex) {
|
|
3118
|
+
let h = hex.slice(1).toLowerCase();
|
|
3119
|
+
if (h.length === 3 || h.length === 4) {
|
|
3120
|
+
h = h.split("").map((c) => c + c).join("").slice(0, 6);
|
|
3121
|
+
}
|
|
3122
|
+
return `#${h.slice(0, 6)}`;
|
|
3123
|
+
}
|
|
3124
|
+
function hexDistance(a, b) {
|
|
3125
|
+
const parse = (h) => {
|
|
3126
|
+
const n = normalizeHex3(h).slice(1);
|
|
3127
|
+
return [
|
|
3128
|
+
parseInt(n.slice(0, 2), 16),
|
|
3129
|
+
parseInt(n.slice(2, 4), 16),
|
|
3130
|
+
parseInt(n.slice(4, 6), 16)
|
|
3131
|
+
];
|
|
3132
|
+
};
|
|
3133
|
+
try {
|
|
3134
|
+
const [ar, ag, ab] = parse(a);
|
|
3135
|
+
const [br, bg, bb] = parse(b);
|
|
3136
|
+
return Math.hypot(ar - br, ag - bg, ab - bb);
|
|
3137
|
+
} catch {
|
|
3138
|
+
return Number.POSITIVE_INFINITY;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
function nearestColor(tokens, hex) {
|
|
3142
|
+
const colors = tokens.filter(
|
|
3143
|
+
(t) => t.category === "colors" || t.type === "color"
|
|
3144
|
+
);
|
|
3145
|
+
let best = null;
|
|
3146
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
3147
|
+
for (const t of colors) {
|
|
3148
|
+
if (!t.value.startsWith("#")) continue;
|
|
3149
|
+
const d = hexDistance(hex, t.value);
|
|
3150
|
+
if (d < bestDist) {
|
|
3151
|
+
bestDist = d;
|
|
3152
|
+
best = t;
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
return best;
|
|
3156
|
+
}
|
|
3157
|
+
function nearestSpacing(tokens, px) {
|
|
3158
|
+
const spacing = tokens.filter(
|
|
3159
|
+
(t) => t.category === "spacing" || t.type === "size"
|
|
3160
|
+
);
|
|
3161
|
+
const target = parseFloat(px);
|
|
3162
|
+
let best = null;
|
|
3163
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
3164
|
+
for (const t of spacing) {
|
|
3165
|
+
const m = t.value.match(/^(\d+(?:\.\d+)?)px$/i);
|
|
3166
|
+
if (!m) continue;
|
|
3167
|
+
const d = Math.abs(parseFloat(m[1]) - target);
|
|
3168
|
+
if (d < bestDist) {
|
|
3169
|
+
bestDist = d;
|
|
3170
|
+
best = t;
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
return best;
|
|
3174
|
+
}
|
|
3175
|
+
async function suggestTokenUsage(tokenFile, context) {
|
|
3176
|
+
const loaded = await loadTokensFromFile(tokenFile);
|
|
3177
|
+
const suggestions = [];
|
|
3178
|
+
const hexes = context.match(HEX_RE2) ?? [];
|
|
3179
|
+
for (const hex of [...new Set(hexes)]) {
|
|
3180
|
+
const nearest = nearestColor(loaded.tokens, hex);
|
|
3181
|
+
const exact = nearest && normalizeHex3(nearest.value) === normalizeHex3(hex);
|
|
3182
|
+
suggestions.push({
|
|
3183
|
+
found: hex,
|
|
3184
|
+
kind: "color",
|
|
3185
|
+
suggestion: nearest ? {
|
|
3186
|
+
name: nearest.name,
|
|
3187
|
+
value: nearest.value,
|
|
3188
|
+
distance: exact ? 0 : hexDistance(hex, nearest.value)
|
|
3189
|
+
} : null,
|
|
3190
|
+
note: nearest ? exact ? `Exact match - use token ${nearest.name}` : `Nearest color token is ${nearest.name} (${nearest.value})` : "No color tokens available"
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
const pxs = [...context.matchAll(PX_RE)].map((m) => `${m[1]}px`);
|
|
3194
|
+
for (const px of [...new Set(pxs)]) {
|
|
3195
|
+
const nearest = nearestSpacing(loaded.tokens, px);
|
|
3196
|
+
const exact = nearest?.value.toLowerCase() === px.toLowerCase();
|
|
3197
|
+
suggestions.push({
|
|
3198
|
+
found: px,
|
|
3199
|
+
kind: "spacing",
|
|
3200
|
+
suggestion: nearest ? { name: nearest.name, value: nearest.value } : null,
|
|
3201
|
+
note: nearest ? exact ? `Exact match - use token ${nearest.name}` : `Nearest spacing token is ${nearest.name} (${nearest.value})` : "No spacing tokens available"
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
return {
|
|
3205
|
+
source: loaded.source,
|
|
3206
|
+
contextPreview: context.slice(0, 200),
|
|
3207
|
+
suggestions
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
// src/index.ts
|
|
3212
|
+
var PACKAGE_ROOT = resolve11(fileURLToPath(new URL("..", import.meta.url)));
|
|
3213
|
+
function packageVersion() {
|
|
3214
|
+
try {
|
|
3215
|
+
const raw = readFileSync(resolve11(PACKAGE_ROOT, "package.json"), "utf8");
|
|
3216
|
+
const version = JSON.parse(raw).version;
|
|
3217
|
+
return typeof version === "string" ? version : "0.0.0";
|
|
3218
|
+
} catch {
|
|
3219
|
+
return "0.0.0";
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
var TOOL_NAMES = [
|
|
3223
|
+
"get_tokens",
|
|
3224
|
+
"list_components",
|
|
3225
|
+
"get_component",
|
|
3226
|
+
"get_brand_dna",
|
|
3227
|
+
"suggest_token",
|
|
3228
|
+
"apply_token_patch",
|
|
3229
|
+
"generate_component_prompt",
|
|
3230
|
+
"check_violations",
|
|
3231
|
+
"scan_workspace",
|
|
3232
|
+
"pull_from_web",
|
|
3233
|
+
"detect_drift",
|
|
3234
|
+
"push_to_web",
|
|
3235
|
+
"list_modules",
|
|
3236
|
+
"get_module",
|
|
3237
|
+
"get_relations",
|
|
3238
|
+
"get_impact",
|
|
3239
|
+
"sync_context",
|
|
3240
|
+
"api_health",
|
|
3241
|
+
"list_design_systems"
|
|
3242
|
+
];
|
|
3243
|
+
function parseCliArgs(argv) {
|
|
3244
|
+
let tokensPath;
|
|
3245
|
+
let componentsPath;
|
|
3246
|
+
let configPath;
|
|
3247
|
+
let apiUrl;
|
|
3248
|
+
let accessToken;
|
|
3249
|
+
let token;
|
|
3250
|
+
const VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
3251
|
+
"--tokens",
|
|
3252
|
+
"-t",
|
|
3253
|
+
"--components",
|
|
3254
|
+
"--config",
|
|
3255
|
+
"-c",
|
|
3256
|
+
"--api-url",
|
|
3257
|
+
"--access-token",
|
|
3258
|
+
"--token"
|
|
3259
|
+
]);
|
|
3260
|
+
let command;
|
|
3261
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3262
|
+
const arg = argv[i];
|
|
3263
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
3264
|
+
i++;
|
|
3265
|
+
continue;
|
|
3266
|
+
}
|
|
3267
|
+
if (arg.startsWith("-")) continue;
|
|
3268
|
+
command = arg;
|
|
3269
|
+
break;
|
|
3270
|
+
}
|
|
3271
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3272
|
+
const arg = argv[i];
|
|
3273
|
+
if (arg === command) continue;
|
|
3274
|
+
if (arg === "--tokens" || arg === "-t") {
|
|
3275
|
+
tokensPath = argv[++i];
|
|
3276
|
+
continue;
|
|
3277
|
+
}
|
|
3278
|
+
if (arg === "--components") {
|
|
3279
|
+
componentsPath = argv[++i];
|
|
3280
|
+
continue;
|
|
3281
|
+
}
|
|
3282
|
+
if (arg === "--config" || arg === "-c") {
|
|
3283
|
+
configPath = argv[++i];
|
|
3284
|
+
continue;
|
|
3285
|
+
}
|
|
3286
|
+
if (arg === "--api-url") {
|
|
3287
|
+
apiUrl = argv[++i];
|
|
3288
|
+
continue;
|
|
3289
|
+
}
|
|
3290
|
+
if (arg === "--access-token") {
|
|
3291
|
+
accessToken = argv[++i];
|
|
3292
|
+
continue;
|
|
3293
|
+
}
|
|
3294
|
+
if (arg === "--token") {
|
|
3295
|
+
token = argv[++i];
|
|
3296
|
+
continue;
|
|
3297
|
+
}
|
|
3298
|
+
if (arg === "--help" || arg === "-h") {
|
|
3299
|
+
console.error(`Usage: flash-genome [command] [options]
|
|
3300
|
+
|
|
3301
|
+
Flash Genome: the local MCP for a Flash design system (stdio).
|
|
3302
|
+
|
|
3303
|
+
Commands:
|
|
3304
|
+
(none) Start the MCP server on stdio
|
|
3305
|
+
link Bind this repo to the design system the token belongs to
|
|
3306
|
+
|
|
3307
|
+
Credentials:
|
|
3308
|
+
FLASH_GENOME_TOKEN Flash MCP token, flash_mcp_\u2026, minted in Flash under
|
|
3309
|
+
Connect. Used by the link command.
|
|
3310
|
+
--token The same token as an argument. Visible in shell history
|
|
3311
|
+
and in process listings, so prefer the variable.
|
|
3312
|
+
|
|
3313
|
+
Options:
|
|
3314
|
+
--tokens, -t Path to design-tokens.json
|
|
3315
|
+
--components Path to components.json
|
|
3316
|
+
--config, -c Path to genome.config.json (or the older flash-ds.config.json)
|
|
3317
|
+
--api-url Flash base URL. Without it: FLASH_GENOME_API_URL, then
|
|
3318
|
+
FLASH_DS_API_URL, then this repo's genome.config.json,
|
|
3319
|
+
then https://flashdsm.com
|
|
3320
|
+
--access-token Deprecated web JWT, for api_health and list_design_systems
|
|
3321
|
+
--help, -h Show this help
|
|
3322
|
+
`);
|
|
3323
|
+
process.exit(0);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
return {
|
|
3327
|
+
command,
|
|
3328
|
+
tokensPath,
|
|
3329
|
+
componentsPath,
|
|
3330
|
+
configPath,
|
|
3331
|
+
apiUrl,
|
|
3332
|
+
accessToken,
|
|
3333
|
+
token
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
function textResult(data, isError = false) {
|
|
3337
|
+
return {
|
|
3338
|
+
content: [
|
|
3339
|
+
{
|
|
3340
|
+
type: "text",
|
|
3341
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2)
|
|
3342
|
+
}
|
|
3343
|
+
],
|
|
3344
|
+
...isError ? { isError: true } : {}
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
function errorResult(err) {
|
|
3348
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3349
|
+
return textResult(`Error: ${message}`, true);
|
|
3350
|
+
}
|
|
3351
|
+
async function createServer(opts) {
|
|
3352
|
+
const cli = { configPath: opts.configPath };
|
|
3353
|
+
const server = new McpServer({
|
|
3354
|
+
name: "flash-genome",
|
|
3355
|
+
version: packageVersion()
|
|
3356
|
+
});
|
|
3357
|
+
const api = createApiClient({
|
|
3358
|
+
baseUrl: opts.apiUrl,
|
|
3359
|
+
accessToken: opts.accessToken
|
|
3360
|
+
});
|
|
3361
|
+
const categoryEnum = z2.enum(
|
|
3362
|
+
TokenCategorySchema.options
|
|
3363
|
+
);
|
|
3364
|
+
const alias = (names, description, schema, handler) => {
|
|
3365
|
+
for (const name of names) server.tool(name, description, schema, handler);
|
|
3366
|
+
};
|
|
3367
|
+
alias(
|
|
3368
|
+
["get_tokens", "get_design_tokens"],
|
|
3369
|
+
"Read design tokens from the local Flash token file. Optionally filter by category. Read-only.",
|
|
3370
|
+
{
|
|
3371
|
+
category: categoryEnum.optional().describe("Optional token category (e.g. colors, spacing)")
|
|
3372
|
+
},
|
|
3373
|
+
async (args) => {
|
|
3374
|
+
try {
|
|
3375
|
+
const category = parseCategoryInput(args.category);
|
|
3376
|
+
const result = await getDesignTokens(opts.tokenFile, { category });
|
|
3377
|
+
return textResult(result);
|
|
3378
|
+
} catch (err) {
|
|
3379
|
+
return errorResult(err);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
);
|
|
3383
|
+
server.tool(
|
|
3384
|
+
"list_components",
|
|
3385
|
+
"List components from the local components.json (optionally filter by category).",
|
|
3386
|
+
{
|
|
3387
|
+
category: z2.string().optional().describe("Optional component category (e.g. actions, forms)")
|
|
3388
|
+
},
|
|
3389
|
+
async (args) => {
|
|
3390
|
+
try {
|
|
3391
|
+
const result = await listComponents(opts.componentsFile, args.category);
|
|
3392
|
+
return textResult(result);
|
|
3393
|
+
} catch (err) {
|
|
3394
|
+
return errorResult(err);
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
);
|
|
3398
|
+
server.tool(
|
|
3399
|
+
"get_component",
|
|
3400
|
+
"Get a single component spec by name from the local components file.",
|
|
3401
|
+
{
|
|
3402
|
+
name: z2.string().describe("Component name (e.g. Button)")
|
|
3403
|
+
},
|
|
3404
|
+
async (args) => {
|
|
3405
|
+
try {
|
|
3406
|
+
const result = await getComponent(opts.componentsFile, args.name);
|
|
3407
|
+
return textResult(result);
|
|
3408
|
+
} catch (err) {
|
|
3409
|
+
return errorResult(err);
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
);
|
|
3413
|
+
server.tool(
|
|
3414
|
+
"get_brand_dna",
|
|
3415
|
+
"Read brand DNA from brand-dna.json (sibling of tokens) or tokens['dsm-brand-dna'].",
|
|
3416
|
+
{},
|
|
3417
|
+
async () => {
|
|
3418
|
+
try {
|
|
3419
|
+
const result = await getBrandDna({ tokenFile: opts.tokenFile });
|
|
3420
|
+
return textResult(result);
|
|
3421
|
+
} catch (err) {
|
|
3422
|
+
return errorResult(err);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
);
|
|
3426
|
+
alias(
|
|
3427
|
+
["suggest_token", "suggest_token_usage"],
|
|
3428
|
+
"Suggest design tokens for hardcoded colors/spacing found in a context snippet (deterministic, no LLM).",
|
|
3429
|
+
{
|
|
3430
|
+
context: z2.string().describe("Code or design context containing colors/px values")
|
|
3431
|
+
},
|
|
3432
|
+
async (args) => {
|
|
3433
|
+
try {
|
|
3434
|
+
const result = await suggestTokenUsage(opts.tokenFile, args.context);
|
|
3435
|
+
return textResult(result);
|
|
3436
|
+
} catch (err) {
|
|
3437
|
+
return errorResult(err);
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
);
|
|
3441
|
+
server.tool(
|
|
3442
|
+
"apply_token_patch",
|
|
3443
|
+
"Upsert/remove tokens in the local design-tokens.json. Dry-run by default; set confirm=true to write.",
|
|
3444
|
+
{
|
|
3445
|
+
patch: z2.array(
|
|
3446
|
+
z2.object({
|
|
3447
|
+
op: z2.enum(["upsert", "remove"]),
|
|
3448
|
+
token: z2.object({
|
|
3449
|
+
name: z2.string(),
|
|
3450
|
+
value: z2.string().optional(),
|
|
3451
|
+
type: z2.string().optional(),
|
|
3452
|
+
category: z2.string().optional(),
|
|
3453
|
+
description: z2.string().optional()
|
|
3454
|
+
})
|
|
3455
|
+
})
|
|
3456
|
+
).describe("List of upsert/remove operations"),
|
|
3457
|
+
confirm: z2.boolean().optional().describe("Must be true to write the file (default: dry-run)")
|
|
3458
|
+
},
|
|
3459
|
+
async (args) => {
|
|
3460
|
+
try {
|
|
3461
|
+
const result = await applyTokenPatch(opts.tokenFile, args.patch, {
|
|
3462
|
+
confirm: args.confirm
|
|
3463
|
+
});
|
|
3464
|
+
return textResult(result);
|
|
3465
|
+
} catch (err) {
|
|
3466
|
+
return errorResult(err);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
);
|
|
3470
|
+
server.tool(
|
|
3471
|
+
"generate_component_prompt",
|
|
3472
|
+
"Generate an AI handoff prompt for implementing a named component with Flash tokens.",
|
|
3473
|
+
{
|
|
3474
|
+
name: z2.string().describe("Component name")
|
|
3475
|
+
},
|
|
3476
|
+
async (args) => {
|
|
3477
|
+
try {
|
|
3478
|
+
const result = await generateComponentPrompt(
|
|
3479
|
+
opts.componentsFile,
|
|
3480
|
+
opts.tokenFile,
|
|
3481
|
+
args.name
|
|
3482
|
+
);
|
|
3483
|
+
return textResult(result);
|
|
3484
|
+
} catch (err) {
|
|
3485
|
+
return errorResult(err);
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
);
|
|
3489
|
+
alias(
|
|
3490
|
+
["check_violations", "list_violations"],
|
|
3491
|
+
"Lint a source file for hardcoded colors / arbitrary Tailwind values vs design tokens.",
|
|
3492
|
+
{
|
|
3493
|
+
file: z2.string().describe("Absolute or cwd-relative path to a source file")
|
|
3494
|
+
},
|
|
3495
|
+
async (args) => {
|
|
3496
|
+
try {
|
|
3497
|
+
const filePath = resolve11(process.cwd(), args.file);
|
|
3498
|
+
const result = await listViolations(opts.tokenFile, filePath);
|
|
3499
|
+
return textResult(result);
|
|
3500
|
+
} catch (err) {
|
|
3501
|
+
return errorResult(err);
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
);
|
|
3505
|
+
server.tool(
|
|
3506
|
+
"scan_workspace",
|
|
3507
|
+
"Scan the workspace for DTCG JSON, CSS variables, and Tailwind colors; POST to FastAPI to create a v2 design system. Requires --access-token.",
|
|
3508
|
+
{
|
|
3509
|
+
workspace: z2.string().optional().describe("Workspace root (default: cwd)"),
|
|
3510
|
+
name: z2.string().optional().describe("Name for the new design system")
|
|
3511
|
+
},
|
|
3512
|
+
async (args) => {
|
|
3513
|
+
try {
|
|
3514
|
+
if (!opts.accessToken) {
|
|
3515
|
+
throw new Error(
|
|
3516
|
+
"scan_workspace requires --access-token or FLASH_DS_ACCESS_TOKEN"
|
|
3517
|
+
);
|
|
3518
|
+
}
|
|
3519
|
+
const root = resolve11(process.cwd(), args.workspace ?? ".");
|
|
3520
|
+
const scan = await scanWorkspace(root);
|
|
3521
|
+
const created = await api.createFromCodeScan({
|
|
3522
|
+
name: args.name,
|
|
3523
|
+
scan
|
|
3524
|
+
});
|
|
3525
|
+
return textResult({ scan: scan.stats, ...created });
|
|
3526
|
+
} catch (err) {
|
|
3527
|
+
return errorResult(err);
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
);
|
|
3531
|
+
const workspaceArgs = {
|
|
3532
|
+
workspace: z2.string().optional().describe("Repo root (default: cwd)"),
|
|
3533
|
+
confirm: z2.boolean().optional().describe("Must be true to write files. Without it, nothing is written")
|
|
3534
|
+
};
|
|
3535
|
+
const pullTool = async (args) => {
|
|
3536
|
+
try {
|
|
3537
|
+
const ctx = await workspaceContext({
|
|
3538
|
+
workspace: args.workspace,
|
|
3539
|
+
configPath: cli.configPath,
|
|
3540
|
+
apiUrl: opts.apiUrl,
|
|
3541
|
+
token: opts.token
|
|
3542
|
+
});
|
|
3543
|
+
return textResult(await pullFromWeb({ ...ctx, confirm: args.confirm }));
|
|
3544
|
+
} catch (err) {
|
|
3545
|
+
return errorResult(err);
|
|
3546
|
+
}
|
|
3547
|
+
};
|
|
3548
|
+
alias(
|
|
3549
|
+
["pull_from_web", "sync_to_repo"],
|
|
3550
|
+
"Write this design system into the repository: the token files and mirrors named by genome.config.json. Returns the plan; writes only with confirm. Run sync_context as well for the files an agent reads before writing UI.",
|
|
3551
|
+
workspaceArgs,
|
|
3552
|
+
pullTool
|
|
3553
|
+
);
|
|
3554
|
+
server.tool(
|
|
3555
|
+
"detect_drift",
|
|
3556
|
+
"Has the repo moved since the last pull, has Flash, and which token values changed. Read-only; every finding is flagged.",
|
|
3557
|
+
{ workspace: workspaceArgs.workspace },
|
|
3558
|
+
async (args) => {
|
|
3559
|
+
try {
|
|
3560
|
+
const ctx = await workspaceContext({
|
|
3561
|
+
workspace: args.workspace,
|
|
3562
|
+
configPath: cli.configPath,
|
|
3563
|
+
apiUrl: opts.apiUrl,
|
|
3564
|
+
token: opts.token
|
|
3565
|
+
});
|
|
3566
|
+
return textResult(await detectDrift(ctx));
|
|
3567
|
+
} catch (err) {
|
|
3568
|
+
return errorResult(err);
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
);
|
|
3572
|
+
server.tool(
|
|
3573
|
+
"push_to_web",
|
|
3574
|
+
"Propose the token values this repo changed to Flash, as a change set a person reviews on the Sync page. Nothing is applied. Returns the plan; proposes only with confirm. Refused while Flash is ahead of the last pull.",
|
|
3575
|
+
workspaceArgs,
|
|
3576
|
+
async (args) => {
|
|
3577
|
+
try {
|
|
3578
|
+
const ctx = await workspaceContext({
|
|
3579
|
+
workspace: args.workspace,
|
|
3580
|
+
configPath: cli.configPath,
|
|
3581
|
+
apiUrl: opts.apiUrl,
|
|
3582
|
+
token: opts.token
|
|
3583
|
+
});
|
|
3584
|
+
return textResult(await pushToWeb({ ...ctx, confirm: args.confirm }));
|
|
3585
|
+
} catch (err) {
|
|
3586
|
+
return errorResult(err);
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
);
|
|
3590
|
+
const graphClient = () => createRpcClient({ baseUrl: opts.apiUrl, token: genomeToken(opts.token) });
|
|
3591
|
+
const graphTool = (name) => async (args) => {
|
|
3592
|
+
try {
|
|
3593
|
+
return textResult(await runGraphTool(graphClient(), name, args));
|
|
3594
|
+
} catch (err) {
|
|
3595
|
+
return errorResult(err);
|
|
3596
|
+
}
|
|
3597
|
+
};
|
|
3598
|
+
server.tool(
|
|
3599
|
+
"list_modules",
|
|
3600
|
+
"Every module, with its part count, the components it is built from, and how many token bindings it carries.",
|
|
3601
|
+
{},
|
|
3602
|
+
graphTool("list_modules")
|
|
3603
|
+
);
|
|
3604
|
+
server.tool(
|
|
3605
|
+
"get_module",
|
|
3606
|
+
"One module by id or name: its parts in order, each resolved to its component, and its bindings.",
|
|
3607
|
+
{ module: z2.string().describe("Module id or name") },
|
|
3608
|
+
graphTool("get_module")
|
|
3609
|
+
);
|
|
3610
|
+
server.tool(
|
|
3611
|
+
"get_relations",
|
|
3612
|
+
"With a token: what it aliases, what aliases it, and what binds to it directly. Without one: every alias edge, binding and module part.",
|
|
3613
|
+
{ token: z2.string().optional().describe("Token id or name") },
|
|
3614
|
+
graphTool("get_relations")
|
|
3615
|
+
);
|
|
3616
|
+
server.tool(
|
|
3617
|
+
"get_impact",
|
|
3618
|
+
"What breaks if a token changes: every token that resolves through it, and the components and modules that end up using it. Call before changing a token.",
|
|
3619
|
+
{ token: z2.string().describe("Token id or name") },
|
|
3620
|
+
graphTool("get_impact")
|
|
3621
|
+
);
|
|
3622
|
+
server.tool(
|
|
3623
|
+
"sync_context",
|
|
3624
|
+
"Refresh the files an agent reads before writing UI: AGENTS.md (once), the Cursor rule, and the Agent Skill. Returns the plan; writes only with confirm.",
|
|
3625
|
+
workspaceArgs,
|
|
3626
|
+
async (args) => {
|
|
3627
|
+
try {
|
|
3628
|
+
const ctx = await workspaceContext({
|
|
3629
|
+
workspace: args.workspace,
|
|
3630
|
+
configPath: cli.configPath,
|
|
3631
|
+
apiUrl: opts.apiUrl,
|
|
3632
|
+
token: opts.token
|
|
3633
|
+
});
|
|
3634
|
+
return textResult(await syncContext({ ...ctx, confirm: args.confirm }));
|
|
3635
|
+
} catch (err) {
|
|
3636
|
+
return errorResult(err);
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
);
|
|
3640
|
+
server.tool(
|
|
3641
|
+
"api_health",
|
|
3642
|
+
"Check Flash DSM FastAPI /health endpoint.",
|
|
3643
|
+
{},
|
|
3644
|
+
async () => {
|
|
3645
|
+
try {
|
|
3646
|
+
const result = await api.health();
|
|
3647
|
+
return textResult(result);
|
|
3648
|
+
} catch (err) {
|
|
3649
|
+
return errorResult(err);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
);
|
|
3653
|
+
server.tool(
|
|
3654
|
+
"list_design_systems",
|
|
3655
|
+
"List design systems from FastAPI (requires --access-token / FLASH_DS_ACCESS_TOKEN).",
|
|
3656
|
+
{},
|
|
3657
|
+
async () => {
|
|
3658
|
+
try {
|
|
3659
|
+
const result = await api.listDesignSystems();
|
|
3660
|
+
return textResult(result);
|
|
3661
|
+
} catch (err) {
|
|
3662
|
+
return errorResult(err);
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
);
|
|
3666
|
+
return server;
|
|
3667
|
+
}
|
|
3668
|
+
async function resolveApiUrl(cli, cwd) {
|
|
3669
|
+
if (cli.apiUrl) return cli.apiUrl;
|
|
3670
|
+
const fromEnv = process.env.FLASH_GENOME_API_URL || process.env.FLASH_DS_API_URL;
|
|
3671
|
+
if (fromEnv) return fromEnv;
|
|
3672
|
+
for (const path of configCandidates(cli.configPath, cwd)) {
|
|
3673
|
+
const raw = await readFile12(path, "utf8").catch(() => null);
|
|
3674
|
+
if (!raw) continue;
|
|
3675
|
+
try {
|
|
3676
|
+
const parsed = GenomeConfigSchema.safeParse(JSON.parse(raw));
|
|
3677
|
+
if (parsed.success) return parsed.data.apiUrl;
|
|
3678
|
+
} catch {
|
|
3679
|
+
continue;
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
return "https://flashdsm.com";
|
|
3683
|
+
}
|
|
3684
|
+
async function runLink(cli) {
|
|
3685
|
+
const token = cli.token || process.env.FLASH_GENOME_TOKEN || "";
|
|
3686
|
+
if (!token) {
|
|
3687
|
+
console.error(
|
|
3688
|
+
"flash-genome link needs an MCP token. Mint one in Flash under Connect, then run it again with --token flash_mcp_\u2026 or FLASH_GENOME_TOKEN set."
|
|
3689
|
+
);
|
|
3690
|
+
process.exit(1);
|
|
3691
|
+
}
|
|
3692
|
+
const cwd = process.cwd();
|
|
3693
|
+
const apiUrl = await resolveApiUrl(cli, cwd);
|
|
3694
|
+
const client = createRpcClient({ baseUrl: apiUrl, token });
|
|
3695
|
+
try {
|
|
3696
|
+
const result = await linkGenome({ client, workspaceRoot: cwd, apiUrl });
|
|
3697
|
+
console.log(describeLink(result));
|
|
3698
|
+
} catch (err) {
|
|
3699
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
3700
|
+
process.exit(1);
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
3703
|
+
async function main() {
|
|
3704
|
+
const cli = parseCliArgs(process.argv.slice(2));
|
|
3705
|
+
if (cli.command === "link") return runLink(cli);
|
|
3706
|
+
if (cli.command) {
|
|
3707
|
+
console.error(
|
|
3708
|
+
`Unknown command "${cli.command}". Run flash-genome --help for the list.`
|
|
3709
|
+
);
|
|
3710
|
+
process.exit(1);
|
|
3711
|
+
}
|
|
3712
|
+
const apiUrl = await resolveApiUrl(cli, process.cwd());
|
|
3713
|
+
const accessToken = cli.accessToken || process.env.FLASH_DS_ACCESS_TOKEN || null;
|
|
3714
|
+
let tokenFile;
|
|
3715
|
+
try {
|
|
3716
|
+
tokenFile = await resolveTokenFilePath({
|
|
3717
|
+
tokensPath: cli.tokensPath,
|
|
3718
|
+
configPath: cli.configPath,
|
|
3719
|
+
cwd: process.cwd()
|
|
3720
|
+
});
|
|
3721
|
+
} catch (err) {
|
|
3722
|
+
tokenFile = resolve11(PACKAGE_ROOT, "fixtures", "design-tokens.json");
|
|
3723
|
+
console.error(
|
|
3724
|
+
`[flash-genome] resolve failed (${err instanceof Error ? err.message : err}); using ${tokenFile}`
|
|
3725
|
+
);
|
|
3726
|
+
}
|
|
3727
|
+
let componentsFile;
|
|
3728
|
+
try {
|
|
3729
|
+
componentsFile = await resolveComponentsFilePath({
|
|
3730
|
+
componentsPath: cli.componentsPath,
|
|
3731
|
+
configPath: cli.configPath,
|
|
3732
|
+
tokenFile,
|
|
3733
|
+
cwd: process.cwd()
|
|
3734
|
+
});
|
|
3735
|
+
} catch {
|
|
3736
|
+
componentsFile = resolve11(PACKAGE_ROOT, "fixtures", "components.json");
|
|
3737
|
+
}
|
|
3738
|
+
console.error(`[flash-genome] tokens: ${tokenFile}`);
|
|
3739
|
+
console.error(`[flash-genome] components: ${componentsFile}`);
|
|
3740
|
+
console.error(`[flash-genome] api: ${apiUrl}`);
|
|
3741
|
+
console.error(`[flash-genome] tools: ${TOOL_NAMES.join(", ")}`);
|
|
3742
|
+
const server = await createServer({
|
|
3743
|
+
tokenFile,
|
|
3744
|
+
componentsFile,
|
|
3745
|
+
apiUrl,
|
|
3746
|
+
accessToken,
|
|
3747
|
+
token: cli.token || process.env.FLASH_GENOME_TOKEN || null,
|
|
3748
|
+
configPath: cli.configPath
|
|
3749
|
+
});
|
|
3750
|
+
const transport = new StdioServerTransport();
|
|
3751
|
+
await server.connect(transport);
|
|
3752
|
+
}
|
|
3753
|
+
function isEntryPoint(argv1, moduleUrl) {
|
|
3754
|
+
if (argv1 === void 0) return false;
|
|
3755
|
+
const real = (p) => {
|
|
3756
|
+
try {
|
|
3757
|
+
return realpathSync(p);
|
|
3758
|
+
} catch {
|
|
3759
|
+
return resolve11(p);
|
|
3760
|
+
}
|
|
3761
|
+
};
|
|
3762
|
+
return real(fileURLToPath(moduleUrl)) === real(argv1);
|
|
3763
|
+
}
|
|
3764
|
+
var invokedDirectly = isEntryPoint(process.argv[1], import.meta.url);
|
|
3765
|
+
if (invokedDirectly) {
|
|
3766
|
+
main().catch((err) => {
|
|
3767
|
+
console.error("[flash-genome] fatal:", err);
|
|
3768
|
+
process.exit(1);
|
|
3769
|
+
});
|
|
3770
|
+
}
|
|
3771
|
+
export {
|
|
3772
|
+
TOOL_NAMES,
|
|
3773
|
+
createServer,
|
|
3774
|
+
isEntryPoint
|
|
3775
|
+
};
|