ksk-design-system 1.63.0 → 1.64.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
@@ -119,9 +119,11 @@ import { Button, Card, Input, FormField } from "ksk-design-system"
119
119
  | `Button` | `type` 既定 `"button"` / disabled・aria-disabled クリックの抑止 / `haptic` / `asChild` | `inline-flex` `items-center` `justify-center` `gap-*` `whitespace-nowrap` `typo-*` `cursor-pointer` と variant・size・layout の全クラス |
120
120
  | `Input` | `showCount`(IME 追従)/ adornment の配置土台 | `h-*` `w-full` `border-*` `bg-*` `px-*` `typo-*` `placeholder:*` |
121
121
  | `Textarea` | `autoGrow` / `showCount` | `min-h-*` `w-full` `border-*` `bg-*` `px-*` `py-*` `typo-*` `placeholder:*` |
122
+ | `Label` | Radix Label の挙動(クリックで対象コントロールへフォーカス移動) | `typo-label-md` `text-[var(--Text-High-Emphasis)]` `peer-disabled:cursor-not-allowed` `peer-disabled:opacity-50` |
122
123
 
123
- キーボード操作時の `focus-visible` リングだけは a11y のため既定で残ります。手書き CSS が
124
- 自前のフォーカス表現を持つ場合は `className="focus-visible:ring-0"` で消せます。
124
+ キーボード操作時の `focus-visible` リングだけは a11y のため既定で残ります(`Label` は
125
+ フォーカス可能要素ではないため対象外)。手書き CSS が自前のフォーカス表現を持つ場合は
126
+ `className="focus-visible:ring-0"` で消せます。
125
127
 
126
128
  移行の順番は次のとおりです。
127
129
 
@@ -198,8 +200,34 @@ const BRAND = "#06C755"
198
200
  - 先頭 12 行に自動生成マーカー(`AUTO-GENERATED` / `DO NOT EDIT` / `自動生成` 等)を持つ
199
201
  ファイルは全ルールを skip します。直す先は生成物ではなく生成元です。
200
202
  - DS 自身のトークン定義 CSS(`src/styles/*.css` / `src/themes/*.css` / `src/preset.css` と、
201
- それを再梱包したベンダリングコピー)は P049 の対象外です。トークンを**定義**している側で
202
- あって、consumer による上書きではないためです。
203
+ それを再梱包したベンダリングコピー)は P049 / P050 の対象外です。トークンを**定義**している側で
204
+ あって、consumer による上書き・並行実装ではないためです。
205
+
206
+ #### P050: DS を参照しない並行パレットの検出(issue #393)
207
+
208
+ P049 は「DS の名前空間の変数(`--Primitive-Brand-500` 等)に触れている CSS」しか見ないため、DS を
209
+ 一切使わない独自パレット(`--bg` / `--accent` / `--surface` 等)を持つ consumer には
210
+ 無言でした。P050 は `:root` / `.dark` / `[data-theme=...]` のようなルート的セレクタの中で、
211
+ DS 名前空間に属さないカスタムプロパティへ色値(`#hex` / `rgb()` / `hsl()` / `oklch()`)を
212
+ <!-- docs-drift-ignore: --Primitive- -->
213
+ 5 個以上定義していたら「並行パレットの疑い」を warn します。値が `var(--Primitive-...)` の
214
+ ように DS トークンを参照しているだけのものはカウントしません。意図的な独立パレット
215
+ (LINE 風チャット画面のライトなど)は `// ksk-ds-lint-ignore P050 -- 理由` で抑制できます。
216
+
217
+ #### ルールごとの除外(rules.json の excludes 系)
218
+
219
+ `contracts/rules.json` の除外指定は、当てる対象ごとに 3 つに分かれています。
220
+
221
+ | フィールド | 当てる対象 | 効く範囲 |
222
+ |---|---|---|
223
+ | `excludePaths` | ファイルパス | 全リポジトリ(`.stories.` 等) |
224
+ | `excludeDsPaths` | ファイルパス | **DS パッケージ配下のファイルだけ**(`components/ui/` 等) |
225
+ | `excludeLines` | 行の内容 | 全リポジトリ(`data-slot` / `asChild` 等) |
226
+
227
+ `excludeDsPaths` は「DS 自身の実装だから生タグを書いてよい」という意味の除外なので、
228
+ consumer が shadcn 既定の `src/components/ui/` に部品を置いても中核ルールは無効化されません
229
+ (v1.63.x までは単一の `excludes` をパスと行内容の両方に OR で当てていたため、
230
+ このディレクトリ名だけで 13 ルールが同時に黙っていました)。
203
231
 
