From 0f8f0b3d4b81242949c743bb60731b9550558650 Mon Sep 17 00:00:00 2001 From: notsapinho Date: Sun, 13 Sep 2026 12:24:21 -0300 Subject: [PATCH 1/8] Polish UI consistency and accessibility --- src/chrome/BranchPicker.tsx | 2 +- src/chrome/ColorPickerPopover.tsx | 21 +++++-- src/chrome/Composer.tsx | 4 +- src/chrome/ContextMeter.tsx | 2 +- src/chrome/GitChangesPanel.tsx | 2 +- src/chrome/Modal.test.ts | 3 + src/chrome/Modal.tsx | 49 ++++++++++++++-- src/chrome/ModalFocus.test.ts | 62 ++++++++++++++++++++ src/chrome/ProjectRail.tsx | 12 ++-- src/chrome/RailAction.tsx | 5 +- src/chrome/RemoveProjectDialog.tsx | 70 ++++++++--------------- src/chrome/SettingsRail.tsx | 35 +----------- src/chrome/Sidebar.tsx | 10 ++-- src/chrome/SurfaceTabs.tsx | 4 +- src/chrome/SwitchBranchDialog.tsx | 82 ++++++++++----------------- src/chrome/TabGroupMenu.tsx | 40 ++++++++++--- src/chrome/TitleBar.tsx | 2 +- src/index.css | 11 ++++ src/lib/settings.ts | 5 +- src/surfaces/InboxComments.tsx | 2 +- src/surfaces/InboxDiscussionPanel.tsx | 2 +- src/surfaces/InboxView.tsx | 8 ++- src/surfaces/SearchView.tsx | 6 +- src/surfaces/SettingsView.tsx | 20 ++++--- 24 files changed, 268 insertions(+), 191 deletions(-) create mode 100644 src/chrome/ModalFocus.test.ts diff --git a/src/chrome/BranchPicker.tsx b/src/chrome/BranchPicker.tsx index 450f34d5..9154ff2b 100644 --- a/src/chrome/BranchPicker.tsx +++ b/src/chrome/BranchPicker.tsx @@ -345,7 +345,7 @@ export function BranchPicker({ ref={search} type="text" value={query} - placeholder="Search or create a branch..." + placeholder="Search or create a branch…" aria-label="Search or create a branch" spellCheck={false} autoComplete="off" diff --git a/src/chrome/ColorPickerPopover.tsx b/src/chrome/ColorPickerPopover.tsx index 32fa0ec4..2d42b969 100644 --- a/src/chrome/ColorPickerPopover.tsx +++ b/src/chrome/ColorPickerPopover.tsx @@ -11,6 +11,7 @@ import { Pipette } from "./icons"; type Props = { value: string; onChange: (hex: string) => void; + className?: string; }; export function ColorSwatchRow({ @@ -28,9 +29,10 @@ export function ColorSwatchRow({ customPickerOpen: boolean; customHighlighted?: boolean; onPickIndex: (index: number) => void; - onToggleCustom?: () => void; + onToggleCustom?: (anchor: HTMLButtonElement) => void; }) { - const pipetteActive = customHighlighted ?? (customColor != null || customPickerOpen); + const pipetteActive = + customHighlighted ?? (customColor != null || customPickerOpen); return (
{colors.map((color, index) => { @@ -66,7 +68,7 @@ export function ColorSwatchRow({ aria-expanded={customPickerOpen} aria-pressed={customColor != null} onMouseDown={(event) => event.preventDefault()} - onClick={onToggleCustom} + onClick={(event) => onToggleCustom?.(event.currentTarget)} className="grid size-5 place-items-center rounded-full" > {!customColor ? ( - + ) : null} @@ -93,7 +98,11 @@ export function ColorSwatchRow({ ); } -export function ColorPickerPopover({ value, onChange }: Props) { +export function ColorPickerPopover({ + value, + onChange, + className = "mt-2 rounded-lg border border-content/10 bg-content/5 p-2", +}: Props) { const [hsv, setHsv] = useState(() => hexToHsv(value)); const svRef = useRef(null); const hueRef = useRef(null); @@ -178,7 +187,7 @@ export function ColorPickerPopover({ value, onChange }: Props) { const hueColor = hsvToHex(hsv.h, 100, 100); return ( -
+
{ setOpen(false); diff --git a/src/chrome/GitChangesPanel.tsx b/src/chrome/GitChangesPanel.tsx index 7b087e42..c0960af3 100644 --- a/src/chrome/GitChangesPanel.tsx +++ b/src/chrome/GitChangesPanel.tsx @@ -765,7 +765,7 @@ function GitSyncActions({ index.upstream ?? `${index.remote ?? "origin"}/${index.branch ?? "HEAD"}`; const syncing = busy === "sync"; const syncTitle = syncing - ? "Synchronizing Changes..." + ? "Synchronizing changes…" : canPublish ? index.branch ? `Publish Branch "${index.branch}"` diff --git a/src/chrome/Modal.test.ts b/src/chrome/Modal.test.ts index cf37547f..a0118e3c 100644 --- a/src/chrome/Modal.test.ts +++ b/src/chrome/Modal.test.ts @@ -9,6 +9,7 @@ describe("ModalPanel", () => { createElement(ModalPanel, { title: "Example", description: "A reusable shell", + closeDisabled: true, onClose: vi.fn(), children: "Body", }), @@ -18,7 +19,9 @@ describe("ModalPanel", () => { expect(markup).toContain("modal-panel"); expect(markup).toContain("Example"); expect(markup).toContain("A reusable shell"); + expect(markup).toContain('title="A reusable shell"'); expect(markup).toContain("Body"); expect(markup).toContain('aria-label="Close"'); + expect(markup).toContain("disabled"); }); }); diff --git a/src/chrome/Modal.tsx b/src/chrome/Modal.tsx index 80bb9f1a..42b84fa3 100644 --- a/src/chrome/Modal.tsx +++ b/src/chrome/Modal.tsx @@ -1,5 +1,12 @@ import { X } from "./icons"; -import { useEffect, useId, useRef, type ReactNode } from "react"; +import { + useEffect, + useId, + useRef, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + type RefObject, +} from "react"; import { createPortal } from "react-dom"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { LAYER } from "../lib/layers"; @@ -23,6 +30,8 @@ type Props = { size?: ModalSize; /** Extra classes on the panel (fixed height, etc). */ className?: string; + initialFocusRef?: RefObject; + closeDisabled?: boolean; children: ReactNode; }; @@ -32,38 +41,64 @@ export function ModalPanel({ description, size = "md", className, + initialFocusRef, + closeDisabled = false, children, }: Props) { const closeRef = useRef(null); + const panelRef = useRef(null); const lockOverscroll = useLockOverscroll(); const uid = useId(); const titleId = `${uid}-title`; const descriptionId = description ? `${uid}-desc` : undefined; useEffect(() => { - closeRef.current?.focus(); - }, []); + (initialFocusRef?.current ?? closeRef.current)?.focus(); + }, [initialFocusRef]); useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key !== "Escape") return; + if (closeDisabled) return; event.preventDefault(); event.stopPropagation(); onClose(); }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [onClose]); + }, [closeDisabled, onClose]); + + const onDialogKeyDown = (event: ReactKeyboardEvent) => { + if (event.key !== "Tab") return; + const focusable = panelRef.current?.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])', + ); + if (!focusable?.length) { + event.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; return (
event.stopPropagation()} className={`modal-panel flex flex-col overflow-hidden rounded-2xl border border-content/10 bg-background-base/55 shadow-2xl backdrop-blur-xl ${className ?? ""}`} > @@ -78,6 +113,7 @@ export function ModalPanel({ {description ? (

{description} @@ -88,8 +124,9 @@ export function ModalPanel({ ref={closeRef} type="button" aria-label="Close" + disabled={closeDisabled} onClick={onClose} - className="grid size-7 shrink-0 place-items-center rounded-md text-content/45 hover:bg-content/8 hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" + className="grid size-7 shrink-0 place-items-center rounded-md text-content/45 hover:bg-content/8 hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-content/45" > @@ -110,7 +147,7 @@ export function Modal(props: Props) {

, diff --git a/src/chrome/ModalFocus.test.ts b/src/chrome/ModalFocus.test.ts new file mode 100644 index 00000000..599add1a --- /dev/null +++ b/src/chrome/ModalFocus.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { ModalPanel } from "./Modal"; + +describe("ModalPanel focus", () => { + it("wraps keyboard focus inside the dialog", () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + createElement( + ModalPanel, + { title: "Example", onClose: vi.fn() }, + createElement("button", { type: "button" }, "Action"), + ), + ); + }); + + const close = container.querySelector( + 'button[aria-label="Close"]', + ); + const action = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Action", + ); + expect(close).toBeTruthy(); + expect(action).toBeTruthy(); + + action!.focus(); + act(() => { + action!.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.activeElement).toBe(close); + + close!.focus(); + act(() => { + close!.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.activeElement).toBe(action); + + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/chrome/ProjectRail.tsx b/src/chrome/ProjectRail.tsx index e690f6e4..5cf06741 100644 --- a/src/chrome/ProjectRail.tsx +++ b/src/chrome/ProjectRail.tsx @@ -711,7 +711,7 @@ function LiveAgentCard({ ? formatLiveElapsed(agent.startedAt, now) : ""; const activity = agent.needsApproval - ? "Need approval" + ? "Needs approval" : agent.done ? "Done" : agent.activity; @@ -965,9 +965,9 @@ function ProjectCard({ title={cardTitle} aria-label={cardAriaLabel} aria-current={selected ? "true" : undefined} - className="flex min-w-0 flex-1 cursor-default items-center gap-2 text-left group-hover:pr-6" + className="flex min-w-0 flex-1 cursor-default items-center gap-2 text-left group-hover:pr-6 group-focus-within:pr-6" > -
+
{logoPath && !busy ? ( {name} )} {hasChanges ? ( - + ) : null} @@ -1008,7 +1008,7 @@ function ProjectCard({ event.stopPropagation(); onOpenMenu(item.path, event.clientX, event.clientY); }} - className="absolute right-1 top-1/2 hidden size-6 -translate-y-1/2 place-items-center rounded-md text-content/55 hover:bg-content/8 hover:text-content group-hover:grid" + className="absolute right-1 top-1/2 hidden size-6 -translate-y-1/2 place-items-center rounded-md text-content/55 hover:bg-content/8 hover:text-content group-hover:grid group-focus-within:grid" > @@ -1022,7 +1022,7 @@ function ProjectCard({ event.stopPropagation(); onTogglePin(item.path); }} - className="absolute left-2 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-content/55 opacity-0 pointer-events-none transition-opacity hover:text-content group-hover:pointer-events-auto group-hover:opacity-100" + className="pointer-events-none absolute left-2 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-content/55 opacity-0 transition-opacity hover:text-content group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100" > {pinned ? ( diff --git a/src/chrome/RailAction.tsx b/src/chrome/RailAction.tsx index e6e8323f..224ffd2b 100644 --- a/src/chrome/RailAction.tsx +++ b/src/chrome/RailAction.tsx @@ -27,7 +27,8 @@ export function RailAction({ onClick={onClick} disabled={!onClick} aria-label={ariaLabel ?? label} - className={`relative flex w-full items-center gap-2 rounded-md px-2 h-8 text-left ${ + aria-current={active ? "page" : undefined} + className={`relative flex h-8 w-full items-center gap-2 rounded-md px-2 text-left ${ active ? "bg-content/10 text-content" : "text-content/50 hover:bg-content/10 hover:text-content" @@ -80,7 +81,7 @@ export function RailSearch({ onClick={onClick} disabled={!onClick} aria-label={ariaLabel ?? label} - className={`relative flex w-full items-center gap-2 rounded-md border border-content/8 px-1.5 shadow-sm h-8 text-left ${ + className={`relative flex h-8 w-full items-center gap-2 rounded-md border border-content/8 px-1.5 text-left shadow-sm ${ active ? "bg-content/10 text-content" : "text-content/50 hover:bg-content/10 hover:text-content" diff --git a/src/chrome/RemoveProjectDialog.tsx b/src/chrome/RemoveProjectDialog.tsx index 08382e91..f0b10012 100644 --- a/src/chrome/RemoveProjectDialog.tsx +++ b/src/chrome/RemoveProjectDialog.tsx @@ -1,8 +1,7 @@ -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { LAYER } from "../lib/layers"; +import { useEffect, useState } from "react"; import { prettyCwd } from "../lib/paths"; import { projectSessionCount } from "../lib/projectData"; +import { Modal } from "./Modal"; type Props = { name: string; @@ -15,13 +14,13 @@ type Props = { * Delete drops the project from the rail and its saved chats. The folder on * disk is left alone; opening it again brings the project back empty. */ -export function RemoveProjectDialog({ name, path, onCancel, onConfirm }: Props) { +export function RemoveProjectDialog({ + name, + path, + onCancel, + onConfirm, +}: Props) { const [sessions, setSessions] = useState(null); - const cancelRef = useRef(null); - - useEffect(() => { - cancelRef.current?.focus(); - }, []); useEffect(() => { let cancelled = false; @@ -33,51 +32,31 @@ export function RemoveProjectDialog({ name, path, onCancel, onConfirm }: Props) }; }, [path]); - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - onCancel(); - }; - window.addEventListener("keydown", onKey, true); - return () => window.removeEventListener("keydown", onKey, true); - }, [onCancel]); - - return createPortal( -
-
-
event.stopPropagation()} - className="absolute left-1/2 top-[22%] flex w-[min(420px,calc(100vw-24px))] -translate-x-1/2 flex-col gap-3 rounded-lg border border-content/10 bg-content/5 p-4 shadow-xl backdrop-blur-xl" - > -
-

- Delete “{name}”? -

-

- All conversations for this project will be deleted. It also - leaves the sidebar. The folder on disk stays put, and opening it - again brings the project back empty. + return ( + +

+
+

+ This deletes all sessions for the project and removes it from the + sidebar. The folder on disk stays intact; reopening it adds the + project back empty.

{sessions != null && sessions > 0 ? (

{sessions === 1 - ? "1 saved conversation will be removed." - : `${sessions} saved conversations will be removed.`} + ? "1 saved session will be removed." + : `${sessions} saved sessions will be removed.`}

) : null} -

- {prettyCwd(path)} -

-
, - document.body, + ); } diff --git a/src/chrome/SettingsRail.tsx b/src/chrome/SettingsRail.tsx index 0394b595..637233ae 100644 --- a/src/chrome/SettingsRail.tsx +++ b/src/chrome/SettingsRail.tsx @@ -11,6 +11,7 @@ import { } from "./icons"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { SETTINGS_SECTIONS, type SettingsSectionId } from "../lib/settings"; +import { RailAction } from "./RailAction"; const SECTION_ICONS: Record = { general: SlidersHorizontal, @@ -40,7 +41,7 @@ export function SettingsNav({ section, onSelect, onClose }: Props) { className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-none px-2 pb-2" > {SETTINGS_SECTIONS.map((item) => ( -
- +
); } - -function NavRow({ - label, - icon: Icon, - active = false, - onClick, -}: { - label: string; - icon: IconComponent; - active?: boolean; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/src/chrome/Sidebar.tsx b/src/chrome/Sidebar.tsx index e84062fd..fa389420 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -1061,8 +1061,8 @@ function SidebarComponent({ ref={searchInputRef} type="text" value={searchQuery} - placeholder="Search conversations..." - aria-label="Search conversations" + placeholder="Search sessions…" + aria-label="Search sessions" spellCheck={false} autoComplete="off" autoCorrect="off" @@ -1887,7 +1887,7 @@ function SidebarProjectPicker({ setQuery(event.target.value); setActive(0); }} - placeholder="Search projects..." + placeholder="Search projects…" className="min-w-0 flex-1 bg-transparent text-[13px] text-content outline-none placeholder:text-content/35" /> @@ -2378,12 +2378,12 @@ function SessionCard({ {needsApproval ? ( <> - Need approval + Needs approval ) : busy ? ( <> - Working... + Working… ) : done ? ( <> diff --git a/src/chrome/SurfaceTabs.tsx b/src/chrome/SurfaceTabs.tsx index 7f1af6a5..0756079b 100644 --- a/src/chrome/SurfaceTabs.tsx +++ b/src/chrome/SurfaceTabs.tsx @@ -375,7 +375,9 @@ export function SurfaceTabs({ onCloseFile(file.id); }} className={`absolute right-1.5 top-1/2 grid size-5 -translate-y-1/2 place-items-center rounded text-content/50 hover:bg-content/10 hover:text-content ${ - active ? "opacity-100" : "opacity-0 group-hover:opacity-100" + active + ? "opacity-100" + : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100" }`} > diff --git a/src/chrome/SwitchBranchDialog.tsx b/src/chrome/SwitchBranchDialog.tsx index 5b6504b7..0fe1edc8 100644 --- a/src/chrome/SwitchBranchDialog.tsx +++ b/src/chrome/SwitchBranchDialog.tsx @@ -1,9 +1,8 @@ import { Loader, WandSparkles } from "./icons"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { generateCommitMessage } from "../lib/harness"; -import { LAYER } from "../lib/layers"; import { MOD } from "../lib/platform"; +import { Modal } from "./Modal"; type Busy = "stash" | "commit" | null; @@ -32,11 +31,8 @@ export function SwitchBranchDialog({ const [generating, setGenerating] = useState(false); const messageRef = useRef(null); const trimmed = message.trim(); - const canCommit = trimmed.length > 0 && !busy && !generating; - - useEffect(() => { - messageRef.current?.focus(); - }, []); + const locked = Boolean(busy) || generating; + const canCommit = trimmed.length > 0 && !locked; useEffect(() => { const el = messageRef.current; @@ -45,19 +41,8 @@ export function SwitchBranchDialog({ el.style.height = `${Math.min(el.scrollHeight, 160)}px`; }, [message]); - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - if (!busy && !generating) onCancel(); - }; - window.addEventListener("keydown", onKey, true); - return () => window.removeEventListener("keydown", onKey, true); - }, [busy, generating, onCancel]); - const generate = async () => { - if (busy || generating) return; + if (locked) return; setGenerating(true); try { setMessage(await generateCommitMessage(cwd)); @@ -69,32 +54,23 @@ export function SwitchBranchDialog({ } }; - return createPortal( -
-
{ - if (!busy && !generating) onCancel(); - }} - /> -
event.stopPropagation()} - className="absolute left-1/2 top-[22%] flex w-[min(420px,calc(100vw-24px))] -translate-x-1/2 flex-col gap-3 rounded-lg border border-content/10 bg-content/5 p-4 shadow-xl backdrop-blur-xl" - > -
-

- Uncommitted changes -

-

- {creating - ? `Creating “${branch}” would overwrite your local changes. Stash them for later, or commit them on this branch first.` - : `Switching to “${branch}” would overwrite your local changes. Stash them for later, or commit them on this branch first.`} -

-
+ return ( + +
+

+ Stash the changes for later, or commit them on this branch first. +