diff --git a/apps/backend/internal/backendapp/app_status_bar_visibility_test.go b/apps/backend/internal/backendapp/app_status_bar_visibility_test.go index 46ef14b206..81d125d949 100644 --- a/apps/backend/internal/backendapp/app_status_bar_visibility_test.go +++ b/apps/backend/internal/backendapp/app_status_bar_visibility_test.go @@ -32,3 +32,14 @@ func TestMapUserSettingsStateIncludesResolveSessionHostnames(t *testing.T) { t.Fatalf("resolveSessionHostnames = %#v, want true", state["resolveSessionHostnames"]) } } + +func TestMapUserSettingsStateIncludesSidebarHoverSettings(t *testing.T) { + var response userdto.UserSettingsResponse + if err := json.Unmarshal([]byte(`{"settings":{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}}`), &response); err != nil { + t.Fatal(err) + } + state := mapUserSettingsState(response, "workspace-1") + if state["sidebarHoverEnabled"] != false || state["sidebarHoverDelayMs"] != 0 { + t.Fatalf("hover boot fields: %v, %v", state["sidebarHoverEnabled"], state["sidebarHoverDelayMs"]) + } +} diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 36374b27c4..e2b0bb0393 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -733,6 +733,8 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "simplified": settings.SystemMetricsDisplay.Simplified, }, "appStatusBarEnabled": settings.AppStatusBarEnabled, + "sidebarHoverEnabled": settings.SidebarHoverEnabled, + "sidebarHoverDelayMs": settings.SidebarHoverDelayMs, "resolveSessionHostnames": settings.ResolveSessionHostnames, "appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder), "quickChatTabOrderByWorkspace": settings.QuickChatTabOrderByWorkspace, diff --git a/apps/backend/internal/settingscatalog/defaults.go b/apps/backend/internal/settingscatalog/defaults.go index dde311b43b..287c1b806d 100644 --- a/apps/backend/internal/settingscatalog/defaults.go +++ b/apps/backend/internal/settingscatalog/defaults.go @@ -646,6 +646,7 @@ func userSettingsDomain() DomainDescriptor { "default_utility_agent_id", "default utility agent", "string", "default_utility_model", "default utility model", "string", "default_utility_agent_profile_id", "default utility profile", "string", "keyboard_shortcuts", "keyboard shortcuts", "object", "terminal_link_behavior", "terminal link behavior", "string", "terminal_font_family", "terminal font family", "string", "terminal_font_size", "terminal font size", "integer", "changes_panel_layout", "changes panel layout", "string", "last_seen_display", "last seen display", "string", + "sidebar_hover_enabled", "sidebar hover enabled", "boolean", "sidebar_hover_delay_ms", "sidebar hover delay", "integer", "system_metrics_display", "system metrics display", "object", "app_status_bar_enabled", "status bar", "boolean", "resolve_session_hostnames", "resolve hostnames", "boolean", "app_status_bar_order", "status bar order", "object", "quick_chat_tab_order_by_workspace", "quick chat tab order", "object", "kanban_hidden_step_ids", "hidden kanban steps", "object", "workflow_ids_with_auto_hide_empty_steps", "auto-hide workflows", "array", "kanban_sort", "kanban board sort", "string", "kanban_priority_filter_tokens", "kanban priority filter", "array", diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 9610832cad..b4363498ce 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -144,6 +144,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS LastSeenDisplay: req.LastSeenDisplay, SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay), AppStatusBarEnabled: req.AppStatusBarEnabled, + SidebarHoverEnabled: req.SidebarHoverEnabled, + SidebarHoverDelayMs: req.SidebarHoverDelayMs, ResolveSessionHostnames: req.ResolveSessionHostnames, AppStatusBarOrder: req.AppStatusBarOrder, QuickChatTabOrderByWorkspace: req.QuickChatTabOrderByWorkspace, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index f408f9f2b1..cd4e948950 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -76,6 +76,8 @@ type UserSettingsDTO struct { LastSeenDisplay string `json:"last_seen_display"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled bool `json:"app_status_bar_enabled"` + SidebarHoverEnabled bool `json:"sidebar_hover_enabled"` + SidebarHoverDelayMs int `json:"sidebar_hover_delay_ms"` ResolveSessionHostnames bool `json:"resolve_session_hostnames"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` @@ -188,6 +190,8 @@ type UpdateUserSettingsRequest struct { LastSeenDisplay *string `json:"last_seen_display,omitempty"` SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"` AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"` + SidebarHoverEnabled *bool `json:"sidebar_hover_enabled,omitempty"` + SidebarHoverDelayMs *int `json:"sidebar_hover_delay_ms,omitempty"` ResolveSessionHostnames *bool `json:"resolve_session_hostnames,omitempty"` AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` QuickChatTabOrderByWorkspace *map[string][]string `json:"quick_chat_tab_order_by_workspace,omitempty"` @@ -384,6 +388,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { LastSeenDisplay: models.NormalizeLastSeenDisplay(settings.LastSeenDisplay), SystemMetricsDisplay: settings.SystemMetricsDisplay, AppStatusBarEnabled: settings.AppStatusBarEnabled, + SidebarHoverEnabled: settings.SidebarHoverEnabled, + SidebarHoverDelayMs: settings.SidebarHoverDelayMs, ResolveSessionHostnames: settings.ResolveSessionHostnames, AppStatusBarOrder: settings.AppStatusBarOrder, QuickChatTabOrderByWorkspace: settings.QuickChatTabOrderByWorkspace, diff --git a/apps/backend/internal/user/handlers/sidebar_hover_settings_test.go b/apps/backend/internal/user/handlers/sidebar_hover_settings_test.go new file mode 100644 index 0000000000..6cfd02d415 --- /dev/null +++ b/apps/backend/internal/user/handlers/sidebar_hover_settings_test.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "net/http" + "testing" +) + +// @covers AC-UI-SIDEBAR-HOVER-002.1, AC-UI-SIDEBAR-HOVER-002.2, AC-UI-SIDEBAR-HOVER-002.3 +func TestSidebarHoverSettingsRoundTrip(t *testing.T) { + router := newTestUserSettingsRouter(t) + read := func() map[string]any { + return readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodGet, "")) + } + defaults := read() + if defaults["sidebar_hover_enabled"] != true || defaults["sidebar_hover_delay_ms"] != float64(500) { + t.Fatalf("hover defaults = %v, %v", defaults["sidebar_hover_enabled"], defaults["sidebar_hover_delay_ms"]) + } + for _, body := range []string{`{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}`, `{"app_status_bar_enabled":true}`} { + readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, body)) + } + got := read() + if got["sidebar_hover_enabled"] != false || got["sidebar_hover_delay_ms"] != float64(0) { + t.Fatalf("hover round trip = %v, %v", got["sidebar_hover_enabled"], got["sidebar_hover_delay_ms"]) + } + readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, `{"sidebar_hover_delay_ms":5000}`)) + got = read() + if got["sidebar_hover_enabled"] != false || got["sidebar_hover_delay_ms"] != float64(5000) { + t.Fatal("delay patch reset toggle or did not persist") + } +} + +func TestSidebarHoverSettingsRejectInvalidWrites(t *testing.T) { + for _, value := range []string{"-1", "5001", "1.5", `""`, `"500"`} { + t.Run(value, func(t *testing.T) { + router := newTestUserSettingsRouter(t) + before := threadSettingsRequest(t, router, http.MethodGet, "").Body.String() + response := threadSettingsRequest(t, router, http.MethodPatch, `{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":`+value+`}`) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d: %s", response.Code, response.Body.String()) + } + after := threadSettingsRequest(t, router, http.MethodGet, "").Body.String() + if before != after { + t.Fatal("invalid request changed saved settings") + } + }) + } +} diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 45f6be8cdc..c19eee997e 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -170,6 +170,8 @@ type UserSettings struct { LastSeenDisplay string `json:"last_seen_display"` // "absolute" | "relative" SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled bool `json:"app_status_bar_enabled"` + SidebarHoverEnabled bool `json:"sidebar_hover_enabled"` + SidebarHoverDelayMs int `json:"sidebar_hover_delay_ms"` ResolveSessionHostnames bool `json:"resolve_session_hostnames"` AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 7394f3387e..15dd930e8b 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -104,6 +104,8 @@ type UpdateUserSettingsRequest struct { LastSeenDisplay *string SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch AppStatusBarEnabled *bool + SidebarHoverEnabled *bool + SidebarHoverDelayMs *int ResolveSessionHostnames *bool AppStatusBarOrder *models.AppStatusBarOrder QuickChatTabOrderByWorkspace *map[string][]string @@ -348,6 +350,9 @@ func taskCreateLastUsedPatchEmpty(patch models.TaskCreateLastUsed) bool { // applyBasicSettings copies simple (non-validated) fields from req to settings. func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if err := applySidebarHoverSettings(settings, req); err != nil { + return err + } if err := applyWorkspaceAndTaskListPreferences(settings, req); err != nil { return err } @@ -1125,6 +1130,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay), "system_metrics_display": settings.SystemMetricsDisplay, "app_status_bar_enabled": settings.AppStatusBarEnabled, + "sidebar_hover_enabled": settings.SidebarHoverEnabled, + "sidebar_hover_delay_ms": settings.SidebarHoverDelayMs, "resolve_session_hostnames": settings.ResolveSessionHostnames, "app_status_bar_order": settings.AppStatusBarOrder, "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, diff --git a/apps/backend/internal/user/service/sidebar_hover_settings.go b/apps/backend/internal/user/service/sidebar_hover_settings.go new file mode 100644 index 0000000000..b242910f1e --- /dev/null +++ b/apps/backend/internal/user/service/sidebar_hover_settings.go @@ -0,0 +1,19 @@ +package service + +import ( + "fmt" + "github.com/kandev/kandev/internal/user/models" +) + +func applySidebarHoverSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if req.SidebarHoverDelayMs != nil && (*req.SidebarHoverDelayMs < 0 || *req.SidebarHoverDelayMs > 5000) { + return fmt.Errorf("sidebar_hover_delay_ms must be between 0 and 5000") + } + if req.SidebarHoverEnabled != nil { + settings.SidebarHoverEnabled = *req.SidebarHoverEnabled + } + if req.SidebarHoverDelayMs != nil { + settings.SidebarHoverDelayMs = *req.SidebarHoverDelayMs + } + return nil +} diff --git a/apps/backend/internal/user/service/sidebar_hover_settings_test.go b/apps/backend/internal/user/service/sidebar_hover_settings_test.go new file mode 100644 index 0000000000..f247fa2307 --- /dev/null +++ b/apps/backend/internal/user/service/sidebar_hover_settings_test.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/user/models" + "go.uber.org/zap" + "testing" +) + +func TestSidebarHoverSettingsPublishAndReject(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + bus := &recordingEventBus{} + repo := &recordingUserRepository{getSettings: &models.UserSettings{SidebarHoverEnabled: true, SidebarHoverDelayMs: 500}} + svc := NewService(repo, bus, log) + invalid := 5001 + if _, err = svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{SidebarHoverDelayMs: &invalid}); err == nil { + t.Fatal("accepted invalid delay") + } + if repo.preservingInput != nil || len(bus.publishedEvents) != 0 { + t.Fatal("invalid delay persisted or broadcast") + } + svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{SidebarHoverEnabled: false, SidebarHoverDelayMs: 0}) + data := bus.publishedEvents[0].Data.(map[string]interface{}) + if data["sidebar_hover_enabled"] != false || data["sidebar_hover_delay_ms"] != 0 { + t.Fatalf("hover event fields: %v, %v", data["sidebar_hover_enabled"], data["sidebar_hover_delay_ms"]) + } +} diff --git a/apps/backend/internal/user/store/sidebar_hover_settings_test.go b/apps/backend/internal/user/store/sidebar_hover_settings_test.go new file mode 100644 index 0000000000..746c2cdb04 --- /dev/null +++ b/apps/backend/internal/user/store/sidebar_hover_settings_test.go @@ -0,0 +1,41 @@ +package store + +import ( + "encoding/json" + "testing" +) + +func TestSidebarHoverStoredDefaults(t *testing.T) { + for _, tc := range []struct { + raw string + enabled bool + delay float64 + }{ + {`{}`, true, 500}, + {`{"sidebar_hover_delay_ms":"invalid"}`, true, 500}, + {`{"sidebar_hover_delay_ms":0.5}`, true, 500}, + {`{"sidebar_hover_delay_ms":null}`, true, 500}, + {`{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}`, false, 0}, + {`{"sidebar_hover_delay_ms":1200}`, true, 1200}, + {`{"sidebar_hover_delay_ms":-1}`, true, 500}, + {`{"sidebar_hover_delay_ms":5001}`, true, 500}, + } { + t.Run(tc.raw, func(t *testing.T) { + settings, err := scanUserSettings(settingsScanner{raw: tc.raw}, DefaultUserID) + if err != nil { + t.Fatal(err) + } + raw, err := marshalUserSettingsPayload(settings) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err = json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if got["sidebar_hover_enabled"] != tc.enabled || got["sidebar_hover_delay_ms"] != tc.delay { + t.Fatalf("hover stored = %v, %v", got["sidebar_hover_enabled"], got["sidebar_hover_delay_ms"]) + } + }) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 3c716a6026..765c0944a1 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -671,6 +671,8 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay), "system_metrics_display": settings.SystemMetricsDisplay, "app_status_bar_enabled": settings.AppStatusBarEnabled, + "sidebar_hover_enabled": settings.SidebarHoverEnabled, + "sidebar_hover_delay_ms": settings.SidebarHoverDelayMs, "resolve_session_hostnames": settings.ResolveSessionHostnames, "app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), "quick_chat_tab_order_by_workspace": quickChatTabOrderByWorkspace, @@ -760,6 +762,8 @@ func defaultUserSettings(userID string) *models.UserSettings { SidebarTaskColorAutomation: models.DefaultSidebarTaskColorAutomation(), SidebarTaskColors: map[string]*string{}, AppStatusBarEnabled: false, + SidebarHoverEnabled: true, + SidebarHoverDelayMs: 500, ResolveSessionHostnames: false, AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), QuickChatTabOrderByWorkspace: map[string][]string{}, @@ -855,6 +859,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin LastSeenDisplay json.RawMessage `json:"last_seen_display"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled *bool `json:"app_status_bar_enabled"` + SidebarHoverEnabled *bool `json:"sidebar_hover_enabled"` + SidebarHoverDelayMs json.RawMessage `json:"sidebar_hover_delay_ms"` ResolveSessionHostnames *bool `json:"resolve_session_hostnames"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` @@ -1018,6 +1024,13 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.TerminalFontFamily = payload.TerminalFontFamily settings.TerminalFontSize = payload.TerminalFontSize settings.SystemMetricsDisplay = payload.SystemMetricsDisplay + if payload.SidebarHoverEnabled != nil { + settings.SidebarHoverEnabled = *payload.SidebarHoverEnabled + } + var hoverDelay *int + if json.Unmarshal(payload.SidebarHoverDelayMs, &hoverDelay) == nil && hoverDelay != nil && *hoverDelay >= 0 && *hoverDelay <= 5000 { + settings.SidebarHoverDelayMs = *hoverDelay + } if payload.AppStatusBarEnabled != nil { settings.AppStatusBarEnabled = *payload.AppStatusBarEnabled } diff --git a/apps/web/components/app-sidebar/app-sidebar-header.test.tsx b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx index eb295fd449..d3137e19da 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx @@ -96,3 +96,17 @@ describe("AppSidebarHeader", () => { expect(setWorkspacePickerOpen).toHaveBeenCalledWith(false); }); }); + +it("offers permanent expansion while displaying the hover-revealed workspace picker", () => { + const toggle = vi.fn(); + render( + + + + + , + ); + expect(screen.getByTestId("workspace-picker")).toBeTruthy(); + screen.getByRole("button", { name: "Expand sidebar" }).click(); + expect(toggle).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx index 399f3ecbce..852b62c875 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -13,19 +13,27 @@ import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker"; type AppSidebarHeaderProps = { collapsed: boolean; + hoverRevealed?: boolean; onToggleCollapse: () => void; }; const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer"; -export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) { +export function AppSidebarHeader({ + collapsed, + hoverRevealed = false, + onToggleCollapse, +}: AppSidebarHeaderProps) { const { t } = useTranslation(); + const toggleLabel = t(hoverRevealed ? "sidebar:expandSidebar" : "sidebar:collapseSidebar"); + const ToggleIcon = hoverRevealed ? IconLayoutSidebarLeftExpand : IconLayoutSidebarLeftCollapse; const workspaces = useAppStore((s) => s.workspaces); const startupPage = useAppStore((s) => s.userSettings.startupPage); // The global WORKSPACE_PICKER shortcut opens this instance (and only this // one) through the store; the mobile sheet keeps its own local open state. const pickerOpen = useAppStore((s) => s.appSidebar.workspacePickerOpen); const setPickerOpen = useAppStore((s) => s.setWorkspacePickerOpen); + const pickerProps = hoverRevealed ? {} : { open: pickerOpen, onOpenChange: setPickerOpen }; const mode = useOfficeModeState(); const activeWorkspace = workspaces.items.find( (workspace) => workspace.id === workspaces.activeId, @@ -62,6 +70,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead variant="ghost" size="icon" className={COLLAPSE_BUTTON_CLASS} + data-sidebar-toggle onClick={onToggleCollapse} aria-label={t("sidebar:expandSidebar")} > @@ -99,20 +108,22 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead / - + {/* Hover already provides an anchor; the global picker action persistently expands the rail. */} + - + - {t("sidebar:collapseSidebar")} + {toggleLabel} ); diff --git a/apps/web/components/app-sidebar/app-sidebar.test.tsx b/apps/web/components/app-sidebar/app-sidebar.test.tsx index 16b5a79603..4d19e0349d 100644 --- a/apps/web/components/app-sidebar/app-sidebar.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { APP_SIDEBAR_EXPANDED_WIDTH } from "./app-sidebar-constants"; @@ -112,6 +112,7 @@ const storeState = { workspaces: { activeId: undefined as string | undefined, }, + userSettings: { sidebarHoverEnabled: true, sidebarHoverDelayMs: 500 }, appSidebar: { collapsed: false, sectionExpanded: { @@ -164,6 +165,8 @@ function renderSidebar() { return render(sidebar()); } +const SIDEBAR = "app-sidebar"; +const COLLAPSED_ATTRIBUTE = "data-collapsed"; const TASKS_SECTION = "tasks-section"; const INTEGRATIONS_SECTION = "integrations-section"; const OFFICE_WORK_SECTION = "office-navigation-section-work"; @@ -193,13 +196,13 @@ describe("AppSidebar", () => { it("renders the expanded nav inside a clipped animation layer", () => { renderSidebar(); - expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("false"); + expect(screen.getByTestId(SIDEBAR).getAttribute(COLLAPSED_ATTRIBUTE)).toBe("false"); expect(screen.getByTestId(TASKS_SECTION)).toBeTruthy(); expect(screen.getByTestId("projects-section")).toBeTruthy(); expect(screen.getByTestId("agents-section")).toBeTruthy(); expect(screen.queryByTestId("settings-section")).toBeNull(); expect(screen.getByTestId("app-sidebar-content").classList).toContain("overflow-hidden"); - expect(screen.getByTestId("app-sidebar").classList).not.toContain("overflow-hidden"); + expect(screen.getByTestId(SIDEBAR).classList).not.toContain("overflow-hidden"); }); it("renders office navigation without kanban-only sections in office mode", () => { @@ -250,8 +253,8 @@ describe("AppSidebar", () => { it("renders collapsed when store reports collapsed=true", () => { storeState.appSidebar.collapsed = true; renderSidebar(); - expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("true"); - expect(screen.getByTestId(TASKS_SECTION).getAttribute("data-collapsed")).toBe("true"); + expect(screen.getByTestId(SIDEBAR).getAttribute(COLLAPSED_ATTRIBUTE)).toBe("true"); + expect(screen.getByTestId(TASKS_SECTION).getAttribute(COLLAPSED_ATTRIBUTE)).toBe("true"); }); it("invokes toggleAppSidebar when the header collapse button is clicked", () => { @@ -352,3 +355,39 @@ describe("AppSidebar before the workspace resolves", () => { expect(screen.queryByTestId(OFFICE_WORK_SECTION)).toBeNull(); }); }); + +// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.2 +describe("sidebar hover reveal", () => { + beforeEach(() => { + resetSidebarState(); + vi.useFakeTimers(); + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: !query.includes("max-width"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + storeState.appSidebar.collapsed = true; + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("reveals at 500 ms without expanding the layout reservation or saved state", () => { + renderSidebar(); + const panel = screen.getByTestId(SIDEBAR); + fireEvent.pointerOver(panel, { pointerType: "mouse" }); + act(() => vi.advanceTimersByTime(499)); + expect(panel.style.width).toBe("56px"); + act(() => vi.advanceTimersByTime(1)); + expect(panel.style.width).toBe(`${APP_SIDEBAR_EXPANDED_WIDTH}px`); + expect(screen.getByTestId("app-sidebar-layout").style.width).toBe("56px"); + expect(panel.getAttribute(COLLAPSED_ATTRIBUTE)).toBe("true"); + expect(storeState.toggleAppSidebar).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index 22bed91df8..fcb134c1a8 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -8,6 +8,7 @@ import { useEnsureWorkspaceWorkflows } from "@/hooks/use-workflows"; import { useOfficeModeState } from "@/hooks/use-in-office"; import type { WorkspaceMode } from "@/components/workspace-scope-provider"; import { useOfficeWorkspaceData } from "@/hooks/use-office-workspace-data"; +import { useSidebarHoverReveal } from "@/hooks/domains/sidebar/use-sidebar-hover-reveal"; import { cn } from "@/lib/utils"; import { APP_SIDEBAR_COLLAPSED_WIDTH, @@ -180,6 +181,14 @@ export function AppSidebar() { const toggleCollapsed = useAppStore((s) => s.toggleAppSidebar); const setWidth = useAppStore((s) => s.setAppSidebarWidth); const pathname = usePathname(); + const enabled = useAppStore((s) => s.userSettings.sidebarHoverEnabled); + const delayMs = useAppStore((s) => s.userSettings.sidebarHoverDelayMs); + const hover = useSidebarHoverReveal({ collapsed, pathname, enabled, delayMs }); + const visuallyCollapsed = collapsed && !hover.revealed; + const handleToggleCollapse = () => { + hover.dismiss(); + toggleCollapsed(); + }; const mode = useOfficeModeState(); const [isResizing, setIsResizing] = useState(false); @@ -244,6 +253,9 @@ export function AppSidebar() { style={{ width: targetWidth }} > diff --git a/apps/web/components/settings/appearance-account-sections.tsx b/apps/web/components/settings/appearance-account-sections.tsx index 66492d4bf5..e7d9e6b6ce 100644 --- a/apps/web/components/settings/appearance-account-sections.tsx +++ b/apps/web/components/settings/appearance-account-sections.tsx @@ -1,13 +1,14 @@ "use client"; import { useTranslation } from "react-i18next"; -import { IconActivity, IconGitBranch } from "@tabler/icons-react"; +import { IconActivity, IconGitBranch, IconLayoutSidebar } from "@tabler/icons-react"; import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Separator } from "@kandev/ui/separator"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; import type { AppearanceState } from "./appearance-settings-state"; +import { SidebarHoverSettingsCard } from "./sidebar-hover-settings-card"; import { AppStatusBarSettingsCard } from "./app-status-bar-settings-card"; import { SettingsCard } from "./settings-card"; import { SettingsSection } from "./settings-section"; @@ -70,6 +71,13 @@ export function AppearanceAccountSections({ const { t } = useTranslation(); return ( <> + + } + title={t("settings:sidebarHoverTitle")} + > + + } diff --git a/apps/web/components/settings/appearance-settings-state.test.ts b/apps/web/components/settings/appearance-settings-state.test.ts index 21c6e3efb7..1889b7c99e 100644 --- a/apps/web/components/settings/appearance-settings-state.test.ts +++ b/apps/web/components/settings/appearance-settings-state.test.ts @@ -36,3 +36,28 @@ describe("Threads appearance draft", () => { }); }); }); + +// @covers AC-UI-SIDEBAR-HOVER-002.1, AC-UI-SIDEBAR-HOVER-002.2, AC-UI-SIDEBAR-HOVER-002.3 +it("saves hover false and zero independently and rebases clean fields", () => { + const saved = createAppearanceSavedState("dark", "flat", true, defaultState.userSettings); + expect(saved).toMatchObject({ sidebarHoverEnabled: true, sidebarHoverDelayMs: "500" }); + const draft = { ...saved, sidebarHoverEnabled: false, sidebarHoverDelayMs: "0" }; + expect(buildAppearanceUserSettingsPatch(draft, saved)).toEqual({ + sidebar_hover_enabled: false, + sidebar_hover_delay_ms: 0, + }); + expect(appearanceRevision(draft)).not.toBe(appearanceRevision(saved)); + const remote = { ...saved, sidebarHoverDelayMs: "1200", appStatusBarEnabled: true }; + expect(rebaseAppearanceDraft(draft, saved, remote)).toEqual({ + ...draft, + appStatusBarEnabled: true, + }); + expect(rebaseAppearanceDraft(saved, saved, remote)).toEqual(remote); +}); + +it.each(["", "-1", "5001", "0.5", "abc"])("rejects invalid delay draft %s", (value) => { + const saved = createAppearanceSavedState("dark", "flat", true, defaultState.userSettings); + expect(() => + buildAppearanceUserSettingsPatch({ ...saved, sidebarHoverDelayMs: value }, saved), + ).toThrow(); +}); diff --git a/apps/web/components/settings/appearance-settings-state.ts b/apps/web/components/settings/appearance-settings-state.ts index b7213bcf95..5a77fe3c0b 100644 --- a/apps/web/components/settings/appearance-settings-state.ts +++ b/apps/web/components/settings/appearance-settings-state.ts @@ -10,6 +10,8 @@ export type AppearanceState = { startupPage: UserSettingsState["startupPage"]; changesPanelLayout: UserSettingsState["changesPanelLayout"]; appStatusBarEnabled: boolean; + sidebarHoverEnabled: boolean; + sidebarHoverDelayMs: string; showMetrics: boolean; simplifiedMetrics: boolean; }; @@ -20,11 +22,18 @@ export function createAppearanceSavedState( richOutputAnimationsEnabled: boolean, userSettings: Pick< UserSettingsState, - "appStatusBarEnabled" | "changesPanelLayout" | "startupPage" | "systemMetricsDisplay" + | "appStatusBarEnabled" + | "changesPanelLayout" + | "startupPage" + | "systemMetricsDisplay" + | "sidebarHoverEnabled" + | "sidebarHoverDelayMs" >, ): AppearanceState { return { theme, + sidebarHoverEnabled: userSettings.sidebarHoverEnabled, + sidebarHoverDelayMs: String(userSettings.sidebarHoverDelayMs), appStatusBarEnabled: userSettings.appStatusBarEnabled, // Per-device, but drafted and saved with account settings under one control. settingsMenuMode, @@ -40,7 +49,16 @@ export function buildAppearanceUserSettingsPatch( submitted: AppearanceState, saved: AppearanceState, ): UserSettingsUpdatePayload { + const delay = parseSidebarHoverDelay(submitted.sidebarHoverDelayMs); + // i18n-exempt: unreachable invariant guard; the contributor presents localized validation. + if (delay === null) throw new Error("Invalid sidebar hover delay"); const patch: UserSettingsUpdatePayload = {}; + if (submitted.sidebarHoverEnabled !== saved.sidebarHoverEnabled) { + patch.sidebar_hover_enabled = submitted.sidebarHoverEnabled; + } + if (delay !== Number(saved.sidebarHoverDelayMs)) { + patch.sidebar_hover_delay_ms = delay; + } if (submitted.startupPage !== saved.startupPage) { patch.startup_page = submitted.startupPage; } @@ -75,6 +93,8 @@ export function rebaseAppearanceDraft( : nextSaved[field]; return { theme: rebase("theme"), + sidebarHoverEnabled: rebase("sidebarHoverEnabled"), + sidebarHoverDelayMs: rebase("sidebarHoverDelayMs"), settingsMenuMode: rebase("settingsMenuMode"), richOutputAnimationsEnabled: rebase("richOutputAnimationsEnabled"), startupPage: rebase("startupPage"), @@ -88,6 +108,8 @@ export function rebaseAppearanceDraft( export function appearanceRevision(state: AppearanceState): string { return JSON.stringify([ state.theme, + state.sidebarHoverEnabled, + state.sidebarHoverDelayMs, state.settingsMenuMode, state.richOutputAnimationsEnabled, state.startupPage, @@ -97,3 +119,9 @@ export function appearanceRevision(state: AppearanceState): string { state.simplifiedMetrics, ]); } + +export function parseSidebarHoverDelay(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const delay = Number(value); + return Number.isInteger(delay) && delay >= 0 && delay <= 5000 ? delay : null; +} diff --git a/apps/web/components/settings/general-settings.test.tsx b/apps/web/components/settings/general-settings.test.tsx index 6a192baf7f..3f18679b5c 100644 --- a/apps/web/components/settings/general-settings.test.tsx +++ b/apps/web/components/settings/general-settings.test.tsx @@ -397,3 +397,26 @@ describe("AppearanceSettings status bar preference", () => { verifyNewerSaveResponseWinsOverOlderLiveUpdate, ); }); + +it("validates hover drafts, preserves them after a failed save, and discards", async () => { + renderAppearance(); + const delay = screen.getByRole("spinbutton", { name: "Hover delay (ms)" }); + fireEvent.change(delay, { target: { value: "" } }); + expect( + (screen.getByRole("button", { name: SAVE_CHANGES_LABEL }) as HTMLButtonElement).disabled, + ).toBe(true); + fireEvent.change(delay, { target: { value: "750" } }); + apiMocks.updateUserSettings.mockRejectedValueOnce(new Error("Save failed")); + fireEvent.click(screen.getByRole("button", { name: SAVE_CHANGES_LABEL })); + await waitFor(() => + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ sidebar_hover_delay_ms: 750 }), + ); + await waitFor(() => + expect((screen.getByRole("button", { name: "Retry save" }) as HTMLButtonElement).disabled).toBe( + false, + ), + ); + expect((delay as HTMLInputElement).value).toBe("750"); + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + expect((delay as HTMLInputElement).value).toBe("500"); +}); diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 2957966a71..d09404b32e 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -50,6 +50,7 @@ import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; import { appearanceRevision, + parseSidebarHoverDelay, buildAppearanceUserSettingsPatch, createAppearanceSavedState, rebaseAppearanceDraft, @@ -299,10 +300,14 @@ function useAppearanceSaveContributor({ const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations); const commitRichOutputAnimations = useAppStore((state) => state.commitRichOutputAnimations); const restoreRichOutputAnimations = useAppStore((state) => state.restoreRichOutputAnimations); + const { t } = useTranslation(); + const delayValid = parseSidebarHoverDelay(draft.sidebarHoverDelayMs) !== null; const revision = appearanceRevision(draft); useSettingsSaveContributor({ id: "general-appearance", + canSave: delayValid, + invalidReason: delayValid ? undefined : t("settings:sidebarHoverDelayError"), order: 10, revision, isDirty: revision !== appearanceRevision(saved), diff --git a/apps/web/components/settings/sidebar-hover-settings-card.test.tsx b/apps/web/components/settings/sidebar-hover-settings-card.test.tsx new file mode 100644 index 0000000000..bdf212d8f6 --- /dev/null +++ b/apps/web/components/settings/sidebar-hover-settings-card.test.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { defaultState } from "@/lib/state/default-state"; +import { createAppearanceSavedState } from "./appearance-settings-state"; +import { SidebarHoverSettingsCard } from "./sidebar-hover-settings-card"; + +afterEach(cleanup); + +function Editor() { + const saved = createAppearanceSavedState("dark", "flat", true, defaultState.userSettings); + const [draft, setDraft] = useState(saved); + return ( + setDraft((current) => ({ ...current, ...patch }))} + /> + ); +} + +it("retains the last valid delay when disabling an invalid draft", () => { + render(); + const delay = screen.getByRole("spinbutton", { name: "Hover delay (ms)" }); + const toggle = screen.getByRole("switch", { name: "Show sidebar on hover" }); + fireEvent.change(delay, { target: { value: "1250" } }); + fireEvent.change(delay, { target: { value: "" } }); + expect(delay.getAttribute("aria-invalid")).toBe("true"); + fireEvent.click(toggle); + expect((delay as HTMLInputElement).disabled).toBe(true); + expect((delay as HTMLInputElement).value).toBe("1250"); + fireEvent.click(toggle); + expect((delay as HTMLInputElement).disabled).toBe(false); + expect((delay as HTMLInputElement).value).toBe("1250"); +}); diff --git a/apps/web/components/settings/sidebar-hover-settings-card.tsx b/apps/web/components/settings/sidebar-hover-settings-card.tsx new file mode 100644 index 0000000000..f3ce2c1828 --- /dev/null +++ b/apps/web/components/settings/sidebar-hover-settings-card.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Switch } from "@kandev/ui/switch"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; +import { parseSidebarHoverDelay, type AppearanceState } from "./appearance-settings-state"; +import { SettingsCard } from "./settings-card"; +import { settingsControlClassName } from "./settings-control"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; + +export function SidebarHoverSettingsCard({ + draft, + saved, + updateDraft, +}: { + draft: AppearanceState; + saved: AppearanceState; + updateDraft: (patch: Partial) => void; +}) { + const { t } = useTranslation(); + const lastValidDelay = useRef(saved.sidebarHoverDelayMs); + const delay = parseSidebarHoverDelay(draft.sidebarHoverDelayMs); + useEffect(() => { + if (delay !== null) lastValidDelay.current = String(delay); + }, [delay]); + const toggleTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.sidebarHover); + const delayTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.sidebarHoverDelay); + const enabledDirty = draft.sidebarHoverEnabled !== saved.sidebarHoverEnabled; + const delayDirty = draft.sidebarHoverDelayMs !== saved.sidebarHoverDelayMs; + return ( + + + + {t("settings:sidebarHoverEnabled")} + + updateDraft({ + sidebarHoverEnabled, + ...(!sidebarHoverEnabled && delay === null + ? { sidebarHoverDelayMs: lastValidDelay.current } + : {}), + }) + } + className="data-checked:bg-transparent data-unchecked:bg-transparent dark:data-unchecked:bg-transparent data-[size=default]:h-7 max-md:data-[size=default]:h-11 [@media(pointer:coarse)]:data-[size=default]:h-11 data-[size=default]:w-11 p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-checked:before:bg-primary dark:data-unchecked:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10" + /> + + + {t("settings:sidebarHoverDelay")} + { + const value = event.target.value; + if (parseSidebarHoverDelay(value) !== null) lastValidDelay.current = value; + updateDraft({ sidebarHoverDelayMs: value }); + }} + /> + {delay === null && ( + + {t("settings:sidebarHoverDelayError")} + + )} + + + {t("settings:sidebarHoverHelp")} + + + + ); +} diff --git a/apps/web/e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts b/apps/web/e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts new file mode 100644 index 0000000000..864becac2b --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +// @covers AC-UI-SIDEBAR-HOVER-001.5 +test("phone navigation stays available by tap with no hover rail", async ({ + testPage, + apiClient, + seedData, +}, testInfo) => { + const first = await apiClient.createTask(seedData.workspaceId, "Hover mobile origin", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const second = await apiClient.createTask(seedData.workspaceId, "Hover mobile destination", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${first.id}`); + await new SessionPage(testPage).waitForLoad(); + await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); + await testPage.getByTestId("mobile-session-menu").tap(); + const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + await expect(drawer).toBeVisible(); + await drawer.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .filter((animation) => Number.isFinite(animation.effect?.getComputedTiming().iterations)) + .map((animation) => animation.finished.catch(() => undefined)), + ); + }); + const bounds = await drawer.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(testPage.viewportSize()!.width); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(testPage.viewportSize()!.height); + await testPage.screenshot({ path: testInfo.outputPath("mobile-navigation.png") }); + await drawer.getByText("Hover mobile destination", { exact: true }).tap(); + await expect(testPage).toHaveURL(new RegExp(`/t/${second.id}`)); + await expect + .poll(() => + testPage.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ), + ) + .toBe(0); +}); diff --git a/apps/web/e2e/tests/layout/sidebar-hover-reveal.spec.ts b/apps/web/e2e/tests/layout/sidebar-hover-reveal.spec.ts new file mode 100644 index 0000000000..d90d53272f --- /dev/null +++ b/apps/web/e2e/tests/layout/sidebar-hover-reveal.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from "../../fixtures/test-base"; + +// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.2 +test("collapsed sidebar reveals after dwell without moving page content", async ({ + testPage, +}, testInfo) => { + await testPage.goto("/"); + const sidebar = testPage.getByTestId("app-sidebar"); + await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveCSS("width", "56px"); + const pageBefore = await testPage + .getByTestId("app-shell") + .locator(":scope > div > main") + .boundingBox(); + await sidebar.hover({ position: { x: 20, y: 200 } }); + await expect(sidebar).toHaveCSS("width", "320px"); + await expect(testPage.getByTestId("app-sidebar-layout")).toHaveCSS("width", "56px"); + await expect(sidebar).toHaveAttribute("data-collapsed", "true"); + const pageAfter = await testPage + .getByTestId("app-shell") + .locator(":scope > div > main") + .boundingBox(); + expect(pageAfter).toEqual(pageBefore); + await testPage.screenshot({ path: testInfo.outputPath("sidebar-hover.png") }); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveCSS("width", "56px"); +}); + +// @covers AC-UI-SIDEBAR-HOVER-001.3, AC-UI-SIDEBAR-HOVER-001.4 +test("reveal supports the workspace menu, Escape, and permanent expansion", async ({ + testPage, +}) => { + await testPage.goto("/"); + const sidebar = testPage.getByTestId("app-sidebar"); + await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveCSS("width", "56px"); + await sidebar.hover({ position: { x: 20, y: 200 } }); + await expect(sidebar.getByTestId("sidebar-workspace-trigger")).toBeVisible(); + await sidebar.getByTestId("sidebar-workspace-trigger").click(); + const menu = testPage.getByRole("menu"); + await expect(menu).toBeVisible(); + await menu.hover(); + await expect(sidebar).toHaveCSS("width", "320px"); + await testPage.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await expect(sidebar).toHaveCSS("width", "320px"); + await testPage.keyboard.press("Escape"); + await expect(sidebar).toHaveCSS("width", "56px"); + await expect(sidebar.getByRole("button", { name: "Expand sidebar", exact: true })).toBeFocused(); + await testPage.mouse.move(600, 400); + await sidebar.hover({ position: { x: 20, y: 200 } }); + await expect(sidebar.getByTestId("sidebar-workspace-trigger")).toBeVisible(); + await sidebar.getByRole("button", { name: "Expand sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveAttribute("data-collapsed", "false"); + await expect(testPage.getByTestId("app-sidebar-layout")).toHaveCSS("width", "320px"); + await testPage.reload(); + await expect(sidebar).toHaveAttribute("data-collapsed", "false"); +}); + +// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.5 +test("short hover cancels and crossing the phone breakpoint closes the reveal", async ({ + testPage, +}) => { + await testPage.goto("/"); + const sidebar = testPage.getByTestId("app-sidebar"); + await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveCSS("width", "56px"); + await testPage.clock.install(); + await testPage.clock.pauseAt(await testPage.evaluate(() => Date.now() + 100)); + await sidebar.hover({ position: { x: 20, y: 200 } }); + await testPage.clock.runFor(499); + await expect(sidebar).toHaveCSS("width", "56px"); + await testPage.mouse.move(600, 400); + await testPage.clock.runFor(1000); + await expect(sidebar).toHaveCSS("width", "56px"); + await testPage.clock.resume(); + await testPage.setViewportSize({ width: 768, height: 900 }); + await sidebar.hover({ position: { x: 20, y: 200 } }); + await expect(sidebar).toHaveCSS("width", "320px"); + await testPage.setViewportSize({ width: 767, height: 900 }); + await expect(sidebar).toBeHidden(); + await testPage.mouse.move(600, 400); + await testPage.setViewportSize({ width: 768, height: 900 }); + await expect(sidebar).toHaveCSS("width", "56px"); + await expect(sidebar).toHaveAttribute("data-collapsed", "true"); +}); + +// @covers AC-UI-SIDEBAR-HOVER-001.3 +test("revealed tasks support context menus and navigation", async ({ + testPage, + apiClient, + seedData, +}) => { + const task = await apiClient.createTask(seedData.workspaceId, "Hover navigation task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto("/"); + const sidebar = testPage.getByTestId("app-sidebar"); + await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveCSS("width", "56px"); + await sidebar.hover({ position: { x: 20, y: 200 } }); + const row = sidebar.getByText("Hover navigation task", { exact: true }); + await expect(row).toBeVisible(); + await row.click({ button: "right" }); + const menu = testPage.getByRole("menu"); + await expect(menu).toBeVisible(); + await menu.hover(); + await expect(row).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await row.click(); + await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`)); + await expect(sidebar).toHaveCSS("width", "56px"); +}); diff --git a/apps/web/e2e/tests/settings/mobile-sidebar-hover-settings.spec.ts b/apps/web/e2e/tests/settings/mobile-sidebar-hover-settings.spec.ts new file mode 100644 index 0000000000..60a7210353 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-sidebar-hover-settings.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "../../fixtures/test-base"; +import { waitForHttp } from "../../helpers/causal-waits"; + +// @covers AC-UI-SIDEBAR-HOVER-002.3, AC-UI-SIDEBAR-HOVER-002.5 +test("phone can save both hover preferences with contained touch controls", async ({ + testPage, +}, testInfo) => { + await testPage.goto("/settings/preferences/appearance"); + const card = testPage.getByTestId("sidebar-hover-settings-card"); + const toggle = card.getByRole("switch", { name: "Show sidebar on hover" }); + const delay = card.getByRole("spinbutton", { name: "Hover delay (ms)" }); + await card.scrollIntoViewIfNeeded(); + for (const control of [toggle, delay]) { + const bounds = await control.boundingBox(); + expect(bounds!.height).toBeGreaterThanOrEqual(44); + expect(bounds!.width).toBeGreaterThanOrEqual(44); + } + await delay.fill("900"); + await toggle.tap(); + await expect(delay).toBeDisabled(); + const saved = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/); + await testPage.getByRole("button", { name: "Save changes", exact: true }).tap(); + expect((await saved).ok()).toBe(true); + await expect(testPage.getByTestId("settings-floating-save")).toBeHidden(); + await testPage.reload(); + await expect(toggle).not.toBeChecked(); + await expect(delay).toHaveValue("900"); + await card.scrollIntoViewIfNeeded(); + expect( + await testPage.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ), + ).toBe(0); + await testPage.screenshot({ path: testInfo.outputPath("sidebar-hover-settings-phone.png") }); + await testPage.goto("/"); + await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); +}); diff --git a/apps/web/e2e/tests/settings/sidebar-hover-settings.spec.ts b/apps/web/e2e/tests/settings/sidebar-hover-settings.spec.ts new file mode 100644 index 0000000000..aeaa0fed78 --- /dev/null +++ b/apps/web/e2e/tests/settings/sidebar-hover-settings.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "../../fixtures/test-base"; +import { waitForHttp } from "../../helpers/causal-waits"; + +// @covers AC-UI-SIDEBAR-HOVER-002.1, AC-UI-SIDEBAR-HOVER-002.2, AC-UI-SIDEBAR-HOVER-002.3, AC-UI-SIDEBAR-HOVER-002.4 +test("saves custom dwell and disabled hover without changing explicit expansion", async ({ + testPage, +}, testInfo) => { + await testPage.goto("/settings/preferences/appearance"); + const toggle = testPage.getByRole("switch", { name: "Show sidebar on hover" }); + const delay = testPage.getByRole("spinbutton", { name: "Hover delay (ms)" }); + await expect(toggle).toBeChecked(); + await expect(delay).toHaveValue("500"); + await delay.fill(""); + await expect(testPage.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled(); + await delay.fill("1200"); + const saved = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/); + await testPage.getByRole("button", { name: "Save changes", exact: true }).click(); + expect((await saved).ok()).toBe(true); + await expect(testPage.getByTestId("settings-floating-save")).toBeHidden(); + await testPage.reload(); + await expect(delay).toHaveValue("1200"); + await delay.scrollIntoViewIfNeeded(); + expect((await delay.boundingBox())!.height).toBe(28); + await testPage.screenshot({ path: testInfo.outputPath("sidebar-hover-settings-desktop.png") }); + + await testPage.goto("/"); + const sidebar = testPage.getByTestId("app-sidebar"); + await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveCSS("width", "56px"); + await testPage.clock.install(); + await testPage.clock.pauseAt(await testPage.evaluate(() => Date.now() + 100)); + await sidebar.hover({ position: { x: 20, y: 200 } }); + await testPage.clock.runFor(1199); + await expect(sidebar).toHaveAttribute("data-hover-revealed", "false"); + await testPage.clock.runFor(1); + await expect(sidebar).toHaveAttribute("data-hover-revealed", "true"); + await testPage.clock.resume(); + + await testPage.goto("/settings/preferences/appearance"); + await toggle.click(); + await expect(delay).toBeDisabled(); + const disabled = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/); + await testPage.getByRole("button", { name: "Save changes", exact: true }).click(); + expect((await disabled).ok()).toBe(true); + await expect(testPage.getByTestId("settings-floating-save")).toBeHidden(); + await testPage.reload(); + await expect(toggle).not.toBeChecked(); + await expect(delay).toHaveValue("1200"); + await testPage.goto("/"); + await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveCSS("width", "56px"); + await testPage.clock.pauseAt(await testPage.evaluate(() => Date.now() + 100)); + await sidebar.hover({ position: { x: 20, y: 200 } }); + await testPage.clock.runFor(5000); + await expect(sidebar).toHaveAttribute("data-hover-revealed", "false"); + await testPage.clock.resume(); + await sidebar.getByRole("button", { name: "Expand sidebar", exact: true }).click(); + await testPage.mouse.move(600, 400); + await expect(sidebar).toHaveAttribute("data-collapsed", "false"); +}); diff --git a/apps/web/hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts b/apps/web/hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts new file mode 100644 index 0000000000..43c9277665 --- /dev/null +++ b/apps/web/hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts @@ -0,0 +1,345 @@ +import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createElement, type PointerEvent } from "react"; +import { createPortal } from "react-dom"; +import { useSidebarHoverReveal } from "./use-sidebar-hover-reveal"; + +const pointer = (pointerType = "mouse") => ({ pointerType }) as PointerEvent; + +// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.3, +// @covers AC-UI-SIDEBAR-HOVER-001.4, AC-UI-SIDEBAR-HOVER-001.5 +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function setup() { + return renderHook( + ({ collapsed, pathname }) => + useSidebarHoverReveal({ enabled: true, delayMs: 500, collapsed, pathname }), + { + initialProps: { collapsed: true, pathname: "/" }, + }, + ); +} + +it("requires a continuous full dwell and cancels early exits", () => { + const { result } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(499)); + expect(result.current.revealed).toBe(false); + act(() => result.current.handlers.onPointerLeave()); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(false); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(true); + act(() => result.current.handlers.onPointerLeave()); + expect(result.current.revealed).toBe(false); +}); + +it("retains focused content until both focus and pointer leave", async () => { + const { result } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.handlers.onFocusCapture()); + act(() => result.current.handlers.onPointerLeave()); + expect(result.current.revealed).toBe(true); + await act(async () => result.current.handlers.onBlurCapture()); + expect(result.current.revealed).toBe(false); +}); + +it("ignores touch and already expanded sidebars", () => { + const { result, rerender } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer("touch"))); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(false); + rerender({ collapsed: false, pathname: "/" }); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(false); +}); + +it("cancels pending and visible reveals on route and saved-state changes", () => { + const { result, rerender } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + rerender({ collapsed: true, pathname: "/tasks" }); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(false); + act(() => result.current.handlers.onPointerLeave()); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(true); + rerender({ collapsed: false, pathname: "/tasks" }); + expect(result.current.revealed).toBe(false); +}); + +it("dismisses until a fresh entry and cleans up timers on unmount", () => { + const { result, unmount } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.dismiss()); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(false); + act(() => result.current.handlers.onPointerLeave()); + act(() => result.current.handlers.onPointerEnter(pointer())); + unmount(); + expect(vi.getTimerCount()).toBe(0); +}); +it("dismisses on Escape even when keyboard focus is outside the sidebar", () => { + const { result } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(result.current.revealed).toBe(false); +}); + +it("does not dismiss for an Escape consumed by a child", () => { + const { result } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + event.preventDefault(); + act(() => document.body.dispatchEvent(event)); + expect(result.current.revealed).toBe(true); +}); + +it("cancels a pending dwell and a visible reveal when hover capability is lost", () => { + let changed = () => {}; + let eligible = true; + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + get matches() { + return eligible; + }, + addEventListener: (_name: string, callback: () => void) => { + changed = callback; + }, + removeEventListener: vi.fn(), + })), + ); + const { result } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => { + eligible = false; + changed(); + vi.advanceTimersByTime(500); + }); + expect(result.current.revealed).toBe(false); + act(() => { + eligible = true; + changed(); + result.current.handlers.onPointerLeave(); + }); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.revealed).toBe(true); + act(() => { + eligible = false; + changed(); + }); + expect(result.current.revealed).toBe(false); +}); + +it("retains an owned portal across pointer gaps and closes when it dismisses", async () => { + let current: ReturnType; + function Harness() { + current = useSidebarHoverReveal({ + enabled: true, + delayMs: 500, + collapsed: true, + pathname: "/", + }); + return createElement( + "aside", + { ref: current.ref, ...current.handlers }, + createElement("button", { "aria-expanded": true, "aria-controls": "owned-menu" }), + createPortal(createElement("div", { id: "owned-menu", role: "menu" }), document.body), + ); + } + const { container } = render(createElement(Harness)); + act(() => current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => current.handlers.onPointerLeave()); + expect(current!.revealed).toBe(true); + await act(async () => { + container.querySelector("button")!.setAttribute("aria-expanded", "false"); + }); + expect(current!.revealed).toBe(false); +}); + +it("does not retain the reveal for unrelated application menus", () => { + const menu = document.createElement("div"); + menu.setAttribute("role", "menu"); + menu.setAttribute("data-state", "open"); + document.body.append(menu); + const { result } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.handlers.onPointerLeave()); + expect(result.current.revealed).toBe(false); + menu.remove(); +}); + +it("drops stale focus when navigation replaces the revealed contents", () => { + const { result, rerender } = setup(); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.handlers.onFocusCapture()); + rerender({ collapsed: true, pathname: "/tasks" }); + act(() => result.current.handlers.onPointerLeave()); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.handlers.onPointerLeave()); + expect(result.current.revealed).toBe(false); +}); + +// @covers AC-UI-SIDEBAR-HOVER-002.4 +it("honors disabled hover, custom dwell and setting changes", () => { + const { result, rerender } = renderHook( + (settings) => + useSidebarHoverReveal({ + collapsed: true, + pathname: "/", + ...settings, + }), + { initialProps: { enabled: false, delayMs: 1200 } }, + ); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(1500)); + expect(result.current.revealed).toBe(false); + rerender({ enabled: true, delayMs: 1200 }); + act(() => result.current.handlers.onPointerLeave()); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(1199)); + expect(result.current.revealed).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.revealed).toBe(true); + rerender({ enabled: false, delayMs: 1200 }); + expect(result.current.revealed).toBe(false); + rerender({ enabled: true, delayMs: 0 }); + act(() => result.current.handlers.onPointerLeave()); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(0)); + expect(result.current.revealed).toBe(true); +}); + +it("cancels old timers when the delay changes and requires fresh entry", () => { + const { result, rerender } = renderHook( + (settings) => + useSidebarHoverReveal({ + collapsed: true, + pathname: "/", + ...settings, + }), + { initialProps: { enabled: true, delayMs: 500 } }, + ); + act(() => result.current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(300)); + rerender({ enabled: true, delayMs: 1000 }); + act(() => vi.advanceTimersByTime(2000)); + expect(result.current.revealed).toBe(false); +}); + +it("returns focus to the visible toggle when a saved setting dismisses a reveal", () => { + let current: ReturnType; + function Harness({ enabled }: { enabled: boolean }) { + current = useSidebarHoverReveal({ collapsed: true, pathname: "/", enabled, delayMs: 500 }); + return createElement( + "aside", + { ref: current.ref, ...current.handlers }, + createElement("button", { "data-sidebar-toggle": "" }, "Expand"), + createElement("button", { "data-navigation": "" }, "Navigation"), + ); + } + const { container, rerender } = render(createElement(Harness, { enabled: true })); + act(() => current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + const navigation = container.querySelector("[data-navigation]")!; + act(() => navigation.focus()); + rerender(createElement(Harness, { enabled: false })); + act(() => vi.advanceTimersByTime(20)); + expect(current!.revealed).toBe(false); + expect(document.activeElement).toBe(container.querySelector("[data-sidebar-toggle]")); +}); + +it("does not restore stale focus after explicit collapse and retain a later hover", () => { + let current: ReturnType; + function Harness({ collapsed }: { collapsed: boolean }) { + current = useSidebarHoverReveal({ collapsed, pathname: "/", enabled: true, delayMs: 500 }); + return createElement( + "aside", + { ref: current.ref, ...current.handlers }, + createElement("button", { "data-sidebar-toggle": "" }, "Toggle"), + createElement("button", { "data-navigation": "" }, "Navigation"), + ); + } + const { container, rerender } = render(createElement(Harness, { collapsed: false })); + act(() => container.querySelector("[data-navigation]")!.focus()); + rerender(createElement(Harness, { collapsed: true })); + act(() => vi.advanceTimersByTime(20)); + act(() => current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => current.handlers.onPointerLeave()); + expect(current!.revealed).toBe(false); +}); + +it.each(["(hover: hover)", "(pointer: fine)"])( + "restores focus when %s capability disappears while navigation is focused", + (capabilityQuery) => { + let hover = true; + let changed = () => {}; + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + get matches() { + return query === capabilityQuery ? hover : true; + }, + addEventListener: (_name: string, callback: () => void) => { + if (query === capabilityQuery) changed = callback; + }, + removeEventListener: vi.fn(), + })), + ); + let current: ReturnType; + function Harness() { + current = useSidebarHoverReveal({ + collapsed: true, + pathname: "/", + enabled: true, + delayMs: 500, + }); + return createElement( + "aside", + { ref: current.ref, ...current.handlers }, + createElement("button", { "data-sidebar-toggle": "" }, "Expand"), + current.revealed && createElement("button", { "data-navigation": "" }, "Navigation"), + ); + } + const { container } = render(createElement(Harness)); + act(() => current.handlers.onPointerEnter(pointer())); + act(() => vi.advanceTimersByTime(500)); + act(() => container.querySelector("[data-navigation]")!.focus()); + act(() => { + hover = false; + changed(); + }); + act(() => vi.advanceTimersByTime(20)); + expect(current!.revealed).toBe(false); + expect(document.activeElement).toBe(container.querySelector("[data-sidebar-toggle]")); + }, +); diff --git a/apps/web/hooks/domains/sidebar/use-sidebar-hover-reveal.ts b/apps/web/hooks/domains/sidebar/use-sidebar-hover-reveal.ts new file mode 100644 index 0000000000..ee86e37e5d --- /dev/null +++ b/apps/web/hooks/domains/sidebar/use-sidebar-hover-reveal.ts @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState, type PointerEvent } from "react"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; + +// i18n-exempt: CSS media query, not displayed copy. +const HOVER_QUERY = "(hover: hover)"; + +function hasOpenSidebarSurface(root: HTMLElement | null): boolean { + if (!root) return false; + if (root.querySelector('[data-slot="context-menu-trigger"][data-state="open"]')) return true; + return Array.from(root.querySelectorAll('[aria-expanded="true"][aria-controls]')).some( + (trigger) => { + const surface = document.getElementById(trigger.getAttribute("aria-controls") ?? ""); + return surface !== null && !root.contains(surface); + }, + ); +} + +function observeSidebarSurfaces(root: HTMLElement | null, onChange: () => void) { + const observer = new MutationObserver(onChange); + if (root) + observer.observe(root, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["aria-expanded", "data-state"], + }); + return observer; +} + +function returnFocusToToggle(root: HTMLElement | null) { + requestAnimationFrame(() => { + if (root?.isConnected) root.querySelector("[data-sidebar-toggle]")?.focus(); + }); +} + +type SidebarHoverOptions = { + enabled: boolean; + delayMs: number; + collapsed: boolean; + pathname: string | null; +}; + +export function useSidebarHoverReveal({ + collapsed, + pathname, + enabled, + delayMs, +}: SidebarHoverOptions) { + const { isMobile, isFinePointer } = useResponsiveBreakpoint(); + const eligible = enabled && !isMobile && isFinePointer; + const ref = useRef(null); + const [revealed, setRevealed] = useState(false); + const timer = useRef | null>(null); + const inside = useRef(false); + const focused = useRef(false); + const mounted = useRef(true); + const previousSettings = useRef({ enabled, delayMs, eligible }); + + function cancelTimer() { + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + } + + function dismiss() { + cancelTimer(); + setRevealed(false); + } + + function closeIfOutside() { + if ( + mounted.current && + !inside.current && + !focused.current && + !hasOpenSidebarSurface(ref.current) + ) { + setRevealed(false); + } + } + + useEffect(() => { + mounted.current = true; + const media = window.matchMedia(HOVER_QUERY); + const reset = (capabilityChanged = false) => { + cancelTimer(); + const changed = + capabilityChanged || + previousSettings.current.eligible !== eligible || + previousSettings.current.enabled !== enabled || + previousSettings.current.delayMs !== delayMs; + if (changed && collapsed && focused.current) returnFocusToToggle(ref.current); + previousSettings.current = { enabled, delayMs, eligible }; + focused.current = false; + setRevealed(false); + }; + reset(); + const onCapabilityChange = () => reset(true); + media.addEventListener("change", onCapabilityChange); + return () => { + mounted.current = false; + cancelTimer(); + media.removeEventListener("change", onCapabilityChange); + }; + // The callbacks only read refs; saved-state and route changes reset the interaction. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collapsed, pathname, eligible, delayMs]); + + useEffect(() => { + if (!revealed) return; + const observer = observeSidebarSurfaces(ref.current, closeIfOutside); + + function onKeyDown(event: KeyboardEvent) { + if (event.key !== "Escape" || event.defaultPrevented || hasOpenSidebarSurface(ref.current)) + return; + event.preventDefault(); + setRevealed(false); + if (focused.current) returnFocusToToggle(ref.current); + } + window.addEventListener("keydown", onKeyDown); + return () => { + observer.disconnect(); + window.removeEventListener("keydown", onKeyDown); + }; + // Only the reveal state changes this subscription; containment reads live refs. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revealed]); + + const handlers = { + onPointerEnter(event: PointerEvent) { + if (inside.current || event.pointerType === "touch") return; + inside.current = true; + if (!collapsed || !eligible || !window.matchMedia(HOVER_QUERY).matches) return; + timer.current = setTimeout(() => { + timer.current = null; + setRevealed(true); + }, delayMs); + }, + onPointerLeave() { + inside.current = false; + cancelTimer(); + closeIfOutside(); + }, + onFocusCapture() { + focused.current = true; + }, + onBlurCapture() { + focused.current = false; + // React focus events bubble through portals; wait for the destination's focus event. + void Promise.resolve().then(closeIfOutside); + }, + }; + + return { ref, revealed: collapsed && revealed, dismiss, handlers }; +} diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 0ac6da40dc..086ebe82ff 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -112,6 +112,8 @@ function makeUnloadedSettings(): UserSettingsState { changesPanelLayout: "tree", lastSeenDisplay: "absolute", systemMetricsDisplay: { showInTopbar: false, simplified: false }, + sidebarHoverEnabled: true, + sidebarHoverDelayMs: 500, appStatusBarEnabled: false, resolveSessionHostnames: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, diff --git a/apps/web/lib/settings-discovery/catalog/preferences.ts b/apps/web/lib/settings-discovery/catalog/preferences.ts index e9663d090f..7a10e5aa0e 100644 --- a/apps/web/lib/settings-discovery/catalog/preferences.ts +++ b/apps/web/lib/settings-discovery/catalog/preferences.ts @@ -20,6 +20,8 @@ export const GENERAL_SETTINGS_TARGETS = { settingsMenuMode: "setting-settings-menu-mode", startupPage: "setting-startup-page", displayLanguage: "setting-display-language", + sidebarHover: "setting-sidebar-hover", + sidebarHoverDelay: "setting-sidebar-hover-delay", appStatusBar: "setting-status-bar", changesPanelLayout: "setting-changes-panel-layout", resourceMetrics: "setting-resource-metrics", @@ -45,6 +47,29 @@ export const GENERAL_SETTINGS_TARGETS = { } as const; export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ + { + id: "appearance-sidebar-hover-enabled", + kind: "control", + labelKey: "settings:sidebarHoverEnabled", + aliasesKey: "settings:discoveryAliasesSidebarHover", + parentId: APPEARANCE_ID, + groupId: "preferences", + href: APPEARANCE_SETTINGS_HREF, + targetId: GENERAL_SETTINGS_TARGETS.sidebarHover, + order: 24, + }, + { + id: "appearance-sidebar-hover-delay", + kind: "control", + labelKey: "settings:sidebarHoverDelay", + aliasesKey: "settings:discoveryAliasesSidebarHover", + parentId: APPEARANCE_ID, + groupId: "preferences", + href: APPEARANCE_SETTINGS_HREF, + targetId: GENERAL_SETTINGS_TARGETS.sidebarHoverDelay, + order: 25, + }, + { id: APPEARANCE_ID, kind: "page", diff --git a/apps/web/lib/settings-discovery/contract.generated.json b/apps/web/lib/settings-discovery/contract.generated.json index 0f6f96ec7e..9123f3a279 100644 --- a/apps/web/lib/settings-discovery/contract.generated.json +++ b/apps/web/lib/settings-discovery/contract.generated.json @@ -5236,6 +5236,36 @@ "change_timing": "future_views", "settings_href": "/settings/preferences" }, + { + "key": "user_settings.sidebar_hover_enabled", + "field_path": "sidebar_hover_enabled", + "label": "sidebar hover enabled", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_hover_delay_ms", + "field_path": "sidebar_hover_delay_ms", + "label": "sidebar hover delay", + "description": "Portable preference owned by the current user.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, { "key": "user_settings.system_metrics_display", "field_path": "system_metrics_display", @@ -6208,6 +6238,8 @@ "show_transcript_auto_scroll_control", "sidebar_active_view_id", "sidebar_draft", + "sidebar_hover_delay_ms", + "sidebar_hover_enabled", "sidebar_task_color_automation", "sidebar_task_color_patch", "sidebar_task_colors", diff --git a/apps/web/lib/settings-discovery/profile-contract.generated.json b/apps/web/lib/settings-discovery/profile-contract.generated.json index 0f2f3e2736..c24c945e37 100644 --- a/apps/web/lib/settings-discovery/profile-contract.generated.json +++ b/apps/web/lib/settings-discovery/profile-contract.generated.json @@ -367,6 +367,8 @@ "show_transcript_auto_scroll_control", "sidebar_active_view_id", "sidebar_draft", + "sidebar_hover_delay_ms", + "sidebar_hover_enabled", "sidebar_task_color_automation", "sidebar_task_color_patch", "sidebar_task_colors", diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 9a9a866590..a0e65f8d27 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -759,6 +759,22 @@ describe("prevent auto-start on open preference", () => { }); }); +it("maps hover settings and retains false/zero through omitted updates", () => { + expect(createDefaultUserSettings()).toMatchObject({ + sidebarHoverEnabled: true, + sidebarHoverDelayMs: 500, + }); + const current = mapUserSettingsData({ sidebar_hover_enabled: false, sidebar_hover_delay_ms: 0 }); + expect(current).toMatchObject({ sidebarHoverEnabled: false, sidebarHoverDelayMs: 0 }); + expect(buildCoreFields({}, current)).toMatchObject({ + sidebarHoverEnabled: false, + sidebarHoverDelayMs: 0, + }); + expect( + mapUserSettingsData({ sidebar_hover_enabled: true, sidebar_hover_delay_ms: 1200 }), + ).toMatchObject({ sidebarHoverEnabled: true, sidebarHoverDelayMs: 1200 }); +}); + // @covers AC-TASKS-CREATION-AUTO-FOCUS-001.1, AC-TASKS-CREATION-AUTO-FOCUS-001.4 describe("task creation auto-focus preference", () => { it("defaults on and preserves explicit false across partial hydration", () => { diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 9291f91712..d37a6b8ae3 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -99,6 +99,8 @@ export function createDefaultUserSettings(): UserSettingsState { lastSeenDisplay: "absolute", systemMetricsDisplay: { showInTopbar: false, simplified: false }, appStatusBarEnabled: false, + sidebarHoverEnabled: true, + sidebarHoverDelayMs: 500, resolveSessionHostnames: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, quickChatTabOrderByWorkspace: {}, @@ -377,6 +379,8 @@ export function buildCoreFields( parseAppStatusBarOrder, ), appStatusBarEnabled: s.app_status_bar_enabled ?? current.appStatusBarEnabled, + sidebarHoverEnabled: s.sidebar_hover_enabled ?? current.sidebarHoverEnabled, + sidebarHoverDelayMs: s.sidebar_hover_delay_ms ?? current.sidebarHoverDelayMs, quickChatTabOrderByWorkspace: s.quick_chat_tab_order_by_workspace ?? current.quickChatTabOrderByWorkspace, resolveSessionHostnames: s.resolve_session_hostnames ?? current.resolveSessionHostnames, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 85e3896f33..9e0181f4dd 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -468,6 +468,8 @@ export type UserSettingsState = { lastSeenDisplay: LastSeenDisplay; systemMetricsDisplay: { showInTopbar: boolean; simplified: boolean }; appStatusBarEnabled: boolean; + sidebarHoverEnabled: boolean; + sidebarHoverDelayMs: number; resolveSessionHostnames: boolean; appStatusBarOrder: AppStatusBarOrderState; quickChatTabOrderByWorkspace: Record; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 1d5ccb630c..824be6bdcf 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -214,6 +214,8 @@ export type UserSettings = { last_seen_display?: LastSeenDisplay; system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; app_status_bar_enabled?: boolean; + sidebar_hover_enabled?: boolean; + sidebar_hover_delay_ms?: number; resolve_session_hostnames?: boolean; app_status_bar_order?: AppStatusBarOrderApi; quick_chat_tab_order_by_workspace?: Record; @@ -290,6 +292,8 @@ export type UserSettingsUpdatePayload = { last_seen_display?: LastSeenDisplay; system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; app_status_bar_enabled?: boolean; + sidebar_hover_enabled?: boolean; + sidebar_hover_delay_ms?: number; resolve_session_hostnames?: boolean; app_status_bar_order?: AppStatusBarOrderApi; quick_chat_tab_order_by_workspace?: Record; diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index eb4e78428b..677e985ddf 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -812,3 +812,19 @@ describe("user settings websocket sidebar settings", () => { }); }); }); + +it("syncs hover settings while preserving omitted fields and rejecting old revisions", () => { + const store = makeStore(); + const handler = registerUsersHandlers(store)["user.settings.updated"]; + handler?.( + userSettingsMessage({ sidebar_hover_enabled: false, sidebar_hover_delay_ms: 0, revision: 10 }), + ); + handler?.(userSettingsMessage({ app_status_bar_enabled: true, revision: 11 })); + handler?.( + userSettingsMessage({ sidebar_hover_enabled: true, sidebar_hover_delay_ms: 500, revision: 9 }), + ); + expect(store.getState().userSettings).toMatchObject({ + sidebarHoverEnabled: false, + sidebarHoverDelayMs: 0, + }); +}); diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index a0ce8901c1..a56c49c9e9 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -790,6 +790,12 @@ "unitRemoveMember": "Remove member", "unitPickPerson": "Pick someone", "unitActionFailed": "That did not work.", + "sidebarHoverTitle": "Sidebar", + "sidebarHoverEnabled": "Show sidebar on hover", + "sidebarHoverDelay": "Hover delay (ms)", + "sidebarHoverHelp": "Use a mouse or trackpad to reveal the collapsed sidebar. Set the delay from 0 to 5000 ms; 0 reveals immediately.", + "sidebarHoverDelayError": "Enter a whole number from 0 to 5000.", + "discoveryAliasesSidebarHover": "sidebar hover reveal delay wait mouse trackpad", "autoFocusNewTasks": "Auto-focus new tasks", "autoFocusNewTasksHelp": "Open newly created tasks automatically. Turn this off to stay on your current view. Tasks and agents still start as requested." } diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 5b53f7a90f..c83fa85335 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -790,6 +790,12 @@ "unitRemoveMember": "Ŕēḿōvē ḿēḿƀēŕ", "unitPickPerson": "Ƥĩćķ śōḿēōńē", "unitActionFailed": "Ţĥàţ ďĩď ńōţ ŵōŕķ.", + "sidebarHoverTitle": "Śĩďēƀàŕ", + "sidebarHoverEnabled": "Śĥōŵ śĩďēƀàŕ ōń ĥōvēŕ", + "sidebarHoverDelay": "Ĥōvēŕ ďēĺàŷ (ḿś)", + "sidebarHoverHelp": "Ũśē à ḿōũśē ōŕ ţŕàćķƥàď ţō ŕēvēàĺ ţĥē ćōĺĺàƥśēď śĩďēƀàŕ. Śēţ ţĥē ďēĺàŷ ƒŕōḿ 0 ţō 5000 ḿś; 0 ŕēvēàĺś ĩḿḿēďĩàţēĺŷ.", + "sidebarHoverDelayError": "Ēńţēŕ à ŵĥōĺē ńũḿƀēŕ ƒŕōḿ 0 ţō 5000.", + "discoveryAliasesSidebarHover": "śĩďēƀàŕ ĥōvēŕ ŕēvēàĺ ďēĺàŷ ŵàĩţ ḿōũśē ţŕàćķƥàď", "autoFocusNewTasks": "Àũţō-ƒōćũś ńēŵ ţàśķś", "autoFocusNewTasksHelp": "Ōƥēń ńēŵĺŷ ćŕēàţēď ţàśķś àũţōḿàţĩćàĺĺŷ. Ţũŕń ţĥĩś ōƒƒ ţō śţàŷ ōń ŷōũŕ ćũŕŕēńţ vĩēŵ. Ţàśķś àńď àĝēńţś śţĩĺĺ śţàŕţ àś ŕēqũēśţēď." } diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index 6da8613089..75d74dc251 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -790,6 +790,12 @@ "unitRemoveMember": "Remover membro", "unitPickPerson": "Escolher alguém", "unitActionFailed": "Não foi possível concluir.", + "sidebarHoverTitle": "Barra lateral", + "sidebarHoverEnabled": "Mostrar a barra lateral ao passar o rato", + "sidebarHoverDelay": "Tempo de espera (ms)", + "sidebarHoverHelp": "Use um rato ou trackpad para mostrar a barra lateral recolhida. Defina o tempo de espera entre 0 e 5000 ms; 0 mostra-a imediatamente.", + "sidebarHoverDelayError": "Introduza um número inteiro entre 0 e 5000.", + "discoveryAliasesSidebarHover": "barra lateral passar rato mostrar atraso espera trackpad", "autoFocusNewTasks": "Focar automaticamente novas tarefas", "autoFocusNewTasksHelp": "Abre automaticamente as tarefas recém-criadas. Desative esta opção para manter a vista atual. As tarefas e os agentes continuam a iniciar conforme solicitado." } diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 27faa5ac19..97bad6176d 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -790,6 +790,12 @@ "unitRemoveMember": "移除成员", "unitPickPerson": "选择成员", "unitActionFailed": "操作未成功。", + "sidebarHoverTitle": "侧边栏", + "sidebarHoverEnabled": "悬停时显示侧边栏", + "sidebarHoverDelay": "悬停延迟(毫秒)", + "sidebarHoverHelp": "使用鼠标或触控板悬停以显示已折叠的侧边栏。延迟可设为 0 到 5000 毫秒;设为 0 时立即显示。", + "sidebarHoverDelayError": "请输入 0 到 5000 之间的整数。", + "discoveryAliasesSidebarHover": "侧边栏 悬停 显示 延迟 等待 鼠标 触控板", "autoFocusNewTasks": "自动聚焦新任务", "autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。" } diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index c24b8cc96d..3ee19d9185 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -790,6 +790,12 @@ "unitRemoveMember": "移除成員", "unitPickPerson": "選擇成員", "unitActionFailed": "操作未成功。", + "sidebarHoverTitle": "側邊欄", + "sidebarHoverEnabled": "懸停時顯示側邊欄", + "sidebarHoverDelay": "懸停延遲(毫秒)", + "sidebarHoverHelp": "使用鼠標或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。", + "sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。", + "discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 鼠標 觸控板", "autoFocusNewTasks": "自動聚焦新任務", "autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。" } diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 2b6f587c64..9564ba5553 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -790,6 +790,12 @@ "unitRemoveMember": "移除成員", "unitPickPerson": "選擇成員", "unitActionFailed": "操作未成功。", + "sidebarHoverTitle": "側邊欄", + "sidebarHoverEnabled": "懸停時顯示側邊欄", + "sidebarHoverDelay": "懸停延遲(毫秒)", + "sidebarHoverHelp": "使用滑鼠或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。", + "sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。", + "discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 滑鼠 觸控板", "autoFocusNewTasks": "自動聚焦新任務", "autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。" } diff --git a/docs/plans/sidebar-hover-reveal/plan.md b/docs/plans/sidebar-hover-reveal/plan.md new file mode 100644 index 0000000000..e20bd557cc --- /dev/null +++ b/docs/plans/sidebar-hover-reveal/plan.md @@ -0,0 +1,208 @@ +--- +created: 2026-09-13 +status: implemented +requirements: + - REQ-UI-SIDEBAR-HOVER-001 + - REQ-UI-SIDEBAR-HOVER-002 +system_design: + - ../../specs/ui/system-design/sidebar-hover-reveal.md +legacy_specs: [] +--- + +# Implementation Plan: Sidebar Hover Reveal + +## Overview + +The completed first work order delivers a temporary 500 ms hover reveal and desktop/phone +regression coverage. The implementation uses local hover state and preserves the saved sidebar preference. + +## Scope + +Global sidebar hover interaction, overlay geometry, explicit expansion and accessible +navigation. The settings follow-up extends existing user preference persistence. Review sidebars +and mobile navigation redesign remain excluded. + +## Technical approach + +Follow the [system design](../../specs/ui/system-design/sidebar-hover-reveal.md). +Keep persisted layout width separate from transient visual width. Add a small local +hook; compose it into `AppSidebar` and separate header presentation from toggle state. +No new architecture boundary or ADR is required for this local presentation state. + +## ASCII UI preview + +UI-01: Desktop, pointer enters collapsed rail (AC .1-.4). + +```text +Before 500 ms After 500 ms +[rail] [page content] [full sidebar ] over page + [workspace ] + [navigation ] + [Expand button] +``` + +The 56 px layout reservation and page/status position stay fixed. The sidebar's +existing task/navigation scroll regions remain scroll owners; header/footer stay +fixed. Exit closes the overlay; explicit Expand keeps it open. Full navigation +and unchanged page geometry are structural; label positions here are illustrative. + +UI-02: Phone, existing menu trigger (AC .5). + +```text +[Menu] [current page] + tap Menu ++---------------------+ +| Navigation / Tasks | +| destinations | <- internal scrolling +| select destination | ++---------------------+ + safe-area space +``` + +Use the existing inset drawer and localized copy. No collapsed rail or hover trigger +appears on phone. Desktop checks target UI-01; mobile checks target UI-02. + +## Tests + +New `hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts` covers “reveals at 500 ms”, +“cancels early exit”, “requires fresh entry”, “cleans up pending callbacks”, and +“cancels on eligibility loss” (AC .1, .3-.5). Extend `app-sidebar.test.tsx` for +“preserves layout reservation and saved collapse”, “keeps owned portals usable”, +“retains focused reveal”, and “explicit expansion persists” (AC .2-.4). +Extend `app-sidebar-header.test.tsx` for the visible pin action (AC .4). + +## E2E tests + +Add `e2e/tests/layout/sidebar-hover-reveal.spec.ts` in chromium: short hover cancellation, +full reveal, task navigation, owned workspace/task menus, Escape, pinning, no layout +shift, breakpoint and touch eligibility (AC .1-.5). Use controlled browser clock for +the timer boundary; use causal assertions for navigation and settled geometry. +Add `e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts` in mobile-chrome: no hover +rail, existing task drawer opens by tap and navigates, contained scrolling and no +horizontal overflow (AC .5). Reuse `mobile-sidebar-read-recovery.spec.ts` fixture patterns. + +## Work orders + +- [x] [Task 01: Implement sidebar hover reveal](task-01-hover-reveal.md) +- [x] [Task 02: Configure sidebar hover activation](task-02-hover-settings.md) + +## Verification + +Run sequentially from repository root; build before browser verification. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts components/app-sidebar/app-sidebar.test.tsx components/app-sidebar/app-sidebar-header.test.tsx components/app-sidebar/app-sidebar-workspace-picker.test.tsx lib/state/slices/ui/app-sidebar-actions.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint hooks/domains/sidebar/use-sidebar-hover-reveal.ts components/app-sidebar/app-sidebar.tsx components/app-sidebar/app-sidebar-header.tsx) +(cd apps/web && pnpm run i18n:check) +GOCACHE=/tmp/kandev-sidebar-go-cache make -C apps/backend build +make build-web-e2e +(cd apps/web && pnpm e2e:run --host --no-build --project chromium e2e/tests/layout/sidebar-hover-reveal.spec.ts e2e/tests/layout/toggle-sidebar-shortcut.spec.ts e2e/tests/layout/sidebar-resize-handle.spec.ts -- --retries=0) +(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts -- --retries=0) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Verification results: original interaction + +Completed on 2026-09-14. + +| Verification | Result | +| --- | --- | +| Workspace frozen-lockfile install | Passed | +| Five targeted Vitest files | 58 tests passed | +| Typecheck, targeted ESLint, i18n check | Passed; no lint warnings | +| Backend build and `make build-web-e2e` | Passed | +| Chromium hover, shortcut and resize specs | 9 tests passed, retries disabled | +| Mobile navigation spec | 1 test passed, retries disabled | +| Specification catalog and full specification lint | Passed | +| Public-doc validator tests and published-page validation | Passed (46 pages) | +| `git diff --check` | Passed | + +Browser RED was confirmed against the original frontend: the panel stayed 56 px +instead of revealing to 320 px. Final browser checks prove stable page geometry, +owned menu interaction, nested Escape/focus return, persistent expansion, task +navigation, responsive cancellation and phone tap navigation. Desktop and phone +screenshots were inspected and matched the planned compositions. + +The fresh-worktree build used `GOCACHE=/tmp/kandev-sidebar-go-cache` because the +default cache was read-only. Browser runs required permission to bind the isolated +backend's local port. Backend artifacts were reused after frontend-only edits; +frontend assets were rebuilt before each affected browser run. Build output retained +existing chunk-size and dynamic-import warnings. + +## Risks + +Portaled menus can appear outside DOM containment and must retain the reveal. +Visual expansion must not overwrite persisted collapse or move Dockview/status geometry. + +## Documentation impact + +Public how-to guidance updated in `docs/public/tasks-and-workflows.md` to explain +temporary hover reveal, explicit expansion, and phone tap navigation. + +## Settings follow-up + +Task 02 follows the completed Task 01 as one vertical TDD slice: persistence and +wire mapping, appearance draft/save integration, then runtime cancellation and +rendered desktop/phone evidence. The original verification results above remain +historical evidence; the settings work is verified separately below. Exact new commands and likely +files are in [Task 02](task-02-hover-settings.md). + +### ASCII UI preview + +UI-03: Preferences > Appearance > Sidebar, enabled (AC-UI-SIDEBAR-HOVER-002.1-.5). + +```text +Desktop Phone +Sidebar Sidebar +Show sidebar on hover [on] Show sidebar on hover [on] +Hover delay (ms) [500] Hover delay (ms) +Mouse or trackpad only. [500 ] + Mouse or trackpad only. +[Discard] [Save changes] [Discard] [Save changes] +``` + +UI-04: Disabled/invalid states at the same entry point (.2-.3). + +```text +Show sidebar on hover [off] Hover delay (ms) [-1] +Hover delay (ms) [500 disabled] Enter a whole number from 0 to 5000. + [Save changes disabled] +``` + +Grouping, units, explicit save and retained disabled value are structural; spacing +is illustrative. The page owns vertical scrolling; the existing floating save +surface remains reachable above phone safe-area insets. Touch controls are at least +44 px, desktop numeric input 28 px. All copy uses locale keys. Screenshots and +geometry assertions in Task 02 compare the rendered controls to these previews. + +### Tests and E2E coverage + +Task 02 maps .1-.3 to backend `sidebar_hover_settings_test.go` files in user +store/service/handlers, mapper and appearance draft tests; .4 to fake-timer hook +and AppSidebar tests; .1-.4 to chromium `sidebar-hover-settings.spec.ts`; .5 to +mobile-chrome `mobile-sidebar-hover-settings.spec.ts`. Browser tests cover save, +reload, custom delay and disabled hover; mobile tests cover both controls and +continued tap navigation. Settings discovery is checked through its existing tests. + +### Risks and documentation + +False and zero must not be mistaken for omission. Numeric editing must not save +an empty field as zero. Remote saves must cancel timers without overwriting unsaved +settings drafts or losing focus. Update `docs/public/tasks-and-workflows.md` after +implementation to identify the appearance controls and defaults. + +### Settings verification results + +Completed on 2026-09-14. Task 02 records the exact verification commands and results. +223 tests across 17 frontend suites, backend user/settings-catalog suites, five +desktop browser tests and two phone browser tests passed. Typecheck, targeted lint +(no warnings), i18n, builds, spec and public-doc validation and whitespace checks +passed. Desktop and phone settings screenshots were inspected. The isolated test +instance at `100.105.155.17:48439` was refreshed with its data retained; the main +`:9998` process was untouched. diff --git a/docs/plans/sidebar-hover-reveal/task-01-hover-reveal.md b/docs/plans/sidebar-hover-reveal/task-01-hover-reveal.md new file mode 100644 index 0000000000..ea25181b2a --- /dev/null +++ b/docs/plans/sidebar-hover-reveal/task-01-hover-reveal.md @@ -0,0 +1,151 @@ +--- +id: "01-hover-reveal" +title: "Implement sidebar hover reveal" +status: done +wave: 1 +depends_on: [] +plan: plan.md +requirements: + - REQ-UI-SIDEBAR-HOVER-001 +acceptance_criteria: + - AC-UI-SIDEBAR-HOVER-001.1 + - AC-UI-SIDEBAR-HOVER-001.2 + - AC-UI-SIDEBAR-HOVER-001.3 + - AC-UI-SIDEBAR-HOVER-001.4 + - AC-UI-SIDEBAR-HOVER-001.5 +system_design: + - ../../specs/ui/system-design/sidebar-hover-reveal.md +--- + +# Task 01: Implement Sidebar Hover Reveal + +## Summary + +Implement the delayed overlay as one vertical slice using TDD. Preserve saved collapse, +existing navigation actions, explicit controls, and touch access. + +## In scope + +Hook lifecycle, sidebar/header composition, owned portal interaction and focused tests. + +## Out of scope + +Backend changes, new preferences, mobile redesign, resize during temporary reveal. + +## Acceptance + +- Delayed reveal and cancellation satisfy AC .1/.3/.5, including owned portals and focus. +- Visual expansion preserves saved layout and explicit toggle semantics (AC .2/.4). +- Desktop and mobile rendered tests prove usable navigation and stable geometry (AC .1-.5). + +## ASCII UI preview + +UI-01: Desktop, pointer enters collapsed rail (AC .1-.4). + +```text +Before 500 ms After 500 ms +[rail] [page content] [full sidebar ] over page + [workspace ] + [navigation ] + [Expand button] +``` + +The 56 px layout reservation and page/status position stay fixed. The sidebar's +existing task/navigation scroll regions remain scroll owners; header/footer stay +fixed. Exit closes the overlay; explicit Expand keeps it open. Full navigation +and unchanged page geometry are structural; label positions here are illustrative. + +UI-02: Phone, existing menu trigger (AC .5). + +```text +[Menu] [current page] + tap Menu ++---------------------+ +| Navigation / Tasks | +| destinations | <- internal scrolling +| select destination | ++---------------------+ + safe-area space +``` + +Use the existing inset drawer and localized copy. No collapsed rail or hover trigger +appears on phone. Desktop checks target UI-01; mobile checks target UI-02. + +Full preview: [plan](plan.md#ascii-ui-preview). + +## Verification + +Run sequentially from repository root. New tests listed here are implementation outputs. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts components/app-sidebar/app-sidebar.test.tsx components/app-sidebar/app-sidebar-header.test.tsx components/app-sidebar/app-sidebar-workspace-picker.test.tsx lib/state/slices/ui/app-sidebar-actions.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint hooks/domains/sidebar/use-sidebar-hover-reveal.ts components/app-sidebar/app-sidebar.tsx components/app-sidebar/app-sidebar-header.tsx) +(cd apps/web && pnpm run i18n:check) +GOCACHE=/tmp/kandev-sidebar-go-cache make -C apps/backend build +make build-web-e2e +(cd apps/web && pnpm e2e:run --host --no-build --project chromium e2e/tests/layout/sidebar-hover-reveal.spec.ts e2e/tests/layout/toggle-sidebar-shortcut.spec.ts e2e/tests/layout/sidebar-resize-handle.spec.ts -- --retries=0) +(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts -- --retries=0) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Files likely touched + +- `apps/web/hooks/domains/sidebar/use-sidebar-hover-reveal.ts` and `.test.ts` (new). +- `apps/web/components/app-sidebar/app-sidebar.tsx` and `.test.tsx`. +- `apps/web/components/app-sidebar/app-sidebar-header.tsx` and `.test.tsx`. +- Sidebar-owned popover/menu components only as required for scoped interaction ownership. +- `apps/web/e2e/tests/layout/sidebar-hover-reveal.spec.ts` and + `mobile-sidebar-hover-reveal.spec.ts` (new). + +## Dependencies + +None. Install workspace dependencies before package commands if absent. + +## Risks + +Portals and focus can outlive pointer containment. Suppress re-entry caused by the +same collapse interaction. Do not broadly capture unrelated application overlays. + +## Parallelism + +Sequential. No subagents authorized. + +## Inputs + +[Requirements](../../specs/ui/requirements/sidebar-hover-reveal.md), +[design](../../specs/ui/system-design/sidebar-hover-reveal.md), existing sidebar unit +fixtures and layout E2E tests. Apply repository TDD and mobile-parity skills. + +## Results + +Completed on 2026-09-14. + +| Verification | Result | +| --- | --- | +| Workspace frozen-lockfile install | Passed | +| Five targeted Vitest files | 58 tests passed | +| Typecheck, targeted ESLint, i18n check | Passed; no lint warnings | +| Backend build and `make build-web-e2e` | Passed | +| Chromium hover, shortcut and resize specs | 9 tests passed, retries disabled | +| Mobile navigation spec | 1 test passed, retries disabled | +| Specification catalog and full specification lint | Passed | +| Public-doc validator tests and published-page validation | Passed (46 pages) | +| `git diff --check` | Passed | + +Browser RED was confirmed against the original frontend: the panel stayed 56 px +instead of revealing to 320 px. Final browser checks prove stable page geometry, +owned menu interaction, nested Escape/focus return, persistent expansion, task +navigation, responsive cancellation and phone tap navigation. Desktop and phone +screenshots were inspected and matched the planned compositions. + +The fresh-worktree build used `GOCACHE=/tmp/kandev-sidebar-go-cache` because the +default cache was read-only. Browser runs required permission to bind the isolated +backend's local port. Backend artifacts were reused after frontend-only edits; +frontend assets were rebuilt before each affected browser run. Build output retained +existing chunk-size and dynamic-import warnings. diff --git a/docs/plans/sidebar-hover-reveal/task-02-hover-settings.md b/docs/plans/sidebar-hover-reveal/task-02-hover-settings.md new file mode 100644 index 0000000000..d427114b9e --- /dev/null +++ b/docs/plans/sidebar-hover-reveal/task-02-hover-settings.md @@ -0,0 +1,205 @@ +--- +id: "02-hover-settings" +title: "Configure sidebar hover activation" +status: done +wave: 2 +depends_on: + - "01-hover-reveal" +plan: plan.md +requirements: + - REQ-UI-SIDEBAR-HOVER-001 + - REQ-UI-SIDEBAR-HOVER-002 +acceptance_criteria: + - AC-UI-SIDEBAR-HOVER-001.1 + - AC-UI-SIDEBAR-HOVER-002.1 + - AC-UI-SIDEBAR-HOVER-002.2 + - AC-UI-SIDEBAR-HOVER-002.3 + - AC-UI-SIDEBAR-HOVER-002.4 + - AC-UI-SIDEBAR-HOVER-002.5 +system_design: + - ../../specs/ui/system-design/sidebar-hover-reveal.md +--- + +# Task 02: Configure Sidebar Hover Activation + +## Summary + +Add two portable user preferences through the existing appearance save flow and +apply them to the implemented sidebar reveal. Use TDD for defaults, validation, +persistence, timer behavior and responsive settings interactions. + +## In scope + +Backend JSON settings, PATCH validation, DTOs and live projection; common frontend +mapping, appearance draft/save state, a labelled settings card and discovery +entries; configurable hover hook; five-locale copy and public documentation. + +## Out of scope + +New endpoints, schema migrations, new mobile navigation, other sidebar preferences, +changing the running main instance, deployment, commits and PR operations. + +## Acceptance + +- New and legacy settings produce enabled/500 defaults; false/zero and custom + delays persist independently. Invalid writes are atomic and do not broadcast. +- Appearance controls support draft, validation, discard, save failure, reload and + live synchronization; saved changes safely cancel old hover interaction. +- Desktop and phone browser checks prove UI-03/UI-04, custom/disabled behavior, + explicit expansion, accessible controls and unchanged phone tap navigation. + +## ASCII UI preview + +UI-03/UI-04: Preferences > Appearance > Sidebar (AC-UI-SIDEBAR-HOVER-002.1-.5). +Full composition and scroll ownership: [plan](plan.md#ascii-ui-preview-1). + +```text +Desktop: Show sidebar on hover [on] Hover delay (ms) [500] +Phone: Show sidebar on hover [on] + Hover delay (ms) + [500 ] + Mouse or trackpad only. + [Discard] [Save changes] +Off: Delay value retained; editor disabled. +Invalid: Inline range error; Save changes disabled. +``` + +Keep desktop controls compact and touch hit areas at least 44 px. Phone fields +stack; the existing page scroll and floating save remain. No local save button. +Copy is illustrative and must be localized. + +## Verification + +Run from repository root, sequentially. Workspace dependencies are already installed. +Write failing tests first; rebuild before browser GREEN checks. Use isolated E2E +fixtures, never the main :9998 instance. A warm Go cache is available below. + +```bash +(cd apps/backend && GOCACHE=/tmp/kandev-sidebar-go-cache go test ./internal/user/...) +(cd apps/web && pnpm exec vitest run hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts components/app-sidebar/app-sidebar.test.tsx components/settings/sidebar-hover-settings-card.test.tsx components/settings/appearance-settings-state.test.ts components/settings/general-settings.test.tsx lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts lib/state/slices/settings/settings-slice.test.ts lib/state/hydration/hydrator.test.ts hooks/use-ensure-user-settings.test.ts lib/settings-discovery) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint hooks/domains/sidebar/use-sidebar-hover-reveal.ts components/app-sidebar/app-sidebar.tsx components/settings/sidebar-hover-settings-card.tsx components/settings/appearance-account-sections.tsx components/settings/appearance-settings-state.ts components/settings/general-settings.tsx lib/ssr/user-settings.ts lib/types/http-user-settings.ts lib/state/slices/settings/types.ts lib/settings-discovery/catalog/preferences.ts) +(cd apps/web && pnpm run i18n:zh-hant) +(cd apps/web && pnpm run i18n:pseudo) +(cd apps/web && pnpm run i18n:check) +GOCACHE=/tmp/kandev-sidebar-go-cache make -C apps/backend build +GOCACHE=/tmp/kandev-sidebar-go-cache make -C apps/backend e2e-plugin-package +make build-web-e2e +(cd apps/web && pnpm e2e:run --host --no-build --project chromium e2e/tests/settings/sidebar-hover-settings.spec.ts e2e/tests/layout/sidebar-hover-reveal.spec.ts -- --retries=0) +(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome e2e/tests/settings/mobile-sidebar-hover-settings.spec.ts e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts -- --retries=0) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/user/{models/models.go,dto/dto.go,service/service.go,store/sqlite.go}` + and the existing request adapter found from `UpdateUserSettingsRequest` callers; + new `sidebar_hover_settings_test.go` in user store/service/handlers. +- `apps/web/lib/{types/http-user-settings.ts,ssr/user-settings.ts,state/slices/settings/types.ts}` + and corresponding mapper, hydration, WS and settings slice tests. +- `apps/web/components/settings/{sidebar-hover-settings-card.tsx,appearance-account-sections.tsx,appearance-settings-state.ts,general-settings.tsx}` + and card/draft/general-settings tests. +- `apps/web/lib/settings-discovery/catalog/preferences.ts`, its existing catalog tests, + and `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/settings.json`. +- Existing sidebar hook and AppSidebar with tests, the two new settings E2E specs, + `docs/public/tasks-and-workflows.md`, and this package's statuses/results. + +## Dependencies + +Task 01 is done. Read scoped backend and web AGENTS.md before implementation. +No separate schema or rollout work is required; use existing account settings. + +## Risks + +Pointer update fields must preserve explicit false/zero. Numeric drafts must not +coerce empty text into zero. Cancelling a revealed panel must safely return hidden +focus. Rebase logic must preserve dirty fields during live updates. Complete +wire payloads and accepted revisions must be consistent across all transports. + +## Parallelism + +sequential + +## Inputs + +- Paired hover requirements and system design, especially the settings extension. +- ADR 0041 portable settings and ADR 0046 route save coordinator. +- Existing status-bar settings persistence and appearance draft tests. +- `e2e/tests/settings/mobile-general-settings.spec.ts` for phone save geometry. + +## Results + +Completed on 2026-09-14. + +| Required verification | Result | +| --- | --- | +| `go test ./internal/user/...` with the documented GOCACHE | Passed all six packages | +| Additional `go test ./internal/settingscatalog` and focused backend boot-projection tests | Passed | +| Targeted Vitest command above, including ensure-user-settings fixture coverage | 223 tests passed in 17 files | +| Typecheck and targeted ESLint | Passed; zero lint warnings | +| Traditional Chinese and pseudo generation, `i18n:check` | Passed; new copy complete in all supported locales | +| Backend build, E2E plugin package and `make build-web-e2e` | Passed | +| Chromium settings and hover specs | 5 passed, retries disabled | +| Mobile settings and navigation specs | 2 passed, retries disabled | +| Spec catalog, full spec lint, public-doc validator tests and public docs | Passed | +| `git diff --check` | Passed | + +RED evidence: backend tests rejected missing defaults and silently ignored invalid +patches; frontend tests exposed fixed timers and missing wire/draft state; browser +RED confirmed the old frontend lacked the new toggle. Browser verification then +caught focus restoration running on explicit collapse; a failing hook regression +proved that path before it was restricted to preference changes. Final tests cover +both focus return on settings changes and normal pointer-exit collapse. + +The existing settings navigation expands the sidebar when entering settings; +the disabled-hover browser case explicitly collapses it after returning home. +Desktop input height is 28 px; phone input/switch targets are at least 44 px with +zero page overflow. Rendered desktop and phone screenshots match the card grouping, +labels, units, disabled state and shared save flow in UI-03/UI-04. + +Generated locale runs were limited in the diff to this feature's new keys; +unrelated regenerated translations were retained as they were. Builds retained +existing dynamic-import/chunk-size warnings. E2E artifact freshness checks required +rebuilding the plugin fixture after locale generation. Browser sockets required +sandbox escalation and used disposable E2E instances. + +The existing seeded Tailscale test instance was refreshed separately, preserving +its database and shutdown script. Main instance :9998 was not restarted or changed. + + +## Appearance heading follow-up + +Use the shared icon-bearing section heading above the Sidebar card. The backend +and frontend default remains 500 ms; the isolated test account had a saved 100 ms +value and was reset to 500 ms at the user's request. + +Follow-up verification: 67 tests in three settings/mapper suites passed; targeted +ESLint, frontend build, spec lint and whitespace checks passed. Browser checks on +the existing Tailscale instance confirmed the icon-bearing heading is outside the +card, the saved delay is 500 ms, and the phone viewport has no horizontal overflow. + +## PR review validation + +Merged current main while preserving task auto-focus settings and both sets of +translation keys and hydration tests. Post-merge verification passed: 226 frontend +tests, affected backend settings and boot tests, typecheck, targeted lint, i18n, +spec lint, fresh backend/plugin/web builds, five desktop and two phone E2E tests +with retries disabled. + +Codex identified stale generated settings snapshots. Reproduced with +`cd apps/backend && go run ./cmd/settings-catalog --check`, regenerated with +`go run ./cmd/settings-catalog`, and verified with +`GOCACHE=/tmp/kandev-sidebar-go-cache node --test scripts/settings-contract-ci.test.mjs` +from the repository root. This restores the existing catalog contract without +changing product behavior or requirements. + +CodeRabbit's aggregate review identified focus loss on hover-capability changes. +A regression failed before the fix; the hook now restores toggle focus when hover +or fine-pointer eligibility changes while revealed navigation owns focus. The +existing explicit-collapse regression remains covered. Verification: +`pnpm --dir apps/web exec vitest run hooks/domains/sidebar/use-sidebar-hover-reveal.test.ts components/app-sidebar/app-sidebar.test.tsx` +and focused hook ESLint. This enforces the existing focus-restoration contract. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 2119bbd942..03937030ce 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -461,6 +461,10 @@ Office workspaces keep their Office Home while Office is enabled. With Office di Explicit view selections (including Kanban, Pipeline, and List), task, session, workflow, overview, and focused Threads links keep their destination on reload instead of applying the saved Threads default. A task's **Task overview** or Back action still opens the overview family using the remembered listing; it does not apply the fixed Threads default. +By default, on desktop, hover over the collapsed sidebar for half a second to reveal its full navigation without moving the page. It closes when the pointer and focus leave the sidebar and its menus. Select **Expand sidebar** to keep it open. On phones, use the existing navigation menu by tapping its button. + +In **Settings → Preferences → Appearance → Sidebar**, turn **Show sidebar on hover** on or off and set **Hover delay (ms)** from 0 to 5000 (default 500). Zero reveals immediately. Choose **Save changes** to apply the settings across your browsers. Turning hover off retains the delay and leaves explicit expansion available. You can edit these preferences on a phone, but hover activation requires a mouse or trackpad. + The **TASKS** list in the left sidebar has two time-based sort choices. These choices are separate from the sort choices in the task **List** view. | Sort choice | Meaning | diff --git a/docs/specs/ui/requirements/sidebar-hover-reveal.md b/docs/specs/ui/requirements/sidebar-hover-reveal.md new file mode 100644 index 0000000000..e65165ff2a --- /dev/null +++ b/docs/specs/ui/requirements/sidebar-hover-reveal.md @@ -0,0 +1,92 @@ +--- +status: active +system: ui +created: 2026-09-13 +owners: + - kandev +--- + +# Sidebar Hover Reveal Requirements + +## Overview + +Reveal the collapsed global navigation sidebar after an intentional half-second +hover so users can use its full navigation without permanently expanding it. +UI owns this reusable navigation presentation contract; task and workspace +state retain their existing owners. + +## Intent and interpretation + +The hover reveal is a temporary overlay that closes after interaction leaves it. +Permanent expansion remains explicit. + +## Requirements + +### REQ-UI-SIDEBAR-HOVER-001: Delayed temporary sidebar reveal + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-HOVER-001.1:** While the global sidebar is collapsed and visible + on a hover-capable fine-pointer device, continuous pointer hover shall begin + revealing its full navigation after the configured delay (500 ms by default), when hover reveal is enabled. + Leaving before the delay expires + shall cancel the reveal; a later entry shall require a fresh full delay. +- **AC-UI-SIDEBAR-HOVER-001.2:** The reveal shall overlay the page at the existing + expanded sidebar width without moving page content or the status bar, changing + the saved collapsed preference, or duplicating navigation content. +- **AC-UI-SIDEBAR-HOVER-001.3:** The reveal shall remain usable while the pointer + or keyboard focus is inside it or an interactive surface opened from it. + Once those interactions end outside it, it shall collapse. Escape shall dismiss + the reveal when no child surface consumes Escape, returning focus to a visible + sidebar control if needed. Dismissal shall require a fresh pointer entry to reopen. +- **AC-UI-SIDEBAR-HOVER-001.4:** Explicit expansion through the existing button or + shortcut shall keep the sidebar expanded after pointer exit. Explicit collapse + shall cancel any pending reveal and shall not immediately reopen under a stationary + pointer. Hover shall not alter an already expanded sidebar. +- **AC-UI-SIDEBAR-HOVER-001.5:** Touch input and phone layouts shall not trigger hover + reveal. Existing mobile navigation shall remain accessible by tap, with contained + scrolling and no horizontal page overflow. A change to an ineligible viewport or + input mode shall cancel pending and visible hover reveals without changing the + saved desktop preference. + +### REQ-UI-SIDEBAR-HOVER-002: User-configurable hover activation + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-HOVER-002.1:** Preferences > Appearance shall expose a Sidebar + group with a “Show sidebar on hover” toggle and “Hover delay (ms)” numeric field. + Hover shall default to enabled and the delay to 500 ms for new and existing + users without saved values. Explicit disabled and zero-delay values shall survive reload. +- **AC-UI-SIDEBAR-HOVER-002.2:** Users shall save whole-millisecond delays from + 0 through 5000 inclusive. Zero shall reveal without a dwell. Empty, fractional, + negative or greater-than-5000 input shall show a validation error and shall not + save. Disabling hover shall retain the last valid delay and disable its editor. +- **AC-UI-SIDEBAR-HOVER-002.3:** Both preferences shall use the existing explicit + Save changes and discard flow and persist per user across reloads and browsers. + Save failure shall retain the editable draft and the previously effective values. + Saving unrelated settings shall not reset either hover preference. +- **AC-UI-SIDEBAR-HOVER-002.4:** Saving or receiving a changed hover preference + shall cancel pending hover activation and dismiss any temporary reveal safely, + without changing saved collapse or hiding focus in the collapsed rail. A fresh + pointer entry shall use the new setting. Disabled hover shall never activate + a reveal; explicit expansion and shortcuts shall still work. +- **AC-UI-SIDEBAR-HOVER-002.5:** Both settings shall remain editable on phones + with labelled touch controls and no horizontal overflow. Helper text shall + explain that hover applies to a mouse or trackpad. Touch navigation shall remain + tap-based regardless of the saved preference. + +## Follow-up design choices + +The settings extension was implemented and tested on 2026-09-14. Default-on is explicitly requested; 500 ms +preserves the tested default. The 0–5000 ms whole-number range is an implementation +choice providing immediate reveal through a deliberate five-second dwell. +Preferences follow the existing account-owned appearance settings and save flow. + +## Out of scope + +Changes to task or review sidebars, additional hover preferences, new endpoints, +new mobile navigation, resizing a temporary reveal, and navigation content redesign. + +## Implementation plan + +[Sidebar hover reveal](../../../plans/sidebar-hover-reveal/plan.md) diff --git a/docs/specs/ui/system-design/sidebar-hover-reveal.md b/docs/specs/ui/system-design/sidebar-hover-reveal.md new file mode 100644 index 0000000000..b88e18f187 --- /dev/null +++ b/docs/specs/ui/system-design/sidebar-hover-reveal.md @@ -0,0 +1,174 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-SIDEBAR-HOVER-001 + - REQ-UI-SIDEBAR-HOVER-002 +--- + +# Sidebar Hover Reveal System Design + +## Boundary and existing behavior + +`apps/web/components/app-sidebar/app-sidebar.tsx` owns the global sidebar. +Its layout wrapper currently snaps between 56 px and the stored expanded width; +its absolutely positioned aside animates width over 300 ms. Keep that explicit +collapse/expand behavior. The hover reveal changes only the visual aside. + +## Requirement mapping + +| Criteria for REQ-UI-SIDEBAR-HOVER-001 | Design section | +| --- | --- | +| .1, .3, .4 | Interaction state | +| .2 | Rendering and persistence | +| .5 | Responsive behavior | + +## Interaction state + +Add a local hook under `hooks/domains/sidebar/use-sidebar-hover-reveal.ts` to own +one cancellable timer using the saved delay (default 500 ms) and transient reveal state. Use pointer entry/exit, +focus containment, and cleanup; do not use the persisted collapse setter for hover. +Eligibility requires a visible sidebar (`!isMobile` from `useResponsiveBreakpoint`), +fine pointer, hover capability, and a non-touch pointer event. Subscribe to hover +capability changes and clean up that listener. + +Cancel on pointer exit before timeout, unmount, explicit toggles, route changes, +and eligibility loss. A stale callback must not reveal after cancellation. +An explicit collapse or Escape dismissal suppresses activation until the next +outside-to-inside pointer entry. Route changes dismiss transient state so navigation +does not leave an overlay covering the destination. + +After reveal, retain it while pointer/focus remains in the sidebar or a sidebar-owned +interactive portal. Use actual open-state callbacks or scoped ownership on those +portal roots; do not keep the sidebar open for every application dialog. Inventory +workspace picker, settings/footer popovers, task menus and plugin navigation portals +before wiring containment. Pointer transitions into owned portals must not unmount +the initiating control. On final pointer/focus exit, close without an added dwell. +Respect nested Escape handling; only an unhandled Escape closes the parent reveal. +If dismissal would hide the focused control, return focus to the persistent expand +control without starting a hover timer. + +## Rendering and persistence + +Keep wrapper width derived from the saved `appSidebar.collapsed` value. Derive visual +collapse from saved collapse plus transient reveal, and pass it to navigation/footer. +Keep `data-collapsed` describing saved state; expose a separate `data-hover-revealed` +attribute for transient state. Reuse the existing absolute aside, stacking and +reduced-motion-aware transition. Do not mount a second sidebar. + +Adapt `AppSidebarHeader` to separate expanded presentation from the saved toggle +state: during reveal it shows the workspace picker and the existing localized +“Expand sidebar” action, which invokes the existing persistent toggle. It must not +show a collapse label on an action that pins the sidebar open. Show the resize +handle only for persistent expansion. While hover-revealed, the workspace picker +uses its existing local open state: the global `setWorkspacePickerOpen` action +intentionally persists rail expansion for the keyboard shortcut and must not run +for a temporary pointer-opened picker. Existing shortcut/store changes clear +transient state. Hover state itself remains transient. The following settings extension adds two +fields to the existing user-settings contract; no new endpoint or metrics. + +## Responsive behavior + +The shipped `components/kanban/mobile-menu-sheet.tsx` and +`components/task/mobile/session-task-switcher-sheet.tsx` provide the phone navigation +exemplars: visible menu trigger, inset drawer, fixed header and internal scrolling. +Retain these compositions and shared workspace/task actions. Phone navigation opens +by tap, then selects a destination; no hover dependency is introduced. Preserve +safe-area clearance, dynamic-height containment and existing touch targets. On +coarse-pointer wider screens retain explicit sidebar controls. Test 767/768 px and +pointer-mode changes to ensure hidden or touch surfaces cannot activate the reveal. + +## Verification + +Fake-timer hook tests prove 499/500 ms boundaries, fresh dwell, cleanup and stale +callback cancellation. Component tests prove visual versus saved state, no duplicate +navigation, toggle semantics, focus retention and owned portal interactions. +Playwright proves real overlay geometry, navigation and nested menus on desktop; +phone tests open the existing task drawer and navigate with no page overflow. +Existing toggle animation and resize tests remain regression coverage. + +## Related contracts + +- [App status bar](app-status-bar.md): layout reservation remains unchanged on hover. +- [Requirements](../requirements/sidebar-hover-reveal.md) + +## Hover settings + +REQ-UI-SIDEBAR-HOVER-002 maps .1-.3 to Preference storage and Settings UI, +.4 to Runtime updates, and .5 to Settings UI and the existing Responsive behavior. +UI remains the owner because these settings control reusable navigation presentation. +Reuse [ADR 0041](../../../decisions/0041-backend-owned-portable-user-settings.md) +and [ADR 0046](../../../decisions/0046-settings-route-save-coordinator.md); no new +persistence owner or ADR is needed. + +### Preference storage + +Extend `internal/user/models/models.go`, `dto/dto.go`, `service/service.go` and +`store/sqlite.go` with `sidebar_hover_enabled` (boolean, default true) and +`sidebar_hover_delay_ms` (integer 0..5000, default 500). Use pointer fields for +partial updates so omitted values remain unchanged and false/zero are explicit. +Validate before mutation; invalid requests must neither save nor broadcast. +Use `internal/user/controller/controller.go` for the DTO-to-service adapter and +register the fields in `internal/settingscatalog/defaults.go` for configuration +discovery. Include the Go boot projection in `internal/backendapp/boot_state_routes.go`. Extend the existing JSON payload serialization, +default overlay, complete response DTO and revisioned user-settings broadcast. +No SQL schema migration is needed. Test legacy JSON with missing fields separately +from explicit false/zero; malformed legacy delay falls back to 500 without +resetting other settings. Reuse existing per-user identity and authorization. + +Extend `lib/types/http-user-settings.ts`, `lib/state/slices/settings/types.ts` +and `lib/ssr/user-settings.ts` with camel-case store fields `sidebarHoverEnabled` +and `sidebarHoverDelayMs`. Reuse the common mapper for boot, save responses and WS +updates; partial payloads retain current values. Do not introduce browser persistence +or consumer-specific defaults. Cover hydration and revision ordering regressions. + +### Settings UI + +Use an icon-bearing `SettingsSection` heading above the sidebar card, matching +the surrounding Appearance sections; keep controls inside the card. Add +`components/settings/sidebar-hover-settings-card.tsx` to +`AppearanceAccountSections` alongside the existing status-bar appearance card. +Extend `AppearanceState`, saved-state construction, minimal patch builder, draft +rebase and revision functions in `appearance-settings-state.ts`; integrate validation +with the existing contributor in `general-settings.tsx`. Preserve unsaved values +when remote settings arrive and use accepted backend responses as saved state. +Keep numeric input text as a draft so clearing a field cannot silently become zero; +only a finite integer within bounds may enter the save payload. Disabling while +input is invalid restores the last valid delay before disabling the editor. +Use the route Save changes / discard surface; no new independent Save button. +Failures leave the contributor dirty and the effective saved hover state unchanged. + +Register searchable toggle and delay entries in +`lib/settings-discovery/catalog/preferences.ts` with anchors targeting their actual +controls. Localize labels, helper text and errors in all five supported locale +catalogs; generate Traditional Chinese with the existing script. + +Reuse SettingsCard, Label, Input, Switch and settings control sizing helpers. +Desktop numeric inputs use 28 px ordinary control height; phone and coarse-pointer +controls have at least 44 px hit areas. Stack the numeric label above its input on +phone, retain the existing settings page vertical scroll owner and floating save +surface, and avoid new drawers. The shipped `mobile-general-settings.spec.ts` +is the settings composition/test exemplar. Settings remain available on phones +because they configure the account's mouse/trackpad behavior. + +### Runtime updates + +Pass the saved enabled/delay fields from AppSidebar into `useSidebarHoverReveal`. +Enabled is part of eligibility; the timer uses the delay argument. Include both +values in cancellation dependencies so stale callbacks cannot reveal using old +preferences. Preference changes dismiss transient state and require fresh pointer entry; +restore focus to the visible toggle if dismissal would hide focus. Gate that +restoration on a preference change so an ordinary explicit collapse cannot retain +stale focus and hold a later hover open. Explicit +expansion remains independent. Zero schedules activation without dwell, still +using cancellable timer ownership. No saved collapse writes occur on setting changes. + +### Extension verification + +Backend tests cover defaults, false/zero preservation, independent patches, bounds, +invalid-request atomicity, persistence and emitted values. Mapper/draft tests cover +boot/WS/partial updates, save/discard/failure and invalid numeric drafts. Fake-timer +hook tests prove disabled activation, 0 ms and custom timing, cancellation during +pending/revealed states and focus safety. Desktop browser tests save both controls, +reload, check disabled/manual behavior and a custom delay. Phone tests edit/save/reload +both controls, measure touch targets and overflow, and preserve tap navigation.
+ {t("settings:sidebarHoverDelayError")} +
+ {t("settings:sidebarHoverHelp")} +