sparkle-design-cli 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,6 +100,9 @@ sparkle-design-cli generate --globals-path src/styles/app.css
100
100
 
101
101
  # CI 向け: @source 注入や Tailwind import 欠落などの失敗を exit 1 にする
102
102
  sparkle-design-cli generate --strict
103
+
104
+ # 単一バンドル内でランタイムにテーマ切替したい場合(詳細は「複数のテーマ配色を1デプロイでサポートしたい場合」参照)
105
+ sparkle-design-cli generate -c ./config/sparkle.admin.json --scope '[data-tenant-theme="admin"]' -o ./src/styles/sparkle-admin-scope.css
103
106
  ```
104
107
 
105
108
  #### generate オプション一覧
@@ -113,6 +116,7 @@ sparkle-design-cli generate --strict
113
116
  - デザインシステムパッケージが `package.json` に入っているのに Tailwind エントリ CSS が見つからない
114
117
  - エントリ CSS はあるが `@import "tailwindcss";` が書かれていない(警告メッセージに追記すべき行まで actionable に表示)
115
118
  - エントリ CSS への書き込みに失敗した
119
+ - `--scope <セレクタ>`: `@theme inline` には触れず、セマンティックトークン(`--color-primary-*` 等)だけを実値までリテラル化して指定セレクタの中にラップ出力する。単一バンドル内でランタイムにテーマ切替したい場合向け(`-o/--output` の指定が必須)。詳細は「複数のテーマ配色を1デプロイでサポートしたい場合」の「ケース B」を参照
116
120
 
117
121
  ### check: アンチパターン検査
118
122
 
@@ -536,22 +540,65 @@ Figma プラグインが出力する基本4項目に加えて、プロジェク
536
540
 
537
541
  `primary` だけを差し替えて `gray` を既定のままにすると、コンポーネントの枠線・背景・テキストに使われる gray 系トークンと primary のトーンが揃わなくなるため、gray も必ずセットで再定義してください。
538
542
 
543
+ ##### `character-*` に無いウェイト(SemiBold 等)を使いたい場合
544
+
545
+ `character-*` utility の `font-weight` は Regular(400) / Bold(700) の 2 値のみで、SemiBold(600) や Medium(500) に対応する `character-*` は存在しません(Sparkle Design のプリミティブトークンである `fontWeights.text.regular` / `fontWeights.text.bold` がこの 2 値のみを持つため)。
546
+
547
+ 日本語見出しでは Bold(700) が視覚的に重すぎると判断して SemiBold(600) を使いたい、といったプロジェクト固有のウェイト方針がある場合は、`character-*` に SemiBold バリアントの追加を待つのではなく、`extend.custom-css` で独自のユーティリティクラスを定義してください。**`className="character-3-regular-pro font-semibold"` のように Tailwind の `font-semibold` を併用する方法は機能しません**。`sparkle-design.css` は Tailwind の後・`@layer utilities` 内に読み込まれるよう意図的に設計されており(同一詳細度なら後着のルールが勝つ cascade layers の仕様上)、`character-*` 側の `font-weight` が常に優先されるためです。
548
+
549
+ ```css
550
+ /* custom-typography.css */
551
+ @layer utilities {
552
+ /* character-3(16px/24px)の SemiBold バリアント。
553
+ font-family / font-size / letter-spacing / line-height は
554
+ character-* と同じプリミティブトークンをそのまま流用し、
555
+ font-weight だけプロジェクト独自の 600 に差し替える */
556
+ .character-3-semibold-pro {
557
+ font-family: var(--font-family-pro);
558
+ font-size: var(--font-size-16);
559
+ font-weight: 600;
560
+ letter-spacing: var(--letter-spacing-wider);
561
+ line-height: var(--line-height-24);
562
+ }
563
+ }
564
+ ```
565
+
566
+ `character-*-semibold-*` は既存の `character-*-regular-*` / `character-*-bold-*` と別名のクラスなので、cascade の競合は起きません。`extend.custom-css` は `sparkle-design.css` の直後に `@import` されるため、上記のように新規クラスを追記するだけで有効になります(上書きではないので `!important` や記述順の工夫も不要です)。
567
+
568
+ 使用するフォントファミリーが実際に 600 ウェイトのファイルを読み込んでいるかも確認してください。`extend.fonts.pro` / `extend.fonts.mono` の `weights` に `600` を含めていないと、`font-weight: 600` を指定してもブラウザによる疑似太字(faux bold)にフォールバックし、正しい SemiBold の字形になりません。
569
+
570
+ 将来 Sparkle Design 本体が正式に `character-N-semibold-*` token を追加した場合は、cascade 上は consumer 側の `custom-css` 定義が勝ち続けるため、その時点で自前定義は削除して本体の token に乗り換えてください(放置すると Sparkle 側の値が更新されても気づかず古い定義が使われ続けます)。
571
+
572
+ なお `check` の `tailwind-typography` ルールは `font-semibold` / `font-medium` を検出対象に含めていますが、`character-*-semibold-*` のような独自クラスに置き換えた行は検出パターンに一致しないため、`sparkle-disable-line` を付けなくても違反として検出されなくなります。
573
+
539
574
  ## 複数のテーマ配色を 1 デプロイでサポートしたい場合
540
575
 
541
- 管理画面と一般ユーザー画面で配色を出し分けたい、テナントごとに固定の配色バリアントを切り替えたいなど、「少数(数種類程度)の固定配色を 1 つのデプロイでサポートする」ケースの推奨パターンです。
576
+ 管理画面と一般ユーザー画面で配色を出し分けたい、テナントごとに固定の配色バリアントを切り替えたいなど、「少数(数種類程度)の固定配色を 1 つのデプロイでサポートする」ケースの推奨パターンです。**「画面ごとに読み込む CSS を切り替える」場合と「1 つの画面内でランタイムに切り替える」場合とで推奨パターンが異なる**ので、まずどちらのケースかを判断してください。
577
+
578
+ ### ケース A: 画面(ビルド)ごとにバリアントが固定される場合 → config を分割して複数の CSS を生成する
542
579
 
543
- ### 推奨: config を分割して複数の CSS を生成する
580
+ 管理画面 / 一般ユーザー画面でレイアウトごと entry CSS を分けられる、role や tenant に応じて import する CSS を切り替えられる、など「1 画面につき常に 1 バリアントだけを読み込む」運用ができる場合の推奨パターンです。
544
581
 
545
582
  `generate` は `-c/--config` と `-o/--output` を組み合わせることで、任意の config から任意の出力先へ CSS を生成できます。バリアントの数だけ config ファイルを用意し、バリアントの数だけ `generate` を実行してください(1 config に複数テーマをまとめて生成する機能はありませんが、CI やスクリプトで複数回呼び出せば十分に運用できます)。
546
583
 
547
584
  ```jsonc
