ksk-design-system 2.1.0 → 2.2.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/AGENTS.md CHANGED
@@ -56,6 +56,16 @@ UI を書く前に必ず確認すること:
56
56
 
57
57
  ---
58
58
 
59
+ ## DS公開通知
60
+
61
+ DSへ依頼するときは、DSの通常issueとappの取り込み待ち通常issueを作成する。DS正本の作業ブランチで `node bin/init.js register-consumer-request --ds-issue N --consumer-issue URL` を実行し、台帳変更をPRに含める。未公開CLIで古いnpm版を起動しないようsourceのコマンドを使う。公開済みCLIでは `npx ksk-ds register-consumer-request` も同じ引数で使える。
62
+
63
+ 登録CLIは既存のgh認証で両issueを確認し、app issueへ `ds:waiting` を自動付与する。closedまたは `ds:released` のissueは降格させない。修正PR作成後は同じコマンドに `--fix-pr N` を付けて紐付ける。省略時は既存fixPrを維持し、累積台帳を削除しない。`--dry-run` は通信・書き込みなしのローカルプレビューで、issue実在や権限は確認しない。失敗は非zero。台帳保存後のラベル失敗は同じコマンドで再試行する。
64
+
65
+ 公開通知はDSの一括bump(`scripts/update-consumers.sh VERSION`)時だけ実行する。公開版に修正が含まれることを確認し、bump PR付きの通知と `ds:released` を付ける。6時間pollingとapp側workflow/scriptは廃止し、`init-release-notices` は移行案内を出して停止する。app側の取り込み・動作確認が完了するまで追従issueは閉じない。詳しくは [公開通知手順](https://github.com/ekusiek716/ksk-design-system/blob/main/docs/consumer-release-notices.md) を参照。
66
+
67
+ 同じapp issueに複数のDS依頼がある場合、公開台帳内の該当依頼がすべて公開・通知済みになるまで `ds:waiting` を残す(`ds:released` と併存する場合あり)。通知はrepo owner本人 `ekusiek716` のPAT認証が前提。通知失敗時は既存bump PRを保持して同じversion・対象repoの一括bumpを再実行し、通知runtime単独実行・並列実行はしない。
68
+
59
69
  ## このDSについて
60
70
 
61
71
  **KSK Design System** は、フリーランスデザイナー / エンジニア / PdM が **複数クライアント案件を1つのDSで高速に回す** ために設計された統合デザインシステムです。
package/CLAUDE.md CHANGED
@@ -163,6 +163,16 @@ afterEach フックが全ストーリー(tags フィルタなし)に対し
163
163
 
164
164
  ---
165
165
 
166
+ ## DS公開通知
167
+
168
+ DSへ依頼するときは、DSの通常issueとappの取り込み待ち通常issueを作成する。DS正本の作業ブランチで `node bin/init.js register-consumer-request --ds-issue N --consumer-issue URL` を実行し、台帳変更をPRに含める。未公開CLIで古いnpm版を起動しないようsourceのコマンドを使う。公開済みCLIでは `npx ksk-ds register-consumer-request` も同じ引数で使える。
169
+
170
+ 登録CLIは既存のgh認証で両issueを確認し、app issueへ `ds:waiting` を自動付与する。closedまたは `ds:released` のissueは降格させない。修正PR作成後は同じコマンドに `--fix-pr N` を付けて紐付ける。省略時は既存fixPrを維持し、累積台帳を削除しない。`--dry-run` は通信・書き込みなしのローカルプレビューで、issue実在や権限は確認しない。失敗は非zero。台帳保存後のラベル失敗は同じコマンドで再試行する。
171
+
172
+ 公開通知はDSの一括bump(`scripts/update-consumers.sh VERSION`)時だけ実行する。公開版に修正が含まれることを確認し、bump PR付きの通知と `ds:released` を付ける。6時間pollingとapp側workflow/scriptは廃止し、`init-release-notices` は移行案内を出して停止する。app側の取り込み・動作確認が完了するまで追従issueは閉じない。詳しくは [公開通知手順](https://github.com/ekusiek716/ksk-design-system/blob/main/docs/consumer-release-notices.md) を参照。
173
+
174
+ 同じapp issueに複数のDS依頼がある場合、公開台帳内の該当依頼がすべて公開・通知済みになるまで `ds:waiting` を残す(`ds:released` と併存する場合あり)。通知はrepo owner本人 `ekusiek716` のPAT認証が前提。通知失敗時は既存bump PRを保持して同じversion・対象repoの一括bumpを再実行し、通知runtime単独実行・並列実行はしない。
175
+
166
176
  ## このDSについて
167
177
 
168
178
  **KSK Design System** は、フリーランスデザイナー / エンジニア / PdM が **複数クライアント案件を1つのDSで高速に回す** ために設計された統合デザインシステムです。
package/NATIVE_RECIPES.md CHANGED
@@ -182,6 +182,8 @@ import { Button, PhotoHero, Screen } from "ksk-design-system/native/ui"
182
182
 
183
183
  Web では `CompactFilePicker` / `ImageAttachmentPicker` が hidden file input、trigger、preview、remove affordance を持ちます。Native では DocumentPicker / ImagePicker の起動だけ consumer が渡し、trigger と preview は DS が持ちます。
184
184
 
185
+ `loadingLabel`(loading 中にボタンへ表示する文言)は web の既定が `"処理中"` なのに対し、native には既定値が無く、未指定時は native Button の spinner のみが表示されます(issue #539。web/native で意図的に非対称)。
186
+
185
187
  ```tsx
186
188
  <ImageAttachmentPicker
187
189
  multiple
package/PUBLISHING.md CHANGED
@@ -244,6 +244,16 @@ publish.yml が失敗・スタックした場合の実行可能な手順は次
244
244
  それでも解消しない場合は、npm 側の障害か Trusted Publishing 設定(npmjs.com の
245
245
  Trusted Publisher 登録)を疑い、対応後に再度 workflow_dispatch で実行する。
246
246
 
247
+ ## 依頼元appへの公開通知
248
+
249
+ DSへ依頼するときは、DSの通常issueとappの取り込み待ち通常issueを作成する。DS正本の作業ブランチで `node bin/init.js register-consumer-request --ds-issue N --consumer-issue URL` を実行し、台帳変更をPRに含める。未公開CLIで古いnpm版を起動しないようsourceのコマンドを使う。公開済みCLIでは `npx ksk-ds register-consumer-request` も同じ引数で使える。
250
+
251
+ 登録CLIは既存のgh認証で両issueを確認し、app issueへ `ds:waiting` を自動付与する。closedまたは `ds:released` のissueは降格させない。修正PR作成後は同じコマンドに `--fix-pr N` を付けて紐付ける。省略時は既存fixPrを維持し、累積台帳を削除しない。`--dry-run` は通信・書き込みなしのローカルプレビューで、issue実在や権限は確認しない。失敗は非zero。台帳保存後のラベル失敗は同じコマンドで再試行する。
252
+
253
+ 公開通知はDSの一括bump(`scripts/update-consumers.sh VERSION`)時だけ実行する。公開版に修正が含まれることを確認し、bump PR付きの通知と `ds:released` を付ける。6時間pollingとapp側workflow/scriptは廃止し、`init-release-notices` は移行案内を出して停止する。app側の取り込み・動作確認が完了するまで追従issueは閉じない。詳しくは [公開通知手順](https://github.com/ekusiek716/ksk-design-system/blob/main/docs/consumer-release-notices.md) を参照。
254
+
255
+ 同じapp issueに複数のDS依頼がある場合、公開台帳内の該当依頼がすべて公開・通知済みになるまで `ds:waiting` を残す(`ds:released` と併存する場合あり)。通知はrepo owner本人 `ekusiek716` のPAT認証が前提。通知失敗時は既存bump PRを保持して同じversion・対象repoの一括bumpを再実行し、通知runtime単独実行・並列実行はしない。
256
+
247
257
  ## 関連
248
258
 
249
259
  - [UPDATING.md](./UPDATING.md) — 消費側(DS を npm 依存に持つプロジェクト)向けのアップデート手順
package/README.md CHANGED
@@ -474,6 +474,16 @@ npm run metrics -- --range last-year
474
474
 
475
475
  > npm は匿名集計のため、CI やミラーの重複を含む **install 回数の目安** です(ユニークユーザー数ではありません)。
476
476
 
477
+ ## DS公開通知
478
+
479
+ DSへ依頼するときは、DSの通常issueとappの取り込み待ち通常issueを作成する。DS正本の作業ブランチで `node bin/init.js register-consumer-request --ds-issue N --consumer-issue URL` を実行し、台帳変更をPRに含める。未公開CLIで古いnpm版を起動しないようsourceのコマンドを使う。公開済みCLIでは `npx ksk-ds register-consumer-request` も同じ引数で使える。
480
+
481
+ 登録CLIは既存のgh認証で両issueを確認し、app issueへ `ds:waiting` を自動付与する。closedまたは `ds:released` のissueは降格させない。修正PR作成後は同じコマンドに `--fix-pr N` を付けて紐付ける。省略時は既存fixPrを維持し、累積台帳を削除しない。`--dry-run` は通信・書き込みなしのローカルプレビューで、issue実在や権限は確認しない。失敗は非zero。台帳保存後のラベル失敗は同じコマンドで再試行する。
482
+
483
+ 公開通知はDSの一括bump(`scripts/update-consumers.sh VERSION`)時だけ実行する。公開版に修正が含まれることを確認し、bump PR付きの通知と `ds:released` を付ける。6時間pollingとapp側workflow/scriptは廃止し、`init-release-notices` は移行案内を出して停止する。app側の取り込み・動作確認が完了するまで追従issueは閉じない。詳しくは [公開通知手順](https://github.com/ekusiek716/ksk-design-system/blob/main/docs/consumer-release-notices.md) を参照。
484
+
485
+ 同じapp issueに複数のDS依頼がある場合、公開台帳内の該当依頼がすべて公開・通知済みになるまで `ds:waiting` を残す(`ds:released` と併存する場合あり)。通知はrepo owner本人 `ekusiek716` のPAT認証が前提。通知失敗時は既存bump PRを保持して同じversion・対象repoの一括bumpを再実行し、通知runtime単独実行・並列実行はしない。
486
+
477
487
  ## 📄 ライセンス
478
488
 
479
489
  [MIT](./LICENSE) © 奥野圭祐 (Keisuke Okuno)
package/RELEASE.md CHANGED
@@ -74,6 +74,10 @@ GitHub Release の作成をスキップしてしまう。そのため:
74
74
  ローカルから `npm publish` を直接叩く経路は廃止した(publish.yml への一本化。
75
75
  下記参照)。
76
76
 
77
+ ## 未公開の修正
78
+
79
+ - [#529](https://github.com/ekusiek716/ksk-design-system/issues/529): native Dialog のダークモードで、中央・上寄せの確認面が背景と同化する問題を修正。パネルに `surface.secondary` を使い、余白と角丸を見分けられるようにする。light・fullscreen の背景、寸法、safe-area は維持。公開後に利用アプリへ取り込み、実機で確認する。
80
+
77
81
  ## ホットフィックス履歴
78
82
 
79
83
  | 日付 | バージョン | 内容 | 影響 |
@@ -0,0 +1,138 @@
1
+ import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { join, relative, resolve } from "node:path"
3
+ import { spawnSync } from "node:child_process"
4
+ import { parseConsumerIssue, validateContract } from "../templates/consumer-release-notices/check-ds-release.mjs"
5
+
6
+ function positiveInt(value, name) {
7
+ if (!/^[1-9]\d*$/.test(String(value)) || !Number.isSafeInteger(Number(value))) {
8
+ throw new Error(`${name} must be a positive safe integer`)
9
+ }
10
+ return Number(value)
11
+ }
12
+
13
+ function issueUrl(value) {
14
+ if (typeof value !== "string" || /\s/.test(value)) throw new Error("consumer-issue must be a GitHub issue URL without whitespace")
15
+ const canonical = value.toLowerCase()
16
+ parseConsumerIssue(canonical)
17
+ return canonical
18
+ }
19
+
20
+ function statOrNull(path) {
21
+ try { return lstatSync(path) } catch (error) {
22
+ if (error.code === "ENOENT") return null
23
+ throw error
24
+ }
25
+ }
26
+
27
+ function safePath(root, path) {
28
+ let current = root
29
+ const parts = relative(root, path).split(/[\\/]/)
30
+ for (const [index, part] of parts.entries()) {
31
+ current = join(current, part)
32
+ const stat = statOrNull(current)
33
+ if (stat?.isSymbolicLink()) throw new Error(`Symlink is not allowed: ${current}`)
34
+ if (stat && index < parts.length - 1 && !stat.isDirectory()) throw new Error(`Not a directory: ${current}`)
35
+ }
36
+ }
37
+
38
+ export function validateConsumerRequests(registry) {
39
+ validateContract(registry)
40
+ for (const request of registry.requests) issueUrl(request.consumerIssue)
41
+ return registry
42
+ }
43
+
44
+ function github(runGh) {
45
+ return (endpoint, method = "GET", fields = {}, paginate = false) => {
46
+ const args = ["api", "--hostname", "github.com", endpoint, "--method", method]
47
+ if (paginate) args.push("--paginate", "--slurp")
48
+ for (const [key, value] of Object.entries(fields)) args.push("-f", `${key}=${value}`)
49
+ const result = runGh("gh", args, { encoding: "utf8", timeout: 30000, maxBuffer: 8 * 1024 * 1024 })
50
+ // Do not echo gh stderr: it may include credentials or private response details.
51
+ if (result.error || result.status !== 0) throw new Error(`GitHub ${method} failed: ${endpoint}. Check gh authentication/permissions and retry the same command.`)
52
+ try { return JSON.parse(result.stdout) } catch { throw new Error(`Invalid GitHub response: ${endpoint}`) }
53
+ }
54
+ }
55
+
56
+ function readIssue(api, repo, number) {
57
+ const issue = api(`repos/${repo}/issues/${number}`)
58
+ if (!issue || issue.pull_request || issue.number !== number ||
59
+ typeof issue.html_url !== "string" || issueUrl(issue.html_url) !== `https://github.com/${repo}/issues/${number}` ||
60
+ !["open", "closed"].includes(issue.state)) throw new Error(`Expected a regular issue: ${repo}#${number}`)
61
+ return issue
62
+ }
63
+
64
+ function ensureWaiting(api, repo, number) {
65
+ // Re-read after writing the registry so a retry cannot downgrade released/closed work.
66
+ const issue = readIssue(api, repo, number)
67
+ if (issue.state === "closed") return
68
+ const pages = api(`repos/${repo}/issues/${number}/labels?per_page=100`, "GET", {}, true)
69
+ if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) throw new Error("Invalid issue label response")
70
+ const labels = pages.flat()
71
+ if (labels.some((label) => typeof label?.name !== "string")) throw new Error("Invalid issue label response")
72
+ if (labels.some((label) => ["ds:released", "ds:waiting"].includes(label.name.toLowerCase()))) return
73
+ const endpoint = `repos/${repo}/labels/ds%3Awaiting`
74
+ try { api(endpoint) } catch {
75
+ try { api(`repos/${repo}/labels`, "POST", { name: "ds:waiting", color: "fbca04", description: "DS修正版の公開待ち" }) }
76
+ catch { api(endpoint) } // Another registration may have created the label concurrently.
77
+ }
78
+ api(`repos/${repo}/issues/${number}/labels`, "POST", { "labels[]": "ds:waiting" })
79
+ console.log(`Ensured ds:waiting on ${repo}#${number}`)
80
+ }
81
+
82
+ function register(args, cwd, runGh) {
83
+ const options = new Map()
84
+ for (let i = 0; i < args.length; i++) {
85
+ const key = args[i]
86
+ if (!["--ds-issue", "--consumer-issue", "--fix-pr", "--dry-run"].includes(key) || options.has(key)) throw new Error(`Unknown or duplicate option: ${key}`)
87
+ if (key === "--dry-run") options.set(key, true)
88
+ else {
89
+ const value = args[++i]
90
+ if (!value || value.startsWith("--")) throw new Error(`Missing value: ${key}`)
91
+ options.set(key, value)
92
+ }
93
+ }
94
+ const dsIssue = positiveInt(options.get("--ds-issue"), "ds-issue")
95
+ const consumerIssue = issueUrl(options.get("--consumer-issue"))
96
+ const fixPr = options.has("--fix-pr") ? positiveInt(options.get("--fix-pr"), "fix-pr") : undefined
97
+ const packagePath = join(cwd, "package.json")
98
+ const contracts = join(cwd, "contracts")
99
+ safePath(cwd, packagePath)
100
+ safePath(cwd, contracts)
101
+ if (!existsSync(join(cwd, ".git")) || !statOrNull(contracts)?.isDirectory() ||
102
+ !existsSync(packagePath) || JSON.parse(readFileSync(packagePath, "utf8")).name !== "ksk-design-system") {
103
+ throw new Error("Run registration at the DS source repo root (ksk-design-system + .git + contracts/), never in a consumer")
104
+ }
105
+ const path = join(contracts, "consumer-requests.json")
106
+ safePath(cwd, path)
107
+ const before = existsSync(path) ? readFileSync(path, "utf8") : null
108
+ const registry = before === null ? { schemaVersion: 1, requests: [] } : JSON.parse(before)
109
+ validateConsumerRequests(registry)
110
+ const existing = registry.requests.find((request) => request.dsIssue === dsIssue && issueUrl(request.consumerIssue) === consumerIssue)
111
+ if (existing) {
112
+ existing.consumerIssue = consumerIssue
113
+ if (fixPr !== undefined) existing.fixPr = fixPr
114
+ } else registry.requests.push({ dsIssue, consumerIssue, fixPr: fixPr ?? null })
115
+ const after = JSON.stringify(registry, null, 2) + "\n"
116
+ if (options.has("--dry-run")) { console.log(after.trimEnd()); return }
117
+ const api = github(runGh)
118
+ const target = parseConsumerIssue(consumerIssue)
119
+ readIssue(api, "ekusiek716/ksk-design-system", dsIssue)
120
+ readIssue(api, target.repo, target.number)
121
+ if (before === null || JSON.stringify(JSON.parse(before)) !== JSON.stringify(registry)) {
122
+ writeFileSync(path, after)
123
+ console.log(`Updated ${path}`)
124
+ } else console.log("Consumer request already registered (no-op)")
125
+ ensureWaiting(api, target.repo, target.number)
126
+ }
127
+
128
+ export function runConsumerReleaseNoticesCli(command, args, { cwd, runGh = spawnSync }) {
129
+ try {
130
+ if (command === "register-consumer-request") register(args, resolve(cwd), runGh)
131
+ else if (command === "init-release-notices") throw new Error("init-release-notices is retired: remove the consumer polling workflow/script. Release notices run only during the DS bulk bump (scripts/update-consumers.sh).")
132
+ else throw new Error(`Unknown command: ${command}`)
133
+ return 0
134
+ } catch (error) {
135
+ console.error(`[ksk-ds ${command}] ${error.message}`)
136
+ return 1
137
+ }
138
+ }
package/bin/init.js CHANGED
@@ -59,6 +59,9 @@ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
59
59
  使い方:
