@takagaki/cortex-decisions-viewer 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/render.js +636 -84
  2. package/package.json +1 -1
package/dist/render.js CHANGED
@@ -26,6 +26,8 @@ exports.sanitizeCapabilitiesNow = sanitizeCapabilitiesNow;
26
26
  exports.sanitizeGithubOwnersNow = sanitizeGithubOwnersNow;
27
27
  exports.splitNeededParts = splitNeededParts;
28
28
  exports.ingestedAtOf = ingestedAtOf;
29
+ exports.keyMissingText = keyMissingText;
30
+ exports.deriveCapTile = deriveCapTile;
29
31
  exports.renderConnectionMap = renderConnectionMap;
30
32
  /** SiteDataを単一の自己完結HTML(埋め込みデータ+CSS+JS)にレンダリングする */
31
33
  function renderSite(site) {
@@ -1181,10 +1183,11 @@ const CAPABILITY_NOW_STATES = ["on", "off", "empty", "broken", "unset"];
1181
1183
  * `capabilitiesNow`(能力が配線されているか)を、**まとめ表の `capabilities` と同じ形**
1182
1184
  * (能力名=日本語のキー)に畳む。読めた能力だけを返し、1つも無ければ null。
1183
1185
  *
1184
- * ⚠ **応答は `caps` で要求した能力ぶんしか来ない**(画面が開いた能力だけを読む・
1185
- * `OPEN_CAPS` の項)。だから**キー単位でまとめ表へ落ちる**——ここだけは
1186
- * `rawFleetVal` の「キーごとにフォールバックしない」の例外で、そうしないと
1187
- * 開いていない能力のタイルが全部「未接続」に落ちる。
1186
+ * ⚠ **応答は `caps` で要求した能力ぶんしか来ない。** 画面は 0.12.0 から常に全量を
1187
+ * 要求する(`ALL_STATE_CAPS` の項)ので普段は6能力が揃うが、**古い Lambda は一部しか
1188
+ * 返さない**。だから**キー単位でまとめ表へ落ちる**——ここだけは `rawFleetVal`
1189
+ * 「キーごとにフォールバックしない」の例外で、そうしないと返らなかった能力の
1190
+ * タイルが「未接続」に落ちる。
1188
1191
  */
1189
1192
  function sanitizeCapabilitiesNow(v) {
1190
1193
  if (!v || typeof v !== "object" || Array.isArray(v))
@@ -1291,6 +1294,142 @@ function ingestedAtOf(rows, capId) {
1291
1294
  }
1292
1295
  return best;
1293
1296
  }
1297
+ /**
1298
+ * タイルと接続グラフの導出に要る、能力ごとの材料(0.12.0・#231 / #235 / #227)。
1299
+ *
1300
+ * - `id` … 接続状態ストアの能力名(`STATE_CAP_NAMES` と同じ綴り)
1301
+ * - `question` … その能力が「実際に動いた」と言える問い。**取り込む能力は `ingested`、
1302
+ * 毎晩の Gold 昇格がその場で読む能力(チャット・開発)は `reachable`。**
1303
+ * 取り込みの行が存在しない能力に `ingested` を割り当てると、**永久に「実績なし」**になる
1304
+ * - `noFact` … 規則4(登録はあるが実績が無い)の文言。**能力ごとに待っているものが違う**
1305
+ * ので1文に丸めない。会議・共有資料は中央 Apps Script の取り込み待ち(=まだ使っていない)、
1306
+ * 課題管理・デザインは夜間同期待ち、チャット・開発は実測待ち
1307
+ *
1308
+ * **6能力すべてを載せること。** ここに無い能力は導出そのものが働かず、規則5
1309
+ * (fleet へのフォールバック)に落ちる=正本化の対象から静かに外れる。
1310
+ */
1311
+ const TILE_CAPS = {
1312
+ "課題管理": { id: "issues", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "APIキー" },
1313
+ "会議": { id: "meetings", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1314
+ "共有資料": { id: "materials", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1315
+ "デザイン": { id: "design", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "トークン" },
1316
+ "チャット": { id: "chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1317
+ "開発": { id: "dev", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1318
+ };
1319
+ /**
1320
+ * 「鍵が無くて飛ばした」の一文(#225)。**能力ごとに鍵の呼び名が違う。**
1321
+ *
1322
+ * 画面の鍵カードは「Backlog のAPIキー」「Figma のトークン」と名乗っている。
1323
+ * ここで別の語を使うと、**利用者は言われたものを画面から探せない**。
1324
+ * 鍵を持たない能力(会議・共有資料・チャット・開発)では skipped は本来起きないが、
1325
+ * 起きたときに「鍵」の話をしないよう、中立の一文に倒す。
1326
+ */
1327
+ function keyMissingText(cap) {
1328
+ var meta = TILE_CAPS[cap];
1329
+ var label = meta ? meta.keyLabel : "";
1330
+ return label ? label + "が未登録です" : "取り込みを飛ばしました";
1331
+ }
1332
+ /**
1333
+ * 実測が「悪い」と言える語。**`unknown` は入れない**——測れなかっただけで、
1334
+ * 悪いとは言えない(入れると、一度も測れていない案件が全部⚠️になる)。
1335
+ */
1336
+ const BAD_STATE_STATUSES = {
1337
+ ingested: ["failed", "skipped"],
1338
+ reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable"],
1339
+ };
1340
+ /**
1341
+ * タイル1行(と接続グラフの1ノード)の状態を、**正本から導出する**(0.12.0)。
1342
+ *
1343
+ * ## 規則は上から順の優先判定(並列判定に書き換えないこと)
1344
+ *
1345
+ * 1. **登録0件** → 「未接続」(中立・点線・線なし)
1346
+ * 2. **登録あり × 実測が悪い** → 警告(⚠+短い一言)
1347
+ * 3. **登録あり × 実績が ok** → 「接続OK」
1348
+ * 4. **登録あり × 実績なし** → 中立(能力ごとの文言)
1349
+ * 5. **`stateNow` の欄が無い**(古い Lambda・ストアが読めなかった)→ `null`
1350
+ * =呼び出し側は従来の fleet 表示へフォールバック
1351
+ *
1352
+ * 🔴 **規則1が最優先であることが、孤児行に対する唯一の守り。** 接続先を外しても
1353
+ * ストアの行は残る(TTL が無い・既知の問題)。並列判定に書き換えると、
1354
+ * **登録を全部外した能力が、残った孤児行のせいで「接続OK」や⚠️を出し続ける**
1355
+ * ——#227 で見た「実際は0件なのに接続OK」がそのまま復活する。
1356
+ *
1357
+ * 🔴 **規則5より規則1を先に見るのも意図的。** 登録が0件であることは `registeredNow`
1358
+ * だけで言い切れる事実で、ストアが読めたかどうかとは無関係。ここを逆にすると、
1359
+ * ストアが読めない案件でだけ「0件なのに接続OK」が残る。
1360
+ *
1361
+ * ## 設定そのものが OFF / 異常のときは、ここへ来ない
1362
+ *
1363
+ * 「OFFに設定されています」「設定に異常があります」「未接続(未設置)」は
1364
+ * `capabilities` の導出(#125②)が答えるもので、**呼び出し側がこの関数より先に**出す。
1365
+ * ここが受け持つのは「設定は済んでいる(on)」か「そもそも導出が無い」場合だけ。
1366
+ * 混ぜると、意図的に止めた能力に「登録済み(接続は未確認です)」と出す。
1367
+ *
1368
+ * @param cap 画面の能力名(日本語)
1369
+ * @param ctx.registered その能力の登録件数。**分からなければ null**(0 と混ぜない——
1370
+ * 取得前の null を 0 として扱うと、読み込み中の一瞬だけ全能力が「未接続」になる)
1371
+ * @param ctx.seen 応答に `stateNow` の欄があったか。**`rows` の空とは別物**——
1372
+ * `sanitizeStateNow` は0件を `null` に畳むので、`rows` だけを見ると
1373
+ * 「読めて空(=まだ何も起きていない事実)」が「分からない」に化ける
1374
+ * @param ctx.rows `stateNow` の行
1375
+ * @returns 導出結果。`null` なら規則5(従来表示へフォールバック)
1376
+ *
1377
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
1378
+ * `TILE_CAPS` / `BAD_STATE_STATUSES` は CLIENT_JS 側にも `var` で流し込んである)。
1379
+ */
1380
+ function deriveCapTile(cap, ctx) {
1381
+ var meta = TILE_CAPS[cap];
1382
+ if (!meta)
1383
+ return null;
1384
+ var registered = ctx ? ctx.registered : null;
1385
+ // ---- 規則1: 登録0件 → 未接続 ----
1386
+ if (registered === 0)
1387
+ return { rule: 1, state: "idle", text: "未接続", keyMissing: false };
1388
+ // ---- 規則5: 材料が届いていない → 従来表示へ ----
1389
+ if (!ctx || !ctx.seen)
1390
+ return null;
1391
+ var rows = Array.isArray(ctx.rows) ? ctx.rows : [];
1392
+ var bad = BAD_STATE_STATUSES[meta.question] || [];
1393
+ var failed = 0;
1394
+ var skipped = 0;
1395
+ var unreachable = 0;
1396
+ var okSeen = false;
1397
+ for (var i = 0; i < rows.length; i++) {
1398
+ var r = rows[i];
1399
+ if (!r || r.cap !== meta.id || r.question !== meta.question)
1400
+ continue;
1401
+ // **一度でも成功していれば実績あり。** `lastOkAt` を見るのは、いま失敗している行でも
1402
+ // 「繋がったことがある」のは事実だから(規則2が先に立つので表示は⚠️のまま)
1403
+ if (r.status === "ok" || r.lastOkAt)
1404
+ okSeen = true;
1405
+ if (bad.indexOf(r.status) < 0)
1406
+ continue;
1407
+ if (r.status === "failed")
1408
+ failed++;
1409
+ else if (r.status === "skipped")
1410
+ skipped++;
1411
+ else
1412
+ unreachable++;
1413
+ }
1414
+ // ---- 規則2: 実測が悪い → 警告 ----
1415
+ //
1416
+ // **失敗を先に言う。** 「鍵が無くて飛ばした」より「鍵はあるのに取れなかった」ほうが
1417
+ // 直す先が違ううえに重い(#225 が分けたかったのはまさにこの2つ)
1418
+ if (failed)
1419
+ return { rule: 2, state: "warn", text: "同期が失敗しています", keyMissing: false };
1420
+ // 🔴 **skipped は「失敗」と言わない(#225)。** 鍵が無いのは取り込みの失敗ではなく
1421
+ // 設定が済んでいないだけで、利用者が取るべき行動が違う(鍵を入れる/原因を調べる)
1422
+ if (skipped)
1423
+ return { rule: 2, state: "warn", text: keyMissingText(cap), keyMissing: !!meta.keyLabel };
1424
+ if (unreachable) {
1425
+ return { rule: 2, state: "warn", text: unreachable + "件が読めていません", keyMissing: false };
1426
+ }
1427
+ // ---- 規則3: 実績あり → 接続OK ----
1428
+ if (okSeen)
1429
+ return { rule: 3, state: "ok", text: "接続OK", keyMissing: false };
1430
+ // ---- 規則4: 実績なし → 中立 ----
1431
+ return { rule: 4, state: "idle", text: meta.noFact, keyMissing: false };
1432
+ }
1294
1433
  function capabilityState(cap, ctx) {
1295
1434
  // **能力→パイプラインの対応はここに持つ。** 呼び出し側に渡させると、
1296
1435
  // クライアント側(この関数を .toString() で受け取る側)が同じ表を書き写すことになり、
@@ -1513,12 +1652,16 @@ function renderConnectionMap(site) {
1513
1652
  else if (internal?.lastSync) {
1514
1653
  statusTs = internal.lastSync;
1515
1654
  }
1516
- // 時刻はクライアントでローカルタイム整形するため data-ts で渡す
1517
- const sub = statusText || statusTs
1518
- ? `<text class="cm-sync" x="${cx}" y="67" text-anchor="middle"` +
1519
- (statusTs ? ` data-ts="${escapeHtml(statusTs)}"` : "") +
1520
- `>${escapeHtml(statusText)}</text>`
1521
- : "";
1655
+ // 時刻はクライアントでローカルタイム整形するため data-ts で渡す。
1656
+ //
1657
+ // 🔴 **3行目は中身が空でも必ず置く(0.12.0)。** ライブ応答が届くたびに全能力の
1658
+ // 状態を書き直す(`applyStateToConnMap`)ので、**書く場所が無いノードがあると
1659
+ // そこだけ更新できない**。以前は無ければ実行時に createElementNS で作っていたが、
1660
+ // 「ビルド時に組み立てる形」と「実行時に組み立てる形」が2つに分かれ、片方だけ
1661
+ // 直る形になっていた(座標の与え方が実際にずれていた)。
1662
+ const sub = `<text class="cm-sync" x="${cx}" y="67" text-anchor="middle"` +
1663
+ (statusTs ? ` data-ts="${escapeHtml(statusTs)}"` : "") +
1664
+ `>${escapeHtml(statusText)}</text>`;
1522
1665
  const badge = warn ? `<text class="cm-badge" x="${cx + 62}" y="24" text-anchor="middle">⚠️</text>` : "";
1523
1666
  // クリックでページ内の該当詳細カード(アンカー)へスクロールする
1524
1667
  const anchor = `ds-${c.cap}`;
@@ -1536,9 +1679,32 @@ function renderConnectionMap(site) {
1536
1679
  //
1537
1680
  // cm-warn は引く。**壊れているのは「繋がったうえで失敗している」状態**で、
1538
1681
  // 経路自体は存在する(線を消すと、設定が無いのと区別が付かなくなる)。
1539
- if (state === "cm-ok" || state === "cm-warn") {
1540
- links.push(`<path class="cm-link ${state}" d="M ${cx} 78 C ${cx} 152, ${REPO_X} 162, ${REPO_X} 238"></path>`);
1541
- }
1682
+ //
1683
+ // 🔴 **線は6本とも必ず焼く。出す/出さないはクラスで切り替える(0.12.0・#231)。**
1684
+ //
1685
+ // この図はビルド時に焼くので、**登録の現在値も実測(stateNow)もここでは分からない**
1686
+ // ——分かるのはライブ応答が届いた後だけ。届いてから毎回、全能力の状態を導出し直して
1687
+ // 当てる(`applyStateToConnMap`)。そのとき **線を作ったり消したりしない**:
1688
+ //
1689
+ // - 消す(removeChild)と、昇格(直したら線が戻る)のときに**ジオメトリを
1690
+ // 実行時にもう一度計算する**ことになる。曲線の制御点の式がビルド時と実行時の
1691
+ // 2箇所に分かれ、片方だけ直す形が生まれる
1692
+ // - 最初から6本置いて表示を切り替えるなら、式はここ1箇所のまま
1693
+ //
1694
+ // **`data-cm-cap` が取っ手。** 位置で数えさせないこと——線は6本とも同じ形で並ぶが、
1695
+ // 「何番目が何の能力か」を数える読み方は、能力が増減した瞬間に静かにずれる。
1696
+ //
1697
+ // 🔴 **`data-cap` は使わない。** あちらは能力ブロック(`.datasources` の中)が
1698
+ // 既に名乗っていて、図はページの**上**にあるので `[data-cap="会議"]` で引くと
1699
+ // 図の線のほうが先に当たる。実際、最初にそう書いてテストが12件落ちた。
1700
+ //
1701
+ // **線を出すのは「実際に繋がっていることが証明できたもの」だけ**(従来どおり)。
1702
+ // 未使用(cm-off)・宣言だけで実体が無いもの(cm-idle)からは出さない。
1703
+ // cm-warn は出す——**壊れているのは「繋がったうえで失敗している」状態**で、
1704
+ // 経路自体は存在する(線を消すと、設定が無いのと区別が付かなくなる)。
1705
+ const linkShown = state === "cm-ok" || state === "cm-warn";
1706
+ links.push(`<path class="cm-link ${state}${linkShown ? "" : " is-hidden"}" data-cm-cap="${escapeHtml(c.cap)}"` +
1707
+ ` d="M ${cx} 78 C ${cx} 152, ${REPO_X} 162, ${REPO_X} 238"></path>`);
1542
1708
  });
1543
1709
  // **中央ノードは GitHub のリポジトリ名そのもの(オーナー指示・2026-08-12)。**
1544
1710
  // 以前は「📦 コンテキストリポジトリ(案件の表示名)」だったが、役割名は図の文脈から自明で、
@@ -2203,6 +2369,9 @@ a.ds-name:hover { text-decoration: underline; }
2203
2369
  .cm-link.cm-warn { stroke: #d97706; }
2204
2370
  .cm-link.cm-off { stroke: #d1d5db; opacity: .6; }
2205
2371
  .cm-link.cm-idle { stroke: #cbd5e1; opacity: .7; }
2372
+ /* 線は6本とも焼いて、出す/出さないをここで切り替える(0.12.0・renderConnectionMap の項)。
2373
+ SVG 要素に HTML の hidden 属性は効かないので、クラスで消す */
2374
+ .cm-link.is-hidden { display: none; }
2206
2375
  @keyframes cm-flow { to { stroke-dashoffset: -24; } }
2207
2376
  @media (prefers-reduced-motion: reduce) { .cm-link.cm-ok { animation: none; } }
2208
2377
  .cm-repo rect { fill: var(--accent-weak); stroke: var(--accent); stroke-width: 1.5; }
@@ -2272,6 +2441,10 @@ a.ds-name:hover { text-decoration: underline; }
2272
2441
  .ds-toggle-btn { font-size: 12px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; }
2273
2442
  .ds-toggle-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
2274
2443
  .ds-toggle-btn:disabled { opacity: .6; cursor: default; }
2444
+ /* 送信中(#222)。**押せたことが分かる見た目**にする。cursor は wait にして、
2445
+ 「反応していないので押し直す」を止める(二重送信の一番多い入口) */
2446
+ .ds-toggle-btn.is-busy { cursor: wait; opacity: .75; }
2447
+ .ds-toggle-btn.is-busy::before { content: "◌ "; }
2275
2448
  .ds-toggle-btn.ds-danger { color: #b42318; border-color: #f3c6c1; }
2276
2449
  .ds-toggle-btn.ds-danger:hover:not(:disabled) { border-color: #b42318; color: #b42318; background: #fff5f4; }
2277
2450
  /* .ds-subitems / .ds-subitem は撤去した(2026-08-21)。共有資料のフォルダと Figma の
@@ -2371,6 +2544,11 @@ a.ds-name:hover { text-decoration: underline; }
2371
2544
  .ds-keyrow { flex: 1 1 100%; min-width: 0; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
2372
2545
  /* 一覧の直後に置くとき(Figma)は、行の中ではないので区切り線を持たない */
2373
2546
  .ds-keyrow-list { margin-top: 10px; padding-top: 0; border-top: none; }
2547
+ /* 鍵の効く範囲を枠で示す(#229)。Figma の鍵は案件に1本で、枠の中の全ファイルに効く。
2548
+ Backlog は行ごとに鍵があるので枠を持たない——**枠の有無がスコープの違いそのもの** */
2549
+ .ds-scope-group { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; background: var(--surface); }
2550
+ /* 鍵がどこまで効くかの1行(#229)。手順(.sec-how)より前に出るので、少し強めに */
2551
+ .sec-scope { font-size: 12px; color: var(--muted); margin: 0; }
2374
2552
  /* 🔴 **hidden を効かせる(2026-08-22)。** 鍵の欄は接続先が0件のあいだ hidden 属性で
2375
2553
  畳む(作らないのではなく隠す——理由は syncKeyCardVisibility の項)。ところが
2376
2554
  .ops-card は display: flex、.ds-keyrow は flex アイテムを名乗るので、
@@ -2631,6 +2809,10 @@ ${sanitizeCapabilitiesNow.toString()}
2631
2809
  ${sanitizeGithubOwnersNow.toString()}
2632
2810
  ${splitNeededParts.toString()}
2633
2811
  ${ingestedAtOf.toString()}
2812
+ var TILE_CAPS = ${JSON.stringify(TILE_CAPS)};
2813
+ var BAD_STATE_STATUSES = ${JSON.stringify(BAD_STATE_STATUSES)};
2814
+ ${keyMissingText.toString()}
2815
+ ${deriveCapTile.toString()}
2634
2816
  // 「はじめに」セクションのチュートリアルデータ(ビルド時に埋め込み。ボタンURL・チャレンジ4の決定は解決済み)
2635
2817
  var TUTORIAL = (function () {
2636
2818
  var elx = document.getElementById("tutorial-data");
@@ -4075,6 +4257,15 @@ ${ingestedAtOf.toString()}
4075
4257
  var LIVE_REGISTERED = null;
4076
4258
  // 接続状態ストアの行(届くか・取り込めたか)。op=state の stateNow から
4077
4259
  var LIVE_STATE = null;
4260
+ /**
4261
+ * 応答に 「stateNow」 の欄があったか(=サーバがストアを読めたか)。
4262
+ *
4263
+ * 🔴 **LIVE_STATE が null かどうかでは代用できない。** sanitize は0件を null に畳むので、
4264
+ * 「読めて0件」と「そもそも読めていない」が同じ値になる。サーバは**読めたなら0件でも
4265
+ * 欄を付ける**ので、区別できるのはこの旗だけ——潰すと、初期化直後の案件
4266
+ * (=会議・共有資料の「未使用」表示が一番効いてほしい案件)でだけ機能が死ぬ。
4267
+ */
4268
+ var LIVE_STATE_SEEN = false;
4078
4269
  /**
4079
4270
  * 鍵がいま登録されているか(op=state の secretsNow・列2)。取得前・取得失敗時は null。
4080
4271
  *
@@ -4145,10 +4336,12 @@ ${ingestedAtOf.toString()}
4145
4336
  * 能力の状態(capabilities)。**能力ごとに、正本(capabilitiesNow)があればそちら。**
4146
4337
  *
4147
4338
  * ⚠ **ここは rawFleetVal の「キー単位でフォールバックしない」の意図的な例外**(工程1.5)。
4148
- * あちらの禁止は「同じ画面に2つの時点が混ざると、上に出す『◯◯時点』が嘘になる」ため
4149
- * だが、capabilitiesNow は**画面が開いた能力ぶんしか来ない**(OPEN_CAPS の項)ので、
4150
- * 丸ごと採るか丸ごと捨てるかしか無いと、**開いていない能力のタイルが全部「未接続」に
4151
- * 落ちる**。能力ごとに正本を優先し、来ていない能力はまとめ表のまま出す。
4339
+ * あちらの禁止は「同じ画面に2つの時点が混ざると、上に出す『◯◯時点』が嘘になる」ため。
4340
+ *
4341
+ * **0.12.0 で caps は常に全量になった**(ALL_STATE_CAPS の項)ので、応答には
4342
+ * 6能力ぶんが揃って届く。それでも能力ごとの合成は残す——**古い Lambda が一部の能力を
4343
+ * 返さない期間がある**からで、丸ごと採るか丸ごと捨てるかしか無いと、その期間に
4344
+ * 返らなかった能力のタイルが「未接続」に落ちる。
4152
4345
  */
4153
4346
  function capsView() {
4154
4347
  var base = fleetVal("capabilities") || {};
@@ -4278,6 +4471,53 @@ ${ingestedAtOf.toString()}
4278
4471
  };
4279
4472
  }
4280
4473
 
4474
+ /**
4475
+ * その能力の登録件数(規則1の材料)。**分からなければ null。**
4476
+ *
4477
+ * 🔴 **正本(registeredNow)だけを見る。** まとめ表(fleet-status.json)は夜間の
4478
+ * スナップショットで、外した直後も「1件」と言い続ける——#227 で「実際は0件なのに
4479
+ * 接続OK」を出していた当の情報源。ここでそちらへフォールバックすると、
4480
+ * この正本化が丸ごと無意味になる。
4481
+ *
4482
+ * **会議は null を返す。** 会議は登録の一覧を持たない(別名の書き換えは #117 で
4483
+ * 読み取り専用にした)ので、「0件」と言える材料が無い。会議の規則1にあたる判断
4484
+ * (自動取り込みが OFF)は 「capabilities」 の導出が答え、呼び出し側がこの関数より
4485
+ * 先に出す(deriveCapTile の項)。
4486
+ */
4487
+ function registeredCountOf(cap) {
4488
+ if (!LIVE_REGISTERED) return null;
4489
+ var meta = TILE_CAPS[cap];
4490
+ if (!meta) return null;
4491
+ var list = LIVE_REGISTERED[meta.id];
4492
+ return Array.isArray(list) ? list.length : null;
4493
+ }
4494
+
4495
+ /**
4496
+ * タイル・接続グラフの導出結果(0.12.0)。**タイルも図もここを通す。**
4497
+ *
4498
+ * 🔴 **呼び出し側で材料を組み立て直さないこと。** 同じ判定が2箇所に増えると、
4499
+ * 「図は接続OK・タイルは未接続」という #231 が殺したはずの食い違いが復活する。
4500
+ */
4501
+ function capDerivation(cap) {
4502
+ return deriveCapTile(cap, {
4503
+ registered: registeredCountOf(cap),
4504
+ seen: LIVE_STATE_SEEN,
4505
+ rows: LIVE_STATE,
4506
+ });
4507
+ }
4508
+
4509
+ /**
4510
+ * 設定そのものの導出(capabilities)が「on 以外」を答えているか。
4511
+ *
4512
+ * **答えているなら、その語が優先(#125②)。** OFF・設定に異常・未接続(未設置)は
4513
+ * 登録や実測より前に立つ——意図的に止めた能力に「登録済み(接続は未確認です)」と
4514
+ * 出すのは、設定する必要が無いものに設定を促すのと同じ誤り。
4515
+ */
4516
+ function capConfigState(cap) {
4517
+ var d = capabilityDisplayState(cap, capsView());
4518
+ return d && d !== "on" ? d : null;
4519
+ }
4520
+
4281
4521
  // ---------- 反映待ち(「⏳ 反映中」)----------
4282
4522
  //
4283
4523
  // ## 何のためか
@@ -5207,14 +5447,36 @@ ${ingestedAtOf.toString()}
5207
5447
  //
5208
5448
  // **「[]」(夜間バッチは走ったが対象0件)と 「undefined」(まだ走っていない)は別物。**
5209
5449
  // 後者に「まだありません」と書くと、実際には登録済みかもしれない案件に嘘を言う
5210
- var ownersNow = githubOwnersView();
5211
- if (Array.isArray(ownersNow) && ownersNow.length === 0) {
5212
- pushCap("開発", el("p", {
5450
+ //
5451
+ // 🔴 **行の一覧と同じ情報源で出し入れする(#232・0.12.0)。** 以前はこの1行を
5452
+ // 節を組み立てるときに1回だけ判定して固定していた。ところがリポジトリの追加は
5453
+ // **モーダルを開いたまま**行われ、行だけが liveList で増える——結果、実機では
5454
+ // **「classmethod-internal/cortex-test-repo」の行と「まだありません」が同時に**出た。
5455
+ //
5456
+ // liveList に載せると、行と同じ署名で描き直される=行が増えた瞬間に消える。
5457
+ // Backlog(backlogTargetsSection の emptyEl 出し入れ)で先に潰した形の横展開で、
5458
+ // **兄弟機能へ展開し忘れていた**(#226・#220 と同じ型の漏れ)。
5459
+ //
5460
+ // **置き場所は追加フォームの後のまま。** 文言が「上のフォームで登録すると」と
5461
+ // 言っているので、フォームより前に出すと指す先が無くなる
5462
+ //
5463
+ // **箱を置いてよいのは、そこへ足せる案件だけ**(liveListWanted の項)。投入の口が
5464
+ // 無い構成に「上のフォームで登録すると」と書いても、指す先のフォームが無い
5465
+ if (liveListWanted("github")) pushCap("開発", liveList("開発", function () {
5466
+ // 判定に使う値そのものを署名にする(行の増減と、夜間の組の一覧の両方)
5467
+ return JSON.stringify([liveExternals("github").length, githubOwnersView() || null]);
5468
+ }, function (box) {
5469
+ var ownersNow = githubOwnersView();
5470
+ // **行が1本でもあれば言わない。** 「行がある」と「まだありません」を
5471
+ // 同じ画面で同時に言わせないための、いちばん外側の条件
5472
+ if (liveExternals("github").length) return;
5473
+ if (!Array.isArray(ownersNow) || ownersNow.length !== 0) return;
5474
+ box.appendChild(el("p", {
5213
5475
  class: "sec-how ds-all-connected",
5214
5476
  text: "読み取り対象の開発リポジトリがまだありません。上のフォームで登録すると、"
5215
5477
  + "読めているかどうかを翌朝からここに出します。",
5216
5478
  }));
5217
- }
5479
+ }));
5218
5480
 
5219
5481
  // 連携の鍵は、その能力のところへ置く(画面下まで往復させない)
5220
5482
  // **GitHub(「external-sources」)はここに無い。** 読み取りトークンは廃止し、
@@ -5272,7 +5534,11 @@ ${ingestedAtOf.toString()}
5272
5534
  return;
5273
5535
  }
5274
5536
  if (k === "figma" && KEY_SLOTS.figma) {
5275
- KEY_SLOTS.figma.appendChild(secretCard(k, SECRET_FORMS[k], { inRow: true }));
5537
+ KEY_SLOTS.figma.appendChild(secretCard(k, SECRET_FORMS[k], {
5538
+ inRow: true,
5539
+ // Backlog(スペースごと)との違いを、見た目だけでなく言葉でも出す(#229)
5540
+ scopeNote: "この鍵は、上に並んでいる登録済みの全ファイルに共通です(ファイルごとの鍵ではありません)。",
5541
+ }));
5276
5542
  return;
5277
5543
  }
5278
5544
  pushCap(SECRET_CAP[k], secretCard(k, SECRET_FORMS[k]));
@@ -5364,16 +5630,35 @@ ${ingestedAtOf.toString()}
5364
5630
  * 節を作り直せない場面(モーダルを開いたまま)でも、**届いた一覧で
5365
5631
  * 引き直す**ために同じ関数を使う——写経すると片方だけ古くなる
5366
5632
  */
5367
- function tileSub(cap, st, derived, adapter, ins, srcs) {
5633
+ /**
5634
+ * @param derive 正本からの導出(deriveCapTile の答え)。**呼び出し側が渡す**
5635
+ * ——ここで引き直すと、タイルの色(st)を決めた材料と2回引くことになり、
5636
+ * 片方だけずれる余地ができる。null なら規則5=従来表示へフォールバック
5637
+ */
5638
+ function tileSub(cap, st, derived, adapter, ins, srcs, derive) {
5368
5639
  var internal = ins.filter(function (s) { return s.kind === cap; })[0];
5369
5640
  var live = srcs.filter(function (s) { return externalCapability(s.type) === cap; });
5370
5641
  var parts = [];
5371
5642
  // **導出が on 以外なら、導出の語を優先する(#125②)。** broken は状態としては
5372
5643
  // 警告(⚠️)だが、原因は「設定そのものが壊れている」であって同期の失敗ではない。
5373
5644
  // 下の warn の文(同期が失敗しています/N件が読めていません)を出すと、
5374
- // 直す場所が違うところを指す
5645
+ // 直す場所が違うところを指す。
5646
+ //
5647
+ // 🔴 **ここが正本からの導出(下)より先。** 意図的に OFF にした能力に
5648
+ // 「登録済み(接続は未確認です)」と出さないため(deriveCapTile の項)
5375
5649
  if (derived && derived !== "on") {
5376
5650
  parts.push((CAP_STATE_MARK[st] || "") + (CAP_STATE_TEXT[derived] || "状態が判定できません"));
5651
+ } else if (derive) {
5652
+ // 🔴 **正本(登録・実測)からの導出(0.12.0・#231 / #235 / #227)。**
5653
+ // 「設定が済んでいる」ことと「実際に動いている」ことは別。設定だけを見て
5654
+ // 「● 接続OK」と出すと、**画面が動いていないものを動いていると言う**。
5655
+ //
5656
+ // **ライブ参照の内訳がある警告だけは、そちらの文を使う。** attentionSummary は
5657
+ // 社内/顧客の出し分けを持っていて、グループの見出しと同じ文になる
5658
+ // (写経すると片方だけ古くなる)。内訳が空なら導出の一言に倒す——
5659
+ // 以前の「⚠️ 0件に問題」は、何が起きているのか読めない文だった
5660
+ var summary = derive.state === "warn" && live.length ? attentionSummary(live) : "";
5661
+ parts.push(summary || (CAP_STATE_MARK[derive.state] || "") + derive.text);
5377
5662
  } else if (st === "warn") {
5378
5663
  if (live.length) {
5379
5664
  // **文面は共有の attentionSummary が持つ。** グループの summary と同じ文で、
@@ -5489,6 +5774,16 @@ ${ingestedAtOf.toString()}
5489
5774
  // カタログにあるが導出が答えていないアダプタ(今日は発生しない。#107 の前方互換)
5490
5775
  ? "idle"
5491
5776
  : capabilityState(cap, stateCtx());
5777
+ // 🔴 **状態の正本は「登録・実測からの導出」(0.12.0・#231 / #235 / #227)。**
5778
+ // 設定が済んでいること(capabilities が on)と、実際に動いていることは別。
5779
+ // 設定だけを見て「● 接続OK」と出すと、**画面が動いていないものを動いていると言う**
5780
+ // ——顧客がそれを信じて資料を置かなくなる/失敗に気づけない。
5781
+ //
5782
+ // **導出が答えないとき(規則5・古い Lambda)だけ、従来の fleet 判定のまま。**
5783
+ // **設定が on 以外のときは、そちらが先に立つ**(tileSub の順序と揃える——
5784
+ // 揃えないと「色は接続OK・文字はOFF」が同じタイルの中で並ぶ)
5785
+ var derive = derived && derived !== "on" ? null : capDerivation(cap);
5786
+ if (derive) st = derive.state;
5492
5787
  // **ロゴはタイルの左に独立して置く。** 名前の行に入れると名前の高さに
5493
5788
  // 縛られて小さくなる。ロゴで能力を見分けられるのが一覧の主な用途
5494
5789
  var logo = ADAPTER_ICONS[adapter];
@@ -5503,10 +5798,15 @@ ${ingestedAtOf.toString()}
5503
5798
  // ——抑制しないと、同じタイルの中で「⏳ 反映中」と「⦿ 未接続」が並んで矛盾する。
5504
5799
  // **抑制するのは unset のときだけ**(off / broken は残す。R4 と同じ理由)
5505
5800
  // まとめ表がまだ知らない行がある能力は、**行と同じ語**に倒す(未接続とも接続OKとも言わない)
5506
- var pend = capPending(cap);
5801
+ // 🔴 **導出が答えているなら、「登録状況を確認中です」より導出が勝つ(0.12.0)。**
5802
+ // あの語は「まとめ表しか材料が無く、登録の現在値が分からない」ときの折衷だった。
5803
+ // 正本(registeredNow)を読むようになった今は分かる——外した直後は「未接続」、
5804
+ // 足した直後は「登録済み(まだ取り込みがありません)」と、**何が起きたかを言える**。
5805
+ // ここを逆にすると、まとめ表が追いつく翌朝まで「確認中です」が居座る
5806
+ var pend = capPending(cap) && !derive;
5507
5807
  var subEl = el("div", {
5508
5808
  class: "ds-tile-sub " + (pend ? DS_GATE_PENDING.cls : "ds-gate-" + st),
5509
- text: pend ? DS_GATE_PENDING.text : tileSub(cap, st, derived, adapter, internals, sources),
5809
+ text: pend ? DS_GATE_PENDING.text : tileSub(cap, st, derived, adapter, internals, sources, derive),
5510
5810
  });
5511
5811
  // **反映が届いたら、届いたスナップショットから引き直す。** 下地へ戻すと
5512
5812
  // 「⏳ 反映中」→「⦿ 未接続」になる——データが正しくなったあとに嘘をつく。
@@ -5519,10 +5819,14 @@ ${ingestedAtOf.toString()}
5519
5819
  var stNow = dNow === "unset" && capabilityDisplayState(cap, capsNow) !== "unset"
5520
5820
  ? "idle"
5521
5821
  : capabilityState(cap, stateCtx());
5522
- var pendNow = capPending(cap);
5822
+ // 正本からの導出も、タイルを作るときと同じ式で引き直す(0.12.0)。
5823
+ // ここを省くと、反映が届いた瞬間だけ「● 接続OK」へ戻る
5824
+ var deriveNow = dNow && dNow !== "on" ? null : capDerivation(cap);
5825
+ if (deriveNow) stNow = deriveNow.state;
5826
+ var pendNow = capPending(cap) && !deriveNow;
5523
5827
  n.className = "ds-tile-sub " + (pendNow ? DS_GATE_PENDING.cls : "ds-gate-" + stNow);
5524
5828
  return {
5525
- text: pendNow ? DS_GATE_PENDING.text : tileSub(cap, stNow, dNow, adapter, insNow, srcsNow),
5829
+ text: pendNow ? DS_GATE_PENDING.text : tileSub(cap, stNow, dNow, adapter, insNow, srcsNow, deriveNow),
5526
5830
  hidden: false,
5527
5831
  };
5528
5832
  });
@@ -5561,7 +5865,15 @@ ${ingestedAtOf.toString()}
5561
5865
  // 0.4.70 は unset で入力欄を畳んでいたので注記も抑えていたが、案A で欄を出すようにしたため揃えた
5562
5866
  // 導出が無いデータでは判断材料が無いので、注記を出す側(=入力欄と揃う側)に倒す
5563
5867
  // 接続先0件の抑止は入力欄と**同じ関数**を通す(keyTargetCount の 🔴 不変条件)
5564
- var tokenRelevant = (derived != null ? derived !== "off" : true) && keyTargetCount(cap) > 0;
5868
+ //
5869
+ // 🔴 **状態の行が既に鍵の不足を言っているなら、ここでは言わない(0.12.0・#225)。**
5870
+ // 取り込みが skipped(鍵が無くて飛ばした)の能力は、2行目が
5871
+ // 「⚠️ APIキーが未登録です」になる。その下にもう一度「⚠ トークンが未設定です」を
5872
+ // 積むと、**同じ1枚のタイルが同じことを2回言う**——#227 が潰した「別々の情報源が
5873
+ // 整合を取らずに1つのタイルに流れ込む」形そのもの
5874
+ var tokenRelevant = (derived != null ? derived !== "off" : true)
5875
+ && keyTargetCount(cap) > 0
5876
+ && !(derive && derive.keyMissing);
5565
5877
  if (tokenRelevant && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !secretPresentForTile(sk)) {
5566
5878
  body.appendChild(el("div", { class: "ds-tile-note", text: "⚠ トークンが未設定です" }));
5567
5879
  }
@@ -5926,7 +6238,9 @@ ${ingestedAtOf.toString()}
5926
6238
  btn.addEventListener("click", async function () {
5927
6239
  var intake = SITE.intake;
5928
6240
  if (!intake) return;
5929
- btn.disabled = true;
6241
+ // 押している間はボタンごと「確認中…」にする(#222・beginBusy の項)。
6242
+ // **語は下の ds-toggle-msg と揃える**——同じ操作を2つの語で言わない
6243
+ var endBusy = beginBusy(btn, "確認中…");
5930
6244
  // **押されて初めて枠を出す(2026-08-20・オーナー指摘)。**
5931
6245
  // それまでは中身が空でも点線だけが全行に出ており、**何も意味していなかった**。
5932
6246
  // 押した結果があるときだけ、行と結果を分ける線になる
@@ -5951,7 +6265,7 @@ ${ingestedAtOf.toString()}
5951
6265
  // サーバの文言をそのまま出す(回数制限の案内・未対応のアダプタの説明を含んでいる)
5952
6266
  msg.className = "ds-toggle-msg is-error";
5953
6267
  msg.textContent = "✗ " + (j.message || "確認できませんでした。");
5954
- btn.disabled = false;
6268
+ endBusy();
5955
6269
  return;
5956
6270
  }
5957
6271
  msg.className = "ds-toggle-msg" + (j.gate === "ok" ? " is-ok" : " is-error");
@@ -5989,7 +6303,7 @@ ${ingestedAtOf.toString()}
5989
6303
  msg.className = "ds-toggle-msg is-error";
5990
6304
  msg.textContent = "✗ 通信に失敗しました。";
5991
6305
  }
5992
- btn.disabled = false;
6306
+ endBusy();
5993
6307
  });
5994
6308
  return { btn: btn, detail: wrap };
5995
6309
  }
@@ -6202,13 +6516,17 @@ ${ingestedAtOf.toString()}
6202
6516
  * **「最終同期」(.ds-sync)を置き換えない。** あちらは run 全体の最終成功時刻で、
6203
6517
  * どの接続先のことか分からない。こちらは接続先1件の成否。意味が違うので併記する。
6204
6518
  */
6205
- function ingestedEl(r) {
6519
+ function ingestedEl(r, cap) {
6206
6520
  if (!r) return null;
6207
6521
  var when = rtAgo(r.at);
6208
6522
  var text = "";
6209
6523
  if (r.status === "ok") text = when ? "取り込み: " + when + "に成功" : "取り込み: 成功";
6210
6524
  else if (r.status === "failed") text = when ? "取り込み: " + when + "に失敗" : "取り込み: 失敗";
6211
- else if (r.status === "skipped") text = "取り込み: スキップ";
6525
+ // 🔴 **「スキップ」で終わらせない(#225)。** 鍵が無くて飛ばしたのが skipped の実体で、
6526
+ // 「スキップ」とだけ書かれても**次に何をすればいいのか読み取れない**。しかも
6527
+ // 「失敗」と並べて置くと、原因を調べに行く側へ人を送ってしまう(直すのは鍵の登録)。
6528
+ // 語は鍵カードの名前と揃える(keyMissingText の項)
6529
+ else if (r.status === "skipped") text = "取り込み: 未実行(" + keyMissingText(cap) + ")";
6212
6530
  if (!text) return null;
6213
6531
  return el("span", {
6214
6532
  class: "ds-ingest" + (r.status === "failed" ? " ds-ingest-failed" : ""),
@@ -6333,7 +6651,7 @@ ${ingestedAtOf.toString()}
6333
6651
  dsNameEl(t.domain + " / " + t.projectKey, "https://" + t.domain + "/projects/" + t.projectKey),
6334
6652
  ]),
6335
6653
  // この接続先の取り込みが最後にどうなったか(段C)。測っていなければ null
6336
- ingestedEl(ingestedStateOf(t.domain, t.projectKey)),
6654
+ ingestedEl(ingestedStateOf(t.domain, t.projectKey), "課題管理"),
6337
6655
  ]);
6338
6656
  if (!editable) return row;
6339
6657
  var btn = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: "この接続先を外す" });
@@ -6393,12 +6711,18 @@ ${ingestedAtOf.toString()}
6393
6711
  // 呼ばれるので、作り直すと**接続先を1つ足しただけで入力中の鍵と「保存中…」が消える**
6394
6712
  //(ライブ差し替えで踏んだのと同じ事故が、別の経路で復活する)。
6395
6713
  // appendChild は既存ノードを移すだけなので、入力値もイベントも保たれる
6396
- for (var d in keySlots) {
6397
- if (!Object.prototype.hasOwnProperty.call(keySlots, d)) continue;
6714
+ //
6715
+ // 🔴 **見るのは正本(KEY_SLOTS.backlog)。節の中の控えを持たない(#223)。**
6716
+ // 応答で新しいスペースが現れたら受け皿はその場で足される(ensureKeySlots)ので、
6717
+ // 節を作ったときの控えを見ていると**足した受け皿がここへ来ない**=
6718
+ // 鍵カードが行の中に入らないまま宙に浮く
6719
+ var slots = KEY_SLOTS.backlog || {};
6720
+ for (var d in slots) {
6721
+ if (!Object.prototype.hasOwnProperty.call(slots, d)) continue;
6398
6722
  if (!d) continue; // 接続先が無い案件の受け皿は box 側に置いてある(行が無い)
6399
6723
  var host = lastRowOf[d];
6400
- if (host) host.appendChild(keySlots[d]);
6401
- else if (keySlots[d].parentNode) keySlots[d].parentNode.removeChild(keySlots[d]);
6724
+ if (host) host.appendChild(slots[d]);
6725
+ else if (slots[d].parentNode) slots[d].parentNode.removeChild(slots[d]);
6402
6726
  }
6403
6727
  // **0件を黙って見せない。** 表示が無いと「まだ何も無い」のか「出し忘れ」なのか
6404
6728
  // 区別できない。**一覧の外**(Figma / Drive と同じ)に、件数に応じて出し入れする
@@ -6433,10 +6757,75 @@ ${ingestedAtOf.toString()}
6433
6757
  // **鍵の欄の判断もこの一覧に従わせる(2026-08-22)。** keyTargetCount はまとめ表を
6434
6758
  // 見るので、ここで教えないと「行はその場で出るのに鍵の欄だけ次の取り直しまで出ない」
6435
6759
  ADOPTED_TARGET_COUNT = targets.length;
6760
+ // 🔴 **新しいスペースの鍵の欄を、その場で足す(#223)。** paint() より先に呼ぶ
6761
+ //(paint は在る受け皿を行へ入れるだけで、無い受け皿は作らない)
6762
+ ensureKeySlots();
6436
6763
  paint();
6437
6764
  return targetsGenerationOf(targets) !== before;
6438
6765
  }
6439
6766
 
6767
+ /**
6768
+ * 応答や取り直しで**新しいスペースが現れたら**、その分の受け皿と鍵カードを足す(#223)。
6769
+ *
6770
+ * ## 直している欠陥
6771
+ *
6772
+ * 受け皿は「モーダルを開いたその瞬間の接続先の集合」から1回だけ作られていた。
6773
+ * その後モーダルを開いたまま別のスペース(例: beams-dev.backlog.com)の接続先を
6774
+ * 足すと、**行は増えるのに鍵を入れる欄がどこにも現れない**——しかも画面は
6775
+ * 「保存しました」と言うので、成功したようにしか見えない。フルリロードするまで
6776
+ * 気づけなかった(#223・受け入れテストで実際に踏んだ)。
6777
+ *
6778
+ * ## 足すだけにする理由
6779
+ *
6780
+ * 単純な「全部作り直す」に戻すと、**入力中の鍵と「保存中…」が消える**——
6781
+ * その再発を防ぐために「作り直さない」と決めたのが元の実装(paint の項)。
6782
+ * だから**既存スペースの受け皿とカードには一切触らない**。
6783
+ *
6784
+ * ⚠ **既存カードの spaceCount は古いままになる。** 1スペースだった案件に2つ目が
6785
+ * 増えると、先にあったカードは「1スペースぶんの答え方」(種別ごとの登録状況)を
6786
+ * 続ける。作り直せば揃うが、それは入力中の値を捨てるということ——**モーダルを
6787
+ * 開き直せば揃う**ほうを取る(登録状況の表示が少し弱くなるだけで、嘘は出ない)。
6788
+ */
6789
+ function ensureKeySlots() {
6790
+ if (!editable) return;
6791
+ // 受け皿を作る経路を通っていない案件(intake が無い・固定IP同期)はここも通さない
6792
+ if (!KEY_SLOTS.backlog || !Object.keys(KEY_SLOTS.backlog).length) return;
6793
+ if ((SITE.secretKinds || []).indexOf("backlog") < 0 || !SECRET_FORMS["backlog"]) return;
6794
+ var domains = [];
6795
+ for (var i = 0; i < targets.length; i++) {
6796
+ var dm = String(targets[i].domain || "").toLowerCase();
6797
+ if (dm && domains.indexOf(dm) === -1) domains.push(dm);
6798
+ }
6799
+ // **0件の案件の受け皿(空文字キー)は、1スペースぶんまでは役目を果たしている。**
6800
+ // あれは「どのスペースとも結び付いていない」欄で、サーバは名簿の値に倒す
6801
+ // ——接続先が1スペースなら、それで正しい先に入る
6802
+ if (KEY_SLOTS.backlog[""] && domains.length <= 1) return;
6803
+ var added = false;
6804
+ for (var di = 0; di < domains.length; di++) {
6805
+ var dom = domains[di];
6806
+ if (KEY_SLOTS.backlog[dom]) continue;
6807
+ var slot = el("div", { class: "ds-keyrow" });
6808
+ slot.appendChild(secretCard("backlog", SECRET_FORMS["backlog"], {
6809
+ // 名乗ってよいのは名簿由来の行だけ(作るときと同じ判断・KEY_SLOTS.backlogNamed)
6810
+ space: KEY_SLOTS.backlogNamed ? dom : "",
6811
+ stateSpace: dom,
6812
+ spaceCount: domains.length,
6813
+ inRow: true,
6814
+ }));
6815
+ KEY_SLOTS.backlog[dom] = slot;
6816
+ added = true;
6817
+ }
6818
+ if (!added) return;
6819
+ // **名無しの受け皿は役目を終える。** スペースを名乗る欄と名乗らない欄が同時に出ると、
6820
+ // どちらに貼れば効くのかが読めない(名乗らない欄は名簿の1スペースに入る)
6821
+ var blank = KEY_SLOTS.backlog[""];
6822
+ if (blank) {
6823
+ if (blank.parentNode) blank.parentNode.removeChild(blank);
6824
+ delete KEY_SLOTS.backlog[""];
6825
+ }
6826
+ syncKeyCardVisibility();
6827
+ }
6828
+
6440
6829
  function targetsHooks() {
6441
6830
  return { onTargets: adoptTargets, staleNote: TARGETS_RACE_NOTE };
6442
6831
  }
@@ -6450,7 +6839,6 @@ ${ingestedAtOf.toString()}
6450
6839
  // **この画面から投入できない案件(固定IP同期)には出さない。** 押しても Lambda が
6451
6840
  // 「固定IP経由のため対応していません」で断るので、導線そのものを作らない。
6452
6841
  // 判定は鍵カードを出すループの条件と同じ線(SITE.secretKinds に backlog があるか)。
6453
- var keySlots = {};
6454
6842
  if (editable && !Object.keys(KEY_SLOTS.backlog).length) {
6455
6843
  // 保存時にスペースを名乗ってよいのは、名簿(fleet の backlogTarget)由来の行だけ。
6456
6844
  // 同期ミラーのURLから作った行で名乗ると、名簿とミラーがずれている案件で
@@ -6470,8 +6858,8 @@ ${ingestedAtOf.toString()}
6470
6858
  //(足した接続先のドメインに対応するスロットは無いので、鍵の欄はこの受け皿に
6471
6859
  // 残ったまま一覧の下に現れる。paint() はこのキーを移動の対象から外している)
6472
6860
  if (!domains.length) domains.push("");
6473
- for (var di = 0; di < domains.length; di++) keySlots[domains[di]] = el("div", { class: "ds-keyrow" });
6474
- KEY_SLOTS.backlog = keySlots;
6861
+ // **正本へ直接入れる**(節の中に控えを持たない・paint / ensureKeySlots の項)
6862
+ for (var di = 0; di < domains.length; di++) KEY_SLOTS.backlog[domains[di]] = el("div", { class: "ds-keyrow" });
6475
6863
  }
6476
6864
 
6477
6865
  // **box を先に作る。** paint() は 0件の1行を box へ出し入れするので、
@@ -6498,10 +6886,12 @@ ${ingestedAtOf.toString()}
6498
6886
  if (targetsGenerationOf(next) === targetsGenerationOf(targets) && sig === lastIngestSig) return;
6499
6887
  targets = next;
6500
6888
  lastIngestSig = sig;
6889
+ // 取り直しで新しいスペースが届くこともある(他の人が足した場合・#223)
6890
+ ensureKeySlots();
6501
6891
  paint();
6502
6892
  };
6503
6893
  // 接続先がまだ無い案件の受け皿は、0件の1行の直後に置く(行が無いので中へ入れられない)
6504
- if (keySlots[""]) box.appendChild(keySlots[""]);
6894
+ if (KEY_SLOTS.backlog[""]) box.appendChild(KEY_SLOTS.backlog[""]);
6505
6895
  if (editable) {
6506
6896
  box.appendChild(addForm({
6507
6897
  // **「追加」に戻す(#127)。** 1件しか持てなかった頃は「保存」(置き換え)だったが、
@@ -6793,9 +7183,24 @@ ${ingestedAtOf.toString()}
6793
7183
  * 据え置き、他の行の取り込み表示は普段どおり追いつかせる(removeButton の onBusy)。
6794
7184
  */
6795
7185
  var figSending = {};
7186
+ // **ファイル一覧と鍵カードを1つの枠に括る(#229・オーナー指摘)。**
7187
+ //
7188
+ // Figma の鍵は**案件に1本**で全ファイル共通なので、特定のファイルの行の中には
7189
+ // 置けない(「このファイル専用」という嘘の見た目になる)。その結果、鍵カードが
7190
+ // レコードの外に浮いて見え、**Backlog(行の中に鍵がある)と見比べると
7191
+ // 「入れ損ねた」ようにしか見えなかった**。
7192
+ //
7193
+ // 枠で括ると「この枠の中のもの全部に効く鍵」が図として読める。文言の側も
7194
+ // 鍵カードの scopeNote が同じことを言う——**見た目と言葉の両方**で、
7195
+ // Backlog とのスコープの違いが伝わるようにする(片方だけだと読み落とされる)。
7196
+ // **見出しは付けない。** 接続先が0件のあいだ鍵カードは畳まれる(syncKeyCardVisibility)
7197
+ // ので、「…と、共通の鍵」のような見出しは**鍵が出ていないときに嘘になる**。
7198
+ // 枠そのものと、鍵カードの中の scopeNote が意味を運ぶ
7199
+ var figGroup = el("div", { class: "ds-scope-group" });
7200
+ box.appendChild(figGroup);
6796
7201
  // **署名に取り込みの状態も混ぜる(段C第二弾)。** 一覧のJSONだけだと、応答が返る前に
6797
7202
  // モーダルを開いた人には取り込みが1件も出ない(designIngestSigOf の項に経緯)
6798
- box.appendChild(liveList("デザイン", function () {
7203
+ figGroup.appendChild(liveList("デザイン", function () {
6799
7204
  return JSON.stringify(figsOf()) + "|" + designIngestSigOf(figsOf());
6800
7205
  }, function (listBox) {
6801
7206
  var figs = figsOf();
@@ -6824,7 +7229,7 @@ ${ingestedAtOf.toString()}
6824
7229
  dsNameEl(f.name || f.key, "https://www.figma.com/design/" + f.key),
6825
7230
  ]),
6826
7231
  // このファイルの取り込みが最後にどうなったか(段C)。測っていなければ null
6827
- ingestedEl(designIngestedStateOf(f.key)),
7232
+ ingestedEl(designIngestedStateOf(f.key), "デザイン"),
6828
7233
  ]);
6829
7234
  row.appendChild(removeButton({
6830
7235
  label: "このファイルを外す",
@@ -6881,7 +7286,8 @@ ${ingestedAtOf.toString()}
6881
7286
  //(intake / OFF のゲートをループ側に残すため。KEY_SLOTS のコメント参照)
6882
7287
  if (!KEY_SLOTS.figma) {
6883
7288
  KEY_SLOTS.figma = el("div", { class: "ds-keyrow ds-keyrow-list" });
6884
- box.appendChild(KEY_SLOTS.figma);
7289
+ // **枠の中に入れる**(一覧と同じ枠=この鍵が効く範囲・#229)
7290
+ figGroup.appendChild(KEY_SLOTS.figma);
6885
7291
  }
6886
7292
  box.appendChild(addForm({
6887
7293
  title: "+ Figmaファイルを追加",
@@ -7024,6 +7430,48 @@ ${ingestedAtOf.toString()}
7024
7430
  // 確認 → 送信 → 結果表示、までを1箇所に置く。操作ごとに書き写すと、
7025
7431
  // 片方だけ直したときにずれる(トグル・削除・追加のどれも同じ経路を通す)。
7026
7432
 
7433
+ /**
7434
+ * 送信中のボタンの見た目(#222)。**押せたかどうかが操作中に分かるようにする。**
7435
+ *
7436
+ * ## なぜ disabled だけでは足りなかったのか
7437
+ *
7438
+ * 送信系はどれも btn.disabled = true はしていた。ところが受け入れテストで
7439
+ * 「この接続先を外す」を押したとき、**画面に何の変化も起きなかった**——disabled
7440
+ * の見た目の差が薄く(opacity .6)、しかも押した直後に視線が行くのはボタン自身。
7441
+ * 「処理が本当に走っているのか、二重クリックしてよいのか」が分からない。
7442
+ *
7443
+ * **ボタンの語そのものを「処理中…」に変える。** 状態表示(ds-toggle-msg)は
7444
+ * ボタンの隣にあって視線の外に出やすいので、**押した場所が変わる**ことが要る。
7445
+ *
7446
+ * ## 3経路で同じ実装を通す
7447
+ *
7448
+ * 送信系は sendConfig(追加・削除・トグル)/secretCard(鍵の保存)/
7449
+ * probeSection(接続確認)の3つ。**同じ見た目を3回書かない**——今後アダプタが
7450
+ * 増えても、この関数を通す限り見た目が揃う(#222 の「共通コンポーネント化」)。
7451
+ *
7452
+ * ⚠ **スケルトンまでは作らない。** 影響を受ける行を骨組み表示にするのは DOM パッチの
7453
+ * 範囲を超える(行の作り直しは入力中の鍵と「保存中…」を壊してきた当のもの)。
7454
+ * 「押せたか分からない」はボタンの無効化+語の差し替えで死ぬ。
7455
+ *
7456
+ * @returns 元へ戻す関数。**何度呼んでも1回しか効かない**(finally と成功パスの
7457
+ * 両方から呼べるようにするため)。引数に語を渡すと、その語で戻す
7458
+ */
7459
+ function beginBusy(btn, label) {
7460
+ if (!btn) return function () {};
7461
+ var before = btn.textContent;
7462
+ btn.disabled = true;
7463
+ btn.classList.add("is-busy");
7464
+ btn.textContent = label || "処理中…";
7465
+ var used = false;
7466
+ return function endBusy(nextLabel) {
7467
+ if (used) return;
7468
+ used = true;
7469
+ btn.disabled = false;
7470
+ btn.classList.remove("is-busy");
7471
+ btn.textContent = nextLabel == null ? before : nextLabel;
7472
+ };
7473
+ }
7474
+
7027
7475
  /**
7028
7476
  * サーバへ設定変更を送る。**一覧は書き換えない**
7029
7477
  * (接続状況は夜間バッチのスナップショットなので、「もう効いている」と誤解させないため)。
@@ -7040,7 +7488,8 @@ ${ingestedAtOf.toString()}
7040
7488
  var intake = SITE.intake;
7041
7489
  if (!intake) return;
7042
7490
  var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
7043
- btn.disabled = true;
7491
+ // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)
7492
+ var endBusy = beginBusy(btn);
7044
7493
  msg.className = "ds-toggle-msg";
7045
7494
  msg.textContent = "保存中…";
7046
7495
  try {
@@ -7080,7 +7529,8 @@ ${ingestedAtOf.toString()}
7080
7529
  if (canConfirm) {
7081
7530
  // **ボタンは必ず先に戻す。** 確認の前に戻しておかないと、「いいえ」を押した人の
7082
7531
  // フォームが押せないまま残り、再読み込みするまで追加できなくなる
7083
- btn.disabled = false;
7532
+ // (語も戻す——確認ダイアログの裏で「処理中…」のままにしない)
7533
+ endBusy();
7084
7534
  msg.className = "ds-toggle-msg";
7085
7535
  msg.textContent = "";
7086
7536
  if (await hooks.onDuplicate()) {
@@ -7101,7 +7551,7 @@ ${ingestedAtOf.toString()}
7101
7551
  // 画面は既に追いついているので、済んでいることをやらせる案内になる(#127)
7102
7552
  msg.className = "ds-toggle-msg is-error";
7103
7553
  msg.textContent = "✗ " + ((res.status === 409 && raced && hooks.staleNote) || j.message || "保存できませんでした。");
7104
- btn.disabled = false;
7554
+ endBusy();
7105
7555
  return;
7106
7556
  }
7107
7557
  msg.className = "ds-toggle-msg is-ok";
@@ -7122,7 +7572,9 @@ ${ingestedAtOf.toString()}
7122
7572
  msg.appendChild(document.createTextNode(" "));
7123
7573
  msg.appendChild(el("a", { href: j.refreshUrl, target: "_blank", rel: "noopener", text: "反映の状況 →" }));
7124
7574
  }
7125
- btn.disabled = false;
7575
+ // 🔴 **語を戻すのは onDone より先。** removeButton の onDone は押せない状態へ
7576
+ // 戻す(外した行のボタン)ので、後に置くと「処理中…」の語が居座る
7577
+ endBusy();
7126
7578
  // **反映待ちとして記録する。** 一覧はスナップショットから描かれるので、
7127
7579
  // 保存できていても数分は「未設定」と出る。その間だけ「⏳ 反映中」に差し替える。
7128
7580
  //
@@ -7137,7 +7589,7 @@ ${ingestedAtOf.toString()}
7137
7589
  // 描かれるので、保存しただけでは行が出ない。ここで読み直すと、登録ファイルの中身が
7138
7590
  // 返ってきて**足りない行として一覧に出る**——さらに「まとめ表がまだ答えていない行」が
7139
7591
  // できるので、まとめ表が追いつくまで取り直しが続く。
7140
- // 取得中なら何もしない(応答後に catchUpOpenCaps が拾う)
7592
+ // 取得中なら freshWanted の旗だけ立てて戻る(応答後にもう一度取りに行く)
7141
7593
  if (j.changed !== false) loadLiveFleet(true);
7142
7594
  // **何も変わっていないなら「追加した」と見せない。** 既に同じものが入っていた場合
7143
7595
  // サーバは 200 + changed:false を返す。ここを res.ok だけで判定すると
@@ -7146,7 +7598,7 @@ ${ingestedAtOf.toString()}
7146
7598
  } catch (e) {
7147
7599
  msg.className = "ds-toggle-msg is-error";
7148
7600
  msg.textContent = "✗ 通信に失敗しました。";
7149
- btn.disabled = false;
7601
+ endBusy();
7150
7602
  }
7151
7603
  }
7152
7604
 
@@ -7863,15 +8315,37 @@ ${ingestedAtOf.toString()}
7863
8315
  */
7864
8316
  var liveFleetRequested = false;
7865
8317
  /**
7866
- * この画面で開いた能力(op=state に送る識別子)。
8318
+ * op=state に送る能力。**常に全能力**(0.12.0・#231 の正本化)。
8319
+ *
8320
+ * ## なぜ「開いた能力だけ」をやめたのか
7867
8321
  *
7868
- * 🔴 **開いている能力の分だけ読む。** Lambda は指定された能力の登録ファイルだけを
7869
- * 読む(ツリー1本+ファイルの本数)。全能力を毎回読ませると GitHub の枠を削る。
8322
+ * 応答は**丸ごと置き換わる**(LIVE_REGISTERED / LIVE_CAPS)。開いた能力だけを
8323
+ * 要求していると、**モーダルを1つ開いた瞬間にその能力だけの caps でリクエストが飛び、
8324
+ * 他5能力が応答から欠落する**——タイルの導出は材料が無ければ規則5(fleet への
8325
+ * フォールバック)へ落ちるので、**開いていない5能力のタイルが一斉に退行する**。
8326
+ * 「初回だけ全量・以降は開いた能力のみ」の2段構えでも、置き換えが起きる限り同じ穴が開く。
7870
8327
  *
7871
- * **一度開いたら覚えておく(閉じても外さない)。** 外すと、次の取り直しで
7872
- * その能力の行が応答から消え、**タイルの件数だけが行ったり来たりする**。
8328
+ * **常に全量なら応答は常に完全**で、置き換えのままで正しい。
8329
+ *
8330
+ * ## GitHub の枠は問題にならない
8331
+ *
8332
+ * op=state が飛ぶのは**ページを開いたとき・モーダルを開いたとき・反映待ちが残って
8333
+ * いる間の取り直し**だけで、恒常ポーリングは存在しない(stateTick は settle で止まる)。
8334
+ * しかも Lambda は 「projectKey + caps集合」 でキャッシュ(60秒)するので、**全ビューアが
8335
+ * 同じキーを共有する**——能力ごとに違うキーを作っていた頃より、むしろ当たりやすい。
8336
+ *
8337
+ * ⚠ **並べ替えてから送る。** サーバのキャッシュキーは caps を並べ替えて繋ぐが、
8338
+ * こちらも定数の並びを固定しておく(同じ集合が何通りにも増えない)。
8339
+ */
8340
+ var ALL_STATE_CAPS = Object.keys(STATE_CAP_NAMES).sort();
8341
+ /**
8342
+ * 一度でも開いた能力。**送る caps とは無関係**(送るのは常に全量)。
8343
+ *
8344
+ * 残してあるのは「モーダルを初めて開いたときに1回だけ取り直す」ためだけ。
8345
+ * 開いた瞬間のいまの値でモーダルを描きたいので取りに行くが、二度目からは
8346
+ * 反映待ちの取り直しに任せる(押すたびに Lambda を叩かない)。
7873
8347
  */
7874
- var OPEN_CAPS = [];
8348
+ var OPENED_CAPS = [];
7875
8349
  /**
7876
8350
  * 画面の能力名 → 接続状態ストアの能力名(対応表の実体は STATE_CAP_NAMES)。
7877
8351
  *
@@ -7893,26 +8367,20 @@ ${ingestedAtOf.toString()}
7893
8367
  }
7894
8368
  return null;
7895
8369
  }
7896
- /** 取得中に開かれた能力があれば、もう一度だけ取りに行く */
7897
- function catchUpOpenCaps() {
7898
- if (OPEN_CAPS.length === liveFleetSentCaps.length) return;
7899
- loadLiveFleet();
7900
- }
7901
- /** 能力を開いた(=その登録ファイルを読んでよい)。増えたときだけ取り直す */
8370
+ /**
8371
+ * 能力を開いた。**初回だけ取り直す。**
8372
+ *
8373
+ * ⚠ **caps は増えない**(常に全量・ALL_STATE_CAPS の項)。以前ここにあった
8374
+ * 「取得中に開かれた能力を取りこぼさない」ための追いかけ(catchUpOpenCaps /
8375
+ * liveFleetSentCaps)は、要求の中身が能力によって変わらなくなったので不要になった
8376
+ * ——取得中の応答にも、その能力は最初から載っている。
8377
+ */
7902
8378
  function noteOpenCap(cap) {
7903
8379
  var id = stateCapIdOf(cap);
7904
- if (!id || OPEN_CAPS.indexOf(id) >= 0) return;
7905
- OPEN_CAPS.push(id);
8380
+ if (!id || OPENED_CAPS.indexOf(id) >= 0) return;
8381
+ OPENED_CAPS.push(id);
7906
8382
  loadLiveFleet();
7907
8383
  }
7908
- /**
7909
- * 直近の取得に載せた能力の一覧。
7910
- *
7911
- * **取得中に能力を開いた分を取りこぼさないため。** 取得中の loadLiveFleet は
7912
- * 何もせずに戻るので、これが無いと「開いたのに、その能力だけ永久に読まれない」
7913
- * (しかも次の取り直しは未確定の行が無いので張られない=二度と追いつかない)。
7914
- */
7915
- var liveFleetSentCaps = [];
7916
8384
  /**
7917
8385
  * 次の取得で据え置きを迂回するか(保存が効いた直後の1回だけ)。
7918
8386
  *
@@ -7933,18 +8401,17 @@ ${ingestedAtOf.toString()}
7933
8401
  // 素で呼ぶと描画中の ReferenceError になり、「画面が例外を出さずに描き切る」検査を割る。
7934
8402
  if (typeof fetch !== "function") return;
7935
8403
  liveFleetRequested = true;
7936
- liveFleetSentCaps = OPEN_CAPS.slice();
7937
8404
  var useFresh = freshWanted;
7938
8405
  freshWanted = false;
7939
8406
  var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
7940
8407
  fetch(pfx + "op=state&t=" + encodeURIComponent(intake.token), {
7941
8408
  method: "POST",
7942
8409
  headers: { "content-type": "application/json" },
7943
- // **開いている能力を伝える。** 知らない値は Lambda 側が黙って捨てる(断らない)ので、
7944
- // 画面と Lambda の版が食い違っても接続状況の表示は消えない
8410
+ // **常に全能力を要求する(ALL_STATE_CAPS の項)。** 知らない値は Lambda 側が黙って
8411
+ // 捨てる(断らない)ので、画面と Lambda の版が食い違っても接続状況の表示は消えない
7945
8412
  // **保存直後だけ据え置きを迂回させる。** これが無いと、サーバが保存前の答えを返し、
7946
8413
  // 足りない行が0件になって取り直しも張られない=手で再読み込みするまで追いつかない
7947
- body: JSON.stringify(useFresh ? { caps: OPEN_CAPS, fresh: true } : { caps: OPEN_CAPS }),
8414
+ body: JSON.stringify(useFresh ? { caps: ALL_STATE_CAPS, fresh: true } : { caps: ALL_STATE_CAPS }),
7948
8415
  })
7949
8416
  // **非2xxも失敗として扱う。** ビューアとLambdaの版は独立に進むので、
7950
8417
  // op=state 未対応のLambdaは 400 を返す。通信エラーだけを見ていると空表示になる。
@@ -7958,6 +8425,8 @@ ${ingestedAtOf.toString()}
7958
8425
  // ここより後に置くと**その案件だけ素通り**する
7959
8426
  LIVE_REGISTERED = sanitizeRegisteredNow(j && j.registeredNow);
7960
8427
  LIVE_STATE = sanitizeStateNow(j && j.stateNow);
8428
+ // **欄の有無をそのまま覚える。** sanitize を通した後では「読めて0件」が消える
8429
+ LIVE_STATE_SEEN = !!(j && Array.isArray(j.stateNow));
7961
8430
  // 🔴 **鍵・能力・組織も fleet より先に読む(工程1.5)。** まとめ表を
7962
8431
  // まだ持たない案件は下で throw するので、ここより後に置くと
7963
8432
  // **立ち上げ直後の案件=この修正が一番効く案件**だけ素通りする
@@ -7971,6 +8440,10 @@ ${ingestedAtOf.toString()}
7971
8440
  liveFleetRequested = false;
7972
8441
  MERGED = null;
7973
8442
  refreshDataSources();
8443
+ // 🔴 **図の降格は refreshDataSources の外で呼ぶ(#235 の表示層)。** あちらは
8444
+ // モーダルが開いている間・入力中は何もせずに戻るので、中に入れると
8445
+ // **モーダルを開いたまま最初の応答が届いた回だけ、図が「接続OK」のまま残る**
8446
+ applyStateToConnMap();
7974
8447
  // **モーダルを開いたままでも「反映中」だけは追いつかせる。** refreshDataSources は
7975
8448
  // モーダルが開いている間・入力中は何もしない(下地を入れ替えると開いているモーダルの
7976
8449
  // 中身が切り離される)ので、局所的に差し替える口をここに置く
@@ -7980,7 +8453,6 @@ ${ingestedAtOf.toString()}
7980
8453
  refreshStateTimer();
7981
8454
  // 取得中に保存が効いていたら、迂回付きでもう一度取りに行く
7982
8455
  if (freshWanted) loadLiveFleet();
7983
- catchUpOpenCaps();
7984
8456
  })
7985
8457
  .catch(function () {
7986
8458
  // 静的ビルドの値で描き続ける。ただし「古いかもしれない」ことは画面に出す
@@ -7996,7 +8468,6 @@ ${ingestedAtOf.toString()}
7996
8468
  refreshStateTimer();
7997
8469
  // 取得中に保存が効いていたら、迂回付きでもう一度取りに行く
7998
8470
  if (freshWanted) loadLiveFleet();
7999
- catchUpOpenCaps();
8000
8471
  });
8001
8472
  }
8002
8473
 
@@ -8327,6 +8798,16 @@ ${ingestedAtOf.toString()}
8327
8798
  var stateSpace = (opts && opts.stateSpace) || "";
8328
8799
  var spaceCount = (opts && opts.spaceCount) || 0;
8329
8800
  var inRow = !!(opts && opts.inRow);
8801
+ /**
8802
+ * 鍵がどこまで効くのか(#229)。**置き場所だけでは伝わらないので、言葉にする。**
8803
+ *
8804
+ * Backlog の鍵は**スペースごと**に別なので、そのスペースの行の中に置いてある。
8805
+ * Figma の鍵は**案件に1本**で全ファイル共通なので、特定のファイルの行に入れると
8806
+ * 「このファイル専用」という嘘の見た目になり、一覧の外に置いてある。
8807
+ * 理屈は通っているが、**ファイルが1件だけの画面では「行の中に入れ損ねた」ように
8808
+ * しか見えない**(オーナー指摘)。枠で括ったうえで、この1行で言い切る。
8809
+ */
8810
+ var scopeNote = (opts && opts.scopeNote) || "";
8330
8811
  // **いま登録されているか**を先に出す。入れ直したのに反映されていない状態が
8331
8812
  // 画面で分かるようにする(接続状況は夜間バッチのスナップショットなので、
8332
8813
  // 保存の直後は前日の値のまま。時刻は上の節に出している)。
@@ -8358,7 +8839,8 @@ ${ingestedAtOf.toString()}
8358
8839
  var value = input.value;
8359
8840
  if (!value.trim()) { msg.className = "ds-toggle-msg is-error"; msg.textContent = "⚠ トークンを入力してください。"; return; }
8360
8841
 
8361
- btn.disabled = true;
8842
+ // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)
8843
+ var endBusy = beginBusy(btn);
8362
8844
  msg.className = "ds-toggle-msg";
8363
8845
  msg.textContent = "保存中…";
8364
8846
  var intake = SITE.intake;
@@ -8378,10 +8860,15 @@ ${ingestedAtOf.toString()}
8378
8860
  if (!res.ok) {
8379
8861
  msg.className = "ds-toggle-msg is-error";
8380
8862
  msg.textContent = "✗ " + (j.message || "保存できませんでした。");
8381
- btn.disabled = false;
8863
+ endBusy();
8382
8864
  return;
8383
8865
  }
8384
8866
  msg.className = "ds-toggle-msg is-ok";
8867
+ // 🔴 **「保存中…」を消してから成功を書く(#220)。** ここが抜けていて、実機の
8868
+ // 画面に「保存中…✓ 保存しました。同期を開始しました。」と**2つの状態が同居**して
8869
+ // いた。appendChild は足すだけなので、消す一文が要る(sendConfig 側には在った)。
8870
+ // **語を書く前に必ずここを通すこと**——次に文面を足す人が同じ形で踏む
8871
+ msg.textContent = "";
8385
8872
  msg.appendChild(document.createTextNode("✓ " + (j.message || "保存しました。")));
8386
8873
  // 起動できたときだけ、実行状況への導線を出す(鍵を入れた直後に効いたか確かめられる)
8387
8874
  if (j.runsUrl) {
@@ -8390,11 +8877,11 @@ ${ingestedAtOf.toString()}
8390
8877
  }
8391
8878
  // 入れた値は画面に残さない(肩越しに見えるのを防ぐ)
8392
8879
  input.value = "";
8393
- btn.disabled = false;
8880
+ endBusy();
8394
8881
  } catch (e) {
8395
8882
  msg.className = "ds-toggle-msg is-error";
8396
8883
  msg.textContent = "✗ 通信に失敗しました。";
8397
- btn.disabled = false;
8884
+ endBusy();
8398
8885
  }
8399
8886
  });
8400
8887
 
@@ -8445,6 +8932,9 @@ ${ingestedAtOf.toString()}
8445
8932
  // **desc は無い種別がある(2026-08-21)。** el() は text をそのまま textContent へ
8446
8933
  // 代入するので、無条件に出すと画面に文字列 undefined が出る
8447
8934
  meta.desc ? el("p", { class: "ops-card-desc", text: meta.desc }) : null,
8935
+ // **鍵の効く範囲は、登録状況より先に言う。** 「登録済み」と読んだ後では
8936
+ // 「どのファイルの話か」を確かめ直すことになる(#229)
8937
+ scopeNote ? el("p", { class: "sec-scope", text: scopeNote }) : null,
8448
8938
  state,
8449
8939
  // **未登録のときだけ、先に試すべき手を出す。** 既に動いているカードに
8450
8940
  // 「まずこれをやれ」と出すと、直す必要が無いものを触らせることになる。
@@ -8485,6 +8975,68 @@ ${ingestedAtOf.toString()}
8485
8975
  });
8486
8976
  app.appendChild(wrap);
8487
8977
  updateConnMapAsOf();
8978
+ applyStateToConnMap();
8979
+ }
8980
+
8981
+ /**
8982
+ * 接続グラフを、正本からの導出で**毎回まるごと当て直す**(0.12.0・#231 / #235)。
8983
+ *
8984
+ * ## なぜ「片道の降格」をやめたのか
8985
+ *
8986
+ * 0.11.1 は「実績が無い会議・共有資料を cm-ok から降ろす(戻す道は無い)」形だった。
8987
+ * 取り込みの事実は**単調に増えるだけ**(一度取り込めたら取り消されない)なので、
8988
+ * あの2能力では片道で成立していた。
8989
+ *
8990
+ * **チャットと開発の「届くか」は双方向に変わる。** アクセスできない → App の設定を
8991
+ * 直す → 「接続を確認」を押すと ok に戻る。片道のまま一般化すると、**直したのに
8992
+ * リロードするまで未接続のまま**という**新しい嘘**を作る(前の嘘を消して別の嘘を
8993
+ * 置くことになる)。
8994
+ *
8995
+ * ## だから冪等な再描画にする
8996
+ *
8997
+ * ライブ応答が届くたびに、6能力ぶんの導出結果(capDerivation)から
8998
+ * **ノードのクラス・3行目・線の表示を毎回計算して当てる**。降格も昇格もする。
8999
+ * 何度呼んでも同じ状態に落ち着く(前回の状態を読まない)。
9000
+ *
9001
+ * 🔴 **線は消さない。表示を切り替えるだけ**(renderConnectionMap の項)。
9002
+ * 消してしまうと、昇格のときに曲線のジオメトリを実行時にもう一度計算することになり、
9003
+ * 同じ式がビルド時と実行時の2箇所に分かれる。
9004
+ *
9005
+ * 🔴 **cm-warn を隠さない。** 壊れているのは「繋がったうえで失敗している」状態で、
9006
+ * 経路自体は存在する。線を消すと**設定が無いのと区別が付かなくなり、失敗が図から
9007
+ * 消える**——「失敗が上位表示に畳み込まれない」(#231)の逆を、こちらの都合で作る。
9008
+ *
9009
+ * **導出が答えない能力(規則5・設定が on 以外)は触らない。** 焼いた図がそのまま
9010
+ * 残る=従来表示へのフォールバック。タイル側と同じ規律。
9011
+ */
9012
+ function applyStateToConnMap() {
9013
+ var wrap = app.querySelector(".conn-map");
9014
+ if (!wrap) return;
9015
+ for (var cap in TILE_CAPS) {
9016
+ if (!Object.prototype.hasOwnProperty.call(TILE_CAPS, cap)) continue;
9017
+ // 設定そのものが OFF / 異常 / 未設置なら、その語(焼いた図)が正しい
9018
+ if (capConfigState(cap)) continue;
9019
+ var d = capDerivation(cap);
9020
+ if (!d) continue; // 規則5: 材料が無い → 焼いた図のまま
9021
+ var node = wrap.querySelector('[data-target="ds-' + cap + '"]');
9022
+ if (!node) continue;
9023
+ node.setAttribute("class", "cm-node cm-" + d.state);
9024
+ var sync = node.querySelector(".cm-sync");
9025
+ if (sync) {
9026
+ // 🔴 **時刻の印は必ず外す。** 残すと renderConnMap の [data-ts] の整形が
9027
+ // あとから「最終同期 …」を書き戻し、導出した語が消える
9028
+ sync.removeAttribute("data-ts");
9029
+ // 接続OK のときだけ、ストア由来の取り込み時刻を添える(run の時刻ではない)。
9030
+ // 出せなければ語だけ——**繋がっていないものに同期時刻を並べない**(タイルと同じ規律)
9031
+ var at = d.state === "ok" ? dsFmtTime(ingestedAtOf(LIVE_STATE, TILE_CAPS[cap].id)) : "";
9032
+ sync.textContent = at ? "最終同期 " + at : d.text;
9033
+ }
9034
+ var link = wrap.querySelector('.cm-link[data-cm-cap="' + cap + '"]');
9035
+ if (link) {
9036
+ link.setAttribute("class", "cm-link cm-" + d.state
9037
+ + (d.state === "ok" || d.state === "warn" ? "" : " is-hidden"));
9038
+ }
9039
+ }
8488
9040
  }
8489
9041
 
8490
9042
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,