548
585
  // config/sparkle.admin.json
549
- { "primary": "blue", "font-pro": "Inter", "font-mono": "JetBrains Mono", "radius": "md" }
586
+ {
587
+ "primary": "blue",
588
+ "font-pro": "Inter",
589
+ "font-mono": "JetBrains Mono",
590
+ "radius": "md"
591
+ }
550
592
  ```
551
593
 
552
594
  ```jsonc
553
595
  // config/sparkle.employee.json
554
- { "primary": "green", "font-pro": "Inter", "font-mono": "JetBrains Mono", "radius": "md" }
596
+ {
597
+ "primary": "green",
598
+ "font-pro": "Inter",
599
+ "font-mono": "JetBrains Mono",
600
+ "radius": "md"
601
+ }
555
602
  ```
556
603
 
557
604
  ```bash
@@ -559,7 +606,68 @@ npx sparkle-design-cli generate -c ./config/sparkle.admin.json -o ./src/styles/s
559
606
  npx sparkle-design-cli generate -c ./config/sparkle.employee.json -o ./src/styles/sparkle-employee.css
560
607
  ```
561
608
 
562
- あとは consumer 側で、表示するバリアントに応じてどちらの CSS を読み込むかを切り替えるだけです(例: role / tenant を判定できるレイアウト単位で `import "./sparkle-admin.css"` と `import "./sparkle-employee.css"` を出し分ける、Next.js のルートグループごとに読み込む entry CSS を分ける、など)。**同時に両方を読み込まない**(1 画面につき常に 1 バリアントだけを読み込む)ようにすると、後述のファイルサイズ増加の影響を最小化できます。
609
+ あとは consumer 側で、表示するバリアントに応じてどちらの CSS を読み込むかを切り替えるだけです(例: role / tenant を判定できるレイアウト単位で `import "./sparkle-admin.css"` と `import "./sparkle-employee.css"` を出し分ける、Next.js のルートグループごとに読み込む entry CSS を分ける、など)。
610
+
611
+ **重要: この 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 バリアントだけを読み込む**構成でのみ、この方式は安全に使えます。
612
+
613
+ 「1 つの画面内で属性の付け外しだけでテーマを切り替えたい(ページ遷移や再ビルドを伴わない)」場合は、このケース A ではなく次のケース B を使ってください。
614
+
615
+ ### ケース B: 単一バンドル内でランタイムに切り替えたい場合 → `generate --scope`
616
+
617
+ SPA の 1 つの JS バンドル内で、`<html data-tenant-theme="admin">` のような属性の付け外しだけで配色を切り替えたい(ページ遷移・再デプロイを伴わない)場合のパターンです。ケース A の「config 分割 + 複数 `generate` の CSS を同時 import する」は、上記の理由(`@theme inline` の衝突)でこのケースには使えません。
618
+
619
+ Tailwind v4 では、この種のランタイム切り替え(dark mode 等)を公式に次のパターンでサポートしています([Tailwind公式ドキュメント](https://tailwindcss.com/docs/colors#using-css-variables)より):
620
+
621
+ ```css
622
+ @import "tailwindcss";
623
+
624
+ :root {
625
+ --acme-canvas-color: oklch(0.967 0.003 264.542);
626
+ }
627
+
628
+ [data-theme="dark"] {
629
+ --acme-canvas-color: oklch(0.21 0.034 264.665);
630
+ }
631
+
632
+ @theme inline {
633
+ --color-canvas: var(--acme-canvas-color);
634
+ }
635
+ ```
636
+
637
+ ポイントは、`@theme inline` は top-level に **1 つだけ** 置いたまま、実際に切り替えたい値は `@theme` を使わない普通の `:root` / `[data-attr]` スコープ付きセレクタで **参照先の CSS 変数** を上書きする、という構成です。`generate --scope` はこの「参照先を差し替える」役割を、`sparkle.config.json` の解決ロジックを再利用しながら自動生成します。
638
+
639
+ ```bash
640
+ # ベース(デフォルト表示)となるバリアントは今まで通り通常の generate
641
+ npx sparkle-design-cli generate -c ./config/sparkle.employee.json -o ./src/styles/sparkle-employee.css
642
+
643
+ # 追加バリアントは --scope で「セマンティックトークンだけをセレクタでラップした差分 CSS」を生成する
644
+ npx sparkle-design-cli generate -c ./config/sparkle.admin.json \
645
+ --scope '[data-tenant-theme="admin"]' \
646
+ -o ./src/styles/sparkle-admin-scope.css
647
+ ```
648
+
649
+ ```css
650
+ /* entry CSS: 両方を同時に import してよい */
651
+ @import "tailwindcss";
652
+ @import "./sparkle-employee.css"; /* @theme inline を含む通常の generate 出力(ベース) */
653
+ @import "./sparkle-admin-scope.css"; /* --scope の出力。@theme inline は含まない */
654
+ ```
655
+
656
+ ```html
657
+ <!-- ランタイムでこの属性を付け外しするだけで配色が切り替わる。ページ遷移・再ビルド不要 -->
658
+ <html data-tenant-theme="admin"></html>
659
+ ```
660
+
661
+ `--scope` の出力は次の性質を持ちます:
662
+
663
+ - `@theme inline` には一切触れない(ベース側の `generate` 出力にある 1 つだけがそのまま有効であり続ける)ので、ケース A のような衝突は起きません。
664
+ - `--color-primary-*` 等の **セマンティックトークン** を、admin config の解決結果に基づいて実値(`oklch(...)`)までリテラル化した上で `--scope` のセレクタの中に閉じ込めます。プリミティブ(`--color-blue-*` 等)ではなくセマンティックトークンを上書き対象にしているのがポイントで、デフォルトでは `primary` と `info` が同じプリミティブ(例: `blue`)を共有しているため、もしプリミティブ側を上書きしてしまうと `primary` を変えたつもりが `info` まで巻き込んで変わってしまいます。`--scope` はこの巻き込みが起きないよう、セマンティック層で解決してから出力します。
665
+ - `--radius-*` / `--shadow-*` は普遍的な共有スケール値(テナント間で衝突しない)なので `var()` 参照のまま出力されます。`config.radius` がバリアントごとに異なれば、その解決結果もちゃんと反映されます。
666
+ - フォント import・`@source`・SparkleHead.tsx・globals.css パッチ等、ドキュメント全体に関わる**出力**は行いません。これらはベース側の通常 `generate` が既に担っているため、バリアントごとに重複させる必要はありません。ただし `font-pro` / `font-mono` 等の **validation 自体**は通常の `generate` と同様に適用されます(出力しないだけで、config の妥当性チェックは変わりません)。
667
+ - `-o/--output` の指定が必須です(既定の `sparkle-design.css` を誤って上書きしないため)。`--strict` / `--globals-path` とは併用できません(`--scope` はグローバル CSS のパッチを行わないため)。
668
+ - 現状 `primary` は 7 色パレットからの選択のみ対応しています。7 色にないカスタムブランドカラーを `--scope` の変数だけで表現したい場合は、`extend.custom-css` の要領で手動でセレクタ配下に `--color-primary-*` を定義してください(`--scope` の出力とマージして使えます)。
669
+
670
+ このパターンは、次の「非推奨: 属性スコープでの Tailwind クラス上書き」が抱えていた脆さも同時に解消します。`--scope` が上書きするのは Sparkle が公開しているトークン契約(`--color-primary-*` 等の CSS 変数)だけであり、ユーティリティクラス名やコンポーネント内部の specificity には一切依存しません。そのため、Sparkle 側のコンポーネント実装(クラス名の付け方や compound attribute selector 等)が変わっても、`--scope` の出力が壊れることはありません。
563
671
 
564
672
  ### 非推奨: 単一 CSS + 属性スコープでの Tailwind クラス上書き
565
673
 
@@ -568,15 +676,16 @@ npx sparkle-design-cli generate -c ./config/sparkle.employee.json -o ./src/style
568
676
  - コンポーネント側の実装詳細(クラス名の付け方や CSS の specificity)に依存するため、Sparkle 側の内部実装(例: 状態別カラーリングで compound attribute selector を使っているコンポーネント)が変わると、`!important` を使っても上書きが効かなくなることがあります。上書き対象は Sparkle が公開している契約(config / トークン)ではなく非公開の実装詳細なので、バージョンアップのたびに壊れるリスクを consumer 側が抱え続けることになります。
569
677
  - 上書きが必要なクラス/コンポーネントが増えるたびに、手動でセレクタを足していく必要があり、考慮漏れに気づきにくいです。
570
678
 
571
- 上記のリスクを踏まえると、テーマ切り替えは config 分割 + `extend.custom-css` によるトークンレベルの上書き(cascade 順で確実に勝つ)で完結させる方が、Sparkle のバージョンアップに対しても壊れにくく、見通しの良い実装になります。
679
+ 上記のリスクを踏まえると、テーマ切り替えは「ケース A: config 分割」または「ケース B: `generate --scope`」+ `extend.custom-css` によるトークンレベルの上書き(cascade 順で確実に勝つ)で完結させる方が、Sparkle のバージョンアップに対しても壊れにくく、見通しの良い実装になります。
572
680
 
573
681
  ### トレードオフ
574
682
 
575
- | 観点 | config 分割(推奨) | 属性スコープ上書き(非推奨) |
576
- | --- | --- | --- |
577
- | ファイルサイズ | バリアントの数だけ CSS が増える(各 CSS は 7 色パレット全体を含むため、1 バリアントあたりのベースコストは一定)。1 画面 1 バリアントのみ読み込む運用なら実質的な増分は小さい | 単一 CSS + 上書き分の差分のみで増分は小さい |
578
- | FOUC リスク | サーバー側 / ビルド時にバリアントを決定できるならほぼ無し。クライアント側でバリアント判定後に切り替える場合は他方式と同程度のリスクがある | クライアント側で属性を付与するタイミング次第でリスクがある(初期描画後に属性が付くと一瞬デフォルト配色が見える) |
579
- | 保守性 | Sparkle が公開している config / トークンだけに依存するため、コンポーネント内部実装の変更に影響されにくい | コンポーネントの内部実装(クラス名・specificity)に依存するため、Sparkle 側の変更で上書きが効かなくなるリスクがある |
683
+ | 観点 | ケース A: config 分割(推奨・画面ごと固定) | ケース B: `generate --scope`(推奨・単一バンドルでランタイム切替) | 属性スコープ上書き(非推奨) |
684
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
685
+ | 適用シーン | 1 画面につき常に 1 バリアントだけを読み込める | 1 バンドル内で属性の付け外しだけで切り替えたい | (非推奨のため参考情報) |
686
+ | ファイルサイズ | バリアントの数だけ CSS が増える(各 CSS は 7 色パレット全体を含む)。1 画面 1 バリアントのみ読み込む運用なら実質的な増分は小さい | ベース CSS 1 本 + バリアントごとの軽量な差分 CSS(セマンティックトークンのみ) | 単一 CSS + 上書き分の差分のみで増分は小さい |
687
+ | FOUC リスク | サーバー側 / ビルド時にバリアントを決定できるならほぼ無し | 属性付与のタイミング次第でリスクがある(初期描画後に属性が付くと一瞬デフォルト配色が見える) | クライアント側で属性を付与するタイミング次第でリスクがある |
688
+ | 保守性 | Sparkle が公開している config / トークンだけに依存するため、コンポーネント内部実装の変更に影響されにくい | 同左(トークンだけに依存し、ユーティリティクラス名や specificity に依存しない) | コンポーネントの内部実装(クラス名・specificity)に依存するため、Sparkle 側の変更で上書きが効かなくなるリスクがある |
580
689
 
581
690
  ## 出力
582
691
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { generateCSS } from '../lib/generate-css.js';
3
+ import { generateCSS, generateScopedCSS } from '../lib/generate-css.js';
4
4
  import { checkProject } from '../lib/check.js';
5
5
  import { setupAssistant } from '../lib/setup.js';
6
6
  import { runStopHook } from '../lib/stop-hook.js';
@@ -23,6 +23,7 @@ function parseGenerateOptions(args) {
23
23
  outputPath: null,
24
24
  globalsPath: null,
25
25
  strict: false,
26
+ scope: null,
26
27
  help: false,
27
28
  };
28
29
 
@@ -42,6 +43,9 @@ function parseGenerateOptions(args) {
42
43
  i += 1;
43
44
  } else if (arg === '--strict') {
44
45
  options.strict = true;
46
+ } else if (arg === '--scope') {
47
+ options.scope = requireOptionValue(args, i, '--scope');
48
+ i += 1;
45
49
  } else {
46
50
  throw new Error(`Unknown option for generate: ${arg}`);
47
51
  }
@@ -150,6 +154,10 @@ Generate:
150
154
  sparkle-design-cli generate --output ./styles/design.css
151
155
  sparkle-design-cli generate -c ./config/custom.json -o ./dist/styles.css
152
156
 
157
+ # 単一バンドル内でランタイムにテーマ切替したい場合(--scope。詳細は README 参照)
158
+ sparkle-design-cli generate -c ./config/sparkle.employee.json -o ./src/styles/sparkle-employee.css
159
+ sparkle-design-cli generate -c ./config/sparkle.admin.json --scope '[data-tenant-theme="admin"]' -o ./src/styles/sparkle-admin-scope.css
160
+
153
161
  Check:
154
162
  sparkle-design-cli check
155
163
  sparkle-design-cli check src --strict
@@ -180,6 +188,13 @@ Generate options:
180
188
  --globals-path <path> Tailwind エントリポイント CSS のパス (default: 自動検出)
181
189
  --strict globals.css への @source 注入等が失敗したら exit 1
182
190
  (CI 向け。既定は warn + 継続で後方互換を維持)
191
+ --scope <selector> 単一バンドル内でランタイムにテーマ切替したい場合の出力モード。
192
+ @theme inline には触れず、セマンティックトークン(--color-primary-* 等)
193
+ だけを実値までリテラル化して指定セレクタの中にラップ出力する
194
+ (例: '[data-tenant-theme="admin"]')。-o/--output の指定が必須。
195
+ --strict / --globals-path とは併用不可(グローバル CSS を
196
+ パッチしないため)。詳細は README の「単一バンドルでランタイム
197
+ 切替する場合」を参照
183
198
 
184
199
  sparkle.config.json の設定フィールド:
185
200
 
@@ -305,6 +320,25 @@ async function main() {
305
320
  process.exit(0);
306
321
  }
307
322
 
323
+ if (options.scope !== null) {
324
+ // --scope はグローバル CSS(@source / globals.css パッチ)や
325
+ // --strict の warn-vs-exit1 昇格ロジックに一切関与しない。両方
326
+ // 指定できてしまうと、指定した --strict / --globals-path が
327
+ // 効いているように見えて実際には黙って無視される(silent no-op)
328
+ // ため、組み合わせ自体を早期に拒否する。
329
+ // en: --scope never touches @source/globals.css patching or
330
+ // --strict's warn-vs-exit1 escalation. Silently accepting them
331
+ // together would look like they're honored while doing nothing —
332
+ // reject the combination up front instead.
333
+ if (options.strict || options.globalsPath) {
334
+ throw new Error(
335
+ '--scope は --strict / --globals-path と併用できません(--scope はグローバル CSS のパッチを行わないため)'
336
+ );
337
+ }
338
+ generateScopedCSS(options.configPath, options.outputPath, options.scope);
339
+ return;
340
+ }
341
+
308
342
  generateCSS(options.configPath, options.outputPath, options.globalsPath, {
309
343
  strict: options.strict,
310
344
  });
@@ -925,7 +925,7 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
925
925
  check: {
926
926
  description: 'Tailwind デフォルト typography を Sparkle Design コンポーネント内で使わない',
927
927
  recommendation:
928
- 'text-sm / text-xs / text-base / font-medium / font-semibold / font-bold は character-* utility に置き換えてください。ただし character-* に対応する token が無いサイズ(text-[10px] 等の arbitrary value、あるいは意図的に token 外のサイズを使う場合)は、同一行または直前行に `// sparkle-disable-line tailwind-typography` コメントを付けて例外扱いとして残すこともできます。',
928
+ 'text-sm / text-xs / text-base / font-medium / font-semibold / font-bold は character-* utility に置き換えてください。ただし character-* に対応する token が無いサイズ(text-[10px] 等の arbitrary value、あるいは意図的に token 外のサイズを使う場合)は、同一行または直前行に `// sparkle-disable-line tailwind-typography` コメントを付けて例外扱いとして残すこともできます。font-medium(500) / font-semibold(600) は character-* に対応する token が無いため、`extend.custom-css` で独自クラスを定義してください(詳細は README の「character-* に無いウェイトを使いたい場合」参照)。',
929
929
  pattern: /\b(text-(?:xs|sm|base|lg|xl|2xl)|font-(?:medium|semibold|bold|normal|light))\b/g,
