diff --git a/docs/0-requirements.md b/docs/0-requirements.md
index 097bd7c..20899f1 100644
--- a/docs/0-requirements.md
+++ b/docs/0-requirements.md
@@ -253,6 +253,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre
- 宣言色(`hello` の `hue` / `room_join` の `hue`。宣言 > 自分の accent > 名前からの導出)
- 部屋が運ぶアカウント id(`hello` / `room_join` の `account_id`、`Participant.account`。任意であり、判定には使わない)
- チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者から選ぶ宛先)
+- 会話面の文字サイズ(`#room` のみ、離散の梯子と上下限、`localStorage` 保持、`Ctrl` + `=` / `-` / `0` とヘッダのピッカー、既定は現状維持)
- 参加者パネル(一つのリスト、種別ごとのグループと件数、参加者ごとの色、オフラインの薄い表示、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ)
- アカウントのモーダル(作成・編集・削除、決定と取消、2 クリックの削除、走る行の表示)
- アカウントごとの端末(リストの行で選ぶ、行からの終了、切り替えで残るスクロールバック)
@@ -273,6 +274,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre
- アカウント面の実機確認。作成・改名・削除、オフライン表示、二重起動の拒否、改名後の `.mcp.json` 登録の追随はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。
- アカウントごとの端末と終了の実機確認。2 セッションでの出力の分離、入力の宛先、切り替え後のスクロールバック、終了後の席の解放と再起動はいずれも実装済みであり、CI の型検査は通っているが、実機での操作は未確認である。
- 一つのリストとアカウントのモーダルの実機確認。種別ごとのグループと件数、オフラインの表示、行からの終了、モーダルの決定と取消、本人のアカウントへの移行はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。`hello` の `account_id` が実際の部屋で名簿へ届くところも未計測である。
+- 会話面の文字サイズの実機確認。キー操作とピッカー、再起動後の保持、上下限、効く範囲が会話面に留まること(特に端末の桁数と行数が動かないこと)はいずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。webview のズームが `Ctrl` + `=` を先に取らないことも未計測である。
- 会話ログの永続化と観測 UI
- plugin としての allowlist 掲載(配布の第二段階)
@@ -354,6 +356,26 @@ armed の「本当に終了」は「終了」より広く、その幅は名前
パネル下部のセッションの値(接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ・生死)は、選択されている端末のものである。最後に起動したセッションのものではない。部屋ソケットだけは部屋自身の値であり、選択に依らない。
+### 会話面の文字サイズ
+
+会話面(`#room`)の文字サイズを、読む側が変えられる。
+
+**効く範囲は会話面だけである。** 参加者パネル・診断面・端末・入力欄の寸法は動かない。範囲は宣言ではなく置き場所で成立させる——寸法は `#room` 自身に載るカスタムプロパティであり、残る四つはこの要素の子孫ではないため継承経路が無い。`:root` に載せれば四つとも巻き込む。端末は特に巻き込めない。xterm.js は自前の寸法を持ち、そこから桁数と行数を計算するため、上から降ってきた寸法は表示だけでなく CLI が使える幅を変える。
+
+**既定値は現在の見た目のままである。** `1rem`、つまりブラウザ既定の `16px` である。`.message .body` には `font-size` が無く、`body` にも `html` にも `:root` にも無いため、会話面はこれまでずっと既定で出ていた。まわり(タイトルバー `0.85rem` / 参加者パネル `0.72rem` 〜 `0.78rem` / 発言者名と時刻 `0.78rem` / 端末 `13px`)が寸法を与えられている中で、会話面だけが縮められていない。「チャットの文字が大きい」(実機観察、2026-08-23)はここから来ている。それでも既定は動かさない——設定していない画面が勝手に変わらないことを優先する。面どうしを揃え直すことは別の判断であり、この節が入れるのは変えられる仕組みのほうである。
+
+**寸法は行全体に効く。** 発言者名と時刻(`.message .meta`)は本文に対する比(`em`)で持ち、本文と一緒に動く。本文だけが大きくなり、それに付く名前と時刻が据え置かれる形は、一つの行が二つの面から来ているように読める。行の最大幅も同じ理由で `em` である——`rem` で固定すると、文字が大きくなるほど一行に入る文字数が減る。最大幅が持つべきものは文字数のほうである。
+
+**段は離散である。** `0.7` から `1.6rem` までの梯子を持ち、その両端が上限と下限である。連続した値の範囲は、読める大きさかどうかの判断を利用者へ渡す。既定より下は段を細かく、上は粗く取る。観察が「大きい」である以上、実際に使われるのは下方向である。
+
+**保持は `localStorage`(`pullcept.room-font-size`)である。** 再起動しても保たれる。画面の設定であって参加者の属性ではない——同じ会話を二人が読むとき、二人が同じ寸法を望む理由が無い。#40 が画面の設定に定めた形と同じである。参加者クラスで書き分けることもしない。誰の発言でも同じ寸法で表示する(#39)。
+
+梯子に無い値が保存されていた場合は既定へ戻す。落ちる先が黙っているためである——梯子の外の値からは段を進めず、キーもピッカーも理由を出さずに効かなくなる。
+
+**操作はキーボードとヘッダの両方に置く。** `Ctrl` + `=` / `-` / `0`(拡大 / 縮小 / 既定へ戻す)と、`診断` の隣の小さなピッカーである。キーボードだけにしないのは既定値の側の事情である。既定を現状維持と決めた以上、設定していない画面に出るのは「大きい」と言われたその状態であり、見つけられない操作の裏に直し方を置けば、知っている者だけが直せる。ピッカーの表示は既定に対する百分率である。選んでいるのは「今より大きいか小さいか」であって、内部で何 `rem` かではない。
+
+キー操作は既定動作を止める。webview 自身のズームが同じキーに割り当たっており、そちらは参加者パネル・診断面・端末・入力欄をまとめて巻き込む。この節が効かせないと決めた四つがそのまま動くため、止めなければ範囲の限定そのものが成立しない。
+
### 診断面
会話面ともパネルとも別枠として、CLI の出力を表示する診断面を持つ。既定は折りたたみで、セッションの起動失敗や異常終了を検出したときは自動で開く。
diff --git a/index.html b/index.html
index 4a2e24d..1b4b837 100644
--- a/index.html
+++ b/index.html
@@ -21,6 +21,21 @@
account's do: in the dialog their row in the participant list
opens. Two surfaces editing one pair is the split this issue is
closing, in miniature. -->
+
+
diff --git a/src/main.ts b/src/main.ts
index f496d75..7bf2f1f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -145,6 +145,63 @@ const HUE_KEY = "pullcept.display-hue";
*/
const LOCAL_KEY = "pullcept.local-account";
+/**
+ * How large the conversation is drawn, in `rem`.
+ *
+ * In `localStorage` beside the key above, and for the same reason: this is a
+ * property of the screen being read from, not of anybody in the room. Two
+ * people reading one conversation do not have to want the same size, and a
+ * size carried on a participant would make the answer travel with whoever
+ * declared it. It is the shape #40 settled for a screen's own settings.
+ *
+ * Not a participant attribute in the other sense either: nothing here is
+ * written per speaker. Every line in the room is drawn at one size, whoever
+ * said it (#39).
+ */
+const ROOM_FONT_SIZE_KEY = "pullcept.room-font-size";
+
+/**
+ * The sizes the conversation can be set to, in `rem`.
+ *
+ * A list rather than a continuous range, like the hues below: what this has to
+ * buy is a readable size that fits, and a ladder buys it without asking anyone
+ * to judge fractions of a millimetre. The ends of the list are the bounds —
+ * there is no size off the ladder to clamp, so nothing separate enforces them.
+ *
+ * The rungs are dense below the default and sparse above it. The observation
+ * this comes from is that the room reads large (#60), so the direction that
+ * gets used is downward and the steps there are the ones worth being fine.
+ */
+const ROOM_FONT_SIZES = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.25, 1.4, 1.6];
+
+/**
+ * Where a screen that has never chosen sits.
+ *
+ * `1rem`, which is what the room already rendered at: `.message .body` is
+ * given no size and inherits none, so the surface has been showing the user
+ * agent's default. Keeping it is a completion condition of #60 — this change
+ * adds the means to move, and moves nobody.
+ */
+const DEFAULT_ROOM_FONT_SIZE = 1;
+
+/**
+ * The keys that move along the ladder, and by how far.
+ *
+ * `Ctrl` with `=` / `-` / `0`, the combination browsers and editors have
+ * trained. Both faces of the shifted keys are listed because a keyboard that
+ * needs `Shift` for `+` reports `+`, and one that does not reports `=`; the
+ * person pressing them is doing the same thing either way. `0` is the reset
+ * and carries a step of zero, so the lookup below tests for `undefined` rather
+ * than for falsity.
+ */
+const ROOM_FONT_SIZE_KEYS: Record = {
+ "=": 1,
+ "+": 1,
+ "-": -1,
+ "_": -1,
+ "0": 0,
+};
+
/**
* The hues a participant can declare.
*
@@ -188,6 +245,7 @@ const toEl = document.getElementById("to-select") as HTMLSelectElement;
const statusEl = document.getElementById("status") as HTMLElement;
const diagnosticsEl = document.getElementById("diagnostics") as HTMLElement;
const toggleEl = document.getElementById("toggle-diagnostics") as HTMLButtonElement;
+const fontSizeEl = document.getElementById("room-font-size") as HTMLSelectElement;
const socketStateEl = document.getElementById("socket-state") as HTMLElement;
const sessionStateEl = document.getElementById("session-state") as HTMLElement;
const transportEl = document.getElementById("session-transport") as HTMLElement;
@@ -244,6 +302,8 @@ let homeDir = "";
* see and does not pretend to (#47).
*/
let lastSeenId: string | null = null;
+/** The size the conversation is currently drawn at, in `rem`. */
+let roomFontSize = DEFAULT_ROOM_FONT_SIZE;
/**
* The emulator options every session's terminal is opened with.
@@ -406,6 +466,73 @@ function saveAccounts(): void {
});
}
+/**
+ * Fill the text size picker.
+ *
+ * Labelled as a proportion of the default rather than in `rem`, because the
+ * choice being made is "larger or smaller than what I have", and the unit the
+ * size happens to be held in answers a question nobody is asking.
+ */
+function fillRoomFontSizes(): void {
+ for (const size of ROOM_FONT_SIZES) {
+ const option = document.createElement("option");
+ option.value = String(size);
+ option.textContent = `${Math.round((size / DEFAULT_ROOM_FONT_SIZE) * 100)}%`;
+ fontSizeEl.appendChild(option);
+ }
+}
+
+/**
+ * The stored size, or the default.
+ *
+ * Only a size that is on the ladder is honoured. What is in `localStorage` was
+ * written by some version of this app and can be anything — a rung that a
+ * later version dropped, a value left by hand, or nothing at all — and the
+ * failure it would cause is silent: a size off the ladder cannot be stepped
+ * from, so the keys and the picker would both stop working with nothing on
+ * screen saying why.
+ */
+function storedRoomFontSize(): number {
+ const stored = Number(localStorage.getItem(ROOM_FONT_SIZE_KEY));
+ return ROOM_FONT_SIZES.includes(stored) ? stored : DEFAULT_ROOM_FONT_SIZE;
+}
+
+/**
+ * Draw the conversation at `size`, and remember it if it was chosen.
+ *
+ * The property goes on `#room` itself, which is what keeps this off everything
+ * else: the panel, the diagnostics pane, the terminal and the composer are
+ * elsewhere in the tree and inherit nothing from here. Setting it on the root
+ * would reach all four, and the terminal computes its columns and rows from
+ * its own size.
+ *
+ * `save` is false for the restore at startup. Writing the value back there
+ * would put a size in storage for a screen that never chose one, which is the
+ * one state this is supposed to leave alone.
+ */
+function applyRoomFontSize(size: number, save: boolean): void {
+ roomFontSize = size;
+ roomEl.style.setProperty("--room-font-size", `${size}rem`);
+ fontSizeEl.value = String(size);
+ if (save) localStorage.setItem(ROOM_FONT_SIZE_KEY, String(size));
+}
+
+/**
+ * Move one rung, or back to the default when `step` is zero.
+ *
+ * The ends hold: stepping past either one lands on it again, so there is no
+ * size to reach that cannot be read or does not fit.
+ */
+function stepRoomFontSize(step: number): void {
+ if (step === 0) {
+ applyRoomFontSize(DEFAULT_ROOM_FONT_SIZE, true);
+ return;
+ }
+ const at = ROOM_FONT_SIZES.indexOf(roomFontSize);
+ const next = Math.min(Math.max(at + step, 0), ROOM_FONT_SIZES.length - 1);
+ applyRoomFontSize(ROOM_FONT_SIZES[next], true);
+}
+
/**
* Fill a hue picker, with "not declared" first.
*
@@ -1639,6 +1766,27 @@ async function main(): Promise {
// picker — the form fills it from whichever account it was opened on.
fillHues(dialogHueEl, null);
+ // Restored before anything is drawn, so the first line to arrive is already
+ // at the size this screen reads at rather than jumping once it lands.
+ fillRoomFontSizes();
+ applyRoomFontSize(storedRoomFontSize(), false);
+ fontSizeEl.addEventListener("change", () => {
+ applyRoomFontSize(Number(fontSizeEl.value), true);
+ });
+ // On the window rather than on the room: the keys are meant to work while
+ // something is being typed, and the room is not what holds focus then.
+ window.addEventListener("keydown", (event) => {
+ if (!event.ctrlKey || event.altKey || event.isComposing) return;
+ const step = ROOM_FONT_SIZE_KEYS[event.key];
+ if (step === undefined) return;
+ // Load-bearing, not tidiness: the webview answers these same keys with its
+ // own zoom, which takes the terminal, the panel and the composer with it.
+ // Scaling those four is the one thing this control may not do, so the
+ // default has to be stopped for the scoped version to be what happens.
+ event.preventDefault();
+ stepRoomFontSize(step);
+ });
+
toggleEl.addEventListener("click", () => {
if (diagnosticsEl.hidden) {
revealDiagnostics();
diff --git a/src/styles.css b/src/styles.css
index 8db9392..09c0c1f 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -129,7 +129,25 @@ body {
/* ── room ────────────────────────────────────────────────────────────────── */
+/* The conversation's own text size, declared here and nowhere else.
+
+ On `#room` rather than on `:root` because that placement is what the scope
+ is made of: the participant panel, the diagnostics pane, the terminal and
+ the composer are not descendants of this element, so no size set here can
+ reach them. The terminal is the one that would hurt — xterm.js carries its
+ own size and computes columns and rows from it, and a size arriving from
+ above would shift both (#60).
+
+ `1rem` is what this surface has always rendered at. No `font-size` is
+ written on `body`, on `html` or on `:root`, so the room has been showing the
+ user agent's default while everything around it was given a size. That is
+ where "the text is large" came from, and keeping the default here is what
+ makes a screen that has set nothing look exactly as it did. Levelling the
+ surfaces against each other is a separate question and not this one's.
+
+ src/main.ts overrides this on the element when a size has been stored. */
#room {
+ --room-font-size: 1rem;
flex: 1;
overflow-y: auto;
padding: 1rem;
@@ -150,8 +168,18 @@ body {
name. The derived hues leave a gap around the accent so that colour still
reads as one's own; a declared one is free of that gap, because it was
chosen. */
+
+/* The size lands on the whole line, not on its text alone: a name and a time
+ that stayed put while the words under them grew would read as a different
+ surface from the one they label. `em` below carries them along, and at the
+ default they resolve to exactly the sizes they were written at.
+ `max-width` is in `em` for the same reason — a measure fixed in `rem` would
+ hold the column still and shorten every line as the text grew, so the number
+ of characters to a line, which is what a measure is for, would change with
+ the size. */
.message {
- max-width: 46rem;
+ font-size: var(--room-font-size);
+ max-width: 46em;
border-left: 3px solid var(--speaker, var(--line));
padding-left: 0.7rem;
}
@@ -160,7 +188,7 @@ body {
display: flex;
align-items: baseline;
gap: 0.5rem;
- font-size: 0.78rem;
+ font-size: 0.78em;
color: var(--muted);
}