60
60
  npx ksk-design-system init AI ルールファイルを設置
61
61
  npx ksk-design-system init --force 既存ファイルを上書き
62
+ npx ksk-ds register-consumer-request --ds-issue N --consumer-issue URL [--fix-pr N] [--dry-run]
63
+ DS依頼を登録しapp issueへds:waitingを付与(gh認証使用)
64
+ npx ksk-ds init-release-notices 廃止済み。一括bump時の公開通知へ移行
62
65
  npx ksk-design-system demo [dir] DS リポを clone + npm install(お試し)
63
66
  dir 省略時は ./ksk-ds-demo
64
67
  npx ksk-ds lint src DS-first ルール違反を検査
@@ -75,6 +78,11 @@ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
75
78
  await exitWith(0)
76
79
  }
77
80
 
81
+ if (cmd === "register-consumer-request" || cmd === "init-release-notices") {
82
+ const { runConsumerReleaseNoticesCli } = await import("./consumer-release-notices.js")
83
+ await exitWith(runConsumerReleaseNoticesCli(cmd, args.slice(1), { cwd: process.cwd(), pkgRoot }))
84
+ }
85
+
78
86
  if (cmd === "lint") {
79
87
  const { runLintCli } = await import("./lint.js")
80
88
  const status = await runLintCli(args.slice(1), { cwd: process.cwd(), pkgRoot })
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "name": "KSK Design System — Component Contracts",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
5
5
  "description": "全コンポーネントの構造化定義。バリアント・アクセシビリティ要件・使用ルールを機械可読形式で管理。",
6
6
  "counts": {
7
7
  "ui": 68,
@@ -1657,10 +1657,14 @@
1657
1657
  "compactStatus",
1658
1658
  "actions"
1659
1659
  ],
1660
+ "props": {
1661
+ "verticalAlignment": "\"center\" | \"top\" (既定 \"center\") — ヘッダー行の縦位置。複数行 brand で status/actions が中央へ下がるのを防ぐ。top でも status/actions 内部は中央揃えのまま(native と同仕様。issue #536)"
1662
+ },
1660
1663
  "rules": [
1661
1664
  "ブランド/ロゴは brand slot に置く",
1662
1665
  "長い同期状態は status と compactStatus の両方を渡す",
1663
- "右側 actions のために brand に flex-shrink をかけない"
1666
+ "右側 actions のために brand に flex-shrink をかけない",
1667
+ "複数行になり得る brand を使うときは verticalAlignment=\"top\" を検討する(既定は上下中央)"
1664
1668
  ]
1665
1669
  },
