@usefragments/core 1.0.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.
Files changed (228) hide show
  1. package/LICENSE +21 -0
  2. package/dist/chunk-243QYRUF.js +2145 -0
  3. package/dist/chunk-243QYRUF.js.map +1 -0
  4. package/dist/chunk-3LLRNCPX.js +111 -0
  5. package/dist/chunk-3LLRNCPX.js.map +1 -0
  6. package/dist/chunk-EIYNNS77.js +48 -0
  7. package/dist/chunk-EIYNNS77.js.map +1 -0
  8. package/dist/chunk-JNBFJ34I.js +326 -0
  9. package/dist/chunk-JNBFJ34I.js.map +1 -0
  10. package/dist/chunk-R3X7UBHU.js +228 -0
  11. package/dist/chunk-R3X7UBHU.js.map +1 -0
  12. package/dist/chunk-SH4KPIYH.js +393 -0
  13. package/dist/chunk-SH4KPIYH.js.map +1 -0
  14. package/dist/chunk-V4VQB57N.js +56 -0
  15. package/dist/chunk-V4VQB57N.js.map +1 -0
  16. package/dist/chunk-X34IA4LR.js +319 -0
  17. package/dist/chunk-X34IA4LR.js.map +1 -0
  18. package/dist/chunk-YF65VYRY.js +493 -0
  19. package/dist/chunk-YF65VYRY.js.map +1 -0
  20. package/dist/codes/index.d.ts +40 -0
  21. package/dist/codes/index.js +23 -0
  22. package/dist/codes/index.js.map +1 -0
  23. package/dist/compiled-types/index.d.ts +5 -0
  24. package/dist/compiled-types/index.js +1 -0
  25. package/dist/compiled-types/index.js.map +1 -0
  26. package/dist/generate/index.d.ts +47 -0
  27. package/dist/generate/index.js +11 -0
  28. package/dist/generate/index.js.map +1 -0
  29. package/dist/governance-BOa3KyiJ.d.ts +3279 -0
  30. package/dist/graph/index.d.ts +66 -0
  31. package/dist/graph/index.js +17 -0
  32. package/dist/graph/index.js.map +1 -0
  33. package/dist/index.d.ts +11388 -0
  34. package/dist/index.js +8711 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/package-identity.d.ts +42 -0
  37. package/dist/package-identity.js +23 -0
  38. package/dist/package-identity.js.map +1 -0
  39. package/dist/preview/index.d.ts +484 -0
  40. package/dist/preview/index.js +1129 -0
  41. package/dist/preview/index.js.map +1 -0
  42. package/dist/react-types.d.ts +22 -0
  43. package/dist/react-types.js +1 -0
  44. package/dist/react-types.js.map +1 -0
  45. package/dist/registry.d.ts +2367 -0
  46. package/dist/registry.js +62 -0
  47. package/dist/registry.js.map +1 -0
  48. package/dist/schemas/index.d.ts +2584 -0
  49. package/dist/schemas/index.js +55 -0
  50. package/dist/schemas/index.js.map +1 -0
  51. package/dist/severity.d.ts +18 -0
  52. package/dist/severity.js +27 -0
  53. package/dist/severity.js.map +1 -0
  54. package/dist/test-utils.d.ts +14 -0
  55. package/dist/test-utils.js +63 -0
  56. package/dist/test-utils.js.map +1 -0
  57. package/dist/topology/index.d.ts +48 -0
  58. package/dist/topology/index.js +7 -0
  59. package/dist/topology/index.js.map +1 -0
  60. package/dist/types-xJ2xyp_G.d.ts +119 -0
  61. package/package.json +120 -0
  62. package/src/__tests__/contract-parser.test.ts +614 -0
  63. package/src/__tests__/dtcg-generators.test.ts +439 -0
  64. package/src/__tests__/dtcg-parser.test.ts +884 -0
  65. package/src/__tests__/preview-runtime.test.tsx +111 -0
  66. package/src/__tests__/schema.test.ts +31 -0
  67. package/src/__tests__/severity.test.ts +34 -0
  68. package/src/__tests__/style-comparison.test.ts +662 -0
  69. package/src/__tests__/tokens-compat.test.ts +51 -0
  70. package/src/agent-format.test.ts +105 -0
  71. package/src/agent-format.ts +141 -0
  72. package/src/bundle.ts +149 -0
  73. package/src/codes/__tests__/codes.test.ts +73 -0
  74. package/src/codes/codes.ts +463 -0
  75. package/src/codes/index.ts +7 -0
  76. package/src/codes/source-finding-bridge.ts +113 -0
  77. package/src/compiled-types/index.ts +293 -0
  78. package/src/component-contract.ts +231 -0
  79. package/src/component-discovery.ts +80 -0
  80. package/src/composition.test.ts +262 -0
  81. package/src/composition.ts +318 -0
  82. package/src/config-paths.test.ts +78 -0
  83. package/src/config-paths.ts +47 -0
  84. package/src/config.ts +18 -0
  85. package/src/conform-prove.test.ts +274 -0
  86. package/src/conform-prove.ts +326 -0
  87. package/src/conform.ts +160 -0
  88. package/src/constants.ts +114 -0
  89. package/src/context.ts +2 -0
  90. package/src/contract/fixture.ts +110 -0
  91. package/src/contract/hash.test.ts +86 -0
  92. package/src/contract/hash.ts +195 -0
  93. package/src/contract/index.ts +43 -0
  94. package/src/contract/preimage.test.ts +254 -0
  95. package/src/contract/preimage.ts +365 -0
  96. package/src/contract/stamp.test.ts +100 -0
  97. package/src/contract/stamp.ts +105 -0
  98. package/src/contract-parser.ts +141 -0
  99. package/src/defineFragment.test.ts +257 -0
  100. package/src/defineFragment.ts +293 -0
  101. package/src/dtcg-generators.ts +324 -0
  102. package/src/dtcg-parser.ts +1 -0
  103. package/src/dtcg.ts +237 -0
  104. package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
  105. package/src/facts/builders.ts +835 -0
  106. package/src/facts/compile.ts +422 -0
  107. package/src/facts/fact-index.ts +394 -0
  108. package/src/facts/facts.test.ts +494 -0
  109. package/src/facts/ids.ts +125 -0
  110. package/src/facts/index.ts +119 -0
  111. package/src/facts/types.ts +614 -0
  112. package/src/figma.ts +263 -0
  113. package/src/fragment-types.ts +214 -0
  114. package/src/generate/generate.test.ts +594 -0
  115. package/src/generate/index.ts +438 -0
  116. package/src/governance-integrity.test.ts +186 -0
  117. package/src/governance-integrity.ts +282 -0
  118. package/src/governance.test.ts +300 -0
  119. package/src/governance.ts +605 -0
  120. package/src/governance.type-test.ts +75 -0
  121. package/src/graph/engine.ts +446 -0
  122. package/src/graph/graph.test.ts +746 -0
  123. package/src/graph/index.ts +32 -0
  124. package/src/graph/serialization.ts +55 -0
  125. package/src/graph/types.ts +178 -0
  126. package/src/index.ts +891 -0
  127. package/src/local-canonical.ts +102 -0
  128. package/src/mcp-snapshot.ts +222 -0
  129. package/src/package-identity-match.ts +33 -0
  130. package/src/package-identity.generated.ts +233 -0
  131. package/src/package-identity.test.ts +99 -0
  132. package/src/package-identity.ts +157 -0
  133. package/src/performance-presets.ts +142 -0
  134. package/src/preview/comparison-artifact.test.ts +228 -0
  135. package/src/preview/comparison-artifact.ts +181 -0
  136. package/src/preview/comparison-context.ts +93 -0
  137. package/src/preview/comparison-evidence.ts +112 -0
  138. package/src/preview/comparison-format.ts +21 -0
  139. package/src/preview/comparison-presentation.ts +187 -0
  140. package/src/preview/comparison-references.test.ts +430 -0
  141. package/src/preview/comparison-references.ts +72 -0
  142. package/src/preview/comparison-score.ts +59 -0
  143. package/src/preview/comparison-styles.ts +197 -0
  144. package/src/preview/comparison.test.ts +559 -0
  145. package/src/preview/comparison.ts +488 -0
  146. package/src/preview/index.ts +10 -0
  147. package/src/preview/types.ts +309 -0
  148. package/src/preview/validation.test.ts +290 -0
  149. package/src/preview/validation.ts +356 -0
  150. package/src/preview-runtime.tsx +144 -0
  151. package/src/raw-html-canonical.test.ts +41 -0
  152. package/src/raw-html-canonical.ts +179 -0
  153. package/src/react-types.ts +21 -0
  154. package/src/registry-install-plan.ts +344 -0
  155. package/src/registry.test.ts +245 -0
  156. package/src/registry.ts +369 -0
  157. package/src/rules/a11y-required-accessible-name.ts +93 -0
  158. package/src/rules/color-math.test.ts +72 -0
  159. package/src/rules/color-math.ts +192 -0
  160. package/src/rules/components-forbidden-prop-value.ts +108 -0
  161. package/src/rules/components-prefer-library.ts +943 -0
  162. package/src/rules/components-unknown-prop.ts +94 -0
  163. package/src/rules/composition-pattern-schema.ts +125 -0
  164. package/src/rules/composition-pattern.test.ts +320 -0
  165. package/src/rules/composition-pattern.ts +271 -0
  166. package/src/rules/emit-gate.test.ts +42 -0
  167. package/src/rules/emit-gate.ts +60 -0
  168. package/src/rules/finding.ts +54 -0
  169. package/src/rules/index.ts +235 -0
  170. package/src/rules/jsx-preferred-component.ts +68 -0
  171. package/src/rules/jsx-preferred-import-path.ts +67 -0
  172. package/src/rules/presets.ts +57 -0
  173. package/src/rules/props-invalid-value.ts +115 -0
  174. package/src/rules/rules.test.ts +3636 -0
  175. package/src/rules/spacing-resolution.ts +184 -0
  176. package/src/rules/styles-no-raw-color.ts +329 -0
  177. package/src/rules/styles-no-raw-dimensions.ts +357 -0
  178. package/src/rules/styles-no-raw-spacing.ts +236 -0
  179. package/src/rules/styles-no-raw-typography.ts +237 -0
  180. package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
  181. package/src/rules/tailwind-arbitrary-color.ts +93 -0
  182. package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
  183. package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
  184. package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
  185. package/src/rules/tailwind-forbidden-palette.ts +107 -0
  186. package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
  187. package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
  188. package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
  189. package/src/rules/tailwind-raw-color-via-token.ts +82 -0
  190. package/src/rules/tailwind-rule-utils.ts +106 -0
  191. package/src/rules/tailwind-unknown-class.test.ts +157 -0
  192. package/src/rules/tailwind-unknown-class.ts +121 -0
  193. package/src/rules/taxonomy.test.ts +238 -0
  194. package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
  195. package/src/rules/tiers.ts +111 -0
  196. package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
  197. package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
  198. package/src/rules/tokens-require-dual-fallback.ts +78 -0
  199. package/src/rules/types.ts +27 -0
  200. package/src/rules/utils.test.ts +110 -0
  201. package/src/rules/utils.ts +599 -0
  202. package/src/schema.ts +427 -0
  203. package/src/schemas/index.ts +275 -0
  204. package/src/schemas/schemas.test.ts +124 -0
  205. package/src/severity.ts +54 -0
  206. package/src/storyAdapter.test.ts +571 -0
  207. package/src/storyAdapter.ts +761 -0
  208. package/src/storyFilters.test.ts +350 -0
  209. package/src/storyFilters.ts +253 -0
  210. package/src/storybook-csf.ts +11 -0
  211. package/src/style-comparison.ts +423 -0
  212. package/src/test-utils.ts +68 -0
  213. package/src/token-parser.ts +2 -0
  214. package/src/token-types.ts +287 -0
  215. package/src/tokens/categories.ts +131 -0
  216. package/src/tokens/color.ts +193 -0
  217. package/src/tokens/design-token-parser.test.ts +159 -0
  218. package/src/tokens/design-token-parser.ts +402 -0
  219. package/src/tokens/dtcg.ts +428 -0
  220. package/src/tokens/index.ts +130 -0
  221. package/src/tokens/lookup.ts +19 -0
  222. package/src/tokens/scss.ts +171 -0
  223. package/src/tokens/tailwind-v4.ts +187 -0
  224. package/src/tokens/types.ts +43 -0
  225. package/src/topology/index.ts +9 -0
  226. package/src/topology/resolve-area.test.ts +174 -0
  227. package/src/topology/resolve-area.ts +215 -0
  228. package/src/types.ts +1057 -0
