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
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 行に使用しています。
Expand Down
16 changes: 16 additions & 0 deletions web/e2e/appearance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
38 changes: 37 additions & 1 deletion web/e2e/support/layoutDefects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion web/src/connections/ConnectionGroupFacets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}`;
}
Expand Down
3 changes: 2 additions & 1 deletion web/src/connections/CreateConnectionPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export function CreateConnectionPane({
onSubmit={(event) => void form.submit(event)}
className={`${editorContainer} flex min-h-0 flex-1 flex-col`}
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{/* 縦に流れる枠は横のはみ出しも切る。欄のフォーカスの枠(外へ4px)が欠けないよう、左右に同じだけ余白を取る。 */}
<div className="-mx-1 flex min-h-0 flex-1 flex-col overflow-y-auto px-1">
<header className="mb-3.5 flex items-center gap-2.5">
<span aria-hidden="true" className="flex size-[34px] shrink-0 items-center justify-center rounded-inset bg-select-fill text-accent">
<Icon name="plus" className="size-[17px]" />
Expand Down
5 changes: 3 additions & 2 deletions web/src/connections/HostDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,9 @@ export function HostDetailPanel({
role="tablist"
aria-label={t("conn.editorLabel")}
data-more-to-the-right={tabsGoOn || undefined}
className={`flex gap-5 overflow-x-auto border-b border-line [scrollbar-width:none] ${tabsGoOn ? fadeAtTheRight : ""}`}
className={`-ml-1 flex gap-3 overflow-x-auto border-b border-line [scrollbar-width:none] ${tabsGoOn ? fadeAtTheRight : ""}`}
>
{/* タブは左右に4pxずつ余白を持ち、内側に描くフォーカスの枠が文字に触れない。列は同じだけ左へ寄せて、文字の位置を保つ。 */}
{areas.map((item, index) => (
<button
key={item.area}
Expand All @@ -177,7 +178,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 px-1 pb-[8px] text-ui transition-colors ${panel === item.area ? "border-accent text-ink" : "border-transparent text-ink-muted hover:text-ink"}`}
>
{t(item.label)}
</button>
Expand Down
3 changes: 2 additions & 1 deletion web/src/secrets/TOTPCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ function CopyableCode({ code, label, className }: { code: string; label: string;
aria-label={label}
title={refused ? t("copy.refused") : label}
onClick={copy}
className={`flex items-center gap-1.5 rounded-md px-1.5 py-0.5 hover:bg-hover ${className}`}
// 前後のコードは bg-well の箱の中にある。ライトでは hover と well がほぼ同じ色なので、一段濃い surface を使う。
className={`flex items-center gap-1.5 rounded-md px-1.5 py-0.5 hover:bg-surface ${className}`}
>
<span className="whitespace-nowrap font-mono tabular-nums tracking-wider">{readableCode(code)}</span>
{state === "idle" ? null : <Icon name={state === "copied" ? "check" : "close"} className={`size-4 ${refused ? "text-danger" : "text-ink-muted"}`} />}
Expand Down
6 changes: 3 additions & 3 deletions web/src/sftp/SFTPTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 : <Icon name={tab.alias === localHostAlias ? "files" : "connections"} className="size-[13px] shrink-0" />}
<span className={`block min-w-0 truncate leading-4 ${compact ? "w-full text-sm font-medium" : "text-xs"}`}>{hostOnly ? labels.host : labels.folder}</span>
Expand All @@ -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]}`}
>
<Icon name="chevronRight" className="size-3.5 rotate-90" />
</button>
Expand All @@ -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]}`}
>
<Icon name="close" className={compact ? "size-4" : "size-3"} />
</button>
Expand Down
2 changes: 1 addition & 1 deletion web/src/sftp/SFTPToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function SFTPToolbar({
{index > 0 && !crumbs[index - 1]!.label.endsWith("/") ? <span aria-hidden="true" className="px-0.5 text-ink-faint">/</span> : null}
{index === crumbs.length - 1 ? <span aria-current="location" className="max-w-40 truncate px-1 py-1.5 font-medium text-ink" title={crumb.path}>{crumb.label}</span>
: <button type="button" disabled={navigationDisabled} onClick={() => void browser.load(crumb.path)}
className="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}</button>}
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}</button>}
</span>)}
</nav>
<button type="button" aria-label={labels.editPath} title={labels.editPath} disabled={navigationDisabled} onClick={() => setPathEditing(true)}
Expand Down
2 changes: 1 addition & 1 deletion web/src/snippets/SnippetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function SnippetList({
type="button"
aria-current={opened ? "true" : undefined}
onClick={() => onOpen(snippet)}
className={`w-full min-w-0 rounded-lg px-2.5 py-2 text-left transition-colors duration-150 ${columns ? "grid grid-cols-[minmax(0,16rem)_minmax(0,28rem)_minmax(0,1fr)] items-center gap-4" : "flex flex-col gap-0.5"} ${opened ? "bg-select-fill" : "hover:bg-hover"}`}
className={`sshc-focus-inset w-full min-w-0 rounded-lg px-2.5 py-2 text-left transition-colors duration-150 ${columns ? "grid grid-cols-[minmax(0,16rem)_minmax(0,28rem)_minmax(0,1fr)] items-center gap-4" : "flex flex-col gap-0.5"} ${opened ? "bg-select-fill" : "hover:bg-hover"}`}
>
<span title={snippet.name} className="truncate text-sm text-ink">{snippet.name}</span>
<code title={snippet.command} className="truncate font-mono text-xs text-ink-faint">{snippetSummary(t, snippet)}</code>
Expand Down
Loading
Loading