@takagaki/cortex-decisions-viewer 0.12.34 → 0.12.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -11,6 +11,7 @@ exports.claudeCodeRepositories = claudeCodeRepositories;
11
11
  exports.claudeCodeUrl = claudeCodeUrl;
12
12
  exports.toolStateLabel = toolStateLabel;
13
13
  exports.toolStateWithLive = toolStateWithLive;
14
+ exports.toolsFoldSummary = toolsFoldSummary;
14
15
  exports.isAlreadyRegistered = isAlreadyRegistered;
15
16
  exports.sanitizeRegisteredNow = sanitizeRegisteredNow;
16
17
  exports.sanitizeStateNow = sanitizeStateNow;
@@ -36,6 +37,14 @@ exports.sanitizeGithubOwnersNow = sanitizeGithubOwnersNow;
36
37
  exports.splitNeededParts = splitNeededParts;
37
38
  exports.ingestedAtOf = ingestedAtOf;
38
39
  exports.keyMissingText = keyMissingText;
40
+ exports.isCalendarId = isCalendarId;
41
+ exports.calendarStateRow = calendarStateRow;
42
+ exports.calendarIdOf = calendarIdOf;
43
+ exports.calendarRowCount = calendarRowCount;
44
+ exports.calendarPhase = calendarPhase;
45
+ exports.calendarWaiting = calendarWaiting;
46
+ exports.calendarTemplateUrl = calendarTemplateUrl;
47
+ exports.calendarAddUrl = calendarAddUrl;
39
48
  exports.deriveCapTile = deriveCapTile;
40
49
  exports.renderConnectionMap = renderConnectionMap;
41
50
  const node_crypto_1 = require("node:crypto");
@@ -554,6 +563,41 @@ function toolStateWithLive(item, rows) {
554
563
  // failed / skipped / not_in_channel / missing_scope / no_token … は全部「人の操作待ち」
555
564
  return "waiting";
556
565
  }
566
+ /**
567
+ * 畳んだ種別の 1 行に出す、状態の要約(#424)。
568
+ *
569
+ * **全部同じなら、その 1 語だけ**(「GitHub 7 件 ● 読み取り中」)。畳んだ 1 行に
570
+ * 「読み取り中 7」と件数を書いても、左に出ている 7 件と同じ数を 2 回言うだけになる。
571
+ *
572
+ * **混ざっていれば件数で**(「読み取り中 5・承認待ち 2」)。ここで「一部が承認待ち」の
573
+ * ような曖昧な言い方にすると、開くまで何件手を入れるのか分からない。
574
+ *
575
+ * 色は `toolStateLabel` の `ds-gate-*` をそのまま使う。混ざっているときは
576
+ * **承認待ちが 1 件でもあれば橙**——畳んだ 1 行が「人の操作待ちを含む」ことを、
577
+ * 開かずに見つけられる必要がある(畳んだせいで気づかれなくなる、が一番痛い)。
578
+ *
579
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
580
+ */
581
+ function toolsFoldSummary(states) {
582
+ // 表示の順は状態の 4 語と同じ(読み取り中→承認待ち→停止中→未確認)
583
+ var order = ["reading", "waiting", "stopped", "unknown"];
584
+ var count = { reading: 0, waiting: 0, stopped: 0, unknown: 0 };
585
+ (states || []).forEach(function (s) {
586
+ // ⚠ 4 語のどれかに当たるかは **order で見る**(素のオブジェクトの添字だと
587
+ // "constructor" のような語が継承プロパティに当たって、知らない語が素通りする)
588
+ var k = String(s == null ? "" : s);
589
+ count[order.indexOf(k) < 0 ? "unknown" : k] += 1;
590
+ });
591
+ var used = order.filter(function (k) { return count[k] > 0; });
592
+ if (used.length <= 1) {
593
+ var lab = toolStateLabel(used[0] || "unknown");
594
+ return { text: lab.text, cls: lab.cls, state: used[0] || "unknown" };
595
+ }
596
+ var text = used
597
+ .map(function (k) { return toolStateLabel(k).text + " " + count[k]; })
598
+ .join("・");
599
+ return { text: text, cls: count.waiting > 0 ? "ds-gate-warn" : "ds-gate-muted", state: "mixed" };
600
+ }
557
601
  /**
558
602
  * 外部ソースの行が「この能力・このアダプタのタイル」に属するか。
559
603
  *
@@ -897,6 +941,9 @@ function sanitizeRegisteredNow(v) {
897
941
  const STATE_STATUSES = {
898
942
  reachable: ["ok", "not_in_channel", "missing_scope", "no_token", "unreachable", "unknown", "not_in_space", "no_credentials", "no_consent", "transcripts_api_disabled", "no_access_policy", "speaker_attribution_off", "folder_not_granted"],
899
943
  ingested: ["ok", "failed", "skipped"],
944
+ // 案件カレンダーは作れたか(tools #427)。行の描画は #428。ここに無いと、中央が報告した
945
+ // 「作成済み」の行が overlay に届く前に落ちて、画面は永久に「作成中」のまま
946
+ calendar: ["requested", "ok", "failed"],
900
947
  };
901
948
  const STATE_ROW_FIELDS = ["cap", "ref", "workspace", "question", "status", "at", "by", "needed", "detail", "lastOkAt", "lastFailAt"];
902
949
  /** stateNow はライブ専用キーで build.ts の網にかからない。画面側でも許可リストを通す */
