feat(ui): reveal collapsed sidebar on configurable hover (#3678)

This commit is contained in:
José Almeida
2026-09-15 11:43:47 +02:00
committed by GitHub
parent f6bf42c662
commit b10ed80fa2
50 changed files with 2234 additions and 16 deletions
@@ -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"])
}
}
@@ -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,
@@ -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",
@@ -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,
+6
View File
@@ -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,
@@ -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")
}
})
}
}
@@ -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"`
@@ -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,
@@ -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
}
@@ -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"])
}
}
@@ -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"])
}
})
}
}
@@ -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
}
@@ -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(
<TooltipProvider>
<WorkspaceScopeProvider>
<AppSidebarHeader collapsed={false} hoverRevealed onToggleCollapse={toggle} />
</WorkspaceScopeProvider>
</TooltipProvider>,
);
expect(screen.getByTestId("workspace-picker")).toBeTruthy();
screen.getByRole("button", { name: "Expand sidebar" }).click();
expect(toggle).toHaveBeenCalledOnce();
});
@@ -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
<span aria-hidden className="shrink-0 select-none text-muted-foreground/30">
/
</span>
<AppSidebarWorkspacePicker open={pickerOpen} onOpenChange={setPickerOpen} />
{/* Hover already provides an anchor; the global picker action persistently expands the rail. */}
<AppSidebarWorkspacePicker {...pickerProps} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={COLLAPSE_BUTTON_CLASS}
data-sidebar-toggle
onClick={onToggleCollapse}
aria-label={t("sidebar:collapseSidebar")}
aria-label={toggleLabel}
>
<IconLayoutSidebarLeftCollapse className="h-4 w-4" />
<ToggleIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("sidebar:collapseSidebar")}</TooltipContent>
<TooltipContent side="top">{toggleLabel}</TooltipContent>
</Tooltip>
</div>
);
@@ -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();
});
});
@@ -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 }}
>
<aside
ref={hover.ref}
{...hover.handlers}
data-hover-revealed={hover.revealed ? "true" : "false"}
data-testid="app-sidebar"
data-collapsed={collapsed ? "true" : "false"}
className={cn(
@@ -256,15 +268,26 @@ export function AppSidebar() {
? "transition-none"
: "transition-[width] duration-300 ease-out motion-reduce:transition-none",
)}
style={{ width: targetWidth }}
style={{ width: visuallyCollapsed ? APP_SIDEBAR_COLLAPSED_WIDTH : expandedWidth }}
>
<AppSidebarHeader collapsed={collapsed} onToggleCollapse={toggleCollapsed} />
<AppSidebarHeader
collapsed={visuallyCollapsed}
hoverRevealed={hover.revealed}
onToggleCollapse={handleToggleCollapse}
/>
<div
data-testid="app-sidebar-content"
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<AppSidebarNavigation collapsed={collapsed} mode={mode} settingsMode={settingsMode} />
<AppSidebarFooter collapsed={collapsed} onToggleSettingsMode={handleToggleSettingsMode} />
<AppSidebarNavigation
collapsed={visuallyCollapsed}
mode={mode}
settingsMode={settingsMode}
/>
<AppSidebarFooter
collapsed={visuallyCollapsed}
onToggleSettingsMode={handleToggleSettingsMode}
/>
</div>
{!collapsed && <AppSidebarResizeHandle onMouseDown={handleResize} />}
</aside>
@@ -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 (
<>
<Separator />
<SettingsSection
icon={<IconLayoutSidebar className="h-5 w-5" />}
title={t("settings:sidebarHoverTitle")}
>
<SidebarHoverSettingsCard draft={draft} saved={saved} updateDraft={updateDraft} />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconActivity className="h-5 w-5" />}
@@ -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();
});
@@ -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;
}
@@ -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");
});
@@ -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),
@@ -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 (
<SidebarHoverSettingsCard
draft={draft}
saved={saved}
updateDraft={(patch) => setDraft((current) => ({ ...current, ...patch }))}
/>
);
}
it("retains the last valid delay when disabling an invalid draft", () => {
render(<Editor />);
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");
});
@@ -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<AppearanceState>) => 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 (
<SettingsCard isDirty={enabledDirty || delayDirty} data-testid="sidebar-hover-settings-card">
<CardContent className="space-y-4">
<div ref={toggleTarget} className="flex items-center justify-between gap-4">
<Label htmlFor="sidebar-hover-enabled">{t("settings:sidebarHoverEnabled")}</Label>
<Switch
id="sidebar-hover-enabled"
checked={draft.sidebarHoverEnabled}
data-settings-dirty={enabledDirty}
onCheckedChange={(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"
/>
</div>
<div ref={delayTarget} className="space-y-2">
<Label htmlFor="sidebar-hover-delay">{t("settings:sidebarHoverDelay")}</Label>
<Input
id="sidebar-hover-delay"
type="number"
inputMode="numeric"
min={0}
max={5000}
step={1}
value={draft.sidebarHoverDelayMs}
disabled={!draft.sidebarHoverEnabled}
aria-invalid={delay === null}
aria-describedby={delay === null ? "sidebar-hover-delay-error" : "sidebar-hover-help"}
data-settings-dirty={delayDirty}
className={settingsControlClassName("md:max-w-40")}
onChange={(event) => {
const value = event.target.value;
if (parseSidebarHoverDelay(value) !== null) lastValidDelay.current = value;
updateDraft({ sidebarHoverDelayMs: value });
}}
/>
{delay === null && (
<p id="sidebar-hover-delay-error" className="text-sm text-destructive" role="alert">
{t("settings:sidebarHoverDelayError")}
</p>
)}
</div>
<p id="sidebar-hover-help" className="text-xs text-muted-foreground">
{t("settings:sidebarHoverHelp")}
</p>
</CardContent>
</SettingsCard>
);
}
@@ -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);
});
@@ -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");
});
@@ -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();
});
@@ -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");
});
@@ -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<HTMLElement>;
// @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<typeof useSidebarHoverReveal>;
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<typeof useSidebarHoverReveal>;
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<HTMLButtonElement>("[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<typeof useSidebarHoverReveal>;
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<HTMLButtonElement>("[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<typeof useSidebarHoverReveal>;
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<HTMLButtonElement>("[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]"));
},
);
@@ -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<HTMLButtonElement>("[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<HTMLElement>(null);
const [revealed, setRevealed] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | 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<HTMLElement>) {
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 };
}
@@ -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: [] },
@@ -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",
@@ -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",
@@ -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",
+16
View File
@@ -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", () => {
+4
View File
@@ -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,
@@ -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<string, string[]>;
+4
View File
@@ -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<string, string[]>;
@@ -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<string, string[]>;
+16
View File
@@ -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,
});
});
+6
View File
@@ -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."
}
@@ -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ũēśţēď."
}
+6
View File
@@ -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."
}
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "移除成员",
"unitPickPerson": "选择成员",
"unitActionFailed": "操作未成功。",
"sidebarHoverTitle": "侧边栏",
"sidebarHoverEnabled": "悬停时显示侧边栏",
"sidebarHoverDelay": "悬停延迟(毫秒)",
"sidebarHoverHelp": "使用鼠标或触控板悬停以显示已折叠的侧边栏。延迟可设为 0 到 5000 毫秒;设为 0 时立即显示。",
"sidebarHoverDelayError": "请输入 0 到 5000 之间的整数。",
"discoveryAliasesSidebarHover": "侧边栏 悬停 显示 延迟 等待 鼠标 触控板",
"autoFocusNewTasks": "自动聚焦新任务",
"autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。"
}
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "移除成員",
"unitPickPerson": "選擇成員",
"unitActionFailed": "操作未成功。",
"sidebarHoverTitle": "側邊欄",
"sidebarHoverEnabled": "懸停時顯示側邊欄",
"sidebarHoverDelay": "懸停延遲(毫秒)",
"sidebarHoverHelp": "使用鼠標或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 鼠標 觸控板",
"autoFocusNewTasks": "自動聚焦新任務",
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。"
}
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "移除成員",
"unitPickPerson": "選擇成員",
"unitActionFailed": "操作未成功。",
"sidebarHoverTitle": "側邊欄",
"sidebarHoverEnabled": "懸停時顯示側邊欄",
"sidebarHoverDelay": "懸停延遲(毫秒)",
"sidebarHoverHelp": "使用滑鼠或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 滑鼠 觸控板",
"autoFocusNewTasks": "自動聚焦新任務",
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。"
}
+208
View File
@@ -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.
@@ -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.
@@ -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.
+4
View File
@@ -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 |
@@ -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)
@@ -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.