From 47cf9cd16612a655dd239abc4919bc64500ed77d Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 07:50:48 +0900 Subject: [PATCH 1/3] feat(ui): let the chat text size be changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会話面(`#room`)の文字サイズを読む側が変えられるようにする。 効く範囲は会話面だけである。範囲は宣言ではなく置き場所で成立させた——寸法は `#room` 自身に載るカスタムプロパティ `--room-font-size` であり、参加者パネル・ 診断面・端末・入力欄はこの要素の子孫ではないため継承経路が無い。`:root` に載せ れば四つとも巻き込む。端末は特に巻き込めない。xterm.js は自前の寸法から桁数と 行数を計算するため、上から降ってきた寸法は CLI が使える幅を変える。 既定値は現在の見た目のままとした。`1rem`、つまりこれまで出ていたブラウザ既定で ある。`.message .body` には `font-size` が無く、`body` にも `html` にも `:root` にも無いため、会話面だけが寸法を与えられずに来ていた。「大きい」はここから来て いるが、設定していない画面が勝手に変わらないことを優先する。面どうしの揃え直し は別の判断である。 寸法は行全体に効く。発言者名と時刻と行の最大幅を `rem` から `em` へ移した。本文 だけが大きくなり、それに付く名前と時刻が据え置かれる形は、一つの行が二つの面か ら来ているように読める。最大幅を `rem` で固定すると、文字が大きくなるほど一行の 文字数が減る——最大幅が持つべきものは文字数のほうである。既定ではどちらも従来と 同じ値に解決する。 段は `0.7`〜`1.6rem` の離散の梯子で、その両端が上下限である。既定より下を細かく 取ったのは、観察が「大きい」である以上、使われるのが下方向であるためである。梯子 に無い値が保存されていた場合は既定へ戻す——梯子の外からは段を進めず、理由を出さ ずに効かなくなるためである。 保持は `localStorage`(`pullcept.room-font-size`)。画面の設定であって参加者の 属性ではない(#40 の形)。参加者クラスでの書き分けはしない(#39)。 操作は `Ctrl` + `=` / `-` / `0` と、`診断` の隣のピッカーの両方に置いた。キー だけにしないのは、既定が現状維持である以上、見つけられない操作の裏に直し方を置 けば知っている者だけが直せるためである。キー操作は既定動作を止める。webview 自 身のズームが同じキーに割り当たっており、そちらは効かせないと決めた四つを巻き込 むため、止めなければ範囲の限定そのものが成立しない。 #60 --- docs/0-requirements.md | 22 ++++++ index.html | 15 +++++ src/main.ts | 148 +++++++++++++++++++++++++++++++++++++++++ src/styles.css | 31 ++++++++- 4 files changed, 214 insertions(+), 2 deletions(-) diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 097bd7c..007f320 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..aaf9a9a 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..e47c0b5 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,17 @@ 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 +187,7 @@ body { display: flex; align-items: baseline; gap: 0.5rem; - font-size: 0.78rem; + font-size: 0.78em; color: var(--muted); } From 2e84b680846ed4c9239e66775484f9716c78e8f2 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 07:52:31 +0900 Subject: [PATCH 2/3] style(ui): tidy the room font size source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ROOM_FONT_SIZE_KEYS` の `_` を他のキーと同じく引用符付きに揃え、`.message` の 二つのコメント塊の間に空行を入れて、色の説明と寸法の説明が別々の塊として読める ようにした。挙動は変わらない。 #60 --- src/main.ts | 2 +- src/styles.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index aaf9a9a..7bf2f1f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -198,7 +198,7 @@ const ROOM_FONT_SIZE_KEYS: Record = { "=": 1, "+": 1, "-": -1, - _: -1, + "_": -1, "0": 0, }; diff --git a/src/styles.css b/src/styles.css index e47c0b5..09c0c1f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -168,6 +168,7 @@ 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 From 290a3fec8931d3b44dd286145a2378e0fcf6dfaf Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 07:53:23 +0900 Subject: [PATCH 3/3] docs: fix the surface count in the chat text size section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 効かせない面は参加者パネル・診断面・端末・入力欄の四つであるのに、継承経路の 段落が「他の三つ」と書いていた。webview のズームの段落も三つしか挙げておらず、 同じ節の中で数が二通りあった。どちらも四つに揃えた。 #60 --- docs/0-requirements.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 007f320..20899f1 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -360,7 +360,7 @@ armed の「本当に終了」は「終了」より広く、その幅は名前 会話面(`#room`)の文字サイズを、読む側が変えられる。 -**効く範囲は会話面だけである。** 参加者パネル・診断面・端末・入力欄の寸法は動かない。範囲は宣言ではなく置き場所で成立させる——寸法は `#room` 自身に載るカスタムプロパティであり、他の三つはこの要素の子孫ではないため継承経路が無い。`:root` に載せれば四つとも巻き込む。端末は特に巻き込めない。xterm.js は自前の寸法を持ち、そこから桁数と行数を計算するため、上から降ってきた寸法は表示だけでなく CLI が使える幅を変える。 +**効く範囲は会話面だけである。** 参加者パネル・診断面・端末・入力欄の寸法は動かない。範囲は宣言ではなく置き場所で成立させる——寸法は `#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)はここから来ている。それでも既定は動かさない——設定していない画面が勝手に変わらないことを優先する。面どうしを揃え直すことは別の判断であり、この節が入れるのは変えられる仕組みのほうである。 @@ -374,7 +374,7 @@ armed の「本当に終了」は「終了」より広く、その幅は名前 **操作はキーボードとヘッダの両方に置く。** `Ctrl` + `=` / `-` / `0`(拡大 / 縮小 / 既定へ戻す)と、`診断` の隣の小さなピッカーである。キーボードだけにしないのは既定値の側の事情である。既定を現状維持と決めた以上、設定していない画面に出るのは「大きい」と言われたその状態であり、見つけられない操作の裏に直し方を置けば、知っている者だけが直せる。ピッカーの表示は既定に対する百分率である。選んでいるのは「今より大きいか小さいか」であって、内部で何 `rem` かではない。 -キー操作は既定動作を止める。webview 自身のズームが同じキーに割り当たっており、そちらは端末・パネル・入力欄を巻き込む。この節が効かせないと決めた四つを動かすため、止めなければ範囲の限定そのものが成立しない。 +キー操作は既定動作を止める。webview 自身のズームが同じキーに割り当たっており、そちらは参加者パネル・診断面・端末・入力欄をまとめて巻き込む。この節が効かせないと決めた四つがそのまま動くため、止めなければ範囲の限定そのものが成立しない。 ### 診断面