@takagaki/cortex-decisions-viewer 0.4.58 → 0.4.60

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.
Files changed (2) hide show
  1. package/dist/render.js +738 -123
  2. package/package.json +1 -1
package/dist/render.js CHANGED
@@ -65,6 +65,26 @@ function escapeHtml(s) {
65
65
  .replace(/"/g, """);
66
66
  }
67
67
  /** 接続マップの能力定義(表示順・絵文字は詳細カードの流儀を踏襲) */
68
+ /**
69
+ * アダプタのロゴ。**公式配布のSVGをそのまま埋め込む。**
70
+ *
71
+ * 外部URLを参照しない——この画面は顧客が見るので、参照すると
72
+ * 顧客のブラウザが Slack・Google・Figma のサーバへ接続しに行く(誰がいつ見たかが
73
+ * 各社のログに残る)。参照先が変わればアイコンだけ黙って消える。
74
+ * 既存の GITHUB_ICON と同じ流儀。
75
+ *
76
+ * 出典: Slack/Figma/Google Meet/Google Drive は simple-icons(CC0)、
77
+ * Backlog は ヌーラボ公式のロゴ配布(nulab.com/logo-guidelines/)。
78
+ * 改変は「サイズ指定の付与」と「余分な属性の除去」のみ。配色と形は変えていない。
79
+ */
80
+ const ADAPTER_ICONS = {
81
+ slack: `<svg width="36" height="36" aria-hidden="true" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><g> <path d="M53.8412698,161.320635 C53.8412698,176.152381 41.8539683,188.139683 27.0222222,188.139683 C12.1904762,188.139683 0.203174603,176.152381 0.203174603,161.320635 C0.203174603,146.488889 12.1904762,134.501587 27.0222222,134.501587 L53.8412698,134.501587 L53.8412698,161.320635 Z M67.2507937,161.320635 C67.2507937,146.488889 79.2380952,134.501587 94.0698413,134.501587 C108.901587,134.501587 120.888889,146.488889 120.888889,161.320635 L120.888889,228.368254 C120.888889,243.2 108.901587,255.187302 94.0698413,255.187302 C79.2380952,255.187302 67.2507937,243.2 67.2507937,228.368254 L67.2507937,161.320635 Z" fill="#E01E5A"></path> <path d="M94.0698413,53.6380952 C79.2380952,53.6380952 67.2507937,41.6507937 67.2507937,26.8190476 C67.2507937,11.9873016 79.2380952,-7.10542736e-15 94.0698413,-7.10542736e-15 C108.901587,-7.10542736e-15 120.888889,11.9873016 120.888889,26.8190476 L120.888889,53.6380952 L94.0698413,53.6380952 Z M94.0698413,67.2507937 C108.901587,67.2507937 120.888889,79.2380952 120.888889,94.0698413 C120.888889,108.901587 108.901587,120.888889 94.0698413,120.888889 L26.8190476,120.888889 C11.9873016,120.888889 0,108.901587 0,94.0698413 C0,79.2380952 11.9873016,67.2507937 26.8190476,67.2507937 L94.0698413,67.2507937 Z" fill="#36C5F0"></path> <path d="M201.549206,94.0698413 C201.549206,79.2380952 213.536508,67.2507937 228.368254,67.2507937 C243.2,67.2507937 255.187302,79.2380952 255.187302,94.0698413 C255.187302,108.901587 243.2,120.888889 228.368254,120.888889 L201.549206,120.888889 L201.549206,94.0698413 Z M188.139683,94.0698413 C188.139683,108.901587 176.152381,120.888889 161.320635,120.888889 C146.488889,120.888889 134.501587,108.901587 134.501587,94.0698413 L134.501587,26.8190476 C134.501587,11.9873016 146.488889,-1.42108547e-14 161.320635,-1.42108547e-14 C176.152381,-1.42108547e-14 188.139683,11.9873016 188.139683,26.8190476 L188.139683,94.0698413 Z" fill="#2EB67D"></path> <path d="M161.320635,201.549206 C176.152381,201.549206 188.139683,213.536508 188.139683,228.368254 C188.139683,243.2 176.152381,255.187302 161.320635,255.187302 C146.488889,255.187302 134.501587,243.2 134.501587,228.368254 L134.501587,201.549206 L161.320635,201.549206 Z M161.320635,188.139683 C146.488889,188.139683 134.501587,176.152381 134.501587,161.320635 C134.501587,146.488889 146.488889,134.501587 161.320635,134.501587 L228.571429,134.501587 C243.403175,134.501587 255.390476,146.488889 255.390476,161.320635 C255.390476,176.152381 243.403175,188.139683 228.571429,188.139683 L161.320635,188.139683 Z" fill="#ECB22E"></path> </g></svg>`,
82
+ figma: `<svg width="36" height="36" aria-hidden="true" viewBox="0 0 256 384" xmlns="http://www.w3.org/2000/svg"><g> <path d="M64,384 C99.328,384 128,355.328 128,320 L128,256 L64,256 C28.672,256 0,284.672 0,320 C0,355.328 28.672,384 64,384 Z" fill="#0ACF83"></path> <path d="M0,192 C0,156.672 28.672,128 64,128 L128,128 L128,256 L64,256 C28.672,256 0,227.328 0,192 Z" fill="#A259FF"></path> <path d="M0,64 C0,28.672 28.672,0 64,0 L128,0 L128,128 L64,128 C28.672,128 0,99.328 0,64 Z" fill="#F24E1E"></path> <path d="M128,0 L192,0 C227.328,0 256,28.672 256,64 C256,99.328 227.328,128 192,128 L128,128 L128,0 Z" fill="#FF7262"></path> <path d="M256,192 C256,227.328 227.328,256 192,256 C156.672,256 128,227.328 128,192 C128,156.672 156.672,128 192,128 C227.328,128 256,156.672 256,192 Z" fill="#1ABCFE"></path> </g></svg>`,
83
+ "google-meet": `<svg width="36" height="36" aria-hidden="true" viewBox="0 0 256 211" xmlns="http://www.w3.org/2000/svg"><g> <polygon fill="#00832D" points="144.822496 105.321856 169.778926 133.848796 203.341343 155.294133 209.178931 105.50196 203.341343 56.8331137 169.136495 75.6715889"></polygon> <path d="M0.000557021739,150.659712 L0.000557021739,193.089915 C0.000557021739,202.77838 7.86384724,210.643527 17.5541688,210.643527 L59.9843714,210.643527 L68.7704609,178.585069 L59.9843714,150.659712 L30.8744153,141.873623 L0.000557021739,150.659712 Z" fill="#0066DA"></path> <polygon fill="#E94235" points="59.9838143 9.9475983e-14 0 59.9838143 30.875715 68.7494798 59.9838143 59.9838143 68.6102243 32.4390893"></polygon> <polygon fill="#2684FC" points="0.000557021739 150.679394 59.9843714 150.679394 59.9843714 59.9832573 0.000557021739 59.9832573"></polygon> <path d="M241.658683,25.3977775 L203.341157,56.8342278 L203.341157,155.29339 L241.818362,186.852385 C247.577967,191.364261 256.003849,187.251584 256.003849,179.930462 L256.003849,32.1785888 C256.003849,24.7757699 247.377439,20.6835169 241.658683,25.3977775" fill="#00AC47"></path> <path d="M144.822496,105.321856 L144.822496,150.659712 L59.9843714,150.659712 L59.9843714,210.643527 L185.787731,210.643527 C195.478053,210.643527 203.341343,202.77838 203.341343,193.089915 L203.341343,155.294133 L144.822496,105.321856 Z" fill="#00AC47"></path> <path d="M185.787731,0 L59.9843714,0 L59.9843714,59.9838143 L144.822496,59.9838143 L144.822496,105.32167 L203.341343,56.832928 L203.341343,17.5536117 C203.341343,7.86329022 195.478053,0 185.787731,0" fill="#FFBA00"></path> </g></svg>`,
84
+ "google-drive": `<svg width="36" height="36" aria-hidden="true" viewBox="0 0 256 229" xmlns="http://www.w3.org/2000/svg"><g> <path d="M19.3542312,196.033928 L30.644172,215.534816 C32.9900287,219.64014 36.3622164,222.86588 40.3210929,225.211737 C51.6602421,210.818376 59.5534225,199.772864 64.000634,192.075201 C68.5137119,184.263529 74.0609657,172.045039 80.6423954,155.41973 C62.9064315,153.085282 49.4659974,151.918058 40.3210929,151.918058 C31.545465,151.918058 18.1051007,153.085282 0,155.41973 C0,159.964996 1.17298825,164.510261 3.51893479,168.615586 L19.3542312,196.033928 Z" fill="#0066DA"></path> <path d="M215.681443,225.211737 C219.64032,222.86588 223.012507,219.64014 225.358364,215.534816 L230.050377,207.470615 L252.483511,168.615586 C254.829368,164.510261 256.002446,159.964996 256.002446,155.41973 C237.79254,153.085282 224.376613,151.918058 215.754667,151.918058 C206.488712,151.918058 193.072785,153.085282 175.506888,155.41973 C182.010479,172.136093 187.484394,184.354584 191.928633,192.075201 C196.412073,199.863919 204.329677,210.909431 215.681443,225.211737 Z" fill="#EA4335"></path> <path d="M128.001268,73.3111515 C141.121182,57.4655263 150.162898,45.2470011 155.126415,36.6555757 C159.123121,29.7376196 163.521739,18.6920726 168.322271,3.51893479 C164.363395,1.1729583 159.818129,0 155.126415,0 L100.876121,0 C96.1841079,0 91.638842,1.31958557 87.6799655,3.51893479 C93.7861943,20.9210065 98.9675428,33.3058067 103.224011,40.6733354 C107.927832,48.8151881 116.186918,59.6944602 128.001268,73.3111515 Z" fill="#00832D"></path> <path d="M175.360141,155.41973 L80.6420959,155.41973 L40.3210929,225.211737 C44.2799694,227.557893 48.8252352,228.730672 53.5172481,228.730672 L202.485288,228.730672 C207.177301,228.730672 211.722567,227.411146 215.681443,225.211737 L175.360141,155.41973 Z" fill="#2684FC"></path> <path d="M128.001268,73.3111515 L87.680265,3.51893479 C83.7213885,5.86488134 80.3489013,9.09044179 78.0030446,13.1960654 L3.51893479,142.223575 C1.17298825,146.329198 0,150.874464 0,155.41973 L80.6423954,155.41973 L128.001268,73.3111515 Z" fill="#00AC47"></path> <path d="M215.241501,77.7099697 L177.999492,13.1960654 C175.653635,9.09044179 172.281148,5.86488134 168.322271,3.51893479 L128.001268,73.3111515 L175.360141,155.41973 L255.855999,155.41973 C255.855999,150.874464 254.682921,146.329198 252.337064,142.223575 L215.241501,77.7099697 Z" fill="#FFBA00"></path> </g></svg>`,
85
+ backlog: `<svg width="20" height="20" aria-hidden="true" viewBox="0 0 142 142" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Nulab-logos-" transform="translate(-1348.000000, -2619.000000)"> <g id="Group-7" transform="translate(1348.000000, 2619.000000)"> <path d="M112.518975,141.27641 L29.5477542,141.27641 C13.8226507,141.27641 0.955380933,128.389559 0.955380933,112.638806 L0.955380933,29.5308457 C0.955380933,13.7800933 13.8226507,0.893241768 29.5477542,0.893241768 L112.518975,0.893241768 C128.244079,0.893241768 141.109945,13.7800933 141.109945,29.5308457 L141.109945,112.638806 C141.109945,128.389559 128.244079,141.27641 112.518975,141.27641" id="Fill-16" fill="#42CE9F"></path> <path d="M60.1988625,61.3236616 C60.1441201,72.9746913 60.1146434,84.3121962 60.1441201,91.0958596 C70.2756738,90.5461368 75.2937266,88.482216 79.2632519,83.7132655 C82.0775724,80.3319784 82.686757,76.4586121 80.9799172,72.8073843 C78.7467083,68.0328101 72.1804284,62.9897012 60.1988625,61.3236616 M53.5357306,104.795345 L53.5357306,104.795345 C51.7348462,104.795345 50.0069517,104.075503 48.7380511,102.796097 L48.7352438,102.793285 C46.7434629,100.784196 46.7153899,98.8622754 46.6648584,95.3741368 C46.6395927,93.6504536 46.6241525,91.2083348 46.6171343,88.2896022 C46.6059051,83.1719784 46.6185379,76.0705725 46.6536292,67.1808101 C46.7153899,51.8855824 46.8262783,36.6381566 46.8262783,36.6381566 L60.3448422,36.7365725 C60.3195765,40.1150477 60.2957144,43.8295428 60.2718523,47.705721 C76.2678604,49.5531269 88.283114,56.4998794 93.2225623,67.065523 C97.1640146,75.4941368 95.8263351,84.9589289 89.6474629,92.3837012 C80.1166718,103.836493 66.5812641,104.795345 53.5357306,104.795345" id="Fill-18" fill="#FFFFFF"></path> </g> </g> </g> </svg>`,
86
+ github: GITHUB_ICON,
87
+ };
68
88
  const CM_CAPS = [
69
89
  { cap: "課題管理", icon: "📋", pipe: /sync-backlog/ },
70
90
  { cap: "会議", icon: "🎙", pipe: /ingest-minutes/ },
@@ -109,6 +129,110 @@ function sourceNeedsAttention(s) {
109
129
  return false;
110
130
  return true;
111
131
  }
132
+ /**
133
+ * 能力(課題管理・会議・…)が**いまどういう状態か**を1つに決める。
134
+ *
135
+ * **接続マップとクライアント側の両方がこれを呼ぶ。** マップはビルド時にSVGを描き、
136
+ * 画面はブラウザで一覧を描くが、判定が2箇所にあると「同じ画面でマップが⚠️・直下が
137
+ * 『接続OK』」になる(`sourceNeedsAttention` の項に、実際にそうなった経緯がある)。
138
+ * クライアント側へは `.toString()` で埋め込んで共有する(`CLIENT_JS`)ので、
139
+ * **クロージャを持たない純関数に保つこと**——外の変数を掴むと直列化した先で壊れる。
140
+ *
141
+ * 返す値:
142
+ * "off" … 未使用(tools が none / enabled:false / 使っている証拠が無い)
143
+ * "idle" … 宣言はあるが一度も繋がっていない(画面表記は「未接続」)
144
+ * "warn" … 問題あり
145
+ * "ok" … 健全
146
+ *
147
+ * **順序が意味を持つ。** 未接続は警告より優先する——繋がっていない能力のパイプラインが
148
+ * 失敗するのは「壊れた」のではなく「まだ設定が済んでいない」結果で、逆にすると
149
+ * ⚠️の箱に「未接続」と書かれ、繋がっていないのに線が引かれる(実際にそうなった:
150
+ * APIキー未投入で sync-backlog が失敗し、課題管理が警告色で線付きになった)。
151
+ *
152
+ * @param cap 能力名
153
+ * @param ctx 生のフリートデータ { tools, internals, externals, pipes }。
154
+ * **解決済みの値を渡させない。** 振り分けや regex を呼び出し側に持たせると、
155
+ * 呼び出し側ごとに写経が生まれ、片方が書き忘れると判定が静かに変わる。
156
+ */
157
+ /**
158
+ * 外部ソースの種別(externalSources[].type)→ どの能力に属するか。
159
+ *
160
+ * **対応表はここ1箇所に持つ。** 画面の受け皿(capOf)と状態判定(capabilityState)と
161
+ * 件数の集計(tileSub)が各自で書き写していたため、未知の種別が
162
+ * 「受け皿には入るが、状態は未使用」という食い違いを起こした。
163
+ *
164
+ * **知らない種別は捨てず「その他」へ。** この画面の主眼は「何を Gold に
165
+ * 読んでいるか」の開示なので、読んでいるものが無言で消えるのが一番まずい。
166
+ */
167
+ const CAP_OTHER = "その他";
168
+ function externalCapability(type) {
169
+ if (type === "slack")
170
+ return "チャット";
171
+ if (String(type).indexOf("github") === 0)
172
+ return "開発";
173
+ return CAP_OTHER;
174
+ }
175
+ function capabilityState(cap, ctx) {
176
+ // **能力→パイプラインの対応はここに持つ。** 呼び出し側に渡させると、
177
+ // クライアント側(この関数を .toString() で受け取る側)が同じ表を書き写すことになり、
178
+ // 書き忘れると warn 判定が丸ごと死ぬ——実データで、東京ヴェルディの課題管理が
179
+ // マップは⚠️・タイルは「接続OK」になる。**この関数が読むものは、この関数が持つ。**
180
+ var PIPE = {
181
+ "課題管理": /sync-backlog/,
182
+ "会議": /ingest-minutes/,
183
+ "共有資料": /sync-materials/,
184
+ "デザイン": /sync-designs|update-design-notes/,
185
+ };
186
+ // **ライブ参照かどうかは、能力名ではなく渡された行から決める。** 名前で決めていたとき、
187
+ // 未知の種別を寄せた「その他」は行を2件持っているのに「未使用」と言い切っていた
188
+ // (読み手は未使用を信じて押さないので、行が消えるより悪い)
189
+ const liveExts = ctx.externals.filter(function (s) { return externalCapability(s.type) === cap; });
190
+ const isLive = cap === "チャット" || cap === "開発" || cap === CAP_OTHER;
191
+ const toolVal = ctx.tools[cap];
192
+ const internal = ctx.internals.filter(function (s) { return s.kind === cap; })[0];
193
+ const pipeRe = PIPE[cap];
194
+ // 使用中か: tools宣言がnone以外 / internalSourceが存在(enabled:false除く) / ライブ参照の実体がある
195
+ const used = (toolVal != null && toolVal !== "none") ||
196
+ (internal != null && internal.enabled !== false) ||
197
+ (isLive && liveExts.length > 0);
198
+ const off = toolVal === "none" || (internal != null && internal.enabled === false) || !used;
199
+ // 問題あり: ライブ参照はgate≠okが1件以上、同期ソースは対応パイプラインの直近runがfailure
200
+ const warn = !off &&
201
+ (isLive
202
+ ? liveExts.some(function (s) {
203
+ return sourceNeedsAttention(s);
204
+ })
205
+ : ctx.pipes.some(function (p) {
206
+ return (pipeRe != null && pipeRe.test(p.id) && p.applicable !== false && p.lastConclusion === "failure");
207
+ }));
208
+ // 「実際に繋がっている」の証拠は共通で次のどれか:
209
+ // - 一度でも同期が走った(lastSync)
210
+ // - 正本のURLが導出できる(url。設定ファイルが実在して初めて出る)
211
+ // - 会議は取り込みが有効(ingestState === "on")
212
+ // - ライブ参照は読む対象が1件以上ある
213
+ //
214
+ // **能力ごとに場当たりで判定しない。** 最初は会議とライブ参照だけを見ていたが、
215
+ // 課題管理が「宣言があるだけ」で接続済みに見えていた——同期が一度も走っておらず、
216
+ // APIキーすら入っていない案件で。指摘されるまで気づけなかった。
217
+ //
218
+ // **空振りした同期の時刻を「繋がっている証拠」にしない。**
219
+ // 対象外のパイプライン(applicable: false)も実行はされ、何もせず成功で終わり、
220
+ // その時刻が lastSync に入る。それを根拠にすると、Figmaのトークンも無く
221
+ // ファイルも0件の案件が「接続済み」として青い線で繋がる(実際にそうなっていた)。
222
+ // パイプライン一覧では「applicable:false は最終成功を出さない(スキップ正常終了のrunが
223
+ // 成功表示に見えるため)」と決めているのに、接続判定だけがその値を信じていた。
224
+ const pipeSkipped = ctx.pipes.some(function (p) {
225
+ return pipeRe != null && pipeRe.test(p.id) && p.applicable === false;
226
+ });
227
+ const syncedForReal = internal != null && ((internal.lastSync && !pipeSkipped) || internal.url);
228
+ const idle = !off &&
229
+ (isLive
230
+ ? liveExts.length === 0
231
+ : cap === "会議"
232
+ ? (internal == null ? undefined : internal.ingestState) !== "on"
233
+ : !syncedForReal);
234
+ return off ? "off" : idle ? "idle" : warn ? "warn" : "ok";
235
+ }
112
236
  function renderConnectionMap(site) {
113
237
  const internals = site.internalSources ?? [];
114
238
  const externals = site.externalSources ?? [];
@@ -129,48 +253,13 @@ function renderConnectionMap(site) {
129
253
  const internal = internals.find((s) => s.kind === c.cap);
130
254
  const isLive = c.cap === "チャット" || c.cap === "開発";
131
255
  const liveExts = c.cap === "チャット" ? slackExts : c.cap === "開発" ? ghExts : [];
132
- // 使用中か: tools宣言がnone以外 / internalSourceが存在(enabled:false除く) / ライブ参照の実体がある
133
- const used = (toolVal != null && toolVal !== "none") ||
134
- (internal != null && internal.enabled !== false) ||
135
- (isLive && liveExts.length > 0);
136
- const off = toolVal === "none" || internal?.enabled === false || !used;
137
- // 問題あり: ライブ参照はgate≠okが1件以上、同期ソースは対応パイプラインの直近runがfailure
138
- const warn = !off &&
139
- (isLive
140
- // カード側の集計(sourceNeedsAttention)と同じ判定にする。ここだけ接続状態だけを見ていると、
141
- // マップが⚠️なのに直下のグループが「接続OK」という矛盾が同じ画面に出る。
142
- ? liveExts.some((s) => sourceNeedsAttention(s))
143
- : pipes.some((p) => c.pipe != null && c.pipe.test(p.id) && p.applicable !== false && p.lastConclusion === "failure"));
144
- // **宣言されているだけで、実際には何も繋がっていない**状態を分けて表す。
145
- //
146
- // 会議に Teams、チャットに Teams を宣言した案件で、どちらも「接続済み」の見た目になっていた。
147
- // notetaker は Teams の文字起こしを取り込めず(Google Meet 前提)、Teams の MCP も配っていないので、
148
- // **実体は何も繋がっていない**。それを繋がって見せると、設定が足りないことに誰も気づけない。
149
- //
150
- // - 会議: 取り込みが有効(ingestState === "on")でなければ「自動連携なし」
151
- // - チャット/開発: ライブ参照の実体(登録されたチャンネル・リポジトリ)が無ければ同じ
152
- //
153
- // **能力ごとに場当たりで判定しない。** 最初は会議とライブ参照だけを見ていたが、
154
- // 課題管理が「宣言があるだけ」で接続済み(青)に見えていた——同期が一度も走っておらず、
155
- // APIキーすら入っていない案件で。指摘されるまで気づけなかった。
156
- //
157
- // 「実際に繋がっている」の証拠は共通で次のどれか:
158
- // - 一度でも同期が走った(lastSync)
159
- // - 正本のURLが導出できる(url。設定ファイルが実在して初めて出る)
160
- // - 会議は取り込みが有効(ingestState === "on")
161
- // - ライブ参照は読む対象が1件以上ある
162
- const idle = !off &&
163
- (isLive
164
- ? liveExts.length === 0
165
- : c.cap === "会議"
166
- ? internal?.ingestState !== "on"
167
- : !(internal?.lastSync || internal?.url));
168
- // **未接続は警告より優先する。** 繋がっていない能力のパイプラインが失敗するのは
169
- // 「壊れた」のではなく「まだ設定が済んでいない」結果で、順番を逆にすると
170
- // **⚠️の箱に「未接続」と書かれ、しかも繋がっていないのに線が引かれる**(実際にそうなった:
171
- // APIキー未投入で sync-backlog が失敗し、課題管理が警告色で線付きになった)。
172
- // 失敗そのものは下の「自動化パイプライン」の一覧に出るので、情報は失われない。
173
- const state = off ? "cm-off" : idle ? "cm-idle" : warn ? "cm-warn" : "cm-ok";
256
+ // **判定は capabilityState に一本化している。** 画面側(クライアントJS)も同じ関数を
257
+ // 呼ぶので、ここで写経すると「同じ画面でマップが⚠️・直下が『接続OK』」が復活する。
258
+ const st = capabilityState(c.cap, { tools, internals, externals, pipes });
259
+ const off = st === "off";
260
+ const idle = st === "idle";
261
+ const warn = st === "warn";
262
+ const state = "cm-" + st;
174
263
  // **ラベルは能力名(会議・課題管理…)。ツール名は下に小さく置く。**
175
264
  // ツールは「その能力をいま何で実現しているか」という差し替え可能なアダプタで、
176
265
  // 主語ではない。ツール名を主にすると、Teams のように複数の能力に使われるものが
@@ -746,6 +835,8 @@ a:hover { text-decoration: underline; }
746
835
  .datasources-asof.is-stale { color: var(--warn, #a15c00); }
747
836
  .conn-map-asof { color: var(--muted); font-size: 12px; margin: 6px 0 0; text-align: right; }
748
837
  .ds-sub { font-size: 13px; font-weight: 700; color: var(--muted); margin: 20px 0 8px; }
838
+ /* 能力の小見出し。同じ能力の設定(状態・追加・鍵)を1か所に集めるための区切り */
839
+ .ds-cap { font-size: 15px; font-weight: 700; margin: 26px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--line, #e5e7eb); }
749
840
  .ds-list { display: flex; flex-direction: column; gap: 10px; }
750
841
  .ds-card {
751
842
  background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
@@ -828,6 +919,35 @@ a.ds-name:hover { text-decoration: underline; }
828
919
  .ds-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; flex: 0 0 auto; }
829
920
  .badge.ds-gold { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
830
921
  .badge.ds-excluded { background: #eceef1; color: #6b7280; }
922
+ /* 保存は済んでいるが、接続状況の再計算(1〜2分)がまだのもの。**確定と同じ見た目にしない**
923
+ ——リロードで消えたときに「戻された」と読まれるため、点線で区別して (反映中) を添える。
924
+ **幅と色まで指定する。** border-style だけだと、border を持たない .ds-excluded 側は
925
+ 初期値の medium(≈3px)/currentColor に解決され、押した向きによって太さ3倍・色が別物になる */
926
+ .badge.is-pending { border: 1px dashed currentColor; opacity: 0.85; }
927
+ /* 能力モーダルは一覧(17チャンネル・19リポジトリ)を入れるので広く取る。
928
+ 既定の480pxでは、チャンネル名+バッジ2つ+トグルが1行に収まらず折り返す。
929
+ 画面の広さいっぱいまで使う——この画面は一覧を読むためのもので、
930
+ 余白より行の読みやすさを優先する */
931
+ /* **セレクタを .modal.modal-wide にする(詳細度を上げる)。** 素の .modal-wide だと
932
+ 後ろにある .modal の max-width:480px と詳細度が並び、**後勝ちで負ける**。
933
+ クラスは付いているのに効かず、見た目は「少し窮屈」なだけなので気づきにくい */
934
+ .modal.modal-wide { max-width: min(1040px, 92vw); }
935
+ .modal-overlay:has(.modal-wide) { padding: 16px; }
936
+ .ds-tile { display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; cursor: pointer; background: var(--card, #fff); border: 1px solid var(--line, #e5e7eb); border-radius: 12px; padding: 14px 16px; }
937
+ .ds-tile:hover { border-color: #c7cdd6; }
938
+ .ds-tile-body { flex: 1; min-width: 0; }
939
+ .ds-tile-name { font-weight: 700; }
940
+ /* ロゴはタイルの左に独立して置き、名前の高さに縛られないようにする。
941
+ 一覧を眺めるとき、先に目に入るのは文字よりロゴなので大きく取る */
942
+ .ds-tile-logo {
943
+ display: inline-flex; align-items: center; justify-content: center;
944
+ width: 40px; height: 40px; flex: 0 0 40px; font-size: 26px; line-height: 1;
945
+ }
946
+ .ds-tile-logo svg { width: 100%; height: 100%; }
947
+ .ds-tile-desc { font-size: 12.5px; color: var(--muted); }
948
+ .ds-tile-sub { font-size: 12.5px; margin-top: 2px; }
949
+ .ds-tile-chev { color: var(--muted); }
950
+ .ds-tile-note { font-size: 12.5px; color: #a15c00; margin-top: 2px; }
831
951
  .badge.ds-undeclared { background: #fff4e5; color: #9a6700; }
832
952
  .ds-toggle { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
833
953
  .ds-toggle-btn { font-size: 12px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; }
@@ -867,6 +987,8 @@ a.ds-name:hover { text-decoration: underline; }
867
987
  .ds-addform .sec-input { margin-bottom: 6px; }
868
988
  .sec-input-inline { width: auto; max-width: 220px; margin-bottom: 0; }
869
989
  .sec-how { color: var(--muted); font-size: 12px; line-height: 1.7; margin: 0 0 10px; }
990
+ /* 入力欄のすぐ下。貼る直前に読める位置に置く */
991
+ .sec-note { color: var(--muted); font-size: 12px; line-height: 1.7; margin: 6px 0 0; }
870
992
  .sec-how p { margin: 0; }
871
993
  /* 取り込みの条件は箇条書きで出す(ANDであることを読み飛ばせないように) */
872
994
  .sec-how-list { margin: 4px 0; padding-left: 1.3em; }
@@ -902,7 +1024,13 @@ button.ops-card-btn:disabled { opacity: .6; cursor: default; }
902
1024
  box-shadow: 0 12px 40px rgba(16,24,40,0.25); width: 100%; max-width: 480px;
903
1025
  max-height: 90vh; overflow-y: auto; padding: 22px; display: flex; flex-direction: column; gap: 12px;
904
1026
  }
1027
+ .modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
905
1028
  .modal-title { font-size: 17px; font-weight: 700; margin: 0; color: var(--text); }
1029
+ .modal-x {
1030
+ flex: 0 0 auto; border: none; background: none; cursor: pointer; font-size: 18px; line-height: 1;
1031
+ color: var(--muted); padding: 2px 6px; border-radius: 6px;
1032
+ }
1033
+ .modal-x:hover { background: var(--bg); color: var(--text); }
906
1034
  .modal-desc { font-size: 13px; color: var(--muted); line-height: 1.7; margin: 0; }
907
1035
  .modal-field { display: flex; flex-direction: column; gap: 4px; }
908
1036
  .modal-field-label { font-size: 12px; font-weight: 600; color: var(--muted); }
@@ -971,6 +1099,21 @@ button.ops-card-btn:disabled { opacity: .6; cursor: default; }
971
1099
  const CLIENT_JS = `
972
1100
  (function () {
973
1101
  var SITE = JSON.parse(document.getElementById("site-data").textContent);
1102
+ var ADAPTER_ICONS = ${JSON.stringify(ADAPTER_ICONS)};
1103
+
1104
+ // ---- ビルド時TSと共有する判定(唯一の正本は上の関数定義) ----
1105
+ //
1106
+ // **写経しない。** 接続マップ(ビルド時にSVGを描く)と画面(ブラウザで一覧を描く)が
1107
+ // 別々に同じ判定を持つと、同じ画面でマップが⚠️・直下が「接続OK」になる。
1108
+ // 実際に起きたので sourceNeedsAttention のコメントに経緯が残っている。
1109
+ // ここは関数の実体をそのまま流し込んでいるので、直すのは上の1箇所だけでよい。
1110
+ //
1111
+ // **この2つはクロージャを持たない純関数に保つこと。** 外の変数を掴むと、
1112
+ // 直列化した先(ここ)で undefined を参照して壊れる。
1113
+ var CAP_OTHER = ${JSON.stringify(CAP_OTHER)};
1114
+ ${externalCapability.toString()}
1115
+ ${sourceNeedsAttention.toString()}
1116
+ ${capabilityState.toString()}
974
1117
  // 「はじめに」セクションのチュートリアルデータ(ビルド時に埋め込み。ボタンURL・チャレンジ4の決定は解決済み)
975
1118
  var TUTORIAL = (function () {
976
1119
  var elx = document.getElementById("tutorial-data");
@@ -1541,6 +1684,35 @@ const CLIENT_JS = `
1541
1684
  }
1542
1685
 
1543
1686
  /** 組み立てだけを行う(設置は呼び出し側)。ライブ差し替えで同じ節を作り直すため分けている */
1687
+ /** ツールid→表示名(クライアント側。接続マップの CM_TOOL_LABEL と同じ内容) */
1688
+ var CM_TOOL_LABEL_C = {
1689
+ backlog: "Backlog", jira: "Jira", "google-meet": "Google Meet", teams: "Teams",
1690
+ "google-drive": "Google Drive", box: "Box", local: "ローカル", figma: "Figma",
1691
+ slack: "Slack", github: "GitHub", none: "",
1692
+ };
1693
+
1694
+ /**
1695
+ * 能力モーダル。**中身は既存の部品をそのまま入れる。**
1696
+ * 一覧・追加フォーム・鍵・手動投入は作り直さない(先出しバッジやライブ差し替えを壊さないため)。
1697
+ */
1698
+ function openCapabilityModal(cap, items) {
1699
+ var m = makeModal(cap, true);
1700
+ // **凡例を必ず入れる。** goldState の4状態は凡例があって初めて読める
1701
+ if (cap === "チャット" || cap === "開発") {
1702
+ m.modal.appendChild(el("p", { class: "modal-desc", text: "「Gold昇格の読み取り対象」のものだけを毎晩の Gold 昇格が読み取ります。「除外」は意図的に外したもの、「未宣言(対象外)」はまだ判断されていないもので、どちらも読み取りません。" }));
1703
+ }
1704
+ if (!items.length) {
1705
+ m.modal.appendChild(el("p", { class: "ds-empty", text: "この能力にはまだ設定できるものがありません。" }));
1706
+ }
1707
+ items.forEach(function (n) { m.modal.appendChild(n); });
1708
+ m.modal.appendChild(el("div", { class: "modal-actions" }, [
1709
+ el("button", { class: "ds-toggle-btn", type: "button", text: "閉じる" }),
1710
+ ]));
1711
+ m.modal.querySelector(".modal-actions button").addEventListener("click", m.close);
1712
+ m.overlay.appendChild(m.modal);
1713
+ showModal(m.overlay, m.modal);
1714
+ }
1715
+
1544
1716
  function buildDataSources() {
1545
1717
  var internals = fleetVal("internalSources") || [];
1546
1718
  var sources = fleetVal("externalSources") || [];
@@ -1549,11 +1721,30 @@ const CLIENT_JS = `
1549
1721
  var tools = fleetVal("tools") || {};
1550
1722
  var chatOff = tools["チャット"] === "none";
1551
1723
  var devOff = tools["開発"] === "none";
1552
- if (!internals.length && !sources.length && !pipes.length && !chatOff && !devOff) return null; // すべて無ければセクションごと出さない
1724
+ // **この節に入るものが1つでもあるなら出す。**
1725
+ //
1726
+ // 条件を「データ行があるか」で書いていて、2回続けて漏らした——
1727
+ // 1回目: 鍵カードを移したのに条件へ足さず、新規案件で鍵が丸ごと消えた
1728
+ // 2回目: tools の宣言だけの案件(Teams等)でタイルが出ず、
1729
+ // 「未対応です」と伝える場所も接続マップのアンカーの受け皿も無くなった
1730
+ //
1731
+ // 個別に足し続けると次も漏らすので、**この節に出す候補を列挙して判定する**形にする。
1732
+ var hasSecrets = ((SITE.secretKinds || []).filter(function (k) { return SECRET_FORMS[k]; })).length > 0;
1733
+ var hasTools = Object.keys(tools).length > 0;
1734
+ // **投入の口も候補。** fleet-status.json はGitHub Actionsが夜間に生成するので、
1735
+ // 立ち上げ直後の案件には存在しない。それを理由に節ごと消すと、
1736
+ // 文字起こし・資料を入れる口が**一番必要な瞬間に**画面から無くなる
1737
+ var hasIntake = !!SITE.intake;
1738
+ if (!internals.length && !sources.length && !pipes.length && !hasTools && !hasSecrets && !hasIntake) return null;
1553
1739
 
1554
1740
  var sec = el("section", { class: "datasources" });
1555
1741
  sec.appendChild(el("h2", { class: "datasources-head", text: "データソースと自動化" }));
1556
- sec.appendChild(el("p", { class: "datasources-intro", text: "このCortexが接続している情報源と、毎晩自動でGoldに昇格する処理の一覧です。" }));
1742
+ // **まだ何も繋がっていない案件では、文言を変える。** 接続がゼロの状態で
1743
+ // 「接続している情報源の一覧です」と書くと、中身(手動投入だけ)と噛み合わない
1744
+ var connected = internals.length > 0 || sources.length > 0 || hasTools;
1745
+ sec.appendChild(el("p", { class: "datasources-intro", text: connected
1746
+ ? "このCortexが接続している情報源と、毎晩自動でGoldに昇格する処理の一覧です。"
1747
+ : "情報源はまだ接続されていません。下から手元のファイルを取り込めます(自動連携の設定はこれから行います)。" }));
1557
1748
  // **いつ時点の情報かを必ず出す。** この節は夜間バッチが作るスナップショットを読んでいるので、
1558
1749
  // 設定を変えても次の生成までは古い値のまま。時刻が無いと「変えたのに反映されない」ではなく
1559
1750
  // 「変えたはずなのに違う値が出ている」と読まれてしまう(実際に設定変更直後にそうなった)。
@@ -1571,8 +1762,26 @@ const CLIENT_JS = `
1571
1762
  }));
1572
1763
  }
1573
1764
 
1765
+ // ---- 能力ごとの受け皿 ----
1766
+ //
1767
+ // **能力の一覧は tools のキーで決める。データ行の有無で決めない。**
1768
+ // internalSources に現れるのは 課題管理/会議/共有資料/デザイン の4つだけで、
1769
+ // チャットと開発は externalSources 側にしかない。さらに tools.チャット = "teams" の
1770
+ // 案件(三菱)は**どちらにも行が無い**——Cortex が Teams に対応していないため。
1771
+ // 行の有無でセクションを作ると、その案件でチャットが丸ごと消える。
1772
+ // 「未対応です」と伝えるべき場面で何も出ないのが一番悪い。
1773
+ var CAP_ORDER = ["課題管理", "会議", "共有資料", "チャット", "デザイン", "開発"];
1774
+ var capBox = {};
1775
+ var manualBox = {};
1776
+ // 対応表は externalCapability が持つ(状態判定・件数の集計と同じものを使う)
1777
+ var capOf = externalCapability;
1778
+ function pushCap(cap, node) {
1779
+ if (!cap || !node) return;
1780
+ if (!capBox[cap]) capBox[cap] = [];
1781
+ capBox[cap].push(node);
1782
+ }
1783
+
1574
1784
  if (internals.length) {
1575
- sec.appendChild(el("div", { class: "ds-sub", text: "リポジトリ内の情報源(自動同期)" }));
1576
1785
  // **「いま同期する」ボタンは出さない。** 受け口(Apps Script の Webアプリ)を
1577
1786
  // 公開できないため、押しても届かない。
1578
1787
  //
@@ -1584,7 +1793,6 @@ const CLIENT_JS = `
1584
1793
  // 最悪60分→20分)。押す口が要るなら、bot の資格情報で Lambda が認証する経路を
1585
1794
  // 用意してからここを戻す(syncNowRow と Lambda の op=sync は残してある)。
1586
1795
  // sec.appendChild(syncNowRow());
1587
- var ilist = el("div", { class: "ds-list" });
1588
1796
  internals.forEach(function (s) {
1589
1797
  // ラベルの下に補足行(会議の照合キー・Drive未設定の注記)を重ねられるよう縦組みにする
1590
1798
  var col = el("div", { class: "ds-col" }, [ dsNameEl(s.label || "", s.url) ]);
@@ -1647,13 +1855,14 @@ const CLIENT_JS = `
1647
1855
  // 会議・共有資料・デザインの操作をカード内に置く(その情報源の文脈で押せるように)
1648
1856
  var ctl = internalControls(s, inactive);
1649
1857
  if (ctl) card.appendChild(ctl);
1650
- ilist.appendChild(card);
1858
+ // 能力ごとの受け皿へ入れる(並べるのは下の CAP_ORDER のループ)
1859
+ pushCap(s.kind, card);
1651
1860
  });
1652
- sec.appendChild(ilist);
1653
1861
  }
1654
1862
 
1655
1863
  if (sources.length || chatOff || devOff) {
1656
- sec.appendChild(el("div", { class: "ds-sub", text: "外部ソース(ライブ参照)" }));
1864
+ // **見出しは付けない。** #101 で行は能力ごとのモーダルへ移ったので、
1865
+ // ここに見出しを置くと中身ゼロの見出しが節の先頭に立つ(実際に8案件すべてで出ていた)
1657
1866
  // ツール(type)別にグループ化し、多いグループ(4件以上)は折りたたむ
1658
1867
  var groups = [];
1659
1868
  var byType = {};
@@ -1663,9 +1872,8 @@ const CLIENT_JS = `
1663
1872
  g.items.push(s);
1664
1873
  });
1665
1874
  var UNIT = { "slack": "チャンネル", "github-issues": "リポジトリ", "github-discussions": "リポジトリ" };
1666
- var wrap = el("div", { class: "ds-list" });
1667
1875
  // チャット未使用: Slackグループの位置(先頭)に「存在するが未設定」のグレー行を出す
1668
- if (chatOff) wrap.appendChild(inactiveSourceRow("💬", "チャット", "ds-チャット"));
1876
+ if (chatOff) pushCap("チャット", inactiveSourceRow("💬", "チャット", "ds-チャット"));
1669
1877
  var chatAnchored = chatOff;
1670
1878
  var devAnchored = devOff;
1671
1879
  groups.forEach(function (g) {
@@ -1678,14 +1886,12 @@ const CLIENT_JS = `
1678
1886
  g.items.forEach(function (s) { cards.appendChild(extSourceCard(s, meta)); });
1679
1887
  if (g.items.length >= 4) {
1680
1888
  // 折りたたみ(既定は閉)。summaryに件数と状態の要約を出す。
1681
- // 判定は接続マップ側の sourceNeedsAttention と揃える(片方だけ直すと同じ画面で矛盾する)。
1682
- // 意図的に外したソースが接続できないのは当たり前なので数えない。ただし通知先に
1683
- // 使っているなら届いていないこと自体が問題なので拾う。gold 不明は警告する側に倒す。
1889
+ // **判定は共有の sourceNeedsAttention を呼ぶ。** 以前はここに中身を写経していたが、
1890
+ // 片方だけ直すと同じ画面でマップが⚠️・直下が「接続OK」になる(実際に起きた)。
1684
1891
  var bad = 0;
1685
1892
  var noToken = 0;
1686
1893
  g.items.forEach(function (s) {
1687
- if (s.gate === "ok") return;
1688
- if (s.gold === false && s.notify !== true) return;
1894
+ if (!sourceNeedsAttention(s)) return;
1689
1895
  bad++;
1690
1896
  if (s.gate === "no_token") noToken++;
1691
1897
  });
@@ -1701,30 +1907,198 @@ const CLIENT_JS = `
1701
1907
  ]);
1702
1908
  var det = el("details", { class: "ds-group" }, [sum, cards]);
1703
1909
  if (anchorId) det.id = anchorId;
1704
- wrap.appendChild(det);
1910
+ pushCap(capOf(g.type), det);
1705
1911
  } else {
1706
1912
  if (anchorId && cards.firstChild) cards.firstChild.id = anchorId;
1707
- while (cards.firstChild) wrap.appendChild(cards.firstChild);
1913
+ // **appendChild と違い pushCap はノードを動かさない。** firstChild を条件にすると
1914
+ // 永久に真のままになる(実際に無限ループを作った)。先に配列へ取り出す。
1915
+ var kids = [];
1916
+ for (var ci = 0; ci < cards.childNodes.length; ci++) kids.push(cards.childNodes[ci]);
1917
+ kids.forEach(function (n) { pushCap(capOf(g.type), n); });
1708
1918
  }
1709
1919
  });
1710
- // 開発未使用: GitHubグループの位置(末尾)にグレー行を出す
1711
- if (devOff) wrap.appendChild(inactiveSourceRow("🐙", "GitHub", "ds-開発"));
1712
- sec.appendChild(wrap);
1713
1920
  }
1714
- // **追加フォームは外部ソースの節の外に置く。** グループは0件だと描かれず、4件以上だと
1715
- // 畳まれる(既定は閉)。さらに節そのものが「1件以上あるとき」しか描かれないので、
1716
- // 中に入れると**新規案件(0件)=一番追加したい場面**で消える。
1921
+ // 開発未使用: グレー行も能力の受け皿へ
1922
+ if (devOff) pushCap("開発", inactiveSourceRow("🐙", "GitHub", "ds-開発"));
1923
+
1924
+ // **追加フォームはグループの外に置く。** グループは0件だと描かれず、4件以上だと
1925
+ // 畳まれる(既定は閉)。中に入れると**新規案件(0件)=一番追加したい場面**で消える。
1717
1926
  var adders = renderExternalAdders();
1718
- if (adders.childNodes.length) {
1719
- if (!sources.length && !chatOff && !devOff) {
1720
- sec.appendChild(el("div", { class: "ds-sub", text: "外部ソース(ライブ参照)" }));
1927
+ pushCap("チャット", adders.querySelector('[data-adder="slack"]'));
1928
+ pushCap("開発", adders.querySelector('[data-adder="github"]'));
1929
+
1930
+ // 連携の鍵は、その能力のところへ置く(画面下まで往復させない)
1931
+ var SECRET_CAP = { figma: "デザイン", backlog: "課題管理", "external-sources": "開発" };
1932
+ (SITE.secretKinds || []).forEach(function (k) {
1933
+ if (!SECRET_FORMS[k]) return;
1934
+ pushCap(SECRET_CAP[k], secretCard(k, SECRET_FORMS[k]));
1935
+ });
1936
+
1937
+ // **手動投入はアダプタに依存しないので、別タイルにする。**
1938
+ // Google Meet でも Teams でも、文字起こしを手で入れる操作は同じ。
1939
+ // アダプタのタイルに混ぜると、Teams案件(自動取り込みが使えない=手動投入が
1940
+ // 唯一の口)で「未対応」のタイルの裏に隠れてしまう。
1941
+ manualBox["会議"] = intakeCardFor("会議");
1942
+ manualBox["共有資料"] = intakeCardFor("共有資料");
1943
+
1944
+ // ---- 能力ごとにタイルを並べる ----
1945
+ //
1946
+ // **タイルは「押すかどうか」を判断できるだけの情報を持つ。**
1947
+ // 押さないと分からない設計にすると、問題があること自体に気づけない。
1948
+ // いまの一覧が一番価値を出しているのはそこなので、畳んで失わないようにする。
1949
+ var CAP_META = {
1950
+ "課題管理": { icon: "📋", desc: "課題・ドキュメントの同期" },
1951
+ "会議": { icon: "🎙", desc: "文字起こしから議事録を生成" },
1952
+ "共有資料": { icon: "📁", desc: "資料をMarkdown化して取り込み" },
1953
+ "チャット": { icon: "💬", desc: "チャンネルの会話をGoldの材料に" },
1954
+ "デザイン": { icon: "🎨", desc: "画面インベントリの同期" },
1955
+ "開発": { icon: "🐙", desc: "IssuesをGoldの材料に" },
1956
+ };
1957
+ var STATE_LABEL = { ok: "● 接続OK", idle: "⦿ 未接続", off: "未使用", warn: "⚠️" };
1958
+ var SECRET_OF_CAP = { "デザイン": "figma", "課題管理": "backlog", "開発": "external-sources" };
1959
+
1960
+ /** タイルの2行目(状態と読み取り範囲)。**件数は出せる能力だけ出す** */
1961
+ function tileSub(cap, st) {
1962
+ var internal = internals.filter(function (s) { return s.kind === cap; })[0];
1963
+ var live = sources.filter(function (s) { return externalCapability(s.type) === cap; });
1964
+ var parts = [];
1965
+ if (st === "warn") {
1966
+ if (live.length) {
1967
+ var bad = 0, noToken = 0;
1968
+ live.forEach(function (s) {
1969
+ if (!sourceNeedsAttention(s)) return;
1970
+ bad++;
1971
+ if (s.gate === "no_token") noToken++;
1972
+ });
1973
+ parts.push(noToken === bad && bad > 0
1974
+ ? "⚠️ " + bad + "件が読めていません(トークン未設定)"
1975
+ : "⚠️ " + bad + "件に問題");
1976
+ } else {
1977
+ // 内部能力の warn はパイプラインの失敗由来。数えるものが無いので件数を出さない
1978
+ parts.push("⚠️ 同期が失敗しています");
1979
+ }
1980
+ } else {
1981
+ // **off には2つの意味が同居している。** 「使わないと宣言した」(tools が none)と
1982
+ // 「まだ何も分かっていない」(fleet-status.json を夜間バッチがまだ作っていない)。
1983
+ // 後者を「未使用」と書くと、立ち上げ直後の案件で顧客が
1984
+ // 「Backlog も Slack も使わない案件だ」と読む——繋ぐ前提で契約しているのに
1985
+ var declaredOff = tools[cap] === "none" || (internal != null && internal.enabled === false);
1986
+ parts.push(st === "off" && !declaredOff ? "未設定" : STATE_LABEL[st] || "");
1987
+ }
1988
+ // 読み取り範囲。**この画面は顧客も見る。何をGoldに読んでいるかの開示が主眼**
1989
+ //
1990
+ // **繋がっているときだけ出す。** 「未使用」と「2リポジトリ/2件をGoldに使用」が
1991
+ // 同じ行に並ぶと読めない。tools を none に倒しても externalSources は翌朝の
1992
+ // 再生成まで残るので、使うのをやめた宣言の直後に必ず通る。
1993
+ // 「⦿ 未接続 Driveフォルダ 0件」も同様(最終同期を出さないのと同じ理由)
1994
+ var connected = st === "ok" || st === "warn";
1995
+ if (!connected) {
1996
+ // 状態だけ出す
1997
+ } else if (live.length) {
1998
+ // 単位は分かるものだけ。知らない種別に勝手な単位を付けない
1999
+ var unit = cap === "チャット" ? "チャンネル" : cap === "開発" ? "リポジトリ" : "件";
2000
+ var gold = live.filter(function (s) { return s.gold === true; }).length;
2001
+ parts.push(live.length + unit + "/" + gold + "件をGoldに使用");
2002
+ } else if (cap === "共有資料" && tools[cap] === "google-drive" && internal && internal.driveFolderIds) {
2003
+ // **ツールを見る。** local の案件(gdo・三菱)は Drive を使っていないのに
2004
+ // 「ローカル」と名乗ったタイルが「Driveフォルダ 0件」と語っていた
2005
+ parts.push("Driveフォルダ " + internal.driveFolderIds.length + "件");
2006
+ } else if (cap === "デザイン" && internal && internal.figmaFiles) {
2007
+ // **キーが無ければ何も出さない。** 未取得を「登録ゼロ」と断定して顧客に見せない
2008
+ parts.push("Figmaファイル " + internal.figmaFiles.length + "件");
2009
+ }
2010
+ // **未接続・未使用のときは最終同期を出さない。** 空振りしたrunの時刻なので、
2011
+ // 「未接続」と並べると「一度は繋がった」と読めてしまう
2012
+ if (internal && internal.lastSync && st !== "off" && st !== "idle") {
2013
+ var t = dsFmtTime(internal.lastSync);
2014
+ if (t) parts.push("最終同期 " + t);
2015
+ }
2016
+ return parts.join(" ");
2017
+ }
2018
+
2019
+ // **CAP_ORDER に無い能力も並べる。** 既知の順に並べたあと、受け皿に入った
2020
+ // 未知のもの(未対応のツール・新しい種別)を後ろに足す
2021
+ var extraCaps = Object.keys(capBox).filter(function (c) { return CAP_ORDER.indexOf(c) === -1; });
2022
+ var anyCap = false;
2023
+ CAP_ORDER.concat(extraCaps).forEach(function (cap) {
2024
+ var items = capBox[cap] || [];
2025
+ var declared = tools[cap] != null;
2026
+ // **tools に宣言があれば、行が無くてもタイルを出す。** 出さないと
2027
+ // Teams案件でチャットが丸ごと消え、「未対応です」と伝える場所も無くなる。
2028
+ // 接続マップのアンカー(ds-<能力>)の受け皿にもなる(いま2案件で解決できていない)
2029
+ // **手動投入はアダプタに依存しない。** 宣言や行の有無で弾くと、
2030
+ // fleet-status.json がまだ無い新規案件で投入の口ごと消える
2031
+ if (!items.length && !declared && !manualBox[cap]) return;
2032
+ anyCap = true;
2033
+ sec.appendChild(el("div", { class: "ds-cap", text: cap }));
2034
+ // アダプタのタイルは、宣言か行があるときだけ。手動投入だけが理由でここへ来た
2035
+ // 新規案件に、中身の無い「会議」タイルを立てても押す先が無い
2036
+ if (items.length || declared) renderAdapterTile(cap, items);
2037
+ renderManualTile(cap);
2038
+ });
2039
+
2040
+ function renderAdapterTile(cap, items) {
2041
+ var meta = CAP_META[cap] || { icon: "🔗", desc: "" };
2042
+ var st = capabilityState(cap, { tools: tools, internals: internals, externals: sources, pipes: pipes });
2043
+ // **ロゴはタイルの左に独立して置く。** 名前の行に入れると名前の高さに
2044
+ // 縛られて小さくなる。ロゴで能力を見分けられるのが一覧の主な用途
2045
+ var logo = ADAPTER_ICONS[tools[cap]];
2046
+ var ic = el("span", { class: "ds-tile-logo" });
2047
+ if (logo) ic.innerHTML = logo; // 公式ロゴ。ビルド時に埋め込み済みで外部通信は起きない
2048
+ else ic.textContent = meta.icon;
2049
+ // "none"(未使用の宣言)はツール名ではない。素通しすると顧客の画面に
2050
+ // 「none」というタイル名が出る(接続マップ側で一度直したのと同じ穴)
2051
+ var rawTool = tools[cap] && tools[cap] !== "none" ? tools[cap] : "";
2052
+ var body = el("div", { class: "ds-tile-body" }, [
2053
+ el("div", { class: "ds-tile-name", text: (rawTool && (CM_TOOL_LABEL_C[rawTool] || rawTool)) || cap }),
2054
+ el("div", { class: "ds-tile-desc", text: meta.desc }),
2055
+ el("div", { class: "ds-tile-sub ds-gate-" + st, text: tileSub(cap, st) }),
2056
+ ]);
2057
+ // トークン未設定は**状態とは別軸**。状態機械に混ぜると判定が1箇所でなくなる。
2058
+ // ただし「使わない」と**宣言した**能力では出さない——設定する必要が無いものに
2059
+ // ついて設定を促す偽の警告になる(sushiro のデザインで出ていた)。
2060
+ //
2061
+ // **状態(off)ではなく宣言(none)で判断する。** off には「none と書いた」と
2062
+ // 「まだ何も書いていない」の両方が落ちてくる。状態で切ると、tools が空の
2063
+ // 新規案件——**まさに何を入れれば繋がるのかを知りたい場面**——から、
2064
+ // 鍵が未設定であることの手がかりまで消える
2065
+ var sk = SECRET_OF_CAP[cap];
2066
+ if (tools[cap] !== "none" && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !((fleetVal("secretSources") || {})[sk])) {
2067
+ body.appendChild(el("div", { class: "ds-tile-note", text: "⚠ トークンが未設定です" }));
2068
+ }
2069
+ var tile = el("button", { class: "ds-tile", type: "button", id: "ds-" + cap }, [
2070
+ ic,
2071
+ body,
2072
+ el("span", { class: "ds-tile-chev", text: "›" }),
2073
+ ]);
2074
+ tile.addEventListener("click", function () { openCapabilityModal(cap, items); });
2075
+ sec.appendChild(tile);
2076
+ }
2077
+
2078
+ // アダプタ非依存の操作(手動投入)は同じ能力の中で別タイルにする
2079
+ function renderManualTile(cap) {
2080
+ var manual = manualBox[cap];
2081
+ if (manual) {
2082
+ var mBody = el("div", { class: "ds-tile-body" }, [
2083
+ el("div", { class: "ds-tile-name", text: cap === "会議" ? "文字起こしを手動で追加" : "資料を手動で追加" }),
2084
+ el("div", { class: "ds-tile-desc", text: cap === "会議"
2085
+ ? "自動取り込みできない会議(顧客主催・別ツール)の文字起こしから議事録を作ります"
2086
+ : "手元のファイルやテキストを Markdown 化して取り込みます" }),
2087
+ ]);
2088
+ var mTile = el("button", { class: "ds-tile", type: "button", id: "ds-manual-" + cap }, [
2089
+ el("span", { class: "ds-tile-logo", text: "📥" }),
2090
+ mBody,
2091
+ el("span", { class: "ds-tile-chev", text: "›" }),
2092
+ ]);
2093
+ mTile.addEventListener("click", function () {
2094
+ openCapabilityModal(cap === "会議" ? "文字起こしを手動で追加" : "資料を手動で追加", [manual]);
2095
+ });
2096
+ sec.appendChild(mTile);
1721
2097
  }
1722
- sec.appendChild(adders);
1723
2098
  }
1724
2099
 
1725
- // 注記は内部・外部の両グループの後ろに1つ(内部の直下だけに置くと外部ソースが
1726
- // Gold昇格の対象外に見えるため)。除外の方法もここで案内する。
1727
- if (internals.length || sources.length || chatOff || devOff) {
2100
+ // 注記は全能力の後ろに1つ。**Gold の4状態は凡例があって初めて読める。**
2101
+ if (anyCap) {
1728
2102
  sec.appendChild(el("p", { class: "ds-note", text: "「Gold昇格の読み取り対象」のものだけを毎晩の Gold 昇格が読み取ります。「除外」は意図的に外したもの、「未宣言(対象外)」はまだ判断されていないもので、どちらも読み取りません(チャンネル・リポジトリ単位で切り替えられます)。" }));
1729
2103
  }
1730
2104
 
@@ -1787,8 +2161,14 @@ const CLIENT_JS = `
1787
2161
  // **共通部品(stateToggle)に載せる。** 以前はここだけ変更者名をダイアログで聞いており、
1788
2162
  // 同じカードの中で2つの流儀が並んでいた。送信・エラー表示・向きの反転も個別実装で、
1789
2163
  // 片方だけ直したときにずれる状態だった。
2164
+ // **控えはボタンにも効かせる。** バッジだけに効かせると、リロード後に同じカードの中で
2165
+ // バッジが「対象です」・ボタンが「始めますか?」と逆を主張する。しかもその状態で
2166
+ // ボタンの表示どおりに押すと、サーバは既に同じ値なので changed:false を返し、
2167
+ // ボタンが永久に反転しない——**15分(TTL切れ)まで止められなくなる**。
2168
+ // stateToggle の doc が要件として書いている「戻す操作を必ず残す」を破る。
2169
+ var pendingNow = pendingGoldFor(s);
1790
2170
  return stateToggle({
1791
- on: s.gold === true,
2171
+ on: pendingNow ? pendingNow.value : s.gold === true,
1792
2172
  labelOn: "Gold昇格を止める",
1793
2173
  labelOff: "Gold昇格を始める",
1794
2174
  confirmOn: [
@@ -1804,6 +2184,23 @@ const CLIENT_JS = `
1804
2184
  build: function (next) {
1805
2185
  return { op: "set-channel-gold", ref: s.ref, value: next };
1806
2186
  },
2187
+ // 保存できた直後に、この行のバッジだけを「反映中」に差し替える。
2188
+ // 一覧そのものは触らない(結果メッセージや他行の状態を巻き込まないため)。
2189
+ pending: function (next, toggleEl) {
2190
+ // **表示済みの値に戻したなら「反映中」ではない。** ON→OFF と押し直した場合、
2191
+ // 画面が出している値と一致するので待つものが無い。ここで消さないと、
2192
+ // 何も保留していないのに (反映中) が残り、リロードで消えて不可解に見える。
2193
+ var back = next === (s.gold === true);
2194
+ if (back) clearPendingGold(s.ref);
2195
+ else markPendingGold(s.ref, next);
2196
+ var card = toggleEl && toggleEl.parentNode;
2197
+ var badges = card && card.querySelector(".ds-badges");
2198
+ if (!badges) return;
2199
+ var b = badges.querySelector(".badge.ds-gold, .badge.ds-excluded, .badge.ds-undeclared");
2200
+ var spec = pendingBadgeSpec(next, back);
2201
+ if (b) { b.className = spec.cls; b.textContent = spec.text; }
2202
+ else badges.insertBefore(el("span", { class: spec.cls, text: spec.text }), badges.firstChild);
2203
+ },
1807
2204
  });
1808
2205
  }
1809
2206
 
@@ -2065,6 +2462,7 @@ const CLIENT_JS = `
2065
2462
  ];
2066
2463
  },
2067
2464
  build: function (url, name) { return { op: "add-channel", url: url, name: name }; },
2465
+ adder: "slack",
2068
2466
  }));
2069
2467
 
2070
2468
  if (usable("開発", "github")) {
@@ -2087,6 +2485,7 @@ const CLIENT_JS = `
2087
2485
  ];
2088
2486
  },
2089
2487
  build: function (repo, name) { return { op: "add-github-source", repo: repo, name: name }; },
2488
+ adder: "github",
2090
2489
  }));
2091
2490
  }
2092
2491
  return box;
@@ -2098,9 +2497,14 @@ const CLIENT_JS = `
2098
2497
  // 片方だけ直したときにずれる(既存の goldToggle で使っている作法をそのまま一般化したもの)。
2099
2498
 
2100
2499
  /**
2101
- * サーバへ設定変更を送る。**成功しても一覧やバッジは書き換えない**
2500
+ * サーバへ設定変更を送る。**一覧は書き換えない**
2102
2501
  * (接続状況は夜間バッチのスナップショットなので、「もう効いている」と誤解させないため)。
2103
- * 呼び出し側は onDone で自分のボタンの向きだけを反転させる。
2502
+ * 呼び出し側は onDone で自分のボタンの向きを反転させる。
2503
+ *
2504
+ * **例外はバッジの先出し。** opts.pending を持つ操作(いまは Gold の切り替えだけ)は、
2505
+ * 押した内容を「(反映中)」として先に出す。出してよいのは (1) 保存の成否がサーバで
2506
+ * 確定していて推測が入らない (2) **確定した表示と見た目で区別できる**、の両方を満たす
2507
+ * ときだけ。区別しないと、リロードで消えたときに「戻された」と読まれる。
2104
2508
  */
2105
2509
  async function sendConfig(payload, msg, btn, onDone) {
2106
2510
  var intake = SITE.intake;
@@ -2167,7 +2571,9 @@ const CLIENT_JS = `
2167
2571
 
2168
2572
  /**
2169
2573
  * ON/OFF のトグル。**戻す操作を必ず残す**(誤って有効にしたとき翌朝まで止められないのは許容できない)。
2170
- * @param opts { on, labelOn, labelOff, confirmOn, build(next) }
2574
+ * @param opts { on, labelOn, labelOff, confirmOn, build(next), pending(next, wrapEl) }
2575
+ * pending … 保存できた直後に、その行のバッジだけを「(反映中)」へ差し替える任意のフック。
2576
+ * 持たない操作は従来どおりボタンの向きだけを反転する(バッジは触らない)。
2171
2577
  */
2172
2578
  function stateToggle(opts) {
2173
2579
  var wrap = el("div", { class: "ds-toggle" });
@@ -2185,9 +2591,12 @@ const CLIENT_JS = `
2185
2591
  // 読み始める方向(危険)だけ確認する。止める方向は素通し
2186
2592
  if (next && opts.confirmOn && !confirmDanger(opts.confirmOn)) { msg.textContent = ""; return; }
2187
2593
  sendConfig(opts.build(next), msg, btn, function () {
2188
- // **押した内容をボタンに反映し、逆向きの操作を残す。** バッジや一覧は触らない
2594
+ // **押した内容をボタンに反映し、逆向きの操作を残す。**
2189
2595
  on = next;
2190
2596
  paint();
2597
+ // バッジは「反映中」として先出しする(opts.pending がある操作だけ)。
2598
+ // **カードごと作り直さない。** 上の msg に出した結果表示が消えてしまう
2599
+ if (opts.pending) opts.pending(next, wrap);
2191
2600
  });
2192
2601
  });
2193
2602
  wrap.appendChild(btn);
@@ -2270,7 +2679,7 @@ const CLIENT_JS = `
2270
2679
  // このセッションで追加したものを並べる場所(リロードすると消える=正式な一覧は上に出る)
2271
2680
  var pending = el("div", { class: "ds-pending" });
2272
2681
 
2273
- var box = el("div", { class: "ds-addform" }, [
2682
+ var box = el("div", opts.adder ? { class: "ds-addform", "data-adder": opts.adder } : { class: "ds-addform" }, [
2274
2683
  el("div", { class: "ds-addform-title", text: opts.title }),
2275
2684
  opts.hint ? el("p", { class: "sec-how", text: opts.hint }) : null,
2276
2685
  main,
@@ -2281,11 +2690,125 @@ const CLIENT_JS = `
2281
2690
  return box;
2282
2691
  }
2283
2692
 
2693
+ /* ---------------------------------------------------------------------------
2694
+ * Gold切り替えの「反映中」表示。
2695
+ *
2696
+ * 押しても一覧のバッジが変わるのは fleet-status.yml が完走したあと(1〜2分)。
2697
+ * その間バッジが古いままだと「押したのに何も起きない」に見える。
2698
+ *
2699
+ * **推測ではない。** Lambda は書き込む前に Bot の在籍を確認し、ダメなら 400 を返すので、
2700
+ * 200 が返った時点で channels.json は確実に書き換わっている。ここで出すのは
2701
+ * 「確定した事実の先出し」であって楽観的な当て推量ではない。
2702
+ *
2703
+ * **確定したバッジと同じ見た目にはしない。** 30秒後にリロードした人には、付けたはずの
2704
+ * ラベルが消えて見える——「変えたはずなのに違う値が出ている」という、この画面が
2705
+ * 繰り返し警告してきた誤読そのものになる。だから (反映中) を付けて区別し、
2706
+ * さらに sessionStorage に持たせてリロードしても残す。
2707
+ *
2708
+ * 消えるのは「fleet-status が追いついたとき」か「TTL 切れ」。TTL が無いと、
2709
+ * ワークフローが落ちている案件で永久に「反映中」が残り、嘘の表示になる。
2710
+ * ------------------------------------------------------------------------- */
2711
+ // **キーは案件別にする。** Slackの共有チャンネルは複数案件のCortexに同時に載りうるので、
2712
+ // 案件をまたぐキーにすると、案件Aで押した「反映中」が案件Bの同じ行に出る
2713
+ // (チュートリアルの既読キーが同じ理由で案件別になっている)。
2714
+ var PENDING_KEY = "cortex.pendingGold:" + ((SITE.intake && SITE.intake.projectKey) || "");
2715
+ var PENDING_TTL_MS = 15 * 60 * 1000;
2716
+ /**
2717
+ * 保存する形: { "<チャンネルID>": { value: boolean, at: number } }
2718
+ * at が無い/0 のエントリは期限切れとして落とす。
2719
+ * **型では守れない**(このコードは render.ts のテンプレートリテラル内で、tsc が見ない)。
2720
+ * 守るのはテストの仕事。
2721
+ */
2722
+
2723
+ /**
2724
+ * 先出しバッジの見た目。**1箇所に集約する**——文言とクラスを2箇所に書くと片方だけ直る。
2725
+ *
2726
+ * **「反映中」とは書かない。** この画面の既存語彙では「反映」が2つの意味で使われていて、
2727
+ * 短いほう(数分)には必ず「一覧への」が付いている(サーバ文言「次回の Gold 昇格
2728
+ * (毎晩22時ごろ)から反映されます。一覧への反映には数分かかります」)。修飾なしの
2729
+ * 「反映中」だと、22時の蒸留の話と読めてしまう。
2730
+ *
2731
+ * とくに OFF 方向で害が大きい。**止めた時点で蒸留に対しては完全に効いている**
2732
+ * (Slack はミラーせず Gold 昇格が直接読むため)。待っているのは画面だけなので、
2733
+ * 「除外(反映中)」は「まだ除外されていない」と逆に読める。
2734
+ */
2735
+ function pendingBadgeSpec(value, settled) {
2736
+ return {
2737
+ cls: "badge " + (value ? "ds-gold" : "ds-excluded") + (settled ? "" : " is-pending"),
2738
+ text: (value ? "Gold昇格の読み取り対象" : "除外") + (settled ? "" : "(画面反映待ち)"),
2739
+ };
2740
+ }
2741
+
2742
+ function readPending() {
2743
+ try {
2744
+ var raw = sessionStorage.getItem(PENDING_KEY);
2745
+ if (!raw) return {};
2746
+ var obj = JSON.parse(raw);
2747
+ if (!obj || typeof obj !== "object") return {};
2748
+ var now = Date.now();
2749
+ var out = {};
2750
+ for (var k in obj) {
2751
+ if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
2752
+ var e = obj[k];
2753
+ if (e && typeof e === "object" && now - (e.at || 0) < PENDING_TTL_MS) out[k] = e;
2754
+ }
2755
+ return out;
2756
+ } catch (_) {
2757
+ // sessionStorage が使えない環境(プライベートモード等)でも画面は出す
2758
+ return {};
2759
+ }
2760
+ }
2761
+ function writePending(map) {
2762
+ try { sessionStorage.setItem(PENDING_KEY, JSON.stringify(map)); } catch (_) {}
2763
+ }
2764
+ function clearPendingGold(ref) {
2765
+ if (!ref) return;
2766
+ var map = readPending();
2767
+ if (!(ref in map)) return;
2768
+ delete map[ref];
2769
+ writePending(map);
2770
+ }
2771
+ /** 押した内容を控える。fleet-status が追いつくまでの間だけ効く */
2772
+ function markPendingGold(ref, value) {
2773
+ if (!ref) return;
2774
+ var map = readPending();
2775
+ map[ref] = { value: value === true, at: Date.now() };
2776
+ writePending(map);
2777
+ }
2778
+ /**
2779
+ * その行の「反映中」を引く。**実データが追いついていたら控えを捨てる。**
2780
+ * 捨てないと、正しくなった後も (反映中) が残り続ける。
2781
+ */
2782
+ function pendingGoldFor(s) {
2783
+ if (!s || !s.ref) return null;
2784
+ var map = readPending();
2785
+ var e = map[s.ref];
2786
+ if (!e) return null;
2787
+ // **「値が一致したら捨てる」だけでは足りない。** 別の人が直後に逆を押して、
2788
+ // その結果で追いついた場合、値が一致しないので控えが残り、TTL切れまで
2789
+ // 実際とは違う値を出し続ける。**押した時点より新しいスナップショットが来たら、
2790
+ // 値が何であれ控えは用済み**——サーバの答えのほうが新しいので、そちらを信じる。
2791
+ var snapshot = fleetVal("generatedAt");
2792
+ var snapAt = snapshot ? Date.parse(snapshot) : NaN;
2793
+ var stale = !isNaN(snapAt) && snapAt >= (e.at || 0);
2794
+ if (s.gold === e.value || stale) {
2795
+ delete map[s.ref];
2796
+ writePending(map);
2797
+ return null;
2798
+ }
2799
+ return e;
2800
+ }
2801
+
2284
2802
  function extSourceCard(s, meta) {
2285
2803
  var badges = el("div", { class: "ds-badges" });
2804
+ var pending = pendingGoldFor(s);
2286
2805
  // 「意図的に外した」と「宣言し忘れ」を分けて出す。同じ「除外」と書くと、
2287
2806
  // 判断されていないだけのものが「判断済み」に見えてしまう。
2288
- if (s.gold === true) badges.appendChild(el("span", { class: "badge ds-gold", text: "Gold昇格の読み取り対象" }));
2807
+ if (pending) {
2808
+ var spec = pendingBadgeSpec(pending.value, false);
2809
+ badges.appendChild(el("span", { class: spec.cls, text: spec.text }));
2810
+ }
2811
+ else if (s.gold === true) badges.appendChild(el("span", { class: "badge ds-gold", text: "Gold昇格の読み取り対象" }));
2289
2812
  else if (s.goldState === "undeclared") badges.appendChild(el("span", { class: "badge ds-undeclared", text: "未宣言(対象外)" }));
2290
2813
  else if (s.gold === false) badges.appendChild(el("span", { class: "badge ds-excluded", text: "除外" }));
2291
2814
  if (s.notify === true) badges.appendChild(el("span", { class: "badge ds-notify", text: "通知先" }));
@@ -2426,6 +2949,9 @@ const CLIENT_JS = `
2426
2949
  */
2427
2950
  function refreshDataSources() {
2428
2951
  if (listState.tab !== "ops") return;
2952
+ // **モーダルが開いている間は差し替えない。** 下地を入れ替えると、開いているモーダルの
2953
+ // 中身が切り離されたノードになる(閉じるときに refreshDataSources を呼び直す)
2954
+ if (isModalOpen()) return;
2429
2955
  var old = app.querySelector(".datasources");
2430
2956
  // **触っている最中は差し替えない。** この節の中には追加フォームの入力欄があり、
2431
2957
  // 貼りかけのURLや「保存中…」の表示ごと作り直してしまう。保存が成功していても
@@ -2443,27 +2969,8 @@ const CLIENT_JS = `
2443
2969
  else app.insertBefore(next, app.firstChild);
2444
2970
  }
2445
2971
  updateConnMapAsOf();
2446
- updateSecretStates();
2447
2972
  }
2448
2973
 
2449
- /**
2450
- * 鍵カードの「現在: ◯◯ から取得中」だけを更新する。
2451
- * **カードごと作り直さない**(貼りかけのトークンが消える)。表示は1行なので差し替えで足りる。
2452
- */
2453
- function updateSecretStates() {
2454
- var sources = fleetVal("secretSources") || {};
2455
- var cards = app.querySelectorAll("[data-secret-kind]");
2456
- for (var i = 0; i < cards.length; i++) {
2457
- var kind = cards[i].getAttribute("data-secret-kind");
2458
- var line = cards[i].querySelector(".sec-state");
2459
- if (!line) continue;
2460
- var src = sources[kind] || "";
2461
- line.className = src
2462
- ? "sec-state" + (src === "Secrets Manager" ? " is-managed" : "")
2463
- : "sec-state is-none";
2464
- line.textContent = src ? "現在: " + src + " から取得中" : "現在: 未設定";
2465
- }
2466
- }
2467
2974
 
2468
2975
  /**
2469
2976
  * 取得中フラグ。**完了しても寝かせたままにしない。**
@@ -2509,15 +3016,25 @@ const CLIENT_JS = `
2509
3016
  renderConnMap();
2510
3017
  renderDataSources();
2511
3018
  loadLiveFleet();
2512
- if (SITE.intake) {
2513
- app.appendChild(el("p", { class: "ops-intro", text: "Cortex にコンテキストを取り込む操作です。ファイルを選ぶかテキストを貼って送信すると、自動で取り込まれます(数分で反映)。" }));
2514
- app.appendChild(el("div", { class: "section-label", text: "取り込み" }));
2515
- var grid = el("div", { class: "ops-grid" });
2516
- grid.appendChild(opsCard("🗒️", "会議の文字起こしを追加", "顧客主催の会議など、自動取り込みできない会議の文字起こしファイル(.txt / .docx 等)から議事録を生成します。", "transcript", "文字起こしを追加"));
2517
- grid.appendChild(opsCard("📎", "共有資料を追加", "提案書・仕様書・議事メモなどの資料を Markdown 化して 共有資料 に取り込みます。", "material", "資料を追加"));
2518
- app.appendChild(grid);
2519
- renderSecrets();
3019
+ // **取り込みカードと鍵カードは能力セクションへ移した**(#101)。
3020
+ // ここで再度出すと二重になる。手動投入は 会議 / 共有資料 の中にある。
3021
+ }
3022
+
3023
+ /**
3024
+ * 手動投入のカード(文字起こし・資料)。**その能力の中に置く。**
3025
+ * 「顧客主催の会議など、自動取り込みできない会議」に使うものなので、
3026
+ * 会議の設定を見ている文脈で押せるのが自然。以前は画面下部の別セクションにあり、
3027
+ * 会議の設定と往復する必要があった。
3028
+ */
3029
+ function intakeCardFor(cap) {
3030
+ if (!SITE.intake) return null;
3031
+ if (cap === "会議") {
3032
+ return opsCard("🗒️", "会議の文字起こしを追加", "顧客主催の会議など、自動取り込みできない会議の文字起こしファイル(.txt / .docx 等)から議事録を生成します。ファイルを選ぶかテキストを貼って送信すると、数分で反映されます。", "transcript", "文字起こしを追加");
3033
+ }
3034
+ if (cap === "共有資料") {
3035
+ return opsCard("📎", "共有資料を追加", "提案書・仕様書・議事メモなどの資料を Markdown 化して 共有資料 に取り込みます。ファイルを選ぶかテキストを貼って送信すると、数分で反映されます。", "material", "資料を追加");
2520
3036
  }
3037
+ return null;
2521
3038
  }
2522
3039
 
2523
3040
  /** 鍵の入力欄に出す説明。kind は Lambda 側(resolveSecretTarget)と綴りを合わせること */
@@ -2562,18 +3079,10 @@ const CLIENT_JS = `
2562
3079
  * **入れた値は読み出せない。** 表示・確認の経路は作っていないので、間違えたら入れ直す。
2563
3080
  * 保存先は案件ごとに分かれており、他の案件には影響しない。
2564
3081
  * 反映は次回のワークフロー実行から(接続状況の表示が変わるのは翌朝の自己チェック後)。
3082
+ *
3083
+ * カードは #101 で能力モーダルの中へ移した(buildDataSources の pushCap 経由)。
3084
+ * まとめて並べる renderSecrets() は呼び出し元が無くなったので消してある。
2565
3085
  */
2566
- function renderSecrets() {
2567
- var kinds = (SITE.secretKinds || []).filter(function (k) { return SECRET_FORMS[k]; });
2568
- if (!kinds.length) return;
2569
-
2570
- app.appendChild(el("div", { class: "section-label", text: "連携の鍵" }));
2571
- app.appendChild(el("p", { class: "ops-intro", text: "各ツールを読むためのトークンです。入れた値はここには表示されません(保存だけできます)。保存先は案件ごとに分かれているので、他の案件には影響しません。" }));
2572
- var grid = el("div", { class: "ops-grid" });
2573
- kinds.forEach(function (k) { grid.appendChild(secretCard(k, SECRET_FORMS[k])); });
2574
- app.appendChild(grid);
2575
- }
2576
-
2577
3086
  function secretCard(kind, meta) {
2578
3087
  // **いま何で動いているか**を先に出す。入れ直したのに反映されていない状態が
2579
3088
  // 画面で分かるようにする(接続状況は夜間バッチのスナップショットなので、
@@ -2642,6 +3151,11 @@ const CLIENT_JS = `
2642
3151
  el("p", { class: "sec-how", text: meta.how }),
2643
3152
  meta.selfServe ? el("p", { class: "sec-how", text: meta.selfServe.note }) : null,
2644
3153
  input,
3154
+ // **扱いの説明はカードに持たせる。** 節の見出しの下に置いていたときは、
3155
+ // #101 でカードだけがモーダルへ移り、説明だけが画面から消えた。
3156
+ // 顧客にトークンの貼り付けを求める画面で、貼った値がどう扱われるかの
3157
+ // 説明が落ちるのは、いちばん落としてはいけない種類の欠落
3158
+ el("p", { class: "sec-note", text: "入れた値はここには表示されません(保存だけできます)。保存先は案件ごとに分かれているので、他の案件には影響しません。" }),
2645
3159
  el("div", { class: "ds-toggle" }, [btn, msg]),
2646
3160
  ]);
2647
3161
  }
@@ -2706,13 +3220,115 @@ const CLIENT_JS = `
2706
3220
  control,
2707
3221
  ]);
2708
3222
  }
3223
+ /**
3224
+ * モーダルの土台。**能力モーダルと投入モーダルで共有する。**
3225
+ *
3226
+ * **app の中に置く(body 直下にしない)。** isModalOpen() が
3227
+ * app.querySelector(".modal-overlay") で見ているので、外に出すと
3228
+ * 「開いている」と判定されず、ライブ差し替えが .datasources を作り直す。
3229
+ * モーダルの中身は差し替え前の古いノードのまま取り残され、トグルを押しても
3230
+ * 結果は切り離されたノードに書かれる——「押したのに何も起きない」に見える。
3231
+ * position:fixed なので app 内でも前面に出る(#app に transform/filter/contain は無い)。
3232
+ *
3233
+ * @returns { overlay, modal, close }
3234
+ */
3235
+ var modalSeq = 0;
3236
+ function makeModal(titleText, wide) {
3237
+ // **二重オープンを防ぐ。** タイルを続けて押すとオーバーレイが重なり、
3238
+ // 1枚閉じても下の1枚が残って操作を受け付けない状態になる。
3239
+ // **前のものは close() で畳む。** DOM から外すだけだと keydown が
3240
+ // document に残り続け、押すたびに1本ずつ増える
3241
+ // **押し替えのときは差し替えを走らせない。** close() は「見送っていたライブ値を
3242
+ // 取り込む」ために .datasources を作り直すが、いま押されたタイルは**古い節の
3243
+ // ノード**で、掴んでいる一覧も古い。作り直すと、下のタイルは「2リポジトリ」・
3244
+ // 上のモーダルは「1リポジトリ」という食い違いになる。
3245
+ // 取り込みは**最後の1枚を閉じたとき**に行えば足りる
3246
+ var existing = app.querySelector(".modal-overlay");
3247
+ if (existing) {
3248
+ if (existing.__close) existing.__close(true);
3249
+ else if (existing.parentNode) existing.parentNode.removeChild(existing);
3250
+ }
3251
+
3252
+ // 開く前にフォーカスがどこにあったかを覚え、閉じたら戻す。
3253
+ // 戻さないと、キーボードだけで操作している人は一覧の先頭へ飛ばされる
3254
+ var returnTo = document.activeElement;
3255
+ var overlay = el("div", { class: "modal-overlay" });
3256
+ var modal = el("div", { class: wide ? "modal modal-wide" : "modal", role: "dialog", "aria-modal": "true" });
3257
+ // **dialog に名前を付ける。** 無いとスクリーンリーダは「ダイアログ」としか
3258
+ // 読まず、どの能力を開いたのかが分からない。直下に見出しがあるのに
3259
+ // 参照していなかった
3260
+ if (titleText) {
3261
+ modalSeq += 1;
3262
+ var titleId = "modal-title-" + modalSeq;
3263
+ // **見出し行に✕を置く。** 「閉じる」は末尾にしかなく、19リポジトリのモーダルでは
3264
+ // マウスだけの人が全部スクロールしないと届かない(キーボードはフォーカスが
3265
+ // 閉じるに載るので届く)
3266
+ var x = el("button", { class: "modal-x", type: "button", "aria-label": "閉じる", text: "✕" });
3267
+ x.addEventListener("click", function () { close(); });
3268
+ modal.appendChild(el("div", { class: "modal-head" }, [
3269
+ el("h2", { class: "modal-title", id: titleId, text: titleText }),
3270
+ x,
3271
+ ]));
3272
+ modal.setAttribute("aria-labelledby", titleId);
3273
+ } else {
3274
+ modal.setAttribute("aria-label", "設定");
3275
+ }
3276
+ overlay.__close = close;
3277
+
3278
+ function close(skipRefresh) {
3279
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
3280
+ var behind = app.querySelector(".datasources");
3281
+ if (behind) { behind.removeAttribute("inert"); behind.removeAttribute("aria-hidden"); }
3282
+ document.removeEventListener("keydown", onKey);
3283
+ if (returnTo && typeof returnTo.focus === "function" && returnTo.isConnected !== false) returnTo.focus();
3284
+ // **閉じたら差し替えを試し直す。** 開いている間はライブ値の反映を見送るが、
3285
+ // 見送ったままだと再取得の口が無く、タブを離れて戻るまで古い値のままになる。
3286
+ if (skipRefresh !== true && typeof refreshDataSources === "function") refreshDataSources();
3287
+ }
3288
+ function onKey(e) { if (e.key === "Escape") close(); }
3289
+ document.addEventListener("keydown", onKey);
3290
+ overlay.addEventListener("click", function (e) { if (e.target === overlay) close(); });
3291
+ return { overlay: overlay, modal: modal, close: close };
3292
+ }
3293
+
3294
+ /**
3295
+ * モーダルを app に載せて、中へフォーカスを移す。
3296
+ *
3297
+ * **「最初の focusable」に載せてはいけない。** 一覧の先頭に来るのは
3298
+ * 「Gold昇格を止める」「読み取りを止める」「このファイルを外す」といった
3299
+ * 設定を書き換えるボタンで、しかも**狭める向きは意図的に確認を出さない**
3300
+ * (stateToggle の confirmOn は広げる向きだけ)。タイルを Enter で開いて
3301
+ * もう一度 Enter を押しただけで、確認なしに設定ファイルが書き換わる。
3302
+ * 移す先は閉じるボタン——押しても何も壊れないものにする。
3303
+ */
3304
+ function showModal(overlay, modal) {
3305
+ // **下地を触れなくする。** 無いと Tab がモーダルの外へ抜け、読み上げも
3306
+ // 背後の一覧を読み続ける(モーダルを開いた意味が無くなる)
3307
+ var behind = app.querySelector(".datasources");
3308
+ if (behind) { behind.setAttribute("inert", ""); behind.setAttribute("aria-hidden", "true"); }
3309
+ app.appendChild(overlay);
3310
+ var safe = modal.querySelector(".modal-actions button, .modal-cancel");
3311
+ if (!safe) {
3312
+ // 閉じるボタンが無い形のモーダル用。見出しに載せて読み上げの起点にする
3313
+ modal.setAttribute("tabindex", "-1");
3314
+ safe = modal;
3315
+ }
3316
+ if (typeof safe.focus === "function") safe.focus();
3317
+ }
3318
+ /** モーダルが開いているか(ライブ差し替えの見送り判定に使う) */
3319
+ function isModalOpen() { return !!app.querySelector(".modal-overlay"); }
3320
+
2709
3321
  function openIntakeModal(kind) {
2710
3322
  var intake = SITE.intake;
2711
3323
  if (!intake) return;
2712
3324
  var isT = kind === "transcript";
2713
- var overlay = el("div", { class: "modal-overlay" });
2714
- var modal = el("div", { class: "modal" });
2715
- modal.appendChild(el("h2", { class: "modal-title", text: isT ? "会議の文字起こしを追加" : "共有資料を追加" }));
3325
+ // **土台は makeModal で作る。** 以前は独自に overlay を組んで body 直下へ
3326
+ // 置いていたため、能力モーダルの上に2枚目が重なり、Escape が下の1枚だけを
3327
+ // 閉じて上が残った。isModalOpen app しか見ないので、開いている間の
3328
+ // ライブ差し替えの見送りも効いていなかった
3329
+ var mk = makeModal(isT ? "会議の文字起こしを追加" : "共有資料を追加", false);
3330
+ var overlay = mk.overlay;
3331
+ var modal = mk.modal;
2716
3332
  modal.appendChild(el("p", { class: "modal-desc", text: isT
2717
3333
  ? "文字起こしファイル(テキスト・Word・PDF・字幕 等)を選んで送信してください。(送信後、数分で議事録が自動生成されます)"
2718
3334
  : "資料ファイル(PDF・Word・PowerPoint・Excel・テキスト・画像 等)を選んで送信してください。(送信後、数分で内容が Markdown に変換されて取り込まれます)" }));
@@ -2740,7 +3356,7 @@ const CLIENT_JS = `
2740
3356
  modal.appendChild(status);
2741
3357
  modal.appendChild(el("div", { class: "modal-actions" }, [cancelBtn, submitBtn]));
2742
3358
  overlay.appendChild(modal);
2743
- document.body.appendChild(overlay);
3359
+ showModal(overlay, modal);
2744
3360
 
2745
3361
  function showStatus(msg, isError) { status.className = "modal-status" + (isError ? " modal-status-error" : ""); status.textContent = msg; }
2746
3362
  function update() { submitBtn.disabled = !(nameInput.value.trim() && selectedFiles.length); }
@@ -2776,9 +3392,8 @@ const CLIENT_JS = `
2776
3392
  dropzone.addEventListener("drop", function (e) { e.preventDefault(); dropzone.classList.remove("dragover"); addFiles(e.dataTransfer.files); });
2777
3393
  nameInput.addEventListener("input", update);
2778
3394
 
2779
- function close() { if (overlay.parentNode) document.body.removeChild(overlay); }
2780
- cancelBtn.addEventListener("click", close);
2781
- overlay.addEventListener("click", function (e) { if (e.target === overlay) close(); });
3395
+ // 閉じる経路(Escape・背景クリック)は makeModal 側に用意されている
3396
+ cancelBtn.addEventListener("click", mk.close);
2782
3397
  submitBtn.addEventListener("click", function () { submitIntake(kind, selectedFiles, nameInput.value, status, submitBtn); });
2783
3398
  }
2784
3399
  async function submitIntake(kind, files, submitter, status, submitBtn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.58",
3
+ "version": "0.4.60",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,