From 35fba450e4a892fc272184ea95059231bb6bb4e5 Mon Sep 17 00:00:00 2001 From: keliangliang Date: Sat, 17 Oct 2026 11:24:38 +0800 Subject: [PATCH] feat: refine workbench appearance --- CHANGELOG.md | 4 + docs/orca-inspired-p0/change.md | 27 ++ src/components/Tooltip.test.tsx | 36 ++ src/components/Tooltip.tsx | 50 +++ src/features/changes/ChangesPanel.tsx | 39 +- src/features/chat/chatLayout.test.ts | 4 +- src/features/editor/EditorPane.test.tsx | 6 +- src/features/editor/EditorPane.tsx | 52 +-- src/features/settings/SettingsDialog.test.tsx | 17 + src/features/settings/SettingsDialog.tsx | 5 +- src/features/theme/ThemeToggle.test.tsx | 16 + src/features/theme/ThemeToggle.tsx | 17 +- src/features/theme/themeStore.test.ts | 23 +- src/features/theme/themeStore.ts | 58 +-- src/features/workbench/Workbench.test.tsx | 13 +- src/features/workbench/Workbench.tsx | 82 +++-- src/i18n/en.ts | 2 + src/i18n/zh-CN.ts | 2 + src/styles/index.css | 343 ++++++++++++------ src/styles/themeTokens.test.ts | 4 + 20 files changed, 563 insertions(+), 237 deletions(-) create mode 100644 docs/orca-inspired-p0/change.md create mode 100644 src/components/Tooltip.test.tsx create mode 100644 src/components/Tooltip.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f3a87..7d52bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Cross-platform development packaging for macOS arm64, Windows x64, and Linux x64. - Projects, managed worktrees, file editing, Git changes, terminals, multi-tab pi Agent chat, settings, and support for user-installed standard Pi resources. +### Changed + +- Refined the workbench with quieter neutral chrome, a narrower Chat reading column, accessible icon tooltips, and a System appearance option that follows the operating system theme. + ### Removed - **Breaking:** SpireCode no longer depends on or bundles packages from `github:klioen/pi-extensions`. Web access, subagents, memory, todo, plan, goal, and SDLC resources must now be installed and declared by users through standard Pi settings. diff --git a/docs/orca-inspired-p0/change.md b/docs/orca-inspired-p0/change.md new file mode 100644 index 0000000..3a592fc --- /dev/null +++ b/docs/orca-inspired-p0/change.md @@ -0,0 +1,27 @@ +# Orca-inspired P0 UI refinement + +## Intent + +Make SpireCode's shell quieter and more content-first while preserving its existing Project → Worktree → Chat/Editor/Changes product model and green brand identity. Improve theme behavior, chat readability, and discoverability of icon-only controls without adopting Orca's feature density or changing desktop security boundaries. + +Risk classification: standard change because it crosses theme, workbench, chat, settings, and shared UI components and changes visible behavior, but it does not change IPC contracts, persisted data schemas, filesystem access, or command execution. + +## Spec + +- Use neutral light/dark chrome surfaces; reserve brand green for focus, primary action, running state, and the logo. +- Preserve semantic danger, warning, success, Git, Monaco, and terminal colors with accessible contrast. +- Reduce chat reading width and establish a compact prose/metadata/code type scale without adding user-facing appearance knobs. +- Restore `system` as a persisted theme preference in Settings. The top-bar theme button remains a two-state light/dark shortcut and never exposes a third computer icon. +- Follow live `prefers-color-scheme` changes only while the stored mode is `system`. +- Suppress transitions during theme swaps so surfaces change atomically. +- Add a reusable, keyboard-visible tooltip primitive and apply it to the primary icon-only workbench controls. Existing accessible names remain intact. +- Keep Project/Worktree breadcrumbs, panel arrangement, chat message chrome, and Renderer/Main security boundaries unchanged. + +## Plan + +1. Add regression tests for system theme persistence/resolution, two-state top-bar behavior under system mode, the Settings system option, and tooltip accessibility. +2. Implement system-aware theme state and localized Settings copy while keeping the top-bar shortcut explicit light/dark. +3. Add a zero-dependency tooltip primitive and use it for top-bar and right-panel icon actions. +4. Replace colored chrome tokens with neutral surfaces, introduce semantic interaction aliases, normalize focus/hover/selected/elevation, and tune chat width/type scale. +5. Run targeted tests, formatting, lint, typecheck, the full `pnpm check` gate, and inspect the final diff for scope and token completeness. +6. If rendered-app validation is available without disrupting the desktop, verify light, dark, system-follow, tooltip focus, and representative Chat/Editor/Changes screens. diff --git a/src/components/Tooltip.test.tsx b/src/components/Tooltip.test.tsx new file mode 100644 index 0000000..cc89a1d --- /dev/null +++ b/src/components/Tooltip.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Tooltip } from "./Tooltip"; + +describe("Tooltip", () => { + it("links an icon-only control to focus-triggered help text", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Open settings" }); + expect(screen.queryByRole("tooltip")).toBeNull(); + + fireEvent.focus(button); + const tooltip = screen.getByRole("tooltip", { name: "Open settings" }); + expect(button).toHaveAttribute("aria-describedby", tooltip.id); + expect(tooltip.parentElement).toHaveClass("app-tooltip"); + }); + + it("dismisses a keyboard-triggered tooltip with Escape", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Open settings" }); + fireEvent.focus(button); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + + fireEvent.keyDown(button, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); +}); diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..ebc6dfe --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,50 @@ +import { cloneElement, useId, useState, type ReactElement } from "react"; + +type TooltipSide = "top" | "bottom" | "left" | "right"; +type TooltipAlign = "start" | "center" | "end"; +type TooltipChild = ReactElement<{ "aria-describedby"?: string }>; + +export function Tooltip({ + label, + side = "bottom", + align = "center", + children, +}: { + label: string; + side?: TooltipSide; + align?: TooltipAlign; + children: TooltipChild; +}) { + const id = useId(); + const [open, setOpen] = useState(false); + const describedBy = [children.props["aria-describedby"], id] + .filter(Boolean) + .join(" "); + return ( + setOpen(true)} + onPointerLeave={() => setOpen(false)} + onFocusCapture={() => setOpen(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); + }} + onKeyDownCapture={(event) => { + if (event.key === "Escape") setOpen(false); + }} + > + {cloneElement(children, { "aria-describedby": describedBy })} + + {label} + + + ); +} diff --git a/src/features/changes/ChangesPanel.tsx b/src/features/changes/ChangesPanel.tsx index 22aff9c..324b702 100644 --- a/src/features/changes/ChangesPanel.tsx +++ b/src/features/changes/ChangesPanel.tsx @@ -14,6 +14,7 @@ import { RiTreeLine, } from "@remixicon/react"; import { type DiffScope, type GitChange } from "../../bindings"; +import { Tooltip } from "../../components/Tooltip"; import { formatNumber, useTranslation } from "../../i18n"; import { useTreeKeyboard } from "../../lib/useTreeKeyboard"; import { diffResourceId, useEditorStore } from "../editor/editorStore"; @@ -368,24 +369,26 @@ export function ChangesPanel({ worktreeId }: { worktreeId: string }) { className="change-mode-toggle" aria-label={t("changes.viewMode")} > - - + + + + + + diff --git a/src/features/chat/chatLayout.test.ts b/src/features/chat/chatLayout.test.ts index 664fab0..1238ad9 100644 --- a/src/features/chat/chatLayout.test.ts +++ b/src/features/chat/chatLayout.test.ts @@ -38,9 +38,9 @@ describe("chat timeline layout", () => { }); it("uses the latest thread and composer geometry", () => { - expect(css).toMatch(/--chat-thread-width:\s*960px;/); + expect(css).toMatch(/--chat-thread-width:\s*800px;/); expect(css).toMatch( - /\.chat-message-user\s*\{[^}]*max-width:\s*min\(720px, 86%\);[^}]*border:\s*0;[^}]*border-radius:\s*8px;/s, + /\.chat-message-user\s*\{[^}]*max-width:\s*min\(680px, 86%\);[^}]*border:\s*0;[^}]*border-radius:\s*8px;/s, ); expect(css).toMatch( /\.chat-thinking,\s*\.chat-process-group\s*\{[^}]*width:\s*min\(800px, 100%\);/s, diff --git a/src/features/editor/EditorPane.test.tsx b/src/features/editor/EditorPane.test.tsx index 00d6556..cd9054d 100644 --- a/src/features/editor/EditorPane.test.tsx +++ b/src/features/editor/EditorPane.test.tsx @@ -4,6 +4,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { commands } from "../../bindings"; @@ -799,10 +800,11 @@ describe("Chat history popover", () => { await user.click(screen.getByRole("button", { name: "New chat" })); expect(await screen.findByText("chat body untitled")).toBeInTheDocument(); - expect(screen.getByText("New chat").closest(".editor-tab")).not.toBeNull(); + const tabs = within(screen.getByRole("tablist")); + expect(tabs.getByText("New chat").closest(".editor-tab")).not.toBeNull(); act(() => setLanguage("zh-CN")); - expect(screen.getByText("新建聊天").closest(".editor-tab")).not.toBeNull(); + expect(tabs.getByText("新建聊天").closest(".editor-tab")).not.toBeNull(); }); it("opens a selected session and closes history", async () => { diff --git a/src/features/editor/EditorPane.tsx b/src/features/editor/EditorPane.tsx index 68c5a7d..412c5a9 100644 --- a/src/features/editor/EditorPane.tsx +++ b/src/features/editor/EditorPane.tsx @@ -22,6 +22,7 @@ import { type FileContent, type GitDiff, } from "../../bindings"; +import { Tooltip } from "../../components/Tooltip"; import { useTranslation } from "../../i18n"; import { commandError } from "../../lib/errors"; import { ResourceCache } from "../../lib/resourceCache"; @@ -756,30 +757,33 @@ export function EditorPane({ worktreeId }: { worktreeId: string }) { })}
- - - + + + + + + + + +
diff --git a/src/features/settings/SettingsDialog.test.tsx b/src/features/settings/SettingsDialog.test.tsx index 63bdcee..53135a0 100644 --- a/src/features/settings/SettingsDialog.test.tsx +++ b/src/features/settings/SettingsDialog.test.tsx @@ -94,6 +94,23 @@ beforeEach(() => { }); describe("SettingsDialog", () => { + it("offers system, light, and dark appearance modes", () => { + render( + undefined} + />, + ); + + const theme = screen.getByRole("combobox", { name: "Theme" }); + expect( + Array.from((theme as HTMLSelectElement).options).map((option) => + option.textContent?.trim(), + ), + ).toEqual(["System", "Light", "Dark"]); + }); + it("shows only resolved user resources as a read-only list", async () => { render( undefined} />); diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsDialog.tsx index 9377fa6..91f1f80 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsDialog.tsx @@ -24,7 +24,7 @@ import { } from "../../bindings"; import { commandError } from "../../lib/errors"; import { useDialogFocus } from "../../lib/useDialogFocus"; -import { useThemeStore } from "../theme/themeStore"; +import { useThemeStore, type ThemeMode } from "../theme/themeStore"; import { useSettingsStore } from "./settingsStore"; import { MemorySettings } from "./MemorySettings"; import { settingsApi } from "./settingsApi"; @@ -181,8 +181,9 @@ function GeneralSettings() { diff --git a/src/features/theme/ThemeToggle.test.tsx b/src/features/theme/ThemeToggle.test.tsx index 492e4bc..0df4fe9 100644 --- a/src/features/theme/ThemeToggle.test.tsx +++ b/src/features/theme/ThemeToggle.test.tsx @@ -29,6 +29,22 @@ describe("ThemeToggle", () => { expect(screen.queryByRole("button", { name: /system/i })).toBeNull(); }); + it("uses the resolved system appearance without exposing a third mode", () => { + useThemeStore.setState({ mode: "system", resolved: "dark" }); + render(); + + const button = screen.getByRole("button", { + name: "Theme: dark. Switch to light", + }); + expect(screen.queryByRole("button", { name: /system/i })).toBeNull(); + + fireEvent.click(button); + expect(useThemeStore.getState()).toMatchObject({ + mode: "light", + resolved: "light", + }); + }); + it("rerenders its accessible label when the language changes", () => { render(); diff --git a/src/features/theme/ThemeToggle.tsx b/src/features/theme/ThemeToggle.tsx index c9bf360..22bf430 100644 --- a/src/features/theme/ThemeToggle.tsx +++ b/src/features/theme/ThemeToggle.tsx @@ -1,20 +1,23 @@ import { RiMoonLine, RiSunLine } from "@remixicon/react"; +import { Tooltip } from "../../components/Tooltip"; import { useTranslation } from "../../i18n"; import { useThemeStore } from "./themeStore"; export function ThemeToggle() { const { t } = useTranslation(); - const mode = useThemeStore((state) => state.mode); + const resolved = useThemeStore((state) => state.resolved); const cycle = useThemeStore((state) => state.cycle); - const Icon = mode === "light" ? RiSunLine : RiMoonLine; - const nextMode = mode === "light" ? "dark" : "light"; + const Icon = resolved === "light" ? RiSunLine : RiMoonLine; + const nextMode = resolved === "light" ? "dark" : "light"; const label = t("theme.toggle", { - mode: t(`theme.${mode}`), + mode: t(`theme.${resolved}`), nextMode: t(`theme.${nextMode}`), }); return ( - + + + ); } diff --git a/src/features/theme/themeStore.test.ts b/src/features/theme/themeStore.test.ts index 8a66d2b..6832d33 100644 --- a/src/features/theme/themeStore.test.ts +++ b/src/features/theme/themeStore.test.ts @@ -17,16 +17,23 @@ const media = (dark: boolean) => { describe("theme controller", () => { beforeEach(() => localStorage.clear()); - it("starts from and persists the current system appearance", () => { + it("starts in system mode and follows the current system appearance", () => { + const query = media(true); const controller = createThemeController( - media(true) as unknown as MediaQueryList, + query as unknown as MediaQueryList, ); expect(controller.getState()).toMatchObject({ - mode: "dark", + mode: "system", resolved: "dark", }); - expect(localStorage.getItem("spirecode.appearance.v1")).toBe("dark"); + expect(localStorage.getItem("spirecode.appearance.v1")).toBe("system"); + + query.emit(false); + expect(controller.getState()).toMatchObject({ + mode: "system", + resolved: "light", + }); }); it("migrates the legacy appearance setting", () => { @@ -48,7 +55,7 @@ describe("theme controller", () => { it.each([ ["current", "spirecode.appearance.v1"], ["legacy", ["pi", "app.appearance.v1"].join("-")], - ])("resolves a %s system setting to an explicit theme", (_source, key) => { + ])("preserves a %s system setting", (_source, key) => { const legacyKey = ["pi", "app.appearance.v1"].join("-"); localStorage.setItem(key, "system"); @@ -57,14 +64,14 @@ describe("theme controller", () => { ); expect(controller.getState()).toMatchObject({ - mode: "dark", + mode: "system", resolved: "dark", }); - expect(localStorage.getItem("spirecode.appearance.v1")).toBe("dark"); + expect(localStorage.getItem("spirecode.appearance.v1")).toBe("system"); expect(localStorage.getItem(legacyKey)).toBeNull(); }); - it("cycles only between light and dark", () => { + it("cycles only between explicit light and dark modes", () => { localStorage.setItem("spirecode.appearance.v1", "light"); const query = media(false); const controller = createThemeController( diff --git a/src/features/theme/themeStore.ts b/src/features/theme/themeStore.ts index ee6377b..e53fbee 100644 --- a/src/features/theme/themeStore.ts +++ b/src/features/theme/themeStore.ts @@ -1,7 +1,7 @@ import { create, type StoreApi, type UseBoundStore } from "zustand"; -export type ThemeMode = "light" | "dark"; -export type ResolvedTheme = ThemeMode; +export type ThemeMode = "system" | "light" | "dark"; +export type ResolvedTheme = Exclude; interface ThemeState { mode: ThemeMode; @@ -12,28 +12,26 @@ interface ThemeState { const STORAGE_KEY = "spirecode.appearance.v1"; const LEGACY_STORAGE_KEY = "pi-app.appearance.v1"; -const modes: ThemeMode[] = ["light", "dark"]; +const modes: ThemeMode[] = ["system", "light", "dark"]; const isThemeMode = (value: string | null): value is ThemeMode => modes.includes(value as ThemeMode); -const storedMode = (systemDark: boolean): ThemeMode => { - const systemMode: ThemeMode = systemDark ? "dark" : "light"; +const resolveMode = (mode: ThemeMode, systemDark: boolean): ResolvedTheme => + mode === "system" ? (systemDark ? "dark" : "light") : mode; + +const storedMode = (): ThemeMode => { try { const current = localStorage.getItem(STORAGE_KEY); if (isThemeMode(current)) return current; - if (current === "system") { - localStorage.setItem(STORAGE_KEY, systemMode); - return systemMode; - } const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); - const mode = isThemeMode(legacy) ? legacy : systemMode; + const mode = isThemeMode(legacy) ? legacy : "system"; localStorage.setItem(STORAGE_KEY, mode); localStorage.removeItem(LEGACY_STORAGE_KEY); return mode; } catch { - return systemMode; + return "system"; } }; @@ -48,18 +46,24 @@ const persist = (mode: ThemeMode) => { export function createThemeController( query: MediaQueryList, ): UseBoundStore> { - const initialMode = storedMode(query.matches); - return create((set, get) => ({ + const initialMode = storedMode(); + const controller = create((set, get) => ({ mode: initialMode, - resolved: initialMode, + resolved: resolveMode(initialMode, query.matches), setMode: (mode) => { persist(mode); - set({ mode, resolved: mode }); + set({ mode, resolved: resolveMode(mode, query.matches) }); }, cycle: () => { - get().setMode(get().mode === "light" ? "dark" : "light"); + get().setMode(get().resolved === "light" ? "dark" : "light"); }, })); + + query.addEventListener("change", (event) => { + if (controller.getState().mode !== "system") return; + controller.setState({ resolved: event.matches ? "dark" : "light" }); + }); + return controller; } const fallbackQuery: MediaQueryList = { @@ -76,11 +80,21 @@ const systemQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? fallbackQuery; export const useThemeStore = createThemeController(systemQuery); -const applyTheme = () => { - document.documentElement.dataset.theme = useThemeStore.getState().resolved; - document.documentElement.style.colorScheme = - useThemeStore.getState().resolved; +const applyTheme = (animate: boolean) => { + const root = document.documentElement; + if (animate) root.classList.add("theme-transition-disabled"); + root.dataset.theme = useThemeStore.getState().resolved; + root.style.colorScheme = useThemeStore.getState().resolved; + if (animate) { + requestAnimationFrame(() => + requestAnimationFrame(() => + root.classList.remove("theme-transition-disabled"), + ), + ); + } }; -applyTheme(); -useThemeStore.subscribe(applyTheme); +applyTheme(false); +useThemeStore.subscribe((state, previous) => { + if (state.resolved !== previous.resolved) applyTheme(true); +}); diff --git a/src/features/workbench/Workbench.test.tsx b/src/features/workbench/Workbench.test.tsx index e3fd615..5eec925 100644 --- a/src/features/workbench/Workbench.test.tsx +++ b/src/features/workbench/Workbench.test.tsx @@ -253,8 +253,17 @@ describe("Workbench panel handles", () => { const files = screen.getByRole("button", { name: "FILES" }); const changes = screen.getByRole("button", { name: "CHANGES" }); - expect(files).toHaveAttribute("title", "FILES"); - expect(changes).toHaveAttribute("title", "CHANGES"); + fireEvent.focus(files); + expect(screen.getByRole("tooltip", { name: "FILES" })).toHaveAttribute( + "id", + files.getAttribute("aria-describedby"), + ); + fireEvent.blur(files, { relatedTarget: changes }); + fireEvent.focus(changes); + expect(screen.getByRole("tooltip", { name: "CHANGES" })).toHaveAttribute( + "id", + changes.getAttribute("aria-describedby"), + ); expect(files).toHaveTextContent(""); expect(changes).toHaveTextContent(""); expect(screen.getByText("Files")).toBeInTheDocument(); diff --git a/src/features/workbench/Workbench.tsx b/src/features/workbench/Workbench.tsx index f6b2719..1df36bf 100644 --- a/src/features/workbench/Workbench.tsx +++ b/src/features/workbench/Workbench.tsx @@ -7,6 +7,7 @@ import { RiSettings3Line, } from "@remixicon/react"; import { SpireCodeMark } from "../../components/SpireCodeMark"; +import { Tooltip } from "../../components/Tooltip"; import { useTranslation } from "../../i18n"; import { ChangesPanel } from "../changes/ChangesPanel"; import { refreshChanges } from "../changes/changesRefresh"; @@ -181,30 +182,33 @@ export function Workbench() {
- - - + + + + + + + + +
@@ -228,23 +232,25 @@ export function Workbench() { )}