From d31372ef1230bebe6d2cb976c37b218aefc26995 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 16 Aug 2026 17:01:17 +0200 Subject: [PATCH] fix(app): distinguish selected sidebar rows Give selected rows their own semantic surface so selection remains visible through hover while badges, status rings, and action scrims inherit the correct backdrop. --- .../src/components/sidebar-workspace-list.tsx | 4 +-- .../sidebar/project-leading-visual.tsx | 3 +++ .../sidebar/sidebar-row-backdrop.test.ts | 14 +++++++++++ .../sidebar/sidebar-row-backdrop.ts | 3 ++- .../sidebar/sidebar-status-list.tsx | 4 +-- .../sidebar/sidebar-workspace-row-content.tsx | 1 + .../sidebar/sidebar-workspace-row.tsx | 4 +-- .../app/src/components/status-ring/frame.tsx | 3 +++ packages/app/src/styles/surface-backdrop.ts | 5 ++-- packages/app/src/styles/theme.test.ts | 25 ++++++++++++++++--- packages/app/src/styles/theme.ts | 24 ++++++++---------- 11 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-row-backdrop.test.ts diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 68de9d73d..7c9c14d0f 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -388,8 +388,8 @@ function getProjectWorkspaceRowStyle({ }) { return [ styles.workspaceRow, - selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, + selected && styles.sidebarRowSelected, isDragging && styles.workspaceRowDragging, isPressed && styles.workspaceRowPressed, ]; @@ -2751,7 +2751,7 @@ const styles = StyleSheet.create((theme) => ({ ...theme.shadow.md, }, sidebarRowSelected: { - backgroundColor: theme.colors.surfaceSidebarHover, + backgroundColor: theme.colors.surfaceSidebarSelected, }, workspaceRowContainer: { position: "relative", diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx index 68dbb19af..c3950efe2 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -207,6 +207,8 @@ function getStatusBadgeBackdropStyle(backdrop: SidebarSurfaceBackdrop): ViewStyl return styles.statusBadgeOnSidebar; case "surfaceSidebarHover": return styles.statusBadgeOnSidebarHover; + case "surfaceSidebarSelected": + return styles.statusBadgeOnSidebarSelected; case "surface2": return styles.statusBadgeOnSurface2; } @@ -309,6 +311,7 @@ const styles = StyleSheet.create((theme) => { }, statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar }, statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover }, + statusBadgeOnSidebarSelected: { backgroundColor: theme.colors.surfaceSidebarSelected }, statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 }, statusDotRunning: statusDot("running"), statusDotFailed: statusDot("failed"), diff --git a/packages/app/src/components/sidebar/sidebar-row-backdrop.test.ts b/packages/app/src/components/sidebar/sidebar-row-backdrop.test.ts new file mode 100644 index 000000000..c255d3fe5 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-row-backdrop.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getSidebarRowBackdrop } from "./sidebar-row-backdrop"; + +describe("getSidebarRowBackdrop", () => { + it("keeps selected rows on the selected surface while hovered", () => { + expect(getSidebarRowBackdrop({ selected: true, isHovered: true })).toBe( + "surfaceSidebarSelected", + ); + }); + + it("uses the hover surface for an unselected hovered row", () => { + expect(getSidebarRowBackdrop({ isHovered: true })).toBe("surfaceSidebarHover"); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-row-backdrop.ts b/packages/app/src/components/sidebar/sidebar-row-backdrop.ts index 1b56d9bd5..7c8d24211 100644 --- a/packages/app/src/components/sidebar/sidebar-row-backdrop.ts +++ b/packages/app/src/components/sidebar/sidebar-row-backdrop.ts @@ -22,6 +22,7 @@ export function getSidebarRowBackdrop({ isHovered?: boolean; }): SidebarSurfaceBackdrop { if (isDragging || isPressed) return "surface2"; - if (selected || isHovered) return "surfaceSidebarHover"; + if (selected) return "surfaceSidebarSelected"; + if (isHovered) return "surfaceSidebarHover"; return "surfaceSidebar"; } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index a1ca1324e..15c912902 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -1025,8 +1025,8 @@ function getStatusWorkspaceRowStyle({ return [ styles.workspaceRow, inStatusGroup && sidebarWorkspaceRowStyles.rowIndented, - selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, + selected && styles.sidebarRowSelected, isDragging && styles.workspaceRowDragging, isPressed && styles.workspaceRowPressed, ]; @@ -1131,6 +1131,6 @@ const styles = StyleSheet.create((theme) => ({ ...theme.shadow.md, }, sidebarRowSelected: { - backgroundColor: theme.colors.surfaceSidebarHover, + backgroundColor: theme.colors.surfaceSidebarSelected, }, })); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index a73609b3e..9363131c3 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -73,6 +73,7 @@ const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg); const scrimColorMappings: Record { color: string }> = { surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }), surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }), + surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }), surface2: (theme) => ({ color: theme.colors.surface2 }), }; diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 87c28cf64..192c6f64d 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -494,8 +494,8 @@ function getWorkspaceRowStyle({ }) { return [ styles.workspaceRow, - selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, + selected && styles.sidebarRowSelected, isDragging && styles.workspaceRowDragging, isPressed && styles.workspaceRowPressed, ]; @@ -535,7 +535,7 @@ const styles = StyleSheet.create((theme) => ({ ...theme.shadow.md, }, sidebarRowSelected: { - backgroundColor: theme.colors.surfaceSidebarHover, + backgroundColor: theme.colors.surfaceSidebarSelected, }, workspaceCreatingText: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/components/status-ring/frame.tsx b/packages/app/src/components/status-ring/frame.tsx index 490790d56..9f85a415c 100644 --- a/packages/app/src/components/status-ring/frame.tsx +++ b/packages/app/src/components/status-ring/frame.tsx @@ -50,6 +50,8 @@ function getBackdropStyle(backdrop: SurfaceBackdrop | null | undefined) { return styles.backdropSurfaceSidebar; case "surfaceSidebarHover": return styles.backdropSurfaceSidebarHover; + case "surfaceSidebarSelected": + return styles.backdropSurfaceSidebarSelected; case "surface2": return styles.backdropSurface2; default: @@ -91,6 +93,7 @@ export const styles = StyleSheet.create((theme) => { backdropSurface1: { backgroundColor: theme.colors.surface1 }, backdropSurfaceSidebar: { backgroundColor: theme.colors.surfaceSidebar }, backdropSurfaceSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover }, + backdropSurfaceSidebarSelected: { backgroundColor: theme.colors.surfaceSidebarSelected }, backdropSurface2: { backgroundColor: theme.colors.surface2 }, // The closed ring the quarter runs on. Same colour rather than a grey so the indicator is one diff --git a/packages/app/src/styles/surface-backdrop.ts b/packages/app/src/styles/surface-backdrop.ts index 8b7bd4326..e069b0e59 100644 --- a/packages/app/src/styles/surface-backdrop.ts +++ b/packages/app/src/styles/surface-backdrop.ts @@ -16,9 +16,10 @@ export type SurfaceBackdrop = | "surface1" | "surface2" | "surfaceSidebar" - | "surfaceSidebarHover"; + | "surfaceSidebarHover" + | "surfaceSidebarSelected"; export type SidebarSurfaceBackdrop = Extract< SurfaceBackdrop, - "surfaceSidebar" | "surfaceSidebarHover" | "surface2" + "surfaceSidebar" | "surfaceSidebarHover" | "surfaceSidebarSelected" | "surface2" >; diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index ea2164192..fed915b76 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { darkPureBlackTheme, getNextThemePreference, THEME_OPTIONS } from "./theme"; +import { + darkPureBlackTheme, + darkTheme, + getNextThemePreference, + lightTheme, + THEME_OPTIONS, +} from "./theme"; describe("Theme catalog", () => { it("owns the picker and shortcut order", () => { @@ -30,9 +36,12 @@ describe("Pure black theme", () => { expect(darkPureBlackTheme.colors.accentBright).toBe("#7ccba0"); }); - it("keeps selected sidebar rows distinct from the black sidebar", () => { + it("derives sidebar interaction surfaces from the surface scale", () => { expect(darkPureBlackTheme.colors.surfaceSidebar).toBe("#000000"); - expect(darkPureBlackTheme.colors.surfaceSidebarHover).toBe("#161616"); + expect(darkPureBlackTheme.colors.surfaceSidebarHover).toBe(darkPureBlackTheme.colors.surface1); + expect(darkPureBlackTheme.colors.surfaceSidebarSelected).toBe( + darkPureBlackTheme.colors.surface2, + ); }); it("keeps ANSI black output readable on its zero-luminance terminal background", () => { @@ -40,3 +49,13 @@ describe("Pure black theme", () => { expect(darkPureBlackTheme.colors.terminal.brightBlack).toBe("#8a8a8a"); }); }); + +describe("Sidebar interaction surfaces", () => { + it.each([lightTheme, darkTheme])( + "derives hover and selection from the surface scale", + (theme) => { + expect(theme.colors.surfaceSidebarHover).toBe(theme.colors.surface1); + expect(theme.colors.surfaceSidebarSelected).toBe(theme.colors.surface2); + }, + ); +}); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 851a34086..c8a8305d0 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -204,17 +204,22 @@ const darkStatusDotColors = { }; // Semantic color tokens - Layer-based system -const lightSemanticColors = { +const lightSurfaces = { // Surfaces (layers) - shifted one step lighter surface0: "#ffffff", // App background surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50) surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100) surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200) surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300) +} as const; + +const lightSemanticColors = { + ...lightSurfaces, surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2 - surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main) - surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode) - surfaceWorkspace: "#ffffff", // Workspace main background + surfaceSidebar: lightSurfaces.surface2, // Sidebar background (darker than main) + surfaceSidebarHover: lightSurfaces.surface1, + surfaceSidebarSelected: lightSurfaces.surface2, + surfaceWorkspace: lightSurfaces.surface0, // Workspace main background // Text foreground: "#1a1a1e", @@ -294,7 +299,6 @@ interface DarkThemeConfig { surface4: string; surfaceDiffEmpty: string; surfaceSidebar: string; - surfaceSidebarHover: string; foregroundMuted: string; foregroundExtraMuted: string; border: string; @@ -333,7 +337,8 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) { surface4: tint.surface4, surfaceDiffEmpty: tint.surfaceDiffEmpty, surfaceSidebar: tint.surfaceSidebar, - surfaceSidebarHover: tint.surfaceSidebarHover, + surfaceSidebarHover: tint.surface1, + surfaceSidebarSelected: tint.surface2, surfaceWorkspace: tint.surface1, foreground: "#fafafa", @@ -397,7 +402,6 @@ const paseoDarkColors = buildDarkSemanticColors({ surface4: "#595B5B", surfaceDiffEmpty: "#252827", surfaceSidebar: "#141716", - surfaceSidebarHover: "#1c1f1e", foregroundMuted: "#A1A5A4", foregroundExtraMuted: "#717574", border: "#252B2A", @@ -418,7 +422,6 @@ const zincDarkColors = buildDarkSemanticColors({ surface4: "#52525b", surfaceDiffEmpty: "#242427", surfaceSidebar: "#131316", - surfaceSidebarHover: "#1b1b1e", foregroundMuted: "#a1a1aa", foregroundExtraMuted: "#71717a", border: "#27272a", @@ -440,7 +443,6 @@ const midnightDarkColors = buildDarkSemanticColors({ surface4: "#535564", surfaceDiffEmpty: "#222430", surfaceSidebar: "#121420", - surfaceSidebarHover: "#1a1c28", foregroundMuted: "#9a9db0", foregroundExtraMuted: "#6b6e82", border: "#242636", @@ -461,7 +463,6 @@ const claudeDarkColors = buildDarkSemanticColors({ surface4: "#605d5b", surfaceDiffEmpty: "#2a2826", surfaceSidebar: "#1a1918", - surfaceSidebarHover: "#222120", foregroundMuted: "#ada9a5", foregroundExtraMuted: "#78746f", border: "#2c2a27", @@ -482,7 +483,6 @@ const ghosttyDarkColors = buildDarkSemanticColors({ surface4: "#5b6175", surfaceDiffEmpty: "#323643", surfaceSidebar: "#21252d", - surfaceSidebarHover: "#292d36", foregroundMuted: "#c8ccd8", foregroundExtraMuted: "#a0a4b2", border: "#353a47", @@ -656,8 +656,6 @@ const pureBlackDarkColors = buildDarkSemanticColors({ surface4: "#2d2d2d", surfaceDiffEmpty: "#0c0c0c", surfaceSidebar: "#000000", - // Selected sidebar rows share this surface with hover, so it must remain visible at rest. - surfaceSidebarHover: "#161616", foregroundMuted: "#a1a1aa", foregroundExtraMuted: "#71717a", border: "#1c1c1c",