@@ -1854,8 +1901,259 @@ function keyMissingText(cap) {
1854
1901
  */
1855
1902
  const BAD_STATE_STATUSES = {
1856
1903
  ingested: ["failed", "skipped"],
1904
+ // 案件カレンダー(tools #427)。**`requested` は中立**(依頼を受けた・作成中)で悪くない。
1905
+ // 入れると、押した直後の 15 分が全部⚠️になる
1906
+ calendar: ["failed"],
1857
1907
  reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable", "not_in_space", "no_credentials", "no_consent", "transcripts_api_disabled", "no_access_policy", "speaker_attribution_off", "folder_not_granted"],
1858
1908
  };
1909
+ /**
1910
+ * 案件カレンダーの ID の形か(#428)。
1911
+ *
1912
+ * 🔴 **依頼直後の番兵と本物の ID を区別する唯一の手掛かり。** 接続状態ストアの
1913
+ * `calendar` の行は、依頼を立てた直後は `ref` にカレンダー ID を持たない(`"calendar"`)。
1914
+ * ここで形を見ずに `ref` をそのまま ID として使うと、**「作成済み」の行が
1915
+ * `src=calendar` の壊れたリンクを出す**——押した人は Google の一般的なエラーに落ち、
1916
+ * 何が悪いのか画面からは分からない。
1917
+ *
1918
+ * **engine 側(`scripts/fleet-status.mjs` の `meetingCalendarId`)と同じ形にすること。**
1919
+ * 片方だけ厳しいと「fleet-status には載っているのに画面が弾く」で、画面は黙って
1920
+ * 「案件カレンダーがまだありません」と出し、**押した人が 2 本目のカレンダーを作る**。
1921
+ *
1922
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
1923
+ * 正規表現も外に出さず、関数の中に置く——外の const は `exports.` に書き換えられて
1924
+ * ブラウザで未定義になる)。
1925
+ */
1926
+ function isCalendarId(v) {
1927
+ if (typeof v !== "string")
1928
+ return false;
1929
+ var s = v.trim();
1930
+ // 長さは接続状態ストアの ref の上限(validateRow の 200)と揃える。
1931
+ // `#` は SK の区切りなので通すと別の行に化ける
1932
+ if (!s || s.length > 200 || s.indexOf("#") >= 0)
1933
+ return false;
1934
+ if (/[\u0000-\u001F\u007F\u00AD\u200B-\u200F\u2028\u2029\u2060\uFEFF]/.test(s))
1935
+ return false;
1936
+ // `…@group.calendar.google.com`(二次カレンダー)と、人のカレンダー(メール)の両方を通す
1937
+ return /^[^\s@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(s);
1938
+ }
1939
+ /**
1940
+ * 接続状態ストアから、案件カレンダーの行を**1本だけ**取る(#428)。
1941
+ *
1942
+ * 🔴 **`at` が最新の1行だけを見る。** この問いの行は SK が2種類になりうる
1943
+ * (依頼直後は `ref = "calendar"`、作成後は `ref = <calendarId>`。裏の経路の契約は #427)。
1944
+ * 両方を見て混ぜると、**古い依頼の行が新しい結果を上書きして「作成中」と「作成済み」が
1945
+ * 交互に出る**——利用者には画面が壊れたようにしか見えない。
1946
+ *
1947
+ * `cap` と `question` の**両方**で絞るのは `ingestedStateOf` / `designIngestedStateOf` と
1948
+ * 同じ流儀(片方だけだと、同じ能力の別の問いの行を読む)。
1949
+ */
1950
+ function calendarStateRow(rows) {
1951
+ var list = rows || [];
1952
+ var best = null;
1953
+ for (var i = 0; i < list.length; i++) {
1954
+ var r = list[i];
1955
+ if (!r || r.cap !== "meetings" || r.question !== "calendar")
1956
+ continue;
1957
+ // 時刻はミリ秒ありの ISO に正規化済み(サーバ側の `normalizeAt` が形を1つに揃え、
1958
+ // 契約表 statusAt.pattern がそれを固定している)ので**辞書順で比べられる**。
1959
+ // ⚠ `overlayStateOnFleet` が #280 で `at` の比較を捨てたのとは事情が違う。あちらは
1960
+ // 「焼いたスナップショットの時刻」と「ストアの時刻」という**別の源**を比べていて、
1961
+ // 秒精度とミリ秒精度が混ざっていた。ここは**同じストアの行どうし**なので混ざらない
1962
+ if (!best || String(r.at) > String(best.at))
1963
+ best = r;
1964
+ }
1965
+ return best;
1966
+ }
1967
+ /**
1968
+ * 案件カレンダーの ID を引く。**順に1本だけ**(#428・設計 §6)。
1969
+ *
1970
+ * 🔴 **2本の条件に割らないこと。** 「ストアが ok なら作成済み」「または fleet に
1971
+ * calendarId があれば作成済み」を別々に評価すると、ライブの行が届く前後で判定が
1972
+ * 入れ替わり、**「作成済み」の行が点滅する**。順に引いて最初に取れたものを使う。
1973
+ *
1974
+ * ストアの `ref` を先に見るのは、そちらが速いから(fleet-status.json は push のたびの
1975
+ * 再生成なので、作った直後は追いついていない)。正本は案件リポ側で、食い違ったら
1976
+ * 次の再生成で fleet が正しくなる。
1977
+ */
1978
+ function calendarIdOf(s, rows) {
1979
+ // 🔴 **ID は「最新の行」ではなく「ID を持つ最新の行」から引く。** 状態を運ぶ行とは
1980
+ // 別に引く理由は「共有を更新」——押すと裏の経路が番兵(`ref = "calendar"`)の
1981
+ // `requested` を書き、それが最新になる。最新の1行だけから ID を引くと、**既にある
1982
+ // カレンダーのリンクとコピー欄が最長20分ぶん画面から消え**、さらに待つと
1983
+ // 「作成が終わっていません」まで出る(既にあるカレンダーについて)。
1984
+ // **一度できたカレンダーは消えない**ので、ID は落とさないのが正しい。
1985
+ var list = rows || [];
1986
+ var best = null;
1987
+ for (var i = 0; i < list.length; i++) {
1988
+ var r = list[i];
1989
+ if (!r || r.cap !== "meetings" || r.question !== "calendar")
1990
+ continue;
1991
+ if (!isCalendarId(r.ref))
1992
+ continue;
1993
+ if (!best || String(r.at) > String(best.at))
1994
+ best = r;
1995
+ }
1996
+ if (best)
1997
+ return String(best.ref).trim();
1998
+ if (s && isCalendarId(s.calendarId))
1999
+ return String(s.calendarId).trim();
2000
+ return "";
2001
+ }
2002
+ /**
2003
+ * 「最長 N 分かかります」の N(分)。**画面の文言と `CALENDAR_STALE_MS` の両方をここから導く。**
2004
+ * 別々に持つと、約束(20分)と「まだ待つ」の境目(旧24時間)が噛み合わず、
2005
+ * **21分目から24時間目までが嘘の空白**になる(画面は「作成中」と言い続ける)。
2006
+ * 裏の経路は15分ごとに最大5件ずつ拾う(#427)ので、混み合っても20分あれば1周する。
2007
+ */
2008
+ const CALENDAR_WAIT_MIN = 20;
2009
+ /**
2010
+ * 依頼が拾われないまま止まっていると見なすまで。**約束の3倍**。
2011
+ *
2012
+ * ぴったり20分で「止まっています」と言うと、たまたま混んだ回で要らない連絡が飛ぶ。
2013
+ * 逆に24時間まで「作成中」と言い続けると、裏の経路が止まっていても丸1日誰も気づけない。
2014
+ * 3倍は「1周期(15分)の取りこぼしを2回まで許す」幅。
2015
+ */
2016
+ const CALENDAR_STALE_MS = CALENDAR_WAIT_MIN * 3 * 60 * 1000;
2017
+ /**
2018
+ * 生の応答(sanitize を通す前)に、案件カレンダーの行が何本あったか(#428)。
2019
+ *
2020
+ * 🔴 **「知らない語だから落とした」と「行が 1 本も無い」を区別するために要る。**
2021
+ * `sanitizeStateNow` は語彙に無い語の行を落とす。`reachable` / `ingested` では
2022
+ * 落ちた行は「確認中」に倒れるので安全だが、**`calendar` では落ちた行が
2023
+ * 「カレンダーが無い」に化ける**——画面が「まだありません」+「作る」を出し、
2024
+ * 押した人が 2 本目を作る。書き手(裏の経路)と画面は別々に配られるので、
2025
+ * サーバの語彙が先行する期間は必ずある。
2026
+ *
2027
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2028
+ */
2029
+ function calendarRowCount(raw) {
2030
+ if (!Array.isArray(raw))
2031
+ return 0;
2032
+ var n = 0;
2033
+ for (var i = 0; i < raw.length; i++) {
2034
+ var r = raw[i];
2035
+ if (r && typeof r === "object" && r.cap === "meetings" && r.question === "calendar")
2036
+ n += 1;
2037
+ }
2038
+ return n;
2039
+ }
2040
+ /**
2041
+ * 案件カレンダーの状態を決める(#428)。
2042
+ *
2043
+ * - `unknown` … 観測がまだ届いていない → 「状況を確認中です」(押せない)
2044
+ * - `unclear` … 行はあるのに ID も既知の状態も引けない → 「確認しています」(押せない)
2045
+ * - `none` … まだ無い(行が 1 本も無い)→ 「この案件のカレンダーを作る」
2046
+ * - `pending` … 依頼が立っている → 「作成中(最長 N 分)」
2047
+ * - `stale` … 約束の 3 倍を過ぎた → 「作成が終わっていません(依頼は届いています)」
2048
+ * - `ready` … ID がある → 予定を作る/マイカレンダーに追加/共有を更新
2049
+ * - `failed` … 作れなかった → 理由の1行+もう一度
2050
+ *
2051
+ * 🔴 **`none` は「行が 1 本も無い」ときだけ。** 契約(裏の経路 #427)は
2052
+ * `ref` が「カレンダー ID **または番兵**」なので、**`ok` が番兵の SK に載る形が
2053
+ * 禁じられていない**——「`ok` なら必ず ref に ID がある」を前提にすると、
2054
+ * その日から画面は「案件カレンダーがまだありません」+「作る」を出す。
2055
+ * 行があるのに読み解けないなら、**押させずに `unclear` へ倒す**のが正しい:
2056
+ * 待てば直る(次の報告で ID が載る)が、押すと 2 本目が出来て戻せない。
2057
+ *
2058
+ * 🔴 **ID があるなら `failed` でも「作れませんでした」と言わない。** 失敗の種別には
2059
+ * 「書き戻し失敗」があり、これは**カレンダーは出来ている**(作成直後の報告で
2060
+ * `ref = <calendarId>` が既に入っている)。そこで「作れませんでした」と出すと嘘になり、
2061
+ * **押し直した人が 2 本目のカレンダーを作る**。ID があるときは作成済みの行を出し、
2062
+ * 失敗は `failure`(添える1行)として渡す。
2063
+ *
2064
+ * **時計は注入する**(`nowMs`)。実時間と比べると、止まっている依頼の判定が
2065
+ * 「テストを書いた日からしばらく経つと赤くなる」日付爆弾になる。
2066
+ *
2067
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
2068
+ * `CALENDAR_STALE_MS` は CLIENT_JS 側にも `var` で流し込んである)。
2069
+ */
2070
+ function calendarPhase(s, rows, ctx, nowMs) {
2071
+ var seen = !!(ctx && ctx.seen);
2072
+ // 生の応答にあった calendar の行の本数(落とされた行も数えている)
2073
+ var rawCal = ctx && typeof ctx.rawCalendar === "number" ? ctx.rawCalendar : 0;
2074
+ var id = calendarIdOf(s, rows);
2075
+ var row = calendarStateRow(rows);
2076
+ var status = row ? String(row.status || "") : "";
2077
+ var detail = row && typeof row.detail === "string" ? row.detail : "";
2078
+ var at = row && typeof row.at === "string" ? row.at : "";
2079
+ var unclear = { phase: "unclear", calendarId: "", detail: "", note: "", at: at };
2080
+ // ---- ID があれば必ず ready(最新の行が何であっても降ろさない) ----
2081
+ if (id) {
2082
+ // 進行中・失敗は**添える1行**として渡す。「作れませんでした」とは言わない
2083
+ //(出来ているので嘘になり、押し直すと 2 本目が作られる)
2084
+ var note = "";
2085
+ if (status === "requested")
2086
+ note = "requested";
2087
+ else if ((BAD_STATE_STATUSES.calendar || []).indexOf(status) >= 0)
2088
+ note = detail || "failed";
2089
+ return { phase: "ready", calendarId: id, detail: "", note: note, at: at };
2090
+ }
2091
+ // ---- 🔴 ID も行も無いとき、「まだ分からない」と「まだ無い」を潰さない ----
2092
+ // ライブ(op=state)が届く前に「案件カレンダーがまだありません」+「作る」を出すと、
2093
+ // **既にカレンダーがある案件で、速い人が押して 2 本目を作る**。画面には既に
2094
+ // 「登録状況を確認中です」の作法があるので、同じ形で待つ(deriveCapTile の規則5と同じ)
2095
+ if (row) {
2096
+ if ((BAD_STATE_STATUSES.calendar || []).indexOf(status) >= 0) {
2097
+ return { phase: "failed", calendarId: "", detail: detail, note: "", at: at };
2098
+ }
2099
+ if (status === "requested") {
2100
+ var t = Date.parse(at);
2101
+ // **読めない時刻を stale にしない。** NaN との比較は常に false なので `t === t` で弾く
2102
+ //(止まっている旨の1行は「担当者に連絡してください」で、誤って出すと要らない連絡を生む)
2103
+ var stale = t === t && nowMs - t > CALENDAR_STALE_MS;
2104
+ return { phase: stale ? "stale" : "pending", calendarId: "", detail: "", note: "", at: at };
2105
+ }
2106
+ // ここに来るのは「`ok` なのに ref が番兵」等。**`none` に落とさない**(上の項)
2107
+ return unclear;
2108
+ }
2109
+ // 行はあったのに sanitize で落ちた(語彙が先行している)。**「無い」と言わない**
2110
+ if (rawCal)
2111
+ return unclear;
2112
+ if (!seen)
2113
+ return { phase: "unknown", calendarId: "", detail: "", note: "", at: "" };
2114
+ return { phase: "none", calendarId: "", detail: "", note: "", at: at };
2115
+ }
2116
+ /**
2117
+ * いま案件カレンダーの作成を待っているか(#428)。
2118
+ *
2119
+ * 🔴 **「自動で更新されます」と画面に書いた以上、実際に取り直す。** 取り直しのタイマは
2120
+ * 仮置き(#388)の件数でしか張られず、**カレンダーの依頼は 1 件も数えられない**
2121
+ * ——ここを見ないと 1 回も取り直さず、押した人は「作成中」のまま放置される。
2122
+ *
2123
+ * **期限は依頼の時刻から測る**(画面を開いた時刻ではない)。他の案件の画面を開いた人が
2124
+ * 何時間も叩き続けないよう、止まったと見なす時刻で終わる——そこから先は取り直しても
2125
+ * 答えは変わらない(人が担当者に連絡する番)。
2126
+ *
2127
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2128
+ */
2129
+ function calendarWaiting(rows, nowMs) {
2130
+ var row = calendarStateRow(rows);
2131
+ if (!row || String(row.status) !== "requested")
2132
+ return false;
2133
+ var t = Date.parse(String(row.at));
2134
+ // 読めない時刻で永久に叩き続けない(NaN との比較は常に false なので `t === t` で弾く)
2135
+ return t === t && nowMs - t <= CALENDAR_STALE_MS;
2136
+ }
2137
+ /**
2138
+ * 「この案件の予定を作る」のリンク。
2139
+ *
2140
+ * ⚠ **`src=` は Google の公式ドキュメントに記載が無い**(#418 の調査 6)。第三者のまとめに
2141
+ * 「既定のカレンダーではなく共有カレンダーに予定を追加する」とある形をそのまま使っている。
2142
+ * **実測は未了**(cortex-tools #425 の 3)——結果が出たらこの1行を差し替える。
2143
+ * 組み立てをこの関数に閉じてあるのは、そのときに直す場所を1つにするため。
2144
+ */
2145
+ function calendarTemplateUrl(id) {
2146
+ return "https://calendar.google.com/calendar/render?action=TEMPLATE&src=" + encodeURIComponent(id);
2147
+ }
2148
+ /**
2149
+ * 「マイカレンダーに追加」のリンク。
2150
+ *
2151
+ * ⚠ `calendarTemplateUrl` と同じく**実測は未了**(#425 の 3)。共有されている人が開くと
2152
+ * 「カレンダーを追加しますか」が出る想定。
2153
+ */
2154
+ function calendarAddUrl(id) {
2155
+ return "https://calendar.google.com/calendar/r?cid=" + encodeURIComponent(id);
2156
+ }
1859
2157
  /**
1860
2158
  * タイル1行(と接続グラフの1ノード)の状態を、**正本から導出する**(0.12.0)。
1861
2159
  *
@@ -2812,6 +3110,58 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
2812
3110
  .home-tools-name { flex: 1 1 220px; min-width: 0; word-break: break-all; }
2813
3111
  .home-tools-state { flex: 0 0 auto; }
2814
3112
  .home-tools-empty, .home-tools-note { font-size: 12.5px; color: var(--muted); line-height: 1.7; margin: 8px 0 0; }
3113
+ /* 文中の「操作タブ」(その場でタブを移る口・#424)。本文のリンクと同じ見た目にする */
3114
+ .home-tools-ops-link { color: var(--accent); text-decoration: underline; cursor: pointer; }
3115
+ /* 種別ごとの折りたたみ(3件以上・#424)。**畳んだ1行は、行と同じ高さ・同じ字の大きさ**
3116
+ ——一覧の途中に「見出し」が生えたように見せない。既定は畳む */
3117
+ .home-tools-fold { margin: 8px 0 0; }
3118
+ /* 🔴 **行そのものがボタン**(2026-09-05 の実機確認)。前は details/summary で、
3119
+ 押せる範囲が中身の箱、開閉の印は summary::after の12pxの擬似要素だった——
3120
+ **小さすぎて見つからず、当たり判定も広げられない**。button にすると
3121
+ 行のどこを押しても・Enter でも Space でも開く(幅いっぱいに伸ばすのが要) */
3122
+ .home-tools-fold-summary {
3123
+ display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center;
3124
+ width: 100%; box-sizing: border-box; text-align: left;
3125
+ padding: 7px 0; border: none; border-bottom: 1px solid var(--border); border-radius: 0;
3126
+ background: none; color: inherit; font: inherit; font-size: 13.5px;
3127
+ cursor: pointer;
3128
+ }
3129
+ /* **行そのものが色を変える。** 字だけ反応させても「行のどこでも押せる」ことは伝わらない
3130
+ (当たり判定を広げた意味が見た目に出ない)。左右へ少しはみ出させて、行の端まで押せると見せる */
3131
+ .home-tools-fold-summary:hover { background: var(--accent-weak); box-shadow: 0 0 0 6px var(--accent-weak); }
3132
+ .home-tools-fold-summary:hover .home-tools-fold-label,
3133
+ .home-tools-fold-summary:hover .home-tools-fold-arrow { color: var(--text); }
3134
+ /* キーボードで来た人に、いまどの行にいるかを見せる(行全体を囲む) */
3135
+ .home-tools-fold-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3136
+ /* アイコンは別の span(読み上げから外すため)。畳まない行の種別と同じ字面にそろえる */
3137
+ .home-tools-fold-icon { flex: 0 0 auto; }
3138
+ .home-tools-fold-label { flex: 0 0 auto; font-weight: 600; color: var(--muted); }
3139
+ /* 状態は行と同じく右端へ寄せる。**縦にそろっていないと一覧に見えない**
3140
+ (畳んだ1行だけ状態が真ん中にあると、同じ列の情報だと分からない) */
3141
+ .home-tools-fold-state { flex: 0 0 auto; margin-left: auto; }
3142
+ /* 開閉の矢印は**行のいちばん右**に、行の字より大きく置く(16px以上・左右に余白)。
3143
+ 状態チップの margin-left:auto で右へ寄った、その先端に来る。
3144
+ ⚠ 字は ▶/▼(U+25B6/U+25BC)。**▸/▾(U+25B8/U+25BE)は「小さい三角」の字形**で、
3145
+ 16px にしてもインクは 8px ほどしか出ず、「小さすぎる」という元の症状が残る */
3146
+ .home-tools-fold-arrow {
3147
+ flex: 0 0 auto; margin-left: 10px; padding-right: 4px;
3148
+ font-size: 16px; line-height: 1; color: var(--muted);
3149
+ }
3150
+ .home-tools-fold-summary[aria-expanded="true"] .home-tools-fold-arrow { color: var(--text); }
3151
+ /* 🔴 **畳みがある節では、畳まない行にも矢印ぶんの席を空ける。** 空けないと、畳み行の
3152
+ 状態チップだけが矢印に押されて 30px ほど左へずれ、状態の列が段違いになる
3153
+ (「縦にそろっていないと一覧に見えない」——上の .home-tools-fold-state の項)。
3154
+ 畳みが 1 つも無い案件は :has が当たらないので、見た目は今までのまま */
3155
+ .home-tools:has(.home-tools-fold) .home-tools-item { padding-right: 38px; }
3156
+ /* 開いた中身は少し寄せて、畳んだ1行との親子が見えるようにする */
3157
+ .home-tools-fold > .home-tools-list { margin: 0 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border); }
3158
+ /* 畳んだ1行が後ろに続くとき・開いた中身の後ろに行が続くときは、その手前の行にも
3159
+ 区切り線を引く。.home-tools-item:last-child で線を落としたままだと、**境目だけ
3160
+ 線が抜けて** 2行が1行に見える(畳みが無い案件の見た目は変えない)。
3161
+ :has が効かない環境では、その境目の線が1本消えるだけ(並びも文言も無事) */
3162
+ .home-tools-list:has(+ .home-tools-fold) > .home-tools-item:last-child,
3163
+ .home-tools-fold:has(+ .home-tools-list) > .home-tools-list > .home-tools-item:last-child,
3164
+ .home-tools-fold:has(+ .home-tools-fold) > .home-tools-list > .home-tools-item:last-child { border-bottom: 1px solid var(--border); }
2815
3165
  /* ---- レコード詳細の「AIによる最近の変更」(リポジトリの git 履歴 由来)----
2816
3166
  カードは詳細ページの提案(.proposals)と同系色にする——どちらも
2817
3167
  「AIがやったこと・言っていることを読む」もので種類が同じ。
@@ -3245,7 +3595,10 @@ a.ds-tile:hover { text-decoration: none; }
3245
3595
  .ds-check-label, .ds-check-action, .ds-check-detail,
3246
3596
  .ds-pipe-label, .ds-pipe-todo,
3247
3597
  .ds-empty, .sec-how, .modal-desc, .ds-toggle-msg,
3248
- .ds-drive-note, .ds-inactive-note, .ds-legacy-chip, .ds-keys-code, .ds-gate-help {
3598
+ .ds-drive-note, .ds-inactive-note, .ds-legacy-chip, .ds-keys-code, .ds-gate-help,
3599
+ /* 案件カレンダーの行(#428)。**カレンダー ID は空白の無い長い連なり**で、
3600
+ 失敗の理由(detail)も長さが読めない。ここを外すと会議のモーダルが横に溢れる */
3601
+ .mt-cal-banner, .mt-cal-note, .mt-cal-warn, .mt-cal-link, .mt-legacy-note, .mt-cal-id .rt-copy-text {
3249
3602
  min-width: 0; white-space: normal; overflow-wrap: anywhere;
3250
3603
  }
3251
3604
  .ds-toggle { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
@@ -3376,8 +3729,16 @@ a.ds-tile:hover { text-decoration: none; }
3376
3729
  畳む(作らないのではなく隠す——理由は syncKeyCardVisibility の項)。ところが
3377
3730
  .ops-card は display: flex、.ds-keyrow は flex アイテムを名乗るので、
3378
3731
  UA 既定の [hidden] { display: none } が**負ける**。畳んだつもりで出たままになる。
3379
- ⚠ CSS は jsdom から見えないので、この行はテストでは守れない(実機で見ること)。 */
3380
- [hidden] { display: none !important; }
3732
+ ⚠ CSS は jsdom から見えないので、この行はテストでは守れない(実機で見ること)。
3733
+
3734
+ 🔴 **until-found だけは巻き込まない(#424)。** 仕様の UA スタイルは
3735
+ [hidden]:not([hidden="until-found" i]) に display:none を当て、until-found には
3736
+ content-visibility: hidden を当てる——**ページ内検索(Ctrl+F)で中身を見つけられる**のは
3737
+ 後者だから。ここで一律に display:none を !important で当てると、畳んだ「使用ツール」の
3738
+ 中身が検索から消え、beforematch も永久に発火しない(属性だけ until-found にしても無意味)。
3739
+ 除いた側は作者スタイルを持たないので、until-found を知らないブラウザでは
3740
+ UA 既定の [hidden] { display: none } がそのまま効き、畳んだままになる */
3741
+ [hidden]:not([hidden="until-found" i]) { display: none !important; }
3381
3742
  /* 行の中に入った鍵カードは、カードの枠を持たない(行の一部に見せる) */
3382
3743
  .ops-card.ds-keycard { border: none; box-shadow: none; padding: 0; background: transparent; gap: 6px; }
3383
3744
  .ops-card.ds-keycard > .ops-card-head { gap: 8px; }
@@ -3411,6 +3772,27 @@ a.ds-tile:hover { text-decoration: none; }
3411
3772
  /* 取り込みの条件は箇条書きで出す(ANDであることを読み飛ばせないように) */
3412
3773
  .sec-how-list { margin: 4px 0; padding-left: 1.3em; }
3413
3774
  .sec-how-list li { margin: 2px 0; }
3775
+ /* 案件カレンダーの行(#428)。**帯は症状・行は一手**。
3776
+ 枠は付けない——すぐ下の「うまく取り込まれないとき」(.mt-share)が枠で囲まれた
3777
+ 別の話なので、こちらまで枠にすると同じ強さの箱が2つ並んで、どちらが本筋か読めない */
3778
+ .mt-cal { margin: 8px 0 0; }
3779
+ /* 症状の帯。**警告帯(.mt-privacy)とは色を分ける**——あちらは「読む前に知っておくこと」、
3780
+ こちらは「いま直せること」。同じ色にすると、どちらも読み飛ばされる */
3781
+ .mt-cal-banner {
3782
+ padding: 6px 10px; border-radius: 6px; margin-bottom: 6px;
3783
+ background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af;
3784
+ font-size: 12.5px; font-weight: 700;
3785
+ }
3786
+ /* 一手(リンク)。**2本を縦に並べる**——横に並べると「予定を作る」と「追加する」が
3787
+ 同じ操作の2択に見えるが、実際は別々に押すもの */
3788
+ .mt-cal-link { display: block; margin: 4px 0; font-weight: 700; }
3789
+ /* 事実の1行(色・作成中・連絡先)。操作ではないので弱く出す */
3790
+ .mt-cal-note { color: var(--muted); margin: 6px 0 0; }
3791
+ /* 作成済みだが共有の更新が終わっていない、の1行。**帯にしない**(カレンダーは使える) */
3792
+ .mt-cal-warn { color: #92400e; margin: 6px 0 0; }
3793
+ .mt-cal-act { margin-top: 8px; }
3794
+ /* 移行期間の1行(会議名の合図・#418 の⑥で消す)。条件の下に弱く置く */
3795
+ .mt-legacy-note { color: var(--muted); margin: 2px 0 0; }
3414
3796
  /* 会議の主催者向けの選択肢(「Google Meet」フォルダの共有・2026-08-29 オーナー承認。
3415
3797
  共有対象のフォルダ名は 2026-09-03 に Meet Recordings から Google Meet へ改めた・#393)。
3416
3798
  **折りたたまない。** 週次整理の節が「誰も見ない」で撤去された経緯があるので、
@@ -3805,6 +4187,12 @@ ${overlayStateOnFleet.toString()}
3805
4187
  var TOOL_KIND_ICONS = ${JSON.stringify(TOOL_KIND_ICONS)};
3806
4188
  ${toolStateLabel.toString()}
3807
4189
  ${toolStateWithLive.toString()}
4190
+ // 畳んだ種別の 1 行に出す状態の要約(#424)。**同じ 4 語を使う**——ここで別の言い方を
4191
+ // 作ると、畳んだ 1 行と開いた行が違う語で同じ状態を指す
4192
+ ${toolsFoldSummary.toString()}
4193
+ // 🔴 **同じ種別が何件あったら畳むか。** 1 にすると 1 件の種別まで畳まれて、
4194
+ // Backlog・Drive・Meet が「1 件 ▸」の行に化ける(#424 で明示的に避けた形)
4195
+ var TOOLS_FOLD_MIN = 3;
3808
4196
  ${nextStateDelay.toString()}
3809
4197
  // 工程1.5: 正本を直接読む(secretsNow / capabilitiesNow / githubOwners)。
3810
4198
  // **判定の実体はビルド時の定義**——画面側に書き写すと許可リストが2枚になる
@@ -3822,6 +4210,20 @@ var TILE_CAPS = ${JSON.stringify(TILE_CAPS)};
3822
4210
  var BAD_STATE_STATUSES = ${JSON.stringify(BAD_STATE_STATUSES)};
3823
4211
  ${keyMissingText.toString()}
3824
4212
  ${deriveCapTile.toString()}
4213
+ // 案件カレンダー(#428)。**判定の実体はビルド時の定義**——画面側に書き写すと、
4214
+ // 「作成済みか」の判定が 2 枚になって片方だけ直る(=2 本目のカレンダーを作らせる)。
4215
+ // 🔴 **BAD_STATE_STATUSES より後に置く。** calendarPhase が「悪い」語をそこから引く
4216
+ // (語を2枚持たないため)ので、宣言の前に呼ぶと undefined を読む
4217
+ ${isCalendarId.toString()}
4218
+ ${calendarStateRow.toString()}
4219
+ ${calendarIdOf.toString()}
4220
+ ${calendarRowCount.toString()}
4221
+ var CALENDAR_WAIT_MIN = ${JSON.stringify(CALENDAR_WAIT_MIN)};
4222
+ var CALENDAR_STALE_MS = ${JSON.stringify(CALENDAR_STALE_MS)};
4223
+ ${calendarPhase.toString()}
4224
+ ${calendarWaiting.toString()}
4225
+ ${calendarTemplateUrl.toString()}
4226
+ ${calendarAddUrl.toString()}
3825
4227
  // 「はじめに」セクションのチュートリアルデータ(ビルド時に埋め込み。ボタンURL・チャレンジ4の決定は解決済み)
3826
4228
  var TUTORIAL = (function () {
3827
4229
  var elx = document.getElementById("tutorial-data");
@@ -4565,6 +4967,9 @@ ${deriveCapTile.toString()}
4565
4967
  }
4566
4968
  mount.innerHTML = "";
4567
4969
  mount.appendChild(box);
4970
+ // 🔴 **失敗した側でも after を呼ぶ。** Home は本文が決まるまで枠を隠している
4971
+ // (renderHome)ので、ここで呼ばないと**読み込めなかったという案内ごと消える**
4972
+ done();
4568
4973
  });
4569
4974
  }
4570
4975
  function getParam(name) {
@@ -4573,6 +4978,46 @@ ${deriveCapTile.toString()}
4573
4978
  function tabUrl(tabKey) {
4574
4979
  return tabKey === DEFAULT_TAB ? "?" : "?tab=" + encodeURIComponent(tabKey);
4575
4980
  }
4981
+ /** そのタブが立っているか(案件によっては操作タブが無い) */
4982
+ function hasTab(tabKey) {
4983
+ for (var i = 0; i < TABS.length; i++) { if (TABS[i].key === tabKey) return true; }
4984
+ return false;
4985
+ }
4986
+ /**
4987
+ * タブを移る。**ヘッダーのタブも文中のリンクもここを通る**(#424)——
4988
+ * 切り替えの手が 2 枚あると、片方だけページ番号を戻し忘れる、が必ず起きる。
4989
+ */
4990
+ // ⚠ push を渡すと履歴に1つ積む(**戻るで元のタブに帰れる**)。ヘッダーのタブは
4991
+ // 今までどおり積まない——連打で履歴が埋まると、戻るがビューアから出られなくなる。
4992
+ // 文中のリンクは「押したら進む=戻れる」に見えるので、そちらだけ積む
4993
+ function switchTab(tabKey, push) {
4994
+ listState.tab = tabKey;
4995
+ listState.page = 1;
4996
+ if (push) history.pushState({}, "", tabUrl(tabKey));
4997
+ else history.replaceState(null, "", tabUrl(tabKey));
4998
+ renderList();
4999
+ }
5000
+ /**
5001
+ * 文中の「操作タブ」を、その場で操作タブへ移るリンクにする(#424)。
5002
+ *
5003
+ * 🔴 **操作タブが立たない案件では素のテキストのまま返す。** タブは
5004
+ * SITE.intake || HAS_FLEET のときだけ生えるので、無条件にリンクにすると
5005
+ * **どこにも飛ばないリンク**が出る。
5006
+ *
5007
+ * ⚠ href は本物(?tab=ops)を入れる——中クリック・新しいタブで開くを殺さない。
5008
+ * 素の遷移でも route() が ?tab= を拾うので、着く先は同じ
5009
+ */
5010
+ function opsTabLink(text) {
5011
+ if (!hasTab("ops")) return document.createTextNode(text);
5012
+ var a = el("a", { class: "home-tools-ops-link", href: tabUrl("ops"), text: text });
5013
+ a.addEventListener("click", function (e) {
5014
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
5015
+ e.preventDefault();
5016
+ switchTab("ops", true);
5017
+ window.scrollTo(0, 0);
5018
+ });
5019
+ return a;
5020
+ }
4576
5021
  function go(id) {
4577
5022
  var url = id ? "?id=" + encodeURIComponent(id) : tabUrl(listState.tab);
4578
5023
  history.pushState({}, "", url);
@@ -4617,12 +5062,7 @@ ${deriveCapTile.toString()}
4617
5062
  TABS.forEach(function (t) {
4618
5063
  var label = t.items ? t.label + "(" + t.items.length + ")" : t.label;
4619
5064
  var b = el("button", { class: "tab" + (listState.tab === t.key ? " active" : ""), text: label });
4620
- b.addEventListener("click", function () {
4621
- listState.tab = t.key;
4622
- listState.page = 1;
4623
- history.replaceState(null, "", tabUrl(t.key));
4624
- renderList();
4625
- });
5065
+ b.addEventListener("click", function () { switchTab(t.key); });
4626
5066
  tabs.appendChild(b);
4627
5067
  });
4628
5068
  app.appendChild(tabs);
@@ -4895,8 +5335,21 @@ ${deriveCapTile.toString()}
4895
5335
  }
4896
5336
 
4897
5337
  /** callIntake の戻り(fetch の Promise)を { ok, j } に畳む(submitIntake と同じ形) */
5338
+ // **status も返す(#428)。** 呼び手のほとんどは ok と j.message で足りるが、
5339
+ // 「断られ方で次の一手が変わる」ものがある——案件カレンダーの依頼は 403(オーナー・
5340
+ // 管理者ではない)のときだけボタンを引っ込める。ok だけでは 403 と 5xx を区別できず、
5341
+ // **一時的な障害でボタンを永久に消す**ことになる
4898
5342
  function intakeJson(req) {
4899
- return req.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); });
5343
+ return req.then(function (r) {
5344
+ return r.json().then(
5345
+ function (j) { return { ok: r.ok, status: r.status, j: j }; },
5346
+ // JSON でない応答(プロキシの HTML エラーページ等)でも status は使える
5347
+ // 🔴 **ok は落とす。** 200 で非 JSON が返るのは異常(プロキシのエラーページ等)で、
5348
+ // ここで ok を通すと**保存できていないのに「保存しました」**が出る(以前は
5349
+ // json() の reject が呼び手の catch に落ちて通信エラーの案内になっていた)
5350
+ function () { return { ok: false, status: r.status, j: {} }; },
5351
+ );
5352
+ });
4900
5353
  }
4901
5354
 
4902
5355
  // ヘッダーの丸のメニューが開いているか。**DOM ではなくこの変数が正本。**
@@ -5213,12 +5666,20 @@ ${deriveCapTile.toString()}
5213
5666
  if (toolsSec) app.appendChild(toolsSec);
5214
5667
 
5215
5668
  var homeBody = el("div", { class: "body" });
5669
+ // 🔴 **出す・出さないが決まるまで隠しておく(#424)。** 本番の Home の本文は
5670
+ // bodyRef で外出しされていて fetch なので、先に枠を出すと**空の白い枠が一瞬出てから
5671
+ // 消える**。中身が届いた後に hideEmptyBody が出す/出さないを決める
5672
+ homeBody.hidden = true;
5216
5673
  app.appendChild(homeBody);
5217
5674
  // **旧い節が本文に残っていたら落とす(防御)。** 本来は build 側で抜けているが、
5218
5675
  // 落とし損ねると自己記述が2度出る・移設したはずの説明が両方に出る、という
5219
5676
  // 一番読み手を混乱させる形になるので、描画側でも同じ見出しを見張る。
5220
- // **本文が入った後に呼ぶ**(外出しされていると fetch なので、直後ではまだ空)
5221
- loadBody(h, homeBody, dropLegacyHomeSections);
5677
+ // **本文が入った後に呼ぶ**(外出しされていると fetch なので、直後ではまだ空)。
5678
+ // 落とした結果として本文が空になったら、枠ごと消す(下の hideEmptyBody)
5679
+ loadBody(h, homeBody, function (mount) {
5680
+ dropLegacyHomeSections(mount);
5681
+ hideEmptyBody(mount);
5682
+ });
5222
5683
  // Markdown本文の下にオンボーディングのチュートリアルを差し込む
5223
5684
  renderTutorialSection();
5224
5685
  }