1666
1670
  {
@@ -2380,9 +2384,13 @@
2380
2384
  "grid",
2381
2385
  "list"
2382
2386
  ],
2387
+ "props": {
2388
+ "loadingLabel": "ReactNode (既定 \"処理中\") — loading 時にボタンへ表示する文言。i18n対応(issue #539)。native は既定値を持たず、未指定時は native Button の spinner のみを表示する(web/native で意図的に非対称。scripts/check-native-parity.mjs の INTENTIONAL_DEFAULT_GAPS 参照)"
2389
+ },
2383
2390
  "accessibility": [
2384
2391
  "img alt",
2385
- "remove button aria-label"
2392
+ "remove button aria-label",
2393
+ "aria-busy on trigger button while loading(テスト/自動化向けの状態フック。loading 中はボタン自体が disabled になるため、スクリーンリーダーへの読み上げ効果は限定的)"
2386
2394
  ],
2387
2395
  "rules": [
2388
2396
  "consumer 側で hidden input + Button trigger を重複実装しない",
@@ -0,0 +1,10 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "requests": [
4
+ {
5
+ "dsIssue": 531,
6
+ "consumerIssue": "https://github.com/ekusiek716/trip-todo/issues/168",
7
+ "fixPr": 532
8
+ }
9
+ ]
10
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "name": "KSK Design System — Semantic Token Hex Cache",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
5
5
  "description": "semantic / semanticDark トークン(var(--Primitive-*) 参照)を実 hex に解決したサイドカー生成物。hex はデフォルト(Blue)テーマでの解決値であり、Brand 系(meta.themeDependentKeys に列挙)はテーマ差し替え(orange/green/violet 等)で実色が変わる。テーマ別の完全解決値は `ksk-design-system/native` エクスポート(バンドル済み native トークンモジュール)の themes を参照。tokens.json 本体のスキーマは変更せず、AI がこのファイルだけで実色を把握できるようにし、primitive 値の変更による semantic 実色のドリフトを --check で機械検出する。",
6
6
  "generatedBy": "scripts/generate-token-hex-cache.mjs",
7
7
  "theme": "default",
package/dist/index.js CHANGED
@@ -8474,7 +8474,7 @@ function ju({ steps: e, open: t, onComplete: n, onSkip: r, variant: i = "default
8474
8474
  let e = 0, n = 0, r = () => {
8475
8475
  let t = Array.from(_.querySelectorAll("button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]")).find((e) => e.tabIndex >= 0);
8476
8476
  if (t) {
8477
- t.focus();
8477
+ t.focus({ preventScroll: !0 });
8478
8478
  return;
8479
8479
  }
8480
8480
  n++ < 5 && (e = requestAnimationFrame(r));
@@ -8538,7 +8538,7 @@ function ju({ steps: e, open: t, onComplete: n, onSkip: r, variant: i = "default
8538
8538
  trapped: !0,
8539
8539
  loop: !0,
8540
8540
  onMountAutoFocus: (e) => {
8541
- c || (e.preventDefault(), e.currentTarget instanceof HTMLElement && e.currentTarget.focus());
8541
+ c || (e.preventDefault(), e.currentTarget instanceof HTMLElement && e.currentTarget.focus({ preventScroll: !0 }));
8542
8542
  },
8543
8543
  onUnmountAutoFocus: (e) => {
8544
8544
  l || e.preventDefault();
@@ -8579,9 +8579,9 @@ function ju({ steps: e, open: t, onComplete: n, onSkip: r, variant: i = "default
8579
8579
  className: "fixed pointer-events-none",
8580
8580
  style: D && h ? {
8581
8581
  top: h.top,
8582
- left: h.left + h.width / 2,
8583
- width: 1,
8584
- height: 1
8582
+ left: h.left,
8583
+ width: h.width,
8584
+ height: h.height
8585
8585
  } : {
8586
8586
  top: "50%",
8587
8587
  left: "50%"
@@ -12983,13 +12983,13 @@ function xf({ className: e, label: t, icon: n, showLabel: r = !1, placement: i =
12983
12983
  }
12984
12984
  //#endregion
12985
12985
  //#region src/components/patterns/mobile-app-header.tsx
12986
- function Sf({ className: e, brand: t, leading: n, status: r, compactStatus: i, actions: a, sticky: o = !0, bordered: s = !0, landmark: c = !0, children: l, ...u }) {
12986
+ function Sf({ className: e, brand: t, leading: n, status: r, compactStatus: i, actions: a, sticky: o = !0, bordered: s = !0, landmark: c = !0, verticalAlignment: l = "center", children: u, ...d }) {
12987
12987
  return /* @__PURE__ */ V(c ? "header" : "div", {
12988
12988
  "data-slot": "mobile-app-header",
12989
12989
  className: G("bg-[var(--Surface-Primary)] text-[var(--Text-High-Emphasis)]", o && "sticky top-0 z-[var(--Z-Nav)]", s && "border-b border-[var(--Border-Low-Emphasis)]", e),
12990
- ...u,
12990
+ ...d,
12991
12991
  children: [/* @__PURE__ */ V("div", {
12992
- className: "flex min-h-14 items-center gap-2 px-4",
12992
+ className: G("flex min-h-14 gap-2 px-4", l === "top" ? "items-start" : "items-center"),
12993
12993
  children: [
12994
12994
  n && /* @__PURE__ */ B("div", {
12995
12995
  "data-slot": "mobile-app-header-leading",
@@ -13020,7 +13020,7 @@ function Sf({ className: e, brand: t, leading: n, status: r, compactStatus: i, a
13020
13020
  ]
13021
13021
  })
13022
13022
  ]
13023
- }), l]
13023
+ }), u]
13024
13024
  });
13025
13025
  }
13026
13026
  //#endregion
@@ -16172,35 +16172,35 @@ function Nm({ accept: e, maxSize: t, multiple: n = !1, maxFiles: r = 10, onUploa
16172
16172
  }
16173
16173
  //#endregion
16174
16174
  //#region src/components/patterns/compact-file-picker.tsx
16175
- function Pm({ className: e, inputClassName: t, label: n = "ファイル", description: r, triggerLabel: i = "選択する", icon: a, loading: o = !1, disabled: s, id: c, multiple: l, onFilesChange: d, ...f }) {
16176
- let p = u.useId(), m = c ?? p, h = u.useRef(null), g = s || o;
16175
+ function Pm({ className: e, inputClassName: t, label: n = "ファイル", description: r, triggerLabel: i = "選択する", loadingLabel: a = "処理中", icon: o, loading: s = !1, disabled: c, id: l, multiple: d, onFilesChange: f, ...p }) {
16176
+ let m = u.useId(), h = l ?? m, g = u.useRef(null), _ = c || s;
16177
16177
  return /* @__PURE__ */ V("div", {
16178
16178
  "data-slot": "compact-file-picker",
16179
16179
  className: G("flex items-center gap-3", e),
16180
16180
  children: [
16181
16181
  /* @__PURE__ */ B("input", {
16182
- ...f,
16183
- ref: h,
16184
- id: m,
16182
+ ...p,
16183
+ ref: g,
16184
+ id: h,
16185
16185
  type: "file",
16186
- multiple: l,
16187
- disabled: g,
16186
+ multiple: d,
16187
+ disabled: _,
16188
16188
  className: G("sr-only", t),
16189
16189
  onChange: (e) => {
16190
16190
  let t = Array.from(e.currentTarget.files ?? []);
16191
- d?.(t), e.currentTarget.value = "";
16191
+ f?.(t), e.currentTarget.value = "";
16192
16192
  }
16193
16193
  }),
16194
16194
  /* @__PURE__ */ V("div", {
16195
16195
  className: "flex min-w-0 flex-1 items-center gap-3",
16196
16196
  children: [/* @__PURE__ */ B("span", {
16197
16197
  className: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-[var(--Surface-Secondary)] text-[var(--Object-Medium-Emphasis)]",
16198
- children: a ?? /* @__PURE__ */ B(se, {
16198
+ children: o ?? /* @__PURE__ */ B(se, {
16199
16199
  size: 20,
16200
16200
  "aria-hidden": !0
16201
16201
  })
16202
16202
  }), /* @__PURE__ */ V("label", {
16203
- htmlFor: m,
16203
+ htmlFor: h,
16204
16204
  className: "min-w-0 flex-1",
16205
16205
  children: [/* @__PURE__ */ B("span", {
16206
16206
  className: "typo-label-md block truncate text-[var(--Text-High-Emphasis)]",
@@ -16215,9 +16215,10 @@ function Pm({ className: e, inputClassName: t, label: n = "ファイル", descri
16215
16215
  type: "button",
16216
16216
  variant: "secondary",
16217
16217
  size: "sm",
16218
- disabled: g,
16219
- onClick: () => h.current?.click(),
16220
- children: o ? "処理中" : i
16218
+ disabled: _,
16219
+ "aria-busy": s || void 0,
16220
+ onClick: () => g.current?.click(),
16221
+ children: s ? a : i
16221
16222
  })
16222
16223
  ]
16223
16224
  });
package/dist/native/ui.js CHANGED
@@ -291,7 +291,7 @@ function X({ variant: e = "primary", elevation: t = "flat", containerStyle: n, p
291
291
  secondary: {
292
292
  bg: m.surface["accent-primary-light"],
293
293
  bgActive: m.active["secondary-button"],
294
- fg: m.text["accent-primary"],
294
+ fg: g === "dark" ? m.text["high-emphasis"] : m.text["accent-primary"],
295
295
  border: m.border["accent-primary"],
296
296
  bottomBorder: m.active["secondary-button"]
297
297
  },
@@ -2736,34 +2736,34 @@ function _n({ tone: e = "info", title: t, description: n, children: r }) {
2736
2736
  //#endregion
2737
2737
  //#region src/native/components/Dialog.tsx
2738
2738
  function vn({ open: e, onClose: t, title: n, description: r, footer: i, children: a, dismissOnBackdrop: o = !0, position: s = "center", safeArea: c = !0 }) {
2739
- let { theme: l, scales: u } = G(), d = q(), f = s === "fullscreen", p = ue(u.spacing.scale[5], d, c);
2739
+ let { theme: l, scales: u, mode: d } = G(), f = q(), p = s === "fullscreen", m = ue(u.spacing.scale[5], f, c);
2740
2740
  return /* @__PURE__ */ E(I, {
2741
2741
  visible: e,
2742
- transparent: !f,
2742
+ transparent: !p,
2743
2743
  animationType: "fade",
2744
2744
  onRequestClose: t,
2745
2745
  children: /* @__PURE__ */ E(R, {
2746
- onPress: () => o && !f && t(),
2746
+ onPress: () => o && !p && t(),
2747
2747
  style: {
2748
2748
  flex: 1,
2749
- backgroundColor: f ? l.surface.primary : l.overlay.dark,
2749
+ backgroundColor: p ? l.surface.primary : l.overlay.dark,
2750
2750
  alignItems: "center",
2751
2751
  justifyContent: s === "top" ? "flex-start" : "center",
2752
- ...f ? { padding: 0 } : {
2752
+ ...p ? { padding: 0 } : {
2753
2753
  padding: u.spacing.scale[4],
2754
- ...s === "top" ? { paddingTop: u.spacing.scale[4] + J(d, "top", c) } : null
2754
+ ...s === "top" ? { paddingTop: u.spacing.scale[4] + J(f, "top", c) } : null
2755
2755
  }
2756
2756
  },
2757
2757
  children: /* @__PURE__ */ D(R, {
2758
2758
  onPress: () => {},
2759
2759
  style: {
2760
2760
  width: "100%",
2761
- backgroundColor: l.surface.primary,
2761
+ backgroundColor: !p && d === "dark" ? l.surface.secondary : l.surface.primary,
2762
2762
  gap: u.spacing.scale[3],
2763
- ...f ? {
2763
+ ...p ? {
2764
2764
  flex: 1,
2765
2765
  borderRadius: 0,
2766
- ...p
2766
+ ...m
2767
2767
  } : {
2768
2768
  maxWidth: 480,
2769
2769
  borderRadius: u.borderRadius["2xl"],
@@ -5441,22 +5441,22 @@ function ai({ title: e, subtitle: t, leading: n, trailing: r, onBack: i, centere
5441
5441
  }
5442
5442
  //#endregion
5443
5443
  //#region src/native/components/MobileAppHeader.tsx
5444
- function oi({ brand: e, leading: t, status: n, compactStatus: r, actions: i, children: a, bordered: o = !0, style: s }) {
5445
- let { theme: c, scales: l } = G();
5444
+ function oi({ brand: e, leading: t, status: n, compactStatus: r, actions: i, children: a, bordered: o = !0, verticalAlignment: s = "center", style: c }) {
5445
+ let { theme: l, scales: u } = G();
5446
5446
  return /* @__PURE__ */ D(H, {
5447
5447
  style: [{
5448
- backgroundColor: c.surface.primary,
5448
+ backgroundColor: l.surface.primary,
5449
5449
  borderBottomWidth: +!!o,
5450
- borderBottomColor: c.border["low-emphasis"],
5451
- paddingTop: L.OS === "ios" ? 48 : l.spacing.scale[3]
5452
- }, s],
5450
+ borderBottomColor: l.border["low-emphasis"],
5451
+ paddingTop: L.OS === "ios" ? 48 : u.spacing.scale[3]
5452
+ }, c],
5453
5453
  children: [/* @__PURE__ */ D(H, {
5454
5454
  style: {
5455
5455
  minHeight: 56,
5456
- paddingHorizontal: l.spacing.scale[4],
5456
+ paddingHorizontal: u.spacing.scale[4],
5457
5457
  flexDirection: "row",
5458
- alignItems: "center",
5459
- gap: l.spacing.scale[2]
5458
+ alignItems: s === "top" ? "flex-start" : "center",
5459
+ gap: u.spacing.scale[2]
5460
5460
  },
5461
5461
  children: [
5462
5462
  t,
@@ -5474,7 +5474,7 @@ function oi({ brand: e, leading: t, status: n, compactStatus: r, actions: i, chi
5474
5474
  flexDirection: "row",
5475
5475
  justifyContent: "flex-end",
5476
5476
  alignItems: "center",
5477
- gap: l.spacing.scale[2]
5477
+ gap: u.spacing.scale[2]
5478
5478
  },
5479
5479
  children: [r ?? n, i]
5480
5480
  })
@@ -6802,23 +6802,23 @@ function $i({ item: e, itemDirection: t, onReveal: n }) {
6802
6802
  }
6803
6803
  //#endregion
6804
6804
  //#region src/native/components/CompactFilePicker.tsx
6805
- function ea({ label: e = "ファイル", description: t, triggerLabel: n = "選択する", icon: r, loading: i = !1, disabled: a = !1, onPress: o, style: s }) {
6806
- let { theme: c, scales: l } = G(), u = a || i;
6805
+ function ea({ label: e = "ファイル", description: t, triggerLabel: n = "選択する", loadingLabel: r, icon: i, loading: a = !1, disabled: o = !1, onPress: s, style: c }) {
6806
+ let { theme: l, scales: u } = G(), d = o || a;
6807
6807
  return /* @__PURE__ */ D(R, {
6808
- disabled: u,
6809
- onPress: o,
6808
+ disabled: d,
6809
+ onPress: s,
6810
6810
  accessibilityRole: "button",
6811
6811
  accessibilityState: {
6812
- disabled: u,
6813
- busy: i
6812
+ disabled: d,
6813
+ busy: a
6814
6814
  },
6815
6815
  style: [{
6816
6816
  minHeight: 56,
6817
6817
  flexDirection: "row",
6818
6818
  alignItems: "center",
6819
- gap: l.spacing.scale[3],
6820
- opacity: u ? .5 : 1
6821
- }, s],
6819
+ gap: u.spacing.scale[3],
6820
+ opacity: d ? .5 : 1
6821
+ }, c],
6822
6822
  children: [
6823
6823
  /* @__PURE__ */ E(H, {
6824
6824
  style: {
@@ -6826,11 +6826,11 @@ function ea({ label: e = "ファイル", description: t, triggerLabel: n = "選
6826
6826
  height: 40,
6827
6827
  alignItems: "center",
6828
6828
  justifyContent: "center",
6829
- borderRadius: l.borderRadius.lg,
6830
- backgroundColor: c.surface.secondary
6829
+ borderRadius: u.borderRadius.lg,
6830
+ backgroundColor: l.surface.secondary
6831
6831
  },
6832
- children: r ?? /* @__PURE__ */ E(B, {
6833
- style: [Y("heading.md"), { color: c.text["medium-emphasis"] }],
6832
+ children: i ?? /* @__PURE__ */ E(B, {
6833
+ style: [Y("heading.md"), { color: l.text["medium-emphasis"] }],
6834
6834
  children: "+"
6835
6835
  })
6836
6836
  }),
@@ -6841,11 +6841,11 @@ function ea({ label: e = "ファイル", description: t, triggerLabel: n = "選
6841
6841
  },
6842
6842
  children: [typeof e == "string" ? /* @__PURE__ */ E(B, {
6843
6843
  numberOfLines: 1,
6844
- style: [Y("label.md"), { color: c.text["high-emphasis"] }],
6844
+ style: [Y("label.md"), { color: l.text["high-emphasis"] }],
6845
6845
  children: e
6846
6846
  }) : e, t && (typeof t == "string" ? /* @__PURE__ */ E(B, {
6847
6847
  style: [Y("body.sm"), {
6848
- color: c.text["medium-emphasis"],
6848
+ color: l.text["medium-emphasis"],
6849
6849
  marginTop: 2
6850
6850
  }],
6851
6851
  children: t
@@ -6853,9 +6853,10 @@ function ea({ label: e = "ファイル", description: t, triggerLabel: n = "選
6853
6853
  }),
6854
6854
  /* @__PURE__ */ E(X, {
6855
6855
  variant: "secondary",
6856
- disabled: u,
6857
- loading: i,
6858
- onPress: o,
6856
+ disabled: d,
6857
+ loading: a,
6858
+ loadingLabel: r,
6859
+ onPress: s,
6859
6860
  children: n
6860
6861
  })
6861
6862
  ]
@@ -3,6 +3,12 @@ interface CompactFilePickerProps extends Omit<React.ComponentProps<"input">, "ch
3
3
  label?: React.ReactNode;
4
4
  description?: React.ReactNode;
5
5
  triggerLabel?: React.ReactNode;
6
+ /**
7
+ * loading 時にボタンへ表示する文言。
8
+ * i18n 対応: 英語では "Processing…" など任意文字列を渡す。
9
+ * @default "処理中"
10
+ */
11
+ loadingLabel?: React.ReactNode;
6
12
  icon?: React.ReactNode;
7
13
  loading?: boolean;
8
14
  onFilesChange?: (files: File[]) => void;
@@ -21,7 +27,7 @@ interface ImageAttachmentPickerProps extends Omit<CompactFilePickerProps, "accep
21
27
  removeLabel?: (image: ImageAttachment) => string;
22
28
  previewVariant?: "grid" | "list";
23
29
  }
24
- declare function CompactFilePicker({ className, inputClassName, label, description, triggerLabel, icon, loading, disabled, id, multiple, onFilesChange, ...inputProps }: CompactFilePickerProps): React.JSX.Element;
30
+ declare function CompactFilePicker({ className, inputClassName, label, description, triggerLabel, loadingLabel, icon, loading, disabled, id, multiple, onFilesChange, ...inputProps }: CompactFilePickerProps): React.JSX.Element;
25
31
  declare function ImageAttachmentPicker({ className, images, accept, previewVariant, triggerLabel, label, description, onRemove, removeLabel, ...props }: ImageAttachmentPickerProps): React.JSX.Element;
26
32
  export { CompactFilePicker, ImageAttachmentPicker };
27
33
  export type { CompactFilePickerProps, ImageAttachment, ImageAttachmentPickerProps };
@@ -15,7 +15,15 @@ interface MobileAppHeaderProps extends Omit<React.ComponentProps<"header">, "ref
15
15
  * @default true
16
16
  */
17
17
  landmark?: boolean;
18
+ /**
19
+ * ヘッダー行の縦位置。複数行の brand(見出し+サブテキスト等)に対して
20
+ * status / actions が行全体の中央へ下がるのを避けたいときに `"top"` を指定する。
21
+ * `"top"` でも status / actions 内部(バッジとボタン同士)は中央揃えのまま
22
+ * (native の同名 prop と同じ仕様。issue #536)。
23
+ * @default "center"
24
+ */
25
+ verticalAlignment?: "center" | "top";
18
26
  }
19
- declare function MobileAppHeader({ className, brand, leading, status, compactStatus, actions, sticky, bordered, landmark, children, ...props }: MobileAppHeaderProps): React.JSX.Element;
27
+ declare function MobileAppHeader({ className, brand, leading, status, compactStatus, actions, sticky, bordered, landmark, verticalAlignment, children, ...props }: MobileAppHeaderProps): React.JSX.Element;
20
28
  export { MobileAppHeader };
21
29
  export type { MobileAppHeaderProps };
@@ -4,6 +4,11 @@ export interface CompactFilePickerProps {
4
4
  label?: React.ReactNode;
5
5
  description?: React.ReactNode;
6
6
  triggerLabel?: string;
7
+ /**
8
+ * loading 中に spinner と併せて表示する文言。未指定なら spinner のみ
9
+ * (Button.loadingLabel と同じ挙動・既定は変更しない)。i18n 対応(issue #539)。
10
+ */
11
+ loadingLabel?: string;
7
12
  icon?: React.ReactNode;
8
13
  loading?: boolean;
9
14
  disabled?: boolean;
@@ -23,5 +28,5 @@ export interface ImageAttachmentPickerProps extends Omit<CompactFilePickerProps,
23
28
  removeLabel?: (image: NativeImageAttachment) => string;
24
29
  previewVariant?: "grid" | "list";
25
30
  }
26
- export declare function CompactFilePicker({ label, description, triggerLabel, icon, loading, disabled, onPress, style, }: CompactFilePickerProps): React.JSX.Element;
31
+ export declare function CompactFilePicker({ label, description, triggerLabel, loadingLabel, icon, loading, disabled, onPress, style, }: CompactFilePickerProps): React.JSX.Element;
27
32
  export declare function ImageAttachmentPicker({ images, onRemove, removeLabel, previewVariant, label, description, triggerLabel, ...props }: ImageAttachmentPickerProps): React.JSX.Element;
@@ -8,6 +8,8 @@ export interface MobileAppHeaderProps {
8
8
  actions?: React.ReactNode;
9
9
  children?: React.ReactNode;
10
10
  bordered?: boolean;
11
+ /** Header row alignment. Right-side status and actions remain vertically centered. */
12
+ verticalAlignment?: "center" | "top";
11
13
  style?: StyleProp<ViewStyle>;
12
14
  }
13
- export declare function MobileAppHeader({ brand, leading, status, compactStatus, actions, children, bordered, style, }: MobileAppHeaderProps): React.JSX.Element;
15
+ export declare function MobileAppHeader({ brand, leading, status, compactStatus, actions, children, bordered, verticalAlignment, style, }: MobileAppHeaderProps): React.JSX.Element;
@@ -0,0 +1,46 @@
1
+ # ConsumerへのDS公開通知
2
+
3
+ 対象は `ekusiek716` 配下です。依頼登録でapp issueを公開待ちにし、DSの一括bump時だけ公開通知します。appの取り込み完了とは区別します。
4
+
5
+ ## 依頼を登録する
6
+
7
+ 1. DSの通常issueと、app側の取り込みを追跡する通常issueを作ります。PRのURL・番号は依頼issueとして使えません。
8
+ 2. DS正本repoの作業ブランチで、sourceのCLIを実行します。未publishのCLIではnpxが旧公開版を拾うため、次のコマンドを使います。
9
+
10
+ ```bash
11
+ node bin/init.js register-consumer-request --ds-issue 531 --consumer-issue https://github.com/ekusiek716/trip-todo/issues/168
12
+ ```
13
+
14
+ 登録は既存の `gh` 認証を使います。両repoのissueを読めて、appのラベルを作成・付与できる権限が必要です。両方が通常issueであることを確認して台帳を保存し、app issueへ `ds:waiting` を自動で付けます。ラベルが無ければ作成します。closedまたは `ds:released` のissueには待機ラベルを付けません。既存ラベルを置き換えたりissueを再開したりしません。
15
+
16
+ 3. 修正PRの番号が決まったら同じ依頼に紐付け、台帳変更をDSのPRに含めます。
17
+
18
+ ```bash
19
+ node bin/init.js register-consumer-request --ds-issue 531 --consumer-issue https://github.com/ekusiek716/trip-todo/issues/168 --fix-pr 532
20
+ ```
21
+
22
+ CLI公開後は `npx ksk-ds register-consumer-request` に同じ引数を渡すこともできます。`--dry-run` は通信も書き込みも行わず、変更後のJSONだけ表示します。そのためissueの実在・通常issueかどうか・gh権限はdry-runでは未確認です。
23
+
24
+ 認証・API・ファイル操作の失敗は非zeroで終了します。ラベル付与だけ失敗した場合は保存済み台帳が残ります。ghの認証・権限を直して同じ登録コマンドを再実行すると、台帳を重複追加せずラベルを再試行できます。途中の成功を全体成功とは扱いません。
25
+
26
+ ## 台帳の契約
27
+
28
+ `contracts/consumer-requests.json` は `{schemaVersion:1,requests:[{dsIssue,consumerIssue,fixPr}]}` の累積台帳です。新規の修正PR未定は `fixPr:null`、既存登録への `--fix-pr` 省略は値を維持します。同じDS issueとapp issueは更新し、複数appの登録を保持します。URLは小文字に正規化し、issue・PR番号は正の安全な整数だけを受け付けます。公開後も過去のentryを削除しません。実物の台帳もruntime共通validatorを通じて `npm test` で検証します。
29
+
30
+ 登録は `package.json` の名前が `ksk-design-system` で、`.git` と `contracts/` があるDS正本ルートでのみ可能です。consumerやnode_modulesの台帳は編集しません。
31
+
32
+ ## 一括bump時の公開通知
33
+
34
+ 通常のnpm公開後、DSの `scripts/update-consumers.sh VERSION` による一括bumpで通知します。指定versionの公開metadata・公開commitの台帳・修正PRの包含を確認し、該当app issueへbump PR付きの公開済みコメントと `ds:released` を付けます。同じapp issueに複数のDS依頼がある場合、公開台帳内の該当依頼がすべて公開・通知済みになるまで `ds:waiting` を残します。その間は `ds:released` と併存する場合があります。不正entryや処理失敗がある場合も待機ラベルを保持します。未公開fix・pending・未マージの修正は通知対象にしません。旧公開versionに台帳が無ければ待機します。
35
+
36
+ 通知runtimeの引数契約は `--repo owner/repo --version VERSION --bump-pr URL [--apply]` です。通知は一括bumpの処理に任せ、単独の実通知は運用手順にしません。登録CLIは公開済みコメントを投稿しません。appは依存更新・暫定回避策撤去・動作確認を終えてから追従issueを閉じます。
37
+
38
+ 通知にはrepo owner本人(`ekusiek716`)のPATによるgh認証を使用します。対象repoのissue書き込み権限が必要で、別ユーザーやGitHub Appのinstallation tokenは通知主体に使いません。
39
+
40
+ 通知に失敗した場合は作成済みbump PRを保持し、原因を解消して同じversion・対象repoの一括bumpを再実行します。既存PRを再利用して、未完了の通知・ラベル処理を修復します。runtime単独での実通知や、一括bumpとruntimeを並列実行する運用は禁止です。重複投稿を避けるため、同じ対象への一括bumpも直列で実行します。
41
+
42
+ ## 旧pollingからの移行
43
+
44
+ app側の `.github/workflows/ds-release-notices.yml` と `.github/scripts/check-ds-release.mjs` は削除してください。6時間scheduleもworkflow_dispatchも使用しません。`init-release-notices` は廃止案内を出して非zero終了し、ファイルを配置しません。通常のinstall/postinstallでも配置しません。
45
+
46
+ GitHub Actionsのrepo限定tokenを使うreceiver方式は廃止しました。登録・一括bumpには操作者の既存gh認証を使い、対象repoへの権限を確認します。この変更自体ではversion bump・publish・実通知は行いません。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ksk-design-system",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "KSK Design System — フリーランス向けマルチテーマ対応デザインシステム",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "MIGRATION.md",
36
36
  "NATIVE_RECIPES.md",
37
37
  "PUBLISHING.md",
38
+ "docs/consumer-release-notices.md",
38
39
  "RELEASE.md",
39
40
  "UPDATING.md"
40
41
  ],
@@ -178,7 +179,7 @@
178
179
  "check:native": "tsc -p tsconfig.native.json --noEmit",
179
180
  "check:native-parity": "node scripts/check-native-parity.mjs",
180
181
  "check:native-lookup": "node scripts/generate-native-component-lookup.mjs --check",
181
- "test": "vitest run",
182
+ "test": "node --test __tests__/consumer-release-notices.test.mjs __tests__/update-consumers-notices.test.mjs && vitest run",
182
183
  "test:legacy-radix": "node scripts/test-legacy-radix.mjs",
183
184
  "test:consumer:web": "npm run build:lib && node scripts/test-packed-web-consumer.mjs",
184
185
  "test:consumer:native": "npm run build:lib && node scripts/test-packed-native-consumer.mjs",
@@ -116,7 +116,7 @@
116
116
  | Chip | `@/components/patterns/chip` | **variant**: `filled`, `accent`, `outline`<br>**size**: `xs`, `sm`, `md`, `lg`, `tile`<br>**shape**: `pill`, `rounded`, `square` | Filled, Accent, Outline, AllVariants, AllSizes, Selected, Removable, SquareShape, WithCount, TileAndSoldOut, AsLink, Real UI — Filters & Status, Touch selection stable, TileTouchTargetNotShrunk, ExtraSmall, RoundedShape |
117
117
  | CoachMarkOverlay | `@/components/patterns/coach-mark-overlay` | — | MultiStepTour, Localized labels (EN), BrandVariant, MissingTarget, FocusTrap |
118
118
  | CollapsibleChipField | `@/components/patterns/collapsible-chip-field` | — | WithLabel, WithIcon, RequiredNoClear, CollapsedDisclosure, AlwaysExpanded, EscapeInsideDialog |
119
- | CompactFilePicker, ImageAttachmentPicker | `@/components/patterns/compact-file-picker` | — | SingleImageReplacement, MultiImageAttachments, ListPreview |
119
+ | CompactFilePicker, ImageAttachmentPicker | `@/components/patterns/compact-file-picker` | — | SingleImageReplacement, MultiImageAttachments, ListPreview, LoadingWithCustomLabel |
120
120
  | ConfirmDialog | `@/components/patterns/confirm-dialog` | — | Default, Destructive, WithLoading |
121
121
  | ContentCarousel | `@/components/patterns/content-carousel` | — | Default, AutoPlay, WithoutControls |
122
122
  | CookieConsent | `@/components/patterns/cookie-consent` | — | Default, CustomLabels, OnDecideCallback |
@@ -138,7 +138,7 @@
138
138
  | ListSkeleton, GridSkeleton | `@/components/patterns/list-skeletons` | — | Default, WithoutFilter, FewRows |
139
139
  | MediaActionCluster | `@/components/patterns/media-action-cluster` | — | Vertical, Horizontal, Auto orientation, No auto-hide |
140
140
  | MenuDrawer | `@/components/patterns/menu-drawer` | — | Default, NoBanner |
141
- | MobileAppHeader | `@/components/patterns/mobile-app-header` | — | WithStatusSlot, WithPendingCount |
141
+ | MobileAppHeader | `@/components/patterns/mobile-app-header` | — | WithStatusSlot, WithPendingCount, WithTopAlignment |
142
142
  | MobileAppShell | `@/components/patterns/mobile-app-shell` | — | FourTabPwaShell, DesktopSidebarHandoff |
143
143
  | MobileFloatingActionButton | `@/components/patterns/mobile-floating-action-button` | — | WithBottomNavOffset, WithLabel, Glass (Liquid Glass), Glass (with label), With pill BottomTabBar inline, Full width CTA, With ad banner offset (bottomOffsetExtra) |
144
144
  | NotificationBadge | `@/components/patterns/notification-badge` | — | SingleDigit, DoubleDigit, MaxOverflow, CustomMax, Zero, VariousCounts, WithIcon |
@@ -48,6 +48,19 @@
48
48
  box-shadow: var(--Nav-Pill-Shadow, var(--glass-highlight), var(--glass-shadow));
49
49
  }
50
50
 
51
+ /* ── ダーク / 暗背景宣言区画での詳細度対策(issue #486 の実測バグ)──
52
+ glass.css の @supports ブロックが `.dark .glass` / `[data-glass-backdrop="dark"] .glass`
53
+ という **2 クラスセレクタ (0,2,0)** で backdrop-filter を上書きしている。
54
+ 上の `[data-slot="bottom-nav-pill"]` は (0,1,0) なので、宣言順に関係なく
55
+ 詳細度で負け、**ダークでだけ --Nav-Pill-Backdrop が無視される**(v2.1.0 で実測)。
56
+ 同じ 2 クラス相当のセレクタで受け直す。フォールバックは glass.css の
57
+ ダーク側の値そのままなので、未宣言時のレンダリングは変わらない。 */
58
+ .dark [data-slot="bottom-nav-pill"],
59
+ [data-glass-backdrop="dark"] [data-slot="bottom-nav-pill"] {
60
+ -webkit-backdrop-filter: var(--Nav-Pill-Backdrop, var(--glass-blur) var(--glass-refract-dark));
61
+ backdrop-filter: var(--Nav-Pill-Backdrop, var(--glass-blur) var(--glass-refract-dark));
62
+ }
63
+
51
64
  [data-slot="bottom-nav-pill"][data-tone="inverse"] {
52
65
  background: var(--Nav-Pill-Surface, var(--glass-bg-dark));
53
66
  -webkit-backdrop-filter: var(--Nav-Pill-Backdrop, var(--glass-blur-media-dark));
@@ -73,6 +73,16 @@ DS に無いと思っても consumer 側に別台帳を作らないこと。最
73
73
  | ドロワー(モバイル) | `<Sheet side="bottom">` |
74
74
  | PC/モバイル自動切替モーダル | `<ResponsiveDialog>` |
75
75
 
76
+ ## DS公開通知
77
+
78
+ DSへ依頼するときは、DSの通常issueとappの取り込み待ち通常issueを作成する。DS正本の作業ブランチで `node bin/init.js register-consumer-request --ds-issue N --consumer-issue URL` を実行し、台帳変更をPRに含める。未公開CLIで古いnpm版を起動しないようsourceのコマンドを使う。公開済みCLIでは `npx ksk-ds register-consumer-request` も同じ引数で使える。
79
+
80
+ 登録CLIは既存のgh認証で両issueを確認し、app issueへ `ds:waiting` を自動付与する。closedまたは `ds:released` のissueは降格させない。修正PR作成後は同じコマンドに `--fix-pr N` を付けて紐付ける。省略時は既存fixPrを維持し、累積台帳を削除しない。`--dry-run` は通信・書き込みなしのローカルプレビューで、issue実在や権限は確認しない。失敗は非zero。台帳保存後のラベル失敗は同じコマンドで再試行する。
81
+
82
+ 公開通知はDSの一括bump(`scripts/update-consumers.sh VERSION`)時だけ実行する。公開版に修正が含まれることを確認し、bump PR付きの通知と `ds:released` を付ける。6時間pollingとapp側workflow/scriptは廃止し、`init-release-notices` は移行案内を出して停止する。app側の取り込み・動作確認が完了するまで追従issueは閉じない。詳しくは [公開通知手順](https://github.com/ekusiek716/ksk-design-system/blob/main/docs/consumer-release-notices.md) を参照。
83
+
84
+ 同じapp issueに複数のDS依頼がある場合、公開台帳内の該当依頼がすべて公開・通知済みになるまで `ds:waiting` を残す(`ds:released` と併存する場合あり)。通知はrepo owner本人 `ekusiek716` のPAT認証が前提。通知失敗時は既存bump PRを保持して同じversion・対象repoの一括bumpを再実行し、通知runtime単独実行・並列実行はしない。
85
+
76
86
  ## 使用方法
77
87
 
78
88
  ### CSS のセットアップ
@@ -73,6 +73,16 @@ DS に無いと思っても consumer 側に別台帳を作らないこと。最
73
73
  | ドロワー(モバイル) | `<Sheet side="bottom">` |
74
74
  | PC/モバイル自動切替モーダル | `<ResponsiveDialog>` |
75
75
 
76
+ ## DS公開通知
77
+
78
+ DSへ依頼するときは、DSの通常issueとappの取り込み待ち通常issueを作成する。DS正本の作業ブランチで `node bin/init.js register-consumer-request --ds-issue N --consumer-issue URL` を実行し、台帳変更をPRに含める。未公開CLIで古いnpm版を起動しないようsourceのコマンドを使う。公開済みCLIでは `npx ksk-ds register-consumer-request` も同じ引数で使える。
79
+
80
+ 登録CLIは既存のgh認証で両issueを確認し、app issueへ `ds:waiting` を自動付与する。closedまたは `ds:released` のissueは降格させない。修正PR作成後は同じコマンドに `--fix-pr N` を付けて紐付ける。省略時は既存fixPrを維持し、累積台帳を削除しない。`--dry-run` は通信・書き込みなしのローカルプレビューで、issue実在や権限は確認しない。失敗は非zero。台帳保存後のラベル失敗は同じコマンドで再試行する。
81
+
82
+ 公開通知はDSの一括bump(`scripts/update-consumers.sh VERSION`)時だけ実行する。公開版に修正が含まれることを確認し、bump PR付きの通知と `ds:released` を付ける。6時間pollingとapp側workflow/scriptは廃止し、`init-release-notices` は移行案内を出して停止する。app側の取り込み・動作確認が完了するまで追従issueは閉じない。詳しくは [公開通知手順](https://github.com/ekusiek716/ksk-design-system/blob/main/docs/consumer-release-notices.md) を参照。
83
+
84
+ 同じapp issueに複数のDS依頼がある場合、公開台帳内の該当依頼がすべて公開・通知済みになるまで `ds:waiting` を残す(`ds:released` と併存する場合あり)。通知はrepo owner本人 `ekusiek716` のPAT認証が前提。通知失敗時は既存bump PRを保持して同じversion・対象repoの一括bumpを再実行し、通知runtime単独実行・並列実行はしない。
85
+
76
86
  ## 使用方法
77
87
 
78
88
  ### CSS のセットアップ
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ export const DS_REPO = 'ekusiek716/ksk-design-system';
5
+ const REGISTRY = 'https://registry.npmjs.org/ksk-design-system/';
6
+ const API = 'https://api.github.com';
7
+ const SHA = /^[a-f0-9]{40}$/i;
8
+ const ID = (value) => Number.isSafeInteger(value) && value > 0;
9
+ class NoticeError extends Error {}
10
+ const fail = (message) => { throw new NoticeError(message); };
11
+
12
+ export function validateRepo(repo, githubRepository) {
13
+ if (typeof repo !== 'string' || !/^ekusiek716\/[a-zA-Z0-9_.-]+$/.test(repo)
14
+ || ['.', '..'].includes(repo.split('/')[1])) fail('対象repoはekusiek716配下で指定してください');
15
+ if (githubRepository && githubRepository.toLowerCase() !== repo.toLowerCase()) fail('GITHUB_REPOSITORYと--repoが一致しません');
16
+ return repo;
17
+ }
18
+
19
+ export function parseArgs(args) {
20
+ let repo;
21
+ let version;
22
+ let bumpPr;
23
+ let apply = false;
24
+ for (let i = 0; i < args.length; i++) {
25
+ if (args[i] === '--repo' && repo === undefined && args[i + 1]) repo = args[++i];
26
+ else if (args[i] === '--version' && version === undefined && args[i + 1]) version = args[++i];
27
+ else if (args[i] === '--bump-pr' && bumpPr === undefined && args[i + 1]) bumpPr = args[++i];
28
+ else if (args[i] === '--apply' && !apply) apply = true;
29
+ else fail('使用法: --repo owner/repo --version VERSION --bump-pr URL [--apply]');
30
+ }
31
+ validateRepo(repo);
32
+ validateVersion(version);
33
+ parseBumpPr(bumpPr, repo);
34
+ return { repo, version, bumpPr, apply };
35
+ }
36
+
37
+ export function validateVersion(version) {
38
+ if (typeof version !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) fail('安定版versionを明示してください');
39
+ return version;
40
+ }
41
+
42
+ export function parseBumpPr(value, repo) {
43
+ const match = typeof value === 'string' && /^https:\/\/github\.com\/(ekusiek716\/[a-zA-Z0-9_.-]+)\/pull\/([1-9]\d*)$/.exec(value);
44
+ if (!match || !ID(Number(match[2])) || match[1].toLowerCase() !== repo.toLowerCase()) fail('bump PR URLは対象repoのPRで指定してください');
45
+ return Number(match[2]);
46
+ }
47
+
48
+ export function validateBumpPr(pr, repo, version) {
49
+ if (pr?.base?.repo?.full_name?.toLowerCase() !== repo.toLowerCase()
50
+ || pr?.head?.repo?.full_name?.toLowerCase() !== repo.toLowerCase()
51
+ || pr.base.ref !== 'main' || pr.head.ref !== `chore/bump-ds-${version}`
52
+ || !(pr.state === 'open' || pr.merged === true)) fail('bump PRのrepo・branch・baseまたは状態が不正です');
53
+ return pr;
54
+ }
55
+
56
+ export function parseConsumerIssue(value) {
57
+ const match = typeof value === 'string' && /^https:\/\/github\.com\/(ekusiek716\/[a-zA-Z0-9_.-]+)\/issues\/([1-9]\d*)$/.exec(value);
58
+ if (!match || !ID(Number(match[2]))) fail('consumerIssue URLが不正です');
59
+ validateRepo(match[1]);
60
+ return { repo: match[1], number: Number(match[2]) };
61
+ }
62
+
63
+ /** Validate the published JSON contract. Strict by default for contract/CLI checks.
64
+ * The runner uses partial mode to continue independent entries after an error.
65
+ */
66
+ export function validateContract(contract, { partial = false } = {}) {
67
+ if (contract?.schemaVersion !== 1 || !Array.isArray(contract.requests)) fail('未対応のconsumer-requests schemaです');
68
+ const requests = [];
69
+ const errors = [];
70
+ const keys = new Set();
71
+ for (const [index, entry] of contract.requests.entries()) {
72
+ try {
73
+ if (!entry || !ID(entry.dsIssue) || !(entry.fixPr === null || ID(entry.fixPr))) fail('依頼entryが不正です');
74
+ const target = parseConsumerIssue(entry.consumerIssue);
75
+ const key = `${entry.dsIssue}:${target.repo.toLowerCase()}:${target.number}`;
76
+ if (keys.has(key)) fail('同一DS依頼・consumerIssueの重複entryです');
77
+ keys.add(key);
78
+ requests.push({ entry, index });
79
+ } catch (error) {
80
+ if (!partial) throw error;
81
+ errors.push({ entry: index, message: error instanceof NoticeError ? error.message : '依頼entryが不正です' });
82
+ }
83
+ }
84
+ return { requests, errors };
85
+ }
86
+
87
+ export function validateRelease(value) {
88
+ if (!value || typeof value.version !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value.version)
89
+ || typeof value.gitHead !== 'string' || !SHA.test(value.gitHead)
90
+ || typeof value.dist?.integrity !== 'string'
91
+ || !/^(sha256|sha384|sha512)-[A-Za-z0-9+/]+={0,2}$/.test(value.dist.integrity)) fail('npm公開metadataが不正です');
92
+ return { version: value.version, gitHead: value.gitHead, integrity: value.dist.integrity };
93
+ }
94
+
95
+ export const noticeMarker = (dsIssue, consumerIssue) => `<!-- ksk-ds-release-notice:${dsIssue}:${consumerIssue} -->`;
96
+ export function noticeBody(entry, number, release, commit, bumpPr) {
97
+ return `${noticeMarker(entry.dsIssue, number)}\nDS側の修正がv${release.version}で公開済み。アプリ取り込み・動作確認待ちです。\n\n`
98
+ + `- DS依頼: https://github.com/${DS_REPO}/issues/${entry.dsIssue}\n`
99
+ + `- 修正PR: https://github.com/${DS_REPO}/pull/${entry.fixPr}\n`
100
+ + `- アプリ取り込みPR: ${bumpPr}\n`
101
+ + `- npm: [ksk-design-system@${release.version}](https://www.npmjs.com/package/ksk-design-system/v/${release.version})\n`
102
+ + `- 修正commit: ${commit}\n- 公開gitHead: ${release.gitHead}\n- dist.integrity: ${release.integrity}\n\n`
103
+ + 'アプリへの取り込みと動作確認が終わったら、この依頼issueで結果を確認してください。';
104
+ }
105
+
106
+ /** Network and environment are injectable. The caller sets process.exitCode from the result.
107
+ * GH_TOKEN may be a local gh PAT. /user verifies the current comment author;
108
+ * the repository owner and github-actions[bot] can satisfy deduplication.
109
+ * Local owner PAT execution is required (/user does not support installation tokens).
110
+ * Serialize updater runs: GitHub has no atomic create-if-marker-absent API.
111
+ */
112
+ export async function run({ repo, version, bumpPr, apply = false, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 15000 } = {}) {
113
+ const results = [];
114
+ const errors = [];
115
+ const safeMessage = (error) => {
116
+ const message = error instanceof NoticeError ? error.message : '処理に失敗しました';
117
+ return env.GH_TOKEN ? message.split(env.GH_TOKEN).join('[REDACTED]') : message;
118
+ };
119
+ try {
120
+ validateRepo(repo, env.GITHUB_REPOSITORY === DS_REPO ? undefined : env.GITHUB_REPOSITORY);
121
+ validateVersion(version);
122
+ const bumpNumber = parseBumpPr(bumpPr, repo);
123
+ if (!env.GH_TOKEN) fail('GH_TOKENが必要です');
124
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) fail('timeoutMsが不正です');
125
+ const own = `/repos/${repo}`;
126
+ async function request(path, { method = 'GET', body, allow404 = false, allow422 = false, registry = false } = {}) {
127
+ if (method !== 'GET' && (!apply || !(path === `${own}/labels` || path.startsWith(`${own}/issues/`)))) fail('書込対象が許可されていません');
128
+ const controller = new AbortController();
129
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
130
+ try {
131
+ const response = await fetchImpl(registry ? `${REGISTRY}${version}` : `${API}${path}`, {
132
+ method, redirect: 'error', signal: controller.signal,
133
+ headers: registry ? { Accept: 'application/json' } : {
134
+ Accept: 'application/vnd.github+json', Authorization: `Bearer ${env.GH_TOKEN}`,
135
+ 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json',
136
+ },
137
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
138
+ });
139
+ if ((allow404 && response.status === 404) || (allow422 && response.status === 422)) return { status: response.status };
140
+ if (!response.ok) fail(`API処理失敗 (HTTP ${response.status})`);
141
+ return { status: response.status, data: response.status === 204 ? null : await response.json(),
142
+ next: /rel="next"/.test(response.headers.get('link') || '') };
143
+ } catch (error) {
144
+ if (error instanceof NoticeError) throw error;
145
+ fail(controller.signal.aborted ? 'APIタイムアウト' : 'API通信またはJSON解析に失敗しました');
146
+ } finally { clearTimeout(timer); }
147
+ }
148
+ async function pages(path) {
149
+ const values = [];
150
+ for (let page = 1; ; page++) {
151
+ const response = await request(`${path}?per_page=100&page=${page}`);
152
+ if (!Array.isArray(response.data)) fail('API一覧形式が不正です');
153
+ values.push(...response.data);
154
+ if (!response.next && response.data.length < 100) return values;
155
+ }
156
+ }
157
+ const release = validateRelease((await request('', { registry: true })).data);
158
+ if (release.version !== version) fail('npm公開versionが指定versionと一致しません');
159
+ const manifest = await request(`/repos/${DS_REPO}/contents/contracts/consumer-requests.json?ref=${release.gitHead}`, { allow404: true });
160
+ if (manifest.status === 404) {
161
+ // Contents 404 can also hide an unknown commit or an inaccessible repository.
162
+ const commit = (await request(`/repos/${DS_REPO}/commits/${release.gitHead}`)).data;
163
+ if (typeof commit?.sha !== 'string' || commit.sha.toLowerCase() !== release.gitHead.toLowerCase()) fail('公開gitHeadの存在を確認できません');
164
+ return { exitCode: 0, results: [{ status: 'legacy-release' }], errors };
165
+ }
166
+ let contract;
167
+ try {
168
+ if (manifest.data?.encoding !== 'base64' || typeof manifest.data.content !== 'string') fail('公開contract形式が不正です');
169
+ contract = JSON.parse(Buffer.from(manifest.data.content, 'base64').toString('utf8'));
170
+ } catch { fail('公開contractを解析できません'); }
171
+ const validated = validateContract(contract, { partial: true });
172
+ errors.push(...validated.errors.map((error) => ({ ...error, message: safeMessage(new NoticeError(error.message)) })));
173
+ const applicable = validated.requests.filter(({ entry }) => parseConsumerIssue(entry.consumerIssue).repo.toLowerCase() === repo.toLowerCase());
174
+ // Apps with no actionable registered requests do not need PR/file inspection.
175
+ // Keep schema/entry failures visible even when there is nothing to notify.
176
+ if (!applicable.some(({ entry }) => entry.fixPr !== null)) {
177
+ for (const { entry } of applicable) results.push({ dsIssue: entry.dsIssue, consumerIssue: parseConsumerIssue(entry.consumerIssue).number, status: 'unfixed' });
178
+ return { exitCode: errors.length ? 1 : 0, results, errors };
179
+ }
180
+ const bump = validateBumpPr((await request(`${own}/pulls/${bumpNumber}`)).data, repo, version);
181
+ if (typeof bump.head.sha !== 'string' || !SHA.test(bump.head.sha)) fail('bump PRのHEAD SHAが不正です');
182
+ const files = await pages(`${own}/pulls/${bumpNumber}/files`);
183
+ let packageMatches = 0;
184
+ let lockMatches = 0;
185
+ // Read immutable PR HEAD, never latest main or the mutable branch name.
186
+ for (const file of files) {
187
+ if (file.status === 'removed' || typeof file.filename !== 'string'
188
+ || !/(^|\/)(package|package-lock)\.json$/.test(file.filename)) continue;
189
+ const encodedPath = file.filename.split('/').map(encodeURIComponent).join('/');
190
+ const contents = (await request(`${own}/contents/${encodedPath}?ref=${bump.head.sha}`)).data;
191
+ let pkg;
192
+ try {
193
+ if (contents?.encoding !== 'base64' || typeof contents.content !== 'string') fail('invalid contents');
194
+ pkg = JSON.parse(Buffer.from(contents.content, 'base64').toString('utf8'));
195
+ } catch { fail('bump PRのpackage/lockを解析できません'); }
196
+ if (file.filename.endsWith('package-lock.json')) {
197
+ const installs = Object.entries(pkg.packages ?? {}).filter(([key]) => /(^|\/)node_modules\/ksk-design-system$/.test(key)).map(([, value]) => value);
198
+ if (pkg.dependencies?.['ksk-design-system']) installs.push(pkg.dependencies['ksk-design-system']);
199
+ for (const install of installs) {
200
+ if (install?.version !== version || install.integrity !== release.integrity) fail('bump PRのlockfileが指定公開版と一致しません');
201
+ lockMatches++;
202
+ }
203
+ } else {
204
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
205
+ const dependency = pkg[field]?.['ksk-design-system'];
206
+ if (dependency === undefined || dependency === '*' || dependency.startsWith?.('workspace:')) continue;
207
+ if (dependency !== version && dependency !== `^${version}`) fail('bump PRのpackage.jsonが指定versionと一致しません');
208
+ packageMatches++;
209
+ }
210
+ }
211
+ }
212
+ if (!packageMatches || !lockMatches) fail('bump PRのpackage.jsonとlockfileに指定公開版の変更が必要です');
213
+ const author = (await request('/user')).data;
214
+ if (!ID(author?.id) || author.login !== 'ekusiek716' || author.type !== 'User') fail('通知にはrepo owner本人のGH_TOKENが必要です');
215
+ const seen = new Set();
216
+ const waitingIssues = new Set();
217
+ let labelReady = false;
218
+ for (const { index, entry } of applicable) {
219
+ try {
220
+ if (!entry || !ID(entry.dsIssue) || !(entry.fixPr === null || ID(entry.fixPr))) fail('依頼entryが不正です');
221
+ const target = parseConsumerIssue(entry.consumerIssue);
222
+ if (target.repo.toLowerCase() !== repo.toLowerCase()) continue;
223
+ const result = { dsIssue: entry.dsIssue, consumerIssue: target.number };
224
+ if (entry.fixPr === null) { results.push({ ...result, status: 'unfixed' }); continue; }
225
+ const pr = (await request(`/repos/${DS_REPO}/pulls/${entry.fixPr}`)).data;
226
+ if (pr?.merged !== true) { results.push({ ...result, status: 'unmerged' }); continue; }
227
+ if (typeof pr.merge_commit_sha !== 'string' || !SHA.test(pr.merge_commit_sha)) fail('修正PRのcommit SHAが不正です');
228
+ const comparison = (await request(`/repos/${DS_REPO}/compare/${pr.merge_commit_sha}...${release.gitHead}`)).data;
229
+ if (!['ahead', 'identical', 'behind', 'diverged'].includes(comparison?.status)) fail('commit比較結果が不正です');
230
+ if (!['ahead', 'identical'].includes(comparison.status)) { results.push({ ...result, status: 'unreleased' }); continue; }
231
+ const issuePath = `${own}/issues/${target.number}`;
232
+ const issue = (await request(issuePath)).data;
233
+ if (!issue || issue.pull_request) fail('通知先は通常のissueである必要があります');
234
+ if (issue.state === 'closed') { results.push({ ...result, status: 'closed' }); continue; }
235
+ if (issue.state !== 'open') fail('通知先issueの状態が不正です');
236
+ const marker = noticeMarker(entry.dsIssue, target.number);
237
+ const comments = await pages(`${issuePath}/comments`);
238
+ const trusted = comments.filter((comment) => ((comment.user?.login === 'github-actions[bot]' && comment.user?.type === 'Bot')
239
+ || (comment.user?.id === author.id && comment.user?.login === 'ekusiek716' && comment.user?.type === 'User'))
240
+ && typeof comment.body === 'string');
241
+ const exists = seen.has(marker) || trusted.some((comment) => comment.body.includes(marker));
242
+ const supplementMarker = `<!-- ksk-ds-bump-pr-notice:${entry.dsIssue}:${target.number} -->`;
243
+ const needsSupplement = exists && !seen.has(marker) && !trusted.some((comment) => comment.body.includes(supplementMarker)
244
+ || (comment.body.includes(marker) && comment.body.includes(`https://github.com/${repo}/pull/`)));
245
+ if (!apply) { results.push({ ...result, status: needsSupplement ? 'would-supplement-bump-pr' : exists ? 'would-repair-labels' : 'would-notify' }); continue; }
246
+ if (!exists) await request(`${issuePath}/comments`, { method: 'POST', body: { body: noticeBody(entry, target.number, release, pr.merge_commit_sha, bumpPr) } });
247
+ if (needsSupplement) await request(`${issuePath}/comments`, { method: 'POST', body: {
248
+ body: `${supplementMarker}\nDS依頼 https://github.com/${DS_REPO}/issues/${entry.dsIssue} の取り込みPRを補足します。\n\n- アプリ取り込みPR: ${bumpPr}\n- ksk-design-system@${version}\n\nアプリ取り込み・動作確認待ちです。`,
249
+ } });
250
+ seen.add(marker);
251
+ if (!labelReady) {
252
+ const labelPath = `${own}/labels/${encodeURIComponent('ds:released')}`;
253
+ if ((await request(labelPath, { allow404: true })).status === 404) {
254
+ const created = await request(`${own}/labels`, { method: 'POST', body: { name: 'ds:released', color: '0e8a16', description: 'DS修正版公開済み・アプリ取り込みと動作確認待ち' }, allow422: true });
255
+ if (created.status === 422) await request(labelPath); // Another run may have created it.
256
+ }
257
+ labelReady = true;
258
+ }
259
+ const labels = await pages(`${issuePath}/labels`);
260
+ if (!labels.some((label) => label.name === 'ds:released')) await request(`${issuePath}/labels`, { method: 'POST', body: { labels: ['ds:released'] } });
261
+ if (labels.some((label) => label.name === 'ds:waiting')) waitingIssues.add(target.number);
262
+ results.push({ ...result, status: exists ? 'labels-repaired' : 'notified' });
263
+ } catch (error) { errors.push({ entry: index, message: safeMessage(error) }); }
264
+ }
265
+ // A shared app issue can track several DS requests. Keep waiting until every
266
+ // matching request is notified; malformed entries or failures also keep it.
267
+ if (apply && errors.length === 0) for (const number of waitingIssues) {
268
+ if (results.filter((result) => result.consumerIssue === number)
269
+ .every((result) => ['notified', 'labels-repaired'].includes(result.status))) {
270
+ try { await request(`${own}/issues/${number}/labels/${encodeURIComponent('ds:waiting')}`, { method: 'DELETE', allow404: true }); }
271
+ catch (error) { errors.push({ consumerIssue: number, message: safeMessage(error) }); }
272
+ }
273
+ }
274
+ } catch (error) { errors.push({ message: safeMessage(error) }); }
275
+ return { exitCode: errors.length ? 1 : 0, results, errors };
276
+ }
277
+
278
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
279
+ try {
280
+ const result = await run(parseArgs(process.argv.slice(2)));
281
+ console.log(JSON.stringify(result, null, 2));
282
+ process.exitCode = result.exitCode;
283
+ } catch { console.error('引数が不正です。使用法: --repo owner/repo --version VERSION --bump-pr URL [--apply]'); process.exitCode = 1; }
284
+ }