@amityco/social-plus-vise 0.8.1 → 0.12.2
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/CHANGELOG.md +207 -0
- package/README.md +107 -40
- package/dist/capabilities.js +447 -0
- package/dist/outcomes.js +463 -5
- package/dist/server.js +115 -3
- package/dist/tools/ast.js +25 -0
- package/dist/tools/compliance.js +88 -20
- package/dist/tools/debug.js +267 -0
- package/dist/tools/design.js +1496 -0
- package/dist/tools/docs.js +9 -4
- package/dist/tools/harness.js +17 -1
- package/dist/tools/integration.js +83 -7
- package/dist/tools/project.js +872 -67
- package/dist/tools/sdkVersion.js +129 -0
- package/dist/types.js +4 -0
- package/package.json +27 -6
- package/rules/auth.yaml +298 -38
- package/rules/comments.yaml +0 -72
- package/rules/feed.yaml +1151 -12
- package/rules/live-data.yaml +316 -36
- package/rules/push.yaml +140 -0
- package/rules/sdk-lifecycle.yaml +1428 -138
- package/rules/security.yaml +60 -0
- package/skills/social-plus-vise/SKILL.md +98 -55
- package/skills/social-plus-vise/reference/debugging.md +39 -0
- package/skills/social-plus-vise/reference/operations.md +59 -0
- package/skills/vise-harness-engineer/SKILL.md +35 -0
- package/social.plus-vise.png +0 -0
|
@@ -0,0 +1,1496 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
|
|
5
|
+
import { packageVersion } from "../version.js";
|
|
6
|
+
import { tryParse } from "./ast.js";
|
|
7
|
+
/**
|
|
8
|
+
* Design-contract extractor.
|
|
9
|
+
*
|
|
10
|
+
* Ingests an HTML/CSS prototype and produces a *graded* design contract:
|
|
11
|
+
* declared CSS custom properties are recorded as EXACT tokens; repeated literal
|
|
12
|
+
* values are recorded as INFERRED tokens; component shapes from the DOM are
|
|
13
|
+
* recorded as ADVISORY observations. Provenance is first-class so downstream
|
|
14
|
+
* consumers never treat an inferred value as authoritative.
|
|
15
|
+
*
|
|
16
|
+
* Design principle (mirrors `tryParse` degrading to regex): a messy prototype
|
|
17
|
+
* yields a *weaker* contract, never a *wrong* one. Less signal -> fewer tokens,
|
|
18
|
+
* never fabricated ones.
|
|
19
|
+
*/
|
|
20
|
+
export const DESIGN_CONTRACT_SCHEMA_VERSION = 1;
|
|
21
|
+
export const DESIGN_CONTRACT_FILENAME = "design-contract.json";
|
|
22
|
+
/** A literal value must appear at least this many times to become an inferred token. Single-use literals are one-offs (a scrim, one accent), not design tokens. */
|
|
23
|
+
export const INFERRED_MIN_USES = 2;
|
|
24
|
+
/** Bound the prototype walk so a large input directory cannot stall extraction. */
|
|
25
|
+
const MAX_PROTOTYPE_FILES = 300;
|
|
26
|
+
/** Bound the source scan for the advisory `design check`. */
|
|
27
|
+
const MAX_SCAN_FILES = 2000;
|
|
28
|
+
/** Cap the off-contract sample so the advisory report stays readable. */
|
|
29
|
+
const OFF_CONTRACT_SAMPLE = 20;
|
|
30
|
+
const SCAN_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".dart", ".kt", ".java", ".swift", ".css", ".scss", ".vue", ".xml"]);
|
|
31
|
+
/** Extensions whose design tokens are extracted by parsing object literals (TS/JS token modules, tailwind config). */
|
|
32
|
+
const MODULE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
33
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
34
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", "vendor", ".turbo", ".cache"]);
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Public entry points
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
export const designExtractTool = {
|
|
39
|
+
name: "design_extract",
|
|
40
|
+
description: "Extract a graded design contract (declared/inferred tokens + advisory components) for design-conformant social.plus UI. Source is an HTML/CSS prototype, or — with fromProject — the host project's own design system (CSS vars, token modules, tailwind config).",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
prototypePath: { type: "string", description: "Path to an HTML/CSS prototype file or a directory of them. Omit when fromProject is true." },
|
|
45
|
+
fromProject: { type: "boolean", description: "Derive the contract from the host project's own detected design system (no external prototype). Uses repoPath as the project root." },
|
|
46
|
+
repoPath: { type: "string", description: "Project root: the sp-vise/ sidecar to write, and (with fromProject) the design system to read. Defaults to the current directory." },
|
|
47
|
+
write: { type: "boolean", description: "Write sp-vise/design-contract.json (default true). When false, only returns the contract." },
|
|
48
|
+
},
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
async call(input) {
|
|
52
|
+
const args = objectInput(input);
|
|
53
|
+
const fromProject = args.fromProject === true;
|
|
54
|
+
const repoPath = optionalStringField(args, "repoPath") ?? ".";
|
|
55
|
+
const write = args.write !== false;
|
|
56
|
+
const contract = fromProject ? await extractDesignContractFromProject(repoPath) : await extractDesignContract(stringField(args, "prototypePath"));
|
|
57
|
+
let written;
|
|
58
|
+
if (write) {
|
|
59
|
+
written = await writeDesignContract(repoPath, contract);
|
|
60
|
+
}
|
|
61
|
+
const empty = contract.source.file_count === 0;
|
|
62
|
+
return textResult({
|
|
63
|
+
status: empty ? "no-input" : "extracted",
|
|
64
|
+
source: contract.source.kind,
|
|
65
|
+
written,
|
|
66
|
+
digest: contract.digest,
|
|
67
|
+
strength: contract.stats.strength,
|
|
68
|
+
stats: contract.stats,
|
|
69
|
+
sources: contract.source.inputs,
|
|
70
|
+
tokenSummary: summarizeTokens(contract.tokens),
|
|
71
|
+
breakpoints: contract.breakpoints.map((bp) => bp.raw),
|
|
72
|
+
components: contract.components.map((component) => component.name),
|
|
73
|
+
guidance: empty
|
|
74
|
+
? fromProject
|
|
75
|
+
? "No design system detected in this project (no theme/token module, tailwind config with concrete values, or CSS custom properties). Ask the customer for a prototype, or point to their design source."
|
|
76
|
+
: "No HTML/CSS found at the prototype path. Pass a path to a prototype file or a directory containing one."
|
|
77
|
+
: contract.stats.strength === "weak"
|
|
78
|
+
? "Weak contract: few named tokens were found. Generated UI can use the inferred tokens as advisory guidance; confirm brand values with the customer."
|
|
79
|
+
: `${fromProject ? "Derived from the host project's own design system. " : ""}Build the feed using the declared tokens, fall back to inferred tokens as advisory, and record the contract digest at attestation time.`,
|
|
80
|
+
where: written ? `Design contract written to ${written}.` : "Contract not written (write=false).",
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
export async function extractDesignContract(prototypePath) {
|
|
85
|
+
const resolved = path.resolve(prototypePath);
|
|
86
|
+
const sources = await readPrototypeSources(resolved);
|
|
87
|
+
return buildDesignContract(sources, { kind: "html-css-prototype", inputs: sources.inputs, file_count: sources.inputs.length });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Derive a design contract from the *host project's own* design system, for when
|
|
91
|
+
* the customer gives no external prototype. Scopes to the design-source files
|
|
92
|
+
* Vise detects (theme/token modules, tailwind config, global CSS) — never the
|
|
93
|
+
* whole repo — and extracts: CSS custom properties (incl. shadcn `:root` and
|
|
94
|
+
* Tailwind v4 `@theme`), plus concrete tokens from TS/JS token modules and
|
|
95
|
+
* inline tailwind configs. References (`var()`/`theme()`/`calc()`) are rejected,
|
|
96
|
+
* so a var-mapped config contributes nothing rather than wrong tokens.
|
|
97
|
+
*/
|
|
98
|
+
export async function extractDesignContractFromProject(repoPath) {
|
|
99
|
+
const root = path.resolve(repoPath);
|
|
100
|
+
const files = await findProjectDesignFiles(root);
|
|
101
|
+
const css = [];
|
|
102
|
+
const moduleTokens = [];
|
|
103
|
+
const inputs = [];
|
|
104
|
+
for (const abs of files) {
|
|
105
|
+
const ext = path.extname(abs).toLowerCase();
|
|
106
|
+
const rel = path.relative(root, abs);
|
|
107
|
+
let content;
|
|
108
|
+
try {
|
|
109
|
+
const info = await stat(abs);
|
|
110
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
content = await readFile(abs, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ext === ".css" || ext === ".scss") {
|
|
119
|
+
css.push(content);
|
|
120
|
+
inputs.push(rel);
|
|
121
|
+
}
|
|
122
|
+
else if (MODULE_EXTS.has(ext)) {
|
|
123
|
+
const tokens = extractTokensFromModule(content);
|
|
124
|
+
if (tokens.length > 0) {
|
|
125
|
+
moduleTokens.push(...tokens);
|
|
126
|
+
inputs.push(rel);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (ext === ".xml") {
|
|
130
|
+
const tokens = extractTokensFromAndroidXml(content);
|
|
131
|
+
if (tokens.length > 0) {
|
|
132
|
+
moduleTokens.push(...tokens);
|
|
133
|
+
inputs.push(rel);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (ext === ".dart") {
|
|
137
|
+
const tokens = extractTokensFromDart(content);
|
|
138
|
+
if (tokens.length > 0) {
|
|
139
|
+
moduleTokens.push(...tokens);
|
|
140
|
+
inputs.push(rel);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (ext === ".json") {
|
|
144
|
+
const name = path.basename(path.dirname(abs)).replace(/\.colorset$/i, "");
|
|
145
|
+
const tokens = extractTokensFromColorset(content, name);
|
|
146
|
+
if (tokens.length > 0) {
|
|
147
|
+
moduleTokens.push(...tokens);
|
|
148
|
+
inputs.push(rel);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (ext === ".swift") {
|
|
152
|
+
const tokens = extractTokensFromSwift(content);
|
|
153
|
+
if (tokens.length > 0) {
|
|
154
|
+
moduleTokens.push(...tokens);
|
|
155
|
+
inputs.push(rel);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
inputs.sort();
|
|
160
|
+
return buildDesignContract({ css, html: [], inputs }, { kind: "host-project", inputs, file_count: inputs.length }, moduleTokens);
|
|
161
|
+
}
|
|
162
|
+
export async function writeDesignContract(repoPath, contract) {
|
|
163
|
+
const sidecarDir = path.join(path.resolve(repoPath), "sp-vise");
|
|
164
|
+
await mkdir(sidecarDir, { recursive: true });
|
|
165
|
+
const target = path.join(sidecarDir, DESIGN_CONTRACT_FILENAME);
|
|
166
|
+
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`, "utf8");
|
|
167
|
+
return target;
|
|
168
|
+
}
|
|
169
|
+
export async function readDesignContract(repoPath) {
|
|
170
|
+
const target = path.join(path.resolve(repoPath), "sp-vise", DESIGN_CONTRACT_FILENAME);
|
|
171
|
+
try {
|
|
172
|
+
const raw = await readFile(target, "utf8");
|
|
173
|
+
const parsed = JSON.parse(raw);
|
|
174
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tokens)) {
|
|
175
|
+
return parsed;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Visual contract review + conformance report (advisory, dependency-free)
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
//
|
|
187
|
+
// Vise produces the visual artifact; a human (or a VLM) judges whether the
|
|
188
|
+
// generated UI matches. This is NOT an automated pixel/render diff — that would
|
|
189
|
+
// need a headless browser (non-deterministic, heavy dep) and does not belong in
|
|
190
|
+
// Vise's deterministic, dependency-free core. The honest comparison data is:
|
|
191
|
+
// (1) the contract's tokens rendered as visual swatches, (2) the actual HTML
|
|
192
|
+
// reference embedded beside them when renderable, and (3) the `design check`
|
|
193
|
+
// conformance numbers (coverage + on/off-contract) as the textual diff.
|
|
194
|
+
export const designPreviewTool = {
|
|
195
|
+
name: "design_preview",
|
|
196
|
+
description: "Generate a self-contained HTML visual review of the design contract (token swatches + embedded HTML reference + conformance report) for human/VLM judgment. Advisory, non-blocking; not an automated pixel diff.",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: {
|
|
200
|
+
repoPath: { type: "string", description: "Project root containing sp-vise/design-contract.json. Defaults to the current directory." },
|
|
201
|
+
reference: { type: "string", description: "Optional path to the HTML/CSS prototype to embed for side-by-side visual comparison." },
|
|
202
|
+
write: { type: "boolean", description: "Write sp-vise/design-preview.html (default true)." },
|
|
203
|
+
},
|
|
204
|
+
additionalProperties: false,
|
|
205
|
+
},
|
|
206
|
+
async call(input) {
|
|
207
|
+
const args = objectInput(input);
|
|
208
|
+
const repoPath = optionalStringField(args, "repoPath") ?? ".";
|
|
209
|
+
const referencePath = optionalStringField(args, "reference");
|
|
210
|
+
const write = args.write !== false;
|
|
211
|
+
const contract = await readDesignContract(repoPath);
|
|
212
|
+
if (!contract) {
|
|
213
|
+
return textResult({
|
|
214
|
+
status: "no-contract",
|
|
215
|
+
message: "No sp-vise/design-contract.json found. Run `vise design extract` (or `--from-project`) first.",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const check = await runDesignCheck(repoPath).catch(() => null);
|
|
219
|
+
const reference = referencePath ? await readReferenceHtml(referencePath) : null;
|
|
220
|
+
const html = renderDesignPreview(contract, check && check.status === "advisory" ? check : null, reference);
|
|
221
|
+
let written;
|
|
222
|
+
if (write) {
|
|
223
|
+
const target = path.join(path.resolve(repoPath), "sp-vise", "design-preview.html");
|
|
224
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
225
|
+
await writeFile(target, html, "utf8");
|
|
226
|
+
written = target;
|
|
227
|
+
}
|
|
228
|
+
return textResult({
|
|
229
|
+
status: "rendered",
|
|
230
|
+
written,
|
|
231
|
+
digest: contract.digest,
|
|
232
|
+
strength: contract.stats.strength,
|
|
233
|
+
referenceEmbedded: Boolean(reference),
|
|
234
|
+
conformance: check && check.status === "advisory" ? { coverage: `${check.tokenCoverage?.referenced}/${check.tokenCoverage?.declared_total}`, off_contract: check.colorLiterals?.off_contract } : "no UI code scanned",
|
|
235
|
+
note: "Open the HTML to visually compare the contract (and embedded reference, if HTML) against your app. A human or VLM judges the visual match — this is not an automated pixel diff.",
|
|
236
|
+
where: written ? `Open ${written} in a browser.` : "Not written (write=false).",
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
/** Read an HTML prototype into a self-contained string (inlining linked stylesheets) for embedding. Returns null if there's no renderable HTML. */
|
|
241
|
+
export async function readReferenceHtml(referencePath) {
|
|
242
|
+
const resolved = path.resolve(referencePath);
|
|
243
|
+
let htmlFile = null;
|
|
244
|
+
try {
|
|
245
|
+
const info = await stat(resolved);
|
|
246
|
+
if (info.isDirectory()) {
|
|
247
|
+
const files = await collectFiles(resolved, 200);
|
|
248
|
+
htmlFile = files.find((f) => /\.html?$/i.test(f)) ?? null;
|
|
249
|
+
}
|
|
250
|
+
else if (/\.html?$/i.test(resolved)) {
|
|
251
|
+
htmlFile = resolved;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (!htmlFile) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
let html;
|
|
261
|
+
try {
|
|
262
|
+
const info = await stat(htmlFile);
|
|
263
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
html = await readFile(htmlFile, "utf8");
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
// Inline <link rel="stylesheet" href="..."> so the embed is self-contained.
|
|
272
|
+
const dir = path.dirname(htmlFile);
|
|
273
|
+
const linkPattern = /<link[^>]*rel\s*=\s*["']stylesheet["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
274
|
+
const links = [...html.matchAll(linkPattern)];
|
|
275
|
+
for (const link of links) {
|
|
276
|
+
const href = link[1];
|
|
277
|
+
if (/^https?:|^\/\//i.test(href)) {
|
|
278
|
+
continue; // external — leave as-is (won't load in sandboxed iframe, that's fine)
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const cssPath = path.join(dir, href);
|
|
282
|
+
const css = await readFile(cssPath, "utf8");
|
|
283
|
+
html = html.replace(link[0], `<style>\n${css}\n</style>`);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// leave the link; missing CSS just won't apply
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return html;
|
|
290
|
+
}
|
|
291
|
+
export function renderDesignPreview(contract, check, referenceHtml) {
|
|
292
|
+
const byCat = (category) => contract.tokens.filter((t) => t.category === category);
|
|
293
|
+
const tokenRow = (t, sample) => `<div class="tok">${sample}<div class="meta"><code>${esc(t.name ?? "—")}</code><code class="val">${esc(t.value)}</code>${t.provenance === "inferred" ? '<span class="inf">inferred</span>' : ""}</div></div>`;
|
|
294
|
+
const colors = byCat("color").map((t) => tokenRow(t, `<span class="sw" style="background:${safeCss(t.value)}"></span>`)).join("");
|
|
295
|
+
const spaces = byCat("space").map((t) => tokenRow(t, `<span class="bar" style="width:${safeCss(t.value)}"></span>`)).join("");
|
|
296
|
+
const radii = byCat("radius").map((t) => tokenRow(t, `<span class="box" style="border-radius:${safeCss(t.value)}"></span>`)).join("");
|
|
297
|
+
const shadows = byCat("shadow").map((t) => tokenRow(t, `<span class="box" style="box-shadow:${safeCss(t.value)}"></span>`)).join("");
|
|
298
|
+
const fonts = byCat("fontFamily").map((t) => tokenRow(t, `<span class="type" style="font-family:${safeCss(t.value)}">Ag</span>`)).join("");
|
|
299
|
+
const sizes = byCat("fontSize").map((t) => tokenRow(t, `<span class="type" style="font-size:${safeCss(t.value)}">Ag</span>`)).join("");
|
|
300
|
+
const motions = byCat("motion").map((t) => tokenRow(t, `<span class="mo">${esc(t.value)}</span>`)).join("");
|
|
301
|
+
const group = (title, body) => (body ? `<h2>${esc(title)}</h2><div class="grid">${body}</div>` : "");
|
|
302
|
+
const reference = referenceHtml
|
|
303
|
+
? `<iframe class="ref" sandbox="allow-same-origin" srcdoc="${escAttr(referenceHtml)}" title="reference prototype"></iframe>`
|
|
304
|
+
: `<p class="muted">No renderable HTML reference. (Host-project / native sources — Android XML, Flutter, iOS — can't be rendered dependency-free; review the swatches against your app by eye.)</p>`;
|
|
305
|
+
let conformance = `<p class="muted">No UI code scanned against the contract (run in a project that has both the contract and source).</p>`;
|
|
306
|
+
if (check) {
|
|
307
|
+
const cov = check.tokenCoverage;
|
|
308
|
+
const cl = check.colorLiterals;
|
|
309
|
+
const off = (cl?.sample ?? []).map((s) => `<li><code>${esc(s.value)}</code> <span class="muted">${esc(s.file)}</span></li>`).join("");
|
|
310
|
+
const undef = (check.undefinedTokenRefs?.sample ?? []).map((s) => `<li><code>${esc(s.token)}</code> <span class="muted">${esc(s.file)}</span></li>`).join("");
|
|
311
|
+
conformance =
|
|
312
|
+
`<p><b>${cov?.referenced}/${cov?.declared_total}</b> declared tokens referenced · <b>${cl?.off_contract}</b> of ${cl?.total} color literals off-contract${(check.undefinedTokenRefs?.count ?? 0) > 0 ? ` · <b>${check.undefinedTokenRefs?.count}</b> undefined token refs` : ""}.</p>` +
|
|
313
|
+
(cov?.unreferenced_tokens.length ? `<p class="muted">Unreferenced: ${cov.unreferenced_tokens.map((t) => `<code>${esc(t)}</code>`).join(" ")}</p>` : "") +
|
|
314
|
+
(off ? `<p>Off-contract color literals (review — one-offs like scrims are fine):</p><ul>${off}</ul>` : "") +
|
|
315
|
+
(undef ? `<p>Undefined token references (likely broken styles):</p><ul>${undef}</ul>` : "");
|
|
316
|
+
}
|
|
317
|
+
return `<!doctype html>
|
|
318
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
319
|
+
<title>Vise design preview — ${esc(contract.digest.slice(0, 16))}</title>
|
|
320
|
+
<style>
|
|
321
|
+
:root { color-scheme: light dark; }
|
|
322
|
+
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 24px; max-width: 1100px; margin: 0 auto; }
|
|
323
|
+
header { border-bottom: 1px solid #8884; padding-bottom: 12px; margin-bottom: 20px; }
|
|
324
|
+
h1 { font-size: 20px; margin: 0 0 4px; } h2 { font-size: 15px; margin: 24px 0 8px; text-transform: uppercase; letter-spacing: .04em; opacity: .7; }
|
|
325
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #8882; font-size: 12px; margin-right: 6px; }
|
|
326
|
+
.advisory { background: #f5a62333; padding: 8px 12px; border-radius: 8px; font-size: 13px; margin: 12px 0; }
|
|
327
|
+
.grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
|
328
|
+
.tok { display: flex; flex-direction: column; gap: 6px; min-width: 90px; }
|
|
329
|
+
.meta { display: flex; flex-direction: column; gap: 1px; } .meta code { font-size: 11px; } .val { opacity: .7; } .inf { font-size: 10px; opacity: .6; }
|
|
330
|
+
.sw { width: 64px; height: 48px; border-radius: 6px; border: 1px solid #8883; display: block; }
|
|
331
|
+
.bar { height: 16px; background: #6a9; display: block; max-width: 200px; border-radius: 3px; }
|
|
332
|
+
.box { width: 64px; height: 48px; background: #fff; border: 1px solid #8883; display: block; }
|
|
333
|
+
.type { font-size: 28px; display: block; } .mo { font-family: monospace; }
|
|
334
|
+
iframe.ref { width: 100%; height: 520px; border: 1px solid #8884; border-radius: 8px; background: #fff; }
|
|
335
|
+
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
|
|
336
|
+
@media (max-width: 800px) { .cols { grid-template-columns: 1fr; } }
|
|
337
|
+
.muted { opacity: .6; } code { background: #8881; padding: 1px 4px; border-radius: 3px; }
|
|
338
|
+
ul { margin: 4px 0; padding-left: 18px; }
|
|
339
|
+
</style></head>
|
|
340
|
+
<body>
|
|
341
|
+
<header>
|
|
342
|
+
<h1>Design preview & conformance</h1>
|
|
343
|
+
<div><span class="badge">${esc(contract.source.kind)}</span><span class="badge">strength: ${esc(contract.stats.strength)}</span><span class="badge">${contract.stats.declared_tokens} declared + ${contract.stats.inferred_tokens} inferred</span><code>${esc(contract.digest)}</code></div>
|
|
344
|
+
<div class="advisory">Advisory artifact. Vise renders the contract; <b>a human or VLM judges the visual match</b> against the reference and your app. This is not an automated pixel diff.</div>
|
|
345
|
+
</header>
|
|
346
|
+
<div class="cols">
|
|
347
|
+
<div><h2>Reference prototype</h2>${reference}</div>
|
|
348
|
+
<div><h2>Conformance (design check)</h2>${conformance}</div>
|
|
349
|
+
</div>
|
|
350
|
+
${group("Colors", colors)}
|
|
351
|
+
${group("Spacing", spaces)}
|
|
352
|
+
${group("Radius", radii)}
|
|
353
|
+
${group("Shadow", shadows)}
|
|
354
|
+
${group("Type — families", fonts)}
|
|
355
|
+
${group("Type — sizes", sizes)}
|
|
356
|
+
${group("Motion", motions)}
|
|
357
|
+
</body></html>`;
|
|
358
|
+
}
|
|
359
|
+
function esc(s) {
|
|
360
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
361
|
+
}
|
|
362
|
+
function escAttr(s) {
|
|
363
|
+
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
364
|
+
}
|
|
365
|
+
/** Sanitize a contract value before use inside an inline CSS `style` attribute (values come from the user's own files, but keep the preview from breaking out of the attribute). */
|
|
366
|
+
function safeCss(value) {
|
|
367
|
+
return value.replace(/[<>"]/g, "").slice(0, 200);
|
|
368
|
+
}
|
|
369
|
+
const ADVISORY_NOTE = "Advisory only — non-blocking and NOT part of `vise check`. One-off literals (overlays, scrims, pure #fff/#000, gradients) are expected and fine; off-contract literals are review hints, not violations.";
|
|
370
|
+
export const designCheckTool = {
|
|
371
|
+
name: "design_check",
|
|
372
|
+
description: "Advisory (non-blocking) report on how closely the project's UI code matches the extracted design contract. Never fails a build; not a `vise check` gate.",
|
|
373
|
+
inputSchema: {
|
|
374
|
+
type: "object",
|
|
375
|
+
properties: { repoPath: { type: "string", description: "Project root containing sp-vise/design-contract.json. Defaults to the current directory." } },
|
|
376
|
+
additionalProperties: false,
|
|
377
|
+
},
|
|
378
|
+
async call(input) {
|
|
379
|
+
const args = objectInput(input);
|
|
380
|
+
return textResult(await runDesignCheck(optionalStringField(args, "repoPath") ?? "."));
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
export async function runDesignCheck(repoPath) {
|
|
384
|
+
const repoRoot = path.resolve(repoPath);
|
|
385
|
+
const contract = await readDesignContract(repoRoot);
|
|
386
|
+
if (!contract) {
|
|
387
|
+
return {
|
|
388
|
+
status: "no-contract",
|
|
389
|
+
message: "No sp-vise/design-contract.json found. Run `vise design extract <prototype>` first, or rely on the host project's own design system (`vise inspect` lists detected design signals).",
|
|
390
|
+
note: ADVISORY_NOTE,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const files = (await collectFiles(repoRoot, MAX_SCAN_FILES)).filter((file) => SCAN_EXTS.has(path.extname(file).toLowerCase()));
|
|
394
|
+
if (files.length === 0) {
|
|
395
|
+
return { status: "no-sources", message: "No UI source files found to check against the contract.", contract: contractSummary(contract), note: ADVISORY_NOTE };
|
|
396
|
+
}
|
|
397
|
+
const declaredTokens = contract.tokens.filter((token) => token.provenance === "declared");
|
|
398
|
+
const contractColorValues = new Set(contract.tokens.filter((token) => token.category === "color").map((token) => token.value));
|
|
399
|
+
const referenced = new Set();
|
|
400
|
+
const colorSample = [];
|
|
401
|
+
const definedVars = new Set();
|
|
402
|
+
const varRefs = [];
|
|
403
|
+
let totalColors = 0;
|
|
404
|
+
let onContract = 0;
|
|
405
|
+
let scanned = 0;
|
|
406
|
+
for (const file of files) {
|
|
407
|
+
let content;
|
|
408
|
+
try {
|
|
409
|
+
const info = await stat(file);
|
|
410
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
content = await readFile(file, "utf8");
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
scanned += 1;
|
|
419
|
+
const rel = path.relative(repoRoot, file);
|
|
420
|
+
const isCss = file.toLowerCase().endsWith(".css") || file.toLowerCase().endsWith(".scss");
|
|
421
|
+
// Token coverage: a declared token is "referenced" if its var name OR its value appears in the code.
|
|
422
|
+
for (const token of declaredTokens) {
|
|
423
|
+
const key = tokenKey(token);
|
|
424
|
+
if (referenced.has(key)) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if ((token.name && content.includes(token.name)) || content.includes(token.value)) {
|
|
428
|
+
referenced.add(key);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Raw color literals: counted, then classified on/off contract.
|
|
432
|
+
for (const value of scanColorLiterals(content)) {
|
|
433
|
+
totalColors += 1;
|
|
434
|
+
if (contractColorValues.has(value)) {
|
|
435
|
+
onContract += 1;
|
|
436
|
+
}
|
|
437
|
+
else if (colorSample.length < OFF_CONTRACT_SAMPLE) {
|
|
438
|
+
colorSample.push({ value, file: rel, on_contract: false });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Token hygiene: collect var(--x) references (any file) and --x: definitions (CSS files).
|
|
442
|
+
for (const name of scanVarReferences(content)) {
|
|
443
|
+
varRefs.push({ token: name, file: rel });
|
|
444
|
+
}
|
|
445
|
+
if (isCss) {
|
|
446
|
+
for (const name of scanVarDefinitions(content)) {
|
|
447
|
+
definedVars.add(name);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const referencedTokens = declaredTokens.filter((token) => referenced.has(tokenKey(token))).map((token) => token.name ?? token.value);
|
|
452
|
+
const unreferencedTokens = declaredTokens.filter((token) => !referenced.has(tokenKey(token))).map((token) => token.name ?? token.value);
|
|
453
|
+
// A var(--x) referenced but defined in no scanned CSS file AND not a known
|
|
454
|
+
// contract token resolves to nothing at runtime — a typo or hallucinated
|
|
455
|
+
// token. Contract token names are excluded: they are legitimate design tokens
|
|
456
|
+
// that may be supplied by an imported/external design-system stylesheet, so
|
|
457
|
+
// flagging them would risk a false positive (Vise's cardinal sin).
|
|
458
|
+
const contractTokenNames = new Set(contract.tokens.map((token) => token.name).filter((name) => Boolean(name)));
|
|
459
|
+
const undefinedRefs = dedupeByToken(varRefs.filter((ref) => !definedVars.has(ref.token) && !contractTokenNames.has(ref.token)));
|
|
460
|
+
return {
|
|
461
|
+
status: "advisory",
|
|
462
|
+
message: `Checked ${scanned} source file(s) against design contract ${contract.digest}. ` +
|
|
463
|
+
`${referencedTokens.length}/${declaredTokens.length} declared tokens referenced; ${totalColors - onContract} of ${totalColors} color literal(s) are off-contract (review hints)` +
|
|
464
|
+
(undefinedRefs.length > 0 ? `; ${undefinedRefs.length} undefined token reference(s) (likely broken styles)` : "") +
|
|
465
|
+
".",
|
|
466
|
+
contract: contractSummary(contract),
|
|
467
|
+
tokenCoverage: {
|
|
468
|
+
declared_total: declaredTokens.length,
|
|
469
|
+
referenced: referencedTokens.length,
|
|
470
|
+
referenced_tokens: referencedTokens,
|
|
471
|
+
unreferenced_tokens: unreferencedTokens,
|
|
472
|
+
},
|
|
473
|
+
colorLiterals: {
|
|
474
|
+
scanned_files: scanned,
|
|
475
|
+
total: totalColors,
|
|
476
|
+
on_contract: onContract,
|
|
477
|
+
off_contract: totalColors - onContract,
|
|
478
|
+
sample: colorSample,
|
|
479
|
+
},
|
|
480
|
+
undefinedTokenRefs: {
|
|
481
|
+
count: undefinedRefs.length,
|
|
482
|
+
sample: undefinedRefs.slice(0, OFF_CONTRACT_SAMPLE),
|
|
483
|
+
},
|
|
484
|
+
note: ADVISORY_NOTE,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function dedupeByToken(refs) {
|
|
488
|
+
const seen = new Set();
|
|
489
|
+
const out = [];
|
|
490
|
+
for (const ref of refs) {
|
|
491
|
+
if (!seen.has(ref.token)) {
|
|
492
|
+
seen.add(ref.token);
|
|
493
|
+
out.push(ref);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return out.sort((a, b) => a.token.localeCompare(b.token));
|
|
497
|
+
}
|
|
498
|
+
function scanVarReferences(content) {
|
|
499
|
+
const out = [];
|
|
500
|
+
const pattern = /var\(\s*(--[\w-]+)/g;
|
|
501
|
+
let match;
|
|
502
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
503
|
+
out.push(match[1]);
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
function scanVarDefinitions(content) {
|
|
508
|
+
const out = [];
|
|
509
|
+
// Match `--x:` declarations, but not `var(--x)` references (no colon after the name there).
|
|
510
|
+
const pattern = /(--[\w-]+)\s*:/g;
|
|
511
|
+
let match;
|
|
512
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
513
|
+
out.push(match[1]);
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
function contractSummary(contract) {
|
|
518
|
+
return {
|
|
519
|
+
digest: contract.digest,
|
|
520
|
+
strength: contract.stats.strength,
|
|
521
|
+
declared_tokens: contract.stats.declared_tokens,
|
|
522
|
+
inferred_tokens: contract.stats.inferred_tokens,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function tokenKey(token) {
|
|
526
|
+
return `${token.category}::${token.name ?? token.value}`;
|
|
527
|
+
}
|
|
528
|
+
/** Extract comparable hex color values from a source file: web `#hex`, Flutter/Android `Color(0xAARRGGBB)`. */
|
|
529
|
+
function scanColorLiterals(content) {
|
|
530
|
+
const out = [];
|
|
531
|
+
let match;
|
|
532
|
+
// Web/Android/XML hex (`#RRGGBB`, incl. `<color>#RRGGBB</color>`).
|
|
533
|
+
const hexPattern = /#[0-9a-fA-F]{3,8}\b/g;
|
|
534
|
+
while ((match = hexPattern.exec(content)) !== null) {
|
|
535
|
+
out.push(normalizeHex(match[0]));
|
|
536
|
+
}
|
|
537
|
+
// Flutter/Android ARGB `0xAARRGGBB` -> #rrggbb (drop alpha).
|
|
538
|
+
const argbPattern = /0x([0-9a-fA-F]{8})\b/g;
|
|
539
|
+
while ((match = argbPattern.exec(content)) !== null) {
|
|
540
|
+
out.push(normalizeHex(`#${match[1].slice(2)}`));
|
|
541
|
+
}
|
|
542
|
+
// iOS Swift `Color(hex: "RRGGBB")`.
|
|
543
|
+
const swiftHex = /Color\(\s*hex:\s*"#?([0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?)"/g;
|
|
544
|
+
while ((match = swiftHex.exec(content)) !== null) {
|
|
545
|
+
out.push(normalizeHex(`#${match[1].slice(0, 6)}`));
|
|
546
|
+
}
|
|
547
|
+
// iOS Swift `Color(red: r, green: g, blue: b)` (floats or n/255).
|
|
548
|
+
const swiftRgb = /(?:UI)?Color\(\s*red:\s*([\d./]+)\s*,\s*green:\s*([\d./]+)\s*,\s*blue:\s*([\d./]+)/g;
|
|
549
|
+
while ((match = swiftRgb.exec(content)) !== null) {
|
|
550
|
+
const r = parseColorComponent(match[1]);
|
|
551
|
+
const g = parseColorComponent(match[2]);
|
|
552
|
+
const b = parseColorComponent(match[3]);
|
|
553
|
+
if (r !== null && g !== null && b !== null) {
|
|
554
|
+
out.push(`#${toHex2(r)}${toHex2(g)}${toHex2(b)}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
async function readPrototypeSources(resolved) {
|
|
560
|
+
const css = [];
|
|
561
|
+
const html = [];
|
|
562
|
+
const inputs = [];
|
|
563
|
+
let isDir = false;
|
|
564
|
+
try {
|
|
565
|
+
isDir = (await stat(resolved)).isDirectory();
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return { css, html, inputs };
|
|
569
|
+
}
|
|
570
|
+
const files = isDir ? await collectFiles(resolved) : [resolved];
|
|
571
|
+
for (const file of files) {
|
|
572
|
+
const ext = path.extname(file).toLowerCase();
|
|
573
|
+
if (ext !== ".css" && ext !== ".scss" && ext !== ".html" && ext !== ".htm") {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
let content;
|
|
577
|
+
try {
|
|
578
|
+
const info = await stat(file);
|
|
579
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
content = await readFile(file, "utf8");
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
inputs.push(isDir ? path.relative(resolved, file) : path.basename(file));
|
|
588
|
+
if (ext === ".css" || ext === ".scss") {
|
|
589
|
+
css.push(content);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
html.push(content);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
inputs.sort();
|
|
596
|
+
return { css, html, inputs };
|
|
597
|
+
}
|
|
598
|
+
async function collectFiles(root, max = MAX_PROTOTYPE_FILES) {
|
|
599
|
+
const out = [];
|
|
600
|
+
const stack = [root];
|
|
601
|
+
while (stack.length > 0 && out.length < max) {
|
|
602
|
+
const dir = stack.pop();
|
|
603
|
+
let entries;
|
|
604
|
+
try {
|
|
605
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
for (const entry of entries) {
|
|
611
|
+
if (out.length >= max) {
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
const full = path.join(dir, entry.name);
|
|
615
|
+
if (entry.isDirectory()) {
|
|
616
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
|
|
617
|
+
stack.push(full);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
else if (entry.isFile()) {
|
|
621
|
+
out.push(full);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// Host-project design-file discovery
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
const DESIGN_FILE_SKIP = new Set([
|
|
631
|
+
...SKIP_DIRS,
|
|
632
|
+
"test", "tests", "__tests__", "__mocks__", "example", "examples", "sample", "samples",
|
|
633
|
+
"pods", ".dart_tool", "coverage", "fastlane", "generated", "gen", "snapshots",
|
|
634
|
+
// Platform-runner dirs (Flutter/React Native) — design lives in lib/ or src/,
|
|
635
|
+
// not these. Skipping them keeps the walk from exhausting its budget before
|
|
636
|
+
// reaching the real design files in large native repos.
|
|
637
|
+
"macos", "windows", "linux", "gradle",
|
|
638
|
+
]);
|
|
639
|
+
const MAX_SCAN_DIRS = 60000;
|
|
640
|
+
const MAX_DESIGN_FILES = 60;
|
|
641
|
+
/**
|
|
642
|
+
* Recursively find a project's design-source files by HIGH-SIGNAL name (not every
|
|
643
|
+
* stylesheet — that would inflate noise). Real apps put these at non-standard
|
|
644
|
+
* paths (e.g. `lib/v4/core/theme.dart`, `common/src/main/res/values/colors.xml`),
|
|
645
|
+
* so a fixed root-relative candidate list misses them.
|
|
646
|
+
*/
|
|
647
|
+
async function findProjectDesignFiles(root) {
|
|
648
|
+
const out = [];
|
|
649
|
+
const stack = [root];
|
|
650
|
+
let visited = 0;
|
|
651
|
+
while (stack.length > 0 && out.length < MAX_DESIGN_FILES && visited < MAX_SCAN_DIRS) {
|
|
652
|
+
const dir = stack.pop();
|
|
653
|
+
let entries;
|
|
654
|
+
try {
|
|
655
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
for (const entry of entries) {
|
|
661
|
+
visited += 1;
|
|
662
|
+
const full = path.join(dir, entry.name);
|
|
663
|
+
if (entry.isDirectory()) {
|
|
664
|
+
if (!DESIGN_FILE_SKIP.has(entry.name) && !entry.name.startsWith(".")) {
|
|
665
|
+
stack.push(full);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else if (entry.isFile() && isDesignFile(full)) {
|
|
669
|
+
out.push(full);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return out.sort();
|
|
674
|
+
}
|
|
675
|
+
function isDesignFile(full) {
|
|
676
|
+
const lower = full.replace(/\\/g, "/").toLowerCase();
|
|
677
|
+
const base = path.basename(lower);
|
|
678
|
+
const ext = path.extname(lower);
|
|
679
|
+
if (ext === ".xml") {
|
|
680
|
+
return /\/values(-[a-z0-9-]+)?\//.test(lower) && /^(colors|themes|dimens|styles)\.xml$/.test(base);
|
|
681
|
+
}
|
|
682
|
+
if (ext === ".dart") {
|
|
683
|
+
return /\/lib\//.test(lower) && /(theme|color|token|palette)/.test(base);
|
|
684
|
+
}
|
|
685
|
+
if (ext === ".css" || ext === ".scss") {
|
|
686
|
+
return base === "globals.css" || /(theme|token|design)/.test(base);
|
|
687
|
+
}
|
|
688
|
+
if (ext === ".json") {
|
|
689
|
+
return /\.colorset\//.test(lower) && base === "contents.json"; // iOS asset-catalog color
|
|
690
|
+
}
|
|
691
|
+
if (ext === ".swift") {
|
|
692
|
+
return /(theme|color|palette|style|appearance|design)/.test(base); // iOS design-named Swift
|
|
693
|
+
}
|
|
694
|
+
if (MODULE_EXTS.has(ext)) {
|
|
695
|
+
if (/^tailwind\.config\.(js|ts|cjs|mjs)$/.test(base)) {
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
if (/^(theme|tokens|design-tokens|colors|palette)\.(ts|tsx|js|jsx|mjs|cjs)$/.test(base)) {
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
return /\/(theme|tokens)\/index\.(ts|tsx|js|jsx)$/.test(lower);
|
|
702
|
+
}
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// Contract construction
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = []) {
|
|
709
|
+
// CSS bodies come from .css files plus <style> blocks inside HTML.
|
|
710
|
+
const cssText = [...sources.css, ...sources.html.flatMap(extractStyleBlocks)].join("\n");
|
|
711
|
+
const strippedCss = stripCssComments(cssText);
|
|
712
|
+
const declarations = parseDeclarations(strippedCss);
|
|
713
|
+
const inlineDeclarations = sources.html.flatMap(extractInlineStyles);
|
|
714
|
+
const allDeclarations = [...declarations, ...inlineDeclarations];
|
|
715
|
+
const varReferenceCounts = countVarReferences(strippedCss);
|
|
716
|
+
// --- Declared (exact) tokens: every CSS custom property. ---
|
|
717
|
+
const declaredTokens = [];
|
|
718
|
+
const declaredValues = new Set();
|
|
719
|
+
for (const decl of declarations) {
|
|
720
|
+
if (!decl.prop.startsWith("--")) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const category = categorizeDeclaredVar(decl.prop, decl.value);
|
|
724
|
+
if (!category) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const value = normalizeValue(category, decl.value);
|
|
728
|
+
if (!value) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
declaredValues.add(value);
|
|
732
|
+
declaredTokens.push({
|
|
733
|
+
category,
|
|
734
|
+
name: decl.prop,
|
|
735
|
+
value,
|
|
736
|
+
provenance: "declared",
|
|
737
|
+
uses: varReferenceCounts.get(decl.prop) ?? 0,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
// Declared tokens from non-CSS sources (TS/JS token modules, tailwind config)
|
|
741
|
+
// are exact too. Record their values so inferred clustering won't duplicate them.
|
|
742
|
+
for (const token of extraDeclaredTokens) {
|
|
743
|
+
declaredValues.add(token.value);
|
|
744
|
+
}
|
|
745
|
+
// Collapse duplicate declarations (CSS custom properties + module tokens),
|
|
746
|
+
// keying on category+name, keeping the highest use count.
|
|
747
|
+
const declaredByName = new Map();
|
|
748
|
+
for (const token of [...declaredTokens, ...extraDeclaredTokens]) {
|
|
749
|
+
const key = `${token.category}::${token.name}`;
|
|
750
|
+
const existing = declaredByName.get(key);
|
|
751
|
+
if (!existing || token.uses > existing.uses) {
|
|
752
|
+
declaredByName.set(key, token);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// --- Inferred tokens: literal values observed >= INFERRED_MIN_USES times. ---
|
|
756
|
+
const observations = new Map();
|
|
757
|
+
for (const decl of allDeclarations) {
|
|
758
|
+
if (decl.prop.startsWith("--")) {
|
|
759
|
+
continue; // declared tokens are exact, handled above
|
|
760
|
+
}
|
|
761
|
+
for (const observed of observeLiterals(decl.prop, decl.value)) {
|
|
762
|
+
const key = `${observed.category}::${observed.value}`;
|
|
763
|
+
const existing = observations.get(key);
|
|
764
|
+
if (existing) {
|
|
765
|
+
existing.uses += 1;
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
observations.set(key, { ...observed, uses: 1 });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const inferredTokens = [];
|
|
773
|
+
for (const obs of observations.values()) {
|
|
774
|
+
if (obs.uses < INFERRED_MIN_USES) {
|
|
775
|
+
continue; // one-off literal, not a token
|
|
776
|
+
}
|
|
777
|
+
if (declaredValues.has(obs.value)) {
|
|
778
|
+
continue; // already represented as a declared token
|
|
779
|
+
}
|
|
780
|
+
inferredTokens.push({ category: obs.category, name: null, value: obs.value, provenance: "inferred", uses: obs.uses });
|
|
781
|
+
}
|
|
782
|
+
const tokens = sortTokens([...declaredByName.values(), ...inferredTokens]);
|
|
783
|
+
const breakpoints = parseBreakpoints(strippedCss);
|
|
784
|
+
const components = inferComponents(sources.html, strippedCss);
|
|
785
|
+
const declaredCount = tokens.filter((token) => token.provenance === "declared").length;
|
|
786
|
+
const inferredCount = tokens.filter((token) => token.provenance === "inferred").length;
|
|
787
|
+
const contract = {
|
|
788
|
+
schema_version: DESIGN_CONTRACT_SCHEMA_VERSION,
|
|
789
|
+
foundry_version: packageVersion,
|
|
790
|
+
source: sourceMeta,
|
|
791
|
+
digest: "",
|
|
792
|
+
tokens,
|
|
793
|
+
breakpoints,
|
|
794
|
+
components,
|
|
795
|
+
stats: {
|
|
796
|
+
declared_tokens: declaredCount,
|
|
797
|
+
inferred_tokens: inferredCount,
|
|
798
|
+
advisory_components: components.length,
|
|
799
|
+
strength: gradeStrength(declaredCount, inferredCount),
|
|
800
|
+
},
|
|
801
|
+
};
|
|
802
|
+
contract.digest = digestContract(contract);
|
|
803
|
+
return contract;
|
|
804
|
+
}
|
|
805
|
+
function gradeStrength(declared, inferred) {
|
|
806
|
+
if (declared >= 6) {
|
|
807
|
+
return "strong";
|
|
808
|
+
}
|
|
809
|
+
if (declared >= 1 || inferred >= 4) {
|
|
810
|
+
return "partial";
|
|
811
|
+
}
|
|
812
|
+
return "weak";
|
|
813
|
+
}
|
|
814
|
+
export function stripCssComments(css) {
|
|
815
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
816
|
+
}
|
|
817
|
+
/** Parse declarations inside the innermost `{ ... }` rule bodies. */
|
|
818
|
+
export function parseDeclarations(css) {
|
|
819
|
+
const out = [];
|
|
820
|
+
const bodyPattern = /\{([^{}]*)\}/g;
|
|
821
|
+
let match;
|
|
822
|
+
while ((match = bodyPattern.exec(css)) !== null) {
|
|
823
|
+
out.push(...splitDeclarations(match[1] ?? ""));
|
|
824
|
+
}
|
|
825
|
+
return out;
|
|
826
|
+
}
|
|
827
|
+
function splitDeclarations(body) {
|
|
828
|
+
const out = [];
|
|
829
|
+
for (const chunk of body.split(";")) {
|
|
830
|
+
const idx = chunk.indexOf(":");
|
|
831
|
+
if (idx <= 0) {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const prop = chunk.slice(0, idx).trim().toLowerCase();
|
|
835
|
+
const value = chunk.slice(idx + 1).trim();
|
|
836
|
+
if (!prop || !value || /[{}]/.test(prop)) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
out.push({ prop, value });
|
|
840
|
+
}
|
|
841
|
+
return out;
|
|
842
|
+
}
|
|
843
|
+
function countVarReferences(css) {
|
|
844
|
+
const counts = new Map();
|
|
845
|
+
const pattern = /var\(\s*(--[\w-]+)/g;
|
|
846
|
+
let match;
|
|
847
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
848
|
+
const name = match[1];
|
|
849
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
850
|
+
}
|
|
851
|
+
return counts;
|
|
852
|
+
}
|
|
853
|
+
export function extractStyleBlocks(html) {
|
|
854
|
+
const out = [];
|
|
855
|
+
const pattern = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
856
|
+
let match;
|
|
857
|
+
while ((match = pattern.exec(html)) !== null) {
|
|
858
|
+
out.push(match[1] ?? "");
|
|
859
|
+
}
|
|
860
|
+
return out;
|
|
861
|
+
}
|
|
862
|
+
export function extractInlineStyles(html) {
|
|
863
|
+
const out = [];
|
|
864
|
+
const pattern = /style\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
865
|
+
let match;
|
|
866
|
+
while ((match = pattern.exec(html)) !== null) {
|
|
867
|
+
const body = match[2] ?? match[3] ?? "";
|
|
868
|
+
out.push(...splitDeclarations(body));
|
|
869
|
+
}
|
|
870
|
+
return out;
|
|
871
|
+
}
|
|
872
|
+
export function parseBreakpoints(css) {
|
|
873
|
+
const seen = new Map();
|
|
874
|
+
const pattern = /\(\s*(min|max)-width\s*:\s*([\d.]+)(px|em|rem)\s*\)/gi;
|
|
875
|
+
let match;
|
|
876
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
877
|
+
const edge = match[1].toLowerCase();
|
|
878
|
+
const num = Number(match[2]);
|
|
879
|
+
const unit = match[3].toLowerCase();
|
|
880
|
+
if (!Number.isFinite(num)) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const px = unit === "px" ? num : num * 16;
|
|
884
|
+
const raw = `(${edge}-width: ${match[2]}${unit})`;
|
|
885
|
+
seen.set(`${edge}:${px}`, { edge, px, raw });
|
|
886
|
+
}
|
|
887
|
+
return [...seen.values()].sort((a, b) => a.px - b.px || a.edge.localeCompare(b.edge));
|
|
888
|
+
}
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
// Value classification
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
const COLOR_FUNCTION = /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/i;
|
|
893
|
+
const HEX_COLOR = /^#[0-9a-f]{3,8}$/i;
|
|
894
|
+
const LENGTH = /^-?\d*\.?\d+(px|rem|em|vh|vw|%)$/i;
|
|
895
|
+
const TIME = /\b\d*\.?\d+m?s\b/i;
|
|
896
|
+
const NAMED_COLORS = new Set([
|
|
897
|
+
"black", "white", "red", "green", "blue", "yellow", "orange", "purple", "pink", "gray", "grey",
|
|
898
|
+
"transparent", "currentcolor", "silver", "maroon", "navy", "teal", "olive", "lime", "aqua", "fuchsia",
|
|
899
|
+
"indigo", "violet", "gold", "coral", "salmon", "crimson", "tomato", "slategray", "slategrey",
|
|
900
|
+
]);
|
|
901
|
+
const SPACING_PROPS = /^(padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left)(-|$)/;
|
|
902
|
+
function isColor(value) {
|
|
903
|
+
const trimmed = value.trim();
|
|
904
|
+
return HEX_COLOR.test(trimmed) || COLOR_FUNCTION.test(trimmed) || NAMED_COLORS.has(trimmed.toLowerCase());
|
|
905
|
+
}
|
|
906
|
+
function categorizeDeclaredVar(name, value) {
|
|
907
|
+
const n = name.toLowerCase();
|
|
908
|
+
const v = value.trim();
|
|
909
|
+
if (/shadow|elevation/.test(n)) {
|
|
910
|
+
return "shadow";
|
|
911
|
+
}
|
|
912
|
+
if (/radius|radii|corner|\bround/.test(n)) {
|
|
913
|
+
return "radius";
|
|
914
|
+
}
|
|
915
|
+
if (/(color|colour|bg|background|fg|foreground|surface|border|brand|primary|secondary|accent|fill|stroke|ink|text-color)/.test(n) || isColor(v)) {
|
|
916
|
+
return "color";
|
|
917
|
+
}
|
|
918
|
+
if (/(font-family|fontfamily|typeface|family)/.test(n) && /[a-z]/i.test(v) && !LENGTH.test(v) && !isColor(v)) {
|
|
919
|
+
return "fontFamily";
|
|
920
|
+
}
|
|
921
|
+
if (/(font-size|fontsize|text-size|leading|line-height)/.test(n) || /\btext-(xs|sm|base|md|lg|xl|\dxl|\d)\b/.test(n)) {
|
|
922
|
+
return "fontSize"; // incl. the Tailwind text-scale convention (--text-sm/base/lg)
|
|
923
|
+
}
|
|
924
|
+
// Motion is value-gated: only time/easing values become motion tokens. This
|
|
925
|
+
// stops substring matches like "increase"/"decrease" (which contain "ease")
|
|
926
|
+
// from turning a px length into a nonsensical motion token.
|
|
927
|
+
if (TIME.test(v) || /cubic-bezier|steps\(/i.test(v)) {
|
|
928
|
+
return "motion";
|
|
929
|
+
}
|
|
930
|
+
if (/(duration|easing|transition|motion|animation|\bease)/.test(n) && !LENGTH.test(v) && !isColor(v)) {
|
|
931
|
+
return "motion";
|
|
932
|
+
}
|
|
933
|
+
// Generic font/type hint, but only for an actual family value (not a length —
|
|
934
|
+
// e.g. "prototype" contains "type" but `--prototype-flag: 10px` is not a font).
|
|
935
|
+
if (/(font|type)/.test(n) && /[a-z]/i.test(v) && !LENGTH.test(v) && !isColor(v) && !TIME.test(v)) {
|
|
936
|
+
return "fontFamily";
|
|
937
|
+
}
|
|
938
|
+
if (/(space|spacing|gap|size|gutter|inset|pad|margin)/.test(n) && LENGTH.test(v)) {
|
|
939
|
+
return "space";
|
|
940
|
+
}
|
|
941
|
+
if (isColor(v)) {
|
|
942
|
+
return "color";
|
|
943
|
+
}
|
|
944
|
+
if (LENGTH.test(v)) {
|
|
945
|
+
return "space";
|
|
946
|
+
}
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
/** Emit zero or more (category, normalizedValue) observations from one literal declaration. */
|
|
950
|
+
function observeLiterals(prop, value) {
|
|
951
|
+
const v = value.trim();
|
|
952
|
+
if (!v || v.startsWith("var(") || v === "inherit" || v === "initial" || v === "unset" || v === "none" || v === "auto") {
|
|
953
|
+
return [];
|
|
954
|
+
}
|
|
955
|
+
// Shadows: keep the whole declaration as one token; don't dissect inner colors/lengths.
|
|
956
|
+
if (/shadow/.test(prop)) {
|
|
957
|
+
return [{ category: "shadow", value: collapseSpaces(v) }];
|
|
958
|
+
}
|
|
959
|
+
// Motion: durations/easing.
|
|
960
|
+
if (/^(transition|animation)(-|$)/.test(prop) || TIME.test(v)) {
|
|
961
|
+
if (TIME.test(v)) {
|
|
962
|
+
const time = v.match(/\b\d*\.?\d+m?s\b/i);
|
|
963
|
+
if (time) {
|
|
964
|
+
return [{ category: "motion", value: time[0].toLowerCase() }];
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return [];
|
|
968
|
+
}
|
|
969
|
+
// Font family.
|
|
970
|
+
if (prop === "font-family") {
|
|
971
|
+
return [{ category: "fontFamily", value: collapseSpaces(v) }];
|
|
972
|
+
}
|
|
973
|
+
// Font size.
|
|
974
|
+
if (prop === "font-size" && LENGTH.test(v)) {
|
|
975
|
+
return [{ category: "fontSize", value: v.toLowerCase() }];
|
|
976
|
+
}
|
|
977
|
+
const out = [];
|
|
978
|
+
// Colors anywhere in the value.
|
|
979
|
+
for (const color of extractColorLiterals(v)) {
|
|
980
|
+
out.push({ category: "color", value: color });
|
|
981
|
+
}
|
|
982
|
+
// Lengths — only meaningful as spacing/radius tokens on the relevant props.
|
|
983
|
+
if (/radius/.test(prop)) {
|
|
984
|
+
for (const len of extractLengths(v)) {
|
|
985
|
+
out.push({ category: "radius", value: len });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
else if (SPACING_PROPS.test(prop)) {
|
|
989
|
+
for (const len of extractLengths(v)) {
|
|
990
|
+
out.push({ category: "space", value: len });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return out;
|
|
994
|
+
}
|
|
995
|
+
function extractColorLiterals(value) {
|
|
996
|
+
const out = [];
|
|
997
|
+
const hexPattern = /#[0-9a-f]{3,8}\b/gi;
|
|
998
|
+
let match;
|
|
999
|
+
while ((match = hexPattern.exec(value)) !== null) {
|
|
1000
|
+
out.push(normalizeHex(match[0]));
|
|
1001
|
+
}
|
|
1002
|
+
const funcPattern = /\b(rgb|rgba|hsl|hsla|hwb|oklch|oklab|lab|lch)\(([^)]*)\)/gi;
|
|
1003
|
+
while ((match = funcPattern.exec(value)) !== null) {
|
|
1004
|
+
out.push(`${match[1].toLowerCase()}(${collapseSpaces(match[2] ?? "").replace(/\s*,\s*/g, ",")})`);
|
|
1005
|
+
}
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
function extractLengths(value) {
|
|
1009
|
+
const out = [];
|
|
1010
|
+
const pattern = /-?\d*\.?\d+(px|rem|em)\b/gi;
|
|
1011
|
+
let match;
|
|
1012
|
+
while ((match = pattern.exec(value)) !== null) {
|
|
1013
|
+
const token = match[0].toLowerCase();
|
|
1014
|
+
if (token.startsWith("0") && (token === "0px" || token === "0rem" || token === "0em")) {
|
|
1015
|
+
continue; // zero is not a spacing token
|
|
1016
|
+
}
|
|
1017
|
+
out.push(token);
|
|
1018
|
+
}
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
function normalizeValue(category, value) {
|
|
1022
|
+
const v = value.trim();
|
|
1023
|
+
if (category === "color") {
|
|
1024
|
+
if (HEX_COLOR.test(v)) {
|
|
1025
|
+
return normalizeHex(v);
|
|
1026
|
+
}
|
|
1027
|
+
const fn = v.match(/^(rgb|rgba|hsl|hsla|hwb|oklch|oklab|lab|lch)\(([^)]*)\)/i);
|
|
1028
|
+
if (fn) {
|
|
1029
|
+
return `${fn[1].toLowerCase()}(${collapseSpaces(fn[2] ?? "").replace(/\s*,\s*/g, ",")})`;
|
|
1030
|
+
}
|
|
1031
|
+
return v.toLowerCase();
|
|
1032
|
+
}
|
|
1033
|
+
if (category === "fontFamily" || category === "shadow") {
|
|
1034
|
+
return collapseSpaces(v);
|
|
1035
|
+
}
|
|
1036
|
+
return v.toLowerCase();
|
|
1037
|
+
}
|
|
1038
|
+
function normalizeHex(hex) {
|
|
1039
|
+
let h = hex.toLowerCase();
|
|
1040
|
+
if (h.length === 4) {
|
|
1041
|
+
// #abc -> #aabbcc
|
|
1042
|
+
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
|
|
1043
|
+
}
|
|
1044
|
+
else if (h.length === 5) {
|
|
1045
|
+
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}${h[4]}${h[4]}`;
|
|
1046
|
+
}
|
|
1047
|
+
return h;
|
|
1048
|
+
}
|
|
1049
|
+
function collapseSpaces(value) {
|
|
1050
|
+
return value.replace(/\s+/g, " ").trim();
|
|
1051
|
+
}
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
// Component inference (advisory only)
|
|
1054
|
+
// ---------------------------------------------------------------------------
|
|
1055
|
+
const COMPONENT_NAME_HINTS = /(card|btn|button|avatar|badge|chip|header|footer|nav|navbar|modal|dialog|sheet|list|item|row|tile|tag|pill|toolbar|toast|banner|input|field|composer|message|bubble|post|feed|comment|reaction)/;
|
|
1056
|
+
const COMPONENT_MIN_USES = 3;
|
|
1057
|
+
function inferComponents(htmlSources, css) {
|
|
1058
|
+
if (htmlSources.length === 0) {
|
|
1059
|
+
return [];
|
|
1060
|
+
}
|
|
1061
|
+
const classUses = new Map();
|
|
1062
|
+
const classPattern = /class\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
1063
|
+
for (const html of htmlSources) {
|
|
1064
|
+
let match;
|
|
1065
|
+
while ((match = classPattern.exec(html)) !== null) {
|
|
1066
|
+
const classes = (match[2] ?? match[3] ?? "").split(/\s+/).filter(Boolean);
|
|
1067
|
+
for (const cls of classes) {
|
|
1068
|
+
classUses.set(cls, (classUses.get(cls) ?? 0) + 1);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const ruleStyles = collectClassRuleStyles(css);
|
|
1073
|
+
const candidates = [];
|
|
1074
|
+
for (const [cls, uses] of classUses) {
|
|
1075
|
+
const looksLikeComponent = COMPONENT_NAME_HINTS.test(cls.toLowerCase());
|
|
1076
|
+
if (!looksLikeComponent && uses < COMPONENT_MIN_USES) {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
candidates.push({
|
|
1080
|
+
name: friendlyComponentName(cls),
|
|
1081
|
+
selector: `.${cls}`,
|
|
1082
|
+
uses,
|
|
1083
|
+
confidence: "low",
|
|
1084
|
+
styles: ruleStyles.get(cls) ?? {},
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
candidates.sort((a, b) => b.uses - a.uses || a.name.localeCompare(b.name));
|
|
1088
|
+
return candidates.slice(0, 24);
|
|
1089
|
+
}
|
|
1090
|
+
function collectClassRuleStyles(css) {
|
|
1091
|
+
const out = new Map();
|
|
1092
|
+
const rulePattern = /([^{}]+)\{([^{}]*)\}/g;
|
|
1093
|
+
let match;
|
|
1094
|
+
while ((match = rulePattern.exec(css)) !== null) {
|
|
1095
|
+
const selector = (match[1] ?? "").trim();
|
|
1096
|
+
const simple = selector.match(/^\.([\w-]+)$/);
|
|
1097
|
+
if (!simple) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const cls = simple[1];
|
|
1101
|
+
const styles = {};
|
|
1102
|
+
for (const decl of splitDeclarations(match[2] ?? "")) {
|
|
1103
|
+
if (!decl.prop.startsWith("--")) {
|
|
1104
|
+
styles[decl.prop] = decl.value;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (Object.keys(styles).length > 0) {
|
|
1108
|
+
out.set(cls, styles);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
1113
|
+
function friendlyComponentName(cls) {
|
|
1114
|
+
const lower = cls.toLowerCase();
|
|
1115
|
+
const hint = lower.match(COMPONENT_NAME_HINTS);
|
|
1116
|
+
return hint ? hint[0] : cls;
|
|
1117
|
+
}
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
// Sorting, summarizing, digest
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
const CATEGORY_ORDER = ["color", "space", "radius", "shadow", "fontFamily", "fontSize", "motion"];
|
|
1122
|
+
function sortTokens(tokens) {
|
|
1123
|
+
return [...tokens].sort((a, b) => {
|
|
1124
|
+
const cat = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
|
|
1125
|
+
if (cat !== 0) {
|
|
1126
|
+
return cat;
|
|
1127
|
+
}
|
|
1128
|
+
if (a.provenance !== b.provenance) {
|
|
1129
|
+
return a.provenance === "declared" ? -1 : 1;
|
|
1130
|
+
}
|
|
1131
|
+
return a.value.localeCompare(b.value) || (a.name ?? "").localeCompare(b.name ?? "");
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
function summarizeTokens(tokens) {
|
|
1135
|
+
const summary = {};
|
|
1136
|
+
for (const token of tokens) {
|
|
1137
|
+
const key = `${token.category}:${token.provenance}`;
|
|
1138
|
+
summary[key] = (summary[key] ?? 0) + 1;
|
|
1139
|
+
}
|
|
1140
|
+
return summary;
|
|
1141
|
+
}
|
|
1142
|
+
/** Digest over design facts only — excludes timestamps, version, and input paths so the same prototype always yields the same digest. */
|
|
1143
|
+
export function digestContract(contract) {
|
|
1144
|
+
const facts = {
|
|
1145
|
+
kind: contract.source.kind,
|
|
1146
|
+
tokens: contract.tokens,
|
|
1147
|
+
breakpoints: contract.breakpoints.map((bp) => ({ edge: bp.edge, px: bp.px })),
|
|
1148
|
+
components: contract.components.map((component) => ({ selector: component.selector, styles: component.styles })),
|
|
1149
|
+
};
|
|
1150
|
+
return `sha256:${createHash("sha256").update(stableStringify(facts)).digest("hex")}`;
|
|
1151
|
+
}
|
|
1152
|
+
// ---------------------------------------------------------------------------
|
|
1153
|
+
// Token-module / tailwind-config extraction (TS/JS object literals)
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
const REFERENCE_VALUE = /var\(|theme\(|calc\(|env\(/i; // references / computed — not concrete tokens
|
|
1156
|
+
const MODULE_LENGTH = /^-?\d*\.?\d+(px|rem|em|vh|vw|%)$/i;
|
|
1157
|
+
/**
|
|
1158
|
+
* Extract concrete declared tokens from the object literals in a TS/JS token
|
|
1159
|
+
* module or tailwind config. Only emits a token when the value is a concrete
|
|
1160
|
+
* literal we can confidently categorize — references and un-placeable values are
|
|
1161
|
+
* skipped (weaker, never wrong).
|
|
1162
|
+
*/
|
|
1163
|
+
export function extractTokensFromModule(source) {
|
|
1164
|
+
const tree = tryParse("tsx", source) ?? tryParse("typescript", source);
|
|
1165
|
+
if (!tree) {
|
|
1166
|
+
return [];
|
|
1167
|
+
}
|
|
1168
|
+
const pairs = [];
|
|
1169
|
+
const stack = [tree.rootNode];
|
|
1170
|
+
while (stack.length > 0) {
|
|
1171
|
+
const node = stack.pop();
|
|
1172
|
+
// Kick off collection at standalone object literals (an object that is a
|
|
1173
|
+
// pair's value is already captured by the parent's recursion). Seed the key
|
|
1174
|
+
// path with the declared variable name so `export const spacing = {...}`
|
|
1175
|
+
// carries the "spacing" category hint down to its leaf values.
|
|
1176
|
+
if (node.type === "object" && node.parent?.type !== "pair") {
|
|
1177
|
+
collectObjectStringPairs(node, seedKeyPath(node), pairs);
|
|
1178
|
+
}
|
|
1179
|
+
for (let i = 0; i < node.namedChildCount; i += 1) {
|
|
1180
|
+
const child = node.namedChild(i);
|
|
1181
|
+
if (child) {
|
|
1182
|
+
stack.push(child);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
const tokens = [];
|
|
1187
|
+
const seen = new Set();
|
|
1188
|
+
for (const { keyPath, value } of pairs) {
|
|
1189
|
+
const categorized = categorizeTokenModuleValue(keyPath, value);
|
|
1190
|
+
if (!categorized) {
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
const dedupeKey = `${categorized.category}::${keyPath}::${categorized.value}`;
|
|
1194
|
+
if (seen.has(dedupeKey)) {
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
seen.add(dedupeKey);
|
|
1198
|
+
tokens.push({ category: categorized.category, name: keyPath, value: categorized.value, provenance: "declared", uses: 0 });
|
|
1199
|
+
}
|
|
1200
|
+
return tokens;
|
|
1201
|
+
}
|
|
1202
|
+
function seedKeyPath(objectNode) {
|
|
1203
|
+
const parent = objectNode.parent;
|
|
1204
|
+
if (!parent) {
|
|
1205
|
+
return [];
|
|
1206
|
+
}
|
|
1207
|
+
if (parent.type === "variable_declarator") {
|
|
1208
|
+
const name = parent.childForFieldName("name");
|
|
1209
|
+
if (name) {
|
|
1210
|
+
return [name.text];
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (parent.type === "assignment_expression") {
|
|
1214
|
+
const left = parent.childForFieldName("left");
|
|
1215
|
+
if (left) {
|
|
1216
|
+
return [left.text];
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return [];
|
|
1220
|
+
}
|
|
1221
|
+
function collectObjectStringPairs(node, keyPath, out) {
|
|
1222
|
+
for (let i = 0; i < node.namedChildCount; i += 1) {
|
|
1223
|
+
const pair = node.namedChild(i);
|
|
1224
|
+
if (!pair || pair.type !== "pair") {
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
const keyNode = pair.childForFieldName("key");
|
|
1228
|
+
const valueNode = pair.childForFieldName("value");
|
|
1229
|
+
if (!keyNode || !valueNode) {
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
const key = stripQuotes(keyNode.text);
|
|
1233
|
+
if (valueNode.type === "object") {
|
|
1234
|
+
collectObjectStringPairs(valueNode, [...keyPath, key], out);
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
// Arrays (e.g. Tailwind fontSize: ['14px', { lineHeight }]) — take the
|
|
1238
|
+
// first string-literal element (the size); numeric/unitless arrays yield
|
|
1239
|
+
// nothing.
|
|
1240
|
+
const valueText = valueNode.type === "array" ? firstArrayStringLiteral(valueNode) : moduleStringLiteral(valueNode);
|
|
1241
|
+
if (valueText !== undefined) {
|
|
1242
|
+
out.push({ keyPath: [...keyPath, key].join("."), value: valueText });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
function stripQuotes(text) {
|
|
1248
|
+
if ((text.startsWith('"') && text.endsWith('"')) ||
|
|
1249
|
+
(text.startsWith("'") && text.endsWith("'")) ||
|
|
1250
|
+
(text.startsWith("`") && text.endsWith("`"))) {
|
|
1251
|
+
return text.slice(1, -1);
|
|
1252
|
+
}
|
|
1253
|
+
return text;
|
|
1254
|
+
}
|
|
1255
|
+
function firstArrayStringLiteral(arrayNode) {
|
|
1256
|
+
for (let i = 0; i < arrayNode.namedChildCount; i += 1) {
|
|
1257
|
+
const child = arrayNode.namedChild(i);
|
|
1258
|
+
if (child) {
|
|
1259
|
+
const literal = moduleStringLiteral(child);
|
|
1260
|
+
if (literal !== undefined) {
|
|
1261
|
+
return literal;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return undefined;
|
|
1266
|
+
}
|
|
1267
|
+
function moduleStringLiteral(node) {
|
|
1268
|
+
if (node.type === "string" || node.type === "template_string") {
|
|
1269
|
+
const text = node.text;
|
|
1270
|
+
if (text.includes("${")) {
|
|
1271
|
+
return undefined; // interpolated — not a concrete literal
|
|
1272
|
+
}
|
|
1273
|
+
return stripQuotes(text);
|
|
1274
|
+
}
|
|
1275
|
+
return undefined;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Android resources: `<color name="x">#hex</color>` (concrete) and
|
|
1279
|
+
* `<dimen name="x">16dp</dimen>`. `<item>@color/x</item>` references are
|
|
1280
|
+
* ignored (they aren't concrete values).
|
|
1281
|
+
*/
|
|
1282
|
+
export function extractTokensFromAndroidXml(content) {
|
|
1283
|
+
const tokens = [];
|
|
1284
|
+
const seen = new Set();
|
|
1285
|
+
const push = (category, name, value) => {
|
|
1286
|
+
const key = `${category}::${name}::${value}`;
|
|
1287
|
+
if (!seen.has(key)) {
|
|
1288
|
+
seen.add(key);
|
|
1289
|
+
tokens.push({ category, name, value, provenance: "declared", uses: 0 });
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
const colorPattern = /<color\s+name\s*=\s*"([^"]+)"\s*>\s*(#[0-9a-fA-F]{3,8})\s*<\/color>/g;
|
|
1293
|
+
let match;
|
|
1294
|
+
while ((match = colorPattern.exec(content)) !== null) {
|
|
1295
|
+
push("color", match[1], normalizeAndroidHex(match[2]));
|
|
1296
|
+
}
|
|
1297
|
+
const dimenPattern = /<dimen\s+name\s*=\s*"([^"]+)"\s*>\s*(-?\d*\.?\d+)(dp|sp|px)\s*<\/dimen>/g;
|
|
1298
|
+
while ((match = dimenPattern.exec(content)) !== null) {
|
|
1299
|
+
const name = match[1];
|
|
1300
|
+
const value = `${match[2]}px`;
|
|
1301
|
+
const key = name.toLowerCase();
|
|
1302
|
+
if (/radius|radii|corner|\bround/.test(key)) {
|
|
1303
|
+
push("radius", name, value);
|
|
1304
|
+
}
|
|
1305
|
+
else if (/(space|spacing|gap|gutter|inset|pad|margin)/.test(key)) {
|
|
1306
|
+
push("space", name, value);
|
|
1307
|
+
}
|
|
1308
|
+
else if (/(text|font)/.test(key)) {
|
|
1309
|
+
push("fontSize", name, value);
|
|
1310
|
+
}
|
|
1311
|
+
// unplaceable dimens are skipped (never guessed)
|
|
1312
|
+
}
|
|
1313
|
+
return tokens;
|
|
1314
|
+
}
|
|
1315
|
+
function normalizeAndroidHex(hex) {
|
|
1316
|
+
const h = hex.toLowerCase();
|
|
1317
|
+
if (h.length === 9) {
|
|
1318
|
+
return `#${h.slice(3)}`; // #AARRGGBB -> #rrggbb (drop alpha)
|
|
1319
|
+
}
|
|
1320
|
+
if (h.length === 5) {
|
|
1321
|
+
return normalizeHex(`#${h.slice(2)}`); // #ARGB -> #RGB -> expand
|
|
1322
|
+
}
|
|
1323
|
+
return normalizeHex(h);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Flutter color tokens: both `kPrimary = Color(0xAARRGGBB)` (const declarations)
|
|
1327
|
+
* and `primaryColor: const Color(0xAARRGGBB)` (named-parameter theme construction,
|
|
1328
|
+
* the common real pattern). Named `Colors.*` are not extractable.
|
|
1329
|
+
*/
|
|
1330
|
+
export function extractTokensFromDart(content) {
|
|
1331
|
+
const tokens = [];
|
|
1332
|
+
const seen = new Set();
|
|
1333
|
+
const pattern = /(\w+)\s*[:=]\s*(?:const\s+)?Color\(\s*0x([0-9a-fA-F]{8})\s*\)/g;
|
|
1334
|
+
let match;
|
|
1335
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1336
|
+
const name = match[1];
|
|
1337
|
+
const value = `#${match[2].slice(2).toLowerCase()}`; // 0xAARRGGBB -> #rrggbb
|
|
1338
|
+
const key = `color::${name}::${value}`;
|
|
1339
|
+
if (!seen.has(key)) {
|
|
1340
|
+
seen.add(key);
|
|
1341
|
+
tokens.push({ category: "color", name, value, provenance: "declared", uses: 0 });
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return tokens;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* iOS asset catalog: `*.colorset/Contents.json` (sRGB/display-p3 components as
|
|
1348
|
+
* 0–255 integers, 0–1 floats, or 0xNN hex). The colorset directory name is the
|
|
1349
|
+
* token name. Dark-appearance variants are skipped in favor of the base color.
|
|
1350
|
+
*/
|
|
1351
|
+
export function extractTokensFromColorset(contentsJson, name) {
|
|
1352
|
+
let parsed;
|
|
1353
|
+
try {
|
|
1354
|
+
parsed = JSON.parse(contentsJson);
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
return [];
|
|
1358
|
+
}
|
|
1359
|
+
const colors = parsed?.colors;
|
|
1360
|
+
if (!Array.isArray(colors)) {
|
|
1361
|
+
return [];
|
|
1362
|
+
}
|
|
1363
|
+
const withComponents = colors.filter((entry) => entry?.color?.components);
|
|
1364
|
+
const chosen = withComponents.find((entry) => !hasDarkAppearance(entry)) ?? withComponents[0];
|
|
1365
|
+
if (!chosen) {
|
|
1366
|
+
return [];
|
|
1367
|
+
}
|
|
1368
|
+
const components = chosen.color.components;
|
|
1369
|
+
const r = parseColorComponent(String(components.red));
|
|
1370
|
+
const g = parseColorComponent(String(components.green));
|
|
1371
|
+
const b = parseColorComponent(String(components.blue));
|
|
1372
|
+
if (r === null || g === null || b === null) {
|
|
1373
|
+
return [];
|
|
1374
|
+
}
|
|
1375
|
+
return [{ category: "color", name, value: `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`, provenance: "declared", uses: 0 }];
|
|
1376
|
+
}
|
|
1377
|
+
function hasDarkAppearance(entry) {
|
|
1378
|
+
const appearances = entry.appearances;
|
|
1379
|
+
return Array.isArray(appearances) && appearances.some((a) => a.value === "dark");
|
|
1380
|
+
}
|
|
1381
|
+
function parseColorComponent(raw) {
|
|
1382
|
+
const v = raw.trim();
|
|
1383
|
+
if (/^0x[0-9a-f]{1,2}$/i.test(v)) {
|
|
1384
|
+
return clampByte(parseInt(v, 16));
|
|
1385
|
+
}
|
|
1386
|
+
if (v.includes("/")) {
|
|
1387
|
+
const [a, b] = v.split("/").map(Number);
|
|
1388
|
+
return b ? clampByte(Math.round((a / b) * 255)) : null;
|
|
1389
|
+
}
|
|
1390
|
+
const num = Number(v);
|
|
1391
|
+
if (!Number.isFinite(num)) {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
// A decimal point means a 0–1 float component; a bare integer is 0–255.
|
|
1395
|
+
return v.includes(".") ? clampByte(Math.round(num * 255)) : clampByte(Math.round(num));
|
|
1396
|
+
}
|
|
1397
|
+
function clampByte(n) {
|
|
1398
|
+
return Math.max(0, Math.min(255, n));
|
|
1399
|
+
}
|
|
1400
|
+
function toHex2(n) {
|
|
1401
|
+
return n.toString(16).padStart(2, "0");
|
|
1402
|
+
}
|
|
1403
|
+
/** Swift color constants: `Color(hex: "RRGGBB")` and `Color(red: r, green: g, blue: b)` (floats or n/255). */
|
|
1404
|
+
export function extractTokensFromSwift(content) {
|
|
1405
|
+
const tokens = [];
|
|
1406
|
+
const seen = new Set();
|
|
1407
|
+
const push = (name, hex) => {
|
|
1408
|
+
const value = normalizeHex(`#${hex.replace(/^#/, "").slice(0, 6).toLowerCase()}`);
|
|
1409
|
+
const key = `${name}::${value}`;
|
|
1410
|
+
if (!seen.has(key)) {
|
|
1411
|
+
seen.add(key);
|
|
1412
|
+
tokens.push({ category: "color", name, value, provenance: "declared", uses: 0 });
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
const hexPattern = /(\w+)\s*[:=]\s*(?:[\w.]+\.)?Color\(\s*hex:\s*"#?([0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?)"/g;
|
|
1416
|
+
let match;
|
|
1417
|
+
while ((match = hexPattern.exec(content)) !== null) {
|
|
1418
|
+
push(match[1], match[2]);
|
|
1419
|
+
}
|
|
1420
|
+
const rgbPattern = /(\w+)\s*[:=]\s*(?:UI)?Color\(\s*red:\s*([\d./]+)\s*,\s*green:\s*([\d./]+)\s*,\s*blue:\s*([\d./]+)/g;
|
|
1421
|
+
while ((match = rgbPattern.exec(content)) !== null) {
|
|
1422
|
+
const r = parseColorComponent(match[2]);
|
|
1423
|
+
const g = parseColorComponent(match[3]);
|
|
1424
|
+
const b = parseColorComponent(match[4]);
|
|
1425
|
+
if (r !== null && g !== null && b !== null) {
|
|
1426
|
+
push(match[1], `${toHex2(r)}${toHex2(g)}${toHex2(b)}`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return tokens;
|
|
1430
|
+
}
|
|
1431
|
+
/** Conservative, FP-safe categorization for a token-module/config value. */
|
|
1432
|
+
function categorizeTokenModuleValue(keyPath, value) {
|
|
1433
|
+
const v = value.trim();
|
|
1434
|
+
if (!v || REFERENCE_VALUE.test(v)) {
|
|
1435
|
+
return null; // reference / computed — not a concrete token
|
|
1436
|
+
}
|
|
1437
|
+
const key = keyPath.toLowerCase();
|
|
1438
|
+
if (/screen|breakpoint|media|z-?index|opacity|weight/.test(key)) {
|
|
1439
|
+
return null; // not a design token we model (or handled as breakpoints elsewhere)
|
|
1440
|
+
}
|
|
1441
|
+
// Colors: unambiguous from the value alone.
|
|
1442
|
+
if (isColor(v)) {
|
|
1443
|
+
return { category: "color", value: normalizeValue("color", v) };
|
|
1444
|
+
}
|
|
1445
|
+
// Shadows: key-hinted, value carries length(s).
|
|
1446
|
+
if (/shadow|elevation/.test(key) && /\d/.test(v)) {
|
|
1447
|
+
return { category: "shadow", value: collapseSpaces(v) };
|
|
1448
|
+
}
|
|
1449
|
+
// Lengths: only categorize when the key places them — otherwise skip (never guess).
|
|
1450
|
+
if (MODULE_LENGTH.test(v)) {
|
|
1451
|
+
if (/radius|radii|corner|\bround/.test(key)) {
|
|
1452
|
+
return { category: "radius", value: v.toLowerCase() };
|
|
1453
|
+
}
|
|
1454
|
+
if (/(space|spacing|gap|gutter|inset|pad|margin)/.test(key)) {
|
|
1455
|
+
return { category: "space", value: v.toLowerCase() };
|
|
1456
|
+
}
|
|
1457
|
+
if (/(font-?size|text-?size|leading|line-?height)/.test(key) ||
|
|
1458
|
+
/\btext-(xs|sm|base|md|lg|xl|\dxl|\d)\b/.test(key) ||
|
|
1459
|
+
(/(typography|font|text)/.test(key) && /(^|\.)sizes?(\.|$)/.test(key))) {
|
|
1460
|
+
return { category: "fontSize", value: v.toLowerCase() }; // incl. text-scale + typography.size.* conventions
|
|
1461
|
+
}
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
// Motion: cubic-bezier/steps is unambiguously an easing token (value-based,
|
|
1465
|
+
// like colors) — catches keys like "smooth"/"standard" that lack an ease/-
|
|
1466
|
+
// duration hint.
|
|
1467
|
+
if (/cubic-bezier|steps\(/i.test(v)) {
|
|
1468
|
+
return { category: "motion", value: collapseSpaces(v) };
|
|
1469
|
+
}
|
|
1470
|
+
if (/(duration|motion|transition|delay)/.test(key)) {
|
|
1471
|
+
const time = v.match(/-?\d*\.?\d+m?s/i);
|
|
1472
|
+
if (time) {
|
|
1473
|
+
return { category: "motion", value: time[0].toLowerCase() };
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (/(ease|easing|bezier)/.test(key) && /cubic-bezier|ease/i.test(v)) {
|
|
1477
|
+
return { category: "motion", value: collapseSpaces(v) };
|
|
1478
|
+
}
|
|
1479
|
+
// Font family: key-hinted, value is a family list.
|
|
1480
|
+
if (/(font-?family|fontfamily|typeface|family)/.test(key) && /[a-z]/i.test(v) && !MODULE_LENGTH.test(v)) {
|
|
1481
|
+
return { category: "fontFamily", value: collapseSpaces(v) };
|
|
1482
|
+
}
|
|
1483
|
+
return null;
|
|
1484
|
+
}
|
|
1485
|
+
function stableStringify(value) {
|
|
1486
|
+
if (Array.isArray(value)) {
|
|
1487
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
1488
|
+
}
|
|
1489
|
+
if (value && typeof value === "object") {
|
|
1490
|
+
const entries = Object.entries(value)
|
|
1491
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
1492
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
1493
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`;
|
|
1494
|
+
}
|
|
1495
|
+
return JSON.stringify(value);
|
|
1496
|
+
}
|