930
930
  },
931
931
  featureSection: lines([
@@ -946,6 +946,8 @@ const BUILTIN_ANTI_PATTERN_GROUPS = [
946
946
  '```',
947
947
  '',
948
948
  'Sparkle Design コンポーネント内では `character-*-pro` / `character-*-mono` を使用する。character-1(12px)より小さい指定や、対応 token が無いサイズは Tailwind の arbitrary value (`text-[10px]` 等) で表現するか、`// sparkle-disable-line tailwind-typography` で個別に例外指定する。',
949
+ '',
950
+ '`font-medium` / `font-semibold`(500 / 600)は character-* に対応する token が存在しない。`character-N-regular-pro font-semibold` のように Tailwind の font-weight ユーティリティを併用しても、character-* が意図的に Tailwind の後に読み込まれる cascade 設計のため上書きされず効かない。これらのウェイトが必要な場合は `extend.custom-css` で `character-N-semibold-pro` のような独自クラスを定義し、font-family / font-size / letter-spacing / line-height は character-* と同じプリミティブトークンを流用する(詳細は README の「character-* に無いウェイトを使いたい場合」)。',
949
951
  ]),
950
952
  jsdocTargets: [],
951
953
  },
@@ -87,9 +87,7 @@ function resolveExtend(config, configPath = null) {
87
87
  throw err;
88
88
  }
