@takagaki/cortex-decisions-viewer 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/render.js +611 -184
  2. package/package.json +1 -1
package/dist/render.js CHANGED
@@ -26,7 +26,8 @@ exports.sanitizeCapabilitiesNow = sanitizeCapabilitiesNow;
26
26
  exports.sanitizeGithubOwnersNow = sanitizeGithubOwnersNow;
27
27
  exports.splitNeededParts = splitNeededParts;
28
28
  exports.ingestedAtOf = ingestedAtOf;
29
- exports.usageFactMissing = usageFactMissing;
29
+ exports.keyMissingText = keyMissingText;
30
+ exports.deriveCapTile = deriveCapTile;
30
31
  exports.renderConnectionMap = renderConnectionMap;
31
32
  /** SiteDataを単一の自己完結HTML(埋め込みデータ+CSS+JS)にレンダリングする */
32
33
  function renderSite(site) {
@@ -516,9 +517,14 @@ const REGISTERED_CAP_NAMES = {
516
517
  materials: "共有資料",
517
518
  issues: "課題管理",
518
519
  };
519
- /** 通してよい欄。**ここに無い欄は1つも通さない**(Lambda 側の列挙と同じ形) */
520
+ /**
521
+ * 通してよい欄。**ここに無い欄は1つも通さない**(Lambda 側の列挙と同じ形)
522
+ *
523
+ * 🔴 **`orgUnknown` は真偽値**(他の欄は全部文字列)。**この1欄だけの例外**で、
524
+ * 篩の側にも `=== true` の分岐がある——`sanitizeRegisteredNow` を参照
525
+ */
520
526
  const REGISTERED_FIELDS = {
521
- chat: ["ref", "name", "workspace"],
527
+ chat: ["ref", "name", "workspace", "orgUnknown"],
522
528
  dev: ["ref", "type"],
523
529
  design: ["key", "name"],
524
530
  materials: ["id", "name"],
@@ -558,6 +564,17 @@ function sanitizeRegisteredNow(v) {
558
564
  const item = {};
559
565
  for (const f of REGISTERED_FIELDS[cap]) {
560
566
  const fv = r[f];
567
+ // 🔴 **真偽値を通すのは orgUnknown だけ**(2026-08-25)。葉を文字列に限った篩が
568
+ // この印を落としており、**追加した直後のチャンネルから「Slack ワークスペースに
569
+ // 接続する」の導線が消えていた**(顧客Slackを繋ぐ唯一の入口)。
570
+ // **`=== true` に限る。** 文字列の "true" や 1 を真と読むと、意図しない値が
571
+ // 「組織不明」の案内を呼び、直すものが無い人に接続を勧めることになる。
572
+ // 例外はこの1欄だけ——他の欄まで真偽値を通すと、許可リストが篩の意味を失う
573
+ if (f === "orgUnknown") {
574
+ if (fv === true)
575
+ item[f] = true;
576
+ continue;
577
+ }
561
578
  if (typeof fv !== "string" || fv === "")
562
579
  continue; // 葉は文字列だけ
563
580
  item[f] = fv;
@@ -667,6 +684,10 @@ function overlayStateOnFleet(view, rows, generatedAt) {
667
684
  * 🔴 **鍵はチャンネルID+ワークスペース。** チャンネルIDはワークスペースの中でしか
668
685
  * 一意でないので、IDだけで突き合わせると別ワークスペースの同IDを同じものと見て
669
686
  * 行が出ない(#104 で同じ理由の事故がある)。
687
+ *
688
+ * 🔴 **`orgUnknown` の印をここで落とさない(2026-08-25)。** 落とすと、この関数が作る行
689
+ * (=追加した直後の主役)が `groupSlackByOrg` で社内の組に落ち、
690
+ * **「Slack ワークスペースに接続する」が画面から消える**——顧客Slackを繋ぐ唯一の入口。
670
691
  */
671
692
  function chatRowsToAdd(externals, rows) {
672
693
  const have = {};
@@ -684,6 +705,8 @@ function chatRowsToAdd(externals, rows) {
684
705
  const row = { type: "slack", name: r.name || r.ref, ref: r.ref };
685
706
  if (r.workspace)
686
707
  row.workspace = r.workspace;
708
+ if (r.orgUnknown === true)
709
+ row.orgUnknown = true;
687
710
  out.push(row);
688
711
  }
689
712
  return out;
@@ -1182,10 +1205,11 @@ const CAPABILITY_NOW_STATES = ["on", "off", "empty", "broken", "unset"];
1182
1205
  * `capabilitiesNow`(能力が配線されているか)を、**まとめ表の `capabilities` と同じ形**
1183
1206
  * (能力名=日本語のキー)に畳む。読めた能力だけを返し、1つも無ければ null。
1184
1207
  *
1185
- * ⚠ **応答は `caps` で要求した能力ぶんしか来ない**(画面が開いた能力だけを読む・
1186
- * `OPEN_CAPS` の項)。だから**キー単位でまとめ表へ落ちる**——ここだけは
1187
- * `rawFleetVal` の「キーごとにフォールバックしない」の例外で、そうしないと
1188
- * 開いていない能力のタイルが全部「未接続」に落ちる。
1208
+ * ⚠ **応答は `caps` で要求した能力ぶんしか来ない。** 画面は 0.12.0 から常に全量を
1209
+ * 要求する(`ALL_STATE_CAPS` の項)ので普段は6能力が揃うが、**古い Lambda は一部しか
1210
+ * 返さない**。だから**キー単位でまとめ表へ落ちる**——ここだけは `rawFleetVal`
1211
+ * 「キーごとにフォールバックしない」の例外で、そうしないと返らなかった能力の
1212
+ * タイルが「未接続」に落ちる。
1189
1213
  */
1190
1214
  function sanitizeCapabilitiesNow(v) {
1191
1215
  if (!v || typeof v !== "object" || Array.isArray(v))
@@ -1293,45 +1317,140 @@ function ingestedAtOf(rows, capId) {
1293
1317
  return best;
1294
1318
  }
1295
1319
  /**
1296
- * 「取り込みの事実」でタイルと線を決める能力(画面の能力名 ストアの能力名)。
1320
+ * タイルと接続グラフの導出に要る、能力ごとの材料(0.12.0・#231 / #235 / #227)。
1321
+ *
1322
+ * - `id` … 接続状態ストアの能力名(`STATE_CAP_NAMES` と同じ綴り)
1323
+ * - `question` … その能力が「実際に動いた」と言える問い。**取り込む能力は `ingested`、
1324
+ * 毎晩の Gold 昇格がその場で読む能力(チャット・開発)は `reachable`。**
1325
+ * 取り込みの行が存在しない能力に `ingested` を割り当てると、**永久に「実績なし」**になる
1326
+ * - `noFact` … 規則4(登録はあるが実績が無い)の文言。**能力ごとに待っているものが違う**
1327
+ * ので1文に丸めない。会議・共有資料は中央 Apps Script の取り込み待ち(=まだ使っていない)、
1328
+ * 課題管理・デザインは夜間同期待ち、チャット・開発は実測待ち
1297
1329
  *
1298
- * **この2つだけ。** 会議と共有資料は中央 Apps Script が取り込むので、設定が済んでいても
1299
- * (capabilities が `on` でも)**一度も取り込まれていない**ことが普通に起きる。他の4能力は
1300
- * 判定の材料が違う(夜間の reachable・パイプラインの成否)ので、ここに足さないこと——
1301
- * 足した瞬間、実績の無い案件が一斉に「未使用」になる。
1330
+ * **6能力すべてを載せること。** ここに無い能力は導出そのものが働かず、規則5
1331
+ * (fleet へのフォールバック)に落ちる=正本化の対象から静かに外れる。
1302
1332
  */
1303
- const FACT_CAPS = { "会議": "meetings", "共有資料": "materials" };
1333
+ const TILE_CAPS = {
1334
+ "課題管理": { id: "issues", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "APIキー" },
1335
+ "会議": { id: "meetings", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1336
+ "共有資料": { id: "materials", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1337
+ "デザイン": { id: "design", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "トークン" },
1338
+ "チャット": { id: "chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1339
+ "開発": { id: "dev", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1340
+ };
1304
1341
  /**
1305
- * その能力が「設定は済んでいるのに、まだ一度も取り込めていない」か(#235 の表示層)。
1342
+ * 「鍵が無くて飛ばした」の一文(#225)。**能力ごとに鍵の呼び名が違う。**
1343
+ *
1344
+ * 画面の鍵カードは「Backlog のAPIキー」「Figma のトークン」と名乗っている。
1345
+ * ここで別の語を使うと、**利用者は言われたものを画面から探せない**。
1346
+ * 鍵を持たない能力(会議・共有資料・チャット・開発)では skipped は本来起きないが、
1347
+ * 起きたときに「鍵」の話をしないよう、中立の一文に倒す。
1348
+ */
1349
+ function keyMissingText(cap) {
1350
+ var meta = TILE_CAPS[cap];
1351
+ var label = meta ? meta.keyLabel : "";
1352
+ return label ? label + "が未登録です" : "取り込みを飛ばしました";
1353
+ }
1354
+ /**
1355
+ * 実測が「悪い」と言える語。**`unknown` は入れない**——測れなかっただけで、
1356
+ * 悪いとは言えない(入れると、一度も測れていない案件が全部⚠️になる)。
1357
+ */
1358
+ const BAD_STATE_STATUSES = {
1359
+ ingested: ["failed", "skipped"],
1360
+ reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable"],
1361
+ };
1362
+ /**
1363
+ * タイル1行(と接続グラフの1ノード)の状態を、**正本から導出する**(0.12.0)。
1364
+ *
1365
+ * ## 規則は上から順の優先判定(並列判定に書き換えないこと)
1366
+ *
1367
+ * 1. **登録0件** → 「未接続」(中立・点線・線なし)
1368
+ * 2. **登録あり × 実測が悪い** → 警告(⚠+短い一言)
1369
+ * 3. **登録あり × 実績が ok** → 「接続OK」
1370
+ * 4. **登録あり × 実績なし** → 中立(能力ごとの文言)
1371
+ * 5. **`stateNow` の欄が無い**(古い Lambda・ストアが読めなかった)→ `null`
1372
+ * =呼び出し側は従来の fleet 表示へフォールバック
1306
1373
  *
1307
- * 🔴 **`seen`(応答に `stateNow` の欄があったか)と `rows`(その中身)は別物。**
1308
- * サーバは**読めたなら0件でも欄を付ける**ので、この2つで3つの状態が区別できる:
1374
+ * 🔴 **規則1が最優先であることが、孤児行に対する唯一の守り。** 接続先を外しても
1375
+ * ストアの行は残る(TTL が無い・既知の問題)。並列判定に書き換えると、
1376
+ * **登録を全部外した能力が、残った孤児行のせいで「接続OK」や⚠️を出し続ける**
1377
+ * ——#227 で見た「実際は0件なのに接続OK」がそのまま復活する。
1309
1378
  *
1310
- * - `seen === false` … 分からない(古い Lambda・ストアの読み取りに失敗)→ **偽**。
1311
- * 従来表示のままにする。分からないことを「未使用」と言い切らない
1312
- * - `seen === true` かつ行が0件 … 読めて空=**まだ一度も取り込めていない**→ 真。
1313
- * 初期化直後の案件がここに来る(この機能が一番効いてほしい案件)
1314
- * - `seen === true` かつ行がある … その能力の取り込み実績で決める
1379
+ * 🔴 **規則5より規則1を先に見るのも意図的。** 登録が0件であることは `registeredNow`
1380
+ * だけで言い切れる事実で、ストアが読めたかどうかとは無関係。ここを逆にすると、
1381
+ * ストアが読めない案件でだけ「0件なのに接続OK」が残る。
1315
1382
  *
1316
- * **`rows` の空と `seen` を混ぜないこと。** `sanitizeStateNow` は0件を `null` に畳むので、
1317
- * `rows` だけを見ると「読めて空」が「分からない」に化け、**まさに未使用の案件でだけ
1318
- * 機能しない**(サーバ側を直した意味が消える)。
1383
+ * ## 設定そのものが OFF / 異常のときは、ここへ来ない
1319
1384
  *
1320
- * **成功の事実は `ingestedAtOf` に一本化する。** あちらは `lastOkAt` も見るので、
1321
- * 「一度取り込めた後に失敗した」能力を未使用に落とさない。判定を写経すると、そこがずれる。
1385
+ * 「OFFに設定されています」「設定に異常があります」「未接続(未設置)」は
1386
+ * `capabilities` の導出(#125②)が答えるもので、**呼び出し側がこの関数より先に**出す。
1387
+ * ここが受け持つのは「設定は済んでいる(on)」か「そもそも導出が無い」場合だけ。
1388
+ * 混ぜると、意図的に止めた能力に「登録済み(接続は未確認です)」と出す。
1389
+ *
1390
+ * @param cap 画面の能力名(日本語)
1391
+ * @param ctx.registered その能力の登録件数。**分からなければ null**(0 と混ぜない——
1392
+ * 取得前の null を 0 として扱うと、読み込み中の一瞬だけ全能力が「未接続」になる)
1393
+ * @param ctx.seen 応答に `stateNow` の欄があったか。**`rows` の空とは別物**——
1394
+ * `sanitizeStateNow` は0件を `null` に畳むので、`rows` だけを見ると
1395
+ * 「読めて空(=まだ何も起きていない事実)」が「分からない」に化ける
1396
+ * @param ctx.rows `stateNow` の行
1397
+ * @returns 導出結果。`null` なら規則5(従来表示へフォールバック)
1322
1398
  *
1323
1399
  * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
1324
- * `FACT_CAPS` は CLIENT_JS 側にも `var` で流し込んである)。
1400
+ * `TILE_CAPS` / `BAD_STATE_STATUSES` は CLIENT_JS 側にも `var` で流し込んである)。
1325
1401
  */
1326
- function usageFactMissing(cap, rows, seen) {
1327
- var capId = FACT_CAPS[cap];
1328
- if (!capId)
1329
- return false;
1330
- if (!seen)
1331
- return false;
1332
- if (!Array.isArray(rows) || !rows.length)
1333
- return true;
1334
- return ingestedAtOf(rows, capId) === "";
1402
+ function deriveCapTile(cap, ctx) {
1403
+ var meta = TILE_CAPS[cap];
1404
+ if (!meta)
1405
+ return null;
1406
+ var registered = ctx ? ctx.registered : null;
1407
+ // ---- 規則1: 登録0件 → 未接続 ----
1408
+ if (registered === 0)
1409
+ return { rule: 1, state: "idle", text: "未接続", keyMissing: false };
1410
+ // ---- 規則5: 材料が届いていない → 従来表示へ ----
1411
+ if (!ctx || !ctx.seen)
1412
+ return null;
1413
+ var rows = Array.isArray(ctx.rows) ? ctx.rows : [];
1414
+ var bad = BAD_STATE_STATUSES[meta.question] || [];
1415
+ var failed = 0;
1416
+ var skipped = 0;
1417
+ var unreachable = 0;
1418
+ var okSeen = false;
1419
+ for (var i = 0; i < rows.length; i++) {
1420
+ var r = rows[i];
1421
+ if (!r || r.cap !== meta.id || r.question !== meta.question)
1422
+ continue;
1423
+ // **一度でも成功していれば実績あり。** `lastOkAt` を見るのは、いま失敗している行でも
1424
+ // 「繋がったことがある」のは事実だから(規則2が先に立つので表示は⚠️のまま)
1425
+ if (r.status === "ok" || r.lastOkAt)
1426
+ okSeen = true;
1427
+ if (bad.indexOf(r.status) < 0)
1428
+ continue;
1429
+ if (r.status === "failed")
1430
+ failed++;
1431
+ else if (r.status === "skipped")
1432
+ skipped++;
1433
+ else
1434
+ unreachable++;
1435
+ }
1436
+ // ---- 規則2: 実測が悪い → 警告 ----
1437
+ //
1438
+ // **失敗を先に言う。** 「鍵が無くて飛ばした」より「鍵はあるのに取れなかった」ほうが
1439
+ // 直す先が違ううえに重い(#225 が分けたかったのはまさにこの2つ)
1440
+ if (failed)
1441
+ return { rule: 2, state: "warn", text: "同期が失敗しています", keyMissing: false };
1442
+ // 🔴 **skipped は「失敗」と言わない(#225)。** 鍵が無いのは取り込みの失敗ではなく
1443
+ // 設定が済んでいないだけで、利用者が取るべき行動が違う(鍵を入れる/原因を調べる)
1444
+ if (skipped)
1445
+ return { rule: 2, state: "warn", text: keyMissingText(cap), keyMissing: !!meta.keyLabel };
1446
+ if (unreachable) {
1447
+ return { rule: 2, state: "warn", text: unreachable + "件が読めていません", keyMissing: false };
1448
+ }
1449
+ // ---- 規則3: 実績あり → 接続OK ----
1450
+ if (okSeen)
1451
+ return { rule: 3, state: "ok", text: "接続OK", keyMissing: false };
1452
+ // ---- 規則4: 実績なし → 中立 ----
1453
+ return { rule: 4, state: "idle", text: meta.noFact, keyMissing: false };
1335
1454
  }
1336
1455
  function capabilityState(cap, ctx) {
1337
1456
  // **能力→パイプラインの対応はここに持つ。** 呼び出し側に渡させると、
@@ -1555,12 +1674,16 @@ function renderConnectionMap(site) {
1555
1674
  else if (internal?.lastSync) {
1556
1675
  statusTs = internal.lastSync;
1557
1676
  }
1558
- // 時刻はクライアントでローカルタイム整形するため data-ts で渡す
1559
- const sub = statusText || statusTs
1560
- ? `<text class="cm-sync" x="${cx}" y="67" text-anchor="middle"` +
1561
- (statusTs ? ` data-ts="${escapeHtml(statusTs)}"` : "") +
1562
- `>${escapeHtml(statusText)}</text>`
1563
- : "";
1677
+ // 時刻はクライアントでローカルタイム整形するため data-ts で渡す。
1678
+ //
1679
+ // 🔴 **3行目は中身が空でも必ず置く(0.12.0)。** ライブ応答が届くたびに全能力の
1680
+ // 状態を書き直す(`applyStateToConnMap`)ので、**書く場所が無いノードがあると
1681
+ // そこだけ更新できない**。以前は無ければ実行時に createElementNS で作っていたが、
1682
+ // 「ビルド時に組み立てる形」と「実行時に組み立てる形」が2つに分かれ、片方だけ
1683
+ // 直る形になっていた(座標の与え方が実際にずれていた)。
1684
+ const sub = `<text class="cm-sync" x="${cx}" y="67" text-anchor="middle"` +
1685
+ (statusTs ? ` data-ts="${escapeHtml(statusTs)}"` : "") +
1686
+ `>${escapeHtml(statusText)}</text>`;
1564
1687
  const badge = warn ? `<text class="cm-badge" x="${cx + 62}" y="24" text-anchor="middle">⚠️</text>` : "";
1565
1688
  // クリックでページ内の該当詳細カード(アンカー)へスクロールする
1566
1689
  const anchor = `ds-${c.cap}`;
@@ -1579,19 +1702,31 @@ function renderConnectionMap(site) {
1579
1702
  // cm-warn は引く。**壊れているのは「繋がったうえで失敗している」状態**で、
1580
1703
  // 経路自体は存在する(線を消すと、設定が無いのと区別が付かなくなる)。
1581
1704
  //
1582
- // **`data-cm-cap` は線を後から取り消すための取っ手(#235 の表示層)。** この図は
1583
- // ビルド時に焼くので、**取り込みの事実(stateNow)はここでは分からない**——分かるのは
1584
- // ライブ応答が届いた後だけ。届いてから会議・共有資料の線を外せるように、線に
1585
- // 能力名を持たせておく(外す側は applyUsageFactToConnMap)。
1586
- // 位置で数えさせないこと——線は ok/warn の能力ぶんしか積まれないので、
1587
- // 並び順は案件ごとに変わる。
1705
+ // 🔴 **線は6本とも必ず焼く。出す/出さないはクラスで切り替える(0.12.0・#231)。**
1706
+ //
1707
+ // この図はビルド時に焼くので、**登録の現在値も実測(stateNow)もここでは分からない**
1708
+ // ——分かるのはライブ応答が届いた後だけ。届いてから毎回、全能力の状態を導出し直して
1709
+ // 当てる(`applyStateToConnMap`)。そのとき **線を作ったり消したりしない**:
1710
+ //
1711
+ // - 消す(removeChild)と、昇格(直したら線が戻る)のときに**ジオメトリを
1712
+ // 実行時にもう一度計算する**ことになる。曲線の制御点の式がビルド時と実行時の
1713
+ // 2箇所に分かれ、片方だけ直す形が生まれる
1714
+ // - 最初から6本置いて表示を切り替えるなら、式はここ1箇所のまま
1715
+ //
1716
+ // **`data-cm-cap` が取っ手。** 位置で数えさせないこと——線は6本とも同じ形で並ぶが、
1717
+ // 「何番目が何の能力か」を数える読み方は、能力が増減した瞬間に静かにずれる。
1588
1718
  //
1589
1719
  // 🔴 **`data-cap` は使わない。** あちらは能力ブロック(`.datasources` の中)が
1590
1720
  // 既に名乗っていて、図はページの**上**にあるので `[data-cap="会議"]` で引くと
1591
1721
  // 図の線のほうが先に当たる。実際、最初にそう書いてテストが12件落ちた。
1592
- if (state === "cm-ok" || state === "cm-warn") {
1593
- links.push(`<path class="cm-link ${state}" data-cm-cap="${escapeHtml(c.cap)}" d="M ${cx} 78 C ${cx} 152, ${REPO_X} 162, ${REPO_X} 238"></path>`);
1594
- }
1722
+ //
1723
+ // **線を出すのは「実際に繋がっていることが証明できたもの」だけ**(従来どおり)。
1724
+ // 未使用(cm-off)・宣言だけで実体が無いもの(cm-idle)からは出さない。
1725
+ // cm-warn は出す——**壊れているのは「繋がったうえで失敗している」状態**で、
1726
+ // 経路自体は存在する(線を消すと、設定が無いのと区別が付かなくなる)。
1727
+ const linkShown = state === "cm-ok" || state === "cm-warn";
1728
+ links.push(`<path class="cm-link ${state}${linkShown ? "" : " is-hidden"}" data-cm-cap="${escapeHtml(c.cap)}"` +
1729
+ ` d="M ${cx} 78 C ${cx} 152, ${REPO_X} 162, ${REPO_X} 238"></path>`);
1595
1730
  });
1596
1731
  // **中央ノードは GitHub のリポジトリ名そのもの(オーナー指示・2026-08-12)。**
1597
1732
  // 以前は「📦 コンテキストリポジトリ(案件の表示名)」だったが、役割名は図の文脈から自明で、
@@ -2256,6 +2391,9 @@ a.ds-name:hover { text-decoration: underline; }
2256
2391
  .cm-link.cm-warn { stroke: #d97706; }
2257
2392
  .cm-link.cm-off { stroke: #d1d5db; opacity: .6; }
2258
2393
  .cm-link.cm-idle { stroke: #cbd5e1; opacity: .7; }
2394
+ /* 線は6本とも焼いて、出す/出さないをここで切り替える(0.12.0・renderConnectionMap の項)。
2395
+ SVG 要素に HTML の hidden 属性は効かないので、クラスで消す */
2396
+ .cm-link.is-hidden { display: none; }
2259
2397
  @keyframes cm-flow { to { stroke-dashoffset: -24; } }
2260
2398
  @media (prefers-reduced-motion: reduce) { .cm-link.cm-ok { animation: none; } }
2261
2399
  .cm-repo rect { fill: var(--accent-weak); stroke: var(--accent); stroke-width: 1.5; }
@@ -2325,6 +2463,10 @@ a.ds-name:hover { text-decoration: underline; }
2325
2463
  .ds-toggle-btn { font-size: 12px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; }
2326
2464
  .ds-toggle-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
2327
2465
  .ds-toggle-btn:disabled { opacity: .6; cursor: default; }
2466
+ /* 送信中(#222)。**押せたことが分かる見た目**にする。cursor は wait にして、
2467
+ 「反応していないので押し直す」を止める(二重送信の一番多い入口) */
2468
+ .ds-toggle-btn.is-busy { cursor: wait; opacity: .75; }
2469
+ .ds-toggle-btn.is-busy::before { content: "◌ "; }
2328
2470
  .ds-toggle-btn.ds-danger { color: #b42318; border-color: #f3c6c1; }
2329
2471
  .ds-toggle-btn.ds-danger:hover:not(:disabled) { border-color: #b42318; color: #b42318; background: #fff5f4; }
2330
2472
  /* .ds-subitems / .ds-subitem は撤去した(2026-08-21)。共有資料のフォルダと Figma の
@@ -2424,6 +2566,11 @@ a.ds-name:hover { text-decoration: underline; }
2424
2566
  .ds-keyrow { flex: 1 1 100%; min-width: 0; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
2425
2567
  /* 一覧の直後に置くとき(Figma)は、行の中ではないので区切り線を持たない */
2426
2568
  .ds-keyrow-list { margin-top: 10px; padding-top: 0; border-top: none; }
2569
+ /* 鍵の効く範囲を枠で示す(#229)。Figma の鍵は案件に1本で、枠の中の全ファイルに効く。
2570
+ Backlog は行ごとに鍵があるので枠を持たない——**枠の有無がスコープの違いそのもの** */
2571
+ .ds-scope-group { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; background: var(--surface); }
2572
+ /* 鍵がどこまで効くかの1行(#229)。手順(.sec-how)より前に出るので、少し強めに */
2573
+ .sec-scope { font-size: 12px; color: var(--muted); margin: 0; }
2427
2574
  /* 🔴 **hidden を効かせる(2026-08-22)。** 鍵の欄は接続先が0件のあいだ hidden 属性で
2428
2575
  畳む(作らないのではなく隠す——理由は syncKeyCardVisibility の項)。ところが
2429
2576
  .ops-card は display: flex、.ds-keyrow は flex アイテムを名乗るので、
@@ -2684,8 +2831,10 @@ ${sanitizeCapabilitiesNow.toString()}
2684
2831
  ${sanitizeGithubOwnersNow.toString()}
2685
2832
  ${splitNeededParts.toString()}
2686
2833
  ${ingestedAtOf.toString()}
2687
- var FACT_CAPS = ${JSON.stringify(FACT_CAPS)};
2688
- ${usageFactMissing.toString()}
2834
+ var TILE_CAPS = ${JSON.stringify(TILE_CAPS)};
2835
+ var BAD_STATE_STATUSES = ${JSON.stringify(BAD_STATE_STATUSES)};
2836
+ ${keyMissingText.toString()}
2837
+ ${deriveCapTile.toString()}
2689
2838
  // 「はじめに」セクションのチュートリアルデータ(ビルド時に埋め込み。ボタンURL・チャレンジ4の決定は解決済み)
2690
2839
  var TUTORIAL = (function () {
2691
2840
  var elx = document.getElementById("tutorial-data");
@@ -4209,10 +4358,12 @@ ${usageFactMissing.toString()}
4209
4358
  * 能力の状態(capabilities)。**能力ごとに、正本(capabilitiesNow)があればそちら。**
4210
4359
  *
4211
4360
  * ⚠ **ここは rawFleetVal の「キー単位でフォールバックしない」の意図的な例外**(工程1.5)。
4212
- * あちらの禁止は「同じ画面に2つの時点が混ざると、上に出す『◯◯時点』が嘘になる」ため
4213
- * だが、capabilitiesNow は**画面が開いた能力ぶんしか来ない**(OPEN_CAPS の項)ので、
4214
- * 丸ごと採るか丸ごと捨てるかしか無いと、**開いていない能力のタイルが全部「未接続」に
4215
- * 落ちる**。能力ごとに正本を優先し、来ていない能力はまとめ表のまま出す。
4361
+ * あちらの禁止は「同じ画面に2つの時点が混ざると、上に出す『◯◯時点』が嘘になる」ため。
4362
+ *
4363
+ * **0.12.0 で caps は常に全量になった**(ALL_STATE_CAPS の項)ので、応答には
4364
+ * 6能力ぶんが揃って届く。それでも能力ごとの合成は残す——**古い Lambda が一部の能力を
4365
+ * 返さない期間がある**からで、丸ごと採るか丸ごと捨てるかしか無いと、その期間に
4366
+ * 返らなかった能力のタイルが「未接続」に落ちる。
4216
4367
  */
4217
4368
  function capsView() {
4218
4369
  var base = fleetVal("capabilities") || {};
@@ -4342,6 +4493,53 @@ ${usageFactMissing.toString()}
4342
4493
  };
4343
4494
  }
4344
4495
 
4496
+ /**
4497
+ * その能力の登録件数(規則1の材料)。**分からなければ null。**
4498
+ *
4499
+ * 🔴 **正本(registeredNow)だけを見る。** まとめ表(fleet-status.json)は夜間の
4500
+ * スナップショットで、外した直後も「1件」と言い続ける——#227 で「実際は0件なのに
4501
+ * 接続OK」を出していた当の情報源。ここでそちらへフォールバックすると、
4502
+ * この正本化が丸ごと無意味になる。
4503
+ *
4504
+ * **会議は null を返す。** 会議は登録の一覧を持たない(別名の書き換えは #117 で
4505
+ * 読み取り専用にした)ので、「0件」と言える材料が無い。会議の規則1にあたる判断
4506
+ * (自動取り込みが OFF)は 「capabilities」 の導出が答え、呼び出し側がこの関数より
4507
+ * 先に出す(deriveCapTile の項)。
4508
+ */
4509
+ function registeredCountOf(cap) {
4510
+ if (!LIVE_REGISTERED) return null;
4511
+ var meta = TILE_CAPS[cap];
4512
+ if (!meta) return null;
4513
+ var list = LIVE_REGISTERED[meta.id];
4514
+ return Array.isArray(list) ? list.length : null;
4515
+ }
4516
+
4517
+ /**
4518
+ * タイル・接続グラフの導出結果(0.12.0)。**タイルも図もここを通す。**
4519
+ *
4520
+ * 🔴 **呼び出し側で材料を組み立て直さないこと。** 同じ判定が2箇所に増えると、
4521
+ * 「図は接続OK・タイルは未接続」という #231 が殺したはずの食い違いが復活する。
4522
+ */
4523
+ function capDerivation(cap) {
4524
+ return deriveCapTile(cap, {
4525
+ registered: registeredCountOf(cap),
4526
+ seen: LIVE_STATE_SEEN,
4527
+ rows: LIVE_STATE,
4528
+ });
4529
+ }
4530
+
4531
+ /**
4532
+ * 設定そのものの導出(capabilities)が「on 以外」を答えているか。
4533
+ *
4534
+ * **答えているなら、その語が優先(#125②)。** OFF・設定に異常・未接続(未設置)は
4535
+ * 登録や実測より前に立つ——意図的に止めた能力に「登録済み(接続は未確認です)」と
4536
+ * 出すのは、設定する必要が無いものに設定を促すのと同じ誤り。
4537
+ */
4538
+ function capConfigState(cap) {
4539
+ var d = capabilityDisplayState(cap, capsView());
4540
+ return d && d !== "on" ? d : null;
4541
+ }
4542
+
4345
4543
  // ---------- 反映待ち(「⏳ 反映中」)----------
4346
4544
  //
4347
4545
  // ## 何のためか
@@ -5271,14 +5469,36 @@ ${usageFactMissing.toString()}
5271
5469
  //
5272
5470
  // **「[]」(夜間バッチは走ったが対象0件)と 「undefined」(まだ走っていない)は別物。**
5273
5471
  // 後者に「まだありません」と書くと、実際には登録済みかもしれない案件に嘘を言う
5274
- var ownersNow = githubOwnersView();
5275
- if (Array.isArray(ownersNow) && ownersNow.length === 0) {
5276
- pushCap("開発", el("p", {
5472
+ //
5473
+ // 🔴 **行の一覧と同じ情報源で出し入れする(#232・0.12.0)。** 以前はこの1行を
5474
+ // 節を組み立てるときに1回だけ判定して固定していた。ところがリポジトリの追加は
5475
+ // **モーダルを開いたまま**行われ、行だけが liveList で増える——結果、実機では
5476
+ // **「classmethod-internal/cortex-test-repo」の行と「まだありません」が同時に**出た。
5477
+ //
5478
+ // liveList に載せると、行と同じ署名で描き直される=行が増えた瞬間に消える。
5479
+ // Backlog(backlogTargetsSection の emptyEl 出し入れ)で先に潰した形の横展開で、
5480
+ // **兄弟機能へ展開し忘れていた**(#226・#220 と同じ型の漏れ)。
5481
+ //
5482
+ // **置き場所は追加フォームの後のまま。** 文言が「上のフォームで登録すると」と
5483
+ // 言っているので、フォームより前に出すと指す先が無くなる
5484
+ //
5485
+ // **箱を置いてよいのは、そこへ足せる案件だけ**(liveListWanted の項)。投入の口が
5486
+ // 無い構成に「上のフォームで登録すると」と書いても、指す先のフォームが無い
5487
+ if (liveListWanted("github")) pushCap("開発", liveList("開発", function () {
5488
+ // 判定に使う値そのものを署名にする(行の増減と、夜間の組の一覧の両方)
5489
+ return JSON.stringify([liveExternals("github").length, githubOwnersView() || null]);
5490
+ }, function (box) {
5491
+ var ownersNow = githubOwnersView();
5492
+ // **行が1本でもあれば言わない。** 「行がある」と「まだありません」を
5493
+ // 同じ画面で同時に言わせないための、いちばん外側の条件
5494
+ if (liveExternals("github").length) return;
5495
+ if (!Array.isArray(ownersNow) || ownersNow.length !== 0) return;
5496
+ box.appendChild(el("p", {
5277
5497
  class: "sec-how ds-all-connected",
5278
5498
  text: "読み取り対象の開発リポジトリがまだありません。上のフォームで登録すると、"
5279
5499
  + "読めているかどうかを翌朝からここに出します。",
5280
5500
  }));
5281
- }
5501
+ }));
5282
5502
 
5283
5503
  // 連携の鍵は、その能力のところへ置く(画面下まで往復させない)
5284
5504
  // **GitHub(「external-sources」)はここに無い。** 読み取りトークンは廃止し、
@@ -5336,7 +5556,11 @@ ${usageFactMissing.toString()}
5336
5556
  return;
5337
5557
  }
5338
5558
  if (k === "figma" && KEY_SLOTS.figma) {
5339
- KEY_SLOTS.figma.appendChild(secretCard(k, SECRET_FORMS[k], { inRow: true }));
5559
+ KEY_SLOTS.figma.appendChild(secretCard(k, SECRET_FORMS[k], {
5560
+ inRow: true,
5561
+ // Backlog(スペースごと)との違いを、見た目だけでなく言葉でも出す(#229)
5562
+ scopeNote: "この鍵は、上に並んでいる登録済みの全ファイルに共通です(ファイルごとの鍵ではありません)。",
5563
+ }));
5340
5564
  return;
5341
5565
  }
5342
5566
  pushCap(SECRET_CAP[k], secretCard(k, SECRET_FORMS[k]));
@@ -5429,30 +5653,34 @@ ${usageFactMissing.toString()}
5429
5653
  * 引き直す**ために同じ関数を使う——写経すると片方だけ古くなる
5430
5654
  */
5431
5655
  /**
5432
- * @param noFact 「設定は済んでいるが、まだ一度も取り込めていない」と言い切れるか
5433
- * (#235 の表示層)。**呼び出し側が渡す**——ここで LIVE_STATE から引き直すと、
5434
- * タイルの色(st)を決めた材料と 2 回引くことになり、片方だけずれる余地ができる
5656
+ * @param derive 正本からの導出(deriveCapTile の答え)。**呼び出し側が渡す**
5657
+ * ——ここで引き直すと、タイルの色(st)を決めた材料と2回引くことになり、
5658
+ * 片方だけずれる余地ができる。null なら規則5=従来表示へフォールバック
5435
5659
  */
5436
- function tileSub(cap, st, derived, adapter, ins, srcs, noFact) {
5660
+ function tileSub(cap, st, derived, adapter, ins, srcs, derive) {
5437
5661
  var internal = ins.filter(function (s) { return s.kind === cap; })[0];
5438
5662
  var live = srcs.filter(function (s) { return externalCapability(s.type) === cap; });
5439
5663
  var parts = [];
5440
- // 🔴 **取り込みの実績が無いなら、まずそれを言う(#235 の表示層)。**
5441
- // 設定は済んでいる(capabilities は on)ので「未接続」ではないし、設定ミスでもないので
5442
- // 警告色にもしない(#230 の決定どおり中立表示)。ここを通るのは会議・共有資料だけで、
5443
- // しかも呼び出し側が st === "ok" のときにしか立てない——OFF・設定異常・同期失敗の
5444
- // 語を、この一文で上書きしないため。
5445
- //
5446
- // 実績が無い= st は "idle" に降りているので、下の「件数」「最終同期」は自動で消える
5447
- // (繋がっていないものに読み取り範囲と同期時刻を並べない、という既存の規律に乗る)
5448
- if (noFact) {
5449
- parts.push((CAP_STATE_MARK[st] || "") + "未使用(まだ取り込みがありません)");
5450
5664
  // **導出が on 以外なら、導出の語を優先する(#125②)。** broken は状態としては
5451
5665
  // 警告(⚠️)だが、原因は「設定そのものが壊れている」であって同期の失敗ではない。
5452
5666
  // 下の warn の文(同期が失敗しています/N件が読めていません)を出すと、
5453
- // 直す場所が違うところを指す
5454
- } else if (derived && derived !== "on") {
5667
+ // 直す場所が違うところを指す。
5668
+ //
5669
+ // 🔴 **ここが正本からの導出(下)より先。** 意図的に OFF にした能力に
5670
+ // 「登録済み(接続は未確認です)」と出さないため(deriveCapTile の項)
5671
+ if (derived && derived !== "on") {
5455
5672
  parts.push((CAP_STATE_MARK[st] || "") + (CAP_STATE_TEXT[derived] || "状態が判定できません"));
5673
+ } else if (derive) {
5674
+ // 🔴 **正本(登録・実測)からの導出(0.12.0・#231 / #235 / #227)。**
5675
+ // 「設定が済んでいる」ことと「実際に動いている」ことは別。設定だけを見て
5676
+ // 「● 接続OK」と出すと、**画面が動いていないものを動いていると言う**。
5677
+ //
5678
+ // **ライブ参照の内訳がある警告だけは、そちらの文を使う。** attentionSummary は
5679
+ // 社内/顧客の出し分けを持っていて、グループの見出しと同じ文になる
5680
+ // (写経すると片方だけ古くなる)。内訳が空なら導出の一言に倒す——
5681
+ // 以前の「⚠️ 0件に問題」は、何が起きているのか読めない文だった
5682
+ var summary = derive.state === "warn" && live.length ? attentionSummary(live) : "";
5683
+ parts.push(summary || (CAP_STATE_MARK[derive.state] || "") + derive.text);
5456
5684
  } else if (st === "warn") {
5457
5685
  if (live.length) {
5458
5686
  // **文面は共有の attentionSummary が持つ。** グループの summary と同じ文で、
@@ -5568,15 +5796,16 @@ ${usageFactMissing.toString()}
5568
5796
  // カタログにあるが導出が答えていないアダプタ(今日は発生しない。#107 の前方互換)
5569
5797
  ? "idle"
5570
5798
  : capabilityState(cap, stateCtx());
5571
- // 🔴 **「接続OK」と言うのは、取り込めた事実があるときだけ(#235 の表示層)。**
5572
- // 会議・共有資料は中央 Apps Script が取り込むので、設定が済んでいるのに一度も
5573
- // 取り込まれていない案件が普通にある。そこへ「● 接続OK」と出すと、**画面が
5574
- // 動いていないものを動いていると言う**——顧客がそれを信じて資料を置かなくなる。
5799
+ // 🔴 **状態の正本は「登録・実測からの導出」(0.12.0・#231 / #235 / #227)。**
5800
+ // 設定が済んでいること(capabilities on)と、実際に動いていることは別。
5801
+ // 設定だけを見て「● 接続OK」と出すと、**画面が動いていないものを動いていると言う**
5802
+ // ——顧客がそれを信じて資料を置かなくなる/失敗に気づけない。
5575
5803
  //
5576
- // **降ろすのは ok からだけ。** warn(繋がったうえで失敗している)は隠さないし、
5577
- // idle / off はもともと繋がっていると言っていない。
5578
- var noFact = st === "ok" && usageFactMissing(cap, LIVE_STATE, LIVE_STATE_SEEN);
5579
- if (noFact) st = "idle";
5804
+ // **導出が答えないとき(規則5・古い Lambda)だけ、従来の fleet 判定のまま。**
5805
+ // **設定が on 以外のときは、そちらが先に立つ**(tileSub の順序と揃える——
5806
+ // 揃えないと「色は接続OK・文字はOFF」が同じタイルの中で並ぶ)
5807
+ var derive = derived && derived !== "on" ? null : capDerivation(cap);
5808
+ if (derive) st = derive.state;
5580
5809
  // **ロゴはタイルの左に独立して置く。** 名前の行に入れると名前の高さに
5581
5810
  // 縛られて小さくなる。ロゴで能力を見分けられるのが一覧の主な用途
5582
5811
  var logo = ADAPTER_ICONS[adapter];
@@ -5591,10 +5820,15 @@ ${usageFactMissing.toString()}
5591
5820
  // ——抑制しないと、同じタイルの中で「⏳ 反映中」と「⦿ 未接続」が並んで矛盾する。
5592
5821
  // **抑制するのは unset のときだけ**(off / broken は残す。R4 と同じ理由)
5593
5822
  // まとめ表がまだ知らない行がある能力は、**行と同じ語**に倒す(未接続とも接続OKとも言わない)
5594
- var pend = capPending(cap);
5823
+ // 🔴 **導出が答えているなら、「登録状況を確認中です」より導出が勝つ(0.12.0)。**
5824
+ // あの語は「まとめ表しか材料が無く、登録の現在値が分からない」ときの折衷だった。
5825
+ // 正本(registeredNow)を読むようになった今は分かる——外した直後は「未接続」、
5826
+ // 足した直後は「登録済み(まだ取り込みがありません)」と、**何が起きたかを言える**。
5827
+ // ここを逆にすると、まとめ表が追いつく翌朝まで「確認中です」が居座る
5828
+ var pend = capPending(cap) && !derive;
5595
5829
  var subEl = el("div", {
5596
5830
  class: "ds-tile-sub " + (pend ? DS_GATE_PENDING.cls : "ds-gate-" + st),
5597
- text: pend ? DS_GATE_PENDING.text : tileSub(cap, st, derived, adapter, internals, sources, noFact),
5831
+ text: pend ? DS_GATE_PENDING.text : tileSub(cap, st, derived, adapter, internals, sources, derive),
5598
5832
  });
5599
5833
  // **反映が届いたら、届いたスナップショットから引き直す。** 下地へ戻すと
5600
5834
  // 「⏳ 反映中」→「⦿ 未接続」になる——データが正しくなったあとに嘘をつく。
@@ -5607,14 +5841,14 @@ ${usageFactMissing.toString()}
5607
5841
  var stNow = dNow === "unset" && capabilityDisplayState(cap, capsNow) !== "unset"
5608
5842
  ? "idle"
5609
5843
  : capabilityState(cap, stateCtx());
5610
- // 実績の判定も、タイルを作るときと同じ式で引き直す(#235 の表示層)。
5844
+ // 正本からの導出も、タイルを作るときと同じ式で引き直す(0.12.0)。
5611
5845
  // ここを省くと、反映が届いた瞬間だけ「● 接続OK」へ戻る
5612
- var noFactNow = stNow === "ok" && usageFactMissing(cap, LIVE_STATE, LIVE_STATE_SEEN);
5613
- if (noFactNow) stNow = "idle";
5614
- var pendNow = capPending(cap);
5846
+ var deriveNow = dNow && dNow !== "on" ? null : capDerivation(cap);
5847
+ if (deriveNow) stNow = deriveNow.state;
5848
+ var pendNow = capPending(cap) && !deriveNow;
5615
5849
  n.className = "ds-tile-sub " + (pendNow ? DS_GATE_PENDING.cls : "ds-gate-" + stNow);
5616
5850
  return {
5617
- text: pendNow ? DS_GATE_PENDING.text : tileSub(cap, stNow, dNow, adapter, insNow, srcsNow, noFactNow),
5851
+ text: pendNow ? DS_GATE_PENDING.text : tileSub(cap, stNow, dNow, adapter, insNow, srcsNow, deriveNow),
5618
5852
  hidden: false,
5619
5853
  };
5620
5854
  });
@@ -5653,7 +5887,15 @@ ${usageFactMissing.toString()}
5653
5887
  // 0.4.70 は unset で入力欄を畳んでいたので注記も抑えていたが、案A で欄を出すようにしたため揃えた
5654
5888
  // 導出が無いデータでは判断材料が無いので、注記を出す側(=入力欄と揃う側)に倒す
5655
5889
  // 接続先0件の抑止は入力欄と**同じ関数**を通す(keyTargetCount の 🔴 不変条件)
5656
- var tokenRelevant = (derived != null ? derived !== "off" : true) && keyTargetCount(cap) > 0;
5890
+ //
5891
+ // 🔴 **状態の行が既に鍵の不足を言っているなら、ここでは言わない(0.12.0・#225)。**
5892
+ // 取り込みが skipped(鍵が無くて飛ばした)の能力は、2行目が
5893
+ // 「⚠️ APIキーが未登録です」になる。その下にもう一度「⚠ トークンが未設定です」を
5894
+ // 積むと、**同じ1枚のタイルが同じことを2回言う**——#227 が潰した「別々の情報源が
5895
+ // 整合を取らずに1つのタイルに流れ込む」形そのもの
5896
+ var tokenRelevant = (derived != null ? derived !== "off" : true)
5897
+ && keyTargetCount(cap) > 0
5898
+ && !(derive && derive.keyMissing);
5657
5899
  if (tokenRelevant && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !secretPresentForTile(sk)) {
5658
5900
  body.appendChild(el("div", { class: "ds-tile-note", text: "⚠ トークンが未設定です" }));
5659
5901
  }
@@ -6018,7 +6260,9 @@ ${usageFactMissing.toString()}
6018
6260
  btn.addEventListener("click", async function () {
6019
6261
  var intake = SITE.intake;
6020
6262
  if (!intake) return;
6021
- btn.disabled = true;
6263
+ // 押している間はボタンごと「確認中…」にする(#222・beginBusy の項)。
6264
+ // **語は下の ds-toggle-msg と揃える**——同じ操作を2つの語で言わない
6265
+ var endBusy = beginBusy(btn, "確認中…");
6022
6266
  // **押されて初めて枠を出す(2026-08-20・オーナー指摘)。**
6023
6267
  // それまでは中身が空でも点線だけが全行に出ており、**何も意味していなかった**。
6024
6268
  // 押した結果があるときだけ、行と結果を分ける線になる
@@ -6043,7 +6287,7 @@ ${usageFactMissing.toString()}
6043
6287
  // サーバの文言をそのまま出す(回数制限の案内・未対応のアダプタの説明を含んでいる)
6044
6288
  msg.className = "ds-toggle-msg is-error";
6045
6289
  msg.textContent = "✗ " + (j.message || "確認できませんでした。");
6046
- btn.disabled = false;
6290
+ endBusy();
6047
6291
  return;
6048
6292
  }
6049
6293
  msg.className = "ds-toggle-msg" + (j.gate === "ok" ? " is-ok" : " is-error");
@@ -6081,7 +6325,7 @@ ${usageFactMissing.toString()}
6081
6325
  msg.className = "ds-toggle-msg is-error";
6082
6326
  msg.textContent = "✗ 通信に失敗しました。";
6083
6327
  }
6084
- btn.disabled = false;
6328
+ endBusy();
6085
6329
  });
6086
6330
  return { btn: btn, detail: wrap };
6087
6331
  }
@@ -6294,13 +6538,17 @@ ${usageFactMissing.toString()}
6294
6538
  * **「最終同期」(.ds-sync)を置き換えない。** あちらは run 全体の最終成功時刻で、
6295
6539
  * どの接続先のことか分からない。こちらは接続先1件の成否。意味が違うので併記する。
6296
6540
  */
6297
- function ingestedEl(r) {
6541
+ function ingestedEl(r, cap) {
6298
6542
  if (!r) return null;
6299
6543
  var when = rtAgo(r.at);
6300
6544
  var text = "";
6301
6545
  if (r.status === "ok") text = when ? "取り込み: " + when + "に成功" : "取り込み: 成功";
6302
6546
  else if (r.status === "failed") text = when ? "取り込み: " + when + "に失敗" : "取り込み: 失敗";
6303
- else if (r.status === "skipped") text = "取り込み: スキップ";
6547
+ // 🔴 **「スキップ」で終わらせない(#225)。** 鍵が無くて飛ばしたのが skipped の実体で、
6548
+ // 「スキップ」とだけ書かれても**次に何をすればいいのか読み取れない**。しかも
6549
+ // 「失敗」と並べて置くと、原因を調べに行く側へ人を送ってしまう(直すのは鍵の登録)。
6550
+ // 語は鍵カードの名前と揃える(keyMissingText の項)
6551
+ else if (r.status === "skipped") text = "取り込み: 未実行(" + keyMissingText(cap) + ")";
6304
6552
  if (!text) return null;
6305
6553
  return el("span", {
6306
6554
  class: "ds-ingest" + (r.status === "failed" ? " ds-ingest-failed" : ""),
@@ -6425,7 +6673,7 @@ ${usageFactMissing.toString()}
6425
6673
  dsNameEl(t.domain + " / " + t.projectKey, "https://" + t.domain + "/projects/" + t.projectKey),
6426
6674
  ]),
6427
6675
  // この接続先の取り込みが最後にどうなったか(段C)。測っていなければ null
6428
- ingestedEl(ingestedStateOf(t.domain, t.projectKey)),
6676
+ ingestedEl(ingestedStateOf(t.domain, t.projectKey), "課題管理"),
6429
6677
  ]);
6430
6678
  if (!editable) return row;
6431
6679
  var btn = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: "この接続先を外す" });
@@ -6485,12 +6733,18 @@ ${usageFactMissing.toString()}
6485
6733
  // 呼ばれるので、作り直すと**接続先を1つ足しただけで入力中の鍵と「保存中…」が消える**
6486
6734
  //(ライブ差し替えで踏んだのと同じ事故が、別の経路で復活する)。
6487
6735
  // appendChild は既存ノードを移すだけなので、入力値もイベントも保たれる
6488
- for (var d in keySlots) {
6489
- if (!Object.prototype.hasOwnProperty.call(keySlots, d)) continue;
6736
+ //
6737
+ // 🔴 **見るのは正本(KEY_SLOTS.backlog)。節の中の控えを持たない(#223)。**
6738
+ // 応答で新しいスペースが現れたら受け皿はその場で足される(ensureKeySlots)ので、
6739
+ // 節を作ったときの控えを見ていると**足した受け皿がここへ来ない**=
6740
+ // 鍵カードが行の中に入らないまま宙に浮く
6741
+ var slots = KEY_SLOTS.backlog || {};
6742
+ for (var d in slots) {
6743
+ if (!Object.prototype.hasOwnProperty.call(slots, d)) continue;
6490
6744
  if (!d) continue; // 接続先が無い案件の受け皿は box 側に置いてある(行が無い)
6491
6745
  var host = lastRowOf[d];
6492
- if (host) host.appendChild(keySlots[d]);
6493
- else if (keySlots[d].parentNode) keySlots[d].parentNode.removeChild(keySlots[d]);
6746
+ if (host) host.appendChild(slots[d]);
6747
+ else if (slots[d].parentNode) slots[d].parentNode.removeChild(slots[d]);
6494
6748
  }
6495
6749
  // **0件を黙って見せない。** 表示が無いと「まだ何も無い」のか「出し忘れ」なのか
6496
6750
  // 区別できない。**一覧の外**(Figma / Drive と同じ)に、件数に応じて出し入れする
@@ -6525,10 +6779,75 @@ ${usageFactMissing.toString()}
6525
6779
  // **鍵の欄の判断もこの一覧に従わせる(2026-08-22)。** keyTargetCount はまとめ表を
6526
6780
  // 見るので、ここで教えないと「行はその場で出るのに鍵の欄だけ次の取り直しまで出ない」
6527
6781
  ADOPTED_TARGET_COUNT = targets.length;
6782
+ // 🔴 **新しいスペースの鍵の欄を、その場で足す(#223)。** paint() より先に呼ぶ
6783
+ //(paint は在る受け皿を行へ入れるだけで、無い受け皿は作らない)
6784
+ ensureKeySlots();
6528
6785
  paint();
6529
6786
  return targetsGenerationOf(targets) !== before;
6530
6787
  }
6531
6788
 
6789
+ /**
6790
+ * 応答や取り直しで**新しいスペースが現れたら**、その分の受け皿と鍵カードを足す(#223)。
6791
+ *
6792
+ * ## 直している欠陥
6793
+ *
6794
+ * 受け皿は「モーダルを開いたその瞬間の接続先の集合」から1回だけ作られていた。
6795
+ * その後モーダルを開いたまま別のスペース(例: beams-dev.backlog.com)の接続先を
6796
+ * 足すと、**行は増えるのに鍵を入れる欄がどこにも現れない**——しかも画面は
6797
+ * 「保存しました」と言うので、成功したようにしか見えない。フルリロードするまで
6798
+ * 気づけなかった(#223・受け入れテストで実際に踏んだ)。
6799
+ *
6800
+ * ## 足すだけにする理由
6801
+ *
6802
+ * 単純な「全部作り直す」に戻すと、**入力中の鍵と「保存中…」が消える**——
6803
+ * その再発を防ぐために「作り直さない」と決めたのが元の実装(paint の項)。
6804
+ * だから**既存スペースの受け皿とカードには一切触らない**。
6805
+ *
6806
+ * ⚠ **既存カードの spaceCount は古いままになる。** 1スペースだった案件に2つ目が
6807
+ * 増えると、先にあったカードは「1スペースぶんの答え方」(種別ごとの登録状況)を
6808
+ * 続ける。作り直せば揃うが、それは入力中の値を捨てるということ——**モーダルを
6809
+ * 開き直せば揃う**ほうを取る(登録状況の表示が少し弱くなるだけで、嘘は出ない)。
6810
+ */
6811
+ function ensureKeySlots() {
6812
+ if (!editable) return;
6813
+ // 受け皿を作る経路を通っていない案件(intake が無い・固定IP同期)はここも通さない
6814
+ if (!KEY_SLOTS.backlog || !Object.keys(KEY_SLOTS.backlog).length) return;
6815
+ if ((SITE.secretKinds || []).indexOf("backlog") < 0 || !SECRET_FORMS["backlog"]) return;
6816
+ var domains = [];
6817
+ for (var i = 0; i < targets.length; i++) {
6818
+ var dm = String(targets[i].domain || "").toLowerCase();
6819
+ if (dm && domains.indexOf(dm) === -1) domains.push(dm);
6820
+ }
6821
+ // **0件の案件の受け皿(空文字キー)は、1スペースぶんまでは役目を果たしている。**
6822
+ // あれは「どのスペースとも結び付いていない」欄で、サーバは名簿の値に倒す
6823
+ // ——接続先が1スペースなら、それで正しい先に入る
6824
+ if (KEY_SLOTS.backlog[""] && domains.length <= 1) return;
6825
+ var added = false;
6826
+ for (var di = 0; di < domains.length; di++) {
6827
+ var dom = domains[di];
6828
+ if (KEY_SLOTS.backlog[dom]) continue;
6829
+ var slot = el("div", { class: "ds-keyrow" });
6830
+ slot.appendChild(secretCard("backlog", SECRET_FORMS["backlog"], {
6831
+ // 名乗ってよいのは名簿由来の行だけ(作るときと同じ判断・KEY_SLOTS.backlogNamed)
6832
+ space: KEY_SLOTS.backlogNamed ? dom : "",
6833
+ stateSpace: dom,
6834
+ spaceCount: domains.length,
6835
+ inRow: true,
6836
+ }));
6837
+ KEY_SLOTS.backlog[dom] = slot;
6838
+ added = true;
6839
+ }
6840
+ if (!added) return;
6841
+ // **名無しの受け皿は役目を終える。** スペースを名乗る欄と名乗らない欄が同時に出ると、
6842
+ // どちらに貼れば効くのかが読めない(名乗らない欄は名簿の1スペースに入る)
6843
+ var blank = KEY_SLOTS.backlog[""];
6844
+ if (blank) {
6845
+ if (blank.parentNode) blank.parentNode.removeChild(blank);
6846
+ delete KEY_SLOTS.backlog[""];
6847
+ }
6848
+ syncKeyCardVisibility();
6849
+ }
6850
+
6532
6851
  function targetsHooks() {
6533
6852
  return { onTargets: adoptTargets, staleNote: TARGETS_RACE_NOTE };
6534
6853
  }
@@ -6542,7 +6861,6 @@ ${usageFactMissing.toString()}
6542
6861
  // **この画面から投入できない案件(固定IP同期)には出さない。** 押しても Lambda が
6543
6862
  // 「固定IP経由のため対応していません」で断るので、導線そのものを作らない。
6544
6863
  // 判定は鍵カードを出すループの条件と同じ線(SITE.secretKinds に backlog があるか)。
6545
- var keySlots = {};
6546
6864
  if (editable && !Object.keys(KEY_SLOTS.backlog).length) {
6547
6865
  // 保存時にスペースを名乗ってよいのは、名簿(fleet の backlogTarget)由来の行だけ。
6548
6866
  // 同期ミラーのURLから作った行で名乗ると、名簿とミラーがずれている案件で
@@ -6562,8 +6880,8 @@ ${usageFactMissing.toString()}
6562
6880
  //(足した接続先のドメインに対応するスロットは無いので、鍵の欄はこの受け皿に
6563
6881
  // 残ったまま一覧の下に現れる。paint() はこのキーを移動の対象から外している)
6564
6882
  if (!domains.length) domains.push("");
6565
- for (var di = 0; di < domains.length; di++) keySlots[domains[di]] = el("div", { class: "ds-keyrow" });
6566
- KEY_SLOTS.backlog = keySlots;
6883
+ // **正本へ直接入れる**(節の中に控えを持たない・paint / ensureKeySlots の項)
6884
+ for (var di = 0; di < domains.length; di++) KEY_SLOTS.backlog[domains[di]] = el("div", { class: "ds-keyrow" });
6567
6885
  }
6568
6886
 
6569
6887
  // **box を先に作る。** paint() は 0件の1行を box へ出し入れするので、
@@ -6590,10 +6908,12 @@ ${usageFactMissing.toString()}
6590
6908
  if (targetsGenerationOf(next) === targetsGenerationOf(targets) && sig === lastIngestSig) return;
6591
6909
  targets = next;
6592
6910
  lastIngestSig = sig;
6911
+ // 取り直しで新しいスペースが届くこともある(他の人が足した場合・#223)
6912
+ ensureKeySlots();
6593
6913
  paint();
6594
6914
  };
6595
6915
  // 接続先がまだ無い案件の受け皿は、0件の1行の直後に置く(行が無いので中へ入れられない)
6596
- if (keySlots[""]) box.appendChild(keySlots[""]);
6916
+ if (KEY_SLOTS.backlog[""]) box.appendChild(KEY_SLOTS.backlog[""]);
6597
6917
  if (editable) {
6598
6918
  box.appendChild(addForm({
6599
6919
  // **「追加」に戻す(#127)。** 1件しか持てなかった頃は「保存」(置き換え)だったが、
@@ -6885,9 +7205,24 @@ ${usageFactMissing.toString()}
6885
7205
  * 据え置き、他の行の取り込み表示は普段どおり追いつかせる(removeButton の onBusy)。
6886
7206
  */
6887
7207
  var figSending = {};
7208
+ // **ファイル一覧と鍵カードを1つの枠に括る(#229・オーナー指摘)。**
7209
+ //
7210
+ // Figma の鍵は**案件に1本**で全ファイル共通なので、特定のファイルの行の中には
7211
+ // 置けない(「このファイル専用」という嘘の見た目になる)。その結果、鍵カードが
7212
+ // レコードの外に浮いて見え、**Backlog(行の中に鍵がある)と見比べると
7213
+ // 「入れ損ねた」ようにしか見えなかった**。
7214
+ //
7215
+ // 枠で括ると「この枠の中のもの全部に効く鍵」が図として読める。文言の側も
7216
+ // 鍵カードの scopeNote が同じことを言う——**見た目と言葉の両方**で、
7217
+ // Backlog とのスコープの違いが伝わるようにする(片方だけだと読み落とされる)。
7218
+ // **見出しは付けない。** 接続先が0件のあいだ鍵カードは畳まれる(syncKeyCardVisibility)
7219
+ // ので、「…と、共通の鍵」のような見出しは**鍵が出ていないときに嘘になる**。
7220
+ // 枠そのものと、鍵カードの中の scopeNote が意味を運ぶ
7221
+ var figGroup = el("div", { class: "ds-scope-group" });
7222
+ box.appendChild(figGroup);
6888
7223
  // **署名に取り込みの状態も混ぜる(段C第二弾)。** 一覧のJSONだけだと、応答が返る前に
6889
7224
  // モーダルを開いた人には取り込みが1件も出ない(designIngestSigOf の項に経緯)
6890
- box.appendChild(liveList("デザイン", function () {
7225
+ figGroup.appendChild(liveList("デザイン", function () {
6891
7226
  return JSON.stringify(figsOf()) + "|" + designIngestSigOf(figsOf());
6892
7227
  }, function (listBox) {
6893
7228
  var figs = figsOf();
@@ -6916,7 +7251,7 @@ ${usageFactMissing.toString()}
6916
7251
  dsNameEl(f.name || f.key, "https://www.figma.com/design/" + f.key),
6917
7252
  ]),
6918
7253
  // このファイルの取り込みが最後にどうなったか(段C)。測っていなければ null
6919
- ingestedEl(designIngestedStateOf(f.key)),
7254
+ ingestedEl(designIngestedStateOf(f.key), "デザイン"),
6920
7255
  ]);
6921
7256
  row.appendChild(removeButton({
6922
7257
  label: "このファイルを外す",
@@ -6973,7 +7308,8 @@ ${usageFactMissing.toString()}
6973
7308
  //(intake / OFF のゲートをループ側に残すため。KEY_SLOTS のコメント参照)
6974
7309
  if (!KEY_SLOTS.figma) {
6975
7310
  KEY_SLOTS.figma = el("div", { class: "ds-keyrow ds-keyrow-list" });
6976
- box.appendChild(KEY_SLOTS.figma);
7311
+ // **枠の中に入れる**(一覧と同じ枠=この鍵が効く範囲・#229)
7312
+ figGroup.appendChild(KEY_SLOTS.figma);
6977
7313
  }
6978
7314
  box.appendChild(addForm({
6979
7315
  title: "+ Figmaファイルを追加",
@@ -7116,6 +7452,48 @@ ${usageFactMissing.toString()}
7116
7452
  // 確認 → 送信 → 結果表示、までを1箇所に置く。操作ごとに書き写すと、
7117
7453
  // 片方だけ直したときにずれる(トグル・削除・追加のどれも同じ経路を通す)。
7118
7454
 
7455
+ /**
7456
+ * 送信中のボタンの見た目(#222)。**押せたかどうかが操作中に分かるようにする。**
7457
+ *
7458
+ * ## なぜ disabled だけでは足りなかったのか
7459
+ *
7460
+ * 送信系はどれも btn.disabled = true はしていた。ところが受け入れテストで
7461
+ * 「この接続先を外す」を押したとき、**画面に何の変化も起きなかった**——disabled
7462
+ * の見た目の差が薄く(opacity .6)、しかも押した直後に視線が行くのはボタン自身。
7463
+ * 「処理が本当に走っているのか、二重クリックしてよいのか」が分からない。
7464
+ *
7465
+ * **ボタンの語そのものを「処理中…」に変える。** 状態表示(ds-toggle-msg)は
7466
+ * ボタンの隣にあって視線の外に出やすいので、**押した場所が変わる**ことが要る。
7467
+ *
7468
+ * ## 3経路で同じ実装を通す
7469
+ *
7470
+ * 送信系は sendConfig(追加・削除・トグル)/secretCard(鍵の保存)/
7471
+ * probeSection(接続確認)の3つ。**同じ見た目を3回書かない**——今後アダプタが
7472
+ * 増えても、この関数を通す限り見た目が揃う(#222 の「共通コンポーネント化」)。
7473
+ *
7474
+ * ⚠ **スケルトンまでは作らない。** 影響を受ける行を骨組み表示にするのは DOM パッチの
7475
+ * 範囲を超える(行の作り直しは入力中の鍵と「保存中…」を壊してきた当のもの)。
7476
+ * 「押せたか分からない」はボタンの無効化+語の差し替えで死ぬ。
7477
+ *
7478
+ * @returns 元へ戻す関数。**何度呼んでも1回しか効かない**(finally と成功パスの
7479
+ * 両方から呼べるようにするため)。引数に語を渡すと、その語で戻す
7480
+ */
7481
+ function beginBusy(btn, label) {
7482
+ if (!btn) return function () {};
7483
+ var before = btn.textContent;
7484
+ btn.disabled = true;
7485
+ btn.classList.add("is-busy");
7486
+ btn.textContent = label || "処理中…";
7487
+ var used = false;
7488
+ return function endBusy(nextLabel) {
7489
+ if (used) return;
7490
+ used = true;
7491
+ btn.disabled = false;
7492
+ btn.classList.remove("is-busy");
7493
+ btn.textContent = nextLabel == null ? before : nextLabel;
7494
+ };
7495
+ }
7496
+
7119
7497
  /**
7120
7498
  * サーバへ設定変更を送る。**一覧は書き換えない**
7121
7499
  * (接続状況は夜間バッチのスナップショットなので、「もう効いている」と誤解させないため)。
@@ -7132,7 +7510,8 @@ ${usageFactMissing.toString()}
7132
7510
  var intake = SITE.intake;
7133
7511
  if (!intake) return;
7134
7512
  var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
7135
- btn.disabled = true;
7513
+ // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)
7514
+ var endBusy = beginBusy(btn);
7136
7515
  msg.className = "ds-toggle-msg";
7137
7516
  msg.textContent = "保存中…";
7138
7517
  try {
@@ -7172,7 +7551,8 @@ ${usageFactMissing.toString()}
7172
7551
  if (canConfirm) {
7173
7552
  // **ボタンは必ず先に戻す。** 確認の前に戻しておかないと、「いいえ」を押した人の
7174
7553
  // フォームが押せないまま残り、再読み込みするまで追加できなくなる
7175
- btn.disabled = false;
7554
+ // (語も戻す——確認ダイアログの裏で「処理中…」のままにしない)
7555
+ endBusy();
7176
7556
  msg.className = "ds-toggle-msg";
7177
7557
  msg.textContent = "";
7178
7558
  if (await hooks.onDuplicate()) {
@@ -7193,7 +7573,7 @@ ${usageFactMissing.toString()}
7193
7573
  // 画面は既に追いついているので、済んでいることをやらせる案内になる(#127)
7194
7574
  msg.className = "ds-toggle-msg is-error";
7195
7575
  msg.textContent = "✗ " + ((res.status === 409 && raced && hooks.staleNote) || j.message || "保存できませんでした。");
7196
- btn.disabled = false;
7576
+ endBusy();
7197
7577
  return;
7198
7578
  }
7199
7579
  msg.className = "ds-toggle-msg is-ok";
@@ -7214,7 +7594,9 @@ ${usageFactMissing.toString()}
7214
7594
  msg.appendChild(document.createTextNode(" "));
7215
7595
  msg.appendChild(el("a", { href: j.refreshUrl, target: "_blank", rel: "noopener", text: "反映の状況 →" }));
7216
7596
  }
7217
- btn.disabled = false;
7597
+ // 🔴 **語を戻すのは onDone より先。** removeButton の onDone は押せない状態へ
7598
+ // 戻す(外した行のボタン)ので、後に置くと「処理中…」の語が居座る
7599
+ endBusy();
7218
7600
  // **反映待ちとして記録する。** 一覧はスナップショットから描かれるので、
7219
7601
  // 保存できていても数分は「未設定」と出る。その間だけ「⏳ 反映中」に差し替える。
7220
7602
  //
@@ -7229,7 +7611,7 @@ ${usageFactMissing.toString()}
7229
7611
  // 描かれるので、保存しただけでは行が出ない。ここで読み直すと、登録ファイルの中身が
7230
7612
  // 返ってきて**足りない行として一覧に出る**——さらに「まとめ表がまだ答えていない行」が
7231
7613
  // できるので、まとめ表が追いつくまで取り直しが続く。
7232
- // 取得中なら何もしない(応答後に catchUpOpenCaps が拾う)
7614
+ // 取得中なら freshWanted の旗だけ立てて戻る(応答後にもう一度取りに行く)
7233
7615
  if (j.changed !== false) loadLiveFleet(true);
7234
7616
  // **何も変わっていないなら「追加した」と見せない。** 既に同じものが入っていた場合
7235
7617
  // サーバは 200 + changed:false を返す。ここを res.ok だけで判定すると
@@ -7238,7 +7620,7 @@ ${usageFactMissing.toString()}
7238
7620
  } catch (e) {
7239
7621
  msg.className = "ds-toggle-msg is-error";
7240
7622
  msg.textContent = "✗ 通信に失敗しました。";
7241
- btn.disabled = false;
7623
+ endBusy();
7242
7624
  }
7243
7625
  }
7244
7626
 
@@ -7955,15 +8337,37 @@ ${usageFactMissing.toString()}
7955
8337
  */
7956
8338
  var liveFleetRequested = false;
7957
8339
  /**
7958
- * この画面で開いた能力(op=state に送る識別子)。
8340
+ * op=state に送る能力。**常に全能力**(0.12.0・#231 の正本化)。
8341
+ *
8342
+ * ## なぜ「開いた能力だけ」をやめたのか
8343
+ *
8344
+ * 応答は**丸ごと置き換わる**(LIVE_REGISTERED / LIVE_CAPS)。開いた能力だけを
8345
+ * 要求していると、**モーダルを1つ開いた瞬間にその能力だけの caps でリクエストが飛び、
8346
+ * 他5能力が応答から欠落する**——タイルの導出は材料が無ければ規則5(fleet への
8347
+ * フォールバック)へ落ちるので、**開いていない5能力のタイルが一斉に退行する**。
8348
+ * 「初回だけ全量・以降は開いた能力のみ」の2段構えでも、置き換えが起きる限り同じ穴が開く。
8349
+ *
8350
+ * **常に全量なら応答は常に完全**で、置き換えのままで正しい。
7959
8351
  *
7960
- * 🔴 **開いている能力の分だけ読む。** Lambda は指定された能力の登録ファイルだけを
7961
- * 読む(ツリー1本+ファイルの本数)。全能力を毎回読ませると GitHub の枠を削る。
8352
+ * ## GitHub の枠は問題にならない
7962
8353
  *
7963
- * **一度開いたら覚えておく(閉じても外さない)。** 外すと、次の取り直しで
7964
- * その能力の行が応答から消え、**タイルの件数だけが行ったり来たりする**。
8354
+ * op=state が飛ぶのは**ページを開いたとき・モーダルを開いたとき・反映待ちが残って
8355
+ * いる間の取り直し**だけで、恒常ポーリングは存在しない(stateTick は settle で止まる)。
8356
+ * しかも Lambda は 「projectKey + caps集合」 でキャッシュ(60秒)するので、**全ビューアが
8357
+ * 同じキーを共有する**——能力ごとに違うキーを作っていた頃より、むしろ当たりやすい。
8358
+ *
8359
+ * ⚠ **並べ替えてから送る。** サーバのキャッシュキーは caps を並べ替えて繋ぐが、
8360
+ * こちらも定数の並びを固定しておく(同じ集合が何通りにも増えない)。
8361
+ */
8362
+ var ALL_STATE_CAPS = Object.keys(STATE_CAP_NAMES).sort();
8363
+ /**
8364
+ * 一度でも開いた能力。**送る caps とは無関係**(送るのは常に全量)。
8365
+ *
8366
+ * 残してあるのは「モーダルを初めて開いたときに1回だけ取り直す」ためだけ。
8367
+ * 開いた瞬間のいまの値でモーダルを描きたいので取りに行くが、二度目からは
8368
+ * 反映待ちの取り直しに任せる(押すたびに Lambda を叩かない)。
7965
8369
  */
7966
- var OPEN_CAPS = [];
8370
+ var OPENED_CAPS = [];
7967
8371
  /**
7968
8372
  * 画面の能力名 → 接続状態ストアの能力名(対応表の実体は STATE_CAP_NAMES)。
7969
8373
  *
@@ -7985,26 +8389,20 @@ ${usageFactMissing.toString()}
7985
8389
  }
7986
8390
  return null;
7987
8391
  }
7988
- /** 取得中に開かれた能力があれば、もう一度だけ取りに行く */
7989
- function catchUpOpenCaps() {
7990
- if (OPEN_CAPS.length === liveFleetSentCaps.length) return;
7991
- loadLiveFleet();
7992
- }
7993
- /** 能力を開いた(=その登録ファイルを読んでよい)。増えたときだけ取り直す */
8392
+ /**
8393
+ * 能力を開いた。**初回だけ取り直す。**
8394
+ *
8395
+ * ⚠ **caps は増えない**(常に全量・ALL_STATE_CAPS の項)。以前ここにあった
8396
+ * 「取得中に開かれた能力を取りこぼさない」ための追いかけ(catchUpOpenCaps /
8397
+ * liveFleetSentCaps)は、要求の中身が能力によって変わらなくなったので不要になった
8398
+ * ——取得中の応答にも、その能力は最初から載っている。
8399
+ */
7994
8400
  function noteOpenCap(cap) {
7995
8401
  var id = stateCapIdOf(cap);
7996
- if (!id || OPEN_CAPS.indexOf(id) >= 0) return;
7997
- OPEN_CAPS.push(id);
8402
+ if (!id || OPENED_CAPS.indexOf(id) >= 0) return;
8403
+ OPENED_CAPS.push(id);
7998
8404
  loadLiveFleet();
7999
8405
  }
8000
- /**
8001
- * 直近の取得に載せた能力の一覧。
8002
- *
8003
- * **取得中に能力を開いた分を取りこぼさないため。** 取得中の loadLiveFleet は
8004
- * 何もせずに戻るので、これが無いと「開いたのに、その能力だけ永久に読まれない」
8005
- * (しかも次の取り直しは未確定の行が無いので張られない=二度と追いつかない)。
8006
- */
8007
- var liveFleetSentCaps = [];
8008
8406
  /**
8009
8407
  * 次の取得で据え置きを迂回するか(保存が効いた直後の1回だけ)。
8010
8408
  *
@@ -8025,18 +8423,17 @@ ${usageFactMissing.toString()}
8025
8423
  // 素で呼ぶと描画中の ReferenceError になり、「画面が例外を出さずに描き切る」検査を割る。
8026
8424
  if (typeof fetch !== "function") return;
8027
8425
  liveFleetRequested = true;
8028
- liveFleetSentCaps = OPEN_CAPS.slice();
8029
8426
  var useFresh = freshWanted;
8030
8427
  freshWanted = false;
8031
8428
  var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
8032
8429
  fetch(pfx + "op=state&t=" + encodeURIComponent(intake.token), {
8033
8430
  method: "POST",
8034
8431
  headers: { "content-type": "application/json" },
8035
- // **開いている能力を伝える。** 知らない値は Lambda 側が黙って捨てる(断らない)ので、
8036
- // 画面と Lambda の版が食い違っても接続状況の表示は消えない
8432
+ // **常に全能力を要求する(ALL_STATE_CAPS の項)。** 知らない値は Lambda 側が黙って
8433
+ // 捨てる(断らない)ので、画面と Lambda の版が食い違っても接続状況の表示は消えない
8037
8434
  // **保存直後だけ据え置きを迂回させる。** これが無いと、サーバが保存前の答えを返し、
8038
8435
  // 足りない行が0件になって取り直しも張られない=手で再読み込みするまで追いつかない
8039
- body: JSON.stringify(useFresh ? { caps: OPEN_CAPS, fresh: true } : { caps: OPEN_CAPS }),
8436
+ body: JSON.stringify(useFresh ? { caps: ALL_STATE_CAPS, fresh: true } : { caps: ALL_STATE_CAPS }),
8040
8437
  })
8041
8438
  // **非2xxも失敗として扱う。** ビューアとLambdaの版は独立に進むので、
8042
8439
  // op=state 未対応のLambdaは 400 を返す。通信エラーだけを見ていると空表示になる。
@@ -8068,7 +8465,7 @@ ${usageFactMissing.toString()}
8068
8465
  // 🔴 **図の降格は refreshDataSources の外で呼ぶ(#235 の表示層)。** あちらは
8069
8466
  // モーダルが開いている間・入力中は何もせずに戻るので、中に入れると
8070
8467
  // **モーダルを開いたまま最初の応答が届いた回だけ、図が「接続OK」のまま残る**
8071
- applyUsageFactToConnMap();
8468
+ applyStateToConnMap();
8072
8469
  // **モーダルを開いたままでも「反映中」だけは追いつかせる。** refreshDataSources は
8073
8470
  // モーダルが開いている間・入力中は何もしない(下地を入れ替えると開いているモーダルの
8074
8471
  // 中身が切り離される)ので、局所的に差し替える口をここに置く
@@ -8078,7 +8475,6 @@ ${usageFactMissing.toString()}
8078
8475
  refreshStateTimer();
8079
8476
  // 取得中に保存が効いていたら、迂回付きでもう一度取りに行く
8080
8477
  if (freshWanted) loadLiveFleet();
8081
- catchUpOpenCaps();
8082
8478
  })
8083
8479
  .catch(function () {
8084
8480
  // 静的ビルドの値で描き続ける。ただし「古いかもしれない」ことは画面に出す
@@ -8094,7 +8490,6 @@ ${usageFactMissing.toString()}
8094
8490
  refreshStateTimer();
8095
8491
  // 取得中に保存が効いていたら、迂回付きでもう一度取りに行く
8096
8492
  if (freshWanted) loadLiveFleet();
8097
- catchUpOpenCaps();
8098
8493
  });
8099
8494
  }
8100
8495
 
@@ -8425,6 +8820,16 @@ ${usageFactMissing.toString()}
8425
8820
  var stateSpace = (opts && opts.stateSpace) || "";
8426
8821
  var spaceCount = (opts && opts.spaceCount) || 0;
8427
8822
  var inRow = !!(opts && opts.inRow);
8823
+ /**
8824
+ * 鍵がどこまで効くのか(#229)。**置き場所だけでは伝わらないので、言葉にする。**
8825
+ *
8826
+ * Backlog の鍵は**スペースごと**に別なので、そのスペースの行の中に置いてある。
8827
+ * Figma の鍵は**案件に1本**で全ファイル共通なので、特定のファイルの行に入れると
8828
+ * 「このファイル専用」という嘘の見た目になり、一覧の外に置いてある。
8829
+ * 理屈は通っているが、**ファイルが1件だけの画面では「行の中に入れ損ねた」ように
8830
+ * しか見えない**(オーナー指摘)。枠で括ったうえで、この1行で言い切る。
8831
+ */
8832
+ var scopeNote = (opts && opts.scopeNote) || "";
8428
8833
  // **いま登録されているか**を先に出す。入れ直したのに反映されていない状態が
8429
8834
  // 画面で分かるようにする(接続状況は夜間バッチのスナップショットなので、
8430
8835
  // 保存の直後は前日の値のまま。時刻は上の節に出している)。
@@ -8456,7 +8861,8 @@ ${usageFactMissing.toString()}
8456
8861
  var value = input.value;
8457
8862
  if (!value.trim()) { msg.className = "ds-toggle-msg is-error"; msg.textContent = "⚠ トークンを入力してください。"; return; }
8458
8863
 
8459
- btn.disabled = true;
8864
+ // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)
8865
+ var endBusy = beginBusy(btn);
8460
8866
  msg.className = "ds-toggle-msg";
8461
8867
  msg.textContent = "保存中…";
8462
8868
  var intake = SITE.intake;
@@ -8476,10 +8882,15 @@ ${usageFactMissing.toString()}
8476
8882
  if (!res.ok) {
8477
8883
  msg.className = "ds-toggle-msg is-error";
8478
8884
  msg.textContent = "✗ " + (j.message || "保存できませんでした。");
8479
- btn.disabled = false;
8885
+ endBusy();
8480
8886
  return;
8481
8887
  }
8482
8888
  msg.className = "ds-toggle-msg is-ok";
8889
+ // 🔴 **「保存中…」を消してから成功を書く(#220)。** ここが抜けていて、実機の
8890
+ // 画面に「保存中…✓ 保存しました。同期を開始しました。」と**2つの状態が同居**して
8891
+ // いた。appendChild は足すだけなので、消す一文が要る(sendConfig 側には在った)。
8892
+ // **語を書く前に必ずここを通すこと**——次に文面を足す人が同じ形で踏む
8893
+ msg.textContent = "";
8483
8894
  msg.appendChild(document.createTextNode("✓ " + (j.message || "保存しました。")));
8484
8895
  // 起動できたときだけ、実行状況への導線を出す(鍵を入れた直後に効いたか確かめられる)
8485
8896
  if (j.runsUrl) {
@@ -8488,11 +8899,11 @@ ${usageFactMissing.toString()}
8488
8899
  }
8489
8900
  // 入れた値は画面に残さない(肩越しに見えるのを防ぐ)
8490
8901
  input.value = "";
8491
- btn.disabled = false;
8902
+ endBusy();
8492
8903
  } catch (e) {
8493
8904
  msg.className = "ds-toggle-msg is-error";
8494
8905
  msg.textContent = "✗ 通信に失敗しました。";
8495
- btn.disabled = false;
8906
+ endBusy();
8496
8907
  }
8497
8908
  });
8498
8909
 
@@ -8543,6 +8954,9 @@ ${usageFactMissing.toString()}
8543
8954
  // **desc は無い種別がある(2026-08-21)。** el() は text をそのまま textContent へ
8544
8955
  // 代入するので、無条件に出すと画面に文字列 undefined が出る
8545
8956
  meta.desc ? el("p", { class: "ops-card-desc", text: meta.desc }) : null,
8957
+ // **鍵の効く範囲は、登録状況より先に言う。** 「登録済み」と読んだ後では
8958
+ // 「どのファイルの話か」を確かめ直すことになる(#229)
8959
+ scopeNote ? el("p", { class: "sec-scope", text: scopeNote }) : null,
8546
8960
  state,
8547
8961
  // **未登録のときだけ、先に試すべき手を出す。** 既に動いているカードに
8548
8962
  // 「まずこれをやれ」と出すと、直す必要が無いものを触らせることになる。
@@ -8583,54 +8997,67 @@ ${usageFactMissing.toString()}
8583
8997
  });
8584
8998
  app.appendChild(wrap);
8585
8999
  updateConnMapAsOf();
8586
- applyUsageFactToConnMap();
9000
+ applyStateToConnMap();
8587
9001
  }
8588
9002
 
8589
9003
  /**
8590
- * 取り込みの実績が無い能力を、接続マップから降ろす(#235 の表示層)。
9004
+ * 接続グラフを、正本からの導出で**毎回まるごと当て直す**(0.12.0・#231 / #235)。
9005
+ *
9006
+ * ## なぜ「片道の降格」をやめたのか
8591
9007
  *
8592
- * **図はビルド時に焼かれている。** 焼いた時点では 「stateNow」 を持っていないので、
8593
- * 会議・共有資料は capabilities が 「on」 なだけで「● 接続OK・線あり」になる。
8594
- * ライブ応答が届いてから、実績の無い2能力だけを**その場で**降ろす。
9008
+ * 0.11.1 は「実績が無い会議・共有資料を cm-ok から降ろす(戻す道は無い)」形だった。
9009
+ * 取り込みの事実は**単調に増えるだけ**(一度取り込めたら取り消されない)なので、
9010
+ * あの2能力では片道で成立していた。
8595
9011
  *
8596
- * - 線は消す(「data-cm-cap」 で引く。位置で数えると案件ごとに並びが変わる)
8597
- * - ノードは 「cm-off」(灰・破線)にして、3行目を「未使用」にする。図の語彙は
8598
- * renderConnectionMap の off と同じにする——同じ画面で図とタイルの語を割らない
9012
+ * **チャットと開発の「届くか」は双方向に変わる。** アクセスできない → App の設定を
9013
+ * 直す 「接続を確認」を押すと ok に戻る。片道のまま一般化すると、**直したのに
9014
+ * リロードするまで未接続のまま**という**新しい嘘**を作る(前の嘘を消して別の嘘を
9015
+ * 置くことになる)。
8599
9016
  *
8600
- * **降ろすのは 「cm-ok」 からだけ**(タイル側と同じ規律。「cm-warn」 は隠さない)。
9017
+ * ## だから冪等な再描画にする
8601
9018
  *
8602
- * ⚠ **戻す道は無い。** 画面を開いたまま初回の取り込みが完走した場合、下の一覧だけが
8603
- * 「接続OK」に変わり、図は次に開くまで「未使用」のまま。図の遅れは
8604
- * 「この図は◯◯時点です(下の一覧は最新)」で既に断ってあるので、ここでは受け入れる
8605
- * ——「cm-ok」 を条件にしているので、何度呼んでも二重に降ろすことはない。
9019
+ * ライブ応答が届くたびに、6能力ぶんの導出結果(capDerivation)から
9020
+ * **ノードのクラス・3行目・線の表示を毎回計算して当てる**。降格も昇格もする。
9021
+ * 何度呼んでも同じ状態に落ち着く(前回の状態を読まない)。
9022
+ *
9023
+ * 🔴 **線は消さない。表示を切り替えるだけ**(renderConnectionMap の項)。
9024
+ * 消してしまうと、昇格のときに曲線のジオメトリを実行時にもう一度計算することになり、
9025
+ * 同じ式がビルド時と実行時の2箇所に分かれる。
9026
+ *
9027
+ * 🔴 **cm-warn を隠さない。** 壊れているのは「繋がったうえで失敗している」状態で、
9028
+ * 経路自体は存在する。線を消すと**設定が無いのと区別が付かなくなり、失敗が図から
9029
+ * 消える**——「失敗が上位表示に畳み込まれない」(#231)の逆を、こちらの都合で作る。
9030
+ *
9031
+ * **導出が答えない能力(規則5・設定が on 以外)は触らない。** 焼いた図がそのまま
9032
+ * 残る=従来表示へのフォールバック。タイル側と同じ規律。
8606
9033
  */
8607
- function applyUsageFactToConnMap() {
9034
+ function applyStateToConnMap() {
8608
9035
  var wrap = app.querySelector(".conn-map");
8609
9036
  if (!wrap) return;
8610
- for (var cap in FACT_CAPS) {
8611
- if (!Object.prototype.hasOwnProperty.call(FACT_CAPS, cap)) continue;
8612
- if (!usageFactMissing(cap, LIVE_STATE, LIVE_STATE_SEEN)) continue;
9037
+ for (var cap in TILE_CAPS) {
9038
+ if (!Object.prototype.hasOwnProperty.call(TILE_CAPS, cap)) continue;
9039
+ // 設定そのものが OFF / 異常 / 未設置なら、その語(焼いた図)が正しい
9040
+ if (capConfigState(cap)) continue;
9041
+ var d = capDerivation(cap);
9042
+ if (!d) continue; // 規則5: 材料が無い → 焼いた図のまま
8613
9043
  var node = wrap.querySelector('[data-target="ds-' + cap + '"]');
8614
- if (!node || node.getAttribute("class").indexOf("cm-ok") < 0) continue;
8615
- node.setAttribute("class", "cm-node cm-off");
9044
+ if (!node) continue;
9045
+ node.setAttribute("class", "cm-node cm-" + d.state);
8616
9046
  var sync = node.querySelector(".cm-sync");
8617
- if (!sync) {
8618
- // 3行目そのものが無いノード(最終同期を持たないまま ok になっていた)。
8619
- // **状態を書く場所を作る**——灰色になっただけで理由が読めないのは、
8620
- // 「名前だけ出て状態が出ない」という #124 の症状に戻ること
8621
- var label = node.querySelector(".cm-label");
8622
- sync = document.createElementNS("http://www.w3.org/2000/svg", "text");
8623
- sync.setAttribute("class", "cm-sync");
8624
- sync.setAttribute("x", label ? label.getAttribute("x") : "0");
8625
- sync.setAttribute("y", "67");
8626
- sync.setAttribute("text-anchor", "middle");
8627
- node.appendChild(sync);
9047
+ if (sync) {
9048
+ // 🔴 **時刻の印は必ず外す。** 残すと renderConnMap の [data-ts] の整形が
9049
+ // あとから「最終同期 …」を書き戻し、導出した語が消える
9050
+ sync.removeAttribute("data-ts");
9051
+ // 接続OK のときだけ、ストア由来の取り込み時刻を添える(run の時刻ではない)。
9052
+ // 出せなければ語だけ——**繋がっていないものに同期時刻を並べない**(タイルと同じ規律)
9053
+ var at = d.state === "ok" ? dsFmtTime(ingestedAtOf(LIVE_STATE, TILE_CAPS[cap].id)) : "";
9054
+ sync.textContent = at ? "最終同期 " + at : d.text;
8628
9055
  }
8629
- // 時刻の整形(renderConnMap の [data-ts])が後から書き戻さないよう、印ごと外す
8630
- sync.removeAttribute("data-ts");
8631
- sync.textContent = "未使用";
8632
9056
  var link = wrap.querySelector('.cm-link[data-cm-cap="' + cap + '"]');
8633
- if (link && link.parentNode) link.parentNode.removeChild(link);
9057
+ if (link) {
9058
+ link.setAttribute("class", "cm-link cm-" + d.state
9059
+ + (d.state === "ok" || d.state === "warn" ? "" : " is-hidden"));
9060
+ }
8634
9061
  }
8635
9062
  }
8636
9063
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,