@takagaki/cortex-decisions-viewer 0.12.35 → 0.12.37

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
@@ -37,10 +37,17 @@ exports.sanitizeGithubOwnersNow = sanitizeGithubOwnersNow;
37
37
  exports.splitNeededParts = splitNeededParts;
38
38
  exports.ingestedAtOf = ingestedAtOf;
39
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;
40
48
  exports.deriveCapTile = deriveCapTile;
41
49
  exports.renderConnectionMap = renderConnectionMap;
42
50
  const node_crypto_1 = require("node:crypto");
43
- const meet_guide_images_1 = require("./meet-guide-images");
44
51
  const google_chat_guide_images_1 = require("./google-chat-guide-images");
45
52
  // 操作タブの仮置き(#388)。判定の実体はあちら——ここは流し込みと配線だけ
46
53
  const pending_ops_1 = require("./pending-ops");
@@ -933,6 +940,9 @@ function sanitizeRegisteredNow(v) {
933
940
  const STATE_STATUSES = {
934
941
  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"],
935
942
  ingested: ["ok", "failed", "skipped"],
943
+ // 案件カレンダーは作れたか(tools #427)。行の描画は #428。ここに無いと、中央が報告した
944
+ // 「作成済み」の行が overlay に届く前に落ちて、画面は永久に「作成中」のまま
945
+ calendar: ["requested", "ok", "failed"],
936
946
  };
937
947
  const STATE_ROW_FIELDS = ["cap", "ref", "workspace", "question", "status", "at", "by", "needed", "detail", "lastOkAt", "lastFailAt"];
938
948
  /** stateNow はライブ専用キーで build.ts の網にかからない。画面側でも許可リストを通す */
@@ -1890,8 +1900,259 @@ function keyMissingText(cap) {
1890
1900
  */
1891
1901
  const BAD_STATE_STATUSES = {
1892
1902
  ingested: ["failed", "skipped"],
1903
+ // 案件カレンダー(tools #427)。**`requested` は中立**(依頼を受けた・作成中)で悪くない。
1904
+ // 入れると、押した直後の 15 分が全部⚠️になる
1905
+ calendar: ["failed"],
1893
1906
  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"],
1894
1907
  };
1908
+ /**
1909
+ * 案件カレンダーの ID の形か(#428)。
1910
+ *
1911
+ * 🔴 **依頼直後の番兵と本物の ID を区別する唯一の手掛かり。** 接続状態ストアの
1912
+ * `calendar` の行は、依頼を立てた直後は `ref` にカレンダー ID を持たない(`"calendar"`)。
1913
+ * ここで形を見ずに `ref` をそのまま ID として使うと、**「作成済み」の行が
1914
+ * `src=calendar` の壊れたリンクを出す**——押した人は Google の一般的なエラーに落ち、
1915
+ * 何が悪いのか画面からは分からない。
1916
+ *
1917
+ * **engine 側(`scripts/fleet-status.mjs` の `meetingCalendarId`)と同じ形にすること。**
1918
+ * 片方だけ厳しいと「fleet-status には載っているのに画面が弾く」で、画面は黙って
1919
+ * 「案件カレンダーがまだありません」と出し、**押した人が 2 本目のカレンダーを作る**。
1920
+ *
1921
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
1922
+ * 正規表現も外に出さず、関数の中に置く——外の const は `exports.` に書き換えられて
1923
+ * ブラウザで未定義になる)。
1924
+ */
1925
+ function isCalendarId(v) {
1926
+ if (typeof v !== "string")
1927
+ return false;
1928
+ var s = v.trim();
1929
+ // 長さは接続状態ストアの ref の上限(validateRow の 200)と揃える。
1930
+ // `#` は SK の区切りなので通すと別の行に化ける
1931
+ if (!s || s.length > 200 || s.indexOf("#") >= 0)
1932
+ return false;
1933
+ if (/[\u0000-\u001F\u007F\u00AD\u200B-\u200F\u2028\u2029\u2060\uFEFF]/.test(s))
1934
+ return false;
1935
+ // `…@group.calendar.google.com`(二次カレンダー)と、人のカレンダー(メール)の両方を通す
1936
+ return /^[^\s@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(s);
1937
+ }
1938
+ /**
1939
+ * 接続状態ストアから、案件カレンダーの行を**1本だけ**取る(#428)。
1940
+ *
1941
+ * 🔴 **`at` が最新の1行だけを見る。** この問いの行は SK が2種類になりうる
1942
+ * (依頼直後は `ref = "calendar"`、作成後は `ref = <calendarId>`。裏の経路の契約は #427)。
1943
+ * 両方を見て混ぜると、**古い依頼の行が新しい結果を上書きして「作成中」と「作成済み」が
1944
+ * 交互に出る**——利用者には画面が壊れたようにしか見えない。
1945
+ *
1946
+ * `cap` と `question` の**両方**で絞るのは `ingestedStateOf` / `designIngestedStateOf` と
1947
+ * 同じ流儀(片方だけだと、同じ能力の別の問いの行を読む)。
1948
+ */
1949
+ function calendarStateRow(rows) {
1950
+ var list = rows || [];
1951
+ var best = null;
1952
+ for (var i = 0; i < list.length; i++) {
1953
+ var r = list[i];
1954
+ if (!r || r.cap !== "meetings" || r.question !== "calendar")
1955
+ continue;
1956
+ // 時刻はミリ秒ありの ISO に正規化済み(サーバ側の `normalizeAt` が形を1つに揃え、
1957
+ // 契約表 statusAt.pattern がそれを固定している)ので**辞書順で比べられる**。
1958
+ // ⚠ `overlayStateOnFleet` が #280 で `at` の比較を捨てたのとは事情が違う。あちらは
1959
+ // 「焼いたスナップショットの時刻」と「ストアの時刻」という**別の源**を比べていて、
1960
+ // 秒精度とミリ秒精度が混ざっていた。ここは**同じストアの行どうし**なので混ざらない
1961
+ if (!best || String(r.at) > String(best.at))
1962
+ best = r;
1963
+ }
1964
+ return best;
1965
+ }
1966
+ /**
1967
+ * 案件カレンダーの ID を引く。**順に1本だけ**(#428・設計 §6)。
1968
+ *
1969
+ * 🔴 **2本の条件に割らないこと。** 「ストアが ok なら作成済み」「または fleet に
1970
+ * calendarId があれば作成済み」を別々に評価すると、ライブの行が届く前後で判定が
1971
+ * 入れ替わり、**「作成済み」の行が点滅する**。順に引いて最初に取れたものを使う。
1972
+ *
1973
+ * ストアの `ref` を先に見るのは、そちらが速いから(fleet-status.json は push のたびの
1974
+ * 再生成なので、作った直後は追いついていない)。正本は案件リポ側で、食い違ったら
1975
+ * 次の再生成で fleet が正しくなる。
1976
+ */
1977
+ function calendarIdOf(s, rows) {
1978
+ // 🔴 **ID は「最新の行」ではなく「ID を持つ最新の行」から引く。** 状態を運ぶ行とは
1979
+ // 別に引く理由は「共有を更新」——押すと裏の経路が番兵(`ref = "calendar"`)の
1980
+ // `requested` を書き、それが最新になる。最新の1行だけから ID を引くと、**既にある
1981
+ // カレンダーのリンクとコピー欄が最長20分ぶん画面から消え**、さらに待つと
1982
+ // 「作成が終わっていません」まで出る(既にあるカレンダーについて)。
1983
+ // **一度できたカレンダーは消えない**ので、ID は落とさないのが正しい。
1984
+ var list = rows || [];
1985
+ var best = null;
1986
+ for (var i = 0; i < list.length; i++) {
1987
+ var r = list[i];
1988
+ if (!r || r.cap !== "meetings" || r.question !== "calendar")
1989
+ continue;
1990
+ if (!isCalendarId(r.ref))
1991
+ continue;
1992
+ if (!best || String(r.at) > String(best.at))
1993
+ best = r;
1994
+ }
1995
+ if (best)
1996
+ return String(best.ref).trim();
1997
+ if (s && isCalendarId(s.calendarId))
1998
+ return String(s.calendarId).trim();
1999
+ return "";
2000
+ }
2001
+ /**
2002
+ * 「最長 N 分かかります」の N(分)。**画面の文言と `CALENDAR_STALE_MS` の両方をここから導く。**
2003
+ * 別々に持つと、約束(20分)と「まだ待つ」の境目(旧24時間)が噛み合わず、
2004
+ * **21分目から24時間目までが嘘の空白**になる(画面は「作成中」と言い続ける)。
2005
+ * 裏の経路は15分ごとに最大5件ずつ拾う(#427)ので、混み合っても20分あれば1周する。
2006
+ */
2007
+ const CALENDAR_WAIT_MIN = 20;
2008
+ /**
2009
+ * 依頼が拾われないまま止まっていると見なすまで。**約束の3倍**。
2010
+ *
2011
+ * ぴったり20分で「止まっています」と言うと、たまたま混んだ回で要らない連絡が飛ぶ。
2012
+ * 逆に24時間まで「作成中」と言い続けると、裏の経路が止まっていても丸1日誰も気づけない。
2013
+ * 3倍は「1周期(15分)の取りこぼしを2回まで許す」幅。
2014
+ */
2015
+ const CALENDAR_STALE_MS = CALENDAR_WAIT_MIN * 3 * 60 * 1000;
2016
+ /**
2017
+ * 生の応答(sanitize を通す前)に、案件カレンダーの行が何本あったか(#428)。
2018
+ *
2019
+ * 🔴 **「知らない語だから落とした」と「行が 1 本も無い」を区別するために要る。**
2020
+ * `sanitizeStateNow` は語彙に無い語の行を落とす。`reachable` / `ingested` では
2021
+ * 落ちた行は「確認中」に倒れるので安全だが、**`calendar` では落ちた行が
2022
+ * 「カレンダーが無い」に化ける**——画面が「まだありません」+「作る」を出し、
2023
+ * 押した人が 2 本目を作る。書き手(裏の経路)と画面は別々に配られるので、
2024
+ * サーバの語彙が先行する期間は必ずある。
2025
+ *
2026
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2027
+ */
2028
+ function calendarRowCount(raw) {
2029
+ if (!Array.isArray(raw))
2030
+ return 0;
2031
+ var n = 0;
2032
+ for (var i = 0; i < raw.length; i++) {
2033
+ var r = raw[i];
2034
+ if (r && typeof r === "object" && r.cap === "meetings" && r.question === "calendar")
2035
+ n += 1;
2036
+ }
2037
+ return n;
2038
+ }
2039
+ /**
2040
+ * 案件カレンダーの状態を決める(#428)。
2041
+ *
2042
+ * - `unknown` … 観測がまだ届いていない → 「状況を確認中です」(押せない)
2043
+ * - `unclear` … 行はあるのに ID も既知の状態も引けない → 「確認しています」(押せない)
2044
+ * - `none` … まだ無い(行が 1 本も無い)→ 「この案件のカレンダーを作る」
2045
+ * - `pending` … 依頼が立っている → 「作成中(最長 N 分)」
2046
+ * - `stale` … 約束の 3 倍を過ぎた → 「作成が終わっていません(依頼は届いています)」
2047
+ * - `ready` … ID がある → 予定を作る/マイカレンダーに追加/共有を更新
2048
+ * - `failed` … 作れなかった → 理由の1行+もう一度
2049
+ *
2050
+ * 🔴 **`none` は「行が 1 本も無い」ときだけ。** 契約(裏の経路 #427)は
2051
+ * `ref` が「カレンダー ID **または番兵**」なので、**`ok` が番兵の SK に載る形が
2052
+ * 禁じられていない**——「`ok` なら必ず ref に ID がある」を前提にすると、
2053
+ * その日から画面は「案件カレンダーがまだありません」+「作る」を出す。
2054
+ * 行があるのに読み解けないなら、**押させずに `unclear` へ倒す**のが正しい:
2055
+ * 待てば直る(次の報告で ID が載る)が、押すと 2 本目が出来て戻せない。
2056
+ *
2057
+ * 🔴 **ID があるなら `failed` でも「作れませんでした」と言わない。** 失敗の種別には
2058
+ * 「書き戻し失敗」があり、これは**カレンダーは出来ている**(作成直後の報告で
2059
+ * `ref = <calendarId>` が既に入っている)。そこで「作れませんでした」と出すと嘘になり、
2060
+ * **押し直した人が 2 本目のカレンダーを作る**。ID があるときは作成済みの行を出し、
2061
+ * 失敗は `failure`(添える1行)として渡す。
2062
+ *
2063
+ * **時計は注入する**(`nowMs`)。実時間と比べると、止まっている依頼の判定が
2064
+ * 「テストを書いた日からしばらく経つと赤くなる」日付爆弾になる。
2065
+ *
2066
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
2067
+ * `CALENDAR_STALE_MS` は CLIENT_JS 側にも `var` で流し込んである)。
2068
+ */
2069
+ function calendarPhase(s, rows, ctx, nowMs) {
2070
+ var seen = !!(ctx && ctx.seen);
2071
+ // 生の応答にあった calendar の行の本数(落とされた行も数えている)
2072
+ var rawCal = ctx && typeof ctx.rawCalendar === "number" ? ctx.rawCalendar : 0;
2073
+ var id = calendarIdOf(s, rows);
2074
+ var row = calendarStateRow(rows);
2075
+ var status = row ? String(row.status || "") : "";
2076
+ var detail = row && typeof row.detail === "string" ? row.detail : "";
2077
+ var at = row && typeof row.at === "string" ? row.at : "";
2078
+ var unclear = { phase: "unclear", calendarId: "", detail: "", note: "", at: at };
2079
+ // ---- ID があれば必ず ready(最新の行が何であっても降ろさない) ----
2080
+ if (id) {
2081
+ // 進行中・失敗は**添える1行**として渡す。「作れませんでした」とは言わない
2082
+ //(出来ているので嘘になり、押し直すと 2 本目が作られる)
2083
+ var note = "";
2084
+ if (status === "requested")
2085
+ note = "requested";
2086
+ else if ((BAD_STATE_STATUSES.calendar || []).indexOf(status) >= 0)
2087
+ note = detail || "failed";
2088
+ return { phase: "ready", calendarId: id, detail: "", note: note, at: at };
2089
+ }
2090
+ // ---- 🔴 ID も行も無いとき、「まだ分からない」と「まだ無い」を潰さない ----
2091
+ // ライブ(op=state)が届く前に「案件カレンダーがまだありません」+「作る」を出すと、
2092
+ // **既にカレンダーがある案件で、速い人が押して 2 本目を作る**。画面には既に
2093
+ // 「登録状況を確認中です」の作法があるので、同じ形で待つ(deriveCapTile の規則5と同じ)
2094
+ if (row) {
2095
+ if ((BAD_STATE_STATUSES.calendar || []).indexOf(status) >= 0) {
2096
+ return { phase: "failed", calendarId: "", detail: detail, note: "", at: at };
2097
+ }
2098
+ if (status === "requested") {
2099
+ var t = Date.parse(at);
2100
+ // **読めない時刻を stale にしない。** NaN との比較は常に false なので `t === t` で弾く
2101
+ //(止まっている旨の1行は「担当者に連絡してください」で、誤って出すと要らない連絡を生む)
2102
+ var stale = t === t && nowMs - t > CALENDAR_STALE_MS;
2103
+ return { phase: stale ? "stale" : "pending", calendarId: "", detail: "", note: "", at: at };
2104
+ }
2105
+ // ここに来るのは「`ok` なのに ref が番兵」等。**`none` に落とさない**(上の項)
2106
+ return unclear;
2107
+ }
2108
+ // 行はあったのに sanitize で落ちた(語彙が先行している)。**「無い」と言わない**
2109
+ if (rawCal)
2110
+ return unclear;
2111
+ if (!seen)
2112
+ return { phase: "unknown", calendarId: "", detail: "", note: "", at: "" };
2113
+ return { phase: "none", calendarId: "", detail: "", note: "", at: at };
2114
+ }
2115
+ /**
2116
+ * いま案件カレンダーの作成を待っているか(#428)。
2117
+ *
2118
+ * 🔴 **「自動で更新されます」と画面に書いた以上、実際に取り直す。** 取り直しのタイマは
2119
+ * 仮置き(#388)の件数でしか張られず、**カレンダーの依頼は 1 件も数えられない**
2120
+ * ——ここを見ないと 1 回も取り直さず、押した人は「作成中」のまま放置される。
2121
+ *
2122
+ * **期限は依頼の時刻から測る**(画面を開いた時刻ではない)。他の案件の画面を開いた人が
2123
+ * 何時間も叩き続けないよう、止まったと見なす時刻で終わる——そこから先は取り直しても
2124
+ * 答えは変わらない(人が担当者に連絡する番)。
2125
+ *
2126
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2127
+ */
2128
+ function calendarWaiting(rows, nowMs) {
2129
+ var row = calendarStateRow(rows);
2130
+ if (!row || String(row.status) !== "requested")
2131
+ return false;
2132
+ var t = Date.parse(String(row.at));
2133
+ // 読めない時刻で永久に叩き続けない(NaN との比較は常に false なので `t === t` で弾く)
2134
+ return t === t && nowMs - t <= CALENDAR_STALE_MS;
2135
+ }
2136
+ /**
2137
+ * 「この案件の予定を作る」のリンク。
2138
+ *
2139
+ * ⚠ **`src=` は Google の公式ドキュメントに記載が無い**(#418 の調査 6)。第三者のまとめに
2140
+ * 「既定のカレンダーではなく共有カレンダーに予定を追加する」とある形をそのまま使っている。
2141
+ * **実測は未了**(cortex-tools #425 の 3)——結果が出たらこの1行を差し替える。
2142
+ * 組み立てをこの関数に閉じてあるのは、そのときに直す場所を1つにするため。
2143
+ */
2144
+ function calendarTemplateUrl(id) {
2145
+ return "https://calendar.google.com/calendar/render?action=TEMPLATE&src=" + encodeURIComponent(id);
2146
+ }
2147
+ /**
2148
+ * 「マイカレンダーに追加」のリンク。
2149
+ *
2150
+ * ⚠ `calendarTemplateUrl` と同じく**実測は未了**(#425 の 3)。共有されている人が開くと
2151
+ * 「カレンダーを追加しますか」が出る想定。
2152
+ */
2153
+ function calendarAddUrl(id) {
2154
+ return "https://calendar.google.com/calendar/r?cid=" + encodeURIComponent(id);
2155
+ }
1895
2156
  /**
1896
2157
  * タイル1行(と接続グラフの1ノード)の状態を、**正本から導出する**(0.12.0)。
1897
2158
  *
@@ -2848,25 +3109,49 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
2848
3109
  .home-tools-name { flex: 1 1 220px; min-width: 0; word-break: break-all; }
2849
3110
  .home-tools-state { flex: 0 0 auto; }
2850
3111
  .home-tools-empty, .home-tools-note { font-size: 12.5px; color: var(--muted); line-height: 1.7; margin: 8px 0 0; }
3112
+ /* 文中の「操作タブ」(その場でタブを移る口・#424)。本文のリンクと同じ見た目にする */
3113
+ .home-tools-ops-link { color: var(--accent); text-decoration: underline; cursor: pointer; }
2851
3114
  /* 種別ごとの折りたたみ(3件以上・#424)。**畳んだ1行は、行と同じ高さ・同じ字の大きさ**
2852
- ——一覧の途中に「見出し」が生えたように見せない。既定は畳む(open を付けない) */
3115
+ ——一覧の途中に「見出し」が生えたように見せない。既定は畳む */
2853
3116
  .home-tools-fold { margin: 8px 0 0; }
2854
- .home-tools-fold > summary {
3117
+ /* 🔴 **行そのものがボタン**(2026-09-05 の実機確認)。前は details/summary で、
3118
+ 押せる範囲が中身の箱、開閉の印は summary::after の12pxの擬似要素だった——
3119
+ **小さすぎて見つからず、当たり判定も広げられない**。button にすると
3120
+ 行のどこを押しても・Enter でも Space でも開く(幅いっぱいに伸ばすのが要) */
3121
+ .home-tools-fold-summary {
2855
3122
  display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center;
2856
- padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 13.5px;
2857
- cursor: pointer; list-style: none;
2858
- }
2859
- .home-tools-fold > summary::-webkit-details-marker { display: none; }
3123
+ width: 100%; box-sizing: border-box; text-align: left;
3124
+ padding: 7px 0; border: none; border-bottom: 1px solid var(--border); border-radius: 0;
3125
+ background: none; color: inherit; font: inherit; font-size: 13.5px;
3126
+ cursor: pointer;
3127
+ }
3128
+ /* **行そのものが色を変える。** 字だけ反応させても「行のどこでも押せる」ことは伝わらない
3129
+ (当たり判定を広げた意味が見た目に出ない)。左右へ少しはみ出させて、行の端まで押せると見せる */
3130
+ .home-tools-fold-summary:hover { background: var(--accent-weak); box-shadow: 0 0 0 6px var(--accent-weak); }
3131
+ .home-tools-fold-summary:hover .home-tools-fold-label,
3132
+ .home-tools-fold-summary:hover .home-tools-fold-arrow { color: var(--text); }
3133
+ /* キーボードで来た人に、いまどの行にいるかを見せる(行全体を囲む) */
3134
+ .home-tools-fold-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2860
3135
  /* アイコンは別の span(読み上げから外すため)。畳まない行の種別と同じ字面にそろえる */
2861
3136
  .home-tools-fold-icon { flex: 0 0 auto; }
2862
3137
  .home-tools-fold-label { flex: 0 0 auto; font-weight: 600; color: var(--muted); }
2863
3138
  /* 状態は行と同じく右端へ寄せる。**縦にそろっていないと一覧に見えない**
2864
3139
  (畳んだ1行だけ状態が真ん中にあると、同じ列の情報だと分からない) */
2865
3140
  .home-tools-fold-state { flex: 0 0 auto; margin-left: auto; }
2866
- /* 開閉の印は CSS の content で出す(.ds-group と同じ形)。**要素にしない**——
2867
- 読み上げ名を「GitHub 7 件」だけにしたいので、文字として DOM に置かない */
2868
- .home-tools-fold > summary::after { content: "▸"; margin-left: 8px; color: var(--muted); font-size: 12px; }
2869
- .home-tools-fold[open] > summary::after { content: "▾"; }
3141
+ /* 開閉の矢印は**行のいちばん右**に、行の字より大きく置く(16px以上・左右に余白)。
3142
+ 状態チップの margin-left:auto で右へ寄った、その先端に来る。
3143
+ ⚠ 字は ▶/▼(U+25B6/U+25BC)。**▸/▾(U+25B8/U+25BE)は「小さい三角」の字形**で、
3144
+ 16px にしてもインクは 8px ほどしか出ず、「小さすぎる」という元の症状が残る */
3145
+ .home-tools-fold-arrow {
3146
+ flex: 0 0 auto; margin-left: 10px; padding-right: 4px;
3147
+ font-size: 16px; line-height: 1; color: var(--muted);
3148
+ }
3149
+ .home-tools-fold-summary[aria-expanded="true"] .home-tools-fold-arrow { color: var(--text); }
3150
+ /* 🔴 **畳みがある節では、畳まない行にも矢印ぶんの席を空ける。** 空けないと、畳み行の
3151
+ 状態チップだけが矢印に押されて 30px ほど左へずれ、状態の列が段違いになる
3152
+ (「縦にそろっていないと一覧に見えない」——上の .home-tools-fold-state の項)。
3153
+ 畳みが 1 つも無い案件は :has が当たらないので、見た目は今までのまま */
3154
+ .home-tools:has(.home-tools-fold) .home-tools-item { padding-right: 38px; }
2870
3155
  /* 開いた中身は少し寄せて、畳んだ1行との親子が見えるようにする */
2871
3156
  .home-tools-fold > .home-tools-list { margin: 0 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border); }
2872
3157
  /* 畳んだ1行が後ろに続くとき・開いた中身の後ろに行が続くときは、その手前の行にも
@@ -3132,28 +3417,9 @@ a.ds-name:hover { text-decoration: underline; }
3132
3417
  .ds-note { color: var(--muted); font-size: 12px; line-height: 1.7; margin: 6px 0 0; }
3133
3418
  .ds-sync { font-size: 12.5px; color: var(--muted); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
3134
3419
  .ds-col { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 auto; }
3135
- /* 会議名に入れる目印。**この行が会議カードで一番強い。** 取り込まれるかどうかは
3136
- 会議名にこれが入っているかで決まるので、補足文の一つに埋もれさせない(#117) */
3137
- .ds-keys {
3138
- display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px;
3139
- margin: 4px 0 2px; padding: 8px 10px;
3140
- background: var(--accent-weak); border: 1px solid #c7d7fe; border-radius: 8px;
3141
- font-size: 13px; color: var(--text); line-height: 1.5;
3142
- }
3143
- .ds-keys-label { font-weight: 700; }
3144
- .ds-keys-code {
3145
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3146
- font-size: 14px; font-weight: 700; color: var(--accent);
3147
- background: #fff; border: 1px solid #c7d7fe; border-radius: 6px; padding: 1px 8px;
3148
- }
3149
- .ds-keys-or { font-size: 12px; color: var(--muted); }
3150
- /* 経過措置で登録された会議名。読み取り専用の記録なので、目印より弱く出す(#117) */
3151
- .ds-legacy { margin-top: 4px; font-size: 12px; color: var(--muted); line-height: 1.6; }
3152
- .ds-legacy-chips { display: flex; flex-wrap: wrap; gap: 4px 6px; margin-top: 3px; }
3153
- .ds-legacy-chip {
3154
- font-size: 11.5px; color: var(--muted);
3155
- background: #f4f5f7; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px;
3156
- }
3420
+ /* 会議名の合図(.ds-keys)と経過措置の別名(.ds-legacy)は 2026-09-05 に撤去した(#438)。
3421
+ 案件カレンダー方式へ一本化したので、画面に会議名の目印は1つも出さない。
3422
+ 使っていないクラスの規則を残すと、次に触る人が「まだ使う場所がある」と読む */
3157
3423
  .ds-drive-note { font-size: 12px; color: #6b7280; line-height: 1.6; }
3158
3424
  .ds-group { border: none; margin: 0; }
3159
3425
  .ds-group > summary {
@@ -3309,7 +3575,12 @@ a.ds-tile:hover { text-decoration: none; }
3309
3575
  .ds-check-label, .ds-check-action, .ds-check-detail,
3310
3576
  .ds-pipe-label, .ds-pipe-todo,
3311
3577
  .ds-empty, .sec-how, .modal-desc, .ds-toggle-msg,
3312
- .ds-drive-note, .ds-inactive-note, .ds-legacy-chip, .ds-keys-code, .ds-gate-help {
3578
+ .ds-drive-note, .ds-inactive-note, .ds-gate-help,
3579
+ /* 案件カレンダーの行(#428)。**カレンダー ID は空白の無い長い連なり**で、
3580
+ 失敗の理由(detail)も長さが読めない。ここを外すと会議のモーダルが横に溢れる */
3581
+ .mt-cal-banner, .mt-cal-note, .mt-cal-warn, .mt-cal-link, .mt-cal-id .rt-copy-text,
3582
+ /* 会議カードの事実の1行(#438)。どれも長い1文なので、折り返さないと横に溢れる */
3583
+ .mt-scope, .mt-cal-lag, .mt-cal-off {
3313
3584
  min-width: 0; white-space: normal; overflow-wrap: anywhere;
3314
3585
  }
3315
3586
  .ds-toggle { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
@@ -3440,8 +3711,16 @@ a.ds-tile:hover { text-decoration: none; }
3440
3711
  畳む(作らないのではなく隠す——理由は syncKeyCardVisibility の項)。ところが
3441
3712
  .ops-card は display: flex、.ds-keyrow は flex アイテムを名乗るので、
3442
3713
  UA 既定の [hidden] { display: none } が**負ける**。畳んだつもりで出たままになる。
3443
- ⚠ CSS は jsdom から見えないので、この行はテストでは守れない(実機で見ること)。 */
3444
- [hidden] { display: none !important; }
3714
+ ⚠ CSS は jsdom から見えないので、この行はテストでは守れない(実機で見ること)。
3715
+
3716
+ 🔴 **until-found だけは巻き込まない(#424)。** 仕様の UA スタイルは
3717
+ [hidden]:not([hidden="until-found" i]) に display:none を当て、until-found には
3718
+ content-visibility: hidden を当てる——**ページ内検索(Ctrl+F)で中身を見つけられる**のは
3719
+ 後者だから。ここで一律に display:none を !important で当てると、畳んだ「使用ツール」の
3720
+ 中身が検索から消え、beforematch も永久に発火しない(属性だけ until-found にしても無意味)。
3721
+ 除いた側は作者スタイルを持たないので、until-found を知らないブラウザでは
3722
+ UA 既定の [hidden] { display: none } がそのまま効き、畳んだままになる */
3723
+ [hidden]:not([hidden="until-found" i]) { display: none !important; }
3445
3724
  /* 行の中に入った鍵カードは、カードの枠を持たない(行の一部に見せる) */
3446
3725
  .ops-card.ds-keycard { border: none; box-shadow: none; padding: 0; background: transparent; gap: 6px; }
3447
3726
  .ops-card.ds-keycard > .ops-card-head { gap: 8px; }
@@ -3475,31 +3754,40 @@ a.ds-tile:hover { text-decoration: none; }
3475
3754
  /* 取り込みの条件は箇条書きで出す(ANDであることを読み飛ばせないように) */
3476
3755
  .sec-how-list { margin: 4px 0; padding-left: 1.3em; }
3477
3756
  .sec-how-list li { margin: 2px 0; }
3478
- /* 会議の主催者向けの選択肢(「Google Meet」フォルダの共有・2026-08-29 オーナー承認。
3479
- 共有対象のフォルダ名は 2026-09-03 に Meet Recordings から Google Meet へ改めた・#393)。
3480
- **折りたたまない。** 週次整理の節が「誰も見ない」で撤去された経緯があるので、
3481
- 知ってほしい選択肢を <details> の中に隠さない。従来の bot 招待の説明の下に、
3482
- 枠で囲った別の話として置く(差し替えではなく追加であることを見た目でも示す) */
3483
- .mt-share { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin: 8px 0 0; background: #fff; }
3484
- .mt-share-title { font-size: 12.5px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
3485
- /* 共有すると何が起きるかの1行。**説明文より強く出す**(.ds-scope-note と同じ理由) */
3486
- .mt-share-note { color: var(--text); margin-top: 6px; }
3487
- /* 「全部見られてしまうのでは」への答え(2026-08-29 オーナー確定文)。
3488
- **手順より前に置く。** 読む人が「共有しようか」と迷うのは手順を踏む前で、
3489
- 手順(画像2枚ぶん長い)の後ろに置くと、迷ったまま読み進むことになる。
3490
- 警告帯(.draft-note)と同じ系統の色で、注意書きだと一目で分かる形にする */
3491
- .mt-privacy {
3492
- margin: 8px 0 0; padding: 8px 10px;
3493
- background: #fffbeb; border: 1px solid #fcd34d; border-radius: 6px;
3494
- color: #92400e; line-height: 1.8;
3495
- }
3496
- /* フォルダを探す手間をなくすリンク。検索URLなので**誰のドライブでも1本で効く**
3497
- (フォルダIDは人ごとに違うので、固定URLは作れない) */
3498
- .mt-share-open { display: inline-block; margin: 6px 0 2px; font-weight: 700; }
3499
- /* 手順の番号つきリスト。**文章だけで完結させる**(画像はその補助) */
3500
- .mt-share-steps { margin: 6px 0 0; padding-left: 1.3em; }
3501
- .mt-share-steps li { margin: 4px 0; }
3502
- /* 手順の画像。モーダルの中なので幅で潰れないようにし、枠で「画面の写し」だと分かるようにする */
3757
+ /* 何を開くかの1行(#438・#418 の A-6 廃止後の文)。**枠にも帯にもしない**——
3758
+ 枠と帯は症状のためのもので、これは常に変わらない事実の1行。囲むと、すぐ下の
3759
+ カレンダーの帯(いま直せること)と同じ強さになり、どちらも読み飛ばされる */
3760
+ .mt-scope { color: var(--text); margin: 6px 0 0; }
3761
+ /* 案件カレンダーの行(#428)。**帯は症状・行は一手**。
3762
+ 枠は付けない——上下に並ぶのはどれも枠の無い行なので、ここだけ枠にすると
3763
+ カード全体の中でこの箱だけが別の話に見える */
3764
+ .mt-cal { margin: 8px 0 0; }
3765
+ /* bot が出席者に入るまでの待ち(#438)。事実の1行なので .mt-cal-note と同じ弱さで出す。
3766
+ **.mt-cal の外に置く**——中は liveList で作り直されるので、入れると差し替えで消える */
3767
+ .mt-cal-lag { color: var(--muted); margin: 6px 0 0; }
3768
+ /* 投入の口を持たない案件で、案件カレンダーの行の代わりに出す1行(#438)。
3769
+ **帯にしない**——直せるのは担当者であって、この画面を見ている人ではない */
3770
+ .mt-cal-off { color: var(--muted); margin: 8px 0 0; }
3771
+ /* 症状の帯。**事実の行(.mt-scope / .mt-cal-lag)とは色で分ける**——あちらは
3772
+ 「読む前に知っておくこと」、こちらは「いま直せること」 */
3773
+ .mt-cal-banner {
3774
+ padding: 6px 10px; border-radius: 6px; margin-bottom: 6px;
3775
+ background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af;
3776
+ font-size: 12.5px; font-weight: 700;
3777
+ }
3778
+ /* 一手(リンク)。**2本を縦に並べる**——横に並べると「予定を作る」と「追加する」が
3779
+ 同じ操作の2択に見えるが、実際は別々に押すもの */
3780
+ .mt-cal-link { display: block; margin: 4px 0; font-weight: 700; }
3781
+ /* 事実の1行(色・作成中・連絡先)。操作ではないので弱く出す */
3782
+ .mt-cal-note { color: var(--muted); margin: 6px 0 0; }
3783
+ /* 作成済みだが共有の更新が終わっていない、の1行。**帯にしない**(カレンダーは使える) */
3784
+ .mt-cal-warn { color: #92400e; margin: 6px 0 0; }
3785
+ .mt-cal-act { margin-top: 8px; }
3786
+ /* 「Google Meet」フォルダの共有の案内(.mt-share ほか)は 2026-09-05 に撤去した(#438)。
3787
+ 案件カレンダー方式へ一本化したので、主催者に頼む手順そのものが無くなっている。
3788
+ 画像の規則(.mt-shot)は Google Chat の手順が今も使っているので下に残してある */
3789
+ /* 手順の画像。モーダルの中なので幅で潰れないようにし、枠で「画面の写し」だと分かるようにする
3790
+ (使うのは Google Chat の「スペースにアプリを追加する」手順・#389) */
3503
3791
  .mt-shot { display: block; max-width: 100%; height: auto; margin: 6px 0 0; border: 1px solid var(--border); border-radius: 6px; }
3504
3792
  /* 0件を黙って見せない(追加フォームだけだと「未登録」か「読めていない」か区別できない) */
3505
3793
  .ds-empty { color: var(--muted); font-size: 12.5px; margin: 4px 0 10px; }
@@ -3892,6 +4180,20 @@ var TILE_CAPS = ${JSON.stringify(TILE_CAPS)};
3892
4180
  var BAD_STATE_STATUSES = ${JSON.stringify(BAD_STATE_STATUSES)};
3893
4181
  ${keyMissingText.toString()}
3894
4182
  ${deriveCapTile.toString()}
4183
+ // 案件カレンダー(#428)。**判定の実体はビルド時の定義**——画面側に書き写すと、
4184
+ // 「作成済みか」の判定が 2 枚になって片方だけ直る(=2 本目のカレンダーを作らせる)。
4185
+ // 🔴 **BAD_STATE_STATUSES より後に置く。** calendarPhase が「悪い」語をそこから引く
4186
+ // (語を2枚持たないため)ので、宣言の前に呼ぶと undefined を読む
4187
+ ${isCalendarId.toString()}
4188
+ ${calendarStateRow.toString()}
4189
+ ${calendarIdOf.toString()}
4190
+ ${calendarRowCount.toString()}
4191
+ var CALENDAR_WAIT_MIN = ${JSON.stringify(CALENDAR_WAIT_MIN)};
4192
+ var CALENDAR_STALE_MS = ${JSON.stringify(CALENDAR_STALE_MS)};
4193
+ ${calendarPhase.toString()}
4194
+ ${calendarWaiting.toString()}
4195
+ ${calendarTemplateUrl.toString()}
4196
+ ${calendarAddUrl.toString()}
3895
4197
  // 「はじめに」セクションのチュートリアルデータ(ビルド時に埋め込み。ボタンURL・チャレンジ4の決定は解決済み)
3896
4198
  var TUTORIAL = (function () {
3897
4199
  var elx = document.getElementById("tutorial-data");
@@ -4006,30 +4308,8 @@ ${deriveCapTile.toString()}
4006
4308
  * 議事録の取り込みでも同じアカウントを使っている。
4007
4309
  */
4008
4310
  var DRIVE_INGEST_ACCOUNT = "cortex-notetaker@classmethod.jp";
4009
- /**
4010
- * 自分の「Google Meet」フォルダへの入口。
4011
- *
4012
- * **フォルダIDは人ごとに違うので、フォルダを直接指すURLは作れない。** 代わりに
4013
- * ドライブの検索URLを使う——開いた人のドライブの中で検索が走るので、**1本で全員に効く**。
4014
- * フォルダはマイドライブ直下にでき、**日本語環境でも名前は英語のまま**作られる
4015
- *(オーナー1人の実測。ロケールによって違う可能性は残る)。
4016
- *
4017
- * 🔴 **検索結果には会議ごとのフォルダも並ぶ。** メモは「Google Meet」の中の
4018
- *「<会議名> - 日付」「<会議名> (recurring)」というフォルダに入るので、名前で検索すると
4019
- * それらも一緒に出てくる。**共有すべきなのは、それらを入れている親のほう**——
4020
- * 絞り込みでは決着しないので、owner:me で他人の共有を落としたうえで、
4021
- * **どれを選ぶかは手順の文で言う**(下の steps の1つ目)。
4022
- *
4023
- * **共有ダイアログを直接開くURLは無い。** リンクの先で「右クリック → 共有」の1手が要ることは、
4024
- * 押す前に画面へ書く(リンクだけ置くと、開いた先で止まる人が出る)。
4025
- */
4026
- var MEET_FOLDER_SEARCH_URL =
4027
- "https://drive.google.com/drive/search?q=type:folder%20owner:me%20%22Google%20Meet%22";
4028
- // 手順の画像(data URI)。中身と埋め込み方の理由は src/meet-guide-images.ts に書いてある
4029
- var MEET_SHOT_STEP1 = "${meet_guide_images_1.MEET_SHARE_STEP1}";
4030
- var MEET_SHOT_STEP2 = "${meet_guide_images_1.MEET_SHARE_STEP2}";
4031
4311
  // Google Chat の「スペースにアプリを追加する」手順の画像(#389)。理由は
4032
- // src/google-chat-guide-images.ts に書いてある(Meet と同じ埋め込み方)
4312
+ // src/google-chat-guide-images.ts に書いてある
4033
4313
  var GCHAT_SHOT_STEP1 = "${google_chat_guide_images_1.GCHAT_APP_STEP1}";
4034
4314
  var GCHAT_SHOT_STEP2 = "${google_chat_guide_images_1.GCHAT_APP_STEP2}";
4035
4315
 
@@ -4635,6 +4915,9 @@ ${deriveCapTile.toString()}
4635
4915
  }
4636
4916
  mount.innerHTML = "";
4637
4917
  mount.appendChild(box);
4918
+ // 🔴 **失敗した側でも after を呼ぶ。** Home は本文が決まるまで枠を隠している
4919
+ // (renderHome)ので、ここで呼ばないと**読み込めなかったという案内ごと消える**
4920
+ done();
4638
4921
  });
4639
4922
  }
4640
4923
  function getParam(name) {
@@ -4643,6 +4926,46 @@ ${deriveCapTile.toString()}
4643
4926
  function tabUrl(tabKey) {
4644
4927
  return tabKey === DEFAULT_TAB ? "?" : "?tab=" + encodeURIComponent(tabKey);
4645
4928
  }
4929
+ /** そのタブが立っているか(案件によっては操作タブが無い) */
4930
+ function hasTab(tabKey) {
4931
+ for (var i = 0; i < TABS.length; i++) { if (TABS[i].key === tabKey) return true; }
4932
+ return false;
4933
+ }
4934
+ /**
4935
+ * タブを移る。**ヘッダーのタブも文中のリンクもここを通る**(#424)——
4936
+ * 切り替えの手が 2 枚あると、片方だけページ番号を戻し忘れる、が必ず起きる。
4937
+ */
4938
+ // ⚠ push を渡すと履歴に1つ積む(**戻るで元のタブに帰れる**)。ヘッダーのタブは
4939
+ // 今までどおり積まない——連打で履歴が埋まると、戻るがビューアから出られなくなる。
4940
+ // 文中のリンクは「押したら進む=戻れる」に見えるので、そちらだけ積む
4941
+ function switchTab(tabKey, push) {
4942
+ listState.tab = tabKey;
4943
+ listState.page = 1;
4944
+ if (push) history.pushState({}, "", tabUrl(tabKey));
4945
+ else history.replaceState(null, "", tabUrl(tabKey));
4946
+ renderList();
4947
+ }
4948
+ /**
4949
+ * 文中の「操作タブ」を、その場で操作タブへ移るリンクにする(#424)。
4950
+ *
4951
+ * 🔴 **操作タブが立たない案件では素のテキストのまま返す。** タブは
4952
+ * SITE.intake || HAS_FLEET のときだけ生えるので、無条件にリンクにすると
4953
+ * **どこにも飛ばないリンク**が出る。
4954
+ *
4955
+ * ⚠ href は本物(?tab=ops)を入れる——中クリック・新しいタブで開くを殺さない。
4956
+ * 素の遷移でも route() が ?tab= を拾うので、着く先は同じ
4957
+ */
4958
+ function opsTabLink(text) {
4959
+ if (!hasTab("ops")) return document.createTextNode(text);
4960
+ var a = el("a", { class: "home-tools-ops-link", href: tabUrl("ops"), text: text });
4961
+ a.addEventListener("click", function (e) {
4962
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
4963
+ e.preventDefault();
4964
+ switchTab("ops", true);
4965
+ window.scrollTo(0, 0);
4966
+ });
4967
+ return a;
4968
+ }
4646
4969
  function go(id) {
4647
4970
  var url = id ? "?id=" + encodeURIComponent(id) : tabUrl(listState.tab);
4648
4971
  history.pushState({}, "", url);
@@ -4687,12 +5010,7 @@ ${deriveCapTile.toString()}
4687
5010
  TABS.forEach(function (t) {
4688
5011
  var label = t.items ? t.label + "(" + t.items.length + ")" : t.label;
4689
5012
  var b = el("button", { class: "tab" + (listState.tab === t.key ? " active" : ""), text: label });
4690
- b.addEventListener("click", function () {
4691
- listState.tab = t.key;
4692
- listState.page = 1;
4693
- history.replaceState(null, "", tabUrl(t.key));
4694
- renderList();
4695
- });
5013
+ b.addEventListener("click", function () { switchTab(t.key); });
4696
5014
  tabs.appendChild(b);
4697
5015
  });
4698
5016
  app.appendChild(tabs);
@@ -4965,8 +5283,21 @@ ${deriveCapTile.toString()}
4965
5283
  }
4966
5284
 
4967
5285
  /** callIntake の戻り(fetch の Promise)を { ok, j } に畳む(submitIntake と同じ形) */
5286
+ // **status も返す(#428)。** 呼び手のほとんどは ok と j.message で足りるが、
5287
+ // 「断られ方で次の一手が変わる」ものがある——案件カレンダーの依頼は 403(オーナー・
5288
+ // 管理者ではない)のときだけボタンを引っ込める。ok だけでは 403 と 5xx を区別できず、
5289
+ // **一時的な障害でボタンを永久に消す**ことになる
4968
5290
  function intakeJson(req) {
4969
- return req.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); });
5291
+ return req.then(function (r) {
5292
+ return r.json().then(
5293
+ function (j) { return { ok: r.ok, status: r.status, j: j }; },
5294
+ // JSON でない応答(プロキシの HTML エラーページ等)でも status は使える
5295
+ // 🔴 **ok は落とす。** 200 で非 JSON が返るのは異常(プロキシのエラーページ等)で、
5296
+ // ここで ok を通すと**保存できていないのに「保存しました」**が出る(以前は
5297
+ // json() の reject が呼び手の catch に落ちて通信エラーの案内になっていた)
5298
+ function () { return { ok: false, status: r.status, j: {} }; },
5299
+ );
5300
+ });
4970
5301
  }
4971
5302
 
4972
5303
  // ヘッダーの丸のメニューが開いているか。**DOM ではなくこの変数が正本。**
@@ -5283,12 +5614,20 @@ ${deriveCapTile.toString()}
5283
5614
  if (toolsSec) app.appendChild(toolsSec);
5284
5615
 
5285
5616
  var homeBody = el("div", { class: "body" });
5617
+ // 🔴 **出す・出さないが決まるまで隠しておく(#424)。** 本番の Home の本文は
5618
+ // bodyRef で外出しされていて fetch なので、先に枠を出すと**空の白い枠が一瞬出てから
5619
+ // 消える**。中身が届いた後に hideEmptyBody が出す/出さないを決める
5620
+ homeBody.hidden = true;
5286
5621
  app.appendChild(homeBody);
5287
5622
  // **旧い節が本文に残っていたら落とす(防御)。** 本来は build 側で抜けているが、
5288
5623
  // 落とし損ねると自己記述が2度出る・移設したはずの説明が両方に出る、という
5289
5624
  // 一番読み手を混乱させる形になるので、描画側でも同じ見出しを見張る。
5290
- // **本文が入った後に呼ぶ**(外出しされていると fetch なので、直後ではまだ空)
5291
- loadBody(h, homeBody, dropLegacyHomeSections);
5625
+ // **本文が入った後に呼ぶ**(外出しされていると fetch なので、直後ではまだ空)。
5626
+ // 落とした結果として本文が空になったら、枠ごと消す(下の hideEmptyBody)
5627
+ loadBody(h, homeBody, function (mount) {
5628
+ dropLegacyHomeSections(mount);
5629
+ hideEmptyBody(mount);
5630
+ });
5292
5631
  // Markdown本文の下にオンボーディングのチュートリアルを差し込む
5293
5632
  renderTutorialSection();
5294
5633
  }
@@ -5323,7 +5662,12 @@ ${deriveCapTile.toString()}
5323
5662
  box.appendChild(el("h2", { class: "home-grounding-title", text: "使用ツール" }));
5324
5663
  var items = t.items || [];
5325
5664
  if (!items.length) {
5326
- box.appendChild(el("p", { class: "home-tools-empty", text: "まだツールを接続していません。操作タブから接続できます。" }));
5665
+ // 「操作タブ」はその場で移れる口にする(文言は変えない・#424)
5666
+ box.appendChild(el("p", { class: "home-tools-empty" }, [
5667
+ document.createTextNode("まだツールを接続していません。"),
5668
+ opsTabLink("操作タブ"),
5669
+ document.createTextNode("から接続できます。"),
5670
+ ]));
5327
5671
  return box;
5328
5672
  }
5329
5673
  // **種別ごとにまとめる(#424)。**
@@ -5356,10 +5700,11 @@ ${deriveCapTile.toString()}
5356
5700
  });
5357
5701
  // **どこで直せるか・いつの情報かを 1 行で言う。** 操作タブで繋いだ直後は
5358
5702
  // この一覧に出ない(engine が毎朝作り直すまで)ので、黙っていると「効いていない」と読まれる
5359
- box.appendChild(el("p", {
5360
- class: "home-tools-note",
5361
- text: "接続の追加・変更は操作タブで行います(この一覧は毎朝更新されます)。",
5362
- }));
5703
+ box.appendChild(el("p", { class: "home-tools-note" }, [
5704
+ document.createTextNode("接続の追加・変更は"),
5705
+ opsTabLink("操作タブ"),
5706
+ document.createTextNode("で行います(この一覧は毎朝更新されます)。"),
5707
+ ]));
5363
5708
  return box;
5364
5709
  }
5365
5710
 
@@ -5392,21 +5737,29 @@ ${deriveCapTile.toString()}
5392
5737
  /**
5393
5738
  * 同じ種別が 3 件以上あるときの、畳んだ 1 行(#424)。
5394
5739
  *
5395
- * 🔴 **既定は畳む(open 属性を付けない)。** GitHub 7 件・Slack 11 件が縦に並ぶと、
5740
+ * 🔴 **既定は畳む。** GitHub 7 件・Slack 11 件が縦に並ぶと、
5396
5741
  * その下の本文が画面の外へ押し出される。件数と状態の要約は畳んだまま読めるので、
5397
5742
  * 「何件つながっていて、手を入れる先があるか」は開かなくても分かる。
5398
5743
  * 開閉は**保存しない**(開き直すと畳んだ状態に戻る・portal #77 と同じ)
5399
5744
  *
5400
- * 🔴 **行は畳んでも DOM に残す**(details の中に置くだけ)。描き直し(syncHomeTools)で
5745
+ * 🔴 **行は畳んでも DOM に残す**(hidden にするだけ)。描き直し(syncHomeTools)で
5401
5746
  * 開閉を戻せるのも、検査が行数を数えられるのも、行がそこにあるからで、
5402
5747
  * **作り直す形にすると開いている最中に中身が消える**
5748
+ *
5749
+ * 🔴 **行そのものを button にする(2026-09-05 の実機確認)。** 初出は
5750
+ * <details>/<summary> だったが、開閉の印が summary::after の擬似要素で、
5751
+ * **12px の「▸」は見つからないし、当たり判定も行の右端まで広げられない**。
5752
+ * button なら行のどこを押しても・Enter でも Space でも開き、aria-expanded で
5753
+ * 開閉が支援技術にも(矢印の向きで目にも)そのまま伝わる
5403
5754
  */
5404
5755
  function homeToolsFold(group, seq) {
5405
5756
  var first = group[0] || {};
5406
5757
  var kind = String(first.kind == null ? "" : first.kind);
5407
5758
  // 畳み 1 つを指す鍵。**種別だけにしない**——同じ種別が離れて 2 度出たときに
5408
5759
  // id が重複し、開閉の復元も両方に効いてしまう
5409
- var key = (kind || "other") + "-" + seq;
5760
+ // ⚠ **id に使うので、種別の字をそのまま持ち込まない。** kind は fleet-status.json 由来で、
5761
+ // 空白の入った値が来ると id が2つの idref に読まれ、aria-controls の対応が壊れる
5762
+ var key = (String(kind).replace(/[^A-Za-z0-9_-]/g, "") || "other") + "-" + seq;
5410
5763
  // 状態は 1 行ずつ求めてから畳む(行と要約が別の判定を持たない)
5411
5764
  var sum = toolsFoldSummary(group.map(function (it) { return toolStateWithLive(it, LIVE_STATE); }));
5412
5765
  var chip = el("span", { class: "home-tools-fold-state ds-gate " + sum.cls, text: sum.text });
@@ -5421,23 +5774,66 @@ ${deriveCapTile.toString()}
5421
5774
  var named = null;
5422
5775
  group.forEach(function (it) { if (!named && it && it.label) named = it; });
5423
5776
  var label = ((named || first).label || first.kind || "その他") + " " + group.length + " 件";
5424
- var sums = el("summary", { class: "home-tools-fold-summary" }, [
5777
+ var listId = "home-tools-fold-list-" + key;
5778
+ // 矢印は**本物の要素**にする(擬似要素だと 16px にしても行の右端に置けない)。
5779
+ // 読み上げからは外す——開閉は aria-expanded が言うので、字を二重に読ませない。
5780
+ // **字は入れない**(PR #436 の整理)。向きの正本は setToolsFoldOpen 1 箇所で、
5781
+ // ここにも初期値を書くと 2 か所になる——片方だけ直した日に、畳んでいるのに
5782
+ // ▼ が出ている行が生まれる(この箱は下で必ず setToolsFoldOpen を通る)
5783
+ var arrow = el("span", { class: "home-tools-fold-arrow", "aria-hidden": "true" });
5784
+ // ⚠ type="button" を明示する(この節が将来 form の中に入っても submit しない)
5785
+ var btn = el("button", { class: "home-tools-fold-summary", type: "button" }, [
5425
5786
  // アイコンは読み上げから外す(「たこ」と読まれても種別は分からない)
5426
5787
  el("span", { class: "home-tools-fold-icon", "aria-hidden": "true", text: TOOL_KIND_ICONS[first.kind] || "🔗" }),
5427
5788
  el("span", { class: "home-tools-fold-label", text: label }),
5428
5789
  chip,
5790
+ arrow,
5429
5791
  ]);
5430
5792
  // ⚠ **読み上げ名は「GitHub 7 件」**(aria-label で言い切る)。状態の要約は
5431
5793
  // aria-describedby で**説明として**読ませる——チップを aria-hidden にすると、
5432
5794
  // 畳んでいるあいだ(中身は支援技術からも見えない)「承認待ちが 2 件ある」ことが
5433
5795
  // 目で見える人にしか伝わらない。それは畳みを橙にした理由と正面から矛盾する
5434
- sums.setAttribute("aria-label", label);
5435
- sums.setAttribute("aria-describedby", chipId);
5436
- var ul = el("ul", { class: "home-tools-list" });
5796
+ btn.setAttribute("aria-label", label);
5797
+ btn.setAttribute("aria-describedby", chipId);
5798
+ btn.setAttribute("aria-controls", listId);
5799
+ var ul = el("ul", { class: "home-tools-list", id: listId });
5437
5800
  group.forEach(function (it) { ul.appendChild(homeToolRow(it)); });
5438
5801
  // data-fold-key は「描き直しても開いたままにする」ための目印(syncHomeTools が見る)。
5439
5802
  // data-kind は種別そのもの(畳んだ 1 行がどのツールのものか、を素直に読めるように)
5440
- return el("details", { class: "home-tools-fold", "data-kind": kind, "data-fold-key": key }, [sums, ul]);
5803
+ var box = el("div", { class: "home-tools-fold", "data-kind": kind, "data-fold-key": key }, [btn, ul]);
5804
+ // 既定は畳む(aria-expanded=false・中身は hidden・矢印は ▶)。**矢印の字が入るのはここ**
5805
+ setToolsFoldOpen(box, false);
5806
+ btn.addEventListener("click", function () { setToolsFoldOpen(box, !toolsFoldOpen(box)); });
5807
+ // ページ内検索が畳んだ中の名前を見つけたとき、ブラウザは hidden を外して開く。
5808
+ // **行の側(矢印・aria-expanded)も一緒に合わせる**——でないと「開いているのに
5809
+ // 行は畳んだ顔をしている」ちぐはぐな状態になり、もう一度押しても閉じない
5810
+ ul.addEventListener("beforematch", function () { setToolsFoldOpen(box, true); });
5811
+ return box;
5812
+ }
5813
+
5814
+ /**
5815
+ * 畳み 1 つの開閉を決める(#424)。**見た目・読み上げ・中身の 3 つを 1 箇所で動かす**
5816
+ * ——別々に書くと、矢印だけ向きが残る/aria-expanded だけ古い、がすぐ起きる。
5817
+ */
5818
+ function setToolsFoldOpen(box, open) {
5819
+ if (!box) return;
5820
+ var btn = box.querySelector(".home-tools-fold-summary");
5821
+ var ul = box.querySelector(".home-tools-list");
5822
+ if (!btn || !ul) return;
5823
+ btn.setAttribute("aria-expanded", open ? "true" : "false");
5824
+ // 🔴 隠すだけ(作り直さない)。開いている最中に実測が届いても中身は消えない。
5825
+ // ⚠ until-found で隠す——**ページ内検索で畳んだ中のリポジトリ名を拾える**
5826
+ // (見つけたブラウザが hidden を外し、下の beforematch で行の見た目も合わせる)。
5827
+ // この値を知らないブラウザでは「hidden 属性がある=隠す」なので畳んだままになるだけ
5828
+ if (open) ul.removeAttribute("hidden");
5829
+ else ul.setAttribute("hidden", "until-found");
5830
+ var arrow = btn.querySelector(".home-tools-fold-arrow");
5831
+ if (arrow) arrow.textContent = open ? "▼" : "▶";
5832
+ }
5833
+ /** 畳み 1 つが開いているか。**正本は aria-expanded**(矢印の字から読み戻さない) */
5834
+ function toolsFoldOpen(box) {
5835
+ var btn = box && box.querySelector(".home-tools-fold-summary");
5836
+ return !!btn && btn.getAttribute("aria-expanded") === "true";
5441
5837
  }
5442
5838
 
5443
5839
  /**
@@ -5459,13 +5855,25 @@ ${deriveCapTile.toString()}
5459
5855
  var before = old.querySelectorAll(".home-tools-fold");
5460
5856
  for (var i = 0; i < before.length; i++) {
5461
5857
  // 素のオブジェクトの添字にしない("constructor" のような鍵が継承側に当たる)
5462
- if (before[i].open) opened["k:" + (before[i].getAttribute("data-fold-key") || "")] = true;
5858
+ if (toolsFoldOpen(before[i])) opened["k:" + (before[i].getAttribute("data-fold-key") || "")] = true;
5463
5859
  }
5464
5860
  var after = next.querySelectorAll(".home-tools-fold");
5465
5861
  for (var j = 0; j < after.length; j++) {
5466
- if (opened["k:" + (after[j].getAttribute("data-fold-key") || "")]) after[j].open = true;
5862
+ if (opened["k:" + (after[j].getAttribute("data-fold-key") || "")]) setToolsFoldOpen(after[j], true);
5863
+ }
5864
+ // 🔴 **キーボードの居場所も戻す(#424)。** 実測は人が行を開いた直後に届き、
5865
+ // しかも未確定の接続があるあいだ何度も届く。節ごと差し替えるとフォーカスを持った
5866
+ // 行が DOM から外れ、**次の Tab がページの先頭から**になる(数秒おきに繰り返される)
5867
+ var act = app.ownerDocument.activeElement;
5868
+ var focusKey = null;
5869
+ if (act && old.contains(act) && act.className === "home-tools-fold-summary") {
5870
+ focusKey = (act.parentNode && act.parentNode.getAttribute("data-fold-key")) || null;
5467
5871
  }
5468
5872
  old.parentNode.replaceChild(next, old);
5873
+ if (focusKey) {
5874
+ var back = next.querySelector('.home-tools-fold[data-fold-key="' + focusKey + '"] .home-tools-fold-summary');
5875
+ if (back) back.focus();
5876
+ }
5469
5877
  }
5470
5878
 
5471
5879
  /**
@@ -5528,6 +5936,41 @@ ${deriveCapTile.toString()}
5528
5936
  });
5529
5937
  }
5530
5938
 
5939
+ /**
5940
+ * 中身が空になった本文の枠を消す(#424・2026-09-05 の実機確認)。
5941
+ *
5942
+ * 🔴 **.body は枠(border)と余白(padding)を持っている。** Home では自己記述を
5943
+ * 上に出し、本文側からは同じ見出しを dropLegacyHomeSections が落とすので、
5944
+ * 冒頭の定型文が消えた案件では**中身が空のまま枠だけが残る**——使用ツールの下に
5945
+ * 「謎の白い枠」が出ていたのはこれ。中身がある案件では今までどおり出す。
5946
+ *
5947
+ * ⚠ 空白だけのテキストノード・空の p・br は「無い」と数える(Markdown の
5948
+ * 変換や節落としの後には、こういう抜け殻がふつうに残る)。
5949
+ */
5950
+ function hideEmptyBody(mount) {
5951
+ if (!mount) return;
5952
+ mount.hidden = !hasVisibleContent(mount);
5953
+ }
5954
+ /** 目に見えるものが1つでも入っているか(テキスト・画像・線・表など) */
5955
+ function hasVisibleContent(node) {
5956
+ var kids = node.childNodes || [];
5957
+ for (var i = 0; i < kids.length; i++) {
5958
+ var n = kids[i];
5959
+ // テキストは空白(改行・全角空白含む)だけなら無いものとして数える
5960
+ if (n.nodeType === 3) { if (String(n.nodeValue || "").trim()) return true; continue; }
5961
+ if (n.nodeType !== 1) continue; // コメントなどは数えない
5962
+ // ⚠ 大文字にそろえる(SVG の要素は tagName が小文字のまま来る)
5963
+ var tag = String(n.tagName || "").toUpperCase();
5964
+ // 中身が空でも場所を取るもの(画像・区切り線・表・タスクリストの升目)は「ある」。
5965
+ // **build 側の許可タグ(SANITIZE_OPTIONS)に合わせる**——ここだけ独自の一覧にすると、
5966
+ // 許可タグが増えた日に「文字が無いから空」と数えて本文が丸ごと消える
5967
+ if (tag === "IMG" || tag === "HR" || tag === "TABLE" || tag === "INPUT") return true;
5968
+ if (tag === "BR") continue;
5969
+ if (hasVisibleContent(n)) return true;
5970
+ }
5971
+ return false;
5972
+ }
5973
+
5531
5974
  // ---------- データソースと自動化(fleet-status.json 由来。無ければ非表示) ----------
5532
5975
  // 顧客も読む面なので内部用語(cron・stub等)を避け「毎晩自動で〜」調にする。
5533
5976
  var DS_TYPE = {
@@ -5953,148 +6396,303 @@ ${deriveCapTile.toString()}
5953
6396
  }
5954
6397
 
5955
6398
  /**
5956
- * 会議名に入れる目印。**案件が宣言した合図が優先で、無ければ艦隊キー。**
6399
+ * 会議の取り込みの案内。**操作ではなく条件を示す。**
5957
6400
  *
5958
- * 人が打つのは案件が決めた合図(艦隊キーは長くてカレンダーに打てないので meetingKey で
5959
- * 短くしている)。**自動取り込みが動いているときだけ**返す——手動運用の案件に
5960
- * 会議名の条件を出すと、自動で拾われると誤解させる。
6401
+ * 取り込まれるには2つとも要る(#418 のオーナー決定・2026-09-05):
6402
+ * ① 予定がこの案件のカレンダーにある(=どの案件かが決まる)
6403
+ * ② Gemini のメモが ON(=メモそのものが作られる)
5961
6404
  *
5962
- * ここに集めているのは、**同じ値を2箇所(目印の案内とプライバシーの注釈)で出す**ため。
5963
- * 別々に導出すると、片方だけ艦隊キーのままになる事故が起きる
5964
- */
5965
- function meetingSignalOf(s) {
5966
- if (!s || s.kind !== "会議" || s.ingestState !== "on") return null;
5967
- return s.meetingKey || (SITE.intake && SITE.intake.projectKey) || null;
5968
- }
5969
-
5970
- /**
5971
- * 経過措置で登録された会議名(別名)。**目印が無くてもこの名前の会議は取り込まれる。**
6405
+ * 🔴 **かつては3つだった。** 「bot を招待する/Google Meet フォルダを共有する」が
6406
+ * 必須条件の1つで、それが無いと bot はメモを**見ることもできなかった**。
6407
+ * bot を予定の出席者に自動で足すようになった(オーナー決定 A-1・#426)ので必須から降りた。
5972
6408
  *
5973
- * 目印の導出と同じく1箇所にまとめる——別名の一覧(.ds-legacy)と、
5974
- * プライバシーの注釈(.mt-privacy)が**同じ条件で出る**必要があるため。
5975
- * 片方だけ出ると、注釈の「上に並ぶ登録済みの会議名」が指す先が画面に無くなる
5976
- */
5977
- function meetingAliasesOf(s) {
5978
- if (!s || s.kind !== "会議" || s.ingestState !== "on") return [];
5979
- return s.meetingAliases || [];
5980
- }
5981
-
5982
- /**
5983
- * 会議の取り込みの案内。**操作ではなく条件を示す。**
6409
+ * 🔴 **会議名の合図・経過措置の別名・「Google Meet」フォルダの共有の案内は、
6410
+ * 2026-09-05 に画面からまとめて撤去した(#438)。** 月曜から全員に案内するので、
6411
+ * 「近く廃止」と書いたものを初日に見せない——移行期間の文言は、それを知っている人にしか
6412
+ * 意味が読めない。
6413
+ *
6414
+ * ⚠ **消えたのは画面だけで、裏はまだ動いている。** Apps Script の照合(Router.gs の
6415
+ * routeTranscript_)は今も**3通り**(カレンダーの添付・目印・別名)で当たる。
6416
+ * **画面のほうが先を行っている状態**で、残りの撤去は廃止便(合図フラグ)で行う。
6417
+ * ここの文言を触るときは、必ず Router.gs の照合を見直すこと(前に「2通り」の前提で
6418
+ * 書いて画面が嘘になった事故がある)。
5984
6419
  *
5985
- * 取り込まれるには2つとも要る:
5986
- * ① cortex-notetaker Bot が会議に招待されている(=取り込む意思。これが同意のゲート)
5987
- * ② 会議名が照合キーのどれかを含む(含まなければ、録れても案件に振り分けられない)
6420
+ * 🔴 **フォルダ共有の経路だけは、もう裏にも無い**(#439 が 2026-09-05 に撤去した)。
6421
+ * つまり「フォルダを共有していれば今までどおり効く」はもう成り立たない——
6422
+ * bot に直接共有されたメモだけを見る。画面から枠を消したのと同じ日に裏も消えた。
5988
6423
  *
5989
- * ②の材料を画面が出さないと、招待したのに届かない理由が分からない。
6424
+ * 並びは上から: 条件 → 何を開くかの1行 → 案件カレンダーの行 → 出席者に入るまでの1行。
5990
6425
  */
5991
6426
  function meetingHowTo(s) {
5992
6427
  // **2つの条件がANDであることを、読み飛ばせない形で出す。** 散文で並べていたときに
5993
6428
  // 「会議名は合っているのにBotを招待しておらず取り込まれなかった」事故が実際に起きた。
5994
- // 会議名の条件は上の行(ds-keys)が出すので、ここでは「上の語」と参照するに留める。
5995
6429
  var wrap = el("div", { class: "sec-how" });
5996
- wrap.appendChild(el("p", { text: "次の2つが揃った会議だけが取り込まれます。" }));
6430
+ wrap.appendChild(el("p", { text: "取り込まれる条件" }));
5997
6431
  var ul = el("ul", { class: "sec-how-list" });
5998
- ul.appendChild(el("li", { text: "cortex-notetaker Bot が招待されている(定例はシリーズに1回でOK)" }));
5999
- ul.appendChild(el("li", { text: "会議名に上の目印が入っている" }));
6432
+ ul.appendChild(el("li", { text: "予定がこの案件のカレンダーにある" }));
6433
+ ul.appendChild(el("li", { text: "Gemini のメモが ON" }));
6000
6434
  wrap.appendChild(ul);
6001
- wrap.appendChild(el("p", { text: "どちらか一方だけでは取り込まれません。" }));
6002
- wrap.appendChild(meetingFolderShare(meetingSignalOf(s), meetingAliasesOf(s).length));
6435
+ wrap.appendChild(el("p", { text: "この 2 つが揃った会議だけが取り込まれます。" }));
6436
+ /*
6437
+ * 「全部見られてしまうのでは」への答え(#418 の A-6・廃止後の文としてオーナー承認済み)。
6438
+ *
6439
+ * ⚠ **分岐しない定文。** 案件の値(合図・艦隊キー・別名)を1文字も埋めない——
6440
+ * 埋めると案件ごとに承認外の文が生まれる。
6441
+ *
6442
+ * 🔴 **この文は、裏より先に新しくなっている。** 前の版は「目印か別名に当たるメモ」も
6443
+ * 開くと書いていた。その経路は #439 と廃止便で消えるが、**いまはまだ生きている**
6444
+ *(Router.gs の照合は3通りのまま)。廃止をやめるなら、この文を先に戻さないと嘘になる。
6445
+ *
6446
+ * 枠にも帯にもしない(.mt-scope)。枠と帯は症状のためのもので、これは事実の1行。
6447
+ */
6448
+ wrap.appendChild(el("p", {
6449
+ class: "mt-scope",
6450
+ text: "案件カレンダーの予定に添付されたメモだけを開きます。それ以外は中身を開きません。",
6451
+ }));
6452
+ // ---- 2番目: 案件カレンダーの行(#428) ----
6453
+ //
6454
+ // 🔴 **投入の口を持たない案件では、この行を1行に差し替える。** 状況(op=state)は
6455
+ // SITE.intake が無いと**一度も取りに行かない**ので、行をそのまま置くと
6456
+ // 「案件カレンダーの状況を確認中です。」が**永久に**居座る(確認は始まってもいない)。
6457
+ // ただし fleet 側に ID がある案件は、取りに行かなくてもカレンダーが分かっている
6458
+ // ——そこは行のまま出す(リンクとコピー欄を、投入の口の有無で落とさない)。
6459
+ if (SITE.intake || calendarIdOf(s, LIVE_STATE)) {
6460
+ wrap.appendChild(meetingCalendarSection(s));
6461
+ // 🔴 **カレンダーの箱(.mt-cal)の外に置く。** あそこは liveList で、差し替えのたびに
6462
+ // 中身を作り直す——中に入れると、状況が届いた瞬間にこの1行だけが消える。
6463
+ // 直前に作った会議が入らない相談は実際に来るので、待ちがあることを先に書いておく
6464
+ wrap.appendChild(el("p", {
6465
+ class: "mt-cal-lag",
6466
+ text: "予定を作ってから bot(cortex-notetaker)が出席者に入るまで数分かかります。直前に作った会議は取り込まれないことがあります。",
6467
+ }));
6468
+ } else {
6469
+ // 帯は症状・行は一手。ここでの一手は「担当者に聞く」しかない——画面から
6470
+ // カレンダーを作る口も、状況を確かめる口も、この案件には生えていない。
6471
+ // **待ちの1行は続けない**(作る場所が分からない人に、作った後の話をしない)
6472
+ wrap.appendChild(el("p", {
6473
+ class: "mt-cal-off",
6474
+ text: "この案件では、この画面から案件カレンダーを確かめられません。カレンダーの場所は cortex の担当者にご確認ください。",
6475
+ }));
6476
+ }
6003
6477
  return wrap;
6004
6478
  }
6005
6479
 
6006
6480
  /**
6007
- * 主催者向けの選択肢: 「Google Meet」フォルダを1回共有すると、以後は招待が要らない。
6481
+ * ボタンを出してよいか(#428・分類B)。
6008
6482
  *
6009
- * **上の bot 招待の説明は残したまま足す(2026-08-29 オーナー承認)。** 招待方式は
6010
- * 参加するだけの会議・他社主催の会議で今も唯一の手段なので、置き換えではない。
6483
+ * 🔴 **画面には「この人がこの案件のオーナーか管理者か」を知る材料が無い。**
6484
+ * ポータルが渡す名乗り券は権限を載せない(券はブラウザの JS から読めるので、
6485
+ * 載せた情報は案件ビューアを開ける人全員に配った情報になる——ポータルの
6486
+ * actor-ticket.ts が明示的にそう決めている)。接続状況の応答にも役割は無い。
6011
6487
  *
6012
- * **共有対象は 2026-09-03 に「Meet Recordings」から「Google Meet」へ改めた(#393)。**
6013
- * Gemini の保存先が変わり、メモは「Google Meet」の中の会議ごとのフォルダに入るようになった。
6488
+ * だから**権限の正本はサーバー側だけ**に置き、画面は「押したら断られたか」を覚える。
6489
+ * 画面が別の判定を持つと二重の真実になり、名簿が変わったときに片方だけが古くなる。
6014
6490
  *
6015
- * **手順の細目は書かない。** 正本は apps-script/docs/meet-recordings-sharing.md で、
6016
- * 「共有したフォルダから1階層下までしか見ない」「メモを移すと対象外になる」
6017
- * 「Google Meet が無い環境では Meet Recordings を共有する」等はそちらが持つ。
6018
- * 画面が担うのは**選択肢があることと、共有すると何が起きるかの1行**まで
6019
- *(同じ説明を2箇所で維持すると、必ず片方が古くなる)。
6491
+ * **覆すときはここ1か所**——接続状況の応答に真偽が1つ載れば、押す前に出し分けられる。
6492
+ */
6493
+ var CALENDAR_DENIED = false;
6494
+ function calendarCanRequest() { return !CALENDAR_DENIED && !!actorTicket(); }
6495
+
6496
+ /**
6497
+ * 案件カレンダーの行(#428・設計は #418 の §6)。
6020
6498
  *
6021
- * 🔴 **注意の1行を落とさない。** フォルダごと共有するので、目印の無い会議
6022
- *(1on1・面談)のメモも bot から**見える状態にはなる**。取り込まれないことと
6023
- * 見えないことは別なので、両方を1行で正直に書く。
6499
+ * **帯は症状・行は一手・繰り返さない。** 帯を出すのは「まだ無い」と「作れなかった」の
6500
+ * 2つだけ——作成中も作成済みも症状ではないので、帯を出すと画面が常に警告色になる。
6024
6501
  */
6025
- // 注: ここは埋め込みJSのテンプレートリテラル内。コメントでもバッククォートを使わない
6026
- function meetingFolderShare(signal, aliasCount) {
6027
- var box = el("div", { class: "mt-share" });
6028
- box.appendChild(el("div", { class: "mt-share-title", text: "毎回の招待をやめる方法(会議の主催者向け・任意)" }));
6029
- box.appendChild(el("p", { text: "自分の Google ドライブにある「Google Meet」フォルダ(2026年7月より前から使っている環境では「Meet Recordings」)を、下のアカウントに「閲覧者」で1回だけ共有すると、以後あなたが主催する会議は招待が要らなくなります。会議名の目印はこれまでどおり必要です。「Google Meet」フォルダがまだ無い場合は、Gemini のメモを ON にした会議を1回主催すると作られます。" }));
6030
- box.appendChild(copyRow(DRIVE_INGEST_ACCOUNT));
6502
+ function meetingCalendarSection(s) {
6503
+ // 🔴 **liveList で包む。** 会議のモーダルを開いたまま「作る」を押すと、
6504
+ // 差し替え(refreshDataSources)はモーダルが開いている間なにもしない。
6505
+ // 追いつくのは [data-live-cap] の箱だけなので、包まないと**閉じて開き直すまで
6506
+ // 「作成中」にも「作成済み」にもならない**(Backlog / Drive / Figma と同じ作法)
6507
+ return liveList("会議", function () { return meetingCalendarSig(s); }, function (box) {
6508
+ meetingCalendarBuild(s, box);
6509
+ });
6510
+ }
6031
6511
 
6032
- /*
6033
- * 🔴 **「全部見られてしまうのでは」に答える**(2026-08-29 オーナー確定文)。
6034
- * **2026-09-03 に1語だけ更新した(#393)**——「ファイル名だけを見ます」は、共有フォルダの
6035
- * 1階層下まで見るようになって不正確になったので「フォルダ名とファイル名だけを見ます」に。
6036
- * それ以外は表記も含めて変えない。
6037
- *
6038
- * すぐ下の注記(.mt-share-note)は「**取り込むか**」に答えていて、
6039
- * 「**見るのか**」に答えていない。共有をためらう理由はそちらなので、専用に1行置く。
6040
- *
6041
- * **実装と一致していることを確かめてある**(apps-script/src/Main.gs:79-102・
6042
- * Router.gs:65-91・Transcripts.gs の getMeetingName_):
6043
- * - 振り分けに使うのは file.getName()(ファイル名)だけ
6044
- * - 当たるのは**目印**(meetingNameHasKey_)**または別名の完全一致**
6045
- * (meetingNameIsAlias_)の2通り。**別名は目印が要らない**
6046
- * - どちらにも当たらないファイルは getTranscriptText_(Docs のエクスポート)を
6047
- * 呼ぶ前に return する=**中身を開かない**
6048
- * - どのリポジトリにも Slack にも書かない
6049
- *
6050
- * 🔴 **最初の版は「目印が入っていないメモは開かない」と書いて嘘になっていた**
6051
- *(別名の経路を見落としていた)。しかも同じカードの .ds-legacy が
6052
- * 「目印が無くても取り込まれます」と並んで出ていた——**カードが自分を否定していた**。
6053
- * 文言を触るときは、必ず Router.gs の照合の**2通り**を見直すこと
6054
- */
6055
- var mark = signal ? "案件の目印([" + signal + "] など)" : "案件の目印";
6056
- // **別名が1件も無い案件では「上に並ぶ登録済みの会議名」が画面に存在しない。**
6057
- // 指す先が無いものを指すと、そこがまた嘘になるので、その句だけを機械的に落とす
6058
- //(新しい言い回しは足さない)
6059
- var scope = aliasCount
6060
- ? mark + "と、上に並ぶ登録済みの会議名——このどちらにも当てはまらない"
6061
- : mark + "に当てはまらない";
6062
- box.appendChild(el("p", {
6063
- class: "mt-privacy",
6064
- text: "⚠ 共有を開始すると、bot はこのフォルダの中のフォルダ名とファイル名だけを見ます。" + scope +
6065
- "メモは、中身を開きません。読み取りも保存も行わないので、1on1や面談のメモがどこかに残ることはありません。",
6066
- }));
6512
+ /**
6513
+ * 判定に渡す観測の文脈。**組み立てはここ 1 か所**——2 か所で作ると、片方だけ
6514
+ * LIVE_STATE_SEEN を渡し忘れて「観測前でも『作る』が出る」形へ静かに戻る
6515
+ */
6516
+ function calendarCtx() {
6517
+ return { seen: LIVE_STATE_SEEN, rawCalendar: LIVE_STATE_RAW_CALENDAR };
6518
+ }
6067
6519
 
6068
- // **フォルダを探すところから助ける。** フォルダIDは人ごとに違うので固定URLは作れないが、
6069
- // 検索URLなら**1本で全員に効く**(開いた人のドライブの中で検索が走る)。
6070
- // **共有ダイアログを直接開くURLは作れない**ので、リンクの先で1手かかることを
6071
- // 押す前に書いておく(押した先で迷子にしない)
6072
- box.appendChild(el("a", {
6073
- class: "mt-share-open", href: MEET_FOLDER_SEARCH_URL, target: "_blank", rel: "noopener",
6074
- text: "自分の「Google Meet」フォルダを開く →",
6075
- }));
6076
- var steps = el("ol", { class: "mt-share-steps" });
6077
- // 🔴 **どれを選ぶかをここで言い切る。** 検索結果には会議ごとのフォルダも並ぶので、
6078
- // 「検索されます」だけで終えると、末尾に日付の付いたフォルダを共有してしまう人が出る
6079
- steps.appendChild(el("li", { text: "上のリンクを開くと、自分のドライブの中から「Google Meet」フォルダが検索されます。名前がちょうど「Google Meet」のものを選んでください。末尾に日付や (recurring) が付いたものは会議ごとのフォルダなので、それらを入れている親を選びます。" }));
6080
- steps.appendChild(el("li", { text: "そのフォルダを右クリック(または右上の ⋮)して「共有」→「共有」を選びます(画像は旧名称「Meet Recordings」のときの画面ですが、操作は同じです)。" }, [
6081
- // **寸法を属性で書く。** 書かないと、モーダルを開いた瞬間に画像の高さのぶんだけ
6082
- // 下の手順がずれる。loading=lazy は付けない——**data: URI には取得が無いので
6083
- // 何もしない**のに、付いていると「遅延読み込みしている」という誤解が仕様として残る
6084
- el("img", { class: "mt-shot", src: MEET_SHOT_STEP1, width: "664", height: "425",
6085
- alt: "Googleドライブの検索結果。フォルダのメニューで「共有」を選び、右に開いたサブメニューの「共有」を指している画面(旧名称「Meet Recordings」のときの例)。" }),
6086
- ]));
6087
- steps.appendChild(el("li", { text: "上のアカウントを貼り付け、権限を「閲覧者」にして「共有」を押します(通知のチェックは外して構いません。画像は旧名称「Meet Recordings」のときの画面ですが、操作は同じです)。" }, [
6088
- el("img", { class: "mt-shot", src: MEET_SHOT_STEP2, width: "425", height: "220",
6089
- alt: "フォルダを共有するダイアログ。宛先に cortex-notetaker のアカウントが入り、権限が「閲覧者」に設定され、右下に「共有」ボタンがある画面(旧名称「Meet Recordings」のときの例)。" }),
6090
- ]));
6091
- box.appendChild(steps);
6520
+ /** 描き直しの要否。**phase と ID と観測の時刻**(この3つ以外では見た目が変わらない) */
6521
+ function meetingCalendarSig(s) {
6522
+ var cal = calendarPhase(s, LIVE_STATE, calendarCtx(), Date.now());
6523
+ return cal.phase + "\t" + cal.calendarId + "\t" + cal.note + "\t" + cal.detail + "\t" + cal.at;
6524
+ }
6092
6525
 
6093
- // 権限の話(**bot から見える状態になる**)は、挙動の話(⚠ の注釈)と隣に置かない。
6094
- // 並べると「見ない」→「見える」と続いて、正本が丁寧に分けている区別が消える
6095
- box.appendChild(el("p", { class: "mt-share-note", text: "共有すると、そのフォルダに入る会議メモは bot から見える状態になります(実際に取り込まれるのは、会議名に目印が入っている会議だけです)。" }));
6526
+ function meetingCalendarBuild(s, box) {
6527
+ var cal = calendarPhase(s, LIVE_STATE, calendarCtx(), Date.now());
6528
+ // **毎回付け直す。** liveList の箱は差し替えで中身だけを作り直すので、
6529
+ // class と data-phase をここで確定させておかないと、最初の1回の値が残る
6530
+ box.className = "ds-livelist mt-cal";
6531
+ box.setAttribute("data-phase", cal.phase);
6096
6532
 
6097
- return box;
6533
+ // まだ観測が届いていない。**帯もボタンも出さない**——ここで「まだありません」+「作る」を
6534
+ // 出すと、既にカレンダーがある案件で速い人が押して 2 本目を作る
6535
+ if (cal.phase === "unknown") {
6536
+ box.appendChild(el("p", { class: "mt-cal-note", text: "案件カレンダーの状況を確認中です。" }));
6537
+ return;
6538
+ }
6539
+
6540
+ // 行はあるのに ID も既知の状態も引けない。**押させない**——待てば直るが、
6541
+ // 押すと 2 本目が出来て戻せない(calendarPhase の項)
6542
+ if (cal.phase === "unclear") {
6543
+ box.appendChild(el("p", {
6544
+ class: "mt-cal-note",
6545
+ text: "案件カレンダーの状況を確認しています。しばらく待っても変わらなければ、cortex の担当者に連絡してください。",
6546
+ }));
6547
+ return;
6548
+ }
6549
+
6550
+ if (cal.phase === "ready") {
6551
+ box.appendChild(el("a", {
6552
+ class: "mt-cal-link", href: calendarTemplateUrl(cal.calendarId), target: "_blank", rel: "noopener",
6553
+ text: "この案件の予定を作る →",
6554
+ }));
6555
+ box.appendChild(el("a", {
6556
+ class: "mt-cal-link", href: calendarAddUrl(cal.calendarId), target: "_blank", rel: "noopener",
6557
+ text: "マイカレンダーに追加 →",
6558
+ }));
6559
+ // **見分けの付くクラスを付ける。** 同じカードに copyRow が2つ(bot のアカウントと
6560
+ // カレンダー ID)並ぶので、素の .rt-copy-text ではどちらか分からない
6561
+ box.appendChild(el("div", { class: "mt-cal-id" }, [copyRow(cal.calendarId)]));
6562
+ // 色は**見る人ごとの設定**で、仕組みでは揃えられない(#418 の調査4)。
6563
+ // 質問にはせず、事実として1行だけ置く(オーナー決定 A-5: 画面に計器は出さない)
6564
+ box.appendChild(el("p", { class: "mt-cal-note", text: "色はご自身のカレンダーで変えられます。" }));
6565
+ // 作成済みのまま進行中・失敗の報告が最新なら、その1行だけ添える。
6566
+ // **「作れませんでした」とは言わない**(出来ているので嘘になり、押し直すと 2 本目が作られる)
6567
+ if (cal.note === "requested") {
6568
+ box.appendChild(el("p", { class: "mt-cal-note", text: "共有を更新しています(最長 " + CALENDAR_WAIT_MIN + " 分)。" }));
6569
+ } else if (cal.note) {
6570
+ box.appendChild(el("p", { class: "mt-cal-warn", text: "共有の更新が終わっていません(" + cal.note + ")。" }));
6571
+ }
6572
+ box.appendChild(calendarRequestButton("共有を更新", "share"));
6573
+ return;
6574
+ }
6575
+
6576
+ if (cal.phase === "pending") {
6577
+ box.appendChild(el("p", { class: "mt-cal-note", text: "作成中(最長 " + CALENDAR_WAIT_MIN + " 分。自動で更新されます)" }));
6578
+ return;
6579
+ }
6580
+
6581
+ if (cal.phase === "stale") {
6582
+ // 約束の3倍を過ぎた=裏の経路が止まっている(トリガー未設置・認可切れ)。
6583
+ // **「依頼が届いていません」とは言わない**——行があるということは、依頼はサーバに
6584
+ // 届いている(行を書いたのがサーバ)。利用者にできることは無いので、押せるものは
6585
+ // 出さずに連絡先だけを出す
6586
+ box.appendChild(el("p", {
6587
+ class: "mt-cal-note",
6588
+ text: "作成が終わっていません(依頼は届いています)。cortex の担当者に連絡してください。",
6589
+ }));
6590
+ return;
6591
+ }
6592
+
6593
+ if (cal.phase === "failed") {
6594
+ box.appendChild(el("div", { class: "mt-cal-banner", text: "カレンダーを作れませんでした" }));
6595
+ // **空の理由は出さない**(空の箱を出すと、理由があるのに読めないように見える)
6596
+ if (cal.detail) box.appendChild(el("p", { class: "mt-cal-note", text: cal.detail }));
6597
+ box.appendChild(calendarRequestButton("もう一度", "retry"));
6598
+ return;
6599
+ }
6600
+
6601
+ box.appendChild(el("div", { class: "mt-cal-banner", text: "案件カレンダーがまだありません" }));
6602
+ box.appendChild(calendarRequestButton("この案件のカレンダーを作る", "create"));
6603
+ }
6604
+
6605
+ /**
6606
+ * 依頼を出すボタン。**3つ(作る・共有を更新・もう一度)とも同じ1本の経路**で、
6607
+ * 押した直後の見え方だけが違う(裏の経路は冪等・#427)。
6608
+ *
6609
+ * **確認を出すのは「作る」だけ。** 「共有を更新」「もう一度」は取り返しがつく
6610
+ * (同じ依頼が立ち直るだけ)ので、確認を挟むと押す手が増えるだけになる。
6611
+ *
6612
+ * 🔴 **本文は空**(projectKey だけ)。共有先のメールをブラウザから受け取らない——
6613
+ * ?t= を持つ人(顧客も持ちうる)が社外アドレスに権限を張れる経路になる(#427 の設計)。
6614
+ */
6615
+ function calendarRequestButton(label, kind) {
6616
+ // 🔴 **名乗り券が無い人に「オーナーが作れます」と出さない。** サーバは券が無くても
6617
+ // 403 を返すが、その人の直し方は「ポータルから開き直す」であって「オーナーに頼む」
6618
+ // ではない。断られ方が同じでも、次の一手は別
6619
+ if (!actorTicket()) return calendarPortalNote();
6620
+ if (!calendarCanRequest()) return calendarOwnerOnlyNote();
6621
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: label });
6622
+ var msg = el("div", { class: "ds-toggle-msg" });
6623
+ var wrap = el("div", { class: "mt-cal-act" }, [btn, msg]);
6624
+ btn.addEventListener("click", function () {
6625
+ var fail = function (text) {
6626
+ btn.disabled = false;
6627
+ msg.className = "ds-toggle-msg is-error";
6628
+ msg.textContent = "⚠ " + text;
6629
+ };
6630
+ var go = function () {
6631
+ btn.disabled = true; // 二重送信で 2 件目の依頼を立てない
6632
+ msg.className = "ds-toggle-msg";
6633
+ msg.textContent = "送信中…";
6634
+ intakeJson(callIntake("calendar-request", { projectKey: SITE.intake.projectKey })).then(
6635
+ function (res) {
6636
+ // 🔴 **403 かつサーバが語で名乗ったときだけ、押せない 1 行に落とす。**
6637
+ // 403 は権限以外でも返る(WAF・CDN・トークン失効)。素の 403 で文言を確定させると、
6638
+ // 一時的な障害のときにオーナー本人へ「あなたには作れません」と言うことになる。
6639
+ // **5xx とも混ぜない**——一時的な障害でボタンを永久に消すと、直ったあとも誰も作れない。
6640
+ //
6641
+ // 語は intake の実装(logic.mjs の planCalendarRequest / calendarShareFrom)が正本。
6642
+ // **断り方で次の一手が違う**ので、1 つに丸めない:
6643
+ // CALENDAR_ROLE_REQUIRED … 権限が無い → 案件のオーナーに頼む
6644
+ // CALENDAR_PORTAL_REQUIRED … ポータル経由で開いていない → 開き直す
6645
+ // CALENDAR_ACTOR_EMAIL_REQUIRED … 券にメールが無い → 同上
6646
+ var code = res.j && typeof res.j.code === "string" ? res.j.code : "";
6647
+ if (res.status === 403 && (code === "CALENDAR_ROLE_REQUIRED" || code === "CALENDAR_PORTAL_REQUIRED" || code === "CALENDAR_ACTOR_EMAIL_REQUIRED")) {
6648
+ CALENDAR_DENIED = true;
6649
+ var note = code === "CALENDAR_ROLE_REQUIRED"
6650
+ ? calendarOwnerOnlyNote()
6651
+ : calendarPortalNote();
6652
+ if (wrap.parentNode) wrap.parentNode.replaceChild(note, wrap);
6653
+ return;
6654
+ }
6655
+ // **サーバの文をそのまま出す。** 断り方は intake が言い分けているので、
6656
+ // 画面が言い換えると 2 枚になる(片方だけ古くなる)
6657
+ if (!res.ok) return fail((res.j && res.j.message) || "依頼できませんでした。しばらくしてからもう一度お試しください。");
6658
+ msg.className = "ds-toggle-msg is-ok";
6659
+ msg.textContent = "✓ 依頼しました。作成中(最長 " + CALENDAR_WAIT_MIN + " 分)です。";
6660
+ // pending-ops(#388)は使わない。**あれは registeredNow のキーで live 判定する
6661
+ // 仕組みで、カレンダーは stateNow 由来**。fresh で取り直せば「作成中」が出る
6662
+ loadLiveFleet(true);
6663
+ },
6664
+ function () { fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); },
6665
+ );
6666
+ };
6667
+ if (kind !== "create") return go();
6668
+ // **確認を出している間も押せなくする。** 押せたままだと確認が 2 枚重なりうる
6669
+ //(openConfirm 自身も多重オープンを無音で断るが、断られた側は無反応に見える)
6670
+ btn.disabled = true;
6671
+ openConfirm(
6672
+ [
6673
+ "cortex-notetaker が「" + (SITE.title || "この案件") + "」のカレンダーを作り、案件の社内メンバーに予定作成の権限で共有します。",
6674
+ "最長 " + CALENDAR_WAIT_MIN + " 分かかります。",
6675
+ ],
6676
+ { okLabel: "作る", okDanger: false, returnTo: btn },
6677
+ ).then(function (yes) {
6678
+ btn.disabled = false; // go() が改めて立てる。キャンセルならここで戻る
6679
+ if (yes) go();
6680
+ });
6681
+ });
6682
+ return wrap;
6683
+ }
6684
+
6685
+ /** 断られた人に出す1行。**ボタンは出さない**(何度押しても同じ答えなので) */
6686
+ function calendarOwnerOnlyNote() {
6687
+ return el("p", { class: "mt-cal-note", text: "案件のオーナーが作れます。" });
6688
+ }
6689
+
6690
+ /** ポータル経由で開いていない人に出す1行。**直し方がオーナーに頼むのとは別** */
6691
+ function calendarPortalNote() {
6692
+ return el("p", {
6693
+ class: "mt-cal-note",
6694
+ text: "案件カレンダーを作るには、ポータルからこの案件を開き直してください。",
6695
+ });
6098
6696
  }
6099
6697
 
6100
6698
  /**
@@ -6893,6 +7491,15 @@ ${deriveCapTile.toString()}
6893
7491
  * (=会議・共有資料の「未使用」表示が一番効いてほしい案件)でだけ機能が死ぬ。
6894
7492
  */
6895
7493
  var LIVE_STATE_SEEN = false;
7494
+ /**
7495
+ * 生の応答にあった案件カレンダーの行の本数(#428)。**sanitize の前に数える。**
7496
+ *
7497
+ * 🔴 **LIVE_STATE から数え直せない。** sanitize は語彙に無い語の行を落とすので、
7498
+ * 落ちた後を数えると「知らない語で落ちた」と「1 本も無い」が同じ 0 になる。
7499
+ * calendar はその差が致命的な唯一の問い——0 と読むと画面が「カレンダーがまだ
7500
+ * ありません」+「作る」を出し、押した人が 2 本目を作る(calendarPhase の項)。
7501
+ */
7502
+ var LIVE_STATE_RAW_CALENDAR = 0;
6896
7503
  /**
6897
7504
  * 鍵がいま登録されているか(op=state の secretsNow・列2)。取得前・取得失敗時は null。
6898
7505
  *
@@ -7361,9 +7968,23 @@ ${deriveCapTile.toString()}
7361
7968
  * 🔴 **未確定の行が無ければタイマを張らない。** テストは load の後に fetch を注入するので、
7362
7969
  * 無条件に張ると jsdom が閉じられず node --test が返らなくなる(反映待ちで実際に踏んだ形)。
7363
7970
  */
7971
+ /**
7972
+ * 案件カレンダーの作成を待っているか(#428)。
7973
+ *
7974
+ * 🔴 **「自動で更新されます」と画面に書いた以上、実際に取り直す。** 仮置き(#388)の
7975
+ * 数え方(unsettledNow)にはカレンダーの依頼が1件も入らないので、これを見ないと
7976
+ * **取り直しが1回も張られず、押した人は「作成中」のまま放置される**。
7977
+ *
7978
+ * **期限は依頼の時刻から測る**(画面を開いた時刻ではない)。他の案件の画面を開いた人が
7979
+ * 何時間も叩き続けないよう、止まったと見なす時刻(CALENDAR_STALE_MS)で終わる
7980
+ * ——そこから先は取り直しても答えは変わらない(人が担当者に連絡する番)。
7981
+ */
7982
+ function calendarPollWanted() { return calendarWaiting(LIVE_STATE, Date.now()); }
7983
+
7364
7984
  function refreshStateTimer() {
7365
7985
  var n = unsettledNow();
7366
- if (!n) {
7986
+ var cal = calendarPollWanted();
7987
+ if (!n && !cal) {
7367
7988
  if (stateTimer) { clearTimeout(stateTimer); stateTimer = null; }
7368
7989
  stateSince = null;
7369
7990
  return;
@@ -7371,7 +7992,10 @@ ${deriveCapTile.toString()}
7371
7992
  if (stateTimer) return;
7372
7993
  if (!SITE.intake || typeof fetch !== "function") return;
7373
7994
  if (stateSince == null) stateSince = Date.now();
7374
- var delay = nextStateDelay(n, stateSince, Date.now());
7995
+ // カレンダー待ちは**仮置きの15分の期限に載せない**(作成の約束は20分で、載せると
7996
+ // 最後の5分だけ静かに止まる=文言が嘘になる時間帯ができる)。依頼の時刻から測った
7997
+ // 期限は calendarPollWanted が持っているので、ここは間隔だけを決める
7998
+ var delay = cal ? UNSETTLED_POLL_MS : nextStateDelay(n, stateSince, Date.now());
7375
7999
  if (delay == null) return;
7376
8000
  stateTimer = setTimeout(stateTick, delay);
7377
8001
  }
@@ -7666,7 +8290,7 @@ ${deriveCapTile.toString()}
7666
8290
  // 用意してからここを戻す(syncNowRow と Lambda の op=sync は残してある)。
7667
8291
  // sec.appendChild(syncNowRow());
7668
8292
  internals.forEach(function (s) {
7669
- // 補足行(会議の照合キー・Drive未設定の注記)を重ねられるよう縦組みにする。
8293
+ // 補足行(Drive未設定の注記・未使用の注記)を重ねられるよう縦組みにする。
7670
8294
  //
7671
8295
  // **情報源の名前は出さない(2026-08-21・オーナー指摘)。** モーダルの見出しが
7672
8296
  // ツール名(Backlog / Figma / Google Drive …)になったので、その直下にもう一度
@@ -7678,63 +8302,16 @@ ${deriveCapTile.toString()}
7678
8302
  // デザイン(Figmaファイル)だけで、どちらも**レコードの行が同じ先へリンクしている**。
7679
8303
  // 会議・共有資料は url を持たない。
7680
8304
  var col = el("div", { class: "ds-col" }, []);
7681
- // 照合キーの表示はここ1箇所に集約する(下の案内と二重に出さない)。
7682
- // **ON/OFFのトグルは出さない。** 取り込むかどうかは「Bot を会議に招待したか」で決まる
7683
- // (Router.gs:「フィルタは持たない…招待が同意のゲート」)。enabled はルーティングの
7684
- // 可否であって取り込みの可否ではないので、OFFにすると**Botは録り続けるのに
7685
- // 文字起こしがINBOXに溜まる**という、招待しないより悪い状態を作れてしまう。
7686
- // **合図(会議名に入れる目印)を出す。** 照合は「会議名に [合図] / 【合図】 が
7687
- // 入っているか」だけ(Router.gs)。合図は案件リポの meetingKey、未宣言なら艦隊
7688
- // レジストリのキーで、後者は案件リポも fleet-status も知らない——投入設定からしか取れない。
8305
+ // **ON/OFFのトグルは出さない。** 取り込むかどうかは会議の設定(enabled)と
8306
+ // 予定の置き場で決まる。enabled はルーティングの可否であって取り込みの可否ではないので、
8307
+ // OFFにすると**Botは録り続けるのに文字起こしがINBOXに溜まる**という、
8308
+ // 招待しないより悪い状態を作れてしまう。
7689
8309
  //
7690
- // **括弧を付けた形で出す。** 語だけ見せると 【KC定例】 のように混ぜて書かれ、
7691
- // どこも一致せず未仕分けへ落ちる(実際に起きた)。そのままコピーできる形にする。
7692
- // **自動取り込みが動いているときだけ出す。** Teams等で手動運用している案件に
7693
- // 会議名の条件を出すと、自動で拾われると誤解させる(実際には拾われない)。
7694
- var signal = meetingSignalOf(s);
7695
- var aliases = meetingAliasesOf(s);
7696
- if (signal) {
7697
- // **カードで一番強い要素にする(#117)。** 実機で見たとき、目印の案内が
7698
- // 別名の一覧や注記と同じ細字で並んでいて、「まず会議名にこれを入れる」が
7699
- // 伝わらなかった。最初に決めた合図が唯一の真実なので、そこだけを太く出す。
7700
- //
7701
- // **括弧を付けた形でそのまま出す。** 語だけ見せると 【KC定例】 のように
7702
- // 混ぜて書かれ、どこも一致せず未仕分けへ落ちる(実際に起きた)。
7703
- col.appendChild(el("div", {
7704
- class: "ds-keys",
7705
- title: "会議名のどこかにこの括弧付きの目印が入っていると、この案件に振り分けられます(大小文字は問いません)。括弧の中に他の文字を混ぜると一致しません。",
7706
- }, [
7707
- el("span", { class: "ds-keys-label", text: "会議名にこれを入れてください" }),
7708
- el("code", { class: "ds-keys-code", text: "【" + signal + "】" }),
7709
- el("span", { class: "ds-keys-or", text: "または" }),
7710
- el("code", { class: "ds-keys-code", text: "[" + signal + "]" }),
7711
- ]));
7712
- }
7713
- if (aliases.length) {
7714
- // **読み取り専用の記録として出す(#117)。** 目印が無くても入る会議=括弧運用へ
7715
- // 移る前から走っていて、改名を頼めなかった定例。出さないと「なぜこれだけ入るのか」が
7716
- // 画面から分からないので残すが、**操作は一切出さない**。
7717
- //
7718
- // 以前は1件ずつ外せるようにしていた。実機確認でユーザーが「最初に決めたキー(合図)が
7719
- // 唯一真実」と確定させたので、画面から触れる余地を無くす——別名を足したり外したり
7720
- // できる限り、合図と別名のどちらが正なのかが人によって揺れ続ける。
7721
- // 変更が要るときはリポジトリの ingest-config.json を直接編集する(Lambda も断る)。
7722
- var legacy = el("div", { class: "ds-legacy" }, [
7723
- el("span", {
7724
- text: "経過措置で登録された会議名(目印が無くても取り込まれます。変更はリポジトリの ingest-config.json で行います)",
7725
- }),
7726
- ]);
7727
- var chips = el("div", { class: "ds-legacy-chips" });
7728
- aliases.forEach(function (a) {
7729
- chips.appendChild(el("span", {
7730
- class: "ds-legacy-chip",
7731
- text: a,
7732
- title: "この名前の会議は、目印が無くてもこの案件に振り分けられます(大小文字・前後の空白は問いません。ファイル名に使えない記号(コロン・スラッシュ等)は同じものとして扱います)。",
7733
- }));
7734
- });
7735
- legacy.appendChild(chips);
7736
- col.appendChild(legacy);
7737
- }
8310
+ // 🔴 **会議名の合図と経過措置の別名は 2026-09-05 に撤去した(#438)。** ここには
8311
+ // 投入設定を持たない案件のために合図を出す分岐が残っていたが、案件カレンダー方式へ
8312
+ // 一本化したので、出すものが無くなった(会議カードの案内は meetingHowTo だけが持つ)。
8313
+ // **その案件に案内を届けるのは internalControls 側**——投入設定のゲートを
8314
+ // 会議の分岐より後ろへ動かしてある(動かす前は、ここを消した結果カードが空になった)
7738
8315
  if (s.driveSync === false) {
7739
8316
  col.appendChild(el("span", { class: "ds-drive-note", text: "Drive自動同期は未設定(手動で置いたファイルの変換のみ)" }));
7740
8317
  }
@@ -7757,9 +8334,8 @@ ${deriveCapTile.toString()}
7757
8334
  // 「📋 課題管理 Backlog」という箱がもう一段あり、見出しと同じことを二度言っていた。
7758
8335
  // モーダルの見出しがツール名になったので、ここは**見出しの直下に中身を並べる**だけ。
7759
8336
  //
7760
- // 🔴 **落としたのは見出しと重複する2つだけ。** 会議の合図(.ds-keys)・別名チップ・
7761
- // Drive / 未使用の注記・情報源へのリンクは col がそのまま抱えており、
7762
- // 最終同期(.ds-sync)も条件ごと残している。
8337
+ // 🔴 **落としたのは見出しと重複する2つだけ。** Drive / 未使用の注記・情報源への
8338
+ // リンクは col がそのまま抱えており、最終同期(.ds-sync)も条件ごと残している。
7763
8339
  //
7764
8340
  // **data-cap を付ける。** 能力名の文字列が画面から消えたので、
7765
8341
  // 「これはどの能力のブロックか」を掴む手掛かりがここしか無くなる(テストが引く)。
@@ -9823,7 +10399,6 @@ ${deriveCapTile.toString()}
9823
10399
  // まさにここから設定を始めたい。以前は inactive で早期returnしており、
9824
10400
  // 「足したい案件でだけ足せない」という逆転になっていた。
9825
10401
  // 押しても必ず失敗するもの(別ツールが宣言済み)は usable() で弾く。
9826
- if (!SITE.intake) return null;
9827
10402
  var usable = capabilityUsable;
9828
10403
  var box = el("div", {});
9829
10404
  var used = false;
@@ -9840,8 +10415,8 @@ ${deriveCapTile.toString()}
9840
10415
  // 改名を強いないために置いたもの。第1弾では「既に別名がある案件にだけ追加口を出す」
9841
10416
  // としたが、実機確認でユーザーが**「最初に決めたキー(合図)が唯一真実」**と確定させた。
9842
10417
  // 経過措置の案件にフォームを残すと、その案件の中では別名が現役の登録手段に見え続け、
9843
- // 合図と別名のどちらが正なのかが人によって揺れる。画面は合図1つだけを案内し、
9844
- // 別名は上のチップ(読み取り専用の記録)で見せるに留める。Lambda 側も add/remove とも断る。
10418
+ // 合図と別名のどちらが正なのかが人によって揺れる。Lambda 側も add/remove とも断る。
10419
+ // **#438 で別名のチップごと画面から消えた**ので、いまは見せる先も無い(口だけ戻さない)。
9845
10420
  used = true;
9846
10421
  } else if (s.kind === "会議" && usable("会議", "google-meet")) {
9847
10422
  // **文面に設定ファイル名を出さない(2026-08-12 の文言原則)。** ここが見ているのは
@@ -9864,6 +10439,21 @@ ${deriveCapTile.toString()}
9864
10439
  }
9865
10440
  }
9866
10441
 
10442
+ /*
10443
+ * 🔴 **会議の案内より後にゲートを置く(#438 の差し戻し)。**
10444
+ *
10445
+ * 注: ここは埋め込みJSのテンプレートリテラル内。コメントでもバッククォートを使わない
10446
+ * ここは元々この関数の1行目で投入設定(SITE.intake)を見て null を返していた。
10447
+ * #438 でカード側(ds-col)にあった会議名の合図の分岐を撤去した結果、
10448
+ * **投入の口を持たない案件では会議のカードが丸ごと空になった**——取り込まれる条件が
10449
+ * どこにも出ない。条件の2つと「何を開くか」の1行は投入の口を要らない(要るのは
10450
+ * 案件カレンダーの行だけで、そこは meetingHowTo が1行に差し替える)。
10451
+ *
10452
+ * ここから下(鍵・接続先のフォーム・追加と削除)は押すと必ず 404 で終わるので、
10453
+ * 従来どおり出さない。**空の箱は返さない**(used が立っていなければ null)。
10454
+ */
10455
+ if (!SITE.intake) return used ? box : null;
10456
+
9867
10457
  // 課題管理: 接続先(ドメイン・プロジェクトキー)をURL1本で設定する。
9868
10458
  // **URLを貼らせる。** ドメインとキーを別々に入れさせると「スペースID」
9869
10459
  // 「プロジェクトキー」「プロジェクト名」の区別が付かず取り違える。
@@ -11591,6 +12181,8 @@ ${deriveCapTile.toString()}
11591
12181
  LIVE_STATE = sanitizeStateNow(j && j.stateNow);
11592
12182
  // **欄の有無をそのまま覚える。** sanitize を通した後では「読めて0件」が消える
11593
12183
  LIVE_STATE_SEEN = !!(j && Array.isArray(j.stateNow));
12184
+ // **カレンダーの行だけは sanitize の前に数える**(#428・上の宣言の項)
12185
+ LIVE_STATE_RAW_CALENDAR = calendarRowCount(j && j.stateNow);
11594
12186
  // 🔴 **鍵・能力・組織も fleet より先に読む(工程1.5)。** まとめ表を
11595
12187
  // まだ持たない案件は下で throw するので、ここより後に置くと
11596
12188
  // **立ち上げ直後の案件=この修正が一番効く案件**だけ素通りする
@@ -14628,6 +15220,12 @@ ${deriveCapTile.toString()}
14628
15220
  var valid = false;
14629
15221
  TABS.forEach(function (t) { if (t.key === tab) valid = true; });
14630
15222
  if (valid) listState.tab = tab;
15223
+ // ⚠ **?tab= がまるごと無いときは既定のタブへ戻す(#424)。** 文中の「操作タブ」の
15224
+ // リンクは履歴を積むので、戻ると ?tab= の無いアドレスへ帰ってくる。ここで今のタブを
15225
+ // 残すと、**アドレスは Home なのに画面は操作タブのまま**になる。
15226
+ // 値が入っていて知らない語だったときは今までどおり動かさない(壊れた ?tab= で
15227
+ // 見ていた画面を飛ばさない・go() の行き先も ?tab= か ?id= のどちらかしか作らない)
15228
+ else if (tab === null) listState.tab = DEFAULT_TAB;
14631
15229
  renderList();
14632
15230
  }
14633
15231
  }