# ホストが「自分は正常」と言い続ける穴を塞ぐ / オフラインを目立たせる

Issue: #1045（+ ツールバーの可視化）

## 一度、原因を読み違えた

最初は「復帰の担い手がブラウザのタブだから、タブが無いと直らない」と診断した。**外れ**だった —
報告は「開きっぱなしでも再現する」。そこから読み直して分かったのが下の経路で、**タブの有無とは
無関係**だった。

## 本当の穴: 生存判定の材料が片方しか無い

- **スマホ**が見ているのは presence ドキュメント（`users/{uid}/hosts/{hostId}`）の鮮度。書いているのは
  core の 60 秒ごとの heartbeat で、その書き込み失敗は `.catch(noop)` で捨てられる
- **Mac**が見ているのはリスナのエラーだけ

この 2 つは別物なので、**presence が届いていないのにリスナは黙っている**状態が成立する。すると:

1. `onClosed` が来ない → `resilientRunner` は何も検知しない → 5 分の諦めにも到達しない
2. `markRecovered` が 60 秒後に無条件で `online` を宣言する
3. core の `connected` は true のまま
4. クライアントの `tryAutoReconnect` が `if (status.value.connected) return;` で即座に諦める

**復帰の仕組みは全部あるのに、誰も「壊れた」と言わない。** だから何も動かない。

## 直し方: 肯定的な生存確認を足す

`presenceProbe.ts` が自分の presence ドキュメントを**サーバから**読み返す（キャッシュだと、
届かなかった自分の書き込みを「新しい」と答えてしまう）。

- 読めない → 接続が死んでいる
- 読めたが古い → heartbeat が届いていない
- 新しい → 本当に生きている

否定的な答えは `onUnderlyingClosed` に流す。**新しい復帰経路は作らない** —— 既存の再購読 → バックオフ →
5 分で諦め → `connected: false` → クライアントが blob 再生、がそのまま動く。足したのはセンサーだけ。

### 判断しない場合を明示的に持つ

`null`（判断できない）を返す場合を 3 つ用意した。ドキュメントが無い / `serverTimestamp` が未確定 /
core が意図的に書いた `online:false`。**誤検知で再接続ループを回す方が、検知漏れより有害**なので、
迷ったら何もしない側に倒している。core が将来パスを変えても、静かに無効化されるだけで済む。

## 可視化

調べたら、色付きの状態表示は**ポップオーバーの中**にあった。常時見えるツールバーのアイコンは
オンライン時だけ緑で、**オフラインは「一度も設定していない人」と同じ灰色**。これでは気づけない。

- ツールバーのアイコンを赤に。パネルを開いている間も消えないよう、既存の緑ルールの隣に置いた
  （単一クラスのユーティリティは `.toolbar-popover-btn.active` に特異度で負ける）
- **使っていない人には出さない。** 大半のユーザはこの機能を設定していないので、常時赤いツールバーは
  害の方が大きい。localStorage の blob の有無で判定し、明示的な Disconnect も静かにする
- 初回描画では鳴らさない（全 ref が切断状態の既定値から始まるため、赤いチラつきになる）

## やらないこと

- **core の `.catch(noop)` 修正**。本筋だが別リポジトリ。こちらの probe は core が直っても無害
- **永久リトライ**。5 分で諦める設計は維持（期限切れの資格情報はブラウザの blob でしか直せない）
- **サーバ側への session 保存**。検討したが、今回の原因は「再認証できない」ことではなく
  「壊れたと誰も言わない」ことなので、置いても直らない。しかも refresh token は実質
  ターミナル入力権なので、持続化は理由が要る