@@ -0,0 +1,184 @@
1
+ import type { TokenDefinitionFact } from "../facts/index.js";
2
+
3
+ import {
4
+ isApplicableTokenReference,
5
+ matchesScale,
6
+ nearestSignedScaleValue,
7
+ normalizeLengthForScale,
8
+ parseLengthValue,
9
+ tokenReference,
10
+ type ParsedLengthValue,
11
+ } from "./utils.js";
12
+
13
+ export interface SpacingResolutionScale {
14
+ name: string;
15
+ unit: "px" | "rem";
16
+ rootFontSizePx?: number;
17
+ emBasePx?: number;
18
+ }
19
+
20
+ /** Spacing tokens keyed by their value normalized into a given scale's unit. */
21
+ export type SpacingTokenLookup = Map<number, TokenDefinitionFact>;
22
+
23
+ export interface CheckedSpacingValue {
24
+ normalizedValue: number;
25
+ /** Best-effort suggestion for display, even when no fix is emitted. */
26
+ suggestedValue?: string;
27
+ /** Token name backing suggestedValue, when the canonical token vocabulary has one. */
28
+ suggestedToken?: string;
29
+ /** Whether a fix object should be attached (suggestion reliable enough). */
30
+ fixEmittable: boolean;
31
+ deterministicFix: boolean;
32
+ /** Dominant reason for the finding — drives message wording. */
33
+ reason: "off-scale" | "token-equivalent";
34
+ /** Token name when the violation is a raw literal that equals a token. */
35
+ matchedToken?: string;
36
+ assumedRootFontSizePx?: number;
37
+ assumedEmBasePx?: number;
38
+ }
39
+
40
+ export function buildSpacingTokenLookup(
41
+ tokens: readonly TokenDefinitionFact[],
42
+ scale: SpacingResolutionScale
43
+ ): SpacingTokenLookup {
44
+ const out: SpacingTokenLookup = new Map();
45
+ for (const token of tokens) {
46
+ const parsed = parseLengthValue(token.value);
47
+ if (!parsed) continue;
48
+ const normalized = normalizeLengthForScale(parsed, scale);
49
+ if (normalized === null) continue;
50
+ const key = Math.abs(normalized.value);
51
+ // First token wins so the suggestion is stable across runs.
52
+ if (!out.has(key)) out.set(key, token);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export function resolveSpacingValue(input: {
58
+ raw: string;
59
+ allowed: readonly number[];
60
+ scale: SpacingResolutionScale;
61
+ tokens: SpacingTokenLookup;
62
+ bareNumberFix?: boolean;
63
+ }): CheckedSpacingValue | null {
64
+ const parts = parseLengthParts(input.raw);
65
+ if (!parts) return null;
66
+
67
+ let firstViolation: CheckedSpacingValue | null = null;
68
+ const suggestedParts: string[] = [];
69
+ let anyViolation = false;
70
+ let anyOffScale = false;
71
+ let canFixAll = true;
72
+ // A snap is only reliable enough to *offer* when the value normalization was
73
+ // exact — an assumed em/rem base makes the px guess unreliable, so report the
74
+ // suggestion but attach no deterministic fix.
75
+ let reliableSuggestion = true;
76
+ // Deterministic only when every violating part is a value-preserving,
77
+ // verbatim-resolvable token swap.
78
+ let allApplicableValuePreserving = true;
79
+
80
+ for (const part of parts) {
81
+ const normalized = normalizeLengthForScale(part.parsed, input.scale);
82
+ if (normalized === null) return null;
83
+ const magnitude = Math.abs(normalized.value);
84
+ const onScale = matchesScale(normalized.value, input.allowed);
85
+ const exactToken = input.tokens.get(magnitude);
86
+
87
+ if (onScale) {
88
+ if (!exactToken) {
89
+ // On-grid value with no named token (e.g. `0`) — not a violation.
90
+ suggestedParts.push(part.raw);
91
+ continue;
92
+ }
93
+ // On-grid literal that equals a token: value-preserving swap.
94
+ anyViolation = true;
95
+ const reference = tokenReference(exactToken.name);
96
+ const applicable =
97
+ isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix;
98
+ if (!applicable) allApplicableValuePreserving = false;
99
+ suggestedParts.push(reference);
100
+ if (!firstViolation) {
101
+ firstViolation = {
102
+ normalizedValue: normalized.value,
103
+ suggestedValue: reference,
104
+ suggestedToken: exactToken.name,
105
+ fixEmittable: true,
106
+ deterministicFix: applicable,
107
+ reason: "token-equivalent",
108
+ matchedToken: exactToken.name,
109
+ assumedRootFontSizePx: normalized.assumedRootFontSizePx,
110
+ assumedEmBasePx: normalized.assumedEmBasePx,
111
+ };
112
+ }
113
+ continue;
114
+ }
115
+
116
+ // Off the scale — flag, suggest the nearest scale value as its token when
117
+ // one exists. Snapping changes rendered value, so this is not deterministic.
118
+ anyViolation = true;
119
+ anyOffScale = true;
120
+ allApplicableValuePreserving = false;
121
+
122
+ const nearest = nearestSignedScaleValue(normalized.value, input.allowed);
123
+ const snapsToZero = nearest === 0 && normalized.value !== 0;
124
+ let suggestedPart: string | undefined;
125
+ let suggestedToken: string | undefined;
126
+ if (nearest === null || snapsToZero) {
127
+ suggestedPart = undefined;
128
+ canFixAll = false;
129
+ suggestedParts.push(part.raw);
130
+ } else {
131
+ const nearestToken = input.tokens.get(Math.abs(nearest));
132
+ suggestedToken = nearestToken?.name;
133
+ suggestedPart = nearestToken
134
+ ? tokenReference(nearestToken.name)
135
+ : input.bareNumberFix
136
+ ? `${nearest}`
137
+ : `${nearest}${input.scale.unit}`;
138
+ suggestedParts.push(suggestedPart);
139
+ if (!normalized.deterministicFix) reliableSuggestion = false;
140
+ }
141
+
142
+ if (!firstViolation) {
143
+ firstViolation = {
144
+ normalizedValue: normalized.value,
145
+ suggestedValue: suggestedPart,
146
+ suggestedToken,
147
+ fixEmittable: false,
148
+ deterministicFix: false,
149
+ reason: "off-scale",
150
+ assumedRootFontSizePx: normalized.assumedRootFontSizePx,
151
+ assumedEmBasePx: normalized.assumedEmBasePx,
152
+ };
153
+ }
154
+ }
155
+
156
+ if (!firstViolation || !anyViolation) return null;
157
+
158
+ const joined = suggestedParts.join(" ");
159
+ const changes = joined !== input.raw.trim().replace(/\s+/g, " ");
160
+ const fixEmittable = canFixAll && reliableSuggestion && changes;
161
+ const deterministic = fixEmittable && !anyOffScale && allApplicableValuePreserving;
162
+
163
+ return {
164
+ ...firstViolation,
165
+ reason: anyOffScale ? "off-scale" : "token-equivalent",
166
+ fixEmittable,
167
+ deterministicFix: deterministic,
168
+ suggestedValue: canFixAll ? joined : firstViolation.suggestedValue,
169
+ };
170
+ }
171
+
172
+ function parseLengthParts(raw: string): Array<{ raw: string; parsed: ParsedLengthValue }> | null {
173
+ const trimmed = raw.trim();
174
+ if (!trimmed || /var\(|calc\(|min\(|max\(|clamp\(|,/i.test(trimmed)) return null;
175
+ const parts = trimmed.split(/\s+/);
176
+ if (parts.length < 1 || parts.length > 4) return null;
177
+
178
+ const parsed = parts.map((part) => {
179
+ const length = parseLengthValue(part);
180
+ return length ? { raw: part, parsed: length } : null;
181
+ });
182
+ if (parsed.some((part) => part === null)) return null;
183
+ return parsed as Array<{ raw: string; parsed: ParsedLengthValue }>;
184
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * `styles/no-raw-color` — fires when a color-bearing CSS declaration or JSX
3
+ * inline style uses a raw color literal (hex, rgb/rgba, hsl/hsla, named) and
4
+ * the global `style.rawColors.forbid` policy is active. Token references
5
+ * (`var(--...)`) and policy-supplied exemptions (`transparent`, etc.) pass.
6
+ *
7
+ * The rule looks at any `style_declaration` or `usage_inline_style` whose
8
+ * value contains a recognizable color literal, not just declarations whose
9
+ * property name suggests a color — backgrounds use `background:` shorthand,
10
+ * borders use `border-color:`, and SVG uses `fill:`/`stroke:`. Detecting on
11
+ * the *value* keeps the rule property-agnostic.
12
+ */
13
+
14
+ import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
15
+
16
+ import { colorDistance, parseOpaqueColor } from "./color-math.js";
17
+ import { makeFinding } from "./finding.js";
18
+ import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
19
+ import {
20
+ detectRawColor,
21
+ indexComponentByNodeId,
22
+ isApplicableTokenReference,
23
+ isExemptColor,
24
+ readUsageNode,
25
+ tokenReference,
26
+ } from "./utils.js";
27
+
28
+ export const RULE_ID = "styles/no-raw-color";
29
+ export const RULE_VERSION = "1";
30
+
31
+ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
32
+ const policy = ix.policy.rawColorPolicy();
33
+ if (!policy) return [];
34
+
35
+ const colorTokens = ix.tokens.byCategory("color");
36
+ const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
37
+ const findings: Finding[] = [];
38
+
39
+ for (const decl of ix.byKind("style_declaration")) {
40
+ if (decl.property.startsWith("--") && decl.declaredTokenSource === true) continue;
41
+ const color = detectRawColor(decl.value);
42
+ if (!color) continue;
43
+ if (isExemptColor(color, policy.except)) continue;
44
+
45
+ const resolution = resolveColorToken(decl.property, color, colorTokens);
46
+ const evidenceIds = resolution
47
+ ? [decl.id, policy.id, resolution.token.id]
48
+ : [decl.id, policy.id];
49
+
50
+ findings.push(
51
+ makeFinding({
52
+ ruleId: RULE_ID,
53
+ ruleVersion: RULE_VERSION,
54
+ severity: policy.severity,
55
+ message: colorMessage(color, `\`${decl.property}\``, resolution, preferLabel),
56
+ location: decl.location,
57
+ evidence: ix.evidence(evidenceIds),
58
+ fingerprintIdentity: {
59
+ source: "style_declaration",
60
+ file: decl.file,
61
+ selector: decl.selector,
62
+ declarationPath: decl.declarationPath,
63
+ property: decl.property,
64
+ value: color,
65
+ },
66
+ fix:
67
+ resolution?.kind === "exact"
68
+ ? buildTokenFix(decl.property, decl.value, color, resolution)
69
+ : undefined,
70
+ attributes: {
71
+ property: decl.property,
72
+ rawValue: decl.value,
73
+ color,
74
+ source: "css",
75
+ suggestedToken: resolution?.token.name,
76
+ ...(resolution ? { tokenMatch: resolution.kind } : {}),
77
+ },
78
+ })
79
+ );
80
+ }
81
+
82
+ const componentByNode = indexComponentByNodeId(ix);
83
+ for (const inline of ix.byKind("usage_inline_style")) {
84
+ if (inline.valueKind === "css-variable") continue;
85
+ const color = detectRawColor(inline.value);
86
+ if (!color) continue;
87
+ if (isExemptColor(color, policy.except)) continue;
88
+
89
+ const node = readUsageNode(ix, inline.nodeId);
90
+ if (!node) continue;
91
+
92
+ const componentEvidenceId = componentByNode.get(node.id)?.id;
93
+ const resolution = resolveColorToken(inline.property, color, colorTokens);
94
+ const baseEvidence = componentEvidenceId
95
+ ? [node.id, componentEvidenceId, inline.id, policy.id]
96
+ : [node.id, inline.id, policy.id];
97
+ const evidenceIds = resolution ? [...baseEvidence, resolution.token.id] : baseEvidence;
98
+
99
+ findings.push(
100
+ makeFinding({
101
+ ruleId: RULE_ID,
102
+ ruleVersion: RULE_VERSION,
103
+ severity: policy.severity,
104
+ message: colorMessage(color, `inline \`${inline.property}\``, resolution, preferLabel),
105
+ location: node.location,
106
+ evidence: ix.evidence(evidenceIds),
107
+ fingerprintIdentity: {
108
+ source: "usage_inline_style",
109
+ file: node.file,
110
+ nodePath: node.nodePath,
111
+ element: node.element,
112
+ property: inline.property,
113
+ value: color,
114
+ },
115
+ fix:
116
+ resolution?.kind === "exact"
117
+ ? buildTokenFix(inline.property, inline.value, color, resolution)
118
+ : undefined,
119
+ attributes: {
120
+ property: inline.property,
121
+ rawValue: inline.value,
122
+ color,
123
+ source: "jsx",
124
+ suggestedToken: resolution?.token.name,
125
+ ...(resolution ? { tokenMatch: resolution.kind } : {}),
126
+ },
127
+ })
128
+ );
129
+ }
130
+
131
+ return findings;
132
+ }
133
+
134
+ interface TokenMatch {
135
+ token: TokenDefinitionFact;
136
+ /** True when several tokens share the value and none matches the property's
137
+ * role — the swap is a guess, so the fix must not be applied automatically. */
138
+ ambiguous: boolean;
139
+ }
140
+
141
+ /**
142
+ * How a raw color resolves against the project's tokens (mirrors the
143
+ * `styles/no-raw-dimensions` resolution semantics):
144
+ * - `exact` — value-identical token; deterministic fix unless role-ambiguous.
145
+ * - `nearest` — no token has this exact value, but one is perceptually close
146
+ * (an off-by-a-shade `#7c3acc` next to a `#7c3aed` accent token). Surfaced as
147
+ * a `suggestedToken` hint on the message + attribute with NO fix object:
148
+ * snapping changes the rendered color, and the conform engine auto-applies
149
+ * token-backed fixes, so attaching one would silently rewrite the value.
150
+ */
151
+ type ColorResolution =
152
+ | { kind: "exact"; token: TokenDefinitionFact; ambiguous: boolean }
153
+ | { kind: "nearest"; token: TokenDefinitionFact };
154
+
155
+ function resolveColorToken(
156
+ property: string,
157
+ raw: string,
158
+ tokens: readonly TokenDefinitionFact[]
159
+ ): ColorResolution | null {
160
+ const exact = findTokenMatch(property, raw, tokens);
161
+ if (exact) return { kind: "exact", token: exact.token, ambiguous: exact.ambiguous };
162
+ const nearest = findNearestColorToken(raw, tokens);
163
+ return nearest ? { kind: "nearest", token: nearest } : null;
164
+ }
165
+
166
+ function colorMessage(
167
+ color: string,
168
+ propertyLabel: string,
169
+ resolution: ColorResolution | null,
170
+ preferLabel: string
171
+ ): string {
172
+ if (resolution?.kind === "nearest") {
173
+ return `Raw color ${color} on ${propertyLabel}. Closest token is \`${resolution.token.name}\` (${String(resolution.token.value)}); use a ${preferLabel} instead.`;
174
+ }
175
+ return `Raw color ${color} on ${propertyLabel}. Use a ${preferLabel} instead.`;
176
+ }
177
+
178
+ /**
179
+ * Redmean-weighted RGB distance cutoff for a "nearest" suggestion. Redmean
180
+ * ranges 0–~765; ~60 keeps suggestions within the same visual family (an
181
+ * off-by-one-shade brand color) while never bridging genuinely different hues.
182
+ * Both sides parse through `parseOpaqueColor` (hex, rgb/rgba, hsl/hsla,
183
+ * oklch), so the raw value and the token may be authored in different
184
+ * notations and still match.
185
+ */
186
+ const NEAREST_COLOR_MAX_DISTANCE = 60;
187
+
188
+ function findNearestColorToken(
189
+ raw: string,
190
+ tokens: readonly TokenDefinitionFact[]
191
+ ): TokenDefinitionFact | null {
192
+ const target = parseOpaqueColor(raw);
193
+ if (!target) return null;
194
+
195
+ let best: TokenDefinitionFact | null = null;
196
+ let bestDistance = Infinity;
197
+ for (const token of tokens) {
198
+ if (typeof token.value !== "string") continue;
199
+ const candidate = parseOpaqueColor(token.value);
200
+ if (!candidate) continue;
201
+ const distance = colorDistance(target, candidate);
202
+ // Strict `<` plus stable iteration keeps the pick deterministic on ties.
203
+ if (distance < bestDistance) {
204
+ best = token;
205
+ bestDistance = distance;
206
+ }
207
+ }
208
+ return bestDistance <= NEAREST_COLOR_MAX_DISTANCE ? best : null;
209
+ }
210
+
211
+ /**
212
+ * Find a color token whose value matches the offending raw color exactly.
213
+ * Comparison is normalized — short hex (`#abc`) is expanded to full hex
214
+ * (`#aabbcc`) so `#fff` and `#ffffff` collapse to the same key.
215
+ *
216
+ * When multiple tokens share the value (e.g. `--color-bg` and `--color-on-x`
217
+ * both `#ffffff`), prefer the one whose name matches the declaration's semantic
218
+ * role (`color:` → foreground, `background:` → surface, `border:` → border) so
219
+ * a foreground color is never auto-rewritten to a background token. If no token
220
+ * matches the role, the swap is flagged ambiguous and emitted as a non-applied
221
+ * suggestion rather than a deterministic fix.
222
+ */
223
+ function findTokenMatch(
224
+ property: string,
225
+ raw: string,
226
+ tokens: readonly TokenDefinitionFact[]
227
+ ): TokenMatch | null {
228
+ const target = normalizeColor(raw);
229
+ const matches = tokens.filter((token) => normalizeColor(token.value) === target);
230
+ if (matches.length === 0) return null;
231
+
232
+ const role = colorRoleForProperty(property);
233
+
234
+ if (matches.length === 1) {
235
+ // A single value-match is safe to apply automatically *unless* the token's
236
+ // own name signals a role that contradicts the property — e.g. a background
237
+ // token (`--color-bg`) swapped into `color:` would make text track the page
238
+ // surface and disappear on dark themes. In that case suggest, never apply.
239
+ const conflicts = role ? tokenRoleConflicts(matches[0].name, role) : false;
240
+ return { token: matches[0], ambiguous: conflicts };
241
+ }
242
+
243
+ const roleMatch = role
244
+ ? matches.find((token) => tokenNameMatchesRole(token.name, role))
245
+ : undefined;
246
+ if (roleMatch) return { token: roleMatch, ambiguous: false };
247
+ return { token: matches[0], ambiguous: true };
248
+ }
249
+
250
+ /**
251
+ * True when a token's name matches a color role *other than* the property's and
252
+ * not the property's own role — applying it would cross semantic roles (the
253
+ * white-text → background-token trap). Role-neutral names (e.g.
254
+ * `--color-brand-500`) never conflict, so a brand color used as text still gets
255
+ * a deterministic swap.
256
+ */
257
+ function tokenRoleConflicts(name: string, role: ColorRole): boolean {
258
+ if (tokenNameMatchesRole(name, role)) return false;
259
+ const others: ColorRole[] = (["fg", "bg", "border"] as ColorRole[]).filter((r) => r !== role);
260
+ return others.some((other) => tokenNameMatchesRole(name, other));
261
+ }
262
+
263
+ type ColorRole = "fg" | "bg" | "border";
264
+
265
+ function colorRoleForProperty(property: string): ColorRole | null {
266
+ const p = property.toLowerCase();
267
+ if (p === "color" || p === "fill" || p === "stroke" || p.endsWith("text-color")) return "fg";
268
+ if (p === "background" || p.startsWith("background") || p === "fill") return "bg";
269
+ if (p.startsWith("border") || p.startsWith("outline") || p === "box-shadow") return "border";
270
+ return null;
271
+ }
272
+
273
+ function tokenNameMatchesRole(name: string, role: ColorRole): boolean {
274
+ const n = name.toLowerCase();
275
+ if (role === "fg")
276
+ return /\b(text|fg|foreground|content|ink|on-|label)\b|text|foreground/.test(n);
277
+ if (role === "bg")
278
+ return /\b(bg|background|surface|fill|elevated|canvas)\b|background|surface/.test(n);
279
+ return /\b(border|outline|stroke|divider|ring)\b|border|outline/.test(n);
280
+ }
281
+
282
+ function normalizeColor(value: string): string {
283
+ const lower = value.trim().toLowerCase();
284
+ const hex = /^#([0-9a-f]{3})$/.exec(lower);
285
+ if (hex) {
286
+ const [r, g, b] = hex[1];
287
+ return `#${r}${r}${g}${g}${b}${b}`;
288
+ }
289
+ const hex4 = /^#([0-9a-f]{4})$/.exec(lower);
290
+ if (hex4) {
291
+ const [r, g, b, a] = hex4[1];
292
+ return `#${r}${r}${g}${g}${b}${b}${a}${a}`;
293
+ }
294
+ return lower;
295
+ }
296
+
297
+ function buildTokenFix(
298
+ property: string,
299
+ rawValue: string,
300
+ rawColor: string,
301
+ match: TokenMatch
302
+ ): FindingReplaceStyleValueFix {
303
+ const reference = tokenReference(match.token.name);
304
+ const value = replaceRawColor(rawValue, rawColor, reference);
305
+ return {
306
+ kind: "replaceStyleValue",
307
+ title: `Replace ${property} with ${value}`,
308
+ property,
309
+ value,
310
+ // Apply automatically only when the swap is unambiguous AND the reference
311
+ // resolves verbatim. An ambiguous match (several tokens share the value,
312
+ // none role-matching) or a token that can't be referenced from CSS (a Sass
313
+ // *map* member, or a DTCG JSON token with no emitted custom property) is a
314
+ // suggestion — applying it would cross roles or write non-compiling code.
315
+ deterministic: !match.ambiguous && isApplicableTokenReference(match.token.referenceFormat),
316
+ };
317
+ }
318
+
319
+ function replaceRawColor(rawValue: string, rawColor: string, replacement: string): string {
320
+ if (rawValue === rawColor) return replacement;
321
+ if (rawValue.includes(rawColor)) return rawValue.replace(rawColor, replacement);
322
+
323
+ const pattern = new RegExp(escapeRegExp(rawColor), "i");
324
+ return rawValue.replace(pattern, replacement);
325
+ }
326
+
327
+ function escapeRegExp(value: string): string {
328
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
329
+ }