sparkle-design-cli 2.4.2 → 2.5.0-beta.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.
@@ -0,0 +1,888 @@
1
+ /**
2
+ * 旧セマンティックトークン → 新セマンティックトークンの移行マッピング。
3
+ *
4
+ * このファイルが移行知識の唯一の source of truth。`check`(アンチパターン
5
+ * ルールの recommendation 文面)と、将来の `migrate` サブコマンド
6
+ * (goodpatch/sparkle-design-cli#73)の両方がここを参照する。同じ対応表を
7
+ * 2 箇所に書くと必ず片方だけ更新されて「ルールが嘘をつく」状態になるため。
8
+ *
9
+ * en: Single source of truth for the legacy -> new semantic token migration.
10
+ * Both the `check` anti-pattern rules and the planned `migrate` subcommand
11
+ * read from here, so the mapping can never drift between them.
12
+ *
13
+ * ## 設計の背景
14
+ *
15
+ * 旧セマンティック層は「意味名 + スケール値」(`bg-primary-500`)、新体系は
16
+ * 「用途 + 意味 + 強度 + 状態」(`bg-surface-primary-high-enabled`)で設計思想が
17
+ * 異なる。とくに **状態がトークン名に内包された**ことで、同じ旧クラスでも
18
+ * 文脈(Tailwind の variant 接頭辞)によって移行先が変わる。
19
+ *
20
+ * そのため単純な文字列置換表ではなく、次の 3 分類で返す:
21
+ *
22
+ * - `auto` … 移行先が一意に決まる。値も変わらない
23
+ * - `review` … 候補が複数ある / 対応する新トークンが無い。人間の判断が要る
24
+ * - `structural` … トークン差し替えでは済まない(マークアップ変更を伴う)
25
+ *
26
+ * ## 用途プレフィックスの対応
27
+ *
28
+ * Tailwind のユーティリティ接頭辞は、そのまま Figma の「用途」軸に対応する。
29
+ * `bg-` → surface(背景) / `text-` → text(文字) / `border-`・`divide-`・
30
+ * `outline-`・`ring-` → border(枠線) / `fill-`・`stroke-` → object(図形)。
31
+ *
32
+ * ## 値が変わらないことの保証
33
+ *
34
+ * 下の SCALE_MAP は「旧トークンが指していたプリミティブのレベル」を key に
35
+ * 「同じプリミティブを指す新トークン」を並べて作ってある(例: 旧
36
+ * `primary-600` と新 `surface-primary-high-enabled` はどちらも
37
+ * `var(--color-<primary>-600)`)。つまり `auto` 分類の変換は**見た目が変わらない**。
38
+ * この不変条件は test/token-migration.test.js が実際に生成した CSS を読んで
39
+ * 継続的に検証する(表と sparkle-variables のテンプレートが食い違ったら落ちる)。
40
+ * en: Every `auto` mapping is value-preserving by construction, and
41
+ * test/token-migration.test.js re-verifies that against generated CSS.
42
+ */
43
+
44
+ export const MIGRATION_CLASS = {
45
+ AUTO: 'auto',
46
+ REVIEW: 'review',
47
+ STRUCTURAL: 'structural',
48
+ };
49
+
50
+ /** Tailwind ユーティリティ接頭辞 → Figma の用途カテゴリ */
51
+ export const UTILITY_PREFIX_TO_CATEGORY = {
52
+ bg: 'surface',
53
+ text: 'text',
54
+ // placeholder-* も文字色。LEGACY_TOKEN_MAP 側で `placeholder-text-placeholder`
55
+ // を扱っているのにここに無いと、`placeholder-neutral-500` のようなスケール
56
+ // 形式だけが素通りする。
57
+ // en: `placeholder-*` is a text colour too — without it the scale form slips
58
+ // through while the standalone form is detected.
59
+ placeholder: 'text',
60
+ border: 'border',
61
+ divide: 'border',
62
+ outline: 'border',
63
+ ring: 'border',
64
+ fill: 'object',
65
+ stroke: 'object',
66
+ };
67
+
68
+ /** 旧セマンティック層のファミリー名。`secondary` は `neutral` に統合された */
69
+ export const LEGACY_FAMILY_ALIASES = {
70
+ secondary: 'neutral',
71
+ };
72
+
73
+ export const LEGACY_FAMILIES = [
74
+ 'primary',
75
+ 'secondary',
76
+ 'info',
77
+ 'success',
78
+ 'warning',
79
+ 'negative',
80
+ 'neutral',
81
+ 'base',
82
+ ];
83
+
84
+ export const LEGACY_LEVELS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
85
+
86
+ /**
87
+ * 用途カテゴリ × ファミリー × 旧レベル → 新トークン名(`--color-` を除いた部分)。
88
+ * 値が配列のときは候補が複数あるという意味で、variant 接頭辞で絞れなければ
89
+ * `review` になる。
90
+ */
91
+ const SCALE_MAP = {
92
+ surface: {
93
+ primary: {
94
+ 600: 'surface-primary-high-enabled',
95
+ 700: 'surface-primary-high-hover',
96
+ 800: 'surface-primary-high-active',
97
+ 50: ['surface-primary-middle-enabled', 'surface-primary-middle-disabled'],
98
+ 100: 'surface-primary-middle-hover',
99
+ 200: ['surface-primary-high-disabled', 'surface-primary-middle-active'],
100
+ },
101
+ negative: {
102
+ 600: 'surface-negative-high-enabled',
103
+ 700: 'surface-negative-high-hover',
104
+ 800: 'surface-negative-high-active',
105
+ 50: ['surface-negative-middle-enabled', 'surface-negative-middle-disabled'],
106
+ 100: 'surface-negative-middle-hover',
107
+ 200: ['surface-negative-high-disabled', 'surface-negative-middle-active'],
108
+ },
109
+ neutral: {
110
+ 600: 'surface-neutral-high-enabled',
111
+ 700: 'surface-neutral-high-hover',
112
+ 800: 'surface-neutral-high-active',
113
+ 50: ['surface-neutral-middle-enabled', 'surface-neutral-middle-disabled'],
114
+ 100: 'surface-neutral-middle-hover',
115
+ 200: ['surface-neutral-high-disabled', 'surface-neutral-middle-active'],
116
+ },
117
+ info: { 500: 'surface-info-high', 100: 'surface-info-middle', 50: 'surface-info-low' },
118
+ success: {
119
+ 500: 'surface-success-high',
120
+ 100: 'surface-success-middle',
121
+ 50: 'surface-success-low',
122
+ },
123
+ warning: {
124
+ 600: 'surface-warning-high',
125
+ 100: 'surface-warning-middle',
126
+ 50: 'surface-warning-low',
127
+ },
128
+ base: { 100: 'surface-base-100', 200: 'surface-base-200' },
129
+ },
130
+ text: {
131
+ primary: {
132
+ 600: 'text-primary-enabled',
133
+ 700: 'text-primary-hover',
134
+ 800: 'text-primary-active',
135
+ 200: 'text-primary-disabled',
136
+ },
137
+ negative: {
138
+ 600: 'text-negative-enabled',
139
+ 700: 'text-negative-hover',
140
+ 800: 'text-negative-active',
141
+ 200: 'text-negative-disabled',
142
+ },
143
+ neutral: {
144
+ 900: 'text-neutral-high',
145
+ 700: 'text-neutral-middle',
146
+ 500: 'text-neutral-low',
147
+ 300: 'text-neutral-disabled',
148
+ },
149
+ info: { 600: 'text-info' },
150
+ success: { 700: 'text-success' },
151
+ warning: { 700: 'text-warning' },
152
+ },
153
+ border: {
154
+ primary: {
155
+ 500: 'border-primary-extra-high',
156
+ 300: 'border-primary-high',
157
+ 100: 'border-primary-low',
158
+ },
159
+ negative: {
160
+ 500: 'border-negative-extra-high-enabled',
161
+ 600: 'border-negative-extra-high-hover',
162
+ 200: 'border-negative-extra-high-disabled',
163
+ 300: 'border-negative-high',
164
+ 100: 'border-negative-low',
165
+ },
166
+ neutral: {
167
+ 500: 'border-neutral-extra-high-enabled',
168
+ 600: 'border-neutral-extra-high-hover',
169
+ 200: ['border-neutral-extra-high-disabled', 'border-neutral-middle'],
170
+ 300: 'border-neutral-high',
171
+ 100: 'border-neutral-low',
172
+ },
173
+ info: { 300: 'border-info' },
174
+ success: { 300: 'border-success' },
175
+ warning: { 300: 'border-warning' },
176
+ },
177
+ object: {
178
+ primary: {
179
+ 600: 'object-primary-enabled',
180
+ 700: 'object-primary-hover',
181
+ 800: 'object-primary-active',
182
+ 200: 'object-primary-disabled',
183
+ },
184
+ negative: {
185
+ 600: 'object-negative-enabled',
186
+ 700: 'object-negative-hover',
187
+ 800: 'object-negative-active',
188
+ 200: 'object-negative-disabled',
189
+ },
190
+ neutral: {
191
+ 900: 'object-neutral-high',
192
+ 700: 'object-neutral-middle',
193
+ 500: 'object-neutral-low',
194
+ 300: 'object-neutral-disabled',
195
+ },
196
+ info: { 600: 'object-info' },
197
+ success: { 700: 'object-success' },
198
+ warning: { 700: 'object-warning' },
199
+ },
200
+ };
201
+
202
+ /**
203
+ * スケールを持たない旧トークン(`--color-text-high` 等)の 1:1 対応。
204
+ * `sameValue` はどちらも同じプリミティブを指す=見た目が変わらないという意味。
205
+ */
206
+ export const LEGACY_TOKEN_MAP = [
207
+ { from: 'text-high', to: 'text-neutral-high', utilities: ['text'], sameValue: true },
208
+ { from: 'text-middle', to: 'text-neutral-middle', utilities: ['text'], sameValue: true },
209
+ { from: 'text-low', to: 'text-neutral-low', utilities: ['text'], sameValue: true },
210
+ { from: 'text-disabled', to: 'text-neutral-disabled', utilities: ['text'], sameValue: true },
211
+ {
212
+ from: 'text-placeholder',
213
+ to: 'text-neutral-low',
214
+ utilities: ['text', 'placeholder'],
215
+ sameValue: true,
216
+ classification: MIGRATION_CLASS.REVIEW,
217
+ reason:
218
+ 'placeholder 専用トークンは新体系には無く、`text/neutral/low` に統合された(値は同じ gray/500)。' +
219
+ 'placeholder と通常の低強度テキストを将来別の色にしたい場合は、統合してよいかを確認してから置換してください。',
220
+ },
221
+ {
222
+ from: 'divider-low',
223
+ to: 'border-neutral-low',
224
+ utilities: ['border', 'divide'],
225
+ sameValue: true,
226
+ },
227
+ {
228
+ from: 'divider-middle',
229
+ to: 'border-neutral-middle',
230
+ utilities: ['border', 'divide'],
231
+ sameValue: true,
232
+ },
233
+ {
234
+ from: 'divider-high',
235
+ to: 'border-neutral-high',
236
+ utilities: ['border', 'divide'],
237
+ sameValue: true,
238
+ },
239
+ { from: 'ring-normal', to: 'border-ring', utilities: ['ring', 'border'], sameValue: true },
240
+ {
241
+ from: 'skeleton-fill',
242
+ to: null,
243
+ utilities: ['bg'],
244
+ classification: MIGRATION_CLASS.REVIEW,
245
+ reason:
246
+ 'Figma の `Colors: Semantics` に対応するトークンが無い。値が同じ `surface/neutral/middle/active`(gray/200)に寄せるか、' +
247
+ 'スケルトン専用トークンを Figma 側に追加してもらうかをデザイナーと決めてください。',
248
+ },
249
+ ];
250
+
251
+ /**
252
+ * beta 期間の後方互換エイリアス。beta 終了時に削除されるため、直接参照は
253
+ * 消えた瞬間にサイレントで壊れる(角丸が none になる)。
254
+ */
255
+ export const DEPRECATED_VAR_ALIASES = [
256
+ {
257
+ from: '--radius-halfModal',
258
+ to: '--radius-container',
259
+ reason:
260
+ 'Figma の `Borders: Semantics` で `halfModal` は `container` にリネームされた。' +
261
+ '`--radius-halfModal` は beta 期間だけ残す後方互換エイリアスで、削除されると角丸がサイレントに消える。',
262
+ },
263
+ ];
264
+
265
+ /** Tailwind の variant 接頭辞 → 新トークンが内包する状態 */
266
+ const VARIANT_TO_STATE = {
267
+ hover: 'hover',
268
+ active: 'active',
269
+ disabled: 'disabled',
270
+ 'aria-disabled': 'disabled',
271
+ 'data-disabled': 'disabled',
272
+ };
273
+
274
+ /**
275
+ * variant チェーン全体から、新トークンが内包する状態を割り出す。
276
+ *
277
+ * 最後のセグメントだけを見ると `group-hover:` / `peer-hover:` / `dark:hover:` を
278
+ * 取りこぼす。実コードで最頻の `group-hover:` が「候補が 1 件しか無いのに要判断」
279
+ * に落ちていたため、全セグメントを走査し `group-` / `peer-` 接頭と `[` `]` を
280
+ * 剥がしてから対応表を引く。
281
+ *
282
+ * `not-hover:` のような否定 variant は剥がした後も対応表に無いので `null` に
283
+ * なる(= 状態で絞り込まない)。ここで hover 扱いにすると意味が反転するため、
284
+ * 部分一致ではなく完全一致で引くことが重要。
285
+ * en: Scan every variant segment (not just the last) and strip `group-`/`peer-`
286
+ * so `group-hover:` resolves. Uses exact lookup, never substring matching —
287
+ * `not-hover:` must not be read as `hover`.
288
+ *
289
+ * @param {string} variantPrefix 例 `dark:group-hover:`
290
+ * @returns {string | null}
291
+ */
292
+ function splitVariantSegments(variantPrefix) {
293
+ // `supports-[display:grid]:` のように角括弧の中に `:` を含む variant があるため、
294
+ // 素朴な `split(':')` では `grid]` のような壊れたセグメントができる。さらに
295
+ // `supports-[&:hover]:` だと `hover]` → 角括弧を剥がすと `hover` になり、
296
+ // **hover 状態のトークンだと誤判定して意味の違うトークンを案内してしまう**
297
+ // (同じレベルの候補は同じプリミティブを指すので色は変わらないが、
298
+ // トークン名が表す意味がずれる)。角括弧の外側の `:` だけで区切る。
299
+ // en: Split only on colons outside brackets — `supports-[&:hover]:` would
300
+ // otherwise yield a `hover]` segment and be misread as the hover state.
301
+ const segments = [];
302
+ let current = '';
303
+ let depth = 0;
304
+ for (const char of variantPrefix) {
305
+ if (char === '[') depth += 1;
306
+ else if (char === ']') depth = Math.max(0, depth - 1);
307
+ if (char === ':' && depth === 0) {
308
+ if (current) segments.push(current);
309
+ current = '';
310
+ continue;
311
+ }
312
+ current += char;
313
+ }
314
+ if (current) segments.push(current);
315
+ return segments;
316
+ }
317
+
318
+ function variantState(variantPrefix) {
319
+ for (const segment of splitVariantSegments(variantPrefix)) {
320
+ // `group-` / `peer-` を先に剥がしてから判定する。順序を逆にすると
321
+ // `group-data-[disabled]:` が「括弧つき = 判定に使わない」に落ちて、
322
+ // `group-hover:` は解決できるのにこちらはできない、という不整合になる。
323
+ const withoutScope = segment.replace(/^(?:group|peer)-/, '');
324
+ // 角括弧つきの arbitrary variant(`supports-[...]` / `[&:hover]`)は
325
+ // 状態の判定に使わない。`data-[disabled]` のような Tailwind 標準形だけ
326
+ // 括弧を剥がして引く。
327
+ const normalized = /^(?:data|aria)-\[[a-z-]+\]$/.test(withoutScope)
328
+ ? withoutScope.replace(/[[\]]/g, '')
329
+ : withoutScope;
330
+ if (normalized.includes('[')) continue;
331
+ if (VARIANT_TO_STATE[normalized]) return VARIANT_TO_STATE[normalized];
332
+ }
333
+ return null;
334
+ }
335
+
336
+ const STATE_SUFFIXES = ['enabled', 'hover', 'active', 'disabled'];
337
+
338
+ function tokenState(tokenName) {
339
+ const last = tokenName.split('-').pop();
340
+ return STATE_SUFFIXES.includes(last) ? last : null;
341
+ }
342
+
343
+ /**
344
+ * `base-*` は旧体系では gray(= `neutral`)と同値。ただし Figma には
345
+ * `surface/base/*`(ページ地の背景)という専用トークンがあるので、surface では
346
+ * それを優先し、対応レベルが無いところだけ `neutral` で埋める。
347
+ * text / border / object には `base` が無いので全面的に `neutral` を使う。
348
+ * en: `base-*` equals gray. Prefer Figma's dedicated `surface/base/*` where it
349
+ * exists and fall back to `neutral` elsewhere — both resolve to the same
350
+ * primitive, so the substitution stays value-preserving.
351
+ */
352
+ function familyScaleMap(category, family) {
353
+ const byFamily = SCALE_MAP[category] ?? {};
354
+ if (family !== 'base') return byFamily[family];
355
+ const merged = { ...(byFamily.neutral ?? {}), ...(byFamily.base ?? {}) };
356
+ return Object.keys(merged).length > 0 ? merged : undefined;
357
+ }
358
+
359
+ // Tailwind の opacity modifier(`/50`・`/[0.6]`)と important(末尾 `!`)。
360
+ // これらを落としたまま「自動変換可」として置換先を案内すると、案内どおりに
361
+ // 直したときに**不透明度や詳細度が黙って変わる**。`auto` は見た目が変わらない、
362
+ // という本モジュールの中心的な保証が破れる唯一の経路なので、必ず持ち回して
363
+ // 置換先の末尾に付け直す。
364
+ // en: Carry the opacity modifier / important flag through to the replacement.
365
+ // Dropping them would silently change opacity or specificity, breaking the
366
+ // "auto conversions are value-preserving" guarantee.
367
+ // 角括弧つきの arbitrary value(`/[var(--x)]` `/[calc(1/3)]`)は括弧の中に
368
+ // `(` `/` を含むため、単純な文字クラスでは途中で切れてしまう。切れたまま
369
+ // 「自動変換可」として案内すると、案内どおり直したクラスが壊れる。
370
+ // en: Bracketed arbitrary modifiers contain `(` and `/`, so a plain character
371
+ // class truncates them and would emit a broken replacement.
372
+ const MODIFIER_BODY = '(?:\\[[^\\]\\s]*\\]|[A-Za-z0-9._%-]+)';
373
+ const MODIFIER_SUFFIX_PATTERN = new RegExp(`^(.*?)((?:\\/${MODIFIER_BODY})?!?)$`);
374
+
375
+ /**
376
+ * ユーティリティクラス 1 個を解析して移行結果を返す。
377
+ *
378
+ * @param {string} utility 例 `hover:bg-primary-700` / `text-text-high`
379
+ * @returns {null | {
380
+ * input: string,
381
+ * classification: 'auto'|'review'|'structural',
382
+ * replacement?: string,
383
+ * candidates?: string[],
384
+ * reason?: string,
385
+ * }} 旧トークンを使っていなければ null
386
+ */
387
+ export function resolveLegacyUtility(utility) {
388
+ if (typeof utility !== 'string' || utility.length === 0) return null;
389
+
390
+ const lastColon = utility.lastIndexOf(':');
391
+ const variantPrefix = lastColon === -1 ? '' : utility.slice(0, lastColon + 1);
392
+ let rawBare = lastColon === -1 ? utility : utility.slice(lastColon + 1);
393
+
394
+ // Tailwind v3 形式の先頭 `!` を切り離して置換先に付け直す
395
+ const importantPrefix = rawBare.startsWith('!') ? '!' : '';
396
+ if (importantPrefix) rawBare = rawBare.slice(1);
397
+
398
+ // opacity modifier / important を切り離してから解析し、置換先に付け直す
399
+ const [, bare, modifier] = MODIFIER_SUFFIX_PATTERN.exec(rawBare);
400
+
401
+ const dashIndex = bare.indexOf('-');
402
+ if (dashIndex === -1) return null;
403
+ const prefix = bare.slice(0, dashIndex);
404
+ const rest = bare.slice(dashIndex + 1);
405
+
406
+ const wrap = (result) => ({ input: utility, ...result });
407
+ const withVariant = (tokenName) =>
408
+ `${variantPrefix}${importantPrefix}${prefix}-${tokenName}${modifier}`;
409
+
410
+ // 1. スケールを持たない旧トークン
411
+ const direct = LEGACY_TOKEN_MAP.find(
412
+ (entry) => entry.from === rest && entry.utilities.includes(prefix)
413
+ );
414
+ if (direct) {
415
+ if (!direct.to) {
416
+ return wrap({ classification: MIGRATION_CLASS.REVIEW, reason: direct.reason });
417
+ }
418
+ return wrap({
419
+ classification: direct.classification ?? MIGRATION_CLASS.AUTO,
420
+ replacement: withVariant(direct.to),
421
+ reason: direct.reason,
422
+ });
423
+ }
424
+
425
+ // 2. 「意味名 + スケール値」形式
426
+ const category = UTILITY_PREFIX_TO_CATEGORY[prefix];
427
+ if (!category) return null;
428
+
429
+ const scaleMatch = /^([a-z]+)-(\d{2,3})$/.exec(rest);
430
+ if (!scaleMatch) return null;
431
+ const rawFamily = scaleMatch[1];
432
+ const level = scaleMatch[2];
433
+ if (!LEGACY_FAMILIES.includes(rawFamily) || !LEGACY_LEVELS.includes(level)) return null;
434
+
435
+ const family = LEGACY_FAMILY_ALIASES[rawFamily] ?? rawFamily;
436
+ const familyMap = familyScaleMap(category, family);
437
+ if (!familyMap) {
438
+ return wrap({
439
+ classification: MIGRATION_CLASS.REVIEW,
440
+ reason:
441
+ `${prefix}-(用途: ${category})と ${rawFamily} の組み合わせに対応する新トークンがありません。` +
442
+ 'Figma の該当箇所を見て用途から選び直してください。',
443
+ });
444
+ }
445
+
446
+ const raw = familyMap[level];
447
+ if (!raw) {
448
+ return wrap({
449
+ classification: MIGRATION_CLASS.REVIEW,
450
+ reason:
451
+ `${rawFamily}-${level} をそのまま引き継ぐ ${category} トークンはありません(新体系は用途ごとに使うレベルを絞っている)。` +
452
+ `利用可能: ${Object.values(familyMap).flat().join(', ')}`,
453
+ });
454
+ }
455
+
456
+ const candidates = Array.isArray(raw) ? raw : [raw];
457
+
458
+ // variant 接頭辞(hover: / group-hover: / disabled: 等)があれば状態で絞り込む
459
+ const variantName = variantPrefix.replace(/:$/, '') || '(なし)';
460
+ const wantedState = variantState(variantPrefix);
461
+ if (wantedState) {
462
+ const matched = candidates.filter((token) => tokenState(token) === wantedState);
463
+ if (matched.length === 1) {
464
+ return wrap({ classification: MIGRATION_CLASS.AUTO, replacement: withVariant(matched[0]) });
465
+ }
466
+ if (matched.length > 1) {
467
+ // 状態で絞ってもなお複数残る場合。絞り込み前の candidates を出すと
468
+ // 状態が合わない候補まで並べてしまうので、絞った結果だけを見せる。
469
+ // en: Report the state-filtered candidates, not the unfiltered set.
470
+ return wrap({
471
+ classification: MIGRATION_CLASS.REVIEW,
472
+ candidates: matched,
473
+ reason:
474
+ `variant \`${variantName}:\` で状態を絞っても移行先が一意に決まりません(${matched.join(' / ')})。` +
475
+ '用途を確認して選んでください。',
476
+ });
477
+ }
478
+ if (matched.length === 0 && candidates.some((token) => tokenState(token) !== null)) {
479
+ // `hover:bg-primary-600` のように「variant は hover なのにレベルは enabled 相当」なケース。
480
+ // 機械的に置換すると hover 時の色が変わってしまうので必ず人間に返す。
481
+ return wrap({
482
+ classification: MIGRATION_CLASS.REVIEW,
483
+ candidates,
484
+ reason:
485
+ `variant は \`${variantName}:\` ですが、${rawFamily}-${level} が対応するのは ` +
486
+ `${candidates.join(' / ')} で状態が一致しません。意図した状態のトークンを選んでください。`,
487
+ });
488
+ }
489
+ }
490
+
491
+ if (candidates.length === 1) {
492
+ const token = candidates[0];
493
+ const state = tokenState(token);
494
+ if (state && state !== 'enabled' && !wantedState) {
495
+ // 状態つきトークンなのに variant が無い=どの状態で使っているか判断できない
496
+ return wrap({
497
+ classification: MIGRATION_CLASS.REVIEW,
498
+ candidates,
499
+ reason:
500
+ `${rawFamily}-${level} の移行先 \`${token}\` は状態を内包しています。` +
501
+ `variant 接頭辞が無いため、どの状態で使っているかコードを見て判断してください。`,
502
+ });
503
+ }
504
+ return wrap({ classification: MIGRATION_CLASS.AUTO, replacement: withVariant(token) });
505
+ }
506
+
507
+ return wrap({
508
+ classification: MIGRATION_CLASS.REVIEW,
509
+ candidates,
510
+ reason:
511
+ `${rawFamily}-${level} の移行先が一意に決まりません(${candidates.join(' / ')})。` +
512
+ 'variant 接頭辞が無いか、強度違いで同じレベルを共有しているためです。',
513
+ });
514
+ }
515
+
516
+ // --- check ルール用のパターン生成 -------------------------------------------
517
+
518
+ // Tailwind のクラス名は `-` で連結されるため `\b` だと `border-ring-normal` の
519
+ // 中の `ring-normal` にもマッチしてしまう。`[\w-]` を境界にして誤検出を防ぐ。
520
+ // en: Use `[\w-]` boundaries — `\b` would match `ring-normal` inside
521
+ // `border-ring-normal` and double-report.
522
+ const NOT_CLASS_CHAR_BEFORE = '(?<![\\w-])';
523
+ const NOT_CLASS_CHAR_AFTER = '(?![\\w-])';
524
+
525
+ // 空配列を `join('|')` すると空文字になり、`(?:)` が**あらゆる位置の空文字に
526
+ // マッチする**正規表現ができてしまう(ルールが死ぬのではなく、全ファイルが
527
+ // 誤検出で埋まる)。beta 終了時に DEPRECATED_VAR_ALIASES / LEGACY_TOKEN_MAP を
528
+ // 空にする計画があるため、そのときに踏む地雷を今のうちに潰しておく。
529
+ // en: `join('|')` on an empty list yields `(?:)`, which matches the empty string
530
+ // everywhere. Emptying these tables is planned for the end of beta, so guard now.
531
+ function alternation(values) {
532
+ if (values.length === 0) {
533
+ throw new Error(
534
+ 'alternation() に空配列が渡されました。空の選択肢は「全位置にマッチする正規表現」になるため許容しません。' +
535
+ '対象のテーブルが空になったなら、対応するルール自体を削除してください。'
536
+ );
537
+ }
538
+ // 長いものを先に並べて、部分一致で短い方が先に食われないようにする
539
+ return [...values].sort((a, b) => b.length - a.length).join('|');
540
+ }
541
+
542
+ // Tailwind の variant 接頭辞チェーン(`hover:` / `focus-visible:` /
543
+ // `group-hover:` / `data-[state=open]:` …)をマッチに含める。
544
+ //
545
+ // これが無いと `hover:bg-primary-700` のマッチ文字列が `bg-primary-700` だけに
546
+ // なり、状態を内包する新トークンへの移行先が「variant が無いので判断不能」と
547
+ // 誤って要判断に落ちる。実コードでは状態つきの旧クラスはほぼ必ず variant と
548
+ // セットで書かれているため、ここを取りこぼすと自動変換可のものが大量に
549
+ // 要判断へ流れてしまう。
550
+ // en: Include Tailwind's variant chain in the match. Without it,
551
+ // `hover:bg-primary-700` matches as bare `bg-primary-700` and gets
552
+ // misclassified as "needs review" for lack of a variant — which is most of
553
+ // the state-bearing legacy classes in real code.
554
+ const VARIANT_CHAIN = '(?:[a-z][a-z0-9-]*(?:\\[[^\\]\\s]*\\])?:)*';
555
+
556
+ // opacity modifier(`/50` `/[0.6]`)と important(末尾 `!`)もマッチに含める。
557
+ // 含めないと `bg-primary-600/50` が `bg-primary-600` として拾われ、案内する
558
+ // 置換先から `/50` が消えて不透明度が黙って変わる(MODIFIER_SUFFIX_PATTERN
559
+ // のコメント参照)。
560
+ // en: Include the opacity modifier / important flag in the match so they can be
561
+ // carried over to the replacement instead of being silently dropped.
562
+ const MODIFIER_SUFFIX = `(?:\\/${MODIFIER_BODY})?!?`;
563
+ // Tailwind v3 形式の先頭 `!`(`!bg-primary-600`)。v4 は末尾 `!` だが、
564
+ // 移行対象のコードベースには v3 記法が残っていることがある。
565
+ const IMPORTANT_PREFIX = '!?';
566
+
567
+ /** `bg-primary-500` / `hover:bg-primary-700/50` のような「意味名 + スケール値」形式を検出する */
568
+ export function buildLegacyScalePattern() {
569
+ const prefixes = alternation(Object.keys(UTILITY_PREFIX_TO_CATEGORY));
570
+ const families = alternation(LEGACY_FAMILIES);
571
+ const levels = alternation(LEGACY_LEVELS);
572
+ return new RegExp(
573
+ `${NOT_CLASS_CHAR_BEFORE}${VARIANT_CHAIN}${IMPORTANT_PREFIX}(?:${prefixes})-(?:${families})-(?:${levels})${NOT_CLASS_CHAR_AFTER}${MODIFIER_SUFFIX}`,
574
+ 'g'
575
+ );
576
+ }
577
+
578
+ /** `text-text-high` / `bg-divider-low` のようなスケール無し旧トークンを検出する正規表現 */
579
+ export function buildLegacyTokenPattern() {
580
+ const classNames = LEGACY_TOKEN_MAP.flatMap((entry) =>
581
+ entry.utilities.map((prefix) => `${prefix}-${entry.from}`)
582
+ );
583
+ return new RegExp(
584
+ `${NOT_CLASS_CHAR_BEFORE}${VARIANT_CHAIN}${IMPORTANT_PREFIX}(?:${alternation(classNames)})${NOT_CLASS_CHAR_AFTER}${MODIFIER_SUFFIX}`,
585
+ 'g'
586
+ );
587
+ }
588
+
589
+ /**
590
+ * `var(--color-ring-normal)` のような **CSS 変数の直接参照**を検出する正規表現。
591
+ *
592
+ * 実際のコードでは `ring-[var(--color-ring-normal)]` のように arbitrary value
593
+ * 経由で参照されることが多く(sparkle-design だけで 14 箇所)、ユーティリティ
594
+ * クラスのパターンだけでは 1 件も拾えない。
595
+ * en: Real code references these through arbitrary values, so the utility-class
596
+ * pattern alone would miss every occurrence.
597
+ */
598
+ export function buildLegacyCssVarPattern() {
599
+ const standalone = LEGACY_TOKEN_MAP.map((entry) => `--color-${entry.from}`);
600
+ const scaled = LEGACY_FAMILIES.flatMap((family) =>
601
+ LEGACY_LEVELS.map((level) => `--color-${family}-${level}`)
602
+ );
603
+ return new RegExp(
604
+ `${NOT_CLASS_CHAR_BEFORE}(?:${alternation([...standalone, ...scaled])})${NOT_CLASS_CHAR_AFTER}`,
605
+ 'g'
606
+ );
607
+ }
608
+
609
+ // --- Tailwind 既定パレット -> Sparkle セマンティックトークン ------------------
610
+
611
+ /**
612
+ * Tailwind 既定パレットのファミリー → Sparkle の意味ファミリー。
613
+ *
614
+ * Sparkle のプリミティブは Tailwind の同名変数をそのまま参照している
615
+ * (`--color-neutral-600: var(--color-gray-600)` / `--color-negative-600:
616
+ * var(--color-red-600)` など)。そのため `gray` / `red` / `green` / `yellow` /
617
+ * `blue` の 5 系統は**値が完全に一致する**ので、置き換えても見た目が変わらない。
618
+ * それ以外(`slate` / `zinc` / `emerald` …)は近いだけで色味が変わるため、
619
+ * 自動変換可とは扱わず必ず要判断にする。
620
+ *
621
+ * - `family`: 対応する旧セマンティックファミリー。`null` は対応する意味が無い
622
+ * - `exact`: Sparkle のプリミティブが同じ値を指しているか
623
+ * - `ambiguousWith`: 用途上もう一方の意味も有力な場合(ブランド色かどうかは
624
+ * コードからは判断できないため、候補を両方出して人に選ばせる)
625
+ *
626
+ * `neutral` は**意図的に載せていない**。Tailwind の既定パレットにも Sparkle の
627
+ * 旧セマンティック層にも同名のファミリーがあり、`text-neutral-500` は
628
+ * `legacy-color-token` がすでに検出する。ここにも載せると同じ箇所が 2 回報告される。
629
+ * en: `neutral` is deliberately absent — it collides with Sparkle's own legacy
630
+ * family name and is already covered by `legacy-color-token`.
631
+ *
632
+ * `gray` を `neutral` ではなく `base` に寄せているのは、`base` が「gray と同値」
633
+ * かつ surface では Figma 専用の `surface/base/*`(ページ地)が優先されるため。
634
+ * text / border / object には `base` が無いので `neutral` に自動フォールバックする
635
+ * (`familyScaleMap` 参照)。
636
+ */
637
+ export const TAILWIND_PALETTE_FAMILIES = {
638
+ // 値まで一致する系統
639
+ gray: { family: 'base', exact: true },
640
+ red: { family: 'negative', exact: true },
641
+ green: { family: 'success', exact: true },
642
+ yellow: { family: 'warning', exact: true },
643
+ blue: { family: 'info', exact: true, ambiguousWith: 'primary' },
644
+ // 意味は同じだが色味が変わる系統
645
+ slate: { family: 'base', exact: false },
646
+ zinc: { family: 'base', exact: false },
647
+ stone: { family: 'base', exact: false },
648
+ rose: { family: 'negative', exact: false },
649
+ emerald: { family: 'success', exact: false },
650
+ teal: { family: 'success', exact: false },
651
+ lime: { family: 'success', exact: false },
652
+ amber: { family: 'warning', exact: false },
653
+ orange: { family: 'warning', exact: false },
654
+ sky: { family: 'info', exact: false, ambiguousWith: 'primary' },
655
+ cyan: { family: 'info', exact: false, ambiguousWith: 'primary' },
656
+ indigo: { family: 'info', exact: false, ambiguousWith: 'primary' },
657
+ // 対応する意味が無い系統
658
+ violet: { family: null },
659
+ purple: { family: null },
660
+ fuchsia: { family: null },
661
+ pink: { family: null },
662
+ };
663
+
664
+ /**
665
+ * Tailwind v4 のパレットは 950 まである。Sparkle の旧セマンティック層は 900 止まり
666
+ * なので、950 は機械的な対応先が無い(要判断として報告する)。
667
+ */
668
+ const TAILWIND_ONLY_LEVELS = ['950'];
669
+ const TAILWIND_PALETTE_LEVELS = [...LEGACY_LEVELS, ...TAILWIND_ONLY_LEVELS];
670
+
671
+ /**
672
+ * `bg-gray-50` / `hover:text-red-600/50` のような Tailwind 既定パレットの
673
+ * 色ユーティリティを検出する正規表現。
674
+ *
675
+ * `legacy-color-token` の `buildLegacyScalePattern` と同じ境界・variant・modifier
676
+ * の扱いを共有する(`\b` ではなく `[\w-]` 境界を使う理由は同関数のコメント参照)。
677
+ */
678
+ export function buildTailwindPalettePattern() {
679
+ const prefixes = alternation(Object.keys(UTILITY_PREFIX_TO_CATEGORY));
680
+ const families = alternation(Object.keys(TAILWIND_PALETTE_FAMILIES));
681
+ const levels = alternation(TAILWIND_PALETTE_LEVELS);
682
+ return new RegExp(
683
+ `${NOT_CLASS_CHAR_BEFORE}${VARIANT_CHAIN}${IMPORTANT_PREFIX}(?:${prefixes})-(?:${families})-(?:${levels})${NOT_CLASS_CHAR_AFTER}${MODIFIER_SUFFIX}`,
684
+ 'g'
685
+ );
686
+ }
687
+
688
+ /**
689
+ * Tailwind 既定パレットのユーティリティ 1 個を解析して移行結果を返す。
690
+ *
691
+ * 旧セマンティックファミリーに読み替えてから `resolveLegacyUtility` に委譲する。
692
+ * 対応表・状態の絞り込み・opacity modifier の持ち回りを二重に実装せずに済み、
693
+ * `legacy-color-token` と同じ移行先を必ず案内できる。
694
+ *
695
+ * ただし classification は**そのまま通さない**。
696
+ * - 値が一致しない系統(`slate` など)は見た目が変わるので必ず `review`
697
+ * - `info` / `primary` のどちらとも読める系統(`blue` など)は、ブランド色かどうかが
698
+ * コードから判断できないので候補を両方出して `review`
699
+ *
700
+ * en: Rewrite the Tailwind family to Sparkle's legacy family and delegate, so the
701
+ * mapping table, state narrowing and modifier handling are never duplicated.
702
+ * Classification is downgraded to `review` whenever the swap is not value-preserving
703
+ * or the semantic (info vs primary) cannot be decided from the code.
704
+ *
705
+ * @param {string} utility 例 `bg-gray-50` / `hover:text-red-600/50`
706
+ * @returns {null | { input: string, classification: string, replacement?: string, candidates?: string[], reason?: string }}
707
+ */
708
+ export function resolveTailwindPaletteUtility(utility) {
709
+ if (typeof utility !== 'string' || utility.length === 0) return null;
710
+
711
+ const lastColon = utility.lastIndexOf(':');
712
+ const variantPrefix = lastColon === -1 ? '' : utility.slice(0, lastColon + 1);
713
+ let rawBare = lastColon === -1 ? utility : utility.slice(lastColon + 1);
714
+
715
+ const importantPrefix = rawBare.startsWith('!') ? '!' : '';
716
+ if (importantPrefix) rawBare = rawBare.slice(1);
717
+
718
+ const [, bare, modifier] = MODIFIER_SUFFIX_PATTERN.exec(rawBare);
719
+
720
+ const dashIndex = bare.indexOf('-');
721
+ if (dashIndex === -1) return null;
722
+ const prefix = bare.slice(0, dashIndex);
723
+ const rest = bare.slice(dashIndex + 1);
724
+ if (!UTILITY_PREFIX_TO_CATEGORY[prefix]) return null;
725
+
726
+ const scaleMatch = /^([a-z]+)-(\d{2,3})$/.exec(rest);
727
+ if (!scaleMatch) return null;
728
+ const [, paletteFamily, level] = scaleMatch;
729
+
730
+ const mapping = TAILWIND_PALETTE_FAMILIES[paletteFamily];
731
+ if (!mapping || !TAILWIND_PALETTE_LEVELS.includes(level)) return null;
732
+
733
+ const wrap = (result) => ({ input: utility, ...result });
734
+
735
+ if (!mapping.family) {
736
+ return wrap({
737
+ classification: MIGRATION_CLASS.REVIEW,
738
+ reason:
739
+ `${paletteFamily} に対応する意味のトークンが Sparkle にありません。` +
740
+ 'Figma の該当箇所を見て用途(surface / text / border / object)から選び直してください。' +
741
+ '装飾目的の面であれば surface-accent-1 / 2 / 3 が候補になります。',
742
+ });
743
+ }
744
+
745
+ if (TAILWIND_ONLY_LEVELS.includes(level)) {
746
+ return wrap({
747
+ classification: MIGRATION_CLASS.REVIEW,
748
+ reason:
749
+ `Tailwind の ${level} に対応するレベルが Sparkle にありません(Sparkle は 900 まで)。` +
750
+ `${paletteFamily}-900 相当で足りるかを Figma で確認してください。`,
751
+ });
752
+ }
753
+
754
+ const delegate = (family) =>
755
+ resolveLegacyUtility(
756
+ `${variantPrefix}${importantPrefix}${prefix}-${family}-${level}${modifier}`
757
+ );
758
+
759
+ const primary = delegate(mapping.family);
760
+ if (!primary) return null;
761
+
762
+ // 用途上どちらの意味とも読める系統は、候補を両方並べて人に選ばせる。
763
+ // en: Surface both semantics when the code cannot tell brand colour from status.
764
+ if (mapping.ambiguousWith) {
765
+ const sides = [
766
+ { family: mapping.family, result: primary },
767
+ { family: mapping.ambiguousWith, result: delegate(mapping.ambiguousWith) },
768
+ ];
769
+ // 片側に該当トークンが無いことがある(例: surface の info には 600 が無い)。
770
+ // 候補を並べただけでは「なぜ片方しか出ないのか」が分からず、AI が
771
+ // 残った候補を唯一の正解と誤解するので、欠けている側を明示する。
772
+ // en: One side can have no matching token; say so, or the remaining
773
+ // candidate reads as the single correct answer.
774
+ const missing = sides.filter((side) => toCandidateList(side.result).length === 0);
775
+ return wrap({
776
+ classification: MIGRATION_CLASS.REVIEW,
777
+ candidates: sides.flatMap((side) => toCandidateList(side.result)),
778
+ reason:
779
+ `${paletteFamily} は「${mapping.family}(状態・意味を表す色)」とも「${mapping.ambiguousWith}(ブランド色)」とも読めます。` +
780
+ 'どちらの用途かはコードから判断できないため、Figma の該当箇所を見て選んでください。' +
781
+ (missing.length > 0
782
+ ? `${missing.map((side) => side.family).join(' / ')} 側には対応するトークンがありません。`
783
+ : '') +
784
+ (mapping.exact ? '' : `なお ${paletteFamily} と Sparkle の色は完全には一致しません。`),
785
+ });
786
+ }
787
+
788
+ if (!mapping.exact) {
789
+ return wrap({
790
+ classification: MIGRATION_CLASS.REVIEW,
791
+ candidates: toCandidateList(primary),
792
+ reason:
793
+ `${paletteFamily} は Sparkle の ${mapping.family} に意味は対応しますが、色が完全には一致しません(置き換えると見た目が変わります)。` +
794
+ 'Figma の該当箇所で意図した色かを確認してください。' +
795
+ (primary.reason ? ` ${primary.reason}` : ''),
796
+ });
797
+ }
798
+
799
+ // 値が一致する系統は、旧トークンからの移行と同じ確度で案内できる。
800
+ // en: Value-identical families keep the delegated classification as-is.
801
+ return wrap({
802
+ classification: primary.classification,
803
+ replacement: primary.replacement,
804
+ candidates: primary.candidates,
805
+ reason: primary.reason,
806
+ });
807
+ }
808
+
809
+ /**
810
+ * `resolveLegacyUtility` の戻り値を候補リストに正規化する。
811
+ * en: Flatten a delegated result into a candidate list.
812
+ */
813
+ function toCandidateList(result) {
814
+ if (!result) return [];
815
+ if (result.replacement) return [result.replacement];
816
+ return result.candidates ?? [];
817
+ }
818
+
819
+ /**
820
+ * CSS 変数名 1 個を解析して移行結果を返す。
821
+ *
822
+ * スケール付きの旧トークン(`--color-primary-600`)は、変数名だけでは用途
823
+ * (surface / text / border / object)が分からないため必ず `review` になる。
824
+ * どの用途で使っているかは参照側のコードを見ないと決まらない。
825
+ *
826
+ * @param {string} varName 例 `--color-ring-normal`
827
+ * @returns {null | { input: string, classification: string, replacement?: string, candidates?: string[], reason?: string }}
828
+ */
829
+ export function resolveLegacyCssVar(varName) {
830
+ if (typeof varName !== 'string') return null;
831
+ const alias = DEPRECATED_VAR_ALIASES.find((entry) => entry.from === varName);
832
+ if (alias) {
833
+ return {
834
+ input: varName,
835
+ classification: MIGRATION_CLASS.AUTO,
836
+ replacement: alias.to,
837
+ reason: alias.reason,
838
+ };
839
+ }
840
+
841
+ if (!varName.startsWith('--color-')) return null;
842
+ const tokenName = varName.slice('--color-'.length);
843
+
844
+ const direct = LEGACY_TOKEN_MAP.find((entry) => entry.from === tokenName);
845
+ if (direct) {
846
+ if (!direct.to) {
847
+ return { input: varName, classification: MIGRATION_CLASS.REVIEW, reason: direct.reason };
848
+ }
849
+ return {
850
+ input: varName,
851
+ classification: direct.classification ?? MIGRATION_CLASS.AUTO,
852
+ replacement: `--color-${direct.to}`,
853
+ reason: direct.reason,
854
+ };
855
+ }
856
+
857
+ const scaleMatch = /^([a-z]+)-(\d{2,3})$/.exec(tokenName);
858
+ if (!scaleMatch) return null;
859
+ const rawFamily = scaleMatch[1];
860
+ const level = scaleMatch[2];
861
+ if (!LEGACY_FAMILIES.includes(rawFamily) || !LEGACY_LEVELS.includes(level)) return null;
862
+
863
+ const family = LEGACY_FAMILY_ALIASES[rawFamily] ?? rawFamily;
864
+ const candidates = Object.keys(SCALE_MAP)
865
+ .flatMap((category) => familyScaleMap(category, family)?.[level] ?? [])
866
+ .map((token) => `--color-${token}`);
867
+
868
+ return {
869
+ input: varName,
870
+ classification: MIGRATION_CLASS.REVIEW,
871
+ candidates,
872
+ reason:
873
+ candidates.length > 0
874
+ ? `変数名だけでは用途(surface / text / border / object)が決まらないため自動変換しません。用途に応じて選んでください。`
875
+ : `${rawFamily}-${level} に対応する新トークンがありません。Figma の該当箇所を見て選び直してください。`,
876
+ };
877
+ }
878
+
879
+ /** `--radius-halfModal` のような削除予定エイリアスへの直接参照を検出する正規表現 */
880
+ export function buildDeprecatedAliasPattern() {
881
+ const names = DEPRECATED_VAR_ALIASES.map((entry) => entry.from);
882
+ return new RegExp(
883
+ `${NOT_CLASS_CHAR_BEFORE}(?:${alternation(names)})${NOT_CLASS_CHAR_AFTER}`,
884
+ 'g'
885
+ );
886
+ }
887
+
888
+ export { SCALE_MAP, familyScaleMap };