feat(ui): reveal collapsed sidebar on configurable hover (#3678)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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ũēśţēď."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -790,6 +790,12 @@
|
||||
"unitRemoveMember": "移除成员",
|
||||
"unitPickPerson": "选择成员",
|
||||
"unitActionFailed": "操作未成功。",
|
||||
"sidebarHoverTitle": "侧边栏",
|
||||
"sidebarHoverEnabled": "悬停时显示侧边栏",
|
||||
"sidebarHoverDelay": "悬停延迟(毫秒)",
|
||||
"sidebarHoverHelp": "使用鼠标或触控板悬停以显示已折叠的侧边栏。延迟可设为 0 到 5000 毫秒;设为 0 时立即显示。",
|
||||
"sidebarHoverDelayError": "请输入 0 到 5000 之间的整数。",
|
||||
"discoveryAliasesSidebarHover": "侧边栏 悬停 显示 延迟 等待 鼠标 触控板",
|
||||
"autoFocusNewTasks": "自动聚焦新任务",
|
||||
"autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。"
|
||||
}
|
||||
|
||||
@@ -790,6 +790,12 @@
|
||||
"unitRemoveMember": "移除成員",
|
||||
"unitPickPerson": "選擇成員",
|
||||
"unitActionFailed": "操作未成功。",
|
||||
"sidebarHoverTitle": "側邊欄",
|
||||
"sidebarHoverEnabled": "懸停時顯示側邊欄",
|
||||
"sidebarHoverDelay": "懸停延遲(毫秒)",
|
||||
"sidebarHoverHelp": "使用鼠標或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
|
||||
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
|
||||
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 鼠標 觸控板",
|
||||
"autoFocusNewTasks": "自動聚焦新任務",
|
||||
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。"
|
||||
}
|
||||
|
||||
@@ -790,6 +790,12 @@
|
||||
"unitRemoveMember": "移除成員",
|
||||
"unitPickPerson": "選擇成員",
|
||||
"unitActionFailed": "操作未成功。",
|
||||
"sidebarHoverTitle": "側邊欄",
|
||||
"sidebarHoverEnabled": "懸停時顯示側邊欄",
|
||||
"sidebarHoverDelay": "懸停延遲(毫秒)",
|
||||
"sidebarHoverHelp": "使用滑鼠或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
|
||||
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
|
||||
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 滑鼠 觸控板",
|
||||
"autoFocusNewTasks": "自動聚焦新任務",
|
||||
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user