From 260bb74fa7d6e73271fce0ed5575256287fe00a1 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 09:20:20 +0900 Subject: [PATCH] feat(ui): show the row lifecycle controls as icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参加者の行の三つの操作を語から記号へ変え、終了の確認をボタンの armed 状態から webview 内の `` へ移した。 - 開始 ▶️ / 終了 ❌ / 編集 ⚙️。語は `aria-label` と `title` に入れた。記号は 読み上げに耐えないため、アクセシブルな名前は別に要る。 - `#end-dialog` を新設した。`#account-dialog` は編集フォームであり、一枚の `` に二役を持たせると開くたびの分岐を抱えるため流用しない。焦点は 「取消」にあり、Escape も取消である。 - `window.confirm` は使わない。ホストが答えなかった場合の失敗の向きがどちらも 誤りであるという #57 の判断は生きている。webview 内の `` であれば その失敗の形が起こらない。 - armed の機構を撤去した。`armedEnd` / `armedTimer` / `armedAt` / `END_ARM_MS` / `END_SETTLE_MS` / `armEnd` / `disarmEnd`、`.end.armed`。 `END_SETTLE_MS` は armed でボタンが広がることへの対処であり、記号固定で幅が 動かなくなった以上その前提が無い。 - ライフサイクルと編集の列を固定幅にした。絵文字の送り幅はフォントが決めるため、 字面から幅を取れば同じ画面が機械ごとに違う形になる。実測で 16.5rem の パネルでも本人の行の名前は出る。 「状態はボタン自身が表示する」形は手放した。Master が三択を見た上でダイアログを 選んだ決定であり、決着済みとして扱う。アカウントの削除は二度押しのまま残した (#72)。 #71 --- docs/0-requirements.md | 22 ++++-- index.html | 35 ++++++++-- src/main.ts | 147 ++++++++++++++++++++++------------------- src/styles.css | 140 +++++++++++++++++++++++++++++++-------- 4 files changed, 235 insertions(+), 109 deletions(-) diff --git a/docs/0-requirements.md b/docs/0-requirements.md index bb0837e..37c3067 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -257,6 +257,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 参加者パネル(一つのリスト、種別ごとのグループと件数、参加者ごとの色、オフラインの薄い表示、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ) - アカウントのモーダル(作成・編集・削除、決定と取消、2 クリックの削除、走る行の表示) - アカウントごとの端末(リストの行で選ぶ、行からの終了、切り替えで残るスクロールバック) +- 行のライフサイクル操作の記号表示(開始 ▶️ / 終了 ❌ / 編集 ⚙️、語は `aria-label` と `title`、幅を固定した列)と、終了の確認ダイアログ(webview 内の ``、取消に焦点、Escape は取消) - 診断面(アカウントごとの CLI の端末表示と入力) - Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD @@ -275,6 +276,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - アカウントごとの端末と終了の実機確認。2 セッションでの出力の分離、入力の宛先、切り替え後のスクロールバック、終了後の席の解放と再起動はいずれも実装済みであり、CI の型検査は通っているが、実機での操作は未確認である。 - 一つのリストとアカウントのモーダルの実機確認。種別ごとのグループと件数、オフラインの表示、行からの終了、モーダルの決定と取消、本人のアカウントへの移行はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。`hello` の `account_id` が実際の部屋で名簿へ届くところも未計測である。 - 会話面の文字サイズのうち、ヘッダのピッカー、再起動後の保持、上下限の実機確認。いずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。キー操作(`Ctrl` + `=` / `-` / `0`)と、効く範囲が会話面に留まること(参加者パネル・診断面・端末・入力欄が動かないこと)は 2026-08-24 の実機観察で確認済みである。後者が webview 自身のズームも閉じている——キーを受ければ寸法は無条件に動くため、動いたこと自体は既定動作を止められた証拠にならないが、会話面以外が一緒に動かなかった以上、webview のズームは走っていない。 +- 行の記号表示と終了の確認ダイアログの実機確認。三つの記号が Windows のフォントで揃って出ること、幅を固定した列で本人の行の名前が出ること、ダイアログの取消・終了・Escape はいずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -308,17 +310,19 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre **行の操作は名前の後ろの二列である。一列目がライフサイクル、二列目が編集。** 列は行の状態で動かない。以前の並びは「編集」「終了」であり、終了が稼働中の行にしか出ないため、未起動の行では編集が左へずれていた。 -- 一列目 = 「開始」または「終了」。種別 `ai` のアカウントの行にだけ出る。 -- 二列目 = 「編集」。アカウントを持つ行には常に出る。 +- 一列目 = 開始 ▶️ または終了 ❌。種別 `ai` のアカウントの行にだけ出る。 +- 二列目 = 編集 ⚙️。アカウントを持つ行には常に出る。 - **列は中身の有無に依らず幅を持つ。** ライフサイクルを持たない行——種別 `user`、および端末の操作を持たない行——も列を閉じない。閉じれば編集がその行だけ左へ寄る。 -armed の「本当に終了」は「終了」より広く、その幅は名前の側へ伸びる。編集の位置は動かない。ライフサイクルの列を armed の幅に固定する形は採らない——パネルは 16.5rem しかなく、実測では本人の行の名前(「マスター」+「(あなた)」)が出なくなる。四秒間の一行のために、リスト全行の名前の幅を払うことになる。 +**行の操作は記号で表示する。** 三つとも語ではなく記号であり、語は `aria-label` と `title` に入る。記号は読み上げに耐えず、名前ではない——何をする操作かを言うのは label の側である。 + +**記号の列は幅を固定する。** 絵文字の送り幅はフォントが決めるものであり、そのフォントはホストが選ぶ。字面から幅を取れば同じ画面が機械ごとに違う形になる。ボタンの寸法を固定し、記号はその中で中央に置く。名前の列は残りを全て取る——パネルは 16.5rem しかなく、実測で本人の行(「マスター」+「(あなた)」)が出なくなるのが、ライフサイクルの列を広げない理由であった(#71 以前は終了が武装時に広がるため、この列は下限だけを持っていた)。 端末を持たないアカウントに端末の操作は出ない。未起動のアカウントに出せる端末の操作は無い(#57 の条件を維持)。開始はこれに含まれない——端末の操作ではなく、端末を作る操作である。 **種別 `user` の行にライフサイクルは無い。** 人間は起動されるものではなく、`start_session` が既に拒否している(上記「アカウント」)。列は空のまま残り、その行の編集は他の行と同じ位置に並ぶ。 -**開始は 2 段階にしない。** 終了が二度押しなのは取り消せないためである(下記「診断面」)。開始は取り消せる——間違えて始めたセッションは、その場所を引き継ぐ終了が終わらせる。取り消せる操作に arm を付けると、undo が効く失敗を防ぐために全ての意図的な操作が二度押しになる。 +**開始は確認を挟まない。** 終了が確認を取るのは取り消せないためである(下記「診断面」)。開始は取り消せる——間違えて始めたセッションは、その場所を引き継ぐ終了が終わらせる。取り消せる操作に確認を付けると、undo が効く失敗を防ぐために全ての意図的な操作が二段階になる。 **起動中と起動失敗は行が読める。** 押した直後から起動が返るまで、その行の開始は押せなくなる。状態の語は名前の隣の note に入る——「未起動」「終了」と同じ場所であり、note は状態を、ボタンは行為を言う。 @@ -401,9 +405,13 @@ armed の「本当に終了」は「終了」より広く、その幅は名前 **終了はアカウントごとに行う。** 参加者のリストのその行から、そのアカウントのセッションを終了する。席の解放は別途要らない——`RoomSeats` は稼働の有無を PTY へ問うため、終了すれば席は空き、そのまま再起動できる(上記「アカウント」)。 -終了は取り消せない操作であり、表示を切り替えるだけの操作の隣に並ぶ。無地のクリック一つでは実行しない。一度目のクリックは武装するだけで、ボタンの文言と見た目が変わり、数秒で武装は解ける。二度目のクリックが実行する。 +終了は取り消せない操作であり、表示を切り替えるだけの操作の隣に並ぶ。無地のクリック一つでは実行しない。クリックは確認のダイアログを開き、そこで答えるまで何も終わらない。ダイアログは「取消」と「終了」の二つの答えを持ち、焦点は「取消」にある。Escape は取消と同じ答えである——開いたダイアログの上で当てずっぽうに押した鍵が、何も変えない側へ倒れる。 + +**`window.confirm` を使わない。** ホストが答えなかった場合の失敗の向きがどちらも誤りであるためである。答えを待たない実装ではボタンが黙って何もしない側へ倒れ、答えを既定で肯定と読む実装では一度のクリックで落ちる。`window.confirm` はこのアプリのどこにも残っていない。 + +**確認は webview の中の `` で出す。** これは上とは別の判断である。`window.confirm` を断るのはホストに答えを預けないためであり、確認をどの面へ出すかはそこから決まらない。#71 以前は確認をボタン自身に置いていた——一度目のクリックが武装し、ボタンの文言と見た目が変わり、二度目が実行する形である。ダイアログへ移したことで「どちらの状態にいるかをボタン自身が表示する」形は手放した(#71)。ホスト依存の失敗の向きは、この `` がアプリの中にあるため、そもそも起こらない。 -`window.confirm` を使わないのは、ホストが答えなかった場合の失敗の向きがどちらも誤りであるためである。答えを待たない実装ではボタンが黙って何もしない側へ倒れ、答えを既定で肯定と読む実装では一度のクリックで落ちる。画面側で武装状態を持てばどちらも起きず、どちらの状態にいるかをボタン自身が表示する。アカウントの削除も同じ形を取る(下記「アカウント」)。`window.confirm` はこのアプリのどこにも残っていない。 +アカウントの削除は二度押しのまま残っている(下記「アカウント」)。同じ形へ揃えるかは #71 では決めず、#72 で扱う。 端末はセッションへの入力路でもある。キー入力はそのまま PTY へ渡り、ウィンドウのリサイズは CLI 側へ伝わる。 @@ -471,7 +479,7 @@ armed の「本当に終了」は「終了」より広く、その幅は名前 これは形の移動ではない。以前は各欄が `change` で保存されており、`+` を押した瞬間にアカウントが実在し、以後はその場で直していく形だった。決定も取消も無かった。モーダルへ移すこと自体はこれを直さない——モーダルの中で `change` 保存を続ければ同じである。直しているのは保存の時点である。 -削除はモーダルの中にあり、終了と同じ 2 クリックを取る。一度目は武装するだけで、二度目が実行する。稼働中のアカウントと、この画面の本人のアカウントは削除を拒否する。 +削除はモーダルの中にあり、2 クリックを取る。一度目は武装するだけで、二度目が実行する。稼働中のアカウントと、この画面の本人のアカウントは削除を拒否する。終了も #71 まではこの形であったが、確認のダイアログへ移った(上記「診断面」)。削除を揃えるかは #72 で扱う。 #### ランチャ行は無い diff --git a/index.html b/index.html index 9db9718..c84e4a4 100644 --- a/index.html +++ b/index.html @@ -204,10 +204,11 @@

