@amityco/social-plus-vise 1.0.0 → 1.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/CHANGELOG.md +71 -24
- package/LICENSE +8 -6
- package/README.md +168 -358
- package/dist/capabilities.js +19 -62
- package/dist/intelligence/grounding.js +0 -23
- package/dist/intelligence/placement.js +0 -9
- package/dist/outcomes.js +44 -22
- package/dist/server.js +75 -38
- package/dist/tools/ast.js +3 -209
- package/dist/tools/blocks.js +6 -20
- package/dist/tools/compliance.js +168 -43
- package/dist/tools/creative.js +15 -41
- package/dist/tools/debug.js +0 -16
- package/dist/tools/design.js +18 -364
- package/dist/tools/docs.js +53 -24
- package/dist/tools/experienceCompiler.js +7 -10
- package/dist/tools/experienceSensors.js +1 -1
- package/dist/tools/harness.js +2 -27
- package/dist/tools/integration.js +6 -38
- package/dist/tools/learning.js +1 -1
- package/dist/tools/project.js +763 -546
- package/dist/tools/sdkFacts.js +2 -15
- package/dist/tools/sdkVersion.js +3 -36
- package/dist/tools/sensors.js +0 -6
- package/dist/tools/uxHarness.js +12 -9
- package/package.json +8 -97
- package/rules/chat.yaml +225 -0
- package/rules/event.yaml +45 -0
- package/rules/feed.yaml +24 -24
- package/rules/invitation.yaml +58 -0
- package/rules/live-data.yaml +104 -2
- package/rules/notification-tray.yaml +106 -0
- package/rules/poll.yaml +71 -0
- package/rules/sdk-lifecycle.yaml +112 -6
- package/rules/search.yaml +131 -0
- package/rules/story.yaml +221 -0
- package/rules/user-blocking.yaml +71 -0
- package/sdk-surface/flutter.json +1 -1
- package/sdk-surface/ios.json +1 -1
- package/sdk-surface/manifest.json +12 -12
- package/sdk-surface/models.flutter.json +96 -96
- package/sdk-surface/models.ios.json +1 -1
- package/sdk-surface/typescript.json +4 -4
- package/skills/social-plus-vise/SKILL.md +25 -5
- package/scripts/catalog-coverage-html.mjs +0 -325
- package/scripts/catalog-relationships-html.mjs +0 -686
- package/scripts/catalog-sheets.mjs +0 -286
- package/scripts/dart-model-extractor/bin/extract_models.dart +0 -169
- package/scripts/dart-model-extractor/pubspec.lock +0 -149
- package/scripts/dart-model-extractor/pubspec.yaml +0 -16
- package/scripts/extract-sdk-models.mjs +0 -749
- package/scripts/import-sdk-surface.mjs +0 -161
- package/scripts/pilot-feedback.mjs +0 -107
- package/scripts/workshop-board-html.mjs +0 -1018
- package/scripts/workshop-kit.mjs +0 -252
- package/skills/vise-harness-engineer/SKILL.md +0 -35
package/dist/tools/design.js
CHANGED
|
@@ -4,33 +4,15 @@ import path from "node:path";
|
|
|
4
4
|
import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
|
|
5
5
|
import { packageVersion } from "../version.js";
|
|
6
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
7
|
export const DESIGN_CONTRACT_SCHEMA_VERSION = 1;
|
|
21
8
|
export const DESIGN_CONTRACT_FILENAME = "design-contract.json";
|
|
22
9
|
export const DESIGN_PREVIEW_FILENAME = "design-preview.html";
|
|
23
10
|
export const DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID = "design_contract_confirmation";
|
|
24
|
-
/** 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. */
|
|
25
11
|
export const INFERRED_MIN_USES = 2;
|
|
26
|
-
/** Bound the prototype walk so a large input directory cannot stall extraction. */
|
|
27
12
|
const MAX_PROTOTYPE_FILES = 300;
|
|
28
|
-
/** Bound the source scan for the advisory `design check`. */
|
|
29
13
|
const MAX_SCAN_FILES = 2000;
|
|
30
|
-
/** Cap the off-contract sample so the advisory report stays readable. */
|
|
31
14
|
const OFF_CONTRACT_SAMPLE = 20;
|
|
32
15
|
const SCAN_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".dart", ".kt", ".java", ".swift", ".css", ".scss", ".vue", ".xml"]);
|
|
33
|
-
/** Extensions whose design tokens are extracted by parsing object literals (TS/JS token modules, tailwind config). */
|
|
34
16
|
const MODULE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
35
17
|
const MAX_FILE_BYTES = 2_000_000;
|
|
36
18
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", "vendor", ".turbo", ".cache"]);
|
|
@@ -55,9 +37,6 @@ export function designContractConfirmationFromAnswers(answers) {
|
|
|
55
37
|
export function designPreviewPath(repoPath) {
|
|
56
38
|
return path.join(path.resolve(repoPath), "sp-vise", DESIGN_PREVIEW_FILENAME);
|
|
57
39
|
}
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
// Public entry points
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
40
|
export const designExtractTool = {
|
|
62
41
|
name: "design_extract",
|
|
63
42
|
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).",
|
|
@@ -116,15 +95,6 @@ export async function extractDesignContract(prototypePath) {
|
|
|
116
95
|
const sources = await readPrototypeSources(resolved);
|
|
117
96
|
return buildDesignContract(sources, { kind: "html-css-prototype", inputs: sources.inputs, file_count: sources.inputs.length });
|
|
118
97
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Derive a design contract from the *host project's own* design system, for when
|
|
121
|
-
* the customer gives no external prototype. Scopes to the design-source files
|
|
122
|
-
* Vise detects (theme/token modules, tailwind config, global CSS) — never the
|
|
123
|
-
* whole repo — and extracts: CSS custom properties (incl. shadcn `:root` and
|
|
124
|
-
* Tailwind v4 `@theme`), plus concrete tokens from TS/JS token modules and
|
|
125
|
-
* inline tailwind configs. References (`var()`/`theme()`/`calc()`) are rejected,
|
|
126
|
-
* so a var-mapped config contributes nothing rather than wrong tokens.
|
|
127
|
-
*/
|
|
128
98
|
export async function extractDesignContractFromProject(repoPath) {
|
|
129
99
|
const root = path.resolve(repoPath);
|
|
130
100
|
const files = await findProjectDesignFiles(root);
|
|
@@ -187,14 +157,13 @@ export async function extractDesignContractFromProject(repoPath) {
|
|
|
187
157
|
}
|
|
188
158
|
}
|
|
189
159
|
inputs.sort();
|
|
190
|
-
// Hash each source file so design check can detect staleness without re-extracting.
|
|
191
160
|
const input_digests = {};
|
|
192
161
|
for (const rel of inputs) {
|
|
193
162
|
try {
|
|
194
163
|
const content = await readFile(path.join(root, rel), "utf8");
|
|
195
164
|
input_digests[rel] = `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
196
165
|
}
|
|
197
|
-
catch {
|
|
166
|
+
catch { }
|
|
198
167
|
}
|
|
199
168
|
return buildDesignContract({ css, html: [], inputs }, { kind: "host-project", inputs, input_digests, file_count: inputs.length }, moduleTokens);
|
|
200
169
|
}
|
|
@@ -228,17 +197,6 @@ export async function writeDesignPreview(repoPath, contract, referencePath) {
|
|
|
228
197
|
await writeFile(target, html, "utf8");
|
|
229
198
|
return target;
|
|
230
199
|
}
|
|
231
|
-
// ---------------------------------------------------------------------------
|
|
232
|
-
// Visual contract review + conformance report (advisory, dependency-free)
|
|
233
|
-
// ---------------------------------------------------------------------------
|
|
234
|
-
//
|
|
235
|
-
// Vise produces the visual artifact; a human (or a VLM) judges whether the
|
|
236
|
-
// generated UI matches. This is NOT an automated pixel/render diff — that would
|
|
237
|
-
// need a headless browser (non-deterministic, heavy dep) and does not belong in
|
|
238
|
-
// Vise's deterministic, dependency-free core. The honest comparison data is:
|
|
239
|
-
// (1) the contract's tokens rendered as visual swatches, (2) the actual HTML
|
|
240
|
-
// reference embedded beside them when renderable, and (3) the `design check`
|
|
241
|
-
// conformance numbers (coverage + on/off-contract) as the textual diff.
|
|
242
200
|
export const designPreviewTool = {
|
|
243
201
|
name: "design_preview",
|
|
244
202
|
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.",
|
|
@@ -281,7 +239,6 @@ export const designPreviewTool = {
|
|
|
281
239
|
});
|
|
282
240
|
},
|
|
283
241
|
};
|
|
284
|
-
/** Read an HTML prototype into a self-contained string (inlining linked stylesheets) for embedding. Returns null if there's no renderable HTML. */
|
|
285
242
|
export async function readReferenceHtml(referencePath) {
|
|
286
243
|
const resolved = path.resolve(referencePath);
|
|
287
244
|
let htmlFile = null;
|
|
@@ -312,14 +269,13 @@ export async function readReferenceHtml(referencePath) {
|
|
|
312
269
|
catch {
|
|
313
270
|
return null;
|
|
314
271
|
}
|
|
315
|
-
// Inline <link rel="stylesheet" href="..."> so the embed is self-contained.
|
|
316
272
|
const dir = path.dirname(htmlFile);
|
|
317
273
|
const linkPattern = /<link[^>]*rel\s*=\s*["']stylesheet["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
318
274
|
const links = [...html.matchAll(linkPattern)];
|
|
319
275
|
for (const link of links) {
|
|
320
276
|
const href = link[1];
|
|
321
277
|
if (/^https?:|^\/\//i.test(href)) {
|
|
322
|
-
continue;
|
|
278
|
+
continue;
|
|
323
279
|
}
|
|
324
280
|
try {
|
|
325
281
|
const cssPath = path.join(dir, href);
|
|
@@ -327,7 +283,6 @@ export async function readReferenceHtml(referencePath) {
|
|
|
327
283
|
html = html.replace(link[0], `<style>\n${css}\n</style>`);
|
|
328
284
|
}
|
|
329
285
|
catch {
|
|
330
|
-
// leave the link; missing CSS just won't apply
|
|
331
286
|
}
|
|
332
287
|
}
|
|
333
288
|
return html;
|
|
@@ -420,29 +375,10 @@ function esc(s) {
|
|
|
420
375
|
function escAttr(s) {
|
|
421
376
|
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
422
377
|
}
|
|
423
|
-
/** 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). */
|
|
424
378
|
function safeCss(value) {
|
|
425
379
|
return value.replace(/[<>"]/g, "").slice(0, 200);
|
|
426
380
|
}
|
|
427
|
-
// ---------------------------------------------------------------------------
|
|
428
|
-
// Social-plus token scaffold (vise design init-tokens)
|
|
429
|
-
// ---------------------------------------------------------------------------
|
|
430
|
-
//
|
|
431
|
-
// Creates a dedicated `src/styles/social-plus-tokens.css` in the customer's
|
|
432
|
-
// project — the single editable source for social.plus feature styling.
|
|
433
|
-
// The contract always points at this file; customers edit it freely without
|
|
434
|
-
// needing an AI agent. Design check detects changes and prompts re-extract.
|
|
435
|
-
//
|
|
436
|
-
// Two cases:
|
|
437
|
-
// Greenfield (no existing design system): scaffold Option-B neutral defaults.
|
|
438
|
-
// Brownfield (existing tokens found): seed from their concrete values.
|
|
439
|
-
// Already exists: leave it untouched (idempotent — never clobbers edits).
|
|
440
|
-
/** Relative path within the customer's project where sp tokens live. */
|
|
441
381
|
export const SP_TOKENS_PATH = "src/styles/social-plus-tokens.css";
|
|
442
|
-
/** Option-B neutral default — a clean, adaptive light-mode system using system
|
|
443
|
-
* fonts. All token names use `--sp-` prefix to avoid collision with the
|
|
444
|
-
* customer's own design system. Will be replaced with the official social.plus
|
|
445
|
-
* palette (Option A) once that palette is finalised. */
|
|
446
382
|
export const NEUTRAL_SP_TOKENS_DEFAULT = `/* social-plus-tokens.css — social.plus feature design system.
|
|
447
383
|
* This file controls the look of all social.plus features in your app.
|
|
448
384
|
* Edit freely. Run: vise design extract --from-project . to refresh the contract.
|
|
@@ -577,7 +513,6 @@ export const designInitTokensTool = {
|
|
|
577
513
|
export async function initSpTokens(repoPath, force = false) {
|
|
578
514
|
const root = path.resolve(repoPath);
|
|
579
515
|
const target = path.join(root, SP_TOKENS_PATH);
|
|
580
|
-
// Idempotent: don't overwrite unless forced.
|
|
581
516
|
try {
|
|
582
517
|
await stat(target);
|
|
583
518
|
if (!force) {
|
|
@@ -588,14 +523,12 @@ export async function initSpTokens(repoPath, force = false) {
|
|
|
588
523
|
};
|
|
589
524
|
}
|
|
590
525
|
}
|
|
591
|
-
catch {
|
|
592
|
-
// Try brownfield seeding: extract concrete token values from the existing project.
|
|
526
|
+
catch { }
|
|
593
527
|
const existingContract = await extractDesignContractFromProject(root);
|
|
594
528
|
const hasBrownfieldTokens = existingContract.tokens.length > 0;
|
|
595
529
|
let css;
|
|
596
530
|
let seededFrom;
|
|
597
531
|
if (hasBrownfieldTokens) {
|
|
598
|
-
// Seed from their existing concrete values, namespaced as --sp-*.
|
|
599
532
|
const lines = [
|
|
600
533
|
`/* social-plus-tokens.css — social.plus feature design system.`,
|
|
601
534
|
` * Seeded from your existing design tokens on ${new Date().toISOString().slice(0, 10)}.`,
|
|
@@ -619,7 +552,6 @@ export async function initSpTokens(repoPath, force = false) {
|
|
|
619
552
|
seededFrom = existingContract.source.inputs;
|
|
620
553
|
}
|
|
621
554
|
else {
|
|
622
|
-
// Greenfield: neutral Option-B defaults.
|
|
623
555
|
css = NEUTRAL_SP_TOKENS_DEFAULT;
|
|
624
556
|
}
|
|
625
557
|
await mkdir(path.dirname(target), { recursive: true });
|
|
@@ -633,16 +565,6 @@ export async function initSpTokens(repoPath, force = false) {
|
|
|
633
565
|
: `Scaffolded ${SP_TOKENS_PATH} with neutral defaults. Fill in your colors, then run vise design extract --from-project .`,
|
|
634
566
|
};
|
|
635
567
|
}
|
|
636
|
-
// ---------------------------------------------------------------------------
|
|
637
|
-
// Design-system reference document (human/VLM-readable, advisory)
|
|
638
|
-
// ---------------------------------------------------------------------------
|
|
639
|
-
//
|
|
640
|
-
// Pairs with design-contract.json (machine-readable): this is the "v1.0" visual
|
|
641
|
-
// spec a human or VLM can read, share, or diff. It reads the source CSS so that
|
|
642
|
-
// var(--x) resolves live in the browser; for non-CSS projects (Android, Flutter,
|
|
643
|
-
// iOS, TS module) it falls back to rendering directly from the contract tokens.
|
|
644
|
-
//
|
|
645
|
-
// NOT a gate — advisory documentation only.
|
|
646
568
|
export const designReferenceTool = {
|
|
647
569
|
name: "design_reference",
|
|
648
570
|
description: "Generate a self-contained HTML design-system reference (token swatches, type samples, component demos, growth-layer summary) from the Vise design contract. Human/VLM-readable; advisory, not an enforcement gate.",
|
|
@@ -685,11 +607,6 @@ export const designReferenceTool = {
|
|
|
685
607
|
});
|
|
686
608
|
},
|
|
687
609
|
};
|
|
688
|
-
/**
|
|
689
|
-
* Generate a self-contained HTML design-system reference from the contract.
|
|
690
|
-
* Reads source CSS files for full var() resolution; falls back to contract tokens
|
|
691
|
-
* for non-CSS projects (Android XML, Flutter Dart, iOS, TS module sources).
|
|
692
|
-
*/
|
|
693
610
|
export async function generateDesignReference(repoPath, contract, title) {
|
|
694
611
|
const root = path.resolve(repoPath);
|
|
695
612
|
const cssTexts = await Promise.all((contract.source?.inputs ?? []).map(async (rel) => {
|
|
@@ -704,10 +621,6 @@ export async function generateDesignReference(repoPath, contract, title) {
|
|
|
704
621
|
}
|
|
705
622
|
}));
|
|
706
623
|
const tokenCss = cssTexts.join("\n");
|
|
707
|
-
// `ref` = how this token is referenced in a CSS style attribute.
|
|
708
|
-
// CSS projects: `var(--name)` so the token resolves live via the inlined :root.
|
|
709
|
-
// Non-CSS projects (Android/Flutter/iOS/TS module): use the concrete value directly
|
|
710
|
-
// since there is no :root to resolve from.
|
|
711
624
|
const hasCssInputs = (contract.source?.inputs ?? []).some((rel) => /\.(css|scss)$/i.test(rel));
|
|
712
625
|
let allTokens;
|
|
713
626
|
if (hasCssInputs && tokenCss.trim()) {
|
|
@@ -720,7 +633,6 @@ export async function generateDesignReference(repoPath, contract, title) {
|
|
|
720
633
|
}));
|
|
721
634
|
}
|
|
722
635
|
else {
|
|
723
|
-
// Non-CSS project: render from contract tokens directly, using concrete values.
|
|
724
636
|
allTokens = contract.tokens
|
|
725
637
|
.filter((t) => t.name !== null)
|
|
726
638
|
.map((t) => ({ name: t.name, value: t.value, ref: safeCss(t.value), inContract: true, category: t.category }));
|
|
@@ -744,8 +656,6 @@ export async function generateDesignReference(repoPath, contract, title) {
|
|
|
744
656
|
{ id: "bp", label: "Breakpoints", kind: "chip", match: (n) => n.startsWith("--bp-") },
|
|
745
657
|
{ id: "z", label: "Z-index", kind: "chip", match: (n) => n.startsWith("--z-") },
|
|
746
658
|
];
|
|
747
|
-
// Maps contract category → rendering kind; used as fallback for non-CSS tokens
|
|
748
|
-
// whose names don't carry the --prefix conventions above.
|
|
749
659
|
const CATEGORY_TO_KIND = {
|
|
750
660
|
color: "color", space: "space", radius: "radius", shadow: "shadow",
|
|
751
661
|
fontFamily: "family", fontSize: "fontsize", motion: "chip", opacity: "opacity",
|
|
@@ -760,8 +670,6 @@ export async function generateDesignReference(repoPath, contract, title) {
|
|
|
760
670
|
items.forEach((t) => used.add(t.name));
|
|
761
671
|
return { id: g.id, label: g.label, kind: g.kind, items };
|
|
762
672
|
}).filter((g) => g.items.length > 0);
|
|
763
|
-
// For non-CSS tokens not matched by name-prefix above, fall back to grouping by
|
|
764
|
-
// the contract category field so native projects produce a properly-sectioned doc.
|
|
765
673
|
const unmatchedByName = allTokens.filter((t) => !used.has(t.name));
|
|
766
674
|
const catBuckets = new Map();
|
|
767
675
|
const trueUngrouped = [];
|
|
@@ -804,14 +712,12 @@ export async function generateDesignReference(repoPath, contract, title) {
|
|
|
804
712
|
function renderTokenRow(t) {
|
|
805
713
|
return `<div class="ds-token-row"><code class="ds-token-name">${esc(t.name)}${provTag(t)}</code><code class="ds-token-pill">${esc(t.value)}</code></div>`;
|
|
806
714
|
}
|
|
807
|
-
// Super-group assignment for nav + section tags.
|
|
808
715
|
const GROUP_SUPER = {
|
|
809
716
|
brand: "COLOR", bg: "COLOR", text: "COLOR", line: "COLOR",
|
|
810
717
|
font: "TYPOGRAPHY", fs: "TYPOGRAPHY", fw: "TYPOGRAPHY", lh: "TYPOGRAPHY",
|
|
811
718
|
space: "LAYOUT", size: "LAYOUT", bp: "LAYOUT",
|
|
812
719
|
radius: "SURFACE", border: "SURFACE", shadow: "SURFACE",
|
|
813
720
|
opacity: "EFFECTS", motion: "EFFECTS", z: "EFFECTS",
|
|
814
|
-
// category-based (native projects)
|
|
815
721
|
color: "COLOR", fontFamily: "TYPOGRAPHY", fontSize: "TYPOGRAPHY",
|
|
816
722
|
other: "OTHER",
|
|
817
723
|
};
|
|
@@ -1180,8 +1086,6 @@ export async function runDesignCheck(repoPath) {
|
|
|
1180
1086
|
note: ADVISORY_NOTE,
|
|
1181
1087
|
};
|
|
1182
1088
|
}
|
|
1183
|
-
// Freshness check: compare source file content hashes to those recorded at extract time.
|
|
1184
|
-
// Advisory only — never blocks, just surfaces a nudge to re-extract.
|
|
1185
1089
|
const staleContract = await checkContractFreshness(repoRoot, contract);
|
|
1186
1090
|
const files = (await collectFiles(repoRoot, MAX_SCAN_FILES)).filter((file) => SCAN_EXTS.has(path.extname(file).toLowerCase()));
|
|
1187
1091
|
if (files.length === 0) {
|
|
@@ -1211,7 +1115,6 @@ export async function runDesignCheck(repoPath) {
|
|
|
1211
1115
|
scanned += 1;
|
|
1212
1116
|
const rel = path.relative(repoRoot, file);
|
|
1213
1117
|
const isCss = file.toLowerCase().endsWith(".css") || file.toLowerCase().endsWith(".scss");
|
|
1214
|
-
// Token coverage: a declared token is "referenced" if its var name OR its value appears in the code.
|
|
1215
1118
|
for (const token of declaredTokens) {
|
|
1216
1119
|
const key = tokenKey(token);
|
|
1217
1120
|
if (referenced.has(key)) {
|
|
@@ -1221,7 +1124,6 @@ export async function runDesignCheck(repoPath) {
|
|
|
1221
1124
|
referenced.add(key);
|
|
1222
1125
|
}
|
|
1223
1126
|
}
|
|
1224
|
-
// Raw color literals: counted, then classified on/off contract.
|
|
1225
1127
|
for (const value of scanColorLiterals(content)) {
|
|
1226
1128
|
totalColors += 1;
|
|
1227
1129
|
if (contractColorValues.has(value)) {
|
|
@@ -1231,7 +1133,6 @@ export async function runDesignCheck(repoPath) {
|
|
|
1231
1133
|
colorSample.push({ value, file: rel, on_contract: false });
|
|
1232
1134
|
}
|
|
1233
1135
|
}
|
|
1234
|
-
// Token hygiene: collect var(--x) references (any file) and --x: definitions (CSS files).
|
|
1235
1136
|
for (const name of scanVarReferences(content)) {
|
|
1236
1137
|
varRefs.push({ token: name, file: rel });
|
|
1237
1138
|
}
|
|
@@ -1243,11 +1144,6 @@ export async function runDesignCheck(repoPath) {
|
|
|
1243
1144
|
}
|
|
1244
1145
|
const referencedTokens = declaredTokens.filter((token) => referenced.has(tokenKey(token))).map((token) => token.name ?? token.value);
|
|
1245
1146
|
const unreferencedTokens = declaredTokens.filter((token) => !referenced.has(tokenKey(token))).map((token) => token.name ?? token.value);
|
|
1246
|
-
// A var(--x) referenced but defined in no scanned CSS file AND not a known
|
|
1247
|
-
// contract token resolves to nothing at runtime — a typo or hallucinated
|
|
1248
|
-
// token. Contract token names are excluded: they are legitimate design tokens
|
|
1249
|
-
// that may be supplied by an imported/external design-system stylesheet, so
|
|
1250
|
-
// flagging them would risk a false positive (Vise's cardinal sin).
|
|
1251
1147
|
const contractTokenNames = new Set(contract.tokens.map((token) => token.name).filter((name) => Boolean(name)));
|
|
1252
1148
|
const undefinedRefs = dedupeByToken(varRefs.filter((ref) => !definedVars.has(ref.token) && !contractTokenNames.has(ref.token)));
|
|
1253
1149
|
return {
|
|
@@ -1278,8 +1174,6 @@ export async function runDesignCheck(repoPath) {
|
|
|
1278
1174
|
note: ADVISORY_NOTE,
|
|
1279
1175
|
};
|
|
1280
1176
|
}
|
|
1281
|
-
/** Compare source.inputs file content against hashes recorded at extract time.
|
|
1282
|
-
* Returns null if the contract is fresh or has no recorded digests. */
|
|
1283
1177
|
async function checkContractFreshness(repoRoot, contract) {
|
|
1284
1178
|
const recorded = contract.source?.input_digests;
|
|
1285
1179
|
if (!recorded || Object.keys(recorded).length === 0)
|
|
@@ -1293,7 +1187,7 @@ async function checkContractFreshness(repoRoot, contract) {
|
|
|
1293
1187
|
changed.push(rel);
|
|
1294
1188
|
}
|
|
1295
1189
|
catch {
|
|
1296
|
-
changed.push(rel);
|
|
1190
|
+
changed.push(rel);
|
|
1297
1191
|
}
|
|
1298
1192
|
}
|
|
1299
1193
|
if (changed.length === 0)
|
|
@@ -1325,7 +1219,6 @@ function scanVarReferences(content) {
|
|
|
1325
1219
|
}
|
|
1326
1220
|
function scanVarDefinitions(content) {
|
|
1327
1221
|
const out = [];
|
|
1328
|
-
// Match `--x:` declarations, but not `var(--x)` references (no colon after the name there).
|
|
1329
1222
|
const pattern = /(--[\w-]+)\s*:/g;
|
|
1330
1223
|
let match;
|
|
1331
1224
|
while ((match = pattern.exec(content)) !== null) {
|
|
@@ -1344,26 +1237,21 @@ function contractSummary(contract) {
|
|
|
1344
1237
|
function tokenKey(token) {
|
|
1345
1238
|
return `${token.category}::${token.name ?? token.value}`;
|
|
1346
1239
|
}
|
|
1347
|
-
/** Extract comparable hex color values from a source file: web `#hex`, Flutter/Android `Color(0xAARRGGBB)`. */
|
|
1348
1240
|
function scanColorLiterals(content) {
|
|
1349
1241
|
const out = [];
|
|
1350
1242
|
let match;
|
|
1351
|
-
// Web/Android/XML hex (`#RRGGBB`, incl. `<color>#RRGGBB</color>`).
|
|
1352
1243
|
const hexPattern = /#[0-9a-fA-F]{3,8}\b/g;
|
|
1353
1244
|
while ((match = hexPattern.exec(content)) !== null) {
|
|
1354
1245
|
out.push(normalizeHex(match[0]));
|
|
1355
1246
|
}
|
|
1356
|
-
// Flutter/Android ARGB `0xAARRGGBB` -> #rrggbb (drop alpha).
|
|
1357
1247
|
const argbPattern = /0x([0-9a-fA-F]{8})\b/g;
|
|
1358
1248
|
while ((match = argbPattern.exec(content)) !== null) {
|
|
1359
1249
|
out.push(normalizeHex(`#${match[1].slice(2)}`));
|
|
1360
1250
|
}
|
|
1361
|
-
// iOS Swift `Color(hex: "RRGGBB")`.
|
|
1362
1251
|
const swiftHex = /Color\(\s*hex:\s*"#?([0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?)"/g;
|
|
1363
1252
|
while ((match = swiftHex.exec(content)) !== null) {
|
|
1364
1253
|
out.push(normalizeHex(`#${match[1].slice(0, 6)}`));
|
|
1365
1254
|
}
|
|
1366
|
-
// iOS Swift `Color(red: r, green: g, blue: b)` (floats or n/255).
|
|
1367
1255
|
const swiftRgb = /(?:UI)?Color\(\s*red:\s*([\d./]+)\s*,\s*green:\s*([\d./]+)\s*,\s*blue:\s*([\d./]+)/g;
|
|
1368
1256
|
while ((match = swiftRgb.exec(content)) !== null) {
|
|
1369
1257
|
const r = parseColorComponent(match[1]);
|
|
@@ -1443,26 +1331,14 @@ async function collectFiles(root, max = MAX_PROTOTYPE_FILES) {
|
|
|
1443
1331
|
}
|
|
1444
1332
|
return out;
|
|
1445
1333
|
}
|
|
1446
|
-
// ---------------------------------------------------------------------------
|
|
1447
|
-
// Host-project design-file discovery
|
|
1448
|
-
// ---------------------------------------------------------------------------
|
|
1449
1334
|
const DESIGN_FILE_SKIP = new Set([
|
|
1450
1335
|
...SKIP_DIRS,
|
|
1451
1336
|
"test", "tests", "__tests__", "__mocks__", "example", "examples", "sample", "samples",
|
|
1452
1337
|
"pods", ".dart_tool", "coverage", "fastlane", "generated", "gen", "snapshots",
|
|
1453
|
-
// Platform-runner dirs (Flutter/React Native) — design lives in lib/ or src/,
|
|
1454
|
-
// not these. Skipping them keeps the walk from exhausting its budget before
|
|
1455
|
-
// reaching the real design files in large native repos.
|
|
1456
1338
|
"macos", "windows", "linux", "gradle",
|
|
1457
1339
|
]);
|
|
1458
1340
|
const MAX_SCAN_DIRS = 60000;
|
|
1459
1341
|
const MAX_DESIGN_FILES = 60;
|
|
1460
|
-
/**
|
|
1461
|
-
* Recursively find a project's design-source files by HIGH-SIGNAL name (not every
|
|
1462
|
-
* stylesheet — that would inflate noise). Real apps put these at non-standard
|
|
1463
|
-
* paths (e.g. `lib/v4/core/theme.dart`, `common/src/main/res/values/colors.xml`),
|
|
1464
|
-
* so a fixed root-relative candidate list misses them.
|
|
1465
|
-
*/
|
|
1466
1342
|
async function findProjectDesignFiles(root) {
|
|
1467
1343
|
const out = [];
|
|
1468
1344
|
const stack = [root];
|
|
@@ -1508,10 +1384,10 @@ async function isDesignFile(full) {
|
|
|
1508
1384
|
return cssFileDeclaresDesignTokens(full);
|
|
1509
1385
|
}
|
|
1510
1386
|
if (ext === ".json") {
|
|
1511
|
-
return /\.colorset\//.test(lower) && base === "contents.json";
|
|
1387
|
+
return /\.colorset\//.test(lower) && base === "contents.json";
|
|
1512
1388
|
}
|
|
1513
1389
|
if (ext === ".swift") {
|
|
1514
|
-
return /(theme|color|palette|style|appearance|design)/.test(base);
|
|
1390
|
+
return /(theme|color|palette|style|appearance|design)/.test(base);
|
|
1515
1391
|
}
|
|
1516
1392
|
if (MODULE_EXTS.has(ext)) {
|
|
1517
1393
|
if (/^tailwind\.config\.(js|ts|cjs|mjs)$/.test(base)) {
|
|
@@ -1537,18 +1413,13 @@ async function cssFileDeclaresDesignTokens(full) {
|
|
|
1537
1413
|
return false;
|
|
1538
1414
|
}
|
|
1539
1415
|
}
|
|
1540
|
-
// ---------------------------------------------------------------------------
|
|
1541
|
-
// Contract construction
|
|
1542
|
-
// ---------------------------------------------------------------------------
|
|
1543
1416
|
export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = []) {
|
|
1544
|
-
// CSS bodies come from .css files plus <style> blocks inside HTML.
|
|
1545
1417
|
const cssText = [...sources.css, ...sources.html.flatMap(extractStyleBlocks)].join("\n");
|
|
1546
1418
|
const strippedCss = stripCssComments(cssText);
|
|
1547
1419
|
const declarations = parseDeclarations(strippedCss);
|
|
1548
1420
|
const inlineDeclarations = sources.html.flatMap(extractInlineStyles);
|
|
1549
1421
|
const allDeclarations = [...declarations, ...inlineDeclarations];
|
|
1550
1422
|
const varReferenceCounts = countVarReferences(strippedCss);
|
|
1551
|
-
// --- Declared (exact) tokens: every CSS custom property. ---
|
|
1552
1423
|
const declaredTokens = [];
|
|
1553
1424
|
const declaredValues = new Set();
|
|
1554
1425
|
for (const decl of declarations) {
|
|
@@ -1572,13 +1443,9 @@ export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = [
|
|
|
1572
1443
|
uses: varReferenceCounts.get(decl.prop) ?? 0,
|
|
1573
1444
|
});
|
|
1574
1445
|
}
|
|
1575
|
-
// Declared tokens from non-CSS sources (TS/JS token modules, tailwind config)
|
|
1576
|
-
// are exact too. Record their values so inferred clustering won't duplicate them.
|
|
1577
1446
|
for (const token of extraDeclaredTokens) {
|
|
1578
1447
|
declaredValues.add(token.value);
|
|
1579
1448
|
}
|
|
1580
|
-
// Collapse duplicate declarations (CSS custom properties + module tokens),
|
|
1581
|
-
// keying on category+name, keeping the highest use count.
|
|
1582
1449
|
const declaredByName = new Map();
|
|
1583
1450
|
for (const token of [...declaredTokens, ...extraDeclaredTokens]) {
|
|
1584
1451
|
const key = `${token.category}::${token.name}`;
|
|
@@ -1587,11 +1454,10 @@ export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = [
|
|
|
1587
1454
|
declaredByName.set(key, token);
|
|
1588
1455
|
}
|
|
1589
1456
|
}
|
|
1590
|
-
// --- Inferred tokens: literal values observed >= INFERRED_MIN_USES times. ---
|
|
1591
1457
|
const observations = new Map();
|
|
1592
1458
|
for (const decl of allDeclarations) {
|
|
1593
1459
|
if (decl.prop.startsWith("--")) {
|
|
1594
|
-
continue;
|
|
1460
|
+
continue;
|
|
1595
1461
|
}
|
|
1596
1462
|
for (const observed of observeLiterals(decl.prop, decl.value)) {
|
|
1597
1463
|
const key = `${observed.category}::${observed.value}`;
|
|
@@ -1607,10 +1473,10 @@ export function buildDesignContract(sources, sourceMeta, extraDeclaredTokens = [
|
|
|
1607
1473
|
const inferredTokens = [];
|
|
1608
1474
|
for (const obs of observations.values()) {
|
|
1609
1475
|
if (obs.uses < INFERRED_MIN_USES) {
|
|
1610
|
-
continue;
|
|
1476
|
+
continue;
|
|
1611
1477
|
}
|
|
1612
1478
|
if (declaredValues.has(obs.value)) {
|
|
1613
|
-
continue;
|
|
1479
|
+
continue;
|
|
1614
1480
|
}
|
|
1615
1481
|
inferredTokens.push({ category: obs.category, name: null, value: obs.value, provenance: "inferred", uses: obs.uses });
|
|
1616
1482
|
}
|
|
@@ -1650,7 +1516,6 @@ function gradeStrength(declared, inferred) {
|
|
|
1650
1516
|
export function stripCssComments(css) {
|
|
1651
1517
|
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
1652
1518
|
}
|
|
1653
|
-
/** Parse declarations inside the innermost `{ ... }` rule bodies. */
|
|
1654
1519
|
export function parseDeclarations(css) {
|
|
1655
1520
|
const out = [];
|
|
1656
1521
|
const bodyPattern = /\{([^{}]*)\}/g;
|
|
@@ -1722,9 +1587,6 @@ export function parseBreakpoints(css) {
|
|
|
1722
1587
|
}
|
|
1723
1588
|
return [...seen.values()].sort((a, b) => a.px - b.px || a.edge.localeCompare(b.edge));
|
|
1724
1589
|
}
|
|
1725
|
-
// ---------------------------------------------------------------------------
|
|
1726
|
-
// Value classification
|
|
1727
|
-
// ---------------------------------------------------------------------------
|
|
1728
1590
|
const COLOR_FUNCTION = /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/i;
|
|
1729
1591
|
const HEX_COLOR = /^#[0-9a-f]{3,8}$/i;
|
|
1730
1592
|
const LENGTH = /^-?\d*\.?\d+(px|rem|em|vh|vw|%)$/i;
|
|
@@ -1748,8 +1610,6 @@ function categorizeDeclaredVar(name, value) {
|
|
|
1748
1610
|
if (/radius|radii|corner|\bround/.test(n)) {
|
|
1749
1611
|
return "radius";
|
|
1750
1612
|
}
|
|
1751
|
-
// borderWidth: name-and-value gated, MUST come before the color branch because
|
|
1752
|
-
// the color regex matches /border/ — `--border-width-thin: 1px` is a length, not a color.
|
|
1753
1613
|
if (/border-?width|stroke-?width|outline-?width/.test(n) && LENGTH.test(v)) {
|
|
1754
1614
|
return "borderWidth";
|
|
1755
1615
|
}
|
|
@@ -1759,46 +1619,30 @@ function categorizeDeclaredVar(name, value) {
|
|
|
1759
1619
|
if (/(font-family|fontfamily|typeface|family)/.test(n) && /[a-z]/i.test(v) && !LENGTH.test(v) && !isColor(v)) {
|
|
1760
1620
|
return "fontFamily";
|
|
1761
1621
|
}
|
|
1762
|
-
// fontSize: broaden to include the common --fs-* naming convention (e.g. --fs-sm: 14px).
|
|
1763
|
-
// 'leading' and 'line-height' are moved to lineHeight below.
|
|
1764
1622
|
if (/(font-size|fontsize|text-size)/.test(n) || /\btext-(xs|sm|base|md|lg|xl|\dxl|\d)\b/.test(n) || /^--fs-\w/.test(n)) {
|
|
1765
|
-
return "fontSize";
|
|
1623
|
+
return "fontSize";
|
|
1766
1624
|
}
|
|
1767
|
-
// Motion is value-gated: only time/easing values become motion tokens. This
|
|
1768
|
-
// stops substring matches like "increase"/"decrease" (which contain "ease")
|
|
1769
|
-
// from turning a px length into a nonsensical motion token.
|
|
1770
1625
|
if (TIME.test(v) || /cubic-bezier|steps\(/i.test(v)) {
|
|
1771
1626
|
return "motion";
|
|
1772
1627
|
}
|
|
1773
1628
|
if (/(duration|easing|transition|motion|animation|\bease)/.test(n) && !LENGTH.test(v) && !isColor(v)) {
|
|
1774
1629
|
return "motion";
|
|
1775
1630
|
}
|
|
1776
|
-
// Generic font/type hint, but only for an actual family value (not a length —
|
|
1777
|
-
// e.g. "prototype" contains "type" but `--prototype-flag: 10px` is not a font).
|
|
1778
1631
|
if (/(font|type)/.test(n) && /[a-z]/i.test(v) && !LENGTH.test(v) && !isColor(v) && !TIME.test(v)) {
|
|
1779
1632
|
return "fontFamily";
|
|
1780
1633
|
}
|
|
1781
|
-
// fontWeight: name-gated (bare integers collide with z-index). Value must be a
|
|
1782
|
-
// 3-digit integer in the valid CSS font-weight range 100–950.
|
|
1783
1634
|
if (/\bfw\b|font-?weight|\bweight\b/.test(n)) {
|
|
1784
1635
|
const num = Number(v);
|
|
1785
1636
|
if (/^\d{3}$/.test(v) && num >= 100 && num <= 950) {
|
|
1786
1637
|
return "fontWeight";
|
|
1787
1638
|
}
|
|
1788
1639
|
}
|
|
1789
|
-
// lineHeight: name-gated + unitless value (1.1, 1.4, 1.6 etc.). Captures both
|
|
1790
|
-
// 'lh-*' shorthand and 'leading-*'/'line-height-*' naming conventions.
|
|
1791
1640
|
if (/\blh\b|leading|line-?height|lineheight/.test(n) && /^-?[0-9]*\.?[0-9]+$/.test(v) && !LENGTH.test(v)) {
|
|
1792
1641
|
return "lineHeight";
|
|
1793
1642
|
}
|
|
1794
|
-
// letterSpacing: name-gated + any CSS length. Must come before the generic
|
|
1795
|
-
// LENGTH→space fallback so --ls-* tokens aren't misfiled as spacing.
|
|
1796
1643
|
if (/\bls\b|letter-?spacing|letterspacing|\btracking\b/.test(n) && LENGTH.test(v)) {
|
|
1797
1644
|
return "letterSpacing";
|
|
1798
1645
|
}
|
|
1799
|
-
// breakpoint: name-gated + length. Must precede the LENGTH fallback. The
|
|
1800
|
-
// contract already captures @media breakpoints in contract.breakpoints[]; this
|
|
1801
|
-
// catches explicit --bp-* / --breakpoint-* custom-property tokens.
|
|
1802
1646
|
if (/\bbp\b|break-?point|viewport-?width|screen-?size/.test(n) && LENGTH.test(v)) {
|
|
1803
1647
|
return "breakpoint";
|
|
1804
1648
|
}
|
|
@@ -1811,29 +1655,22 @@ function categorizeDeclaredVar(name, value) {
|
|
|
1811
1655
|
if (LENGTH.test(v)) {
|
|
1812
1656
|
return "space";
|
|
1813
1657
|
}
|
|
1814
|
-
// Opacity: declared-only (a bare 0.4 in code could be line-height/scale/anything — never infer).
|
|
1815
1658
|
if (/opacity/.test(n) && /^(0(\.\d+)?|\.\d+|1(\.0*)?)$/.test(v)) {
|
|
1816
1659
|
return "opacity";
|
|
1817
1660
|
}
|
|
1818
|
-
// zIndex: name-gated strictly; integers collide with fontWeight so the name is
|
|
1819
|
-
// the only reliable signal. Use z-index / z-idx or the common --z-* prefix.
|
|
1820
|
-
// Note: bare `/z/` would catch --zoom, --size, etc. — do NOT relax this guard.
|
|
1821
1661
|
if ((/z-?index|z-?idx/.test(n) || /^--z-\w/.test(n)) && /^-?\d+$/.test(v)) {
|
|
1822
1662
|
return "zIndex";
|
|
1823
1663
|
}
|
|
1824
1664
|
return null;
|
|
1825
1665
|
}
|
|
1826
|
-
/** Emit zero or more (category, normalizedValue) observations from one literal declaration. */
|
|
1827
1666
|
function observeLiterals(prop, value) {
|
|
1828
1667
|
const v = value.trim();
|
|
1829
1668
|
if (!v || v.startsWith("var(") || v === "inherit" || v === "initial" || v === "unset" || v === "none" || v === "auto") {
|
|
1830
1669
|
return [];
|
|
1831
1670
|
}
|
|
1832
|
-
// Shadows: keep the whole declaration as one token; don't dissect inner colors/lengths.
|
|
1833
1671
|
if (/shadow/.test(prop)) {
|
|
1834
1672
|
return [{ category: "shadow", value: collapseSpaces(v) }];
|
|
1835
1673
|
}
|
|
1836
|
-
// Motion: durations/easing.
|
|
1837
1674
|
if (/^(transition|animation)(-|$)/.test(prop) || TIME.test(v)) {
|
|
1838
1675
|
if (TIME.test(v)) {
|
|
1839
1676
|
const time = v.match(/\b\d*\.?\d+m?s\b/i);
|
|
@@ -1843,20 +1680,16 @@ function observeLiterals(prop, value) {
|
|
|
1843
1680
|
}
|
|
1844
1681
|
return [];
|
|
1845
1682
|
}
|
|
1846
|
-
// Font family.
|
|
1847
1683
|
if (prop === "font-family") {
|
|
1848
1684
|
return [{ category: "fontFamily", value: collapseSpaces(v) }];
|
|
1849
1685
|
}
|
|
1850
|
-
// Font size.
|
|
1851
1686
|
if (prop === "font-size" && LENGTH.test(v)) {
|
|
1852
1687
|
return [{ category: "fontSize", value: v.toLowerCase() }];
|
|
1853
1688
|
}
|
|
1854
1689
|
const out = [];
|
|
1855
|
-
// Colors anywhere in the value.
|
|
1856
1690
|
for (const color of extractColorLiterals(v)) {
|
|
1857
1691
|
out.push({ category: "color", value: color });
|
|
1858
1692
|
}
|
|
1859
|
-
// Lengths — only meaningful as spacing/radius tokens on the relevant props.
|
|
1860
1693
|
if (/radius/.test(prop)) {
|
|
1861
1694
|
for (const len of extractLengths(v)) {
|
|
1862
1695
|
out.push({ category: "radius", value: len });
|
|
@@ -1889,7 +1722,7 @@ function extractLengths(value) {
|
|
|
1889
1722
|
while ((match = pattern.exec(value)) !== null) {
|
|
1890
1723
|
const token = match[0].toLowerCase();
|
|
1891
1724
|
if (token.startsWith("0") && (token === "0px" || token === "0rem" || token === "0em")) {
|
|
1892
|
-
continue;
|
|
1725
|
+
continue;
|
|
1893
1726
|
}
|
|
1894
1727
|
out.push(token);
|
|
1895
1728
|
}
|
|
@@ -1915,7 +1748,6 @@ function normalizeValue(category, value) {
|
|
|
1915
1748
|
function normalizeHex(hex) {
|
|
1916
1749
|
let h = hex.toLowerCase();
|
|
1917
1750
|
if (h.length === 4) {
|
|
1918
|
-
// #abc -> #aabbcc
|
|
1919
1751
|
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
|
|
1920
1752
|
}
|
|
1921
1753
|
else if (h.length === 5) {
|
|
@@ -1926,9 +1758,6 @@ function normalizeHex(hex) {
|
|
|
1926
1758
|
function collapseSpaces(value) {
|
|
1927
1759
|
return value.replace(/\s+/g, " ").trim();
|
|
1928
1760
|
}
|
|
1929
|
-
// ---------------------------------------------------------------------------
|
|
1930
|
-
// Component inference (advisory only)
|
|
1931
|
-
// ---------------------------------------------------------------------------
|
|
1932
1761
|
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)/;
|
|
1933
1762
|
const COMPONENT_MIN_USES = 3;
|
|
1934
1763
|
function inferComponents(htmlSources, css) {
|
|
@@ -1992,9 +1821,6 @@ function friendlyComponentName(cls) {
|
|
|
1992
1821
|
const hint = lower.match(COMPONENT_NAME_HINTS);
|
|
1993
1822
|
return hint ? hint[0] : cls;
|
|
1994
1823
|
}
|
|
1995
|
-
// ---------------------------------------------------------------------------
|
|
1996
|
-
// Sorting, summarizing, digest
|
|
1997
|
-
// ---------------------------------------------------------------------------
|
|
1998
1824
|
const CATEGORY_ORDER = ["color", "space", "radius", "shadow", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "borderWidth", "breakpoint", "motion", "opacity", "zIndex"];
|
|
1999
1825
|
function sortTokens(tokens) {
|
|
2000
1826
|
return [...tokens].sort((a, b) => {
|
|
@@ -2016,7 +1842,6 @@ function summarizeTokens(tokens) {
|
|
|
2016
1842
|
}
|
|
2017
1843
|
return summary;
|
|
2018
1844
|
}
|
|
2019
|
-
/** Digest over design facts only — excludes timestamps, version, and input paths so the same prototype always yields the same digest. */
|
|
2020
1845
|
export function digestContract(contract) {
|
|
2021
1846
|
const facts = {
|
|
2022
1847
|
kind: contract.source.kind,
|
|
@@ -2026,17 +1851,8 @@ export function digestContract(contract) {
|
|
|
2026
1851
|
};
|
|
2027
1852
|
return `sha256:${createHash("sha256").update(stableStringify(facts)).digest("hex")}`;
|
|
2028
1853
|
}
|
|
2029
|
-
|
|
2030
|
-
// Token-module / tailwind-config extraction (TS/JS object literals)
|
|
2031
|
-
// ---------------------------------------------------------------------------
|
|
2032
|
-
const REFERENCE_VALUE = /var\(|theme\(|calc\(|env\(/i; // references / computed — not concrete tokens
|
|
1854
|
+
const REFERENCE_VALUE = /var\(|theme\(|calc\(|env\(/i;
|
|
2033
1855
|
const MODULE_LENGTH = /^-?\d*\.?\d+(px|rem|em|vh|vw|%)$/i;
|
|
2034
|
-
/**
|
|
2035
|
-
* Extract concrete declared tokens from the object literals in a TS/JS token
|
|
2036
|
-
* module or tailwind config. Only emits a token when the value is a concrete
|
|
2037
|
-
* literal we can confidently categorize — references and un-placeable values are
|
|
2038
|
-
* skipped (weaker, never wrong).
|
|
2039
|
-
*/
|
|
2040
1856
|
export function extractTokensFromModule(source) {
|
|
2041
1857
|
const tree = tryParse("tsx", source) ?? tryParse("typescript", source);
|
|
2042
1858
|
if (!tree) {
|
|
@@ -2046,10 +1862,6 @@ export function extractTokensFromModule(source) {
|
|
|
2046
1862
|
const stack = [tree.rootNode];
|
|
2047
1863
|
while (stack.length > 0) {
|
|
2048
1864
|
const node = stack.pop();
|
|
2049
|
-
// Kick off collection at standalone object literals (an object that is a
|
|
2050
|
-
// pair's value is already captured by the parent's recursion). Seed the key
|
|
2051
|
-
// path with the declared variable name so `export const spacing = {...}`
|
|
2052
|
-
// carries the "spacing" category hint down to its leaf values.
|
|
2053
1865
|
if (node.type === "object" && node.parent?.type !== "pair") {
|
|
2054
1866
|
collectObjectStringPairs(node, seedKeyPath(node), pairs);
|
|
2055
1867
|
}
|
|
@@ -2111,9 +1923,6 @@ function collectObjectStringPairs(node, keyPath, out) {
|
|
|
2111
1923
|
collectObjectStringPairs(valueNode, [...keyPath, key], out);
|
|
2112
1924
|
}
|
|
2113
1925
|
else {
|
|
2114
|
-
// Arrays (e.g. Tailwind fontSize: ['14px', { lineHeight }]) — take the
|
|
2115
|
-
// first string-literal element (the size); numeric/unitless arrays yield
|
|
2116
|
-
// nothing.
|
|
2117
1926
|
const valueText = valueNode.type === "array" ? firstArrayStringLiteral(valueNode) : moduleStringLiteral(valueNode);
|
|
2118
1927
|
if (valueText !== undefined) {
|
|
2119
1928
|
out.push({ keyPath: [...keyPath, key].join("."), value: valueText });
|
|
@@ -2145,17 +1954,12 @@ function moduleStringLiteral(node) {
|
|
|
2145
1954
|
if (node.type === "string" || node.type === "template_string") {
|
|
2146
1955
|
const text = node.text;
|
|
2147
1956
|
if (text.includes("${")) {
|
|
2148
|
-
return undefined;
|
|
1957
|
+
return undefined;
|
|
2149
1958
|
}
|
|
2150
1959
|
return stripQuotes(text);
|
|
2151
1960
|
}
|
|
2152
1961
|
return undefined;
|
|
2153
1962
|
}
|
|
2154
|
-
/**
|
|
2155
|
-
* Android resources: `<color name="x">#hex</color>` (concrete) and
|
|
2156
|
-
* `<dimen name="x">16dp</dimen>`. `<item>@color/x</item>` references are
|
|
2157
|
-
* ignored (they aren't concrete values).
|
|
2158
|
-
*/
|
|
2159
1963
|
export function extractTokensFromAndroidXml(content) {
|
|
2160
1964
|
const tokens = [];
|
|
2161
1965
|
const seen = new Set();
|
|
@@ -2185,25 +1989,19 @@ export function extractTokensFromAndroidXml(content) {
|
|
|
2185
1989
|
else if (/(text|font)/.test(key)) {
|
|
2186
1990
|
push("fontSize", name, value);
|
|
2187
1991
|
}
|
|
2188
|
-
// unplaceable dimens are skipped (never guessed)
|
|
2189
1992
|
}
|
|
2190
1993
|
return tokens;
|
|
2191
1994
|
}
|
|
2192
1995
|
function normalizeAndroidHex(hex) {
|
|
2193
1996
|
const h = hex.toLowerCase();
|
|
2194
1997
|
if (h.length === 9) {
|
|
2195
|
-
return `#${h.slice(3)}`;
|
|
1998
|
+
return `#${h.slice(3)}`;
|
|
2196
1999
|
}
|
|
2197
2000
|
if (h.length === 5) {
|
|
2198
|
-
return normalizeHex(`#${h.slice(2)}`);
|
|
2001
|
+
return normalizeHex(`#${h.slice(2)}`);
|
|
2199
2002
|
}
|
|
2200
2003
|
return normalizeHex(h);
|
|
2201
2004
|
}
|
|
2202
|
-
/**
|
|
2203
|
-
* Flutter color tokens: both `kPrimary = Color(0xAARRGGBB)` (const declarations)
|
|
2204
|
-
* and `primaryColor: const Color(0xAARRGGBB)` (named-parameter theme construction,
|
|
2205
|
-
* the common real pattern). Named `Colors.*` are not extractable.
|
|
2206
|
-
*/
|
|
2207
2005
|
export function extractTokensFromDart(content) {
|
|
2208
2006
|
const tokens = [];
|
|
2209
2007
|
const seen = new Set();
|
|
@@ -2211,7 +2009,7 @@ export function extractTokensFromDart(content) {
|
|
|
2211
2009
|
let match;
|
|
2212
2010
|
while ((match = pattern.exec(content)) !== null) {
|
|
2213
2011
|
const name = match[1];
|
|
2214
|
-
const value = `#${match[2].slice(2).toLowerCase()}`;
|
|
2012
|
+
const value = `#${match[2].slice(2).toLowerCase()}`;
|
|
2215
2013
|
const key = `color::${name}::${value}`;
|
|
2216
2014
|
if (!seen.has(key)) {
|
|
2217
2015
|
seen.add(key);
|
|
@@ -2220,11 +2018,6 @@ export function extractTokensFromDart(content) {
|
|
|
2220
2018
|
}
|
|
2221
2019
|
return tokens;
|
|
2222
2020
|
}
|
|
2223
|
-
/**
|
|
2224
|
-
* iOS asset catalog: `*.colorset/Contents.json` (sRGB/display-p3 components as
|
|
2225
|
-
* 0–255 integers, 0–1 floats, or 0xNN hex). The colorset directory name is the
|
|
2226
|
-
* token name. Dark-appearance variants are skipped in favor of the base color.
|
|
2227
|
-
*/
|
|
2228
2021
|
export function extractTokensFromColorset(contentsJson, name) {
|
|
2229
2022
|
let parsed;
|
|
2230
2023
|
try {
|
|
@@ -2268,7 +2061,6 @@ function parseColorComponent(raw) {
|
|
|
2268
2061
|
if (!Number.isFinite(num)) {
|
|
2269
2062
|
return null;
|
|
2270
2063
|
}
|
|
2271
|
-
// A decimal point means a 0–1 float component; a bare integer is 0–255.
|
|
2272
2064
|
return v.includes(".") ? clampByte(Math.round(num * 255)) : clampByte(Math.round(num));
|
|
2273
2065
|
}
|
|
2274
2066
|
function clampByte(n) {
|
|
@@ -2277,7 +2069,6 @@ function clampByte(n) {
|
|
|
2277
2069
|
function toHex2(n) {
|
|
2278
2070
|
return n.toString(16).padStart(2, "0");
|
|
2279
2071
|
}
|
|
2280
|
-
/** Swift color constants: `Color(hex: "RRGGBB")` and `Color(red: r, green: g, blue: b)` (floats or n/255). */
|
|
2281
2072
|
export function extractTokensFromSwift(content) {
|
|
2282
2073
|
const tokens = [];
|
|
2283
2074
|
const seen = new Set();
|
|
@@ -2305,28 +2096,21 @@ export function extractTokensFromSwift(content) {
|
|
|
2305
2096
|
}
|
|
2306
2097
|
return tokens;
|
|
2307
2098
|
}
|
|
2308
|
-
/** Conservative, FP-safe categorization for a token-module/config value. */
|
|
2309
2099
|
function categorizeTokenModuleValue(keyPath, value) {
|
|
2310
2100
|
const v = value.trim();
|
|
2311
2101
|
if (!v || REFERENCE_VALUE.test(v)) {
|
|
2312
|
-
return null;
|
|
2102
|
+
return null;
|
|
2313
2103
|
}
|
|
2314
2104
|
const key = keyPath.toLowerCase();
|
|
2315
|
-
// These categories are CSS-only (declared custom properties): the name-gating
|
|
2316
|
-
// that makes them safe against false positives only works in CSS. Module/native
|
|
2317
|
-
// extraction skips them rather than risk inventing wrong tokens.
|
|
2318
2105
|
if (/screen|breakpoint|media|z-?index|opacity|weight|\blh\b|leading|line-?height|letter-?spacing/.test(key)) {
|
|
2319
2106
|
return null;
|
|
2320
2107
|
}
|
|
2321
|
-
// Colors: unambiguous from the value alone.
|
|
2322
2108
|
if (isColor(v)) {
|
|
2323
2109
|
return { category: "color", value: normalizeValue("color", v) };
|
|
2324
2110
|
}
|
|
2325
|
-
// Shadows: key-hinted, value carries length(s).
|
|
2326
2111
|
if (/shadow|elevation/.test(key) && /\d/.test(v)) {
|
|
2327
2112
|
return { category: "shadow", value: collapseSpaces(v) };
|
|
2328
2113
|
}
|
|
2329
|
-
// Lengths: only categorize when the key places them — otherwise skip (never guess).
|
|
2330
2114
|
if (MODULE_LENGTH.test(v)) {
|
|
2331
2115
|
if (/radius|radii|corner|\bround/.test(key)) {
|
|
2332
2116
|
return { category: "radius", value: v.toLowerCase() };
|
|
@@ -2337,13 +2121,10 @@ function categorizeTokenModuleValue(keyPath, value) {
|
|
|
2337
2121
|
if (/(font-?size|text-?size|leading|line-?height)/.test(key) ||
|
|
2338
2122
|
/\btext-(xs|sm|base|md|lg|xl|\dxl|\d)\b/.test(key) ||
|
|
2339
2123
|
(/(typography|font|text)/.test(key) && /(^|\.)sizes?(\.|$)/.test(key))) {
|
|
2340
|
-
return { category: "fontSize", value: v.toLowerCase() };
|
|
2124
|
+
return { category: "fontSize", value: v.toLowerCase() };
|
|
2341
2125
|
}
|
|
2342
2126
|
return null;
|
|
2343
2127
|
}
|
|
2344
|
-
// Motion: cubic-bezier/steps is unambiguously an easing token (value-based,
|
|
2345
|
-
// like colors) — catches keys like "smooth"/"standard" that lack an ease/-
|
|
2346
|
-
// duration hint.
|
|
2347
2128
|
if (/cubic-bezier|steps\(/i.test(v)) {
|
|
2348
2129
|
return { category: "motion", value: collapseSpaces(v) };
|
|
2349
2130
|
}
|
|
@@ -2356,7 +2137,6 @@ function categorizeTokenModuleValue(keyPath, value) {
|
|
|
2356
2137
|
if (/(ease|easing|bezier)/.test(key) && /cubic-bezier|ease/i.test(v)) {
|
|
2357
2138
|
return { category: "motion", value: collapseSpaces(v) };
|
|
2358
2139
|
}
|
|
2359
|
-
// Font family: key-hinted, value is a family list.
|
|
2360
2140
|
if (/(font-?family|fontfamily|typeface|family)/.test(key) && /[a-z]/i.test(v) && !MODULE_LENGTH.test(v)) {
|
|
2361
2141
|
return { category: "fontFamily", value: collapseSpaces(v) };
|
|
2362
2142
|
}
|
|
@@ -2374,26 +2154,13 @@ function stableStringify(value) {
|
|
|
2374
2154
|
}
|
|
2375
2155
|
return JSON.stringify(value);
|
|
2376
2156
|
}
|
|
2377
|
-
/**
|
|
2378
|
-
* Structural grounding helper: builds a BriefLine and throws a TypeError at
|
|
2379
|
-
* construction time if groundedIn is empty. This makes the grounding invariant
|
|
2380
|
-
* structural — an ungrounded line is impossible rather than a runtime surprise.
|
|
2381
|
-
*/
|
|
2382
2157
|
function line(text, groundedIn, confidence = "high") {
|
|
2383
2158
|
if (groundedIn.length === 0) {
|
|
2384
2159
|
throw new TypeError(`BriefLine created with empty groundedIn: "${text}"`);
|
|
2385
2160
|
}
|
|
2386
2161
|
return { text, groundedIn, confidence };
|
|
2387
2162
|
}
|
|
2388
|
-
/**
|
|
2389
|
-
* Per-role keyword rules in spec-defined order.
|
|
2390
|
-
* Each entry: [pattern, role, confidence].
|
|
2391
|
-
* Rules are applied with first-match-wins semantics.
|
|
2392
|
-
* Compound rules (muted+text, muted+bg/surface) must precede their plain
|
|
2393
|
-
* counterparts so "--color-text-muted" resolves to textSecondary, not textPrimary.
|
|
2394
|
-
*/
|
|
2395
2163
|
const ROLE_RULES = [
|
|
2396
|
-
// Compound rules — must precede their plain counterparts
|
|
2397
2164
|
{
|
|
2398
2165
|
test: (n) => /muted/.test(n) && /\btext\b|foreground|fg/.test(n),
|
|
2399
2166
|
role: "textSecondary",
|
|
@@ -2406,11 +2173,6 @@ const ROLE_RULES = [
|
|
|
2406
2173
|
confidence: "medium",
|
|
2407
2174
|
reason: "name contains 'muted' and 'bg'/'surface'/'background'",
|
|
2408
2175
|
},
|
|
2409
|
-
// Noun-first compounds — in real design systems the NOUN keyword (text/surface/
|
|
2410
|
-
// bg/border) sets the role family and primary/secondary act as modifiers within
|
|
2411
|
-
// it: "--text-primary" is the primary BODY-TEXT color, not the action color.
|
|
2412
|
-
// These must precede the plain primary/secondary rules below, or first-match-wins
|
|
2413
|
-
// would misbind some of the most common token names in the wild.
|
|
2414
2176
|
{
|
|
2415
2177
|
test: (n) => /\btext\b|foreground|\bfg\b/.test(n) && /\bprimary\b/.test(n),
|
|
2416
2178
|
role: "textPrimary",
|
|
@@ -2441,12 +2203,6 @@ const ROLE_RULES = [
|
|
|
2441
2203
|
confidence: "medium",
|
|
2442
2204
|
reason: "name contains a border keyword with a primary/secondary modifier — the noun keyword sets the role family",
|
|
2443
2205
|
},
|
|
2444
|
-
// Primary: plain "primary" → high; "brand"/"accent" → medium
|
|
2445
|
-
// EXCLUSION: action roles bind interactive-family names only.
|
|
2446
|
-
// A token whose name contains a text-family noun (text/foreground/fg) MUST
|
|
2447
|
-
// NEVER bind primaryAction or secondaryAction — it represents a text colour,
|
|
2448
|
-
// not an interactive accent (e.g. --text-bright-accent is a text accent, while
|
|
2449
|
-
// --essential-bright-accent is the real interactive accent).
|
|
2450
2206
|
{
|
|
2451
2207
|
test: (n) => /\bprimary\b/.test(n) && !/brand|accent/.test(n) && !/\btext\b|foreground|\bfg\b/.test(n),
|
|
2452
2208
|
role: "primaryAction",
|
|
@@ -2508,7 +2264,6 @@ const ROLE_RULES = [
|
|
|
2508
2264
|
reason: "name contains 'avatar'",
|
|
2509
2265
|
},
|
|
2510
2266
|
];
|
|
2511
|
-
/** Infer a (role, confidence, reason) from a token name, or null if no rule matches. */
|
|
2512
2267
|
function inferRole(tokenName) {
|
|
2513
2268
|
const n = tokenName.toLowerCase();
|
|
2514
2269
|
for (const rule of ROLE_RULES) {
|
|
@@ -2518,11 +2273,6 @@ function inferRole(tokenName) {
|
|
|
2518
2273
|
}
|
|
2519
2274
|
return null;
|
|
2520
2275
|
}
|
|
2521
|
-
/**
|
|
2522
|
-
* Among multiple candidates for the same role, prefer:
|
|
2523
|
-
* 1. high confidence over medium
|
|
2524
|
-
* 2. shorter name (e.g. "--color-primary" beats "--color-primary-hover")
|
|
2525
|
-
*/
|
|
2526
2276
|
function bestCandidate(a, b) {
|
|
2527
2277
|
if (a.confidence === "high" && b.confidence !== "high")
|
|
2528
2278
|
return a;
|
|
@@ -2530,22 +2280,8 @@ function bestCandidate(a, b) {
|
|
|
2530
2280
|
return b;
|
|
2531
2281
|
return a.token.length <= b.token.length ? a : b;
|
|
2532
2282
|
}
|
|
2533
|
-
/**
|
|
2534
|
-
* Build a DesignBuildBrief from a DesignContract.
|
|
2535
|
-
*
|
|
2536
|
-
* - Pure: no I/O, no side effects.
|
|
2537
|
-
* - Never persisted or digested.
|
|
2538
|
-
* - Every BriefLine has non-empty groundedIn citing actual contract tokens/roles.
|
|
2539
|
-
* - Roles inferred from NAME only (never from value).
|
|
2540
|
-
* - Inferred tokens (name: null) are never cited by name; they may be cited only
|
|
2541
|
-
* as a count for do/avoid prose, but only if the contract has declared color
|
|
2542
|
-
* tokens with recognizable names to ground the line instead.
|
|
2543
|
-
*/
|
|
2544
2283
|
export function buildDesignBrief(contract) {
|
|
2545
2284
|
const strength = contract.stats.strength;
|
|
2546
|
-
// ── Role inference ──────────────────────────────────────────────────────────
|
|
2547
|
-
// Walk only declared color tokens (provenance=declared, category=color, name != null).
|
|
2548
|
-
// Inferred tokens always have name: null; value-only matching is forbidden.
|
|
2549
2285
|
const colorTokens = contract.tokens.filter((t) => t.category === "color" && t.name !== null);
|
|
2550
2286
|
const roleMap = new Map();
|
|
2551
2287
|
for (const token of colorTokens) {
|
|
@@ -2564,26 +2300,12 @@ export function buildDesignBrief(contract) {
|
|
|
2564
2300
|
roleMap.set(inferred.role, existing ? bestCandidate(existing, candidate) : candidate);
|
|
2565
2301
|
}
|
|
2566
2302
|
const roles = [...roleMap.values()];
|
|
2567
|
-
// Helper: is a role name in this brief?
|
|
2568
2303
|
const roleNames = new Set(roles.map((r) => r.role));
|
|
2569
|
-
// String-typed version for use in contexts where we compare arbitrary strings against role names.
|
|
2570
2304
|
const roleNameStrings = new Set(roles.map((r) => r.role));
|
|
2571
|
-
// ── Component hints ─────────────────────────────────────────────────────────
|
|
2572
|
-
// Reference ONLY tokens that actually exist in the contract.
|
|
2573
|
-
/**
|
|
2574
|
-
* Representative token selector: among a category's declared tokens, prefer
|
|
2575
|
-
* (in order) a name containing "base", then "default", then "md"/"medium",
|
|
2576
|
-
* then the SHORTEST name, then first-in-order.
|
|
2577
|
-
*
|
|
2578
|
-
* Using first-in-contract-order (old behaviour) selected unrepresentative tokens
|
|
2579
|
-
* like --encore-corner-radius-smaller over --encore-corner-radius-base, causing
|
|
2580
|
-
* agents to anchor on non-base variants and miss the authoritative base token.
|
|
2581
|
-
*/
|
|
2582
2305
|
function representativeToken(cat) {
|
|
2583
2306
|
const candidates = contract.tokens.filter((t) => t.category === cat && t.name !== null && t.provenance === "declared");
|
|
2584
2307
|
if (candidates.length === 0)
|
|
2585
2308
|
return undefined;
|
|
2586
|
-
// Scoring: lower is better. 0 = base, 1 = default, 2 = md/medium, 3 = shortest, 4 = first.
|
|
2587
2309
|
const score = (name) => {
|
|
2588
2310
|
const n = name.toLowerCase();
|
|
2589
2311
|
if (/\bbase\b/.test(n))
|
|
@@ -2601,18 +2323,14 @@ export function buildDesignBrief(contract) {
|
|
|
2601
2323
|
return t;
|
|
2602
2324
|
if (ts > bs)
|
|
2603
2325
|
return best;
|
|
2604
|
-
// Same bucket: prefer shorter name, then first-in-order (best wins ties).
|
|
2605
2326
|
return t.name.length < best.name.length ? t : best;
|
|
2606
2327
|
});
|
|
2607
2328
|
}
|
|
2608
2329
|
const radiusToken = representativeToken("radius");
|
|
2609
2330
|
const spaceToken = representativeToken("space");
|
|
2610
|
-
// Border colour: sourced from the inferred border role (a color token named *border*/*outline*/*divider*).
|
|
2611
|
-
// There is no "border" TokenCategory — border colours live in the "color" category.
|
|
2612
2331
|
const borderRoleColor = roleMap.get("border");
|
|
2613
2332
|
const shadowToken = representativeToken("shadow");
|
|
2614
2333
|
const primaryRole = roleMap.get("primaryAction");
|
|
2615
|
-
// card hint
|
|
2616
2334
|
const cardGuidanceLines = [];
|
|
2617
2335
|
if (radiusToken) {
|
|
2618
2336
|
cardGuidanceLines.push(line(`Use ${radiusToken.name} (${radiusToken.value}) for card corner radius.`, [radiusToken.name]));
|
|
@@ -2629,7 +2347,6 @@ export function buildDesignBrief(contract) {
|
|
|
2629
2347
|
const cardHint = cardGuidanceLines.length > 0
|
|
2630
2348
|
? { kind: "card", guidance: cardGuidanceLines, confidence: radiusToken && spaceToken ? "high" : "medium" }
|
|
2631
2349
|
: { kind: "card", absent: true, note: "No card pattern confidently identified — reuse the host app's existing card styles." };
|
|
2632
|
-
// button hint
|
|
2633
2350
|
const buttonGuidanceLines = [];
|
|
2634
2351
|
if (primaryRole) {
|
|
2635
2352
|
buttonGuidanceLines.push(line(`Use ${primaryRole.token} (${primaryRole.value}) as the primary button background.`, [primaryRole.role]));
|
|
@@ -2643,7 +2360,6 @@ export function buildDesignBrief(contract) {
|
|
|
2643
2360
|
const buttonHint = buttonGuidanceLines.length > 0
|
|
2644
2361
|
? { kind: "button", guidance: buttonGuidanceLines, confidence: primaryRole ? "high" : "medium" }
|
|
2645
2362
|
: { kind: "button", absent: true, note: "No button pattern confidently identified — reuse the host app's existing button styles." };
|
|
2646
|
-
// input hint
|
|
2647
2363
|
const inputGuidanceLines = [];
|
|
2648
2364
|
if (borderRoleColor) {
|
|
2649
2365
|
inputGuidanceLines.push(line(`Use ${borderRoleColor.token} for input border colour.`, [borderRoleColor.role]));
|
|
@@ -2658,60 +2374,38 @@ export function buildDesignBrief(contract) {
|
|
|
2658
2374
|
? { kind: "input", guidance: inputGuidanceLines, confidence: borderRoleColor ? "high" : "medium" }
|
|
2659
2375
|
: { kind: "input", absent: true, note: "No input pattern confidently identified — reuse the host app's existing input styles." };
|
|
2660
2376
|
const componentHints = [cardHint, buttonHint, inputHint];
|
|
2661
|
-
// ── Do/Avoid lines ──────────────────────────────────────────────────────────
|
|
2662
|
-
// Every line MUST be grounded in tokens/roles that actually exist in this brief.
|
|
2663
2377
|
const doLines = [];
|
|
2664
2378
|
const avoidLines = [];
|
|
2665
|
-
// Only emit do/avoid lines that are grounded in actually-present tokens.
|
|
2666
2379
|
const declaredColorTokens = colorTokens.filter((t) => t.provenance === "declared");
|
|
2667
2380
|
const declaredSpaceTokens = contract.tokens.filter((t) => t.category === "space" && t.name !== null && t.provenance === "declared");
|
|
2668
2381
|
const declaredRadiusTokens = contract.tokens.filter((t) => t.category === "radius" && t.name !== null && t.provenance === "declared");
|
|
2669
|
-
// Do: use declared color tokens
|
|
2670
2382
|
if (declaredColorTokens.length > 0) {
|
|
2671
2383
|
const tokenNames = declaredColorTokens.slice(0, 3).map((t) => t.name);
|
|
2672
2384
|
doLines.push(line(`Reference declared color tokens (e.g. ${tokenNames.join(", ")}) — never introduce new hex literals.`, tokenNames));
|
|
2673
2385
|
}
|
|
2674
|
-
// Do: use declared space tokens
|
|
2675
2386
|
if (declaredSpaceTokens.length > 0) {
|
|
2676
2387
|
const tokenNames = declaredSpaceTokens.slice(0, 3).map((t) => t.name);
|
|
2677
2388
|
doLines.push(line(`Reference declared spacing tokens (e.g. ${tokenNames.join(", ")}) for margins, padding, and gaps.`, tokenNames));
|
|
2678
2389
|
}
|
|
2679
|
-
// Do: use declared radius tokens
|
|
2680
2390
|
if (declaredRadiusTokens.length > 0) {
|
|
2681
2391
|
const tokenNames = declaredRadiusTokens.slice(0, 2).map((t) => t.name);
|
|
2682
2392
|
doLines.push(line(`Use declared radius tokens (e.g. ${tokenNames.join(", ")}) for corner rounding.`, tokenNames));
|
|
2683
2393
|
}
|
|
2684
|
-
// Do: use primary-role token for interactive elements
|
|
2685
2394
|
if (primaryRole) {
|
|
2686
2395
|
doLines.push(line(`Use the primary colour token (${primaryRole.token}) for primary interactive elements (buttons, CTAs).`, [primaryRole.role]));
|
|
2687
2396
|
}
|
|
2688
|
-
// Avoid: hex literals (grounded in declared color tokens)
|
|
2689
2397
|
if (declaredColorTokens.length > 0) {
|
|
2690
2398
|
const tokenNames = declaredColorTokens.slice(0, 3).map((t) => t.name);
|
|
2691
2399
|
avoidLines.push(line(`Do not introduce new hex or colour literals — use the ${declaredColorTokens.length} declared colour token(s) (e.g. ${tokenNames.join(", ")}).`, tokenNames));
|
|
2692
2400
|
}
|
|
2693
|
-
// Avoid: raw spacing literals (grounded in declared space tokens)
|
|
2694
2401
|
if (declaredSpaceTokens.length > 0) {
|
|
2695
2402
|
const tokenNames = declaredSpaceTokens.slice(0, 2).map((t) => t.name);
|
|
2696
2403
|
avoidLines.push(line(`Do not hardcode raw spacing values — use declared spacing tokens (e.g. ${tokenNames.join(", ")}).`, tokenNames));
|
|
2697
2404
|
}
|
|
2698
|
-
// Avoid: overriding the primary colour token on interactive elements
|
|
2699
2405
|
if (primaryRole) {
|
|
2700
2406
|
avoidLines.push(line(`Do not override the primary colour token (${primaryRole.token}) with ad-hoc colours on interactive elements.`, [primaryRole.role]));
|
|
2701
2407
|
}
|
|
2702
|
-
// ── Breadth instruction (Fix 3 — anti-anchoring) ─────────────────────────────
|
|
2703
|
-
//
|
|
2704
|
-
// The roles and hints above are a starting lens — a minimal named anchor set.
|
|
2705
|
-
// Without an explicit instruction, agents anchor on the named tokens and stop
|
|
2706
|
-
// reading the full token file. This breadth line counters that by directing the
|
|
2707
|
-
// agent to the FULL declared token set, grounded in one representative token
|
|
2708
|
-
// per category so the grounding itself spans the system's breadth.
|
|
2709
|
-
//
|
|
2710
|
-
// Grounding rule: up to one representative declared token per category present,
|
|
2711
|
-
// using the representativeToken() selector (Fix 2). Only emitted when ≥1
|
|
2712
|
-
// declared token exists — weak/empty contracts do NOT get an invented breadth line.
|
|
2713
2408
|
const ALL_TOKEN_CATEGORIES = ["color", "space", "radius", "shadow", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "borderWidth", "breakpoint", "motion", "opacity", "zIndex"];
|
|
2714
|
-
// Collect one representative declared token per category that has any declared tokens.
|
|
2715
2409
|
const breadthGroundingTokens = [];
|
|
2716
2410
|
for (const cat of ALL_TOKEN_CATEGORIES) {
|
|
2717
2411
|
const rep = representativeToken(cat);
|
|
@@ -2720,11 +2414,9 @@ export function buildDesignBrief(contract) {
|
|
|
2720
2414
|
}
|
|
2721
2415
|
const totalDeclaredTokens = contract.tokens.filter((t) => t.provenance === "declared" && t.name !== null).length;
|
|
2722
2416
|
if (breadthGroundingTokens.length > 0) {
|
|
2723
|
-
// Build the grounding set (names of the representative tokens).
|
|
2724
2417
|
const breadthGrounding = breadthGroundingTokens.map((t) => t.name);
|
|
2725
2418
|
doLines.push(line(`The roles and hints above are a starting lens, not the full design system — reference the FULL declared token set (${totalDeclaredTokens} declared tokens) and prefer an existing token over any new value.`, breadthGrounding));
|
|
2726
2419
|
}
|
|
2727
|
-
// ── Review notes ─────────────────────────────────────────────────────────────
|
|
2728
2420
|
const reviewNotes = [];
|
|
2729
2421
|
if (strength === "weak") {
|
|
2730
2422
|
reviewNotes.push("Contract is weak — very few named tokens were found. Guidance above is minimal. Run `vise design extract --from-project` to derive a richer contract from the host project's design system, or provide a prototype.");
|
|
@@ -2732,8 +2424,6 @@ export function buildDesignBrief(contract) {
|
|
|
2732
2424
|
if (roles.length === 0) {
|
|
2733
2425
|
reviewNotes.push("No colour roles could be inferred from token names. Role-based guidance is unavailable. Ensure tokens use recognisable names (e.g. --color-primary, --color-surface) and run `vise design extract --from-project` again.");
|
|
2734
2426
|
}
|
|
2735
|
-
// Suggest missing roles using name examples, not camelCase role identifiers
|
|
2736
|
-
// (camelCase role names must not appear in prose to keep the weak/neutral brief JSON clean).
|
|
2737
2427
|
if (!roleNames.has("primaryAction") && contract.stats.declared_tokens > 0) {
|
|
2738
2428
|
reviewNotes.push("No primary action colour found — consider naming a token --color-primary (or --color-brand / --color-accent) for primary interactive elements.");
|
|
2739
2429
|
}
|
|
@@ -2743,19 +2433,12 @@ export function buildDesignBrief(contract) {
|
|
|
2743
2433
|
if (!roleNames.has("border") && contract.stats.declared_tokens > 0) {
|
|
2744
2434
|
reviewNotes.push("No border colour found — consider naming a token --color-border or --color-outline.");
|
|
2745
2435
|
}
|
|
2746
|
-
// Conservative-inference reviewNote (Fix 3):
|
|
2747
|
-
// When roles bind fewer than half the declared COLOR tokens, the role inference
|
|
2748
|
-
// was conservative on this vocabulary (e.g. Encore's essential-/decorative-
|
|
2749
|
-
// prefixed names are correct restraint but result in thin role coverage).
|
|
2750
|
-
// Alert the agent that it MUST read the full token file rather than relying on roles alone.
|
|
2751
2436
|
const declaredColorCount = declaredColorTokens.length;
|
|
2752
2437
|
const colorTokensBoundToRoles = roles.filter((r) => declaredColorTokens.some((t) => t.name === r.token)).length;
|
|
2753
2438
|
if (declaredColorCount > 0 && colorTokensBoundToRoles < declaredColorCount / 2) {
|
|
2754
2439
|
reviewNotes.push(`Role inference was conservative on this vocabulary — only ${colorTokensBoundToRoles} of ${declaredColorCount} declared colour token(s) are bound to roles. The full token file must be read to discover the complete colour system.`);
|
|
2755
2440
|
}
|
|
2756
|
-
// ── Summary ──────────────────────────────────────────────────────────────────
|
|
2757
2441
|
const tokenCount = contract.tokens.filter((t) => t.name !== null).length;
|
|
2758
|
-
// Count how many distinct declared token names are referenced by roles + hints.
|
|
2759
2442
|
const briefReferencedTokenNames = new Set();
|
|
2760
2443
|
for (const r of roles)
|
|
2761
2444
|
briefReferencedTokenNames.add(r.token);
|
|
@@ -2763,7 +2446,6 @@ export function buildDesignBrief(contract) {
|
|
|
2763
2446
|
if (!("absent" in h)) {
|
|
2764
2447
|
for (const l of h.guidance) {
|
|
2765
2448
|
for (const g of l.groundedIn) {
|
|
2766
|
-
// Only add entries that are actual declared token names (not role names).
|
|
2767
2449
|
if (!roleNameStrings.has(g))
|
|
2768
2450
|
briefReferencedTokenNames.add(g);
|
|
2769
2451
|
}
|
|
@@ -2786,26 +2468,14 @@ export function buildDesignBrief(contract) {
|
|
|
2786
2468
|
reviewNotes,
|
|
2787
2469
|
};
|
|
2788
2470
|
}
|
|
2789
|
-
/**
|
|
2790
|
-
* Build outcome-specific design recipe items grounded in an existing brief.
|
|
2791
|
-
*
|
|
2792
|
-
* HARD INVARIANT: every item is grounded ONLY in roles/tokens already in the brief.
|
|
2793
|
-
* Items for absent roles are silently omitted. Returns `undefined` when zero items
|
|
2794
|
-
* can be grounded (e.g. empty brief).
|
|
2795
|
-
*
|
|
2796
|
-
* Pure — no I/O, no side effects. Generated at plan time; never persisted.
|
|
2797
|
-
*/
|
|
2798
2471
|
export function buildOutcomeDesignRecipe(brief, outcome) {
|
|
2799
2472
|
const roleMap = new Map(brief.roles.map((r) => [r.role, r]));
|
|
2800
|
-
// Collect groundedIn entries from a given component hint (absent hints contribute nothing).
|
|
2801
2473
|
const hintGrounding = (kind) => {
|
|
2802
2474
|
const hint = brief.componentHints.find((h) => h.kind === kind);
|
|
2803
2475
|
if (!hint || "absent" in hint)
|
|
2804
2476
|
return [];
|
|
2805
2477
|
return hint.guidance.flatMap((l) => l.groundedIn);
|
|
2806
2478
|
};
|
|
2807
|
-
// Collect radius-specific grounding from the card hint by looking for guidance
|
|
2808
|
-
// lines that mention "corner radius" — avoids mis-citing a space token as radius.
|
|
2809
2479
|
const cardRadiusGrounding = () => {
|
|
2810
2480
|
const hint = brief.componentHints.find((h) => h.kind === "card");
|
|
2811
2481
|
if (!hint || "absent" in hint)
|
|
@@ -2816,30 +2486,24 @@ export function buildOutcomeDesignRecipe(brief, outcome) {
|
|
|
2816
2486
|
};
|
|
2817
2487
|
const items = [];
|
|
2818
2488
|
if (outcome === "add-feed") {
|
|
2819
|
-
// Composer / action button — only when primaryAction exists.
|
|
2820
2489
|
const primaryAction = roleMap.get("primaryAction");
|
|
2821
2490
|
if (primaryAction) {
|
|
2822
2491
|
items.push(line(`The post composer action button uses the primary action colour token (${primaryAction.token}).`, ["primaryAction"]));
|
|
2823
2492
|
}
|
|
2824
|
-
// Post cards — only when the card hint has grounding tokens.
|
|
2825
2493
|
const cardGrounding = hintGrounding("card");
|
|
2826
2494
|
if (cardGrounding.length > 0) {
|
|
2827
2495
|
items.push(line("Post cards follow the card component hint: apply the card hint tokens for corner radius, padding, and border/shadow.", cardGrounding));
|
|
2828
2496
|
}
|
|
2829
|
-
// Post metadata and timestamps.
|
|
2830
2497
|
const textSecondary = roleMap.get("textSecondary");
|
|
2831
2498
|
if (textSecondary) {
|
|
2832
2499
|
items.push(line(`Post metadata and timestamps use the secondary text colour token (${textSecondary.token}).`, ["textSecondary"]));
|
|
2833
2500
|
}
|
|
2834
|
-
// Report / delete affordances.
|
|
2835
2501
|
const danger = roleMap.get("danger");
|
|
2836
2502
|
if (danger) {
|
|
2837
2503
|
items.push(line(`Report and delete affordances use the danger colour token (${danger.token}).`, ["danger"]));
|
|
2838
2504
|
}
|
|
2839
2505
|
}
|
|
2840
2506
|
else {
|
|
2841
|
-
// add-chat
|
|
2842
|
-
// Message bubbles use surface.
|
|
2843
2507
|
const surface = roleMap.get("surface");
|
|
2844
2508
|
if (surface) {
|
|
2845
2509
|
const radiusGrounding = cardRadiusGrounding();
|
|
@@ -2850,33 +2514,23 @@ export function buildOutcomeDesignRecipe(brief, outcome) {
|
|
|
2850
2514
|
items.push(line(`Message bubbles use the surface colour token (${surface.token}).`, ["surface"]));
|
|
2851
2515
|
}
|
|
2852
2516
|
}
|
|
2853
|
-
// Own-message vs other-message contrast — ONLY when BOTH primaryAction AND surface exist.
|
|
2854
2517
|
const primaryAction = roleMap.get("primaryAction");
|
|
2855
2518
|
if (primaryAction && surface) {
|
|
2856
2519
|
items.push(line(`Own messages use the primary action colour (${primaryAction.token}) as background; other messages use the surface colour (${surface.token}).`, ["primaryAction", "surface"]));
|
|
2857
2520
|
}
|
|
2858
|
-
// Timestamps.
|
|
2859
2521
|
const textSecondary = roleMap.get("textSecondary");
|
|
2860
2522
|
if (textSecondary) {
|
|
2861
2523
|
items.push(line(`Message timestamps use the secondary text colour token (${textSecondary.token}).`, ["textSecondary"]));
|
|
2862
2524
|
}
|
|
2863
|
-
// Composer follows the input hint tokens.
|
|
2864
2525
|
const inputGrounding = hintGrounding("input");
|
|
2865
2526
|
if (inputGrounding.length > 0) {
|
|
2866
2527
|
items.push(line("The message composer follows the input component hint: apply the input hint tokens for border colour, corner radius, and padding.", inputGrounding));
|
|
2867
2528
|
}
|
|
2868
|
-
// Moderation actions.
|
|
2869
2529
|
const danger = roleMap.get("danger");
|
|
2870
2530
|
if (danger) {
|
|
2871
2531
|
items.push(line(`Moderation actions (report, block, mute) use the danger colour token (${danger.token}).`, ["danger"]));
|
|
2872
2532
|
}
|
|
2873
2533
|
}
|
|
2874
|
-
// Breadth audit item (Fix 3 — anti-anchoring):
|
|
2875
|
-
// Append a final item instructing the agent to audit against the FULL token set.
|
|
2876
|
-
// Grounded the same way as the brief's breadth do-line: reuse the groundedIn of
|
|
2877
|
-
// the "starting lens" do-line if it exists in the brief (spans the breadth of
|
|
2878
|
-
// the system's categories). Only emitted when the brief has declared tokens
|
|
2879
|
-
// (the breadth do-line only exists when there are declared tokens).
|
|
2880
2534
|
const breadthDoLine = brief.do.find((l) => l.text.includes("starting lens"));
|
|
2881
2535
|
if (breadthDoLine) {
|
|
2882
2536
|
items.push(line("Before finishing, audit the UI against the full declared token set and replace any near-miss values with the matching token.", breadthDoLine.groundedIn));
|