@@ -5253,42 +5714,177 @@ ${deriveCapTile.toString()}
5253
5714
  box.appendChild(el("h2", { class: "home-grounding-title", text: "使用ツール" }));
5254
5715
  var items = t.items || [];
5255
5716
  if (!items.length) {
5256
- box.appendChild(el("p", { class: "home-tools-empty", text: "まだツールを接続していません。操作タブから接続できます。" }));
5717
+ // 「操作タブ」はその場で移れる口にする(文言は変えない・#424)
5718
+ box.appendChild(el("p", { class: "home-tools-empty" }, [
5719
+ document.createTextNode("まだツールを接続していません。"),
5720
+ opsTabLink("操作タブ"),
5721
+ document.createTextNode("から接続できます。"),
5722
+ ]));
5257
5723
  return box;
5258
5724
  }
5259
- var ul = el("ul", { class: "home-tools-list" });
5725
+ // **種別ごとにまとめる(#424)。**
5726
+ //
5727
+ // 🔴 **隣り合う同じ種別だけをまとめる**(前から順に見て、種別が変わったら切る)。
5728
+ // 種別ごとに拾い集める形にすると、engine が種別を飛び飛びに出した日に
5729
+ // **並び順が変わる**——「並び順は変えない」は仕様なので、engine の出力の性質
5730
+ // (いまは種別ごとに連続している)に寄りかからず、構造として変わらない形にする
5731
+ var groups = [];
5260
5732
  items.forEach(function (it) {
5261
- var li = el("li", { class: "home-tools-item" });
5262
- // 種別は**ツール名**(能力名でも単位語でもない)。DS_TYPE の行の約束と同じ
5263
- li.appendChild(el("span", {
5264
- class: "home-tools-kind",
5265
- text: (TOOL_KIND_ICONS[it.kind] || "🔗") + " " + (it.label || it.kind),
5266
- }));
5267
- var name = it.name || it.ref || "";
5268
- // **開ける先が無い行(Google Meet)はリンクにしない。** 押せないリンクを出すより
5269
- // 素のテキストのほうが正直(url は engine が null で「無い」と言い切っている)
5270
- // href に載せる前に isHttp を通す(build 側の許可リストと二重の守り。el() の注意書きのとおり)
5271
- if (it.url && isHttp(it.url)) li.appendChild(el("a", { class: "home-tools-name", href: it.url, target: "_blank", rel: "noopener", text: name }));
5272
- else li.appendChild(el("span", { class: "home-tools-name", text: name }));
5273
- // ライブの実測が届いていれば重ねる(stopped は上書きしない・toolStateWithLive)
5274
- var st = toolStateWithLive(it, LIVE_STATE);
5275
- var lab = toolStateLabel(st);
5276
- var chip = el("span", { class: "home-tools-state ds-gate " + lab.cls, text: lab.text });
5277
- // 状態そのものを属性に残す(表示語を読み替えずに検査・スタイルできるように)
5278
- chip.setAttribute("data-state", st);
5279
- li.appendChild(chip);
5280
- ul.appendChild(li);
5733
+ var k = String(it && it.kind != null ? it.kind : "");
5734
+ var last = groups[groups.length - 1];
5735
+ if (last && last.kind === k) last.items.push(it);
5736
+ else groups.push({ kind: k, items: [it] });
5737
+ });
5738
+ // 畳まない種別は 1 つの <ul> に続けて入れる(1〜2 件の種別のあいだに区切り線が
5739
+ // 入らない、いまの見た目のまま)。畳みを挟んだら次の種別から新しい <ul> にする
5740
+ var ul = null;
5741
+ var foldSeq = 0;
5742
+ groups.forEach(function (g) {
5743
+ if (g.items.length >= TOOLS_FOLD_MIN) {
5744
+ ul = null;
5745
+ // 連番を渡す。**同じ種別が離れて 2 度出ると、種別だけでは名前がぶつかる**
5746
+ // (id が重複し、開閉の復元も両方に効く)
5747
+ box.appendChild(homeToolsFold(g.items, foldSeq++));
5748
+ return;
5749
+ }
5750
+ if (!ul) { ul = el("ul", { class: "home-tools-list" }); box.appendChild(ul); }
5751
+ g.items.forEach(function (it) { ul.appendChild(homeToolRow(it)); });
5281
5752
  });