アカウント

- + @@ -215,6 +216,32 @@

アカウント

+ + + +
+

セッションの終了

+

+ + + + + +
+
diff --git a/src/main.ts b/src/main.ts index f1cdfc9..9087791 100644 --- a/src/main.ts +++ b/src/main.ts @@ -267,6 +267,10 @@ const dialogPreviewEl = document.getElementById("dialog-preview") as HTMLElement const dialogErrorEl = document.getElementById("dialog-error") as HTMLElement; const dialogDeleteEl = document.getElementById("dialog-delete") as HTMLButtonElement; const dialogCancelEl = document.getElementById("dialog-cancel") as HTMLButtonElement; +const endDialogEl = document.getElementById("end-dialog") as HTMLDialogElement; +const endMessageEl = document.getElementById("end-dialog-message") as HTMLElement; +const endCancelEl = document.getElementById("end-cancel") as HTMLButtonElement; +const endCommitEl = document.getElementById("end-commit") as HTMLButtonElement; let accounts: Account[] = []; /** @@ -323,18 +327,6 @@ const TERMINAL_OPTIONS = { scrollback: 5000, }; -/** How long a 終了 stays armed before it falls back to its resting state. */ -const END_ARM_MS = 4000; -/** - * How long an armed 終了 refuses to act. - * - * The armed button is wider than the resting one and grows under the pointer, - * so a double-click lands both clicks on it: without this, one slip of the - * finger arms and ends in a single gesture, which is the shape the two clicks - * exist to rule out. Short enough that a deliberate second click never waits. - */ -const END_SETTLE_MS = 400; - /** * One account's terminal: the session's output, its scrollback, and the way in. * @@ -377,10 +369,15 @@ let shownAccount: string | null = null; * goes back to reading 未起動, as though it never had been. */ const launchFailures = new Map(); -/** The account whose 終了 is armed. Its next click is the one that ends it. */ -let armedEnd: string | null = null; -let armedTimer = 0; -let armedAt = 0; +/** + * The account the open 終了 dialog is asking about, or null while it is closed. + * + * The id rather than the view: the dialog stays open across whatever else the + * screen does, and a view can be discarded while it is (`showView`). Resolving + * the id when the answer comes back finds a session that is still there, or + * finds nothing and ends nothing. + */ +let endingAccount: string | null = null; function status(text: string, kind: "info" | "error" = "info"): void { statusEl.textContent = text; @@ -848,37 +845,45 @@ function memberRow(row: Member): HTMLLIElement { /** * The control that starts one account's session. * - * One click, where 終了 beside it takes two. The asymmetry is the difference - * between the two acts: ending a session cannot be taken back, and starting one - * is undone by the button that replaces this one. An arm here would charge - * every deliberate start a second click to guard a mistake that undoes itself. + * It acts on the click, where 終了 beside it asks first. The asymmetry is the + * difference between the two acts: ending a session cannot be taken back, and + * starting one is undone by the button that replaces this one. A question here + * would charge every deliberate start an answer, to guard a mistake that undoes + * itself. * * It carries its own launch. Pressed, it goes dead until the launch comes back, * on the row that was pressed — the launcher's button held that state for * whichever account its picker was on, and could not say which one (#62). What * the state *is* stays in the note beside the name, where 未起動 and 終了 and * 起動失敗 are: the button says what can be done, the note says what is so. + * + * A mark rather than a word (#71). The word it was is on `aria-label` and on + * `title`, because a mark is not a name: the label is what a screen reader + * says and what the pointer resting here reads. */ function startButton(account: Account, launching: boolean): HTMLButtonElement { const start = document.createElement("button"); start.type = "button"; start.className = "start"; - start.textContent = "開始"; + start.textContent = "▶️"; start.disabled = launching; - start.title = launching + const label = launching ? `${account.name} を起動しています` : `${account.name} のセッションを開始する`; + start.title = label; + start.setAttribute("aria-label", label); start.addEventListener("click", () => void startSession(account)); return start; } -/** The control that opens one account's form. */ +/** The control that opens one account's form. A mark, named by its label. */ function editButton(account: Account): HTMLButtonElement { const edit = document.createElement("button"); edit.type = "button"; edit.className = "edit"; - edit.textContent = "編集"; + edit.textContent = "⚙️"; edit.title = `${account.name} の設定`; + edit.setAttribute("aria-label", `${account.name} の設定`); edit.addEventListener("click", () => openAccountDialog(account)); return edit; } @@ -1145,60 +1150,56 @@ function renderSessionFacts(): void { } /** - * The 終了 control, which takes two clicks. + * The 終了 control, which asks before it acts. * * Ending a session cannot be undone, and this control sits in a list whose * other click merely changes which pane is showing. One plain click away from a * harmless neighbour is how a slip ends a session that was mid-answer, so the - * first click only arms: the button changes what it says and how it looks, and - * the second click within a few seconds is the one that acts. - * - * Two clicks rather than `window.confirm`, which the account delete beside this - * uses. That dialog answers on the host's terms, and the two ways it can fail - * here are both wrong: a host that answers nothing either makes the button - * silently dead or — the bias the delete chose — makes a single click destroy. - * An arm state held on this side has neither failure, and the button says which - * state it is in rather than a modal saying it elsewhere. + * click opens the question and the dialog is where it is answered (#71). + * + * A dialog of this app's own, never `window.confirm`. That one answers on the + * host's terms and the two ways it can fail here are both wrong: a host that + * answers nothing either makes the button silently dead or — reading its own + * default as yes — ends the session on the single click. `#end-dialog` is in + * the webview and has neither failure (#57). + * + * The mark is fixed, so the button no longer changes width under the pointer. + * That is what the two-click form had to hold a settle window for, and what + * made the lifecycle column's width a thing the whole list paid for. */ function endButton(view: SessionView, name: string): HTMLButtonElement { - const armed = armedEnd === view.accountId; const end = document.createElement("button"); end.type = "button"; - end.className = armed ? "end armed" : "end"; - end.textContent = armed ? "本当に終了" : "終了"; - end.title = armed - ? `もう一度押すと ${name} のセッションを終了します。` - : `${name} のセッションを終了する`; - end.addEventListener("click", () => { - if (!armed) { - armEnd(view.accountId); - return; - } - // A click that arrives inside the settle window is the tail of the gesture - // that armed it, not an answer to it. Ignored, arm left standing. - if (Date.now() - armedAt < END_SETTLE_MS) return; - void endSession(view); - }); + end.className = "end"; + end.textContent = "❌"; + end.title = `${name} のセッションを終了する`; + end.setAttribute("aria-label", `${name} のセッションを終了する`); + end.addEventListener("click", () => openEndDialog(view.accountId, name)); return end; } -/** Put one account's 終了 into its armed state, and let the arm lapse. */ -function armEnd(accountId: string): void { - window.clearTimeout(armedTimer); - armedEnd = accountId; - armedAt = Date.now(); - // The arm expires on its own. A button left saying 「本当に終了」 for the rest - // of a session is one that an unrelated click, minutes later, fires. - armedTimer = window.setTimeout(() => { - armedEnd = null; - renderPanel(); - }, END_ARM_MS); - renderPanel(); +/** Ask whether one account's session is to end. Nothing ends until answered. */ +function openEndDialog(accountId: string, name: string): void { + endingAccount = accountId; + endMessageEl.textContent = `${name} のセッションを終了します。よろしいですか?`; + endDialogEl.showModal(); } -function disarmEnd(): void { - window.clearTimeout(armedTimer); - armedEnd = null; +/** Leave the question unanswered. Escape lands here too, by the close handler. */ +function closeEndDialog(): void { + endingAccount = null; + if (endDialogEl.open) endDialogEl.close(); +} + +/** The answer that acts. */ +function confirmEndDialog(): void { + const accountId = endingAccount; + closeEndDialog(); + if (accountId === null) return; + // Resolved now, not when the dialog opened: the session may have ended on its + // own while the question stood, and there is then nothing left to end. + const view = views.get(accountId); + if (view) void endSession(view); } /** @@ -1210,7 +1211,6 @@ function disarmEnd(): void { * chosen, because what it last printed is the only account of how it ended. */ async function endSession(view: SessionView): Promise { - disarmEnd(); const name = viewName(view); try { await invoke("kill_pty", { id: view.ptyId }); @@ -1323,7 +1323,6 @@ function discardView(view: SessionView | undefined): void { * they have read it (#57). */ function showView(accountId: string | null): void { - disarmEnd(); for (const view of [...views.values()]) { if (view.ended !== null && view.accountId !== accountId) discardView(view); } @@ -1507,7 +1506,7 @@ function unusedAccountName(): string { let editing: Account | null = null; /** The draft the form is filling in. Never the account itself. */ let draft: Account | null = null; -/** True once 削除 has been armed, in the same shape 終了 uses. */ +/** True once 削除 has been armed. The shape 終了 held until #71; see #72. */ let deleteArmed = false; /** Say why the form cannot be decided yet, or clear that. */ @@ -1681,7 +1680,8 @@ async function commitAccountDialog(): Promise { * * Two clicks rather than `window.confirm`, for the reason 終了 does not use one * either: a host that answers nothing makes the button either silently dead or - * — the bias `confirm` defaults to — destructive on one click (#57). + * — the bias `confirm` defaults to — destructive on one click (#57). 終了 asks + * in a `` of the app's own since #71; whether this follows is #72. * * Refused while it is running: the session in the room belongs to this account, * and deleting the account under it would leave a participant on the roster @@ -1847,6 +1847,15 @@ async function main(): Promise { }); }); + endCancelEl.addEventListener("click", () => closeEndDialog()); + endCommitEl.addEventListener("click", () => confirmEndDialog()); + // Escape closes the dialog itself, and it means 取消. Clearing the account + // here as well is what makes that true on every path out: a dialog closed by + // anything but 終了 leaves nothing standing that a later click could fire. + endDialogEl.addEventListener("close", () => { + endingAccount = null; + }); + try { const config = await invoke("load_config"); accounts = config.accounts; diff --git a/src/styles.css b/src/styles.css index 09c0c1f..346ef9d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -373,16 +373,15 @@ body { who is not launched — from closing the column and sliding its 編集 left of everyone else's. - The lifecycle track has a floor rather than a fixed width: 本当に終了 is wider - than the 終了 it replaces, and it is meant to be. It grows leftward into the - name, because its own track's right edge is where 編集 begins and that does - not move. Fixing the track to the armed width instead would spend that width - on every row of the list to hold four seconds of one of them, and the panel - is 16.5rem wide — measured, it leaves 「マスター(あなた)」 with no room for - the name. */ + Both tracks are fixed, because both hold a mark and a mark is one width + (#71). The lifecycle track had a floor instead while 終了 answered its own + question on itself and grew to say so; the question is a dialog now, so + nothing here changes width and the name keeps everything the two tracks do + not take. That matters at this size: the panel is 16.5rem, and it was a + measured 「マスター(あなた)」 that ruled out widening these tracks before. */ #participants .member { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(3.2rem, auto) 3rem; + grid-template-columns: minmax(0, 1fr) 2rem 2rem; align-items: center; gap: 0.3rem; } @@ -478,18 +477,28 @@ body { Each fills its track, so the button is the column: 開始 and 終了 are one width in one place, and which of them a row has is read off the button rather than - off where the row's controls have slid to (#62). */ + off where the row's controls have slid to (#62). + + Sized rather than fitted, because what is in them is a mark (#71). An emoji's + advance width is the font's, and the font is whichever one the host resolves + the mark in; a box that took its width from the glyph would be a different + box on a different machine. The box is fixed and the mark is centred in it, + so the three read as one column of the same three shapes anywhere. */ #participants .member .edit, #participants .member .start, #participants .member .end { + display: flex; + align-items: center; + justify-content: center; width: 100%; + height: 1.6rem; font: inherit; - font-size: 0.72rem; + font-size: 0.8rem; + line-height: 1; background: none; border: 1px solid var(--line); border-radius: 4px; - padding: 0.2rem 0.4rem; - white-space: nowrap; + padding: 0; cursor: pointer; } @@ -502,37 +511,39 @@ body { display: flex; } +/* Colour on these reaches the outline and the mark's own text fallback, not the + mark as the host normally paints it: an emoji is drawn by a colour font, + which answers to no `color`. It is kept because the fallback is the case that + needs it — a host resolving ▶ or ❌ as plain text has only this to say which + of the three it is. */ #participants .member .edit { color: var(--muted); } -/* One click, unlike the 終了 that replaces it: a session started by mistake is - ended by that button, and an arm here would charge every deliberate start a - second click. Dead while the launch is out; what the row is doing meanwhile - is the 起動中 beside the name (src/main.ts). */ +/* It acts on the click, unlike the 終了 that replaces it: a session started by + mistake is ended by that button, and a question here would charge every + deliberate start an answer. Dead while the launch is out; what the row is + doing meanwhile is the 起動中 beside the name (src/main.ts). */ #participants .member .start { color: var(--fg); } +/* Dimmed as a whole, because the mark is not dimmed by `color`. Being unable to + press it is the state, and the state has to be visible on the button that is + in it. */ #participants .member .start:disabled { color: var(--muted); + opacity: 0.45; cursor: default; } -/* Its resting state is a quiet outline in the danger colour: this is the one - control here that cannot be taken back, and it sits next to two that can. - Armed, it fills — the button says which of its two states it is in, and the - second click is the one that acts (src/main.ts). */ +/* A quiet outline in the danger colour: this is the one control here that + cannot be taken back, and it sits next to two that can. It has one state now + — the question it asks is asked in `#end-dialog`, not on the button (#71). */ #participants .member .end { color: var(--danger); } -#participants .member .end.armed { - color: var(--bg); - background: var(--danger); - border-color: var(--danger); -} - #participants .facts { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -726,9 +737,10 @@ body { border-color: var(--accent); } -/* Two clicks, in the same shape 終了 uses in the list: a quiet outline at rest, - filled once armed. Selected through `.actions` so it outranks the colour that - rule sets on every button in the row. */ +/* Two clicks: a quiet outline at rest, filled once armed. Selected through + `.actions` so it outranks the colour that rule sets on every button in the + row. 終了 in the list held this same shape until #71; whether this one + follows it into a dialog is #72. */ #account-dialog .actions .delete { color: var(--danger); } @@ -738,3 +750,73 @@ body { background: var(--danger); border-color: var(--danger); } + +/* ── the 終了 confirmation ───────────────────────────────────────────────── */ + +/* Its own dialog, holding a question and two answers. It borrows the account + dialog's frame because the two are the same kind of surface, and shares none + of its body: that one is a form, this one is a sentence (#71). */ +#end-dialog { + width: min(22rem, calc(100vw - 3rem)); + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0; + font-size: 0.85rem; +} + +#end-dialog::backdrop { + background: rgb(0 0 0 / 0.45); +} + +#end-dialog .confirm { + display: flex; + flex-direction: column; + gap: 0.7rem; + padding: 1rem 1.1rem 0.9rem; +} + +#end-dialog h2 { + margin: 0; + font-size: 0.95rem; +} + +/* Which session, by name. The name is the whole content of the question — the + row that was clicked is no longer under the pointer once this is open. */ +#end-dialog p { + margin: 0; + color: var(--muted); + overflow-wrap: anywhere; +} + +#end-dialog .actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + padding: 0; +} + +#end-dialog .actions .spacer { + flex: 1; +} + +#end-dialog .actions button { + font: inherit; + color: var(--fg); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.35rem 0.9rem; + cursor: pointer; +} + +/* The answer that acts, filled in the danger colour. 取消 stays a plain button + and holds the focus, so the two are told apart by more than their position + and the easy answer is the one that changes nothing. */ +#end-dialog .actions .danger { + color: var(--bg); + background: var(--danger); + border-color: var(--danger); +}