204
232
  ### Jest(CommonJS)でコンポーネントをテストする
205
233
 
@@ -275,6 +303,11 @@ npx ksk-ds check-duplicates ./src --strict
275
303
 
276
304
  既定は助言モードで終了コード 0、`--strict` は重複候補があると終了コード 1 です。正本は同梱の `contracts/components.json` であり、consumer 側に別の「昇格候補台帳」を作らないでください。
277
305
 
306
+ DS を import して委譲するだけの薄いラッパー(段階移行の型)は、DS と同名でも重複報告から除外され、`ℹ ラッパー(DS 委譲済み)として除外` として件数のみ表示されます。
307
+
308
+ <!-- docs-drift-ignore: PrimaryButton AppTextInput BottomSheet CandidateOverflowMenu CrisisBanner AvatarView -->
309
+ さらに、`contracts/components.json` と完全一致しない名前でも、DS 部品を連想させる命名パターン(`PrimaryButton` → `Button` / `AppTextInput` → `Input` / `BottomSheet` → `Sheet` / `CandidateOverflowMenu` → `DropdownMenu` / `CrisisBanner` → `Banner` / `AvatarView` → `Avatar` 等)は「重複の疑い」として `⚠` 付きで warn 表示されます。これは名前一致のみによる推測(AST は使いません)のため確度は下がり、`--strict` でも exit code には影響しません。誤検知の場合は無視して構いませんが、恒常的に抑制したい場合はそのファイル内のコメントに `ksk-ds-local-fallback` を含めてください。
310
+
278
311
  ### Media overlay utilities
279
312
 
280
313
  動画・写真の上に文字や操作を置く場合は、`--Text-on-Media` と `.text-on-media` / `.text-on-media-secondary`、上下の `.media-scrim-top` / `.media-scrim-bottom` を使います。TikTok / Reels 型の操作群は `MediaActionCluster` が glass ボタン、ラベル、safe-area anchor、idle auto-hide をまとめて扱います。
@@ -43,8 +43,13 @@ export function runCheckDuplicatesCli(
43
43
  const wrapperExclusions = results
44
44
  .flatMap((result) => result.wrapperExclusions)
45
45
  .sort((a, b) => a.file.localeCompare(b.file) || a.name.localeCompare(b.name))
46
+ const suspicions = results
47
+ .flatMap((result) => result.suspicions)
48
+ .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.name.localeCompare(b.name))
46
49
 
47
- printFindings(findings, wrapperExclusions, files.length, options.strict)
50
+ printFindings(findings, wrapperExclusions, suspicions, files.length, options.strict)
51
+ // suspicions は名前ヒューリスティックによる warn 止まりの「疑い」であり、
52
+ // --strict でも exit code には影響させない(issue #392 取りこぼし側)。
48
53
  return options.strict && findings.length > 0 ? 1 : 0
49
54
  }
50
55
 
@@ -187,12 +192,60 @@ function collectDsReExports(source) {
187
192
  return reExports
188
193
  }
189
194
 
