figma-console-mcp 1.37.1 β 1.38.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/README.md +6 -3
- package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +140 -15
- package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +100 -40
- package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +26 -1
- package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +64 -1
- package/dist/cloudflare/core/design-code-tools.js +179 -7
- package/dist/cloudflare/core/history/component-history.js +388 -0
- package/dist/cloudflare/core/history/git-history.js +161 -0
- package/dist/cloudflare/core/history/history-formatter.js +116 -0
- package/dist/cloudflare/core/library-tools.js +56 -7
- package/dist/cloudflare/core/port-discovery.js +101 -20
- package/dist/cloudflare/core/tokens-tools.js +1 -1
- package/dist/cloudflare/index.js +3 -3
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +179 -7
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/history/component-history.d.ts +73 -0
- package/dist/core/history/component-history.d.ts.map +1 -0
- package/dist/core/history/component-history.js +389 -0
- package/dist/core/history/component-history.js.map +1 -0
- package/dist/core/history/git-history.d.ts +56 -0
- package/dist/core/history/git-history.d.ts.map +1 -0
- package/dist/core/history/git-history.js +162 -0
- package/dist/core/history/git-history.js.map +1 -0
- package/dist/core/history/history-formatter.d.ts +30 -0
- package/dist/core/history/history-formatter.d.ts.map +1 -0
- package/dist/core/history/history-formatter.js +117 -0
- package/dist/core/history/history-formatter.js.map +1 -0
- package/dist/core/library-tools.d.ts.map +1 -1
- package/dist/core/library-tools.js +56 -7
- package/dist/core/library-tools.js.map +1 -1
- package/dist/core/port-discovery.d.ts.map +1 -1
- package/dist/core/port-discovery.js +101 -20
- package/dist/core/port-discovery.js.map +1 -1
- package/dist/core/tokens-tools.js +1 -1
- package/dist/core/types/design-code.d.ts +21 -0
- package/dist/core/types/design-code.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
> **Your design system as an API.** Model Context Protocol server that bridges design and developmentβgiving AI assistants complete access to Figma for **extraction**, **creation**, **debugging**, and **bidirectional token sync**.
|
|
10
10
|
|
|
11
|
-
>
|
|
11
|
+
> **π Connection Stability Fix (v1.38.2):** If your MCP client kept showing **"Server disconnected"**, this release fixes it. The server's own orphan reaper was terminating *healthy* servers: its `/health` liveness probe requested `127.0.0.1` while the server binds `localhost`, which resolves to the **IPv6 loopback** on dual-stack macOS β so the probe returned "nothing responding" for every healthy instance and inverted every kill-safety gate into a rubber stamp. Also fixes non-atomic port-file writes, deletion of files on a transient read error, and an orphan path that killed without probing. Affected all MCP clients equally (server-side, unrelated to the Desktop Bridge plugin). **Fully restart your MCP clients after upgrading** β a server still running an older build keeps the broken probe and will terminate healthy siblings. Server-only β **no plugin re-import needed**. [See what's new β](CHANGELOG.md#1382---2026-07-29)
|
|
12
12
|
|
|
13
13
|
## What is this?
|
|
14
14
|
|
|
@@ -19,7 +19,7 @@ Figma Console MCP connects AI assistants (like Claude) to Figma, enabling:
|
|
|
19
19
|
- **πΈ Visual debugging** - Take screenshots for context
|
|
20
20
|
- **βοΈ Design creation** - Create UI components, frames, and layouts directly in Figma β including one-call component-set creation from a variant axes matrix
|
|
21
21
|
- **π§ Variable management** - Create, update, rename, and delete design tokens
|
|
22
|
-
- **π° Version history & time-series awareness** - List versions, diff snapshots, generate markdown changelogs, trace property/variant introduction via binary-search blame
|
|
22
|
+
- **π° Version history & time-series awareness** - List versions, diff snapshots, generate markdown changelogs, trace property/variant introduction via binary-search blame, and fold per-component design + git history into generated component docs
|
|
23
23
|
- **β‘ Real-time monitoring** - Watch console logs from the Desktop Bridge plugin
|
|
24
24
|
- **π FigJam boards** - Create stickies, flowcharts, tables, and code blocks on collaborative boards
|
|
25
25
|
- **ποΈ Slides presentations** - Build and manage Figma Slides decks programmatically
|
|
@@ -823,9 +823,12 @@ The architecture supports adding new apps with minimal boilerplate β each app
|
|
|
823
823
|
|
|
824
824
|
## π€οΈ Roadmap
|
|
825
825
|
|
|
826
|
-
**Current Status:** v1.
|
|
826
|
+
**Current Status:** v1.38.2 (Stable) - Production-ready. Latest: a connection-stability fix β the server's own orphan reaper was terminating healthy MCP servers because its liveness probe used an IPv4 literal while the server binds the IPv6 loopback, which turned every kill-safety gate into a rubber stamp; this was the cause of recurring "Server disconnected" errors across all MCP clients. Alongside v1.38.1's fix for the shared-library variable tools, which had silently reported zero collections since v1.29.0 and reported failed imports as successes. On top of v1.38.0's ongoing component changelogs in generated docs β `figma_generate_component_doc` takes an opt-in `history` parameter that pulls per-component **design history** from Figma version history (each version diffed and scoped to that component, so renames, added properties, and token bindings show up as rows) alongside **code history** from `git log` on the component's source files, rendered as a `## History` section with design, code, and release-note tables. Prefers labeled versions, falls back to auto-saves when a file has none, and is off by default so existing calls are unchanged (server-only, no plugin re-import). On top of v1.37.x's design-system health audit for every client β `figma_audit_design_system_report` runs the dashboard's deterministic six-category scoring engine and returns the report as data with per-finding remediation, live-first fileKey-verified data (source disclosed), chunked per-category drill-down, and a 5-minute cache (server-only, no plugin re-import). On top of v1.36.0's target lock for multi-file parallel work β β `figma_navigate` takes a `lock: true` flag that pins the active file so an AI agent can work in one file while you work in another without commands routing to the wrong file. On top of v1.35.0's Figma Slots write support β create, inspect, populate, and reset Slots (GA at Config 2026) via 5 new tools (`figma_create_slot`, `figma_get_slots`, `figma_append_to_slot`, `figma_reset_slot`, `figma_add_slot_property`), live-validated against the GA Plugin API and based on community PR #77. On top of v1.34.0's Bidirectional Token Sync v2 + DTCG 2025.10 β `figma_import_tokens` applies the complete diff plan (creates missing collections/variables, applies renames, writes real `VARIABLE_ALIAS` references, and deletes only under explicit `replace`), `figma_export_tokens` speaks the DTCG 2025.10 dialect on request (legacy default byte-identical), variable scopes/codeSyntax round-trip via `$extensions`, `figma_setup_design_tokens` accepts alias values via DTCG brace references, and `figma_create_component_set` builds a full variant set from an axes matrix in one call. On top of the v1.33.x line: version-handshake fix (re-import banner only fires when plugin files actually changed), security dependency sweep, and the v1.33.0 connection UX overhaul (honest status pill derived from live connection state, `/health` auto-discovery with self-healing reconnect) + a 33-fix full-codebase audit (lossless DTCG multi-mode round-trips, cross-collection alias resolution, branch-URL correctness across REST tools, cache-poisoning and CSWSH fixes, bridge-first screenshots). Built on WCAG-accurate accessibility auditing (line height below 1.5Γ is no longer mis-flagged as a failure; readability hints decoupled from conformance checks and scoped to multi-line text; code-side WCAG 1.4.12 check), a self-healing Desktop Bridge connection (zombie-process reaper + auto-reconnect watchdog β fixes the recurring "not connected until restart" bug), native variable binding on fills/strokes + typography control in the write tools, shared-library inspection (key-based component resolution + library variable read/import without Enterprise plan), 10-format token export pipeline (DTCG, CSS, Tailwind v4, Tailwind v3, SCSS, TS module, JSON flat/nested, Style Dictionary v3, Tokens Studio), bidirectional Figmaβcode token sync, version history & time-series awareness, FigJam + Slides support, Cloud Write Relay, Design System Kit, WebSocket-only connectivity, smart multi-file tracking, **113 tools** (Local) / **101 tools** (Cloud) / **9 tools** (Remote read-only), Comments API, cross-MCP identity disambiguation, and MCP Apps.
|
|
827
827
|
|
|
828
828
|
**Recent Releases:**
|
|
829
|
+
- [x] **v1.38.2** - Connection stability: the orphan reaper was terminating healthy MCP servers, the cause of recurring "Server disconnected" errors across every MCP client (reproduced in both Claude Code and Claude Desktop; unrelated to the Desktop Bridge plugin). The reaper probes a sibling's `/health` before deciding it is dead, but requested `127.0.0.1` while the WebSocket server binds `localhost` β resolved to the IPv6 loopback on dual-stack macOS, with nothing on IPv4 β so the probe reported "nothing responding" for healthy servers and every kill-safety gate built on it became a rubber stamp, including the one written to spare siblings after the machine sleeps. Three supporting defects: port files written non-atomically (a reader landing mid-rewrite got a parse error that both cleanup paths treated as "corrupt, delete it", stranding a healthy server), an orphan path that terminated fileless port-holders without probing at all, and a heartbeat that gave up permanently once its own file went missing. Fixes: probe `localhost`, atomic temp+rename writes, never delete on a parse failure, health-probe before the orphan kill in both paths, and self-healing re-advertisement guarded by in-process port ownership. **Fully restart MCP clients after upgrading** β older running builds keep the broken probe and still terminate healthy siblings. Server-only, no plugin re-import. 1422 tests (7 new regressions, each verified to fail pre-fix).
|
|
830
|
+
- [x] **v1.38.1** - Shared-library variable tools fixed. `figma_get_library_variables` returned `totalCollections: 0` for every file, on every plan, since it shipped in v1.29.0, and `figma_import_library_variable` reported failed imports as successes with `id: undefined`. Both read the Desktop Bridge's `{ success, result }` envelope as if it were the injected script's bare return value, so `Array.isArray()` was always false, the `__error` guard was dead code, and `.id` was always undefined. The failure presented as a *success* with a plausible hint attached ("subscribe a library via the Assets panel"), which is why it survived two months unreported. Both tools now unwrap through a shared helper that also maps a bridge-level `success: false` onto the error path, so a plugin timeout surfaces as a real error instead of being swallowed as "0 collections"; an import yielding no `id` is now an explicit error. All 26 `executeCodeViaUI` call sites audited β only these two were affected. Test mocks had encoded a wire contract that does not exist, which is why CI stayed green; they now reproduce the real envelope, plus six regression tests verified to fail against the pre-fix source. Reported by Isabella Minzly. Server-only β no plugin re-import needed.
|
|
831
|
+
- [x] **v1.38.0** - Ongoing component changelogs in generated docs. `figma_generate_component_doc` gained an opt-in `history` parameter that produces a real changelog instead of only echoing hand-written `codeInfo.changelog` rows: `history.figma` walks Figma version history and diffs each consecutive pair scoped to the component (reusing the same engine as `figma_diff_versions`, so a rename, a newly added component property, or a token binding each become a row), while `history.git` runs `git log` over the component's source files auto-derived from `codeInfo.filePath` / `sourceFiles[]`. Labeled versions are preferred but auto-saves are used automatically when a file has none β verified against a mature system with 72 auto-saves and 0 labeled versions, where a labeled-only walk yields nothing. Detailed-mode bindings group by property (binding one token across a 24-variant set otherwise produced 44 near-identical bullets in a single table cell). Both sources default off, so existing callers get byte-identical output. No new tools; server-only, no plugin re-import.
|
|
829
832
|
- [x] **v1.37.0** - Design-system health audit for every MCP client. New `figma_audit_design_system_report` runs the Design System Dashboard's deterministic scoring engine (naming, token architecture, component metadata, accessibility, consistency, coverage) and returns the scored report as data β no MCP Apps support or `ENABLE_MCP_APPS` needed (the previous registration gated everything, making the audit unreachable outside Claude Desktop). Component data is now live-first: a fileKey-verified per-page bridge crawl (30s/page, failures isolated per page) with REST published-library fallback, and the chosen source is disclosed in the report (`bridge-live` / `rest-published` / `none`) so stale-publish scores can't masquerade as live ones. Output is token-safe by design: bounded summary, per-category chunked drill-down, clamped full JSON, 5-minute raw-data cache. Every finding carries a remediation verdict β auto-fixable by this MCP's write tools, fixable after a design decision, or manual β with the exact tools named. Scoring accuracy pass: variant components no longer poison PascalCase/casing checks, Title Case accepted, component-set names count toward core-component coverage, `color/content/*` recognized in contrast pairing, state synonyms (`active`β`pressed` etc.), and `.`/`_`-prefixed internals excluded. Server-only β no plugin re-import needed.
|
|
830
833
|
- [x] **v1.36.0** - Target lock for multi-file parallel work. `figma_navigate` gains a `lock: true` flag that pins the active file β new connections, reconnects, and the user's own selection/page changes in other files no longer move the command target, so an agent can safely write to one file while the user works in another. Auto-releases when the pinned file disconnects or navigates away; `figma_list_open_files` reports a `targetLocked` flag for pre-write guards. Server-only β no plugin re-import needed.
|
|
831
834
|
- [x] **v1.35.0** - Figma Slots write support, closing [#29](https://github.com/southleft/figma-console-mcp/issues/29) (the tracker's most-requested feature) via community PR [#77](https://github.com/southleft/figma-console-mcp/pull/77) by @simonesalvucci, updated to the GA API. Five new tools: `figma_create_slot` (slot + auto-linked SLOT property; variants inside component sets supported β the beta restriction was lifted at GA), `figma_get_slots`, `figma_append_to_slot` (clone or create content into instance slots; snaps clones to the slot origin), `figma_reset_slot`, and `figma_add_slot_property` (retrofit an existing frame as a slot). Hardened by live validation: VARIANT `defaultValue` passthrough restored (Figma requires non-empty), destructive-path reorder in the append handler (content validated before `clearExisting` empties anything), relay whitelist fix for `slot` payloads. **Plugin re-import required** (`code.js` + `ui.html` changed).
|
|
@@ -65,28 +65,59 @@ function isDirectColor(value) {
|
|
|
65
65
|
v.type !== "VARIABLE_ALIAS");
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
* Extract resolved color values from variables.
|
|
69
|
-
*
|
|
68
|
+
* Extract resolved color values from variables, following alias chains.
|
|
69
|
+
* Semantic tokens are usually ALIASES of primitives β skipping them (the old
|
|
70
|
+
* behavior) dropped exactly the tokens whose names drive fg/bg pairing, so
|
|
71
|
+
* alias-heavy systems fell back to noisy scale-vs-scale pairing.
|
|
72
|
+
* Returns an array of { name, r, g, b } objects.
|
|
70
73
|
*/
|
|
71
74
|
function extractColorValues(variables) {
|
|
72
|
-
const
|
|
75
|
+
const byId = new Map();
|
|
73
76
|
for (const variable of variables) {
|
|
74
|
-
if (variable.
|
|
75
|
-
|
|
77
|
+
if (variable.id)
|
|
78
|
+
byId.set(variable.id, variable);
|
|
79
|
+
}
|
|
80
|
+
const resolve = (variable, depth = 0) => {
|
|
81
|
+
if (!variable?.valuesByMode || depth > 8)
|
|
82
|
+
return null;
|
|
76
83
|
for (const value of Object.values(variable.valuesByMode)) {
|
|
77
|
-
if (isDirectColor(value))
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
if (isDirectColor(value))
|
|
85
|
+
return value;
|
|
86
|
+
const v = value;
|
|
87
|
+
if (v?.type === "VARIABLE_ALIAS" && typeof v.id === "string") {
|
|
88
|
+
const target = byId.get(v.id);
|
|
89
|
+
const resolved = target ? resolve(target, depth + 1) : null;
|
|
90
|
+
if (resolved)
|
|
91
|
+
return resolved;
|
|
85
92
|
}
|
|
86
93
|
}
|
|
94
|
+
return null;
|
|
95
|
+
};
|
|
96
|
+
const colors = [];
|
|
97
|
+
for (const variable of variables) {
|
|
98
|
+
if (variable.resolvedType !== "COLOR")
|
|
99
|
+
continue;
|
|
100
|
+
const value = resolve(variable);
|
|
101
|
+
// Skip translucent colors: they composite over whatever is beneath
|
|
102
|
+
// them, so a standalone contrast ratio is meaningless (a 5%-alpha
|
|
103
|
+
// "background/subtle" wash is not a 1.0:1 background).
|
|
104
|
+
if (value && (value.a === undefined || value.a >= 0.99)) {
|
|
105
|
+
colors.push({ name: variable.name, r: value.r, g: value.g, b: value.b });
|
|
106
|
+
}
|
|
87
107
|
}
|
|
88
108
|
return colors;
|
|
89
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Component-scoped tokens (comp/button/..., component/card/...) only ever
|
|
112
|
+
* co-occur with tokens of the SAME component scope β pairing a button label
|
|
113
|
+
* against a card background measures a combination that never renders.
|
|
114
|
+
* Returns the scope prefix for component-scoped names, or null for global
|
|
115
|
+
* semantic tokens (which may pair freely).
|
|
116
|
+
*/
|
|
117
|
+
function componentScope(name) {
|
|
118
|
+
const m = name.match(/^(comp|component|components)\/([^/]+)/i);
|
|
119
|
+
return m ? `${m[1].toLowerCase()}/${m[2].toLowerCase()}` : null;
|
|
120
|
+
}
|
|
90
121
|
/**
|
|
91
122
|
* Identify likely foreground/background color pairs and check contrast.
|
|
92
123
|
*
|
|
@@ -115,13 +146,107 @@ function scoreColorContrast(data) {
|
|
|
115
146
|
// "annotation-gray" don't false-match.
|
|
116
147
|
const fgPattern = /text|foreground|fg|content|(?:^|\/)on-|on\.|label|title|body|heading/i;
|
|
117
148
|
const backgrounds = colors.filter((c) => bgPattern.test(c.name));
|
|
118
|
-
|
|
149
|
+
// A token whose name also matches the background pattern is a surface, not
|
|
150
|
+
// a foreground β "body/background" must not be treated as body text.
|
|
151
|
+
const foregrounds = colors.filter((c) => fgPattern.test(c.name) && !bgPattern.test(c.name));
|
|
119
152
|
// If naming conventions are not used, use luminance-based heuristic
|
|
120
153
|
let pairs = [];
|
|
121
154
|
if (backgrounds.length > 0 && foregrounds.length > 0) {
|
|
122
|
-
// Pair foregrounds with backgrounds
|
|
155
|
+
// Pair foregrounds with backgrounds where the token names DECLARE the
|
|
156
|
+
// relationship, instead of a full cross-product (which measures
|
|
157
|
+
// combinations that never render β a primary button label on a
|
|
158
|
+
// knockout background, default text on an inverted surface):
|
|
159
|
+
// 1. inverse-content tokens pair with inverted backgrounds only;
|
|
160
|
+
// 2. utility-family content (danger/warning/β¦) pairs with the same
|
|
161
|
+
// utility family's backgrounds;
|
|
162
|
+
// 3. other GLOBAL content tokens pair with the default background
|
|
163
|
+
// (the canvas every non-inverse text token must survive on);
|
|
164
|
+
// 4. component-scoped tokens pair by path mirror within the same
|
|
165
|
+
// component and variant family (content β background).
|
|
166
|
+
// Interaction/emphasis modifiers (-hover, -active, -strong, β¦) are
|
|
167
|
+
// stripped when matching so state variants inherit their base pair.
|
|
168
|
+
const stripModifiers = (leaf) => leaf.replace(/-(hover|active|pressed|focus|strong|subtle|weak)$/i, "");
|
|
169
|
+
const leafOf = (name) => stripModifiers(name.split("/").pop()?.toLowerCase() ?? "");
|
|
170
|
+
const utilityFamily = (name) => name.match(/utility\/([a-z]+)/i)?.[1]?.toLowerCase() ?? null;
|
|
171
|
+
// Family of a background token: first path segment after "background"
|
|
172
|
+
// when one exists ("background/danger/weak" β danger), else null
|
|
173
|
+
// (canvas-level backgrounds like "background/default").
|
|
174
|
+
const bgFamilyOf = (name) => {
|
|
175
|
+
const segs = name.toLowerCase().split("/");
|
|
176
|
+
const i = segs.findIndex((s) => /^(background|bg|surface)$/.test(s));
|
|
177
|
+
if (i < 0)
|
|
178
|
+
return null;
|
|
179
|
+
const rest = segs.slice(i + 1).filter((s) => s !== "utility");
|
|
180
|
+
return rest.length >= 2 ? rest[0] : null;
|
|
181
|
+
};
|
|
182
|
+
// Is a background the canvas (the default app surface)?
|
|
183
|
+
const bgIsCanvas = (name) => {
|
|
184
|
+
const segs = name.toLowerCase().split("/");
|
|
185
|
+
const i = segs.findIndex((s) => /^(background|bg|surface)$/.test(s));
|
|
186
|
+
if (i < 0)
|
|
187
|
+
return false;
|
|
188
|
+
const rest = segs.slice(i + 1);
|
|
189
|
+
return rest.length === 1 && stripModifiers(rest[0]) === "default";
|
|
190
|
+
};
|
|
191
|
+
// On-surface declaration for a content token: either an explicit
|
|
192
|
+
// "/on/<family>" path segment (content/on/primary) or an "on-<family>"
|
|
193
|
+
// / "<family>-strong" leaf β both dialects declare "this text sits ON
|
|
194
|
+
// that family's surface". Returns the target family, or null.
|
|
195
|
+
const onSurfaceFamilyOf = (name) => {
|
|
196
|
+
const lower = name.toLowerCase();
|
|
197
|
+
const pathMatch = lower.match(/\/on\/([a-z][a-z0-9-]*)/);
|
|
198
|
+
if (pathMatch)
|
|
199
|
+
return stripModifiers(pathMatch[1]);
|
|
200
|
+
const leaf = lower.split("/").pop() ?? "";
|
|
201
|
+
const onLeaf = leaf.match(/^on-([a-z0-9-]+)$/);
|
|
202
|
+
if (onLeaf)
|
|
203
|
+
return stripModifiers(onLeaf[1]);
|
|
204
|
+
const strongLeaf = leaf.match(/^([a-z0-9]+)-strong$/);
|
|
205
|
+
if (strongLeaf)
|
|
206
|
+
return strongLeaf[1];
|
|
207
|
+
return null;
|
|
208
|
+
};
|
|
123
209
|
for (const fg of foregrounds) {
|
|
210
|
+
const fgScope = componentScope(fg.name);
|
|
211
|
+
const fgOnFamily = onSurfaceFamilyOf(fg.name);
|
|
212
|
+
const fgIsInverse = fgOnFamily === "inverse" || /inverse/i.test(leafOf(fg.name));
|
|
124
213
|
for (const bg of backgrounds) {
|
|
214
|
+
const bgScope = componentScope(bg.name);
|
|
215
|
+
if (fgScope !== bgScope)
|
|
216
|
+
continue;
|
|
217
|
+
const bgIsInverted = /invert|inverse/i.test(leafOf(bg.name)) ||
|
|
218
|
+
bgFamilyOf(bg.name) === "inverse";
|
|
219
|
+
if (fgScope !== null) {
|
|
220
|
+
// Component scope: mirror the path with contentβbackground
|
|
221
|
+
// swapped and modifiers stripped.
|
|
222
|
+
const mirror = fg.name
|
|
223
|
+
.toLowerCase()
|
|
224
|
+
.replace(/(^|\/)(content|text|foreground|fg)(\/|$)/, "$1background$3");
|
|
225
|
+
const bgNorm = bg.name.toLowerCase();
|
|
226
|
+
const strip = (s) => s
|
|
227
|
+
.split("/")
|
|
228
|
+
.map((seg, i, arr) => (i === arr.length - 1 ? stripModifiers(seg) : seg))
|
|
229
|
+
.join("/");
|
|
230
|
+
if (strip(bgNorm) !== strip(mirror))
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
else if (fgIsInverse || bgIsInverted) {
|
|
234
|
+
// inverse content β inverted/inverse surfaces only
|
|
235
|
+
if (!(fgIsInverse && bgIsInverted))
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
else if (fgOnFamily) {
|
|
239
|
+
// declared on-surface text β its family's surface only
|
|
240
|
+
if (bgFamilyOf(bg.name) !== fgOnFamily)
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
// everything else (plain content, family-COLORED canvas text
|
|
245
|
+
// like content/brand or content/danger/default) must survive
|
|
246
|
+
// on the canvas β and only the canvas is a declared pair.
|
|
247
|
+
if (!bgIsCanvas(bg.name))
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
125
250
|
pairs.push({ fg, bg });
|
|
126
251
|
}
|
|
127
252
|
}
|
|
@@ -134,7 +134,9 @@ function scoreCasingConsistency(data) {
|
|
|
134
134
|
const segments = comp.name.split("/").map((s) => s.trim());
|
|
135
135
|
componentSegments.push(...segments);
|
|
136
136
|
}
|
|
137
|
-
// Check variable name
|
|
137
|
+
// Check variable name segments. Pure-numeric segments (scale steps like
|
|
138
|
+
// "100", "3.5", "12") carry no casing signal and are skipped.
|
|
139
|
+
const NUMERIC_SEGMENT_RE = /^\d+([.Β·β€β€]?\d+)?$/;
|
|
138
140
|
const variableLeaves = [];
|
|
139
141
|
for (const v of data.variables) {
|
|
140
142
|
const segments = v.name.split(/[/.]/);
|
|
@@ -142,46 +144,90 @@ function scoreCasingConsistency(data) {
|
|
|
142
144
|
variableLeaves.push(...segments);
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
// Calibration: components and variables legitimately follow DIFFERENT
|
|
148
|
+
// conventions (TitleCase components + lowercase/kebab token paths is the
|
|
149
|
+
// dominant real-world pattern). Pooling them and demanding one dominant
|
|
150
|
+
// casing structurally fails every such system, so consistency is measured
|
|
151
|
+
// WITHIN each pool and combined as a segment-weighted average.
|
|
152
|
+
const pools = [
|
|
153
|
+
{
|
|
154
|
+
label: "component",
|
|
155
|
+
segments: componentSegments.filter((s) => s.length > 1 && !NUMERIC_SEGMENT_RE.test(s)),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
label: "variable",
|
|
159
|
+
segments: variableLeaves.filter((s) => s.length > 1 && !NUMERIC_SEGMENT_RE.test(s)),
|
|
160
|
+
},
|
|
161
|
+
].filter((p) => p.segments.length > 0);
|
|
162
|
+
if (pools.length === 0) {
|
|
147
163
|
return {
|
|
148
164
|
id: "consistency-casing",
|
|
149
165
|
label: "Casing consistency",
|
|
150
166
|
score: 100,
|
|
151
167
|
severity: "info",
|
|
152
|
-
tooltip: "Name segments should
|
|
168
|
+
tooltip: "Name segments should follow a consistent casing convention within components and within variables (the two may differ β e.g. TitleCase components with kebab-case token paths).",
|
|
153
169
|
details: "No name segments to evaluate.",
|
|
154
170
|
};
|
|
155
171
|
}
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
// A segment can be AMBIGUOUS between conventions: a single lowercase word
|
|
173
|
+
// ("default", "brand") is simultaneously valid camelCase, kebab-case, and
|
|
174
|
+
// snake_case β only multi-word segments reveal the convention. Score each
|
|
175
|
+
// segment against the set of conventions it is compatible with, and pick
|
|
176
|
+
// the convention that the most segments are compatible with.
|
|
177
|
+
const compatibleCasings = (segment) => {
|
|
178
|
+
const out = [];
|
|
179
|
+
if (PASCAL_CASE_RE.test(segment))
|
|
180
|
+
out.push("PascalCase");
|
|
181
|
+
if (CAMEL_CASE_RE.test(segment))
|
|
182
|
+
out.push("camelCase");
|
|
183
|
+
if (KEBAB_CASE_RE.test(segment))
|
|
184
|
+
out.push("kebab-case");
|
|
185
|
+
if (SNAKE_CASE_RE.test(segment))
|
|
186
|
+
out.push("snake_case");
|
|
187
|
+
if (out.length === 0)
|
|
188
|
+
out.push(detectCasing(segment));
|
|
189
|
+
return out;
|
|
190
|
+
};
|
|
191
|
+
let weightedConsistent = 0;
|
|
192
|
+
let totalSegments = 0;
|
|
193
|
+
const poolSummaries = [];
|
|
194
|
+
const nonDominantSegments = [];
|
|
195
|
+
for (const pool of pools) {
|
|
196
|
+
const casingCounts = new Map();
|
|
197
|
+
const compat = pool.segments.map((s) => compatibleCasings(s));
|
|
198
|
+
for (const casings of compat) {
|
|
199
|
+
for (const casing of casings) {
|
|
200
|
+
casingCounts.set(casing, (casingCounts.get(casing) ?? 0) + 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
let dominantCasing = "mixed";
|
|
204
|
+
let dominantCount = 0;
|
|
205
|
+
for (const [casing, count] of casingCounts.entries()) {
|
|
206
|
+
if (count > dominantCount) {
|
|
207
|
+
dominantCount = count;
|
|
208
|
+
dominantCasing = casing;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
weightedConsistent += dominantCount;
|
|
212
|
+
totalSegments += pool.segments.length;
|
|
213
|
+
poolSummaries.push(`${pool.label} names ${Math.round((dominantCount / pool.segments.length) * 100)}% ${dominantCasing}`);
|
|
214
|
+
for (let i = 0; i < pool.segments.length; i++) {
|
|
215
|
+
if (!compat[i].includes(dominantCasing) &&
|
|
216
|
+
nonDominantSegments.length < MAX_EXAMPLES) {
|
|
217
|
+
nonDominantSegments.push(`${pool.segments[i]} (${pool.label})`);
|
|
218
|
+
}
|
|
169
219
|
}
|
|
170
220
|
}
|
|
171
|
-
const ratio =
|
|
221
|
+
const ratio = weightedConsistent / totalSegments;
|
|
172
222
|
const score = clamp(ratio * 100);
|
|
173
|
-
// Find segments using non-dominant casing
|
|
174
|
-
const nonDominantSegments = allSegments.filter((seg) => detectCasing(seg) !== dominantCasing);
|
|
175
223
|
return {
|
|
176
224
|
id: "consistency-casing",
|
|
177
225
|
label: "Casing consistency",
|
|
178
226
|
score,
|
|
179
227
|
severity: getSeverity(score),
|
|
180
|
-
tooltip: "Name segments should
|
|
181
|
-
details: `${Math.round(ratio * 100)}% of name segments
|
|
182
|
-
examples: nonDominantSegments.length > 0
|
|
183
|
-
? nonDominantSegments.slice(0, MAX_EXAMPLES)
|
|
184
|
-
: undefined,
|
|
228
|
+
tooltip: "Name segments should follow a consistent casing convention within components and within variables (the two may differ β e.g. TitleCase components with kebab-case token paths).",
|
|
229
|
+
details: `${Math.round(ratio * 100)}% of name segments follow their pool's dominant casing (${poolSummaries.join("; ")}).`,
|
|
230
|
+
examples: nonDominantSegments.length > 0 ? nonDominantSegments : undefined,
|
|
185
231
|
};
|
|
186
232
|
}
|
|
187
233
|
/**
|
|
@@ -299,29 +345,43 @@ function scoreModeNamingConsistency(data) {
|
|
|
299
345
|
details: "Only one collection has modes; consistency is not applicable.",
|
|
300
346
|
};
|
|
301
347
|
}
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
348
|
+
// Calibration: collections legitimately serve DIFFERENT mode axes β a
|
|
349
|
+
// theme collection's Light/Dark, a density collection's Compact/
|
|
350
|
+
// Comfortable, a shape collection's Pill/Sharp. Demanding identical mode
|
|
351
|
+
// SETS across them penalizes correct architecture. What actually causes
|
|
352
|
+
// confusion is the same mode CONCEPT spelled differently across
|
|
353
|
+
// collections ("light" vs "Light" vs "light-mode"), so measure spelling
|
|
354
|
+
// consistency per mode concept instead.
|
|
355
|
+
const spellingsByConcept = new Map();
|
|
356
|
+
for (const collection of collections) {
|
|
357
|
+
for (const mode of collection.modes || []) {
|
|
358
|
+
const concept = mode.name.toLowerCase().replace(/[\s_-]+/g, "");
|
|
359
|
+
if (!spellingsByConcept.has(concept)) {
|
|
360
|
+
spellingsByConcept.set(concept, new Set());
|
|
361
|
+
}
|
|
362
|
+
spellingsByConcept.get(concept)?.add(mode.name);
|
|
312
363
|
}
|
|
313
364
|
}
|
|
314
|
-
const
|
|
365
|
+
const concepts = [...spellingsByConcept.entries()];
|
|
366
|
+
const inconsistent = concepts.filter(([, spellings]) => spellings.size > 1);
|
|
367
|
+
const ratio = concepts.length === 0
|
|
368
|
+
? 1
|
|
369
|
+
: (concepts.length - inconsistent.length) / concepts.length;
|
|
315
370
|
const score = clamp(ratio * 100);
|
|
316
371
|
return {
|
|
317
372
|
id: "consistency-mode-naming",
|
|
318
373
|
label: "Mode naming consistency",
|
|
319
374
|
score,
|
|
320
375
|
severity: getSeverity(score),
|
|
321
|
-
tooltip: "
|
|
322
|
-
details:
|
|
323
|
-
? `${
|
|
324
|
-
: "
|
|
376
|
+
tooltip: "The same mode concept should be spelled identically across collections (e.g. always \"Light\", never a mix of \"Light\" and \"light\"). Collections may have different mode sets β that's architecture, not inconsistency.",
|
|
377
|
+
details: inconsistent.length > 0
|
|
378
|
+
? `${inconsistent.length} mode name${inconsistent.length === 1 ? "" : "s"} spelled inconsistently across collections.`
|
|
379
|
+
: "Mode names are spelled consistently across collections.",
|
|
380
|
+
examples: inconsistent.length > 0
|
|
381
|
+
? inconsistent
|
|
382
|
+
.slice(0, MAX_EXAMPLES)
|
|
383
|
+
.map(([, spellings]) => [...spellings].join(" vs "))
|
|
384
|
+
: undefined,
|
|
325
385
|
};
|
|
326
386
|
}
|
|
327
387
|
/**
|
|
@@ -98,7 +98,32 @@ function containsVisualColorWord(name) {
|
|
|
98
98
|
* visual names (color.blue.500).
|
|
99
99
|
*/
|
|
100
100
|
function scoreVariableNaming(data) {
|
|
101
|
-
|
|
101
|
+
// Only judge tokens OUTSIDE the primitive tier: primitives are SUPPOSED to
|
|
102
|
+
// describe appearance (color/blue/500, color/neutral/black β they're the
|
|
103
|
+
// palette), and the tooltip's own contract ("semantic names survive theme
|
|
104
|
+
// changes") only applies to the tokens a theme swap re-points. The
|
|
105
|
+
// primitive tier is detected the same way the alias-usage check detects
|
|
106
|
+
// it: the collection contributing the most raw (non-alias) values.
|
|
107
|
+
const rawByCollection = new Map();
|
|
108
|
+
for (const v of data.variables) {
|
|
109
|
+
for (const value of Object.values(v.valuesByMode ?? {})) {
|
|
110
|
+
const val = value;
|
|
111
|
+
if (val?.type !== "VARIABLE_ALIAS") {
|
|
112
|
+
const key = v.variableCollectionId ?? "?";
|
|
113
|
+
rawByCollection.set(key, (rawByCollection.get(key) ?? 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let primitiveCollectionId = null;
|
|
118
|
+
let maxRaw = -1;
|
|
119
|
+
for (const [key, count] of rawByCollection) {
|
|
120
|
+
if (count > maxRaw) {
|
|
121
|
+
maxRaw = count;
|
|
122
|
+
primitiveCollectionId = key;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const colorVars = data.variables.filter((v) => v.resolvedType === "COLOR" &&
|
|
126
|
+
(v.variableCollectionId ?? "?") !== primitiveCollectionId);
|
|
102
127
|
if (colorVars.length === 0) {
|
|
103
128
|
return {
|
|
104
129
|
id: "naming-variable-semantic",
|
|
@@ -154,7 +154,70 @@ function scoreAliasUsage(data) {
|
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
156
|
const ratio = aliasCount / totalValues;
|
|
157
|
-
|
|
157
|
+
// Calibration: primitives hold raw values BY DEFINITION β they are the
|
|
158
|
+
// alias TARGETS β so a raw all-values ratio punishes systems for having a
|
|
159
|
+
// primitive tier at all. What layering actually demands is that values
|
|
160
|
+
// OUTSIDE the primitive tier alias into it instead of duplicating raw
|
|
161
|
+
// values. Treat the collection contributing the most raw values as the
|
|
162
|
+
// primitive tier and measure alias density over the remaining values.
|
|
163
|
+
const rawByCollection = new Map();
|
|
164
|
+
for (const variable of data.variables) {
|
|
165
|
+
if (!variable.valuesByMode)
|
|
166
|
+
continue;
|
|
167
|
+
for (const value of Object.values(variable.valuesByMode)) {
|
|
168
|
+
if (!isAlias(value)) {
|
|
169
|
+
const key = variable.variableCollectionId ?? "?";
|
|
170
|
+
rawByCollection.set(key, (rawByCollection.get(key) ?? 0) + 1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// The primitive tier = the collection contributing the most raw values.
|
|
175
|
+
// Additionally, any collection containing ZERO aliases is an alias SOURCE
|
|
176
|
+
// (a self-contained primitive set, e.g. a motion-timing collection) β it
|
|
177
|
+
// cannot be expected to alias into anything, so its raw values are also
|
|
178
|
+
// excluded from the layering denominator.
|
|
179
|
+
const aliasByCollection = new Map();
|
|
180
|
+
for (const variable of data.variables) {
|
|
181
|
+
if (!variable.valuesByMode)
|
|
182
|
+
continue;
|
|
183
|
+
for (const value of Object.values(variable.valuesByMode)) {
|
|
184
|
+
if (isAlias(value)) {
|
|
185
|
+
const key = variable.variableCollectionId ?? "?";
|
|
186
|
+
aliasByCollection.set(key, (aliasByCollection.get(key) ?? 0) + 1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let primitiveKey = null;
|
|
191
|
+
let primitiveRaw = 0;
|
|
192
|
+
for (const [key, count] of rawByCollection) {
|
|
193
|
+
if (count > primitiveRaw) {
|
|
194
|
+
primitiveRaw = count;
|
|
195
|
+
primitiveKey = key;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// A system can have MULTIPLE primitive roots (e.g. a Brand tier plus a
|
|
199
|
+
// Primitive tier that aliases brand colors but roots its own neutrals) β
|
|
200
|
+
// recognize primitive tiers by the strongest signals available: zero
|
|
201
|
+
// aliases (pure source), the largest raw contributor, or a tier-naming
|
|
202
|
+
// convention (primitive/core/base/palette/brand/foundation).
|
|
203
|
+
const tierNames = new Map();
|
|
204
|
+
for (const col of data.collections) {
|
|
205
|
+
if (col.id)
|
|
206
|
+
tierNames.set(col.id, col.name ?? "");
|
|
207
|
+
}
|
|
208
|
+
const PRIMITIVE_NAME_RE = /primitive|core|base|palette|brand|foundation/i;
|
|
209
|
+
let excludedRaw = 0;
|
|
210
|
+
for (const [key, count] of rawByCollection) {
|
|
211
|
+
const isZeroAlias = !(aliasByCollection.get(key) ?? 0);
|
|
212
|
+
const isMaxRaw = key === primitiveKey;
|
|
213
|
+
const isPrimitiveNamed = PRIMITIVE_NAME_RE.test(tierNames.get(key) ?? "");
|
|
214
|
+
if (isZeroAlias || isMaxRaw || isPrimitiveNamed) {
|
|
215
|
+
excludedRaw += count;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const nonPrimitiveValues = Math.max(1, totalValues - excludedRaw);
|
|
219
|
+
const layeredRatio = aliasCount / nonPrimitiveValues;
|
|
220
|
+
const score = clamp(layeredRatio * 100);
|
|
158
221
|
return {
|
|
159
222
|
id: "token-alias-usage",
|
|
160
223
|
label: "Alias usage",
|