5282
- box.appendChild(ul);
5283
5753
  // **どこで直せるか・いつの情報かを 1 行で言う。** 操作タブで繋いだ直後は
5284
5754
  // この一覧に出ない(engine が毎朝作り直すまで)ので、黙っていると「効いていない」と読まれる
5285
- box.appendChild(el("p", {
5286
- class: "home-tools-note",
5287
- text: "接続の追加・変更は操作タブで行います(この一覧は毎朝更新されます)。",
5755
+ box.appendChild(el("p", { class: "home-tools-note" }, [
5756
+ document.createTextNode("接続の追加・変更は"),
5757
+ opsTabLink("操作タブ"),
5758
+ document.createTextNode("で行います(この一覧は毎朝更新されます)。"),
5759
+ ]));
5760
+ return box;
5761
+ }
5762
+
5763
+ /**
5764
+ * 使用ツールの 1 行。**畳んだ種別の中でも同じ形**(開いたときに見えるのは今までの行)。
5765
+ */
5766
+ function homeToolRow(it) {
5767
+ var li = el("li", { class: "home-tools-item" });
5768
+ // 種別は**ツール名**(能力名でも単位語でもない)。DS_TYPE の行の約束と同じ
5769
+ li.appendChild(el("span", {
5770
+ class: "home-tools-kind",
5771
+ text: (TOOL_KIND_ICONS[it.kind] || "🔗") + " " + (it.label || it.kind),
5288
5772
  }));
5773
+ var name = it.name || it.ref || "";
5774
+ // **開ける先が無い行(Google Meet)はリンクにしない。** 押せないリンクを出すより
5775
+ // 素のテキストのほうが正直(url は engine が null で「無い」と言い切っている)
5776
+ // href に載せる前に isHttp を通す(build 側の許可リストと二重の守り。el() の注意書きのとおり)
5777
+ if (it.url && isHttp(it.url)) li.appendChild(el("a", { class: "home-tools-name", href: it.url, target: "_blank", rel: "noopener", text: name }));
5778
+ else li.appendChild(el("span", { class: "home-tools-name", text: name }));
5779
+ // ライブの実測が届いていれば重ねる(stopped は上書きしない・toolStateWithLive)
5780
+ var st = toolStateWithLive(it, LIVE_STATE);
5781
+ var lab = toolStateLabel(st);
5782
+ var chip = el("span", { class: "home-tools-state ds-gate " + lab.cls, text: lab.text });
5783
+ // 状態そのものを属性に残す(表示語を読み替えずに検査・スタイルできるように)
5784
+ chip.setAttribute("data-state", st);
5785
+ li.appendChild(chip);
5786
+ return li;
5787
+ }
5788
+
5789
+ /**
5790
+ * 同じ種別が 3 件以上あるときの、畳んだ 1 行(#424)。
5791
+ *
5792
+ * 🔴 **既定は畳む。** GitHub 7 件・Slack 11 件が縦に並ぶと、
5793
+ * その下の本文が画面の外へ押し出される。件数と状態の要約は畳んだまま読めるので、
5794
+ * 「何件つながっていて、手を入れる先があるか」は開かなくても分かる。
5795
+ * 開閉は**保存しない**(開き直すと畳んだ状態に戻る・portal #77 と同じ)
5796
+ *
5797
+ * 🔴 **行は畳んでも DOM に残す**(hidden にするだけ)。描き直し(syncHomeTools)で
5798
+ * 開閉を戻せるのも、検査が行数を数えられるのも、行がそこにあるからで、
5799
+ * **作り直す形にすると開いている最中に中身が消える**
5800
+ *
5801
+ * 🔴 **行そのものを button にする(2026-09-05 の実機確認)。** 初出は
5802
+ * <details>/<summary> だったが、開閉の印が summary::after の擬似要素で、
5803
+ * **12px の「▸」は見つからないし、当たり判定も行の右端まで広げられない**。
5804
+ * button なら行のどこを押しても・Enter でも Space でも開き、aria-expanded で
5805
+ * 開閉が支援技術にも(矢印の向きで目にも)そのまま伝わる
5806
+ */
5807
+ function homeToolsFold(group, seq) {
5808
+ var first = group[0] || {};
5809
+ var kind = String(first.kind == null ? "" : first.kind);
5810
+ // 畳み 1 つを指す鍵。**種別だけにしない**——同じ種別が離れて 2 度出たときに
5811
+ // id が重複し、開閉の復元も両方に効いてしまう
5812
+ // ⚠ **id に使うので、種別の字をそのまま持ち込まない。** kind は fleet-status.json 由来で、
5813
+ // 空白の入った値が来ると id が2つの idref に読まれ、aria-controls の対応が壊れる
5814
+ var key = (String(kind).replace(/[^A-Za-z0-9_-]/g, "") || "other") + "-" + seq;
5815
+ // 状態は 1 行ずつ求めてから畳む(行と要約が別の判定を持たない)
5816
+ var sum = toolsFoldSummary(group.map(function (it) { return toolStateWithLive(it, LIVE_STATE); }));
5817
+ var chip = el("span", { class: "home-tools-fold-state ds-gate " + sum.cls, text: sum.text });
5818
+ chip.setAttribute("data-state", sum.state);
5819
+ // 説明として読ませるので id が要る
5820
+ var chipId = "home-tools-fold-state-" + key;
5821
+ chip.setAttribute("id", chipId);
5822
+ // 件数まで含めて 1 つのテキストノードにする(分けると読み上げが途切れ、
5823
+ // 文言の完全一致で拾えなくなる。portal #77 と同じ約束)。
5824
+ // ⚠ **ツール名はまとまりの中から拾う**(先頭だけ見ると、engine が先頭の 1 件の
5825
+ // label を落とした日に「github 3 件」と内部名が出る)
5826
+ var named = null;
5827
+ group.forEach(function (it) { if (!named && it && it.label) named = it; });
5828
+ var label = ((named || first).label || first.kind || "その他") + " " + group.length + " 件";
5829
+ var listId = "home-tools-fold-list-" + key;
5830
+ // 矢印は**本物の要素**にする(擬似要素だと 16px にしても行の右端に置けない)。
5831
+ // 読み上げからは外す——開閉は aria-expanded が言うので、字を二重に読ませない
5832
+ var arrow = el("span", { class: "home-tools-fold-arrow", "aria-hidden": "true", text: "▶" });
5833
+ // ⚠ type="button" を明示する(この節が将来 form の中に入っても submit しない)
5834
+ var btn = el("button", { class: "home-tools-fold-summary", type: "button" }, [
5835
+ // アイコンは読み上げから外す(「たこ」と読まれても種別は分からない)
5836
+ el("span", { class: "home-tools-fold-icon", "aria-hidden": "true", text: TOOL_KIND_ICONS[first.kind] || "🔗" }),
5837
+ el("span", { class: "home-tools-fold-label", text: label }),
5838
+ chip,
5839
+ arrow,
5840
+ ]);
5841
+ // ⚠ **読み上げ名は「GitHub 7 件」**(aria-label で言い切る)。状態の要約は
5842
+ // aria-describedby で**説明として**読ませる——チップを aria-hidden にすると、
5843
+ // 畳んでいるあいだ(中身は支援技術からも見えない)「承認待ちが 2 件ある」ことが
5844
+ // 目で見える人にしか伝わらない。それは畳みを橙にした理由と正面から矛盾する
5845
+ btn.setAttribute("aria-label", label);
5846
+ btn.setAttribute("aria-describedby", chipId);
5847
+ btn.setAttribute("aria-controls", listId);
5848
+ var ul = el("ul", { class: "home-tools-list", id: listId });
5849
+ group.forEach(function (it) { ul.appendChild(homeToolRow(it)); });
5850
+ // data-fold-key は「描き直しても開いたままにする」ための目印(syncHomeTools が見る)。
5851
+ // data-kind は種別そのもの(畳んだ 1 行がどのツールのものか、を素直に読めるように)
5852
+ var box = el("div", { class: "home-tools-fold", "data-kind": kind, "data-fold-key": key }, [btn, ul]);
5853
+ // 既定は畳む(aria-expanded=false・中身は hidden・矢印は ▸)。1 箇所で決める
5854
+ setToolsFoldOpen(box, false);
5855
+ btn.addEventListener("click", function () { setToolsFoldOpen(box, !toolsFoldOpen(box)); });
5856
+ // ページ内検索が畳んだ中の名前を見つけたとき、ブラウザは hidden を外して開く。
5857
+ // **行の側(矢印・aria-expanded)も一緒に合わせる**——でないと「開いているのに
5858
+ // 行は畳んだ顔をしている」ちぐはぐな状態になり、もう一度押しても閉じない
5859
+ ul.addEventListener("beforematch", function () { setToolsFoldOpen(box, true); });
5289
5860
  return box;
5290
5861
  }
5291
5862
 
5863
+ /**
5864
+ * 畳み 1 つの開閉を決める(#424)。**見た目・読み上げ・中身の 3 つを 1 箇所で動かす**
5865
+ * ——別々に書くと、矢印だけ向きが残る/aria-expanded だけ古い、がすぐ起きる。
5866
+ */
5867
+ function setToolsFoldOpen(box, open) {
5868
+ if (!box) return;
5869
+ var btn = box.querySelector(".home-tools-fold-summary");
5870
+ var ul = box.querySelector(".home-tools-list");
5871
+ if (!btn || !ul) return;
5872
+ btn.setAttribute("aria-expanded", open ? "true" : "false");
5873
+ // 🔴 隠すだけ(作り直さない)。開いている最中に実測が届いても中身は消えない。
5874
+ // ⚠ until-found で隠す——**ページ内検索で畳んだ中のリポジトリ名を拾える**
5875
+ // (見つけたブラウザが hidden を外し、下の beforematch で行の見た目も合わせる)。
5876
+ // この値を知らないブラウザでは「hidden 属性がある=隠す」なので畳んだままになるだけ
5877
+ if (open) ul.removeAttribute("hidden");
5878
+ else ul.setAttribute("hidden", "until-found");
5879
+ var arrow = btn.querySelector(".home-tools-fold-arrow");
5880
+ if (arrow) arrow.textContent = open ? "▼" : "▶";
5881
+ }
5882
+ /** 畳み 1 つが開いているか。**正本は aria-expanded**(矢印の字から読み戻さない) */
5883
+ function toolsFoldOpen(box) {
5884
+ var btn = box && box.querySelector(".home-tools-fold-summary");
5885
+ return !!btn && btn.getAttribute("aria-expanded") === "true";
5886
+ }
5887
+
5292
5888
  /**
5293
5889
  * 接続状況(op=state)が届いたときに「使用ツール」を描き直す(#275)。
5294
5890
  *
@@ -5300,7 +5896,33 @@ ${deriveCapTile.toString()}
5300
5896
  var old = app.ownerDocument.querySelector(".home-tools");
5301
5897
  if (!old || !old.parentNode) return;
5302
5898
  var next = homeToolsSection();
5303
- if (next) old.parentNode.replaceChild(next, old);
5899
+ if (!next) return;
5900
+ // 🔴 **人が開いた折りたたみは、描き直しても開いたままにする(#424)。**
5901
+ // 実測は開いた直後に届くことがあり、そこで畳み直すと「押したのに閉じた」になる
5902
+ // (portal #77 で踏んだのと同じ罠)。**保存はしない**——開き直せば畳んだ状態に戻る
5903
+ var opened = {};
5904
+ var before = old.querySelectorAll(".home-tools-fold");
5905
+ for (var i = 0; i < before.length; i++) {
5906
+ // 素のオブジェクトの添字にしない("constructor" のような鍵が継承側に当たる)
5907
+ if (toolsFoldOpen(before[i])) opened["k:" + (before[i].getAttribute("data-fold-key") || "")] = true;
5908
+ }
5909
+ var after = next.querySelectorAll(".home-tools-fold");
5910
+ for (var j = 0; j < after.length; j++) {
5911
+ if (opened["k:" + (after[j].getAttribute("data-fold-key") || "")]) setToolsFoldOpen(after[j], true);
5912
+ }
5913
+ // 🔴 **キーボードの居場所も戻す(#424)。** 実測は人が行を開いた直後に届き、
5914
+ // しかも未確定の接続があるあいだ何度も届く。節ごと差し替えるとフォーカスを持った
5915
+ // 行が DOM から外れ、**次の Tab がページの先頭から**になる(数秒おきに繰り返される)
5916
+ var act = app.ownerDocument.activeElement;
5917
+ var focusKey = null;
5918
+ if (act && old.contains(act) && act.className === "home-tools-fold-summary") {
5919
+ focusKey = (act.parentNode && act.parentNode.getAttribute("data-fold-key")) || null;
5920
+ }
5921
+ old.parentNode.replaceChild(next, old);
5922
+ if (focusKey) {
5923
+ var back = next.querySelector('.home-tools-fold[data-fold-key="' + focusKey + '"] .home-tools-fold-summary');
5924
+ if (back) back.focus();
5925
+ }
5304
5926
  }
5305
5927
 
5306
5928
  /**
@@ -5363,6 +5985,41 @@ ${deriveCapTile.toString()}
5363
5985
  });
5364
5986
  }
5365
5987
 
5988
+ /**
5989
+ * 中身が空になった本文の枠を消す(#424・2026-09-05 の実機確認)。
5990
+ *
5991
+ * 🔴 **.body は枠(border)と余白(padding)を持っている。** Home では自己記述を
5992
+ * 上に出し、本文側からは同じ見出しを dropLegacyHomeSections が落とすので、
5993
+ * 冒頭の定型文が消えた案件では**中身が空のまま枠だけが残る**——使用ツールの下に
5994
+ * 「謎の白い枠」が出ていたのはこれ。中身がある案件では今までどおり出す。
5995
+ *
5996
+ * ⚠ 空白だけのテキストノード・空の p・br は「無い」と数える(Markdown の
5997
+ * 変換や節落としの後には、こういう抜け殻がふつうに残る)。
5998
+ */
5999
+ function hideEmptyBody(mount) {
6000
+ if (!mount) return;
6001
+ mount.hidden = !hasVisibleContent(mount);
6002
+ }
6003
+ /** 目に見えるものが1つでも入っているか(テキスト・画像・線・表など) */
6004
+ function hasVisibleContent(node) {
6005
+ var kids = node.childNodes || [];
6006
+ for (var i = 0; i < kids.length; i++) {
6007
+ var n = kids[i];
6008
+ // テキストは空白(改行・全角空白含む)だけなら無いものとして数える
6009
+ if (n.nodeType === 3) { if (String(n.nodeValue || "").trim()) return true; continue; }
6010
+ if (n.nodeType !== 1) continue; // コメントなどは数えない
6011
+ // ⚠ 大文字にそろえる(SVG の要素は tagName が小文字のまま来る)
6012
+ var tag = String(n.tagName || "").toUpperCase();
6013
+ // 中身が空でも場所を取るもの(画像・区切り線・表・タスクリストの升目)は「ある」。
6014
+ // **build 側の許可タグ(SANITIZE_OPTIONS)に合わせる**——ここだけ独自の一覧にすると、
6015
+ // 許可タグが増えた日に「文字が無いから空」と数えて本文が丸ごと消える
6016
+ if (tag === "IMG" || tag === "HR" || tag === "TABLE" || tag === "INPUT") return true;
6017
+ if (tag === "BR") continue;
6018
+ if (hasVisibleContent(n)) return true;
6019
+ }
6020
+ return false;
6021
+ }
6022
+
5366
6023
  // ---------- データソースと自動化(fleet-status.json 由来。無ければ非表示) ----------
5367
6024
  // 顧客も読む面なので内部用語(cron・stub等)を避け「毎晩自動で〜」調にする。
5368
6025
  var DS_TYPE = {
@@ -5817,32 +6474,333 @@ ${deriveCapTile.toString()}
5817
6474
  /**
5818
6475
  * 会議の取り込みの案内。**操作ではなく条件を示す。**
5819
6476
  *
5820
- * 取り込まれるには2つとも要る:
5821
- * ① cortex-notetaker Bot が会議に招待されている(=取り込む意思。これが同意のゲート)
5822
- * ② 会議名が照合キーのどれかを含む(含まなければ、録れても案件に振り分けられない)
6477
+ * 取り込まれるには2つとも要る(#418 のオーナー決定・2026-09-05):
6478
+ * ① 予定がこの案件のカレンダーにある(=どの案件かが決まる)
6479
+ * ② Gemini のメモが ON(=メモそのものが作られる)
6480
+ *
6481
+ * 🔴 **かつては3つだった。** 「bot を招待する/Google Meet フォルダを共有する」が
6482
+ * 必須条件の1つで、それが無いと bot はメモを**見ることもできなかった**。
6483
+ * bot を予定の出席者に自動で足すようになった(オーナー決定 A-1・#426)ので必須から降り、
6484
+ * 「うまく取り込まれないとき」の手段へ移した。**参加するだけの会議・他社主催の会議では
6485
+ * 今も唯一の手段**なので、消してはいない。
5823
6486
  *
5824
- * ②の材料を画面が出さないと、招待したのに届かない理由が分からない。
6487
+ * 並びは上から: 条件 → 合図(移行期間)→ 案件カレンダーの行 → うまく取り込まれないとき。
6488
+ * **合図を先頭から2番目へ下げた**のは、第一の条件がカレンダーになったから
6489
+ * (#117 で「カードで一番強い要素」にした判断を、条件が変わったのでここで畳み直す)。
5825
6490
  */
5826
6491
  function meetingHowTo(s) {
5827
6492
  // **2つの条件がANDであることを、読み飛ばせない形で出す。** 散文で並べていたときに
5828
6493
  // 「会議名は合っているのにBotを招待しておらず取り込まれなかった」事故が実際に起きた。
5829
- // 会議名の条件は上の行(ds-keys)が出すので、ここでは「上の語」と参照するに留める。
5830
6494
  var wrap = el("div", { class: "sec-how" });
5831
- wrap.appendChild(el("p", { text: "次の2つが揃った会議だけが取り込まれます。" }));
6495
+ wrap.appendChild(el("p", { text: "取り込まれる条件" }));
5832
6496
  var ul = el("ul", { class: "sec-how-list" });
5833
- ul.appendChild(el("li", { text: "cortex-notetaker Bot が招待されている(定例はシリーズに1回でOK)" }));
5834
- ul.appendChild(el("li", { text: "会議名に上の目印が入っている" }));
6497
+ ul.appendChild(el("li", { text: "予定がこの案件のカレンダーにある" }));
6498
+ ul.appendChild(el("li", { text: "Gemini のメモが ON" }));
5835
6499
  wrap.appendChild(ul);
5836
- wrap.appendChild(el("p", { text: "どちらか一方だけでは取り込まれません。" }));
5837
- wrap.appendChild(meetingFolderShare(meetingSignalOf(s), meetingAliasesOf(s).length));
6500
+ wrap.appendChild(el("p", { text: "この 2 つが揃った会議だけが取り込まれます。合図は不要です。" }));
6501
+ // 移行期間の1行。**廃止便(#418 の⑥)で消す。** 合図で決まった件数がゼロになった時点で
6502
+ // オーナーが切るので、それまでは今までどおり効く
6503
+ var signal = meetingSignalOf(s);
6504
+ wrap.appendChild(el("p", {
6505
+ class: "mt-legacy-note",
6506
+ text: signal
6507
+ ? "会議名の合図(【" + signal + "】)でも取り込めます(近く廃止)。"
6508
+ : "会議名の合図でも取り込めます(近く廃止)。",
6509
+ }));
6510
+ // ---- 2番目: 合図と別名(移行期間の材料。以前はカードの先頭にあった) ----
6511
+ var keys = meetingKeysEl(s);
6512
+ if (keys) wrap.appendChild(keys);
6513
+ var legacy = meetingAliasesEl(s);
6514
+ if (legacy) wrap.appendChild(legacy);
6515
+ // ---- 3番目: 案件カレンダーの行(この Issue の本体) ----
6516
+ wrap.appendChild(meetingCalendarSection(s));
6517
+ // ---- 4番目: うまく取り込まれないとき(必須条件から降りた手段) ----
6518
+ wrap.appendChild(meetingFolderShare());
5838
6519
  return wrap;
5839
6520
  }
5840
6521
 
5841
6522
  /**
5842
- * 主催者向けの選択肢: 「Google Meet」フォルダを1回共有すると、以後は招待が要らない。
6523
+ * 会議名に入れる合図(移行期間)。**以前は internals.forEach の col が組み立てていた。**
6524
+ *
6525
+ * 出す条件は完全に同じ(meetingSignalOf が非空を返すのは
6526
+ * kind === "会議" && ingestState === "on" のときだけ=meetingHowTo の呼び出し条件と同一)
6527
+ * なので、移しても出る/出ないは1件も変わらない。**2箇所に持たない**ために col 側は消した。
6528
+ *
6529
+ * 見出しの語だけ「会議名にこれを入れてください」から改めた。**必須条件から降りたのに
6530
+ * 命令形のままだと、すぐ上の条件(①②)と矛盾する**——読む人は、どちらが本当なのか
6531
+ * 判断できない。括弧を付けた形でそのまま出すのは以前と同じ(語だけ見せると 【KC定例】 の
6532
+ * ように混ぜて書かれ、どこも一致せず未仕分けへ落ちる。実際に起きた)。
6533
+ */
6534
+ function meetingKeysEl(s) {
6535
+ var signal = meetingSignalOf(s);
6536
+ if (!signal) return null;
6537
+ return el("div", {
6538
+ class: "ds-keys",
6539
+ title: "会議名のどこかにこの括弧付きの目印が入っていると、この案件に振り分けられます(大小文字は問いません)。括弧の中に他の文字を混ぜると一致しません。",
6540
+ }, [
6541
+ el("span", { class: "ds-keys-label", text: "会議名の合図(移行期間)" }),
6542
+ el("code", { class: "ds-keys-code", text: "【" + signal + "】" }),
6543
+ el("span", { class: "ds-keys-or", text: "または" }),
6544
+ el("code", { class: "ds-keys-code", text: "[" + signal + "]" }),
6545
+ ]);
6546
+ }
6547
+
6548
+ /**
6549
+ * 経過措置で登録された会議名(別名)。**読み取り専用の記録として出す(#117)。**
6550
+ *
6551
+ * 目印が無くても入る会議=括弧運用へ移る前から走っていて、改名を頼めなかった定例。
6552
+ * 出さないと「なぜこれだけ入るのか」が画面から分からないので残すが、**操作は一切出さない**
6553
+ * (最初に決めた合図が唯一の真実。変更が要るときはリポジトリの ingest-config.json を直接編集する)。
6554
+ *
6555
+ * meetingKeysEl と同じく col から移設した。同意文(.mt-privacy)が「目印か別名」と
6556
+ * 言っているので、**その別名が画面のどこかに並んでいる**必要がある——指す先が無いものを
6557
+ * 指すと、そこがまた嘘になる。
6558
+ */
6559
+ function meetingAliasesEl(s) {
6560
+ var aliases = meetingAliasesOf(s);
6561
+ if (!aliases.length) return null;
6562
+ var legacy = el("div", { class: "ds-legacy" }, [
6563
+ el("span", {
6564
+ text: "経過措置で登録された会議名(目印が無くても取り込まれます。変更はリポジトリの ingest-config.json で行います)",
6565
+ }),
6566
+ ]);
6567
+ var chips = el("div", { class: "ds-legacy-chips" });
6568
+ aliases.forEach(function (a) {
6569
+ chips.appendChild(el("span", {
6570
+ class: "ds-legacy-chip",
6571
+ text: a,
6572
+ title: "この名前の会議は、目印が無くてもこの案件に振り分けられます(大小文字・前後の空白は問いません。ファイル名に使えない記号(コロン・スラッシュ等)は同じものとして扱います)。",
6573
+ }));
6574
+ });
6575
+ legacy.appendChild(chips);
6576
+ return legacy;
6577
+ }
6578
+
6579
+ /**
6580
+ * ボタンを出してよいか(#428・分類B)。
5843
6581
  *
5844
- * **上の bot 招待の説明は残したまま足す(2026-08-29 オーナー承認)。** 招待方式は
5845
- * 参加するだけの会議・他社主催の会議で今も唯一の手段なので、置き換えではない。
6582
+ * 🔴 **画面には「この人がこの案件のオーナーか管理者か」を知る材料が無い。**
6583
+ * ポータルが渡す名乗り券は権限を載せない(券はブラウザの JS から読めるので、
6584
+ * 載せた情報は案件ビューアを開ける人全員に配った情報になる——ポータルの
6585
+ * actor-ticket.ts が明示的にそう決めている)。接続状況の応答にも役割は無い。
6586
+ *
6587
+ * だから**権限の正本はサーバー側だけ**に置き、画面は「押したら断られたか」を覚える。
6588
+ * 画面が別の判定を持つと二重の真実になり、名簿が変わったときに片方だけが古くなる。
6589
+ *
6590
+ * **覆すときはここ1か所**——接続状況の応答に真偽が1つ載れば、押す前に出し分けられる。
6591
+ */
6592
+ var CALENDAR_DENIED = false;
6593
+ function calendarCanRequest() { return !CALENDAR_DENIED && !!actorTicket(); }
6594
+
6595
+ /**
6596
+ * 案件カレンダーの行(#428・設計は #418 の §6)。
6597
+ *
6598
+ * **帯は症状・行は一手・繰り返さない。** 帯を出すのは「まだ無い」と「作れなかった」の
6599
+ * 2つだけ——作成中も作成済みも症状ではないので、帯を出すと画面が常に警告色になる。
6600
+ */
6601
+ function meetingCalendarSection(s) {
6602
+ // 🔴 **liveList で包む。** 会議のモーダルを開いたまま「作る」を押すと、
6603
+ // 差し替え(refreshDataSources)はモーダルが開いている間なにもしない。
6604
+ // 追いつくのは [data-live-cap] の箱だけなので、包まないと**閉じて開き直すまで
6605
+ // 「作成中」にも「作成済み」にもならない**(Backlog / Drive / Figma と同じ作法)
6606
+ return liveList("会議", function () { return meetingCalendarSig(s); }, function (box) {
6607
+ meetingCalendarBuild(s, box);
6608
+ });
6609
+ }
6610
+
6611
+ /**
6612
+ * 判定に渡す観測の文脈。**組み立てはここ 1 か所**——2 か所で作ると、片方だけ
6613
+ * LIVE_STATE_SEEN を渡し忘れて「観測前でも『作る』が出る」形へ静かに戻る
6614
+ */
6615
+ function calendarCtx() {
6616
+ return { seen: LIVE_STATE_SEEN, rawCalendar: LIVE_STATE_RAW_CALENDAR };
6617
+ }
6618
+
6619
+ /** 描き直しの要否。**phase と ID と観測の時刻**(この3つ以外では見た目が変わらない) */
6620
+ function meetingCalendarSig(s) {
6621
+ var cal = calendarPhase(s, LIVE_STATE, calendarCtx(), Date.now());
6622
+ return cal.phase + "\t" + cal.calendarId + "\t" + cal.note + "\t" + cal.detail + "\t" + cal.at;
6623
+ }
6624
+
6625
+ function meetingCalendarBuild(s, box) {
6626
+ var cal = calendarPhase(s, LIVE_STATE, calendarCtx(), Date.now());
6627
+ // **毎回付け直す。** liveList の箱は差し替えで中身だけを作り直すので、
6628
+ // class と data-phase をここで確定させておかないと、最初の1回の値が残る
6629
+ box.className = "ds-livelist mt-cal";
6630
+ box.setAttribute("data-phase", cal.phase);
6631
+
6632
+ // まだ観測が届いていない。**帯もボタンも出さない**——ここで「まだありません」+「作る」を
6633
+ // 出すと、既にカレンダーがある案件で速い人が押して 2 本目を作る
6634
+ if (cal.phase === "unknown") {
6635
+ box.appendChild(el("p", { class: "mt-cal-note", text: "案件カレンダーの状況を確認中です。" }));
6636
+ return;
6637
+ }
6638
+
6639
+ // 行はあるのに ID も既知の状態も引けない。**押させない**——待てば直るが、
6640
+ // 押すと 2 本目が出来て戻せない(calendarPhase の項)
6641
+ if (cal.phase === "unclear") {
6642
+ box.appendChild(el("p", {
6643
+ class: "mt-cal-note",
6644
+ text: "案件カレンダーの状況を確認しています。しばらく待っても変わらなければ、cortex の担当者に連絡してください。",
6645
+ }));
6646
+ return;
6647
+ }
6648
+
6649
+ if (cal.phase === "ready") {
6650
+ box.appendChild(el("a", {
6651
+ class: "mt-cal-link", href: calendarTemplateUrl(cal.calendarId), target: "_blank", rel: "noopener",
6652
+ text: "この案件の予定を作る →",
6653
+ }));
6654
+ box.appendChild(el("a", {
6655
+ class: "mt-cal-link", href: calendarAddUrl(cal.calendarId), target: "_blank", rel: "noopener",
6656
+ text: "マイカレンダーに追加 →",
6657
+ }));
6658
+ // **見分けの付くクラスを付ける。** 同じカードに copyRow が2つ(bot のアカウントと
6659
+ // カレンダー ID)並ぶので、素の .rt-copy-text ではどちらか分からない
6660
+ box.appendChild(el("div", { class: "mt-cal-id" }, [copyRow(cal.calendarId)]));
6661
+ // 色は**見る人ごとの設定**で、仕組みでは揃えられない(#418 の調査4)。
6662
+ // 質問にはせず、事実として1行だけ置く(オーナー決定 A-5: 画面に計器は出さない)
6663
+ box.appendChild(el("p", { class: "mt-cal-note", text: "色はご自身のカレンダーで変えられます。" }));
6664
+ // 作成済みのまま進行中・失敗の報告が最新なら、その1行だけ添える。
6665
+ // **「作れませんでした」とは言わない**(出来ているので嘘になり、押し直すと 2 本目が作られる)
6666
+ if (cal.note === "requested") {
6667
+ box.appendChild(el("p", { class: "mt-cal-note", text: "共有を更新しています(最長 " + CALENDAR_WAIT_MIN + " 分)。" }));
6668
+ } else if (cal.note) {
6669
+ box.appendChild(el("p", { class: "mt-cal-warn", text: "共有の更新が終わっていません(" + cal.note + ")。" }));
6670
+ }
6671
+ box.appendChild(calendarRequestButton("共有を更新", "share"));
6672
+ return;
6673
+ }
6674
+
6675
+ if (cal.phase === "pending") {
6676
+ box.appendChild(el("p", { class: "mt-cal-note", text: "作成中(最長 " + CALENDAR_WAIT_MIN + " 分。自動で更新されます)" }));
6677
+ return;
6678
+ }
6679
+
6680
+ if (cal.phase === "stale") {
6681
+ // 約束の3倍を過ぎた=裏の経路が止まっている(トリガー未設置・認可切れ)。
6682
+ // **「依頼が届いていません」とは言わない**——行があるということは、依頼はサーバに
6683
+ // 届いている(行を書いたのがサーバ)。利用者にできることは無いので、押せるものは
6684
+ // 出さずに連絡先だけを出す
6685
+ box.appendChild(el("p", {
6686
+ class: "mt-cal-note",
6687
+ text: "作成が終わっていません(依頼は届いています)。cortex の担当者に連絡してください。",
6688
+ }));
6689
+ return;
6690
+ }
6691
+
6692
+ if (cal.phase === "failed") {
6693
+ box.appendChild(el("div", { class: "mt-cal-banner", text: "カレンダーを作れませんでした" }));
6694
+ // **空の理由は出さない**(空の箱を出すと、理由があるのに読めないように見える)
6695
+ if (cal.detail) box.appendChild(el("p", { class: "mt-cal-note", text: cal.detail }));
6696
+ box.appendChild(calendarRequestButton("もう一度", "retry"));
6697
+ return;
6698
+ }
6699
+
6700
+ box.appendChild(el("div", { class: "mt-cal-banner", text: "案件カレンダーがまだありません" }));
6701
+ box.appendChild(calendarRequestButton("この案件のカレンダーを作る", "create"));
6702
+ }
6703
+
6704
+ /**
6705
+ * 依頼を出すボタン。**3つ(作る・共有を更新・もう一度)とも同じ1本の経路**で、
6706
+ * 押した直後の見え方だけが違う(裏の経路は冪等・#427)。
6707
+ *
6708
+ * **確認を出すのは「作る」だけ。** 「共有を更新」「もう一度」は取り返しがつく
6709
+ * (同じ依頼が立ち直るだけ)ので、確認を挟むと押す手が増えるだけになる。
6710
+ *
6711
+ * 🔴 **本文は空**(projectKey だけ)。共有先のメールをブラウザから受け取らない——
6712
+ * ?t= を持つ人(顧客も持ちうる)が社外アドレスに権限を張れる経路になる(#427 の設計)。
6713
+ */
6714
+ function calendarRequestButton(label, kind) {
6715
+ // 🔴 **名乗り券が無い人に「オーナーが作れます」と出さない。** サーバは券が無くても
6716
+ // 403 を返すが、その人の直し方は「ポータルから開き直す」であって「オーナーに頼む」
6717
+ // ではない。断られ方が同じでも、次の一手は別
6718
+ if (!actorTicket()) return calendarPortalNote();
6719
+ if (!calendarCanRequest()) return calendarOwnerOnlyNote();
6720
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: label });
6721
+ var msg = el("div", { class: "ds-toggle-msg" });
6722
+ var wrap = el("div", { class: "mt-cal-act" }, [btn, msg]);
6723
+ btn.addEventListener("click", function () {
6724
+ var fail = function (text) {
6725
+ btn.disabled = false;
6726
+ msg.className = "ds-toggle-msg is-error";
6727
+ msg.textContent = "⚠ " + text;
6728
+ };
6729
+ var go = function () {
6730
+ btn.disabled = true; // 二重送信で 2 件目の依頼を立てない
6731
+ msg.className = "ds-toggle-msg";
6732
+ msg.textContent = "送信中…";
6733
+ intakeJson(callIntake("calendar-request", { projectKey: SITE.intake.projectKey })).then(
6734
+ function (res) {
6735
+ // 🔴 **403 かつサーバが語で名乗ったときだけ、押せない 1 行に落とす。**
6736
+ // 403 は権限以外でも返る(WAF・CDN・トークン失効)。素の 403 で文言を確定させると、
6737
+ // 一時的な障害のときにオーナー本人へ「あなたには作れません」と言うことになる。
6738
+ // **5xx とも混ぜない**——一時的な障害でボタンを永久に消すと、直ったあとも誰も作れない。
6739
+ //
6740
+ // 語は intake の実装(logic.mjs の planCalendarRequest / calendarShareFrom)が正本。
6741
+ // **断り方で次の一手が違う**ので、1 つに丸めない:
6742
+ // CALENDAR_ROLE_REQUIRED … 権限が無い → 案件のオーナーに頼む
6743
+ // CALENDAR_PORTAL_REQUIRED … ポータル経由で開いていない → 開き直す
6744
+ // CALENDAR_ACTOR_EMAIL_REQUIRED … 券にメールが無い → 同上
6745
+ var code = res.j && typeof res.j.code === "string" ? res.j.code : "";
6746
+ if (res.status === 403 && (code === "CALENDAR_ROLE_REQUIRED" || code === "CALENDAR_PORTAL_REQUIRED" || code === "CALENDAR_ACTOR_EMAIL_REQUIRED")) {
6747
+ CALENDAR_DENIED = true;
6748
+ var note = code === "CALENDAR_ROLE_REQUIRED"
6749
+ ? calendarOwnerOnlyNote()
6750
+ : calendarPortalNote();
6751
+ if (wrap.parentNode) wrap.parentNode.replaceChild(note, wrap);
6752
+ return;
6753
+ }
6754
+ // **サーバの文をそのまま出す。** 断り方は intake が言い分けているので、
6755
+ // 画面が言い換えると 2 枚になる(片方だけ古くなる)
6756
+ if (!res.ok) return fail((res.j && res.j.message) || "依頼できませんでした。しばらくしてからもう一度お試しください。");
6757
+ msg.className = "ds-toggle-msg is-ok";
6758
+ msg.textContent = "✓ 依頼しました。作成中(最長 " + CALENDAR_WAIT_MIN + " 分)です。";
6759
+ // pending-ops(#388)は使わない。**あれは registeredNow のキーで live 判定する
6760
+ // 仕組みで、カレンダーは stateNow 由来**。fresh で取り直せば「作成中」が出る
6761
+ loadLiveFleet(true);
6762
+ },
6763
+ function () { fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); },
6764
+ );
6765
+ };
6766
+ if (kind !== "create") return go();
6767
+ // **確認を出している間も押せなくする。** 押せたままだと確認が 2 枚重なりうる
6768
+ //(openConfirm 自身も多重オープンを無音で断るが、断られた側は無反応に見える)
6769
+ btn.disabled = true;
6770
+ openConfirm(
6771
+ [
6772
+ "cortex-notetaker が「" + (SITE.title || "この案件") + "」のカレンダーを作り、案件の社内メンバーに予定作成の権限で共有します。",
6773
+ "最長 " + CALENDAR_WAIT_MIN + " 分かかります。",
6774
+ ],
6775
+ { okLabel: "作る", okDanger: false, returnTo: btn },
6776
+ ).then(function (yes) {
6777
+ btn.disabled = false; // go() が改めて立てる。キャンセルならここで戻る
6778
+ if (yes) go();
6779
+ });
6780
+ });
6781
+ return wrap;
6782
+ }
6783
+
6784
+ /** 断られた人に出す1行。**ボタンは出さない**(何度押しても同じ答えなので) */
6785
+ function calendarOwnerOnlyNote() {
6786
+ return el("p", { class: "mt-cal-note", text: "案件のオーナーが作れます。" });
6787
+ }
6788
+
6789
+ /** ポータル経由で開いていない人に出す1行。**直し方がオーナーに頼むのとは別** */
6790
+ function calendarPortalNote() {
6791
+ return el("p", {
6792
+ class: "mt-cal-note",
6793
+ text: "案件カレンダーを作るには、ポータルからこの案件を開き直してください。",
6794
+ });
6795
+ }
6796
+
6797
+ /**
6798
+ * うまく取り込まれないときの手段: 「Google Meet」フォルダを1回共有すると、招待が要らない。
6799
+ *
6800
+ * 🔴 **2026-09-05 に必須条件から降ろした(#428・オーナー決定 A-1)。** bot を予定の
6801
+ * 出席者に自動で足すようになった(#426)ので、ふだんは「予定を案件カレンダーに置く」
6802
+ * だけで足りる。**消してはいない**——参加するだけの会議・他社主催の会議・自動招待が
6803
+ * 間に合わなかった回では今も唯一の手段なので、見出しを変えて下へ移した。
5846
6804
  *
5847
6805
  * **共有対象は 2026-09-03 に「Meet Recordings」から「Google Meet」へ改めた(#393)。**
5848
6806
  * Gemini の保存先が変わり、メモは「Google Meet」の中の会議ごとのフォルダに入るようになった。
@@ -5858,10 +6816,12 @@ ${deriveCapTile.toString()}
5858
6816
  * 見えないことは別なので、両方を1行で正直に書く。
5859
6817
  */