89
89
  if (typeof extend !== 'object' || extend === null || Array.isArray(extend)) {
90
- const err = new Error(
91
- `extend ファイルの中身は object である必要があります: ${extendPath}`
92
- );
90
+ const err = new Error(`extend ファイルの中身は object である必要があります: ${extendPath}`);
93
91
  err.code = 'E_EXTEND_FILE_INVALID_SHAPE';
94
92
  throw err;
95
93
  }
@@ -167,7 +165,9 @@ function assertSafeFontWeights(weights, label) {
167
165
 
168
166
  function assertSafePackageName(name, label) {
169
167
  if (typeof name !== 'string' || !NPM_PACKAGE_SAFE.test(name)) {
170
- const err = new Error(`${label} は有効な npm パッケージ名ではありません (${JSON.stringify(name)})`);
168
+ const err = new Error(
169
+ `${label} は有効な npm パッケージ名ではありません (${JSON.stringify(name)})`
170
+ );
171
171
  err.code = 'E_UNSAFE_PACKAGE_NAME';
172
172
  throw err;
173
173
  }
@@ -522,12 +522,7 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
522
522
  // wildcard として広く置換してしまう L-2 型の regex injection 両方を防げる。
523
523
  // en: Restrict replacement to a whitelist of known config keys so unknown /
524
524
  // adversarial keys cannot smuggle regex metacharacters into the template.
525
- const TEMPLATE_KEY_ALLOWLIST = new Set([
526
- 'primary',
527
- 'radius',
528
- 'font-pro',
529
- 'font-mono',
530
- ]);
525
+ const TEMPLATE_KEY_ALLOWLIST = new Set(['primary', 'radius', 'font-pro', 'font-mono']);
531
526
  const ALPHANUMERIC_KEY = /^[a-z0-9-]+$/;
532
527
  Object.entries(config).forEach(([key, value]) => {
533
528
  // 配列・オブジェクト・拡張フィールドはスキップ
@@ -589,6 +584,312 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
589
584
  return { css: processedCSS, resolvedFonts };
590
585
  }
591
586
 
587
+ /**
588
+ * `generate --scope` 用のテンプレート解析エラーを投げる。
589
+ * sparkle-variables 側でテンプレートのマーカーコメント/構造が変わると
590
+ * ここで検知され、silent に壊れた scope CSS を出すことなく loud に停止する。
591
+ * @param {string} label 'primitive' | 'semantic'
592
+ * @param {string} detail 具体的な失敗内容
593
+ */
594
+ function throwScopeTemplateShapeChanged(label, detail) {
595
+ const err = new Error(
596
+ `generate --scope 用のテンプレート解析に失敗しました(${label}: ${detail})。` +
597
+ 'sparkle-variables のテンプレート構造が変わった可能性があります。CLI 側の対応が必要です。'
598
+ );
599
+ err.code = 'E_SCOPE_TEMPLATE_SHAPE_CHANGED';
600
+ throw err;
601
+ }
602
+
603
+ /**
604
+ * `markerComment` の直後にある最初の `:root { ... }` ブロックの中身を抜き出す。
605
+ * sparkle-design.template.css は同じセマンティックトークン定義を
606
+ * 「プレーンな :root」(issue #229 対応前から存在。ランタイムで CSS 変数として
607
+ * 読める汎用ブロック)と「@theme inline」(Tailwind ユーティリティ生成用。
608
+ * top-level 必須で nest 不可)の 2 箇所に重複して持つ。`generate --scope` は
609
+ * 後者には一切触れず、前者(プレーンな :root、Tailwind の at-rule 制約を
610
+ * 受けない)だけを抽出してセレクタでラップし直す。
611
+ * en: Extract the content of the first `:root { ... }` block right after
612
+ * `markerComment`. The template intentionally duplicates the semantic tokens
613
+ * as both a plain `:root` block and an `@theme inline` block (the latter
614
+ * exists solely to trigger Tailwind's utility-class generation and must stay
615
+ * top-level per Tailwind v4's spec). `generate --scope` only ever touches the
616
+ * plain `:root` copy, since it isn't subject to that at-rule constraint.
617
+ * @param {string} cssText processTemplate 済みの CSS 全文
618
+ * @param {string} markerComment ブロック直前の目印コメント
619
+ * @param {string} label エラーメッセージ用ラベル
620
+ * @returns {string} :root { と対応する \n} の間の中身
621
+ */
622
+ function extractRootBlockContent(cssText, markerComment, label) {
623
+ const markerIndex = cssText.indexOf(markerComment);
624
+ if (markerIndex === -1) {
625
+ throwScopeTemplateShapeChanged(label, `マーカーコメントが見つかりません: ${markerComment}`);
626
+ }
627
+ const braceStart = cssText.indexOf(':root {', markerIndex);
628
+ if (braceStart === -1) {
629
+ throwScopeTemplateShapeChanged(label, ':root ブロックの開始位置が見つかりません');
630
+ }
631
+ const contentStart = braceStart + ':root {'.length;
632
+ const closeIndex = cssText.indexOf('\n}', contentStart);
633
+ if (closeIndex === -1) {
634
+ throwScopeTemplateShapeChanged(label, ':root ブロックの終端が見つかりません');
635
+ }
636
+ return cssText.slice(contentStart, closeIndex);
637
+ }
638
+
639
+ /**
640
+ * プリミティブ `:root` ブロックの中身から `--color-*` の実値(リテラル)だけを
641
+ * Map に集める。`var(...)` を値に持つ宣言(プリミティブブロックには通常無いが
642
+ * 念のため)は解決対象にならないため除外する。
643
+ * @param {string} primitiveBlockContent extractRootBlockContent の戻り値
644
+ * @returns {Map<string, string>} `--color-blue-500` -> `oklch(...)` 等
645
+ */
646
+ function buildPrimitiveColorValueMap(primitiveBlockContent) {
647
+ const map = new Map();
648
+ const pattern = /(--color-[\w-]+):\s*([^;]+);/g;
649
+ let match = pattern.exec(primitiveBlockContent);
650
+ while (match !== null) {
651
+ const [, varName, rawValue] = match;
652
+ const value = rawValue.trim();
653
+ if (!value.startsWith('var(')) {
654
+ map.set(varName, value);
655
+ }
656
+ match = pattern.exec(primitiveBlockContent);
657
+ }
658
+ return map;
659
+ }
660
+
661
+ /**
662
+ * セマンティックブロック内の `var(--color-*)` 参照を、プリミティブの実値まで
663
+ * 再帰的に解決してリテラル値に置き換える(`--radius-*` / `--shadow-*` は対象外
664
+ * のまま var() 参照を維持する — こちらは共有ロール衝突が起きない普遍的な
665
+ * スケール値のため、スコープ上書きでも安全に参照を残せる)。
666
+ *
667
+ * セマンティックトークンには `--color-neutral-*` → `--color-gray-*`(プリミティブ)
668
+ * のような1段の間接参照だけでなく、`--color-divider-low` → `--color-neutral-100`
669
+ * → `--color-gray-100` のような2段の間接参照も含まれるため、単純な1パス置換では
670
+ * 解決しきれない。解決できた宣言を都度 Map に足し込みながら複数パス回すことで、
671
+ * 間接参照の段数に依存せず解決する。
672
+ *
673
+ * プリミティブ(`--color-blue-*` 等)ではなくセマンティックトークン
674
+ * (`--color-primary-*` 等)をリテラル化してからスコープに閉じ込めるのがポイント:
675
+ * デフォルトでは `primary` と `info` が同じプリミティブ(例: blue)を共有して
676
+ * いるため、プリミティブ側をスコープ上書きすると primary を変えたつもりが
677
+ * info まで巻き込んで変わってしまう(issue #229 の議論)。セマンティック層で
678
+ * 実値に固定してしまえば、この巻き込みは起こらない。
679
+ * en: Flatten `var(--color-*)` references (not `--radius-*`/`--shadow-*`,
680
+ * which are safe shared universal scales) down to literal values, resolving
681
+ * multi-level indirection (divider -> neutral -> gray) over several passes.
682
+ * Flattening at the semantic layer (not the primitive layer) avoids the
683
+ * cross-role bleed where overriding a shared primitive like `blue` would
684
+ * unintentionally also change every other role that happens to reference it
685
+ * (e.g. `info` defaults to the same primitive as `primary`).
686
+ * @param {string} semanticBlockContent extractRootBlockContent の戻り値
687
+ * @param {Map<string, string>} primitiveMap buildPrimitiveColorValueMap の戻り値
688
+ * @returns {string} `var(--color-*)` がすべてリテラル値に置き換わった内容
689
+ * @throws {Error} 5パス以内に解決しきれない参照が残った場合(`E_SCOPE_UNRESOLVED_COLOR_VAR`)
690
+ */
691
+ function flattenSemanticColorVars(semanticBlockContent, primitiveMap) {
692
+ const resolved = new Map(primitiveMap);
693
+ const declPattern = /(--color-[\w-]+):\s*([^;]+);/g;
694
+ // `(?:\s*,[^()]*)?` オプショナルで CSS の fallback 引数(`var(--x, fallback)`)
695
+ // も含めてマッチする。プリミティブは常に定義済みなので fallback 側は無視して
696
+ // 実値に置き換えてよい。これが無いと `var(--color-x, fallback)` 形式の参照が
697
+ // マッチせずスルーされ、`stillUnresolved` チェック(下記)でも検知されない
698
+ // まま非リテラルな var() がスコープ出力に紛れ込んでしまう。
699
+ // en: The optional `(?:\s*,[^()]*)?` also matches CSS fallback args
700
+ // (`var(--x, fallback)`). Without it, a fallback-form reference would
701
+ // silently pass through unflattened AND unflagged by the unresolved-check
702
+ // below, defeating the "fail loud" guarantee.
703
+ const colorVarRefPattern = /var\((--color-[\w-]+)(?:\s*,[^()]*)?\)/g;
704
+ let content = semanticBlockContent;
705
+
706
+ for (let pass = 0; pass < 5; pass += 1) {
707
+ let match = declPattern.exec(content);
708
+ while (match !== null) {
709
+ const [, name, rawValue] = match;
710
+ const value = rawValue.trim();
711
+ if (!value.startsWith('var(') && !resolved.has(name)) {
712
+ resolved.set(name, value);
713
+ }
714
+ match = declPattern.exec(content);
715
+ }
716
+ declPattern.lastIndex = 0;
717
+
718
+ let changed = false;
719
+ content = content.replace(colorVarRefPattern, (full, varName) => {
720
+ const literal = resolved.get(varName);
721
+ if (literal === undefined) {
722
+ return full;
723
+ }
724
+ changed = true;
725
+ return literal;
726
+ });
727
+
728
+ if (!changed) break;
729
+ }
730
+
731
+ const stillUnresolved = content.match(colorVarRefPattern);
732
+ if (stillUnresolved) {
733
+ const err = new Error(
734
+ `generate --scope: 次の色トークン参照を実値に解決できませんでした: ${stillUnresolved.join(', ')}`
735
+ );
736
+ err.code = 'E_SCOPE_UNRESOLVED_COLOR_VAR';
737
+ throw err;
738
+ }
739
+
740
+ return content;
741
+ }
742
+
743
+ // `--scope` に渡すセレクタ文字列は生成 CSS にそのまま埋め込まれるため、denylist
744
+ // (`{` `}` `/*` 等の既知の危険文字だけを弾く)ではなく **allowlist** で検証する。
745
+ // denylist だと `;` や `@` を見落とすと `@import url(...);` のような文が
746
+ // ラップ用の `{` の手前で「閉じて」しまい、後続の `{ ... }` が無関係な空セレクタ
747
+ // ルールとして扱われる(意図した上書きが消え、任意の外部 CSS import が
748
+ // 差し込まれる)CSS injection を許してしまう。CSS セレクタが実際に必要とする
749
+ // 文字集合(英数字・空白・属性セレクタ用の `[]="'`・クラス/ID の `.` `#`・
750
+ // 疑似クラスの `:`・結合子 `>` `+` `~`・複数セレクタ区切りの `,`)だけを許可し、
751
+ // それ以外(`;` `@` `{` `}` `/` `\` 等)は default-deny にする。
752
+ // en: Validate --scope with an ALLOWLIST, not a denylist. A denylist that
753
+ // misses `;`/`@` lets a value like `@import url(...);` terminate before the
754
+ // wrapping `{`, turning the intended override into a dropped empty-selector
755
+ // rule while smuggling an arbitrary external `@import` — a real CSS
756
+ // injection bypass. Only allow the character set real CSS selectors need.
757
+ // `\s` は使わない — 改行/CR も許可してしまい、複数行にまたがる injection の
758
+ // 余地を残す。CSS セレクタは1行で書くのが通常のため、空白はスペース/タブのみ許可する。
759
+ // en: Use an explicit space/tab class instead of `\s` — `\s` also matches
760
+ // newlines/CR, which would defeat the point of rejecting multi-line values.
761
+ const SCOPE_SELECTOR_SAFE_PATTERN = /^[A-Za-z0-9_\-.,:>+~=[\]"' \t#]+$/;
762
+
763
+ /**
764
+ * `--scope` のセレクタ文字列を検証する。
765
+ * @param {unknown} selector
766
+ * @throws {Error} 空文字列、非文字列、または許可されていない文字を含む場合(`E_UNSAFE_SCOPE_SELECTOR`)
767
+ */
768
+ function assertSafeScopeSelector(selector) {
769
+ if (typeof selector !== 'string' || selector.trim().length === 0) {
770
+ const err = new Error('--scope には空でないセレクタ文字列を指定してください');
771
+ err.code = 'E_UNSAFE_SCOPE_SELECTOR';
772
+ throw err;
773
+ }
774
+ if (!SCOPE_SELECTOR_SAFE_PATTERN.test(selector)) {
775
+ const err = new Error(
776
+ `--scope に許可されていない文字が含まれています(CSS セレクタとして使える英数字・` +
777
+ `[ ] = " ' . : > + ~ # , 空白のみ許可): ${JSON.stringify(selector)}`
778
+ );
779
+ err.code = 'E_UNSAFE_SCOPE_SELECTOR';
780
+ throw err;
781
+ }
782
+ }
783
+
784
+ const PRIMITIVE_ROOT_MARKER = '/* Primitive Tokens */';
785
+ const SEMANTIC_ROOT_MARKER =
786
+ '/* セマンティックトークン - CSS変数としてランタイムで利用可能にする */';
787
+
788
+ /**
789
+ * 単一バンドル内でランタイムにテーマ配色を切り替えたいケース(issue #229)向けの
790
+ * `generate` バリアント。通常の `generate` が出力する `@theme inline`(top-level
791
+ * 必須で nest 不可)には一切触れず、セマンティックトークン(`--color-primary-*`
792
+ * 等)だけを実値までリテラル化した上で、指定セレクタの中に閉じ込めて出力する。
793
+ *
794
+ * ベースとなる CSS(`@theme inline` を含む通常の `generate` 出力)を1つ、
795
+ * バリアントごとにこの `generateScopedCSS` の出力を1つ、という組み合わせで
796
+ * 同時 import すれば、`@theme inline` の重複によるビルド衝突を起こさずに
797
+ * `[data-tenant-theme="..."]` のようなランタイム属性でテーマを切り替えられる
798
+ * (README.md の「単一バンドルでランタイム切替する場合」参照)。
799
+ *
800
+ * フォント import・`@source`・SparkleHead.tsx・globals.css パッチなど
801
+ * ドキュメント全体に関わる **出力** は行わない(ベース側の通常 `generate` 実行が
802
+ * 既に担っているため、バリアントごとに重複させる必要が無い)。ただし内部的に
803
+ * `processTemplate`(通常の `generate` と共通)を経由するため、フォント設定
804
+ * (`font-pro` / `font-mono` / `fonts.*`)の validation(`assertSafeFontFamily` /
805
+ * `assertSafeFontWeights` 等)自体は通常の `generate` と同様に適用される。
806
+ * 出力しないだけで、config の妥当性チェックは変わらず全項目に効く。
807
+ *
808
+ * en: A `generate` variant for runtime theme switching within a single bundle
809
+ * (issue #229). Never touches `@theme inline` (which Tailwind v4 requires to
810
+ * stay top-level and un-nestable) — instead flattens only the semantic color
811
+ * tokens down to literal values and wraps them in the given selector. Pair one
812
+ * normal `generate` run (the base CSS, with `@theme inline`) with one
813
+ * `generateScopedCSS` run per variant; importing both simultaneously is safe
814
+ * because the scoped output never redeclares `@theme inline`.
815
+ *
816
+ * @param {string|null} configPath カスタム設定ファイルのパス(オプション)
817
+ * @param {string} outputPath 出力先パス(必須。既定の sparkle-design.css を
818
+ * 誤って上書きしないよう、省略時は throw する)
819
+ * @param {string} scopeSelector 出力全体をラップするセレクタ(例: `[data-tenant-theme="admin"]`)
820
+ * @returns {{ outputPath: string, scopeSelector: string }}
821
+ */
822
+ export function generateScopedCSS(configPath, outputPath, scopeSelector) {
823
+ if (!outputPath) {
824
+ const err = new Error(
825
+ '--scope 指定時は -o/--output の指定が必須です(既定の sparkle-design.css を誤って上書きしないため)'
826
+ );
827
+ err.code = 'E_SCOPE_OUTPUT_REQUIRED';
828
+ throw err;
829
+ }
830
+ assertSafeScopeSelector(scopeSelector);
831
+
832
+ console.log(
833
+ `🎯 スコープ限定でセマンティックトークンを生成します(scope: ${scopeSelector})...\n`
834
+ );
835
+
836
+ const rawConfig = loadConfig(configPath);
837
+ const config = resolveExtend(rawConfig, configPath);
838
+ const template = loadTemplate();
839
+ const colors = loadColors();
840
+ const grayMapping = loadGrayMapping();
841
+ const radiusMapping = loadRadiusMapping();
842
+
843
+ assertValidRadius(config.radius, radiusMapping);
844
+
845
+ const { css: processedCSS } = processTemplate(
846
+ template,
847
+ config,
848
+ grayMapping,
849
+ radiusMapping,
850
+ colors
851
+ );
852
+
853
+ const primitiveBlock = extractRootBlockContent(processedCSS, PRIMITIVE_ROOT_MARKER, 'primitive');
854
+ const semanticBlock = extractRootBlockContent(processedCSS, SEMANTIC_ROOT_MARKER, 'semantic');
855
+
856
+ const primitiveMap = buildPrimitiveColorValueMap(primitiveBlock);
857
+ const flattenedSemanticBlock = flattenSemanticColorVars(semanticBlock, primitiveMap);
858
+
859
+ const header = [
860
+ `/* Sparkle Design — スコープ限定テーマ上書き(scope: ${scopeSelector}) */`,
861
+ '/* generate --scope により自動生成されます。@theme inline は含まれません。 */',
862
+ '/* 手動編集した場合、再度 generate --scope を実行すると上書きされます。 */',
863
+ '',
864
+ ].join('\n');
865
+
866
+ const outputCSS = `${header}${scopeSelector} {${flattenedSemanticBlock}\n}\n`;
867
+
868
+ const resolvedOutputPath = path.resolve(outputPath);
869
+ // `writeCSS()` と同じエラーメッセージ(MESSAGES.CSS_WRITE_FAILED)で統一する。
870
+ // ただし `writeCSS()` と異なり `process.exit(1)` は呼ばない — throw のままに
871
+ // しておくことで、bin/sparkle-design.js の外側 catch が一律 exit 1 に昇格
872
+ // させる既存の仕組みに乗せられ、かつテストから try/catch で検証できる
873
+ // (process.exit を直接呼ぶとテストプロセスごと落ちてしまう)。
874
+ // en: Reuse writeCSS()'s error message for consistency, but keep throwing
875
+ // (not process.exit) so bin/sparkle-design.js's outer catch handles the
876
+ // exit-1 escalation uniformly and tests can assert on the thrown error.
877
+ try {
878
+ const outputDir = path.dirname(resolvedOutputPath);
879
+ if (!fs.existsSync(outputDir)) {
880
+ fs.mkdirSync(outputDir, { recursive: true });
881
+ }
882
+ fs.writeFileSync(resolvedOutputPath, outputCSS, 'utf8');
883
+ } catch (error) {
884
+ const err = new Error(MESSAGES.CSS_WRITE_FAILED(error.message));
885
+ err.code = 'E_SCOPE_CSS_WRITE_FAILED';
886
+ throw err;
887
+ }
888
+ console.log(`✅ スコープ限定 CSS を生成しました: ${resolvedOutputPath}`);
889
+
890
+ return { outputPath: resolvedOutputPath, scopeSelector };
891
+ }
892
+
592
893
  /**
593
894
  * 生成されたCSSをファイルに書き出す
594
895
  * @param {string} cssContent CSS内容
@@ -898,10 +1199,7 @@ export function generateCSS(
898
1199
  // index.html (Astro / Remix / plain SPA) must not receive Sparkle's
899
1200
  // managed block.
900
1201
  const cwdForHtml = process.cwd();
901
- if (
902
- isViteProject(cwdForHtml) &&
903
- fs.existsSync(path.resolve(cwdForHtml, 'index.html'))
904
- ) {
1202
+ if (isViteProject(cwdForHtml) && fs.existsSync(path.resolve(cwdForHtml, 'index.html'))) {
905
1203
  const htmlResult = upsertViteIndexHtmlFonts(cwdForHtml, resolvedFonts);
906
1204
  if (htmlResult.status === 'created' || htmlResult.status === 'updated') {
907
1205
  const displayPath = path.relative(cwdForHtml, htmlResult.path) || htmlResult.path;
@@ -1007,4 +1305,11 @@ export {
1007
1305
  removeFontImportsFromCSS,
1008
1306
  updateGlobalsWithFonts,
1009
1307
  manageFontImports,
1308
+ // generate --scope 内部ヘルパー(テスト用)
1309
+ extractRootBlockContent,
1310
+ buildPrimitiveColorValueMap,
1311
+ flattenSemanticColorVars,
1312
+ assertSafeScopeSelector,
1313
+ PRIMITIVE_ROOT_MARKER,
1314
+ SEMANTIC_ROOT_MARKER,
1010
1315
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Sparkle Design CLI — プロジェクトセットアップ、CSS・フォント生成、アンチパターン検査、AI エージェント(Claude Code / Cursor / Codex)向けのガードと hook 設定まで一括で行う sparkle-design 公式 CLI。",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",