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/appearance.spec.ts b/web/e2e/appearance.spec.ts index 9b3227786..c5e528f57 100644 --- a/web/e2e/appearance.spec.ts +++ b/web/e2e/appearance.spec.ts @@ -243,3 +243,19 @@ test("a field in focus is marked by the accent outline alone, as a button is", a await expect(field).toHaveCSS("box-shadow", "none"); await expect(field).toHaveCSS("border-top-color", borderAtRest); }); + +// A row that reaches the edge of a box that hides its overflow would lose one side of the outline, 4px outside it. +test("a row at the edge of its box draws the outline of focus inside itself", async ({ page, installation }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await openApplication(page, installation); + await openSection(page, "Connections"); + const group = page.getByRole("navigation", { name: "Groups" }).getByRole("button", { name: /Ungrouped/ }).first(); + + // A press on the keyboard comes first, so the browser shows the outline as it does for someone who tabs. + await page.keyboard.press("Shift"); + await group.focus(); + + await expect(group).toHaveCSS("outline-style", "solid"); + await expect(group).toHaveCSS("outline-width", "2px"); + await expect(group).toHaveCSS("outline-offset", "-2px"); +}); 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`} > -