sparkle-design-cli 2.4.1 → 2.5.0-beta.1

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,122 @@
1
+ # 複数のテーマ配色を 1 デプロイでサポートしたい場合
2
+
3
+ 管理画面と一般ユーザー画面で配色を出し分けたい、テナントごとに固定の配色バリアントを切り替えたいなど、「少数(数種類程度)の固定配色を 1 つのデプロイでサポートする」ケースの推奨パターンです。**「画面ごとに読み込む CSS を切り替える」場合と「1 つの画面内でランタイムに切り替える」場合とで推奨パターンが異なる**ので、まずどちらのケースかを判断してください。
4
+
5
+ ## ケース A: 画面(ビルド)ごとにバリアントが固定される場合 → config を分割して複数の CSS を生成する
6
+
7
+ 管理画面 / 一般ユーザー画面でレイアウトごと entry CSS を分けられる、role や tenant に応じて import する CSS を切り替えられる、など「1 画面につき常に 1 バリアントだけを読み込む」運用ができる場合の推奨パターンです。
8
+
9
+ `generate` は `-c/--config` と `-o/--output` を組み合わせることで、任意の config から任意の出力先へ CSS を生成できます。バリアントの数だけ config ファイルを用意し、バリアントの数だけ `generate` を実行してください(1 config に複数テーマをまとめて生成する機能はありませんが、CI やスクリプトで複数回呼び出せば十分に運用できます)。
10
+
11
+ `config/sparkle.admin.json`:
12
+
13
+ ```json
14
+ {
15
+ "primary": "blue",
16
+ "font-pro": "Inter",
17
+ "font-mono": "JetBrains Mono",
18
+ "radius": "md"
19
+ }
20
+ ```
21
+
22
+ `config/sparkle.employee.json`:
23
+
24
+ ```json
25
+ {
26
+ "primary": "green",
27
+ "font-pro": "Inter",
28
+ "font-mono": "JetBrains Mono",
29
+ "radius": "md"
30
+ }
31
+ ```
32
+
33
+ ```bash
34
+ npx sparkle-design-cli generate -c ./config/sparkle.admin.json -o ./src/styles/sparkle-admin.css
35
+ npx sparkle-design-cli generate -c ./config/sparkle.employee.json -o ./src/styles/sparkle-employee.css
36
+ ```
37
+
38
+ あとは consumer 側で、表示するバリアントに応じてどちらの CSS を読み込むかを切り替えるだけです(例: role / tenant を判定できるレイアウト単位で `import "./sparkle-admin.css"` と `import "./sparkle-employee.css"` を出し分ける、Next.js のルートグループごとに読み込む entry CSS を分ける、など)。
39
+
40
+ **重要: この 2 つの CSS を同時に import してはいけません。** これはファイルサイズ最適化の話ではなく **correctness 上のハード制約** です。`generate` が出力する CSS は `@theme inline { ... }` という Tailwind v4 の at-rule を含みますが、Tailwind の仕様上 `@theme`(`@theme inline` を含む)は **stylesheet の top-level にしか置けず、かつ 1 種類のトークン定義として扱われます**。2 つの `generate` 出力を同時 import すると、両方の `@theme inline` が同じ top-level スコープで衝突し、後に読み込んだ方の値で utility class(`bg-primary-500` 等)の意味がビルド時に固定されてしまいます(`data-*` 属性の付け外しのようなランタイム操作では変わりません)。**1 画面につき常に 1 バリアントだけを読み込む**構成でのみ、この方式は安全に使えます。
41
+
42
+ 「1 つの画面内で属性の付け外しだけでテーマを切り替えたい(ページ遷移や再ビルドを伴わない)」場合は、このケース A ではなく次のケース B を使ってください。
43
+
44
+ ## ケース B: 単一バンドル内でランタイムに切り替えたい場合 → `generate --scope`
45
+
46
+ SPA の 1 つの JS バンドル内で、`<html data-tenant-theme="admin">` のような属性の付け外しだけで配色を切り替えたい(ページ遷移・再デプロイを伴わない)場合のパターンです。ケース A の「config 分割 + 複数 `generate` の CSS を同時 import する」は、上記の理由(`@theme inline` の衝突)でこのケースには使えません。
47
+
48
+ Tailwind v4 では、この種のランタイム切り替え(dark mode 等)を公式に次のパターンでサポートしています([Tailwind公式ドキュメント](https://tailwindcss.com/docs/colors#using-css-variables)より):
49
+
50
+ ```css
51
+ @import 'tailwindcss';
52
+
53
+ :root {
54
+ --acme-canvas-color: oklch(0.967 0.003 264.542);
55
+ }
56
+
57
+ [data-theme='dark'] {
58
+ --acme-canvas-color: oklch(0.21 0.034 264.665);
59
+ }
60
+
61
+ @theme inline {
62
+ --color-canvas: var(--acme-canvas-color);
63
+ }
64
+ ```
65
+
66
+ ポイントは、`@theme inline` は top-level に **1 つだけ** 置いたまま、実際に切り替えたい値は `@theme` を使わない普通の `:root` / `[data-attr]` スコープ付きセレクタで **参照先の CSS 変数** を上書きする、という構成です。`generate --scope` はこの「参照先を差し替える」役割を、`sparkle.config.json` の解決ロジックを再利用しながら自動生成します。
67
+
68
+ ```bash
69
+ # ベース(デフォルト表示)となるバリアントは今まで通り通常の generate
70
+ npx sparkle-design-cli generate -c ./config/sparkle.employee.json -o ./src/styles/sparkle-employee.css
71
+
72
+ # 追加バリアントは --scope で「セマンティックトークンだけをセレクタでラップした差分 CSS」を生成する
73
+ npx sparkle-design-cli generate -c ./config/sparkle.admin.json \
74
+ --scope '[data-tenant-theme="admin"]' \
75
+ -o ./src/styles/sparkle-admin-scope.css
76
+ ```
77
+
78
+ ```css
79
+ /* entry CSS: 両方を同時に import してよい */
80
+ @import 'tailwindcss';
81
+ @import './sparkle-employee.css'; /* @theme inline を含む通常の generate 出力(ベース) */
82
+ @import './sparkle-admin-scope.css'; /* --scope の出力。@theme inline は含まない */
83
+ ```
84
+
85
+ ```html
86
+ <!-- ランタイムでこの属性を付け外しするだけで配色が切り替わる。ページ遷移・再ビルド不要 -->
87
+ <html data-tenant-theme="admin"></html>
88
+ ```
89
+
90
+ `--scope` の出力は次の性質を持ちます:
91
+
92
+ - `@theme inline` には一切触れない(ベース側の `generate` 出力にある 1 つだけがそのまま有効であり続ける)ので、ケース A のような衝突は起きません。
93
+ - `--color-primary-*` 等の **セマンティックトークン** を、admin config の解決結果に基づいて実値(`oklch(...)`)までリテラル化した上で `--scope` のセレクタの中に閉じ込めます。プリミティブ(`--color-blue-*` 等)ではなくセマンティックトークンを上書き対象にしているのがポイントで、デフォルトでは `primary` と `info` が同じプリミティブ(例: `blue`)を共有しているため、もしプリミティブ側を上書きしてしまうと `primary` を変えたつもりが `info` まで巻き込んで変わってしまいます。`--scope` はこの巻き込みが起きないよう、セマンティック層で解決してから出力します。
94
+ - `--radius-*` / `--shadow-*` は普遍的な共有スケール値(テナント間で衝突しない)なので `var()` 参照のまま出力されます。`config.radius` がバリアントごとに異なれば、その解決結果もちゃんと反映されます。
95
+ - フォント import・`@source`・SparkleHead.tsx・globals.css パッチ等、ドキュメント全体に関わる**出力**は行いません。これらはベース側の通常 `generate` が既に担っているため、バリアントごとに重複させる必要はありません。ただし `font-pro` / `font-mono` 等の **validation 自体**は通常の `generate` と同様に適用されます(出力しないだけで、config の妥当性チェックは変わりません)。
96
+ - `-o/--output` の指定が必須です(既定の `sparkle-design.css` を誤って上書きしないため)。`--strict` / `--globals-path` とは併用できません(`--scope` はグローバル CSS のパッチを行わないため)。
97
+ - 現状 `primary` は 7 色パレットからの選択のみ対応しています。7 色にないカスタムブランドカラーを `--scope` の変数だけで表現したい場合は、`extend.custom-css` の要領で手動でセレクタ配下に `--color-primary-*` を定義してください(`--scope` の出力とマージして使えます)。
98
+ - **v2.4.1 以降**: ベース側 `generate` が出す `@theme inline` の各宣言は、プリミティブへの直接参照ではなく同名のセマンティック変数への自己参照(例: `--color-primary-500: var(--color-primary-500)`)になっています。Tailwind v4 は `@theme inline` の宣言右辺をそのまま compiled utility class にインライン展開する仕様のため、これが無いと `.bg-primary-500` 等の compiled utility は最初からプリミティブ変数だけを参照し、`--scope` がセマンティック変数だけを上書きしても utility class には一切反映されません(v2.4.0 はこの自己参照が無く、`--scope` の上書きが compiled utility に効かない状態でした)。v2.4.1 以降を使ってください。
99
+
100
+ このパターンは、次の「非推奨: 属性スコープでの Tailwind クラス上書き」が抱えていた脆さも同時に解消します。`--scope` が上書きするのは Sparkle が公開しているトークン契約(`--color-primary-*` 等の CSS 変数)だけであり、ユーティリティクラス名やコンポーネント内部の specificity には一切依存しません。そのため、Sparkle 側のコンポーネント実装(クラス名の付け方や compound attribute selector 等)が変わっても、`--scope` の出力が壊れることはありません。
101
+
102
+ ## 非推奨: 単一 CSS + 属性スコープでの Tailwind クラス上書き
103
+
104
+ `sparkle-design.css` を 1 本のまま、`[data-theme="xxx"] .bg-primary-500 { ... }` のような属性セレクタで Tailwind ユーティリティクラスを個別に上書きする方式は **推奨しません**。
105
+
106
+ - コンポーネント側の実装詳細(クラス名の付け方や CSS の specificity)に依存するため、Sparkle 側の内部実装(例: 状態別カラーリングで compound attribute selector を使っているコンポーネント)が変わると、`!important` を使っても上書きが効かなくなることがあります。上書き対象は Sparkle が公開している契約(config / トークン)ではなく非公開の実装詳細なので、バージョンアップのたびに壊れるリスクを consumer 側が抱え続けることになります。
107
+ - 上書きが必要なクラス/コンポーネントが増えるたびに、手動でセレクタを足していく必要があり、考慮漏れに気づきにくいです。
108
+
109
+ 上記のリスクを踏まえると、テーマ切り替えは「ケース A: config 分割」または「ケース B: `generate --scope`」+ `extend.custom-css` によるトークンレベルの上書き(cascade 順で確実に勝つ)で完結させる方が、Sparkle のバージョンアップに対しても壊れにくく、見通しの良い実装になります。
110
+
111
+ ## トレードオフ
112
+
113
+ | 観点 | ケース A: config 分割(推奨・画面ごと固定) | ケース B: `generate --scope`(推奨・単一バンドルでランタイム切替) | 属性スコープ上書き(非推奨) |
114
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
115
+ | 適用シーン | 1 画面につき常に 1 バリアントだけを読み込める | 1 バンドル内で属性の付け外しだけで切り替えたい | (非推奨のため参考情報) |
116
+ | ファイルサイズ | バリアントの数だけ CSS が増える(各 CSS は 7 色パレット全体を含む)。1 画面 1 バリアントのみ読み込む運用なら実質的な増分は小さい | ベース CSS 1 本 + バリアントごとの軽量な差分 CSS(セマンティックトークンのみ) | 単一 CSS + 上書き分の差分のみで増分は小さい |
117
+ | FOUC リスク | サーバー側 / ビルド時にバリアントを決定できるならほぼ無し | 属性付与のタイミング次第でリスクがある(初期描画後に属性が付くと一瞬デフォルト配色が見える) | クライアント側で属性を付与するタイミング次第でリスクがある |
118
+ | 保守性 | Sparkle が公開している config / トークンだけに依存するため、コンポーネント内部実装の変更に影響されにくい | 同左(トークンだけに依存し、ユーティリティクラス名や specificity に依存しない) | コンポーネントの内部実装(クラス名・specificity)に依存するため、Sparkle 側の変更で上書きが効かなくなるリスクがある |
119
+
120
+ ---
121
+
122
+ [← README に戻る](../README.md)(docs/theming.md)
@@ -1,6 +1,94 @@
1
+ import {
2
+ MIGRATION_CLASS,
3
+ buildDeprecatedAliasPattern,
4
+ buildLegacyCssVarPattern,
5
+ buildLegacyScalePattern,
6
+ buildLegacyTokenPattern,
7
+ resolveLegacyCssVar,
8
+ resolveLegacyUtility,
9
+ } from './token-migration.js';
10
+ import { buildSpacingUtilityPattern, pxToStep, resolveSpacingStep } from './spacing-scale.js';
11
+
1
12
  const COMMON_INTRO =
2
13
  '以下は頻繁に発生する誤用パターンです。コンポーネントが提供する専用 props・サブコンポーネントを使ってください。';
3
14
 
15
+ /**
16
+ * ルールの「強さ」。AI がすべてのルールを同じ重みで扱ってしまい、全部を過剰に
17
+ * 守るか全部を読み流すかの両極端に振れるのを防ぐために付ける(issue #74)。
18
+ *
19
+ * - `error` … 例外なし。`--strict` / stop-hook はこれだけを失敗として扱う
20
+ * - `warning` … 原則ダメだが理由があれば例外可。報告はするが exit code は変えない
21
+ * - `info` … 参考。従わなくてもよい選択肢
22
+ *
23
+ * 既存ルールはすべて `error` 相当(`--strict` で落ちる)だったため、severity 未指定は
24
+ * `error` にフォールバックする。これで**プラグイン rule を含め既存の挙動が一切変わらない**。
25
+ * en: Unspecified severity falls back to `error`, so existing built-in and plugin
26
+ * rules keep their current pass/fail behaviour exactly.
27
+ */
28
+ export const SEVERITY = {
29
+ ERROR: 'error',
30
+ WARNING: 'warning',
31
+ INFO: 'info',
32
+ };
33
+
34
+ export const DEFAULT_SEVERITY = SEVERITY.ERROR;
35
+
36
+ /**
37
+ * ルールを当てるファイル種別。
38
+ *
39
+ * 既定は `source`(`.js` / `.jsx` / `.ts` / `.tsx`)で、severity 導入前の
40
+ * 挙動と同じ。組み込み・プラグインを問わず既存ルールは JSX 前提で書かれて
41
+ * いるため、既定を広げると既存 consumer に新しい findings が湧いて CI が落ちる。
42
+ *
43
+ * トークン移行ルールだけは `css` も対象にする。`--radius-halfModal` のような
44
+ * CSS 変数の直接参照は `.css` にこそ自然に書かれるうえ、消えてもビルドエラーに
45
+ * ならず角丸だけ失われる — まさにこのルールが防ごうとしている故障が、CSS では
46
+ * 検出できないという穴になっていた。
47
+ * en: Which file kinds a rule applies to. Defaults to source files (previous
48
+ * behaviour); the token-migration rules opt into `.css` as well, since CSS
49
+ * variable references live there and fail silently when the alias is removed.
50
+ */
51
+ export const RULE_TARGET = {
52
+ SOURCE: 'source',
53
+ CSS: 'css',
54
+ };
55
+
56
+ export const DEFAULT_RULE_TARGETS = [RULE_TARGET.SOURCE];
57
+ const KNOWN_RULE_TARGETS = new Set(Object.values(RULE_TARGET));
58
+
59
+ function normalizeRuleTargets(targets, ruleId) {
60
+ if (targets === undefined || targets === null) return DEFAULT_RULE_TARGETS;
61
+ const list = Array.isArray(targets) ? targets : [targets];
62
+ const unknown = list.filter((target) => !KNOWN_RULE_TARGETS.has(target));
63
+ if (unknown.length > 0 || list.length === 0) {
64
+ console.warn(
65
+ `⚠️ sparkle-design-cli: rule "${ruleId ?? '(unknown)'}" has invalid targets ${JSON.stringify(targets)} — falling back to ${JSON.stringify(DEFAULT_RULE_TARGETS)}`
66
+ );
67
+ return DEFAULT_RULE_TARGETS;
68
+ }
69
+ return list;
70
+ }
71
+
72
+ /** `--strict` / stop-hook が失敗として扱う severity */
73
+ export const BLOCKING_SEVERITIES = new Set([SEVERITY.ERROR]);
74
+
75
+ const KNOWN_SEVERITIES = new Set(Object.values(SEVERITY));
76
+
77
+ function normalizeSeverity(severity, ruleId) {
78
+ if (severity === undefined || severity === null) return DEFAULT_SEVERITY;
79
+ if (KNOWN_SEVERITIES.has(severity)) return severity;
80
+ // 既定(error)に倒すので CI は落ちる。これは意図した fail-safe —— 「強さが
81
+ // 分からないルールを黙って非ブロックにする」ほうが危険なため。ただし黙って
82
+ // 落とすとタイポに気付けないので warn を出す(loadAntiPatternPlugins の
83
+ // warn-and-skip と同じ方針)。
84
+ // en: Falling back to `error` is deliberate (fail-safe): silently downgrading
85
+ // an unknown severity to non-blocking is worse. Warn so the typo is visible.
86
+ console.warn(
87
+ `⚠️ sparkle-design-cli: rule "${ruleId ?? '(unknown)'}" has an unknown severity ${JSON.stringify(severity)} — falling back to "${DEFAULT_SEVERITY}"`
88
+ );
89
+ return DEFAULT_SEVERITY;
90
+ }
91
+
4
92
  function lines(values) {
5
93
  return values.join('\n');
6
94
  }
@@ -46,7 +134,7 @@ const BUILTIN_MANUAL_REVIEW_REMINDERS = [
46
134
  },
47
135
  ];
48
136
 
49
- const BUILTIN_ANTI_PATTERN_GROUPS = [
137
+ const COMPONENT_ANTI_PATTERN_GROUPS = [
50
138
  {
51
139
  id: 'card-description',
52
140
  featureSection: lines([
@@ -57,7 +145,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
57
145
  '<CardHeader>',
58
146
  ' <CardTitle>',
59
147
  ' プロジェクト一覧',
60
- ' <CardDescription className="character-3-regular-pro text-text-low">',
148
+ ' <CardDescription className="character-3-regular-pro text-text-neutral-low">',
61
149
  ' 全 12 件',
62
150
  ' </CardDescription>',
63
151
  ' </CardTitle>',
@@ -93,7 +181,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
93
181
  '// ✅ Correct',
94
182
  '<CardTitle>',
95
183
  ' プロジェクト一覧',
96
- ' <CardDescription className="character-3-regular-pro text-text-low">',
184
+ ' <CardDescription className="character-3-regular-pro text-text-neutral-low">',
97
185
  ' 全 12 件',
98
186
  ' </CardDescription>',
99
187
  '</CardTitle>',
@@ -297,7 +385,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
297
385
  '',
298
386
  '```tsx',
299
387
  '// ✅ Correct — Sparkle Design の typography / color token を使う',
300
- '<CardDescription className="character-3-regular-pro text-text-low">',
388
+ '<CardDescription className="character-3-regular-pro text-text-neutral-low">',
301
389
  ' 全 12 件',
302
390
  '</CardDescription>',
303
391
  '',
@@ -438,7 +526,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
438
526
  '<CardHeader>',
439
527
  ' <CardTitle>',
440
528
  ' タイトル',
441
- ' <CardDescription className="character-3-regular-pro text-text-low">',
529
+ ' <CardDescription className="character-3-regular-pro text-text-neutral-low">',
442
530
  ' 全 12 件',
443
531
  ' </CardDescription>',
444
532
  ' </CardTitle>',
@@ -940,7 +1028,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
940
1028
  '',
941
1029
  '// 例外 — character-* に対応するサイズが無い場合は arbitrary value を使うか',
942
1030
  '// suppress コメントを付けて残す',
943
- '<span className="text-[10px] text-text-low">12px 未満の極小メタ情報</span>',
1031
+ '<span className="text-[10px] text-text-neutral-low">12px 未満の極小メタ情報</span>',
944
1032
  '{/* sparkle-disable-next-line tailwind-typography */}',
945
1033
  '<span className="text-xs">どうしても text-xs で残したいケース</span>',
946
1034
  '```',
@@ -1205,6 +1293,327 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
1205
1293
  // Built-in groups always come before plugin-supplied groups in this priority order.
1206
1294
  // Plugin-supplied IDs that aren't in this list fall to the end (stable insertion order).
1207
1295
  // en: Determines `check` finding emission order so the most-important built-in rules surface first.
1296
+ // --- 新セマンティックトークンへの移行ルール(beta 期間中は warning) ----------
1297
+ //
1298
+ // 旧トークンは beta 期間中テンプレートに併存するので、使っていても即座には
1299
+ // 壊れない。ここで `error` にすると「まだ移行していないコンポーネントでは
1300
+ // 旧トークンが正」という移行期の現実と衝突して CI が通らなくなるため、beta
1301
+ // 中は `warning`(報告のみ)に留める。beta 終了で旧トークンを削除するとき
1302
+ // `error` に昇格させる(issue #74)。
1303
+ // en: Legacy tokens still exist during beta, so flag them as `warning` — an
1304
+ // `error` here would collide with the reality that un-migrated components
1305
+ // legitimately still use them. Promote to `error` when the legacy layer is
1306
+ // removed.
1307
+
1308
+ /**
1309
+ * マッチ 1 件ごとに移行先を計算して recommendation にする match 関数を作る。
1310
+ * 「代わりに何を使うか」までルールに含めないと、AI は違反を見つけても直し方を
1311
+ * 発明してしまう(issue #74 の提案3)。
1312
+ * en: Compute the migration target per occurrence — a rule that only says
1313
+ * "don't" makes the AI invent its own replacement.
1314
+ */
1315
+ /**
1316
+ * ブロックコメントの中身を同じ長さの空白に置き換える。
1317
+ *
1318
+ * コメント内の言及(「旧 `--color-ring-normal` の移行先」のような説明文)まで
1319
+ * 移行対象として報告すると、生成物やドキュメントコメントが大量の誤検出になる。
1320
+ * 中身を消すのではなく**同じ長さの空白で潰す**ことで、後段が使う index と
1321
+ * 行番号がずれない。
1322
+ *
1323
+ * **行頭(インデント可)から始まるものだけ**を対象にする。任意位置の `/*` を
1324
+ * コメント開始とみなすと、文字列リテラル内の `/*`——`matcher: ['/dashboard/*']`
1325
+ * や `content: "/*"`、glob、正規表現リテラル——を起点に、次の `*\/` までの
1326
+ * **実コードを黙って検査対象から外してしまう**。無言の false negative は、
1327
+ * 未解決の hit をわざわざ表に出しているこのモジュールの方針と正反対なので、
1328
+ * 「コメントは行頭から書く」という緩い前提を取るほうが安全側に倒れる。
1329
+ * 行末コメント(`const x = 1; /* ... *\/`)は潰さないので、そこに書かれた
1330
+ * トークン名は検出されうるが、warning 止まりで抑制コメントも使える。
1331
+ * en: Only mask block comments that start a line. Treating any `/*` as a
1332
+ * comment start would silently blank real code after a string literal such as
1333
+ * `matcher: ['/dashboard/*']` — a false negative, which is worse than the
1334
+ * false positive it avoids.
1335
+ */
1336
+ function maskBlockComments(content) {
1337
+ return content.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, (comment) => comment.replace(/[^\n]/g, ' '));
1338
+ }
1339
+
1340
+ function migrationMatcher(pattern, resolve, label) {
1341
+ return (rawContent) => {
1342
+ const content = maskBlockComments(rawContent);
1343
+ // そのファイル自身が宣言している変数への参照は「移行対象の使用」ではなく
1344
+ // 定義側の内部配線。CLI が生成した CSS(旧トークンを定義している側)を
1345
+ // 丸ごと報告してしまうのを防ぐ。
1346
+ //
1347
+ // ファイル名(`sparkle-design.css`)で除外していたが、`generate --scope` は
1348
+ // `-o` が必須で出力名が任意になるため、テナント別テーマを使うプロジェクトの
1349
+ // 生成物が素通りしていた。「定義しているファイルは、その変数について正」
1350
+ // という内容ベースの判定なら出力名に依存しない。
1351
+ // en: Skip references to variables the same file declares — those are the
1352
+ // definition side, not usage to migrate. Content-based, so it also covers
1353
+ // `generate --scope` output whose filename is arbitrary.
1354
+ // `{` / `;` 直後も宣言位置として拾う。行頭だけを見ていると、1 行にまとめた
1355
+ // CSS や minify された theme ファイルで**定義側まで「使用」として報告**する。
1356
+ // en: Also treat `{`/`;` as declaration starts so single-line/minified CSS
1357
+ // doesn't get its own definitions reported as usages.
1358
+ const declaredHere = new Set(
1359
+ [...content.matchAll(/(?:^|[{;])\s*(--[\w-]+)\s*:/gm)].map((m) => m[1])
1360
+ );
1361
+
1362
+ const hits = [];
1363
+ for (const match of content.matchAll(pattern)) {
1364
+ const text = match[0];
1365
+ if (text.startsWith('--') && declaredHere.has(text)) continue;
1366
+ const resolved = resolve(text);
1367
+ // 検出パターンが拾ったのに解決できない = 検出側と対応表がずれている。
1368
+ // ここで `continue` すると「旧トークンを使っているのに何も報告されない」
1369
+ // 無記録の drop になるので、要判断として必ず表に出す。
1370
+ // (現状この分岐には到達しないことを token-migration.test.js の全数検証で
1371
+ // 確認しているが、テーブル編集で簡単に壊れるため fail-loud にしておく)
1372
+ // en: A pattern hit that the table cannot resolve means the two have
1373
+ // drifted. Surface it instead of dropping it silently.
1374
+ if (!resolved) {
1375
+ hits.push({
1376
+ index: match.index ?? 0,
1377
+ text,
1378
+ recommendation:
1379
+ `[要判断] ${text}: ${label}として検出しましたが移行先を解決できませんでした。` +
1380
+ 'sparkle-design-cli の検出パターンと対応表がずれている可能性があります(issue 報告をお願いします)。',
1381
+ });
1382
+ continue;
1383
+ }
1384
+
1385
+ let recommendation;
1386
+ if (resolved.classification === MIGRATION_CLASS.AUTO) {
1387
+ recommendation = `[自動変換可] ${text} → ${resolved.replacement}${resolved.reason ? `(${resolved.reason})` : ''}`;
1388
+ } else if (resolved.candidates?.length) {
1389
+ recommendation = `[要判断] ${text} の移行先候補: ${resolved.candidates.join(' / ')}。${resolved.reason ?? ''}`;
1390
+ } else if (resolved.replacement) {
1391
+ // 移行先自体は算出できているが人間の確認が要るケース(`text-placeholder`
1392
+ // の統合など)。せっかく分かっている置換先を捨てない。
1393
+ // en: The target is known but needs a human decision — still show it.
1394
+ recommendation = `[要判断] ${text} → ${resolved.replacement}。${resolved.reason ?? ''}`;
1395
+ } else {
1396
+ recommendation = `[要判断] ${text}: ${resolved.reason ?? `${label} の移行先を Figma で確認してください。`}`;
1397
+ }
1398
+
1399
+ hits.push({ index: match.index ?? 0, text, recommendation });
1400
+ }
1401
+ return hits;
1402
+ };
1403
+ }
1404
+
1405
+ const SPACING_UTILITY_PATTERN = buildSpacingUtilityPattern();
1406
+
1407
+ /**
1408
+ * Figma の spacing スケールから外れた値を拾う。
1409
+ *
1410
+ * 移行ルール(migrationMatcher)と違い「同じファイルが宣言しているか」は見ない。
1411
+ * spacing は CSS 変数として出力していないので、**変数の**定義側という概念が
1412
+ * 無いため。ただし Tailwind がコンパイルした CSS の `.p-7{padding:1.75rem}` は
1413
+ * 素通しで拾う。既定の検査対象が `src` なので通常は踏まないが、`dist` や
1414
+ * vendored CSS を明示的に渡すと大量に報告される。
1415
+ * en: No CSS-variable definition side exists for spacing, but compiled Tailwind
1416
+ * output (`.p-7{…}`) is still matched — only reachable if someone points the
1417
+ * check at `dist`/vendored CSS.
1418
+ */
1419
+ function spacingScaleMatcher(rawContent) {
1420
+ const content = maskBlockComments(rawContent);
1421
+ const hits = [];
1422
+ for (const match of content.matchAll(SPACING_UTILITY_PATTERN)) {
1423
+ const { lead, sign: prefixModifiers, step, important } = match.groups;
1424
+ const text = match[0];
1425
+ const offScale = resolveSpacingStep(step);
1426
+ if (!offScale) continue;
1427
+
1428
+ // 負のマージン(`-mt-7`)はステップだけ見ると正数なので、px 表示に符号を
1429
+ // 戻す。クラス名は lead に `-` が入っているので正しいが、「-mt-7 は 28px」
1430
+ // という案内は px 換算の正確さが売りのルールとして嘘になる。
1431
+ // 判定は lead 全体ではなく **ユーティリティ直前の修飾子だけ**を見る。
1432
+ // lead を走査すると `[&:-moz-any-link]:mt-7` の variant 内の `-` を拾う。
1433
+ // en: Restore the sign in the px figures, reading only the modifier right
1434
+ // before the utility — scanning `lead` would misread variants like
1435
+ // `[&:-moz-any-link]:mt-7` as negative.
1436
+ const sign = prefixModifiers.includes('-') ? '-' : '';
1437
+
1438
+ // 候補は variant 連鎖・負号・important をすべて保ったまま数字だけ差し替える。
1439
+ // `p-7!` に対して `p-6` を案内すると、そのまま従った人が important を落とす。
1440
+ // en: Rebuild candidates with every modifier intact — suggesting `p-6` for
1441
+ // `p-7!` would quietly drop the important flag.
1442
+ const neighbours = [offScale.lower, offScale.upper]
1443
+ .filter((px) => px !== null)
1444
+ .map((px) => `${lead}${pxToStep(px)}${important}(${sign}${px}px)`);
1445
+
1446
+ hits.push({
1447
+ index: match.index ?? 0,
1448
+ text,
1449
+ recommendation:
1450
+ `[要判断] ${text} は ${sign}${offScale.px}px で、Figma の Spacing: Primitives に無い値です。` +
1451
+ // スケールに 0 が含まれる限り `lower` は必ず埋まるので、この else には
1452
+ // 到達しない(`upper` が null になるのは 120px 超のときだけで、
1453
+ // そのとき `lower` は 120)。FIGMA_SPACING_PX を空にした場合の保険。
1454
+ // en: Unreachable while the scale contains 0 — kept as a guard.
1455
+ (neighbours.length
1456
+ ? `近いステップは ${neighbours.join(' / ')}。`
1457
+ : 'Figma のスケールを確認してください。') +
1458
+ 'デザイン上その値が必要なら、Figma 側にステップを追加するか、' +
1459
+ '`sparkle-disable-next-line use-figma-spacing-scale` で明示的に外してください。',
1460
+ });
1461
+ }
1462
+ return hits;
1463
+ }
1464
+
1465
+ const LEGACY_SCALE_PATTERN = buildLegacyScalePattern();
1466
+ const LEGACY_TOKEN_PATTERN = buildLegacyTokenPattern();
1467
+ const LEGACY_CSS_VAR_PATTERN = buildLegacyCssVarPattern();
1468
+ const DEPRECATED_ALIAS_PATTERN = buildDeprecatedAliasPattern();
1469
+
1470
+ const TOKEN_MIGRATION_GROUPS = [
1471
+ {
1472
+ id: 'legacy-color-token',
1473
+ check: {
1474
+ severity: SEVERITY.WARNING,
1475
+ // `.css` も対象にする(`@apply` のユーティリティ、`var(--color-*)` の直接参照)
1476
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1477
+ description:
1478
+ '旧セマンティックカラーのユーティリティクラスを使っています(新体系は用途 × 意味 × 強度 × 状態)',
1479
+ recommendation:
1480
+ '用途別セマンティックトークン(bg- → surface / text- → text / border- → border / fill- → object)に置き換えてください。',
1481
+ match: (content) => [
1482
+ ...migrationMatcher(LEGACY_SCALE_PATTERN, resolveLegacyUtility, '旧トークン')(content),
1483
+ ...migrationMatcher(LEGACY_TOKEN_PATTERN, resolveLegacyUtility, '旧トークン')(content),
1484
+ ],
1485
+ },
1486
+ featureSection: lines([
1487
+ '### 旧セマンティックカラーではなく用途別トークンを使う',
1488
+ '',
1489
+ '新体系は「用途 × 意味 × 強度 × **状態**」で、状態がトークン名に内包される。',
1490
+ 'そのため**同じ旧クラスでも文脈によって移行先が変わる**。',
1491
+ '',
1492
+ '| if(状況) | then(移行先) |',
1493
+ '|---|---|',
1494
+ '| 通常状態の背景に `bg-primary-600` | `bg-surface-primary-high-enabled` |',
1495
+ '| hover の背景に `hover:bg-primary-700` | `hover:bg-surface-primary-high-hover` |',
1496
+ '| variant 接頭辞なしで `bg-primary-200` | **自動変換しない**(`surface-primary-high-disabled` と `surface-primary-middle-active` のどちらか判断が要る) |',
1497
+ '| 文字色に `text-primary-600` | `text-text-primary-enabled` |',
1498
+ '| 枠線に `border-primary-300` | `border-border-primary-high` |',
1499
+ '| アイコン色に `fill-primary-600` | `fill-object-primary-enabled` |',
1500
+ '',
1501
+ '`secondary` は `neutral` に統合された。ユーティリティ接頭辞がそのまま用途に対応する',
1502
+ '(`bg-` → surface / `text-` → text / `border-`・`divide-`・`outline-`・`ring-` → border / `fill-`・`stroke-` → object)。',
1503
+ '',
1504
+ '判断が付かないときは推測で置換せず、Figma の該当箇所を確認する。',
1505
+ ]),
1506
+ jsdocTargets: [],
1507
+ },
1508
+ {
1509
+ id: 'legacy-color-var',
1510
+ check: {
1511
+ severity: SEVERITY.WARNING,
1512
+ // `.css` も対象にする(`@apply` のユーティリティ、`var(--color-*)` の直接参照)
1513
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1514
+ description: '旧セマンティックカラーの CSS 変数を直接参照しています',
1515
+ recommendation:
1516
+ '新しい用途別セマンティックトークンの変数に置き換えてください。用途が変数名から決まらない場合は Figma を確認してください。',
1517
+ match: migrationMatcher(LEGACY_CSS_VAR_PATTERN, resolveLegacyCssVar, '旧トークン'),
1518
+ },
1519
+ featureSection: lines([
1520
+ '### 旧セマンティックカラーの CSS 変数を直接参照しない',
1521
+ '',
1522
+ '```tsx',
1523
+ '// ✅ Correct',
1524
+ '<div className="ring-2 ring-[var(--color-border-ring)] ring-offset-2" />',
1525
+ '',
1526
+ '// ❌ Wrong — beta 終了時に削除される旧トークン',
1527
+ '<div className="ring-2 ring-[var(--color-ring-normal)] ring-offset-2" />',
1528
+ '```',
1529
+ '',
1530
+ 'フォーカスリングの `--color-ring-normal` は `--color-border-ring`(blue/700 固定)に移行する。',
1531
+ '`--color-primary-600` のようなスケール付きの旧変数は、変数名だけでは用途',
1532
+ '(surface / text / border / object)が決まらないため**自動変換しない**。',
1533
+ ]),
1534
+ jsdocTargets: [],
1535
+ },
1536
+ {
1537
+ id: 'deprecated-radius-alias',
1538
+ check: {
1539
+ severity: SEVERITY.WARNING,
1540
+ // `.css` も対象にする(`@apply` のユーティリティ、`var(--color-*)` の直接参照)
1541
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1542
+ description: 'beta 期間だけ残している後方互換エイリアスを直接参照しています',
1543
+ recommendation: 'リネーム後の変数を直接参照してください。',
1544
+ match: migrationMatcher(DEPRECATED_ALIAS_PATTERN, resolveLegacyCssVar, '後方互換エイリアス'),
1545
+ },
1546
+ featureSection: lines([
1547
+ '### 削除予定の後方互換エイリアスを直接参照しない',
1548
+ '',
1549
+ '```tsx',
1550
+ '// ✅ Correct',
1551
+ '<div className="rounded-[var(--radius-container)]" />',
1552
+ '',
1553
+ '// ❌ Wrong — beta 終了時に削除され、角丸がサイレントに消える',
1554
+ '<div className="rounded-[var(--radius-halfModal)]" />',
1555
+ '```',
1556
+ '',
1557
+ 'Figma の `Borders: Semantics` で `halfModal` は `container` にリネームされた。',
1558
+ '`--radius-halfModal` は beta 期間だけのエイリアスで、消えても CSS としては',
1559
+ '有効なまま(値が空になるだけ)なので**ビルドエラーにならずに角丸だけが失われる**。',
1560
+ ]),
1561
+ jsdocTargets: [],
1562
+ },
1563
+ {
1564
+ id: 'use-figma-spacing-scale',
1565
+ check: {
1566
+ // `info` から始める。既存 consumer にとっては未知の制約で、しかも
1567
+ // 「動かない」わけではなく「スケールから外れている」だけなので、
1568
+ // いきなり warning にすると移行ルールの警告に埋もれて全部読み流される。
1569
+ // en: Ships at `info` — it's a new constraint on working code, and
1570
+ // shouting would drown out the migration warnings that do need action.
1571
+ severity: SEVERITY.INFO,
1572
+ // `.css` も対象にする(`@apply p-7` のような書き方を拾うため)
1573
+ targets: [RULE_TARGET.SOURCE, RULE_TARGET.CSS],
1574
+ description: 'Figma の Spacing: Primitives に無い余白ステップを使っています',
1575
+ recommendation:
1576
+ 'Figma のスケールに乗ったステップに寄せてください。判断が付かない場合は Figma の該当箇所を確認してください。',
1577
+ match: spacingScaleMatcher,
1578
+ },
1579
+ featureSection: lines([
1580
+ '### 余白は Figma の Spacing: Primitives のステップだけ使う',
1581
+ '',
1582
+ 'Figma の余白は 17 段(0 / 2 / 4 / 6 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48 / 56 / 72 / 88 / 104 / 120 px)。',
1583
+ 'Tailwind の 1 ステップは 4px なので、`p-4` = 16px、`p-6` = 24px が対応する。',
1584
+ '',
1585
+ '```tsx',
1586
+ '// ✅ Correct — 6=24px / 4=16px / 8=32px はいずれもスケール上にある',
1587
+ '<div className="p-6 gap-4 mt-8" />',
1588
+ '',
1589
+ '// ❌ Wrong — 7 は 28px。24px と 32px の間にステップは無い',
1590
+ '<div className="p-7" />',
1591
+ '```',
1592
+ '',
1593
+ '**Tailwind の数字と Figma の数字は一致しない。** Figma の `padding/16` は 16px、',
1594
+ 'Tailwind の `p-16` は 64px で 4 倍違う。Figma の値を見て同じ数字を書かないこと。',
1595
+ '',
1596
+ '| Figma | px | Tailwind |',
1597
+ '|---|---|---|',
1598
+ '| `padding/8` | 8px | `p-2` |',
1599
+ '| `padding/16` | 16px | `p-4` |',
1600
+ '| `padding/24` | 24px | `p-6` |',
1601
+ '| `padding/120` | 120px | `p-30` |',
1602
+ '',
1603
+ 'このスケールは **CSS 変数として出力されていない**。`--spacing-16` のような',
1604
+ '名前付きキーを定義すると Tailwind の `p-16` がその値に差し替わり、',
1605
+ '既存コードの余白がビルドも警告も通ったまま変わってしまうため。',
1606
+ 'だから「変数を使う」ではなく「使ってよい値を守る」という形の制約になっている。',
1607
+ '',
1608
+ 'デザイン上どうしてもスケール外の値が要る場合は、Figma 側にステップを追加するのが本筋。',
1609
+ '個別に外すなら `sparkle-disable-next-line use-figma-spacing-scale` を使う。',
1610
+ ]),
1611
+ jsdocTargets: [],
1612
+ },
1613
+ ];
1614
+
1615
+ const BUILTIN_ANTI_PATTERN_GROUPS = [...COMPONENT_ANTI_PATTERN_GROUPS, ...TOKEN_MIGRATION_GROUPS];
1616
+
1208
1617
  const BUILTIN_CHECK_ORDER = [
1209
1618
  'dialog-form',
1210
1619
  'dialog-button-wrap',
@@ -1219,6 +1628,16 @@ const BUILTIN_CHECK_ORDER = [
1219
1628
  'disabled-vs-is-disabled',
1220
1629
  'button-prefixicon-jsx',
1221
1630
  'icon-children-text',
1631
+ // NOTE: この配列が決めるのは **ルールの評価順** だけで、レポートの表示順では
1632
+ // ない(findings は check.js 側で severity 優先にソートされる)。移行ルールを
1633
+ // 末尾に置いているのは評価順を安定させるためで、「warning が error を埋もれ
1634
+ // させない」保証は severity ソートが担っている。
1635
+ // en: This only orders rule *evaluation*. Report ordering is by severity in
1636
+ // check.js — don't read this list as a display-order guarantee.
1637
+ 'legacy-color-token',
1638
+ 'legacy-color-var',
1639
+ 'deprecated-radius-alias',
1640
+ 'use-figma-spacing-scale',
1222
1641
  ];
1223
1642
 
1224
1643
  function getCheckRules(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
@@ -1227,6 +1646,8 @@ function getCheckRules(groups = BUILTIN_ANTI_PATTERN_GROUPS) {
1227
1646
  .map((group) => ({
1228
1647
  id: group.id,
1229
1648
  ...group.check,
1649
+ severity: normalizeSeverity(group.check.severity, group.id),
1650
+ targets: normalizeRuleTargets(group.check.targets, group.id),
1230
1651
  }))
1231
1652
  .sort((left, right) => {
1232
1653
  const leftIndex = BUILTIN_CHECK_ORDER.indexOf(left.id);