195
+ // issue #392 取りこぼし側: 名前が違う真の重複(改名された DS 相当の自前実装)を
196
+ // AST を使わず名前ヒューリスティックで拾う。確度が下がるため error にはせず warn
197
+ // (「疑い」)止まりとし、--strict でも exit code は変えない。
198
+ //
199
+ // キーワードは接尾辞(PrimaryButton・AppTextInput 等の前置修飾パターン)と
200
+ // 接頭辞(AvatarView 等、DS 部品名 + 汎用語のパターン)の両方で判定する。
201
+ const NAME_HEURISTIC_MAP = {
202
+ Button: "Button",
203
+ TextInput: "Input",
204
+ Input: "Input",
205
+ Sheet: "Sheet",
206
+ Modal: "Dialog",
207
+ Menu: "DropdownMenu",
208
+ Banner: "Banner",
209
+ Alert: "Alert",
210
+ Avatar: "Avatar",
211
+ // Card は除外: ドメインカード(PropertyCard 等)が正当に多く、ノイズ源になる
212
+ Chip: "Chip",
213
+ Badge: "Badge",
214
+ Toast: "Toast",
215
+ Tooltip: "Tooltip",
216
+ Dialog: "Dialog",
217
+ }
218
+ const NAME_HEURISTIC_KEYWORDS = Object.keys(NAME_HEURISTIC_MAP).sort((a, b) => b.length - a.length)
219
+ // 接頭辞パターンで許容する汎用語(残り部分がこれらに完全一致する場合のみ疑いとする)。
220
+ // 無制限に許可すると「AvatarUploadFlow」のような無関係な名前まで拾ってしまうため絞る。
221
+ const GENERIC_NAME_SUFFIXES = new Set(["View", "Component", "Element", "Widget", "Item", "Base", "Wrapper"])
222
+ // 抑制マーカー: このコメントを含むファイルは「疑い」報告の対象から除外する
223
+ // (contracts/rules.json 等の既存の一時実装ルールに合わせた運用)。
224
+ const SUPPRESSION_MARKER = "ksk-ds-local-fallback"
225
+
226
+ function suggestDsNameForLocalName(name) {
227
+ for (const keyword of NAME_HEURISTIC_KEYWORDS) {
228
+ if (name.length > keyword.length && name.endsWith(keyword)) {
229
+ return NAME_HEURISTIC_MAP[keyword]
230
+ }
231
+ }
232
+ for (const keyword of NAME_HEURISTIC_KEYWORDS) {
233
+ if (name.length > keyword.length && name.startsWith(keyword)) {
234
+ const remainder = name.slice(keyword.length)
235
+ if (GENERIC_NAME_SUFFIXES.has(remainder)) return NAME_HEURISTIC_MAP[keyword]
236
+ }
237
+ }
238
+ return null
239
+ }
240
+
190
241
  function findDuplicateExports(file, cwd, registry) {
191
242
  const source = readFileSync(file, "utf8")
192
243
  const lines = source.split(/\r?\n/)
193
244
  const findings = []
194
245
  const wrapperExclusions = []
246
+ const suspicions = []
195
247
  const relFile = normalize(relative(cwd, file))
248
+ const suppressSuspicions = source.includes(SUPPRESSION_MARKER)
196
249
 
197
250
  const codeForImportScan = stripComments(source)
198
251
  const dsImportSourceNames = collectDsImportSourceNames(codeForImportScan)
@@ -206,7 +259,23 @@ function findDuplicateExports(file, cwd, registry) {
206
259
  if (!match) continue
207
260
  const name = match[1]
208
261
  const components = registry.get(name)
209
- if (!components) continue
262
+
263
+ if (!components) {
264
+ // 完全一致は無いが、名前が DS 部品を連想させるパターン(issue #392 取りこぼし側)。
265
+ // DS を import しているファイル(何らかの形で DS を使っている=別部品のラッパー等)
266
+ // でも疑いは出す。抑制は SUPPRESSION_MARKER コメントのみ。
267
+ if (suppressSuspicions) continue
268
+ const suggested = suggestDsNameForLocalName(name)
269
+ if (!suggested) continue
270
+ suspicions.push({
271
+ file: relFile,
272
+ line: index + 1,
273
+ name,
274
+ suggested,
275
+ suggestedComponents: registry.get(suggested) ?? null,
276
+ })
277
+ continue
278
+ }
210
279
 
211
280
  // 同名のローカル宣言があっても、同ファイルが ksk-design-system から同名(元の
212
281
  // export 名基準。ローカル alias は問わない)を import して使っているなら、
@@ -231,10 +300,10 @@ function findDuplicateExports(file, cwd, registry) {
231
300
  wrapperExclusions.push({ file: relFile, name: localName })
232
301
  }
233
302
 
234
- return { findings, wrapperExclusions }
303
+ return { findings, wrapperExclusions, suspicions }
235
304
  }
236
305
 
237
- function printFindings(findings, wrapperExclusions, scannedFiles, strict) {
306
+ function printFindings(findings, wrapperExclusions, suspicions, scannedFiles, strict) {
238
307
  if (findings.length === 0) {
239
308
  console.log(`ksk-ds check-duplicates: 重複候補はありません(${scannedFiles} files)`)
240
309
  } else {
@@ -260,6 +329,21 @@ function printFindings(findings, wrapperExclusions, scannedFiles, strict) {
260
329
  console.log(` ℹ ラッパー(DS 委譲済み)として除外: ${wrapper.name} (${wrapper.file})`)
261
330
  }
262
331
  }
332
+
333
+ if (suspicions.length > 0) {
334
+ console.log(`\n⚠ 重複の疑い(名前ヒューリスティック・要確認): ${suspicions.length} 件`)
335
+ for (const suspicion of suspicions) {
336
+ const dsPathHint = suspicion.suggestedComponents
337
+ ? ` (DS: ${suspicion.suggestedComponents.map((component) => component.path).join(", ")})`
338
+ : ""
339
+ console.log(
340
+ ` ⚠ ${suspicion.file}:${suspicion.line} ${suspicion.name} は DS の ${suspicion.suggested} で置換できる可能性があります${dsPathHint}`,
341
+ )
342
+ }
343
+ console.log(
344
+ " ※ 名前一致のみによる推測(warn)です。誤検知の場合は無視して問題ありません。抑制するには実装コメントに `ksk-ds-local-fallback` を含めてください。",
345
+ )
346
+ }
263
347
  }
264
348
 
265
349
  function printHelp() {
@@ -278,6 +362,12 @@ DIR の既定値:
278
362
  ただし、同ファイルが ksk-design-system から同名(alias 可)を import/re-export して
279
363
  使っているものは DS 委譲ラッパーとみなし、重複報告から除外します(info 行で件数報告)。
280
364
  既定は助言モード(常に exit 0)、--strict は検出時 exit 1 です。
365
+
366
+ 完全一致しない名前でも、DS 部品を連想させる命名パターン
367
+ (PrimaryButton → Button・AppTextInput → Input・BottomSheet → Sheet・
368
+ CandidateOverflowMenu → DropdownMenu・CrisisBanner → Banner・AvatarView → Avatar 等)は
369
+ 「重複の疑い」として warn 表示します(--strict でも exit code には影響しません)。
370
+ 抑制するにはファイル内のコメントに \`ksk-ds-local-fallback\` を含めてください。
281
371
  `)
282
372
  }
283
373
 
package/bin/lint.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
2
- import { basename, extname, join, relative, resolve } from "node:path"
2
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"
3
3
  import { spawnSync } from "node:child_process"
4
- import { inspectProductThemeOverrides, loadProductThemeContract } from "./product-theme-override.js"
4
+ import {
5
+ inspectParallelPaletteCandidates,
6
+ inspectProductThemeOverrides,
7
+ loadProductThemeContract,
8
+ } from "./product-theme-override.js"
5
9
 
6
10
  // card-child-spacing.js は `import ts from "typescript"` している(P046 専用エンジン)。
7
11
  // typescript は 24MB 前後あり、59ルール中 P046 の1つにしか使わないため、
@@ -354,9 +358,13 @@ export async function runLintCli(argv, { cwd = process.cwd(), pkgRoot = resolve(
354
358
  return 1
355
359
  }
356
360
 
361
+ // CSS 専用エンジン一覧。P049(product-theme-override)と P050(parallel-palette、
362
+ // issue #393)はどちらも .css だけを対象にした独自エンジンで、TSX 向けの
363
+ // 正規表現エンジンとは別の走査経路(lintCssFile)を通す。
364
+ const CSS_ENGINES = new Set(["product-theme-override", "parallel-palette"])
357
365
  const rules = loadRules(rulesPath)
358
- const cssRules = rules.filter((rule) => rule.engine === "product-theme-override")
359
- let sourceRules = rules.filter((rule) => rule.engine !== "product-theme-override")
366
+ const cssRules = rules.filter((rule) => CSS_ENGINES.has(rule.engine))
367
+ let sourceRules = rules.filter((rule) => !CSS_ENGINES.has(rule.engine))
360
368
  const productThemeContract = cssRules.length > 0 ? readProductThemeContract(pkgRoot) : null
361
369
  // DS 自身 / ベンダリングされた DS の CSS を P049 の対象から外すための材料(issue #407)
362
370
  const dsCssIdentity = cssRules.length > 0 ? collectDsCssIdentity(pkgRoot) : null
@@ -393,12 +401,19 @@ export async function runLintCli(argv, { cwd = process.cwd(), pkgRoot = resolve(
393
401
  continue
394
402
  }
395
403
  if (CSS_EXTENSIONS.has(extname(file))) {
396
- if (productThemeContract) {
397
- findings.push(...lintCssFile(file, cwd, cssRules, productThemeContract, dsCssIdentity))
404
+ // productThemeContract が読めない環境(contracts/product-theme-overrides.json
405
+ // が無い)でも P050 は動かせるので、CSS ルールが 1 件でもあれば走査する
406
+ // (P049 側は contract が null なら lintCssFile 内で個別に skip する)。
407
+ if (cssRules.length > 0) {
408
+ findings.push(
409
+ ...lintCssFile(file, cwd, cssRules, productThemeContract, dsCssIdentity, pkgRoot),
410
+ )
398
411
  }
399
412
  continue
400
413
  }
401
- findings.push(...lintFile(file, cwd, sourceRules, { ...options, inspectCardChildSpacing }))
414
+ findings.push(
415
+ ...lintFile(file, cwd, sourceRules, { ...options, inspectCardChildSpacing, pkgRoot }),
416
+ )
402
417
  }
403
418
 
404
419
  const summary = summarize(findings)
@@ -645,11 +660,12 @@ function readProductThemeContract(pkgRoot) {
645
660
  }
646
661
 
647
662
  /**
648
- * CSS ファイルには product theme の許可リスト検査(P049)だけを当てる。
649
- * TSX 向けの正規表現ルールを CSS に流すと誤検知しかしない。
663
+ * CSS ファイルには P049(product theme の許可リスト検査)と P050(並行パレット検出、
664
+ * issue #393)だけを当てる。TSX 向けの正規表現ルールを CSS に流すと誤検知しかしない。
650
665
  */
651
- function lintCssFile(file, cwd, cssRules, contract, dsCssIdentity = null) {
666
+ function lintCssFile(file, cwd, cssRules, contract, dsCssIdentity = null, pkgRoot = null) {
652
667
  const rel = normalize(relative(cwd, file))
668
+ const isDsFile = isDsPackageFile(file, pkgRoot)
653
669
  const source = readFileSync(file, "utf8")
654
670
  // 自動生成 CSS(source-safelist.css 等)は生成元を直すべきなので全ルール skip(issue #408)
655
671
  if (isAutoGeneratedSource(source)) return []
@@ -663,10 +679,36 @@ function lintCssFile(file, cwd, cssRules, contract, dsCssIdentity = null) {
663
679
  findings.push(...ignores.problems)
664
680
  for (const rule of cssRules) {
665
681
  // CSS は web のみに存在する(RN に .css は無い)。glob 指定の appliesTo は
666
- // capability タグを含まないので、この判定でも従来どおり P049 が当たる。
682
+ // capability タグを含まないので、この判定でも従来どおり P049/P050 が当たる。
667
683
  if (!ruleAppliesTo(rule, { capabilities: CSS_CAPABILITIES, filePath: rel })) continue
684
+
685
+ if (rule.engine === "parallel-palette") {
686
+ // contract が読めない(DS の名前空間一覧が無い)と何が DS 名前空間かを
687
+ // 判定できず false positive しか出せないので、その場合は skip する。
688
+ if (!contract) continue
689
+ const threshold = typeof rule.minColorVars === "number" ? rule.minColorVars : 5
690
+ const candidates = inspectParallelPaletteCandidates(source, { namespaces: contract.namespaces })
691
+ if (candidates.length < threshold) continue
692
+ const first = candidates[0]
693
+ if (matchesRuleExclude(rule, { file: rel, line: first.name, isDsFile })) continue
694
+ if (ignores.suppresses(rule.id ?? "UNKNOWN", first.line)) continue
695
+ const names = candidates.slice(0, 5).map((c) => c.name).join(", ")
696
+ const suffix = candidates.length > 5 ? ` ほか計${candidates.length}個` : `(計${candidates.length}個)`
697
+ findings.push({
698
+ ...toFinding(rule, rel, first.line, "web"),
699
+ message: `${rule.message ?? "DS を参照しない並行パレットの疑い"}: ${names}${suffix}`,
700
+ })
701
+ continue
702
+ }
703
+
704
+ // contract が読めない環境(contracts/product-theme-overrides.json が無い)では
705
+ // P049 の許可リスト判定ができないので skip する(従来は呼び出し元で丸ごと
706
+ // skip していたのと同じ挙動を、CSS ルール単位に落とし込んだだけ)。
707
+ if (!contract) continue
668
708
  for (const violation of inspectProductThemeOverrides(source, contract)) {
669
- if (matchesRuleExclude(rule, rel, violation.name)) continue
709
+ // violation.name は CSS 変数名(=内容)なので line 側に渡す。
710
+ // パス除外を変数名に当ててはいけない(issue #404 の提案 c)。
711
+ if (matchesRuleExclude(rule, { file: rel, line: violation.name, isDsFile })) continue
670
712
  if (ignores.suppresses(rule.id ?? "UNKNOWN", violation.line)) continue
671
713
  findings.push({
672
714
  ...toFinding(rule, rel, violation.line, "web"),
@@ -679,6 +721,7 @@ function lintCssFile(file, cwd, cssRules, contract, dsCssIdentity = null) {
679
721
 
680
722
  function lintFile(file, cwd, rules, options = {}) {
681
723
  const rel = normalize(relative(cwd, file))
724
+ const isDsFile = isDsPackageFile(file, options.pkgRoot)
682
725
  const source = readFileSync(file, "utf8")
683
726
  // 自動生成物(src/tokens/native/themes.ts 等)は hex をそこにしか書けない。
684
727
  // 直す先は生成物ではなく生成元なので、全ルールを skip する(issue #408)。
@@ -710,7 +753,7 @@ function lintFile(file, cwd, rules, options = {}) {
710
753
  for (const finding of options.inspectCardChildSpacing(source, file)) {
711
754
  const line = lines[finding.line - 1] ?? ""
712
755
  if (ignores.suppresses(rule.id ?? "UNKNOWN", finding.line)) continue
713
- if (!matchesRuleExclude(rule, rel, line)) {
756
+ if (!matchesRuleExclude(rule, { file: rel, line, isDsFile })) {
714
757
  findings.push(toFinding(rule, rel, finding.line, platform))
715
758
  }
716
759
  }
@@ -735,7 +778,7 @@ function lintFile(file, cwd, rules, options = {}) {
735
778
  // excludes(ksk-ds-allow-* 等)はコメントに書かれることが多いため、
736
779
  // 判定は常にマスク前の生の行に対して行う。
737
780
  const rawLine = lines[lineNumber - 1] ?? ""
738
- if (matchesRuleExclude(rule, rel, rawLine)) continue
781
+ if (matchesRuleExclude(rule, { file: rel, line: rawLine, isDsFile })) continue
739
782
  if (ignores.suppresses(rule.id ?? "UNKNOWN", lineNumber)) continue
740
783
  findings.push(toFinding(rule, rel, lineNumber, platform))
741
784
  }
@@ -743,7 +786,7 @@ function lintFile(file, cwd, rules, options = {}) {
743
786
  }
744
787
  for (let index = 0; index < lines.length; index++) {
745
788
  const rawLine = lines[index]
746
- if (matchesRuleExclude(rule, rel, rawLine)) continue
789
+ if (matchesRuleExclude(rule, { file: rel, line: rawLine, isDsFile })) continue
747
790
  const maskedLine = maskedLines[index] ?? ""
748
791
  if (!regex.test(maskedLine)) continue
749
792
  if (ignores.suppresses(rule.id ?? "UNKNOWN", index + 1)) continue
@@ -1076,9 +1119,47 @@ function maskTemplateLiterals(source) {
1076
1119
  return maskStrings(source, TEMPLATE_DELIMITER_ONLY)
1077
1120
  }
1078
1121
 
1079
- function matchesRuleExclude(rule, file, line) {
1080
- const excludes = Array.isArray(rule.excludes) ? rule.excludes : []
1081
- return excludes.some((exclude) => file.includes(exclude) || line.includes(exclude))
1122
+ function excludeList(value) {
1123
+ return Array.isArray(value) ? value : []
1124
+ }
1125
+
1126
+ /**
1127
+ * このファイルは DS パッケージ本体の中にあるか(issue #404)。
1128
+ *
1129
+ * `excludeDsPaths`(`components/ui/` 等)は「DS 自身の実装なので生タグを書いてよい」
1130
+ * という意味の除外なので、consumer が同名ディレクトリ(shadcn 既定の
1131
+ * `src/components/ui/`)を持っているだけで効いてはいけない。judgement は
1132
+ * 走査対象の実ファイルが DS パッケージ(pkgRoot)配下かどうかで行う。
1133
+ */
1134
+ export function isDsPackageFile(absFile, pkgRoot) {
1135
+ if (!pkgRoot || !absFile) return false
1136
+ const rel = relative(resolve(pkgRoot), resolve(absFile))
1137
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)
1138
+ }
1139
+
1140
+ /**
1141
+ * ルール単位の除外判定(issue #404)。
1142
+ *
1143
+ * 除外語彙は用途ごとに 3 つに分かれている:
1144
+ * - `excludePaths` … ファイルパスに当てる。どのリポジトリでも効く(`.stories.` 等)
1145
+ * - `excludeDsPaths` … ファイルパスに当てるが、**DS パッケージ配下のファイルにだけ**効く
1146
+ * (`components/ui/` 等。consumer の同名ディレクトリでは効かない)
1147
+ * - `excludeLines` … 行の内容に当てる(`data-slot` / `asChild` / CSS 変数名 等)
1148
+ *
1149
+ * v1.62.x までは全部が単一の `excludes` に混在し `file.includes || line.includes` の
1150
+ * OR で判定していたため、(1) 行内にパス文字列が出るだけでルールが黙り、
1151
+ * (2) consumer が `src/components/ui/` に部品を置くと中核 13 ルールが丸ごと無効に
1152
+ * なっていた。旧スキーマの `excludes` は後方互換のため従来の OR 判定で引き続き読む。
1153
+ */
1154
+ function matchesRuleExclude(rule, { file, line = "", isDsFile = false }) {
1155
+ if (excludeList(rule.excludePaths).some((exclude) => file.includes(exclude))) return true
1156
+ if (isDsFile && excludeList(rule.excludeDsPaths).some((exclude) => file.includes(exclude))) {
1157
+ return true
1158
+ }
1159
+ if (excludeList(rule.excludeLines).some((exclude) => line.includes(exclude))) return true
1160
+ return excludeList(rule.excludes).some(
1161
+ (exclude) => file.includes(exclude) || line.includes(exclude),
1162
+ )
1082
1163
  }
1083
1164
 
1084
1165
  function shouldIgnorePath(relPath, options) {
@@ -102,6 +102,61 @@ export function inspectProductThemeOverrides(source, contract) {
102
102
  return findings
103
103
  }
104
104
 
105
+ /**
106
+ * P050 — DS を参照しない並行パレットの検出(issue #393)
107
+ *
108
+ * P049 は「DS の名前空間に触れている CSS」しか見ないため、DS 名前空間を
109
+ * 一切使わない独自パレット(`--bg` / `--accent` / `--surface` 等)を持つ
110
+ * consumer には無言だった。`:root` / `.dark` / `[data-theme=...]` のような
111
+ * ルート的セレクタの中で、DS 名前空間に属さないカスタムプロパティへ
112
+ * 色値(hex / rgb / hsl / oklch)を敷いている数を数え、閾値以上なら
113
+ * 「並行パレットの疑い」として warn する。
114
+ *
115
+ * 値が `var(--Primitive-...)` のように DS トークンを参照しているだけの
116
+ * ものはカウントしない(それは並行パレットではなく DS への委譲)。
117
+ */
118
+
119
+ /** ルート的セレクタ(ここで宣言された変数だけがテーマの「パレット」候補) */
120
+ const ROOT_LIKE_BLOCK =
121
+ /(?:^|[};])\s*(:root|\.dark|\[data-theme(?:=[^\]]*)?\])\s*\{([^{}]*)\}/g
122
+
123
+ /** ブロック内のカスタムプロパティ宣言(値も一緒に取る) */
124
+ const DECLARATION_WITH_VALUE = /(^|[;{}\s])(--[A-Za-z0-9_-]+)\s*:\s*([^;]+);/g
125
+
126
+ /** 色値を含む値(hex / rgb() / rgba() / hsl() / hsla() / oklch()) */
127
+ const COLOR_VALUE_RE = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(/i
128
+
129
+ /** 値が `var(...)` の参照だけで完結しているか(DS トークンへの委譲はカウントしない) */
130
+ const VAR_ONLY_RE = /^var\(/i
131
+
132
+ /**
133
+ * @param {string} source CSS ソース
134
+ * @param {{ namespaces?: string[] }} options DS 名前空間の一覧
135
+ * @returns {Array<{ line: number, name: string }>} DS 名前空間に属さず色値を持つ宣言
136
+ */
137
+ export function inspectParallelPaletteCandidates(source, { namespaces = [] } = {}) {
138
+ const stripped = stripCssComments(source)
139
+ const candidates = []
140
+
141
+ for (const block of stripped.matchAll(ROOT_LIKE_BLOCK)) {
142
+ const body = block[2]
143
+ const bodyStart = block.index + block[0].indexOf(body)
144
+ for (const decl of body.matchAll(DECLARATION_WITH_VALUE)) {
145
+ const name = decl[2]
146
+ const value = decl[3].trim()
147
+ if (namespaces.some((prefix) => name.startsWith(prefix))) continue
148
+ if (VAR_ONLY_RE.test(value)) continue
149
+ if (!COLOR_VALUE_RE.test(value)) continue
150
+ const nameIndex = bodyStart + decl.index + decl[0].indexOf(name)
151
+ candidates.push({
152
+ line: stripped.slice(0, nameIndex).split(/\r?\n/).length,
153
+ name,
154
+ })
155
+ }
156
+ }
157
+ return candidates
158
+ }
159
+
105
160
  /**
106
161
  * 契約 JSON から判定に使う形へ畳む。
107
162
  *
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "name": "KSK Design System — Component Contracts",
4
- "version": "1.63.0",
4
+ "version": "1.64.0",
5
5
  "description": "全コンポーネントの構造化定義。バリアント・アクセシビリティ要件・使用ルールを機械可読形式で管理。",
6
6
  "counts": {
7
7
  "ui": 68,
@@ -211,7 +211,10 @@
211
211
  "rules": [
212
212
  "value/onChange は常に \"YYYY-MM-DD\" 文字列。Date オブジェクトを直接渡さない",
213
213
  "strToDate/dateToStr は new Date(y, m-1, d) のローカルタイム方式(TZ ずれ回避)"
214
- ]
214
+ ],
215
+ "props": {
216
+ "triggerLabel": "string — trigger のスクリーンリーダー向けラベル。未指定時は選択済みの日付/placeholder が読み上げられる(issue #426。React Native 版は accessibilityLabel)"
217
+ }
215
218
  },
216
219
  {
217
220
  "name": "Textarea",
@@ -240,6 +243,9 @@
240
243
  "path": "src/components/ui/label.tsx",
241
244
  "description": "フォームラベル。すべての入力コントロールに対して必須。htmlForで入力と紐付ける。",
242
245
  "variants": [],
246
+ "props": {
247
+ "unstyled": "boolean — typo-label-md・文字色・peer-disabled 系クラスを出さず Radix Label の挙動だけ提供(段階移行用 / issue #443, #420の続き)"
248
+ },
243
249
  "rules": [
244
250
  "Always pair with form control via htmlFor"
245
251
  ]
@@ -1037,7 +1043,8 @@
1037
1043
  "3 件以上選択する用途に。少数なら CheckboxGroup を優先"
1038
1044
  ],
1039
1045
  "props": {
1040
- "clearLabel": "string (既定 \"選択をクリア\") — クリアボタンの aria-label(issue #428)"
1046
+ "clearLabel": "string (既定 \"選択をクリア\") — クリアボタンの aria-label(issue #428)",
1047
+ "triggerLabel": "string — trigger のスクリーンリーダー向けラベル。未指定時は未選択なら placeholder、選択済みならチップの表示テキストが読み上げられる(issue #426。React Native 版は accessibilityLabel)"
1041
1048
  }
1042
1049
  },
1043
1050
  {
@@ -2107,7 +2114,10 @@
2107
2114
  "単純な on/off トグルは FilterChip、単一選択リストは DropdownFilter、任意パネル + 明示クリアは FilterPill",
2108
2115
  "適用中かどうかの正本は value(active フラグを別に持たない)",
2109
2116
  "value の整形(複数選択の \"渋谷 他2件\" 等)は呼び出し側の責務"
2110
- ]
2117
+ ],
2118
+ "props": {
2119
+ "icon": "ReactNode — ラベル左のアイコン(装飾・aria-hidden。iconsax size=16 相当)"
2120
+ }
2111
2121
  },
2112
2122
  {
2113
2123
  "name": "ImageOverlayAction",