From 519b068e1465b30584a3d85f37409bdba7162e61 Mon Sep 17 00:00:00 2001
From: Masaki Aida <51500566+aida0710@users.noreply.github.com>
Date: Wed, 11 Nov 2026 20:00:18 +0900
Subject: [PATCH 1/3] =?UTF-8?q?fix(web):=20=E3=83=95=E3=82=A9=E3=83=BC?=
=?UTF-8?q?=E3=82=AB=E3=82=B9=E3=81=AE=E6=9E=A0=E3=81=8C=E3=80=81=E3=81=AF?=
=?UTF-8?q?=E3=81=BF=E5=87=BA=E3=81=97=E3=82=92=E5=88=87=E3=82=8B=E8=A6=AA?=
=?UTF-8?q?=E3=81=A7=E6=AC=A0=E3=81=91=E3=82=8B=E9=83=A8=E5=93=81=E3=82=92?=
=?UTF-8?q?=E7=9B=B4=E3=81=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
フォーカスの枠は要素の外へ4px出る。端まで届く行や、横に流れるタブの列の中のボタンでは、
親が枠を切って片側が見えなかった。
- 全ページを巡回する配置の検査(e2e/support/layoutDefects.ts)に cut-focus-outline を足す。
PCの大きさの画面で、枠の片側が親に切られる部品を報告する。直す前は延べ167件
- Connectionsのグループの行、サイドバーの「新しいセッション」、接続エディタのタブ、スニペットの行、
SFTPのタブとパンくずは、枠を内側に描く(sshc-focus-inset)
- 接続先を追加するフォームは、縦に流れる枠の左右に余白を取る
Claude-Session: https://claude.ai/code/session_014WPnWu8NLT7prLQhhzYpc1
---
docs/design.md | 2 +-
web/e2e/support/layoutDefects.ts | 38 ++++++++++++++++++-
web/src/connections/ConnectionGroupFacets.tsx | 2 +-
web/src/connections/CreateConnectionPane.tsx | 3 +-
web/src/connections/HostDetail.tsx | 2 +-
web/src/sftp/SFTPTabStrip.tsx | 6 +--
web/src/sftp/SFTPToolbar.tsx | 2 +-
web/src/snippets/SnippetList.tsx | 2 +-
web/src/terminal/WorkspaceTree.tsx | 2 +-
9 files changed, 48 insertions(+), 11 deletions(-)
diff --git a/docs/design.md b/docs/design.md
index efcc17508..1787d2588 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -116,7 +116,7 @@
- カード外周は子要素に隠れるinset shadowではなく、背景との差を抑えた`--ui-line`の実borderで描きます。外周と区切りは面の境界が分かる最小限の強さにし、操作対象の`--ui-control-line`より明確に弱くします。角丸は役割で決め、`web/src/index.css`の`@theme`にある5段階と、スマホの入力欄の22px(`rounded-composer`)だけを使います。板・ダイアログ・メニューは12px(`rounded-lg`)、板の中に置く箱・注意の箱・キーバーのキー・プレビューの枠・アイコンの台は10px(`rounded-inset`)、入力欄・ボタン・アイコンボタン・選ぶ行・メニューの項目は8px(`rounded-md`)、小さな札・チェックボックス・サムネイルは6px(`rounded-sm`)です。スマホのカードとタイル、ログイン前の画面のカードは14px(`rounded-card`)にします。素の`rounded`、`rounded-[10px]`のような直書き、`rounded-xl`や`rounded-2xl`は使いません。図の細い棒や升目に付ける3px以下の角丸だけは直書きできます。`web/src/ui/radius.test.ts`が違反をファイル名と行番号付きで報告します。`.sshc-card`(`Card`とモーダルの枠)は12pxの角丸を自分で持つので、使う側で角丸を重ねて指定しません。表の内部や連続したrowには角を付けず、接続状態badgeなど意味のあるpillだけ`rounded-full`を使用します。分割paneの左右headerは同じ最小高さとpaddingに揃えます。
- Connectionsの基本、踏み台・転送・その他、表示とターミナル、最終的な値の各タブは、tablistと選択中tabpanelを同じ外枠へ入れます。tabpanelを内側へpaddingし、タブから離れた別ブロックに見せません。各tabの`aria-controls`とtabpanelの`aria-labelledby`を対にします。踏み台・転送・その他のタブは、踏み台・ポート転送・その他の設定を節に分けて1画面に並べ、タブの中にさらにタブを置きません。Hostブロックの文をそのまま編集する欄は、見出しの「…」の[テキストで編集]で開き、URLでは`advanced=raw`で表します。
- 主要操作は対象と動作を明確にし、説明や未設定状態よりも目立たせます。Homeは検索・必要なグループ・接続先を主役にし、未所属や未接続の繰り返しを省きます。PCではカードのクリックで選択、ダブルクリックで接続します。タッチは1タップ、キーボードはEnter/Spaceで接続します。Homeには常設Connectボタンと操作方法の案内文を置かず、開始中の表示とカードの操作メニューを残します。Connectionsの設定画面にはConnectボタンを置きます。
-- フォーカスリングとチェック済みの印にも同じaccentを使います。ターミナルとコードエディタは各自のフォーカス/選択描画を維持します。フォーカスの枠は、`web/src/index.css`の決まりが付ける2pxのアウトライン(要素から2px離す)の1つの形だけです。ボタン・リンク・入力欄には決まりが自動で付けるので、部品の側でリングや枠線の色を重ねません。親がはみ出しを切っていて外側の枠が欠ける要素には`sshc-focus-inset`を付け、枠を内側に描きます。押せないボタンの不透明度も同じファイルの決まりが50%にそろえるので、部品の側に`disabled:opacity-*`を書きません(書いても効きません)。明暗それぞれで小さな文字・選択中の文字・主要ボタンの文字のコントラストを確認します。
+- フォーカスリングとチェック済みの印にも同じaccentを使います。ターミナルとコードエディタは各自のフォーカス/選択描画を維持します。フォーカスの枠は、`web/src/index.css`の決まりが付ける2pxのアウトライン(要素から2px離す)の1つの形だけです。ボタン・リンク・入力欄には決まりが自動で付けるので、部品の側でリングや枠線の色を重ねません。親がはみ出しを切っていて外側の枠が欠ける要素には`sshc-focus-inset`を付け、枠を内側に描きます。PCの大きさの画面で枠が欠ける部品は、全ページを巡回する`web/e2e/layout-audit.spec.ts`が`cut-focus-outline`として報告します。押せないボタンの不透明度も同じファイルの決まりが50%にそろえるので、部品の側に`disabled:opacity-*`を書きません(書いても効きません)。明暗それぞれで小さな文字・選択中の文字・主要ボタンの文字のコントラストを確認します。
- 文字の大きさは、Tailwindの`text-xs`(12px)・`text-sm`(14px)・`text-base`(16px)・`text-lg`・`text-xl`・`text-2xl`と、`web/src/index.css`の`@theme`が足す`text-3xs`(10px、札やキーの印)・`text-2xs`(11px、小さなラベルとセッションの行の2行目)・`text-ui`(13px、入力欄・ボタン・エディタ)だけを使います。名前は14px、補助の文は12pxです。`text-[12.5px]`のような直書きと0.5px刻みは使いません(`web/src/ui/fontSize.test.ts`が検査します。丸い印の中の記号の8px以下は対象外です)。足した3つは行の高さを持たないので、行の高さは置く場所の`leading-*`で決めます。共有の入力欄(`control`)に`text-xs`などを重ねても、CSSの並び順で`text-ui`が勝つので効きません。
- 重なりの順番は、ページの中(上に貼り付く見出し、スマホの下のナビゲーション、引き出し、メニュー、ダイアログ)を`z-50`までの数で書きます。それより手前に出す層は`web/src/index.css`が名前を付けます。コマンドパレットは`z-palette`(70)、ダイアログを開いていても見せる知らせ・リンクの行き先・コマンドのカードは`z-above-dialog`(80)、エンジンに届かないときの画面は`z-fallback`(100)です。`z-[70]`のような直書きは`web/src/ui/layers.test.ts`が検査します。
- パレットの規則は `web/src/ui/palette.test.ts` で検査します。Tailwind のパレット名(`text-red-400` など)、任意値(`text-[#ff0000]`)、inline style の hex 値を走査し、違反箇所をファイル名と行番号付きで報告します。例外は `palette-exempt` を記載した行だけで、現在は native の色入力が独自の既定値を必要とする 2 行に使用しています。
diff --git a/web/e2e/support/layoutDefects.ts b/web/e2e/support/layoutDefects.ts
index 9a6987382..ad32c6d7a 100644
--- a/web/e2e/support/layoutDefects.ts
+++ b/web/e2e/support/layoutDefects.ts
@@ -20,7 +20,9 @@ export type LayoutDefectKind =
// An icon or a dot beside a line of text, higher or lower than the middle of the line.
| "off-centre-mark"
// Rows of one list built alike whose leading marks differ in size, or whose text starts at different places.
- | "uneven-rows";
+ | "uneven-rows"
+ // The outline that marks the control in focus is cut away on one side by a box that hides its overflow.
+ | "cut-focus-outline";
export type LayoutDefect = {
kind: LayoutDefectKind;
@@ -372,6 +374,38 @@ function listLayoutDefects({ ignored }: ListingOptions): LayoutDefect[] {
return Math.round(bottom - top) + 1;
};
+ // The outline of a control in focus is 2px wide and 2px away from it (index.css). A control marked
+ // sshc-focus-inset draws it inside itself, where nothing cuts it.
+ const outlineReachPx = 4;
+ // sideWhereFocusOutlineIsCut names the side on which a box that hides its overflow leaves no room for the whole
+ // outline, with the box. A control used by touch alone shows no outline, so a phone's layout is not judged.
+ const sideWhereFocusOutlineIsCut = (control: Element, box: DOMRect): { toString(): string; by: string } | null => {
+ if (laidOutAsPhone || control.classList.contains("sshc-focus-inset")) return null;
+ // A field that is hidden and drawn by the element after it (a switch, a swatch) shows the outline on that one.
+ if (getComputedStyle(control).opacity === "0") return null;
+ for (let holder = control.parentElement; holder !== null; holder = holder.parentElement) {
+ const style = getComputedStyle(holder);
+ const cutsSideways = style.overflowX !== "visible";
+ const cutsDown = style.overflowY !== "visible";
+ if (!cutsSideways && !cutsDown) continue;
+ const outer = holder.getBoundingClientRect();
+ // What the holder shows is inside its border.
+ const shown = { left: outer.left + holder.clientLeft, top: outer.top + holder.clientTop, right: outer.left + holder.clientLeft + holder.clientWidth, bottom: outer.top + holder.clientTop + holder.clientHeight };
+ const sides: Array<[string, boolean]> = [
+ ["left", cutsSideways && holder.scrollLeft === 0 && box.left - outlineReachPx < shown.left - sidewaysTolerancePx],
+ ["right", cutsSideways && holder.scrollLeft + holder.clientWidth >= holder.scrollWidth - 1 && box.right + outlineReachPx > shown.right + sidewaysTolerancePx],
+ ["top", cutsDown && holder.scrollTop === 0 && box.top - outlineReachPx < shown.top - sidewaysTolerancePx],
+ ["bottom", cutsDown && holder.scrollTop + holder.clientHeight >= holder.scrollHeight - 1 && box.bottom + outlineReachPx > shown.bottom + sidewaysTolerancePx],
+ ];
+ const cut = sides.find(([, isCut]) => isCut);
+ if (cut !== undefined) {
+ const by = `${holder.tagName.toLowerCase()}${holder.getAttribute("aria-label") === null ? "" : ` "${holder.getAttribute("aria-label")}"`}`;
+ return { toString: () => cut[0], by };
+ }
+ }
+ return null;
+ };
+
for (const control of controls) {
control.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
const box = control.getBoundingClientRect();
@@ -386,6 +420,8 @@ function listLayoutDefects({ ignored }: ListingOptions): LayoutDefect[] {
note("covered-control", control, `a press at its middle lands on ${hit === null ? "nothing" : `<${hit.tagName.toLowerCase()}> "${nameOf(hit)}"`}`);
continue;
}
+ const cutSide = sideWhereFocusOutlineIsCut(control, box);
+ if (cutSide !== null) note("cut-focus-outline", control, `the outline around it is cut away on the ${cutSide} by <${cutSide.by}>`);
if (!laidOutAsPhone || control.hasAttribute("data-touch-compact")) continue;
// A link in a line of text is the height of the line. It is pressed as a part of what is being read.
if (control.tagName === "A" && getComputedStyle(control).display === "inline") continue;
diff --git a/web/src/connections/ConnectionGroupFacets.tsx b/web/src/connections/ConnectionGroupFacets.tsx
index 23b9d3801..6a39ef002 100644
--- a/web/src/connections/ConnectionGroupFacets.tsx
+++ b/web/src/connections/ConnectionGroupFacets.tsx
@@ -30,7 +30,7 @@ function floatingListSide(pointerX: number, listWidth: number): "left" | "right"
}
function facetLook(active: boolean): string {
- return `flex min-h-[30px] w-full min-w-0 items-center gap-2.5 rounded-md px-2 text-left text-ui transition-colors ${
+ return `sshc-focus-inset flex min-h-[30px] w-full min-w-0 items-center gap-2.5 rounded-md px-2 text-left text-ui transition-colors ${
active ? "bg-select-fill font-medium text-ink" : "text-ink-muted hover:bg-hover hover:text-ink"
}`;
}
diff --git a/web/src/connections/CreateConnectionPane.tsx b/web/src/connections/CreateConnectionPane.tsx
index fb11b41fc..52e945eea 100644
--- a/web/src/connections/CreateConnectionPane.tsx
+++ b/web/src/connections/CreateConnectionPane.tsx
@@ -78,7 +78,8 @@ export function CreateConnectionPane({
onSubmit={(event) => void form.submit(event)}
className={`${editorContainer} flex min-h-0 flex-1 flex-col`}
>
-
+ {/* 縦に流れる枠は横のはみ出しも切る。欄のフォーカスの枠(外へ4px)が欠けないよう、左右に同じだけ余白を取る。 */}
+
diff --git a/web/src/connections/HostDetail.tsx b/web/src/connections/HostDetail.tsx
index 0432753eb..73c602415 100644
--- a/web/src/connections/HostDetail.tsx
+++ b/web/src/connections/HostDetail.tsx
@@ -177,7 +177,7 @@ export function HostDetailPanel({
tabIndex={panel === item.area ? 0 : -1}
onClick={() => selectArea(item.area)}
onKeyDown={(event) => activateTabFromKeyboard(event, index, areaNames, selectArea)}
- className={`-mb-px shrink-0 whitespace-nowrap border-b-2 pb-[8px] text-ui transition-colors ${panel === item.area ? "border-accent text-ink" : "border-transparent text-ink-muted hover:text-ink"}`}
+ className={`sshc-focus-inset -mb-px shrink-0 whitespace-nowrap border-b-2 pb-[8px] text-ui transition-colors ${panel === item.area ? "border-accent text-ink" : "border-transparent text-ink-muted hover:text-ink"}`}
>
{t(item.label)}
diff --git a/web/src/sftp/SFTPTabStrip.tsx b/web/src/sftp/SFTPTabStrip.tsx
index e78b2d7bb..e495b7f52 100644
--- a/web/src/sftp/SFTPTabStrip.tsx
+++ b/web/src/sftp/SFTPTabStrip.tsx
@@ -208,7 +208,7 @@ export function SFTPTabStrip({
title={labels.title || labels.name}
onClick={() => onSelect(tab.id)}
onKeyDown={(event) => keyDown(event, tab, index)}
- className={`flex min-w-0 flex-1 rounded-md py-0.5 text-left ${compact ? "px-2" : "pl-3 pr-1"} ${tabLayout[size]} ${selected ? "text-ink" : "text-ink-muted"}`}
+ className={`sshc-focus-inset flex min-w-0 flex-1 rounded-md py-0.5 text-left ${compact ? "px-2" : "pl-3 pr-1"} ${tabLayout[size]} ${selected ? "text-ink" : "text-ink-muted"}`}
>
{compact || tab.alias === "" ? null : }
{hostOnly ? labels.host : labels.folder}
@@ -223,7 +223,7 @@ export function SFTPTabStrip({
title={t("sftp.chooseHostHeading")}
disabled={!canChangeHost(tab)}
onClick={() => onChangeHost(tab)}
- className={`flex shrink-0 items-center justify-center rounded-md text-ink-muted hover:bg-hover hover:text-ink ${controlSize[size]}`}
+ className={`sshc-focus-inset flex shrink-0 items-center justify-center rounded-md text-ink-muted hover:bg-hover hover:text-ink ${controlSize[size]}`}
>
@@ -243,7 +243,7 @@ export function SFTPTabStrip({
type="button"
aria-label={t("sftp.closeTab", { name: labels.name })}
onClick={(event) => onClose(tab, { skipConfirmation: event.shiftKey })}
- className={`flex shrink-0 items-center justify-center rounded-md text-ink-faint hover:bg-hover hover:text-danger ${controlSize[size]}`}
+ className={`sshc-focus-inset flex shrink-0 items-center justify-center rounded-md text-ink-faint hover:bg-hover hover:text-danger ${controlSize[size]}`}
>
diff --git a/web/src/sftp/SFTPToolbar.tsx b/web/src/sftp/SFTPToolbar.tsx
index eab5f4dfa..aa5aa4105 100644
--- a/web/src/sftp/SFTPToolbar.tsx
+++ b/web/src/sftp/SFTPToolbar.tsx
@@ -120,7 +120,7 @@ export function SFTPToolbar({
{index > 0 && !crumbs[index - 1]!.label.endsWith("/") ? / : null}
{index === crumbs.length - 1 ? {crumb.label}
: }
+ className="sshc-focus-inset max-w-28 truncate rounded-md px-1 py-1.5 text-ink-muted hover:bg-hover hover:text-ink" title={crumb.path}>{crumb.label}}
)}