5860
6818
  // 注: ここは埋め込みJSのテンプレートリテラル内。コメントでもバッククォートを使わない
5861
- function meetingFolderShare(signal, aliasCount) {
6819
+ // 引数は取らない。**文面が案件ごとに変わらなくなった**(#428)——同意文が定文になり、
6820
+ // 目印も別名も文の中で参照しなくなったので、渡す材料が無い
6821
+ function meetingFolderShare() {
5862
6822
  var box = el("div", { class: "mt-share" });
5863
- box.appendChild(el("div", { class: "mt-share-title", text: "毎回の招待をやめる方法(会議の主催者向け・任意)" }));
5864
- box.appendChild(el("p", { text: "自分の Google ドライブにある「Google Meet」フォルダ(2026年7月より前から使っている環境では「Meet Recordings」)を、下のアカウントに「閲覧者」で1回だけ共有すると、以後あなたが主催する会議は招待が要らなくなります。会議名の目印はこれまでどおり必要です。「Google Meet」フォルダがまだ無い場合は、Gemini のメモを ON にした会議を1回主催すると作られます。" }));
6823
+ box.appendChild(el("div", { class: "mt-share-title", text: "うまく取り込まれないとき(会議の主催者向け・任意)" }));
6824
+ box.appendChild(el("p", { text: "自分の Google ドライブにある「Google Meet」フォルダ(2026年7月より前から使っている環境では「Meet Recordings」)を、下のアカウントに「閲覧者」で1回だけ共有すると、そのフォルダに入るメモを bot が見られるようになります(取り込まれる条件は上のとおりです)。「Google Meet」フォルダがまだ無い場合は、Gemini のメモを ON にした会議を1回主催すると作られます。" }));
5865
6825
  box.appendChild(copyRow(DRIVE_INGEST_ACCOUNT));
5866
6826
 
5867
6827
  /*
@@ -5873,31 +6833,34 @@ ${deriveCapTile.toString()}
5873
6833
  * すぐ下の注記(.mt-share-note)は「**取り込むか**」に答えていて、
5874
6834
  * 「**見るのか**」に答えていない。共有をためらう理由はそちらなので、専用に1行置く。
5875
6835
  *
5876
- * **実装と一致していることを確かめてある**(apps-script/src/Main.gs:79-102・
5877
- * Router.gs:65-91・Transcripts.gs の getMeetingName_):
5878
- * - 振り分けに使うのは file.getName()(ファイル名)だけ
5879
- * - 当たるのは**目印**(meetingNameHasKey_)**または別名の完全一致**
5880
- * (meetingNameIsAlias_)の2通り。**別名は目印が要らない**
5881
- * - どちらにも当たらないファイルは getTranscriptText_(Docs のエクスポート)を
6836
+ * 🔴 **2026-09-05 に真ん中の1文を書き換えた(#428・A-6 オーナー承認)。**
6837
+ * 案件カレンダーの予定に添付されたメモは、**目印が無くても中身を開く**ようになった
6838
+ *(#418 の設計 §1)。前の文(「目印か別名に当てはまらないメモは開かない」)は
6839
+ * そのまま残すと嘘になる。**前後の2文は別々に承認済みの文なのでそのまま残し**、
6840
+ * 真ん中だけを差し替えてある。
6841
+ *
6842
+ * **実装と一致していることを確かめてある**(apps-script/src/Main.gs・Router.gs の
6843
+ * routeTranscript_・Transcripts.gs の getMeetingName_):
6844
+ * - 当たるのは**カレンダーの添付**(fileId の完全一致)**または目印**
6845
+ * (meetingNameHasKey_)**または別名の完全一致**(meetingNameIsAlias_)の
6846
+ * **3通り**。目印と別名は移行期間だけの経路(#418 の⑥で廃止)
6847
+ * - どれにも当たらないファイルは getTranscriptText_(Docs のエクスポート)を
5882
6848
  * 呼ぶ前に return する=**中身を開かない**
5883
6849
  * - どのリポジトリにも Slack にも書かない
5884
6850
  *
5885
6851
  * 🔴 **最初の版は「目印が入っていないメモは開かない」と書いて嘘になっていた**
5886
6852
  *(別名の経路を見落としていた)。しかも同じカードの .ds-legacy が
5887
6853
  * 「目印が無くても取り込まれます」と並んで出ていた——**カードが自分を否定していた**。
5888
- * 文言を触るときは、必ず Router.gs の照合の**2通り**を見直すこと
6854
+ * 文言を触るときは、必ず Router.gs の照合の**3通り**を見直すこと。
6855
+ *
6856
+ * ⚠ **別名の有無で分岐しない。** 承認された文は1本で、分岐すると承認外の文が生まれる。
6857
+ * 文が指す「別名」は meetingAliasesEl が同じカードに並べている(指す先はある)。
5889
6858
  */
5890
- var mark = signal ? "案件の目印([" + signal + "] など)" : "案件の目印";
5891
- // **別名が1件も無い案件では「上に並ぶ登録済みの会議名」が画面に存在しない。**
5892
- // 指す先が無いものを指すと、そこがまた嘘になるので、その句だけを機械的に落とす
5893
- //(新しい言い回しは足さない)
5894
- var scope = aliasCount
5895
- ? mark + "と、上に並ぶ登録済みの会議名——このどちらにも当てはまらない"
5896
- : mark + "に当てはまらない";
5897
6859
  box.appendChild(el("p", {
5898
6860
  class: "mt-privacy",
5899
- text: "⚠ 共有を開始すると、bot はこのフォルダの中のフォルダ名とファイル名だけを見ます。" + scope +
5900
- "メモは、中身を開きません。読み取りも保存も行わないので、1on1や面談のメモがどこかに残ることはありません。",
6861
+ text: "⚠ 共有を開始すると、bot はこのフォルダの中のフォルダ名とファイル名だけを見ます。" +
6862
+ "案件カレンダーの予定に添付されたメモと、目印か別名に当たるメモだけを開きます。それ以外は中身を開きません。" +
6863
+ "読み取りも保存も行わないので、1on1や面談のメモがどこかに残ることはありません。",
5901
6864
  }));
5902
6865
 
5903
6866
  // **フォルダを探すところから助ける。** フォルダIDは人ごとに違うので固定URLは作れないが、
@@ -5927,7 +6890,10 @@ ${deriveCapTile.toString()}
5927
6890
 
5928
6891
  // 権限の話(**bot から見える状態になる**)は、挙動の話(⚠ の注釈)と隣に置かない。
5929
6892
  // 並べると「見ない」→「見える」と続いて、正本が丁寧に分けている区別が消える
5930
- box.appendChild(el("p", { class: "mt-share-note", text: "共有すると、そのフォルダに入る会議メモは bot から見える状態になります(実際に取り込まれるのは、会議名に目印が入っている会議だけです)。" }));
6893
+ // 🔴 **「取り込まれるのは会議名に目印が入っている会議だけ」ではなくなった(#428)。**
6894
+ // カレンダーの予定に添付されたメモは目印が無くても取り込まれる。ここを直さないと、
6895
+ // すぐ上の同意文(3通りの照合を書いている)と**同じカードの中で食い違う**
6896
+ box.appendChild(el("p", { class: "mt-share-note", text: "共有すると、そのフォルダに入る会議メモは bot から見える状態になります(実際に取り込まれるのは、このカードの一番上に出ている条件に当てはまる会議だけです)。" }));
5931
6897
 
5932
6898
  return box;
5933
6899
  }
@@ -6728,6 +7694,15 @@ ${deriveCapTile.toString()}
6728
7694
  * (=会議・共有資料の「未使用」表示が一番効いてほしい案件)でだけ機能が死ぬ。
6729
7695
  */
6730
7696
  var LIVE_STATE_SEEN = false;
7697
+ /**
7698
+ * 生の応答にあった案件カレンダーの行の本数(#428)。**sanitize の前に数える。**
7699
+ *
7700
+ * 🔴 **LIVE_STATE から数え直せない。** sanitize は語彙に無い語の行を落とすので、
7701
+ * 落ちた後を数えると「知らない語で落ちた」と「1 本も無い」が同じ 0 になる。
7702
+ * calendar はその差が致命的な唯一の問い——0 と読むと画面が「カレンダーがまだ
7703
+ * ありません」+「作る」を出し、押した人が 2 本目を作る(calendarPhase の項)。
7704
+ */
7705
+ var LIVE_STATE_RAW_CALENDAR = 0;
6731
7706
  /**
6732
7707
  * 鍵がいま登録されているか(op=state の secretsNow・列2)。取得前・取得失敗時は null。
6733
7708
  *
@@ -7196,9 +8171,23 @@ ${deriveCapTile.toString()}
7196
8171
  * 🔴 **未確定の行が無ければタイマを張らない。** テストは load の後に fetch を注入するので、
7197
8172
  * 無条件に張ると jsdom が閉じられず node --test が返らなくなる(反映待ちで実際に踏んだ形)。
7198
8173
  */
8174
+ /**
8175
+ * 案件カレンダーの作成を待っているか(#428)。
8176
+ *
8177
+ * 🔴 **「自動で更新されます」と画面に書いた以上、実際に取り直す。** 仮置き(#388)の
8178
+ * 数え方(unsettledNow)にはカレンダーの依頼が1件も入らないので、これを見ないと
8179
+ * **取り直しが1回も張られず、押した人は「作成中」のまま放置される**。
8180
+ *
8181
+ * **期限は依頼の時刻から測る**(画面を開いた時刻ではない)。他の案件の画面を開いた人が
8182
+ * 何時間も叩き続けないよう、止まったと見なす時刻(CALENDAR_STALE_MS)で終わる
8183
+ * ——そこから先は取り直しても答えは変わらない(人が担当者に連絡する番)。
8184
+ */
8185
+ function calendarPollWanted() { return calendarWaiting(LIVE_STATE, Date.now()); }
8186
+
7199
8187
  function refreshStateTimer() {
7200
8188
  var n = unsettledNow();
7201
- if (!n) {
8189
+ var cal = calendarPollWanted();
8190
+ if (!n && !cal) {
7202
8191
  if (stateTimer) { clearTimeout(stateTimer); stateTimer = null; }
7203
8192
  stateSince = null;
7204
8193
  return;
@@ -7206,7 +8195,10 @@ ${deriveCapTile.toString()}
7206
8195
  if (stateTimer) return;
7207
8196
  if (!SITE.intake || typeof fetch !== "function") return;
7208
8197
  if (stateSince == null) stateSince = Date.now();
7209
- var delay = nextStateDelay(n, stateSince, Date.now());
8198
+ // カレンダー待ちは**仮置きの15分の期限に載せない**(作成の約束は20分で、載せると
8199
+ // 最後の5分だけ静かに止まる=文言が嘘になる時間帯ができる)。依頼の時刻から測った
8200
+ // 期限は calendarPollWanted が持っているので、ここは間隔だけを決める
8201
+ var delay = cal ? UNSETTLED_POLL_MS : nextStateDelay(n, stateSince, Date.now());
7210
8202
  if (delay == null) return;
7211
8203
  stateTimer = setTimeout(stateTick, delay);
7212
8204
  }
@@ -7513,62 +8505,27 @@ ${deriveCapTile.toString()}
7513
8505
  // デザイン(Figmaファイル)だけで、どちらも**レコードの行が同じ先へリンクしている**。
7514
8506
  // 会議・共有資料は url を持たない。
7515
8507
  var col = el("div", { class: "ds-col" }, []);
7516
- // 照合キーの表示はここ1箇所に集約する(下の案内と二重に出さない)。
7517
- // **ON/OFFのトグルは出さない。** 取り込むかどうかは「Bot を会議に招待したか」で決まる
7518
- // (Router.gs:「フィルタは持たない…招待が同意のゲート」)。enabled はルーティングの
7519
- // 可否であって取り込みの可否ではないので、OFFにすると**Botは録り続けるのに
7520
- // 文字起こしがINBOXに溜まる**という、招待しないより悪い状態を作れてしまう。
7521
- // **合図(会議名に入れる目印)を出す。** 照合は「会議名に [合図] / 【合図】 が
7522
- // 入っているか」だけ(Router.gs)。合図は案件リポの meetingKey、未宣言なら艦隊
7523
- // レジストリのキーで、後者は案件リポも fleet-status も知らない——投入設定からしか取れない。
8508
+ // **ON/OFFのトグルは出さない。** 取り込むかどうかは会議の設定(enabled)と
8509
+ // 予定の置き場で決まる。enabled はルーティングの可否であって取り込みの可否ではないので、
8510
+ // OFFにすると**Botは録り続けるのに文字起こしがINBOXに溜まる**という、
8511
+ // 招待しないより悪い状態を作れてしまう。
7524
8512
  //
7525
- // **括弧を付けた形で出す。** 語だけ見せると 【KC定例】 のように混ぜて書かれ、
7526
- // どこも一致せず未仕分けへ落ちる(実際に起きた)。そのままコピーできる形にする。
7527
- // **自動取り込みが動いているときだけ出す。** Teams等で手動運用している案件に
7528
- // 会議名の条件を出すと、自動で拾われると誤解させる(実際には拾われない)。
7529
- var signal = meetingSignalOf(s);
7530
- var aliases = meetingAliasesOf(s);
7531
- if (signal) {
7532
- // **カードで一番強い要素にする(#117)。** 実機で見たとき、目印の案内が
7533
- // 別名の一覧や注記と同じ細字で並んでいて、「まず会議名にこれを入れる」が
7534
- // 伝わらなかった。最初に決めた合図が唯一の真実なので、そこだけを太く出す。
7535
- //
7536
- // **括弧を付けた形でそのまま出す。** 語だけ見せると 【KC定例】 のように
7537
- // 混ぜて書かれ、どこも一致せず未仕分けへ落ちる(実際に起きた)。
7538
- col.appendChild(el("div", {
7539
- class: "ds-keys",
7540
- title: "会議名のどこかにこの括弧付きの目印が入っていると、この案件に振り分けられます(大小文字は問いません)。括弧の中に他の文字を混ぜると一致しません。",
7541
- }, [
7542
- el("span", { class: "ds-keys-label", text: "会議名にこれを入れてください" }),
7543
- el("code", { class: "ds-keys-code", text: "【" + signal + "】" }),
7544
- el("span", { class: "ds-keys-or", text: "または" }),
7545
- el("code", { class: "ds-keys-code", text: "[" + signal + "]" }),
7546
- ]));
7547
- }
7548
- if (aliases.length) {
7549
- // **読み取り専用の記録として出す(#117)。** 目印が無くても入る会議=括弧運用へ
7550
- // 移る前から走っていて、改名を頼めなかった定例。出さないと「なぜこれだけ入るのか」が
7551
- // 画面から分からないので残すが、**操作は一切出さない**。
7552
- //
7553
- // 以前は1件ずつ外せるようにしていた。実機確認でユーザーが「最初に決めたキー(合図)が
7554
- // 唯一真実」と確定させたので、画面から触れる余地を無くす——別名を足したり外したり
7555
- // できる限り、合図と別名のどちらが正なのかが人によって揺れ続ける。
7556
- // 変更が要るときはリポジトリの ingest-config.json を直接編集する(Lambda も断る)。
7557
- var legacy = el("div", { class: "ds-legacy" }, [
7558
- el("span", {
7559
- text: "経過措置で登録された会議名(目印が無くても取り込まれます。変更はリポジトリの ingest-config.json で行います)",
7560
- }),
7561
- ]);
7562
- var chips = el("div", { class: "ds-legacy-chips" });
7563
- aliases.forEach(function (a) {
7564
- chips.appendChild(el("span", {
7565
- class: "ds-legacy-chip",
7566
- text: a,
7567
- title: "この名前の会議は、目印が無くてもこの案件に振り分けられます(大小文字・前後の空白は問いません。ファイル名に使えない記号(コロン・スラッシュ等)は同じものとして扱います)。",
7568
- }));
7569
- });
7570
- legacy.appendChild(chips);
7571
- col.appendChild(legacy);
8513
+ // 🔴 **合図(.ds-keys)と別名(.ds-legacy)はここから meetingHowTo へ移した(#428)。**
8514
+ // 取り込まれる第一の条件が「予定が案件カレンダーにある」になったので、合図は
8515
+ // 条件の見出しより**下**に出す必要がある。ここ(col)は internalControls より前に
8516
+ // 並ぶので、置いたままでは順序を変えられない。**2箇所に持たない**ために消してある
8517
+ // ——出す条件はほぼ同じ(meetingSignalOf / meetingAliasesOf が非空を返すのは
8518
+ // kind === "会議" && ingestState === "on" のときだけ=meetingHowTo の呼び出し条件)。
8519
+ //
8520
+ // 🔴 **ただし1つだけ違う: internalControls は SITE.intake が無いと丸ごと null を返す。**
8521
+ // 投入設定が無い案件(案件リポの meetingKey だけがある形)は、移設しただけだと
8522
+ // 合図が画面から消える。その案件ではカレンダーの行も出せない(依頼を出す先が無い)
8523
+ // ので、**ここで従来どおり出す**。同じ組み立て関数を呼ぶので文言は1箇所のまま
8524
+ if (!SITE.intake) {
8525
+ var keysEl = meetingKeysEl(s);
8526
+ if (keysEl) col.appendChild(keysEl);
8527
+ var legacyEl = meetingAliasesEl(s);
8528
+ if (legacyEl) col.appendChild(legacyEl);
7572
8529
  }
7573
8530
  if (s.driveSync === false) {
7574
8531
  col.appendChild(el("span", { class: "ds-drive-note", text: "Drive自動同期は未設定(手動で置いたファイルの変換のみ)" }));
@@ -11426,6 +12383,8 @@ ${deriveCapTile.toString()}
11426
12383
  LIVE_STATE = sanitizeStateNow(j && j.stateNow);
11427
12384
  // **欄の有無をそのまま覚える。** sanitize を通した後では「読めて0件」が消える
11428
12385
  LIVE_STATE_SEEN = !!(j && Array.isArray(j.stateNow));
12386
+ // **カレンダーの行だけは sanitize の前に数える**(#428・上の宣言の項)
12387
+ LIVE_STATE_RAW_CALENDAR = calendarRowCount(j && j.stateNow);
11429
12388
  // 🔴 **鍵・能力・組織も fleet より先に読む(工程1.5)。** まとめ表を
11430
12389
  // まだ持たない案件は下で throw するので、ここより後に置くと
11431
12390
  // **立ち上げ直後の案件=この修正が一番効く案件**だけ素通りする
@@ -14463,6 +15422,12 @@ ${deriveCapTile.toString()}
14463
15422
  var valid = false;
14464
15423
  TABS.forEach(function (t) { if (t.key === tab) valid = true; });
14465
15424
  if (valid) listState.tab = tab;
15425
+ // ⚠ **?tab= がまるごと無いときは既定のタブへ戻す(#424)。** 文中の「操作タブ」の
15426
+ // リンクは履歴を積むので、戻ると ?tab= の無いアドレスへ帰ってくる。ここで今のタブを
15427
+ // 残すと、**アドレスは Home なのに画面は操作タブのまま**になる。
15428
+ // 値が入っていて知らない語だったときは今までどおり動かさない(壊れた ?tab= で
15429
+ // 見ていた画面を飛ばさない・go() の行き先も ?tab= か ?id= のどちらかしか作らない)
15430
+ else if (tab === null) listState.tab = DEFAULT_TAB;
14466
15431
  renderList();
14467
15432
  }
14468
15433
  }