Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 クリックの削除、走る行の表示)
- アカウントごとの端末(リストの行で選ぶ、行からの終了、切り替えで残るスクロールバック)
Expand All @@ -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 掲載(配布の第二段階)

Expand Down Expand Up @@ -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 の出力を表示する診断面を持つ。既定は折りたたみで、セッションの起動失敗や異常終了を検出したときは自動で開く。
Expand Down
15 changes: 15 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<!-- The conversation's text size. The keyboard is the main way in
(Ctrl + = / - / 0); this is here because of what the default is.
The default is deliberately the size that was called too large, so
what an untouched screen shows is the thing being complained
about, and a control reachable only by keys would leave that
fixable only by whoever already knew the keys.

It reaches the conversation alone. The panel, the diagnostics
pane, the terminal and the composer keep their own sizes — see the
`#room` block in src/styles.css for why the scope holds. -->
<select
id="room-font-size"
aria-label="会話の文字サイズ"
title="会話の文字サイズ(Ctrl + = / - / 0)"
></select>
<button id="toggle-diagnostics" type="button" aria-expanded="false">診断</button>
</header>

Expand Down
148 changes: 148 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
"=": 1,
"+": 1,
"-": -1,
"_": -1,
"0": 0,
};

/**
* The hues a participant can declare.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -1639,6 +1766,27 @@ async function main(): Promise<void> {
// 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();
Expand Down
32 changes: 30 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -160,7 +188,7 @@ body {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.78rem;
font-size: 0.78em;
color: var(--muted);
}

Expand Down