diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index f8f3ce85322..87c1a884809 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -12,6 +12,7 @@ :root { --sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */ --sidebar-collapsed-width: 51px; /* icon rail on web; desktop overrides to 0 before first paint */ + --sidebar-expanded-width: 248px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */ --desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */ --desktop-title-bar-inset-x: 0px; /* clearance past the traffic lights; desktop overrides */ --desktop-title-bar-control-offset: 0px; /* centres a lane control; desktop overrides */ @@ -154,6 +155,24 @@ html[data-sim-desktop-title-bar="inset"] --sidebar-width: var(--sidebar-collapsed-width); } +/** + * Hover-peek: the shell floats out of flow as a card, so its subtree reads the restore + * width instead of the collapsed one. Re-declaring the same variable the rule above + * sets carries the inner shell and the aside along with no per-element overrides. + * + * Lives here rather than on the component because a Tailwind arbitrary property is one + * class (0,1,0) and would lose to that rule's (0,2,0) selector. + */ +.sidebar-shell-outer[data-collapsed][data-peek] { + --sidebar-width: var(--sidebar-expanded-width); +} + +/* The card appears at full width, so the aside's own width transition would animate + 0 -> expanded inside it. */ +.sidebar-shell-outer[data-peek] .sidebar-container { + transition: none; +} + .sidebar-container span, .sidebar-container .text-small { transition: opacity 120ms ease; diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 035405a5ae6..3a42fca1bc8 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -112,22 +112,26 @@ export default function RootLayout({ children }: { children: React.ReactNode }) document.cookie = 'sidebar_collapsed=' + (collapsed ? '1' : '0') + '; path=/; max-age=31536000; samesite=lax'; } - if (collapsed) { - document.documentElement.style.setProperty( - '--sidebar-width', - collapsedSidebarWidth + 'px' - ); - } else { - var width = state && state.sidebarWidth; - var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); - var finalWidth = - typeof width === 'number' && isFinite(width) - ? Math.min(Math.max(width, 248), maxSidebarWidth) - : defaultSidebarWidth; - document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); - } + // The expanded width is published unconditionally, even while + // collapsed, because the desktop hover-peek renders the sidebar at + // its restore width while --sidebar-width still reads collapsed. + var width = state && state.sidebarWidth; + var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); + var expandedWidth = + typeof width === 'number' && isFinite(width) + ? Math.min(Math.max(width, 248), maxSidebarWidth) + : defaultSidebarWidth; + document.documentElement.style.setProperty( + '--sidebar-expanded-width', + expandedWidth + 'px' + ); + document.documentElement.style.setProperty( + '--sidebar-width', + (collapsed ? collapsedSidebarWidth : expandedWidth) + 'px' + ); } catch (e) { document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); + document.documentElement.style.setProperty('--sidebar-expanded-width', defaultSidebarWidth + 'px'); } // Panel width and active tab diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx new file mode 100644 index 00000000000..320c266fcb2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx @@ -0,0 +1,470 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PEEK_CLOSE_DELAY_MS as CLOSE_DELAY_MS, + PEEK_EXIT_DURATION_MS as EXIT_DURATION_MS, + PEEK_OPEN_DELAY_MS as OPEN_DELAY_MS, + PEEK_POINTER_SAMPLE_MS as POINTER_SAMPLE_MS, + useSidebarPeek, +} from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek' + +/** + * Screen geometry the hook hit-tests against. jsdom gives every element a zero + * rect, so the harness stubs these — without them every coordinate falls inside + * the card's 0×0 box (padded by the gap tolerance) and the peek never retracts. + * Values mirror the real desktop layout: a 32px toggle in the title-bar lane, and + * the card inset 8px from the left starting below that lane. + */ +const TRIGGER_RECT = { left: 78, top: 4, right: 110, bottom: 36 } +const CARD_RECT = { left: 8, top: 40, right: 258, bottom: 852 } + +/** Points used by the tests, in client coordinates. */ +const POINT = { + onTrigger: [94, 20], + inCard: [120, 300], + inGap: [100, 38], + onContent: [800, 400], +} as const + +function stubRect( + element: HTMLElement, + rect: { left: number; top: number; right: number; bottom: number } +) { + element.getBoundingClientRect = () => + ({ + ...rect, + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + toJSON: () => rect, + }) as DOMRect +} + +interface Harness { + state: () => { isPeekActive: boolean; isPeekOpen: boolean } + triggerEnter: () => void + triggerLeave: () => void + setEnabled: (enabled: boolean) => void + setDismissed: (dismissed: boolean) => void + unmount: () => void +} + +/** + * Minimal dependency-free hook harness (the repo has no `@testing-library/react`). + * Mounts the hook in a real React root under jsdom so the refs point at live nodes, + * then stubs their rects — the close hit-test reads geometry off those refs. + */ +function renderPeek(initialEnabled: boolean): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + let latest = { isPeekActive: false, isPeekOpen: false } + let onTriggerEnter = () => {} + let onTriggerLeave = () => {} + + function Probe({ enabled, dismissed }: { enabled: boolean; dismissed: boolean }) { + const peek = useSidebarPeek(enabled, dismissed) + latest = { isPeekActive: peek.isPeekActive, isPeekOpen: peek.isPeekOpen } + onTriggerEnter = peek.onTriggerEnter + onTriggerLeave = peek.onTriggerLeave + return ( +
+
+
+
+ ) + } + + let props = { enabled: initialEnabled, dismissed: false } + const render = (next: Partial) => { + props = { ...props, ...next } + act(() => { + root.render() + }) + } + render({}) + + const query = (name: string) => + container.querySelector(`[data-probe="${name}"]`) as HTMLElement + + stubRect(query('card'), CARD_RECT) + stubRect(query('trigger'), TRIGGER_RECT) + + return { + state: () => latest, + triggerEnter: () => onTriggerEnter(), + triggerLeave: () => onTriggerLeave(), + setEnabled: (enabled: boolean) => render({ enabled }), + setDismissed: (dismissed: boolean) => render({ dismissed }), + unmount: () => { + act(() => root.unmount()) + container.remove() + }, + } +} + +/** + * Dispatches a pointer move at client coordinates. `target` only matters for the + * portal check — the card and trigger are matched by geometry. Each call advances the + * clock past the hook's sample floor so consecutive moves are never coalesced. + */ +function movePointerTo([x, y]: readonly [number, number], target: Element = document.body) { + act(() => { + vi.advanceTimersByTime(POINTER_SAMPLE_MS) + target.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y })) + }) +} + +function openPeek(harness: Harness) { + act(() => { + harness.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS) + }) +} + +let active: Harness | null = null +const appended: Element[] = [] + +/** + * Appends a portal-like node to `document.body`, standing in for a menu or tooltip + * the sidebar renders outside the card. Tracked for teardown so a failing assertion + * can never leak a node into the next test. + */ +async function appendToBody(tag: string, attributes: Record) { + const node = document.createElement(tag) + for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value) + appended.push(node) + await act(async () => { + document.body.appendChild(node) + }) + return node +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal( + 'matchMedia', + vi.fn().mockReturnValue({ matches: false }) as unknown as typeof matchMedia + ) +}) + +afterEach(() => { + active?.unmount() + active = null + for (const node of appended.splice(0)) node.remove() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useSidebarPeek', () => { + it('opens only after the hover dwell elapses', () => { + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS - 1) + }) + expect(active.state().isPeekActive).toBe(false) + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(active.state().isPeekActive).toBe(true) + expect(active.state().isPeekOpen).toBe(true) + }) + + it('does not open when the pointer leaves before the dwell elapses', () => { + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS - 10) + active?.triggerLeave() + vi.advanceTimersByTime(OPEN_DELAY_MS) + }) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('never opens while disabled', () => { + active = renderPeek(false) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS * 5) + }) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('stays open while the pointer is inside the card', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.inCard) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 3) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('stays open while the pointer is still over the toggle that opened it', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.onTrigger) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 3) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('stays open while the pointer is over a portalled popper', async () => { + active = renderPeek(true) + openPeek(active) + + const overlay = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + + movePointerTo(POINT.onContent, overlay) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 3) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('retracts after the grace period once the pointer moves to content', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.onContent) + expect(active.state().isPeekOpen).toBe(true) + + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + expect(active.state().isPeekOpen).toBe(false) + // Stays mounted so the fade-out can play. + expect(active.state().isPeekActive).toBe(true) + + act(() => { + vi.advanceTimersByTime(EXIT_DURATION_MS) + }) + expect(active.state().isPeekActive).toBe(false) + }) + + it('stays open while crossing the gap between the toggle and the card', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.inGap) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 2) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('cancels a pending retraction when the pointer returns', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.onContent) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS - 20) + }) + movePointerTo(POINT.inCard) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 2) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('does not open on hover while a modal is already open', () => { + active = renderPeek(true) + active.setDismissed(true) + + openPeek(active) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('never mounts the card when a modal opens mid-dwell', () => { + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS - 20) + }) + active.setDismissed(true) + act(() => { + vi.advanceTimersByTime(OPEN_DELAY_MS * 2) + }) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('drops an already-exiting card the instant a modal opens', () => { + active = renderPeek(true) + openPeek(active) + movePointerTo(POINT.onContent) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + expect(active.state().isPeekActive).toBe(true) + + active.setDismissed(true) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('snaps a card that is animating out back open on re-hover', () => { + active = renderPeek(true) + openPeek(active) + movePointerTo(POINT.onContent) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + // Mid-exit: mounted but no longer open. + expect(active.state().isPeekActive).toBe(true) + expect(active.state().isPeekOpen).toBe(false) + + // Re-hover late in the exit window; the pending exit timer must not win. + act(() => { + vi.advanceTimersByTime(EXIT_DURATION_MS - 20) + active?.triggerEnter() + }) + expect(active.state().isPeekOpen).toBe(true) + + act(() => { + vi.advanceTimersByTime(EXIT_DURATION_MS * 2) + }) + expect(active.state().isPeekOpen).toBe(true) + }) + + it('retracts when a modal opens, even with the pointer inside', () => { + active = renderPeek(true) + openPeek(active) + movePointerTo(POINT.inCard) + + active.setDismissed(true) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('keeps the peek open while the pointer is over a non-modal popper', async () => { + active = renderPeek(true) + openPeek(active) + + const popper = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + movePointerTo(POINT.onContent, popper) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 2) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + /** + * Regression: a modal's scrim is `fixed inset-0` and carries emcn's + * `data-native-surface-overlay` marker, so matching that marker made every pointer + * position read as "inside a popper" and pinned the card open for good. Only Radix's + * popper wrapper counts. + */ + it('retracts even while a full-screen modal scrim is present', () => { + active = renderPeek(true) + openPeek(active) + + const scrim = document.createElement('div') + scrim.setAttribute('data-native-surface-overlay', '') + appended.push(scrim) + document.body.appendChild(scrim) + + movePointerTo(POINT.onContent, scrim) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('leaves Escape to an open popper rather than retracting', async () => { + active = renderPeek(true) + openPeek(active) + // Radix nests the state-bearing content inside the wrapper; only an *open* one + // claims Escape, so a menu animating closed still lets the card retract. + const wrapper = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + const content = document.createElement('div') + content.setAttribute('data-state', 'open') + wrapper.appendChild(content) + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('retracts on Escape when a popper is only animating closed', async () => { + active = renderPeek(true) + openPeek(active) + const wrapper = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + const content = document.createElement('div') + content.setAttribute('data-state', 'closed') + wrapper.appendChild(content) + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('retracts on Escape', () => { + active = renderPeek(true) + openPeek(active) + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('drops the peek immediately when it stops being enabled', () => { + active = renderPeek(true) + openPeek(active) + + active.setEnabled(false) + + expect(active.state().isPeekOpen).toBe(false) + expect(active.state().isPeekActive).toBe(false) + }) + + it('opens under reduced motion', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockReturnValue({ matches: true }) as unknown as typeof matchMedia + ) + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS) + }) + + expect(active.state().isPeekActive).toBe(true) + expect(active.state().isPeekOpen).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts new file mode 100644 index 00000000000..5eedee7a713 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts @@ -0,0 +1,244 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +/** Hover dwell before the card appears, so a cursor passing over the control doesn't trigger it. */ +export const PEEK_OPEN_DELAY_MS = 90 + +/** Grace period after the pointer leaves, so a small overshoot doesn't retract the card. */ +export const PEEK_CLOSE_DELAY_MS = 180 + +/** How long the card stays mounted while its exit animation runs. */ +export const PEEK_EXIT_DURATION_MS = 150 + +/** Floor between pointer hit-tests, so a fast drag doesn't measure rects every event. */ +export const PEEK_POINTER_SAMPLE_MS = 16 + +/** + * Slack around the trigger and the card when hit-testing the pointer. The trigger sits + * in the title-bar lane and the card starts below it, so a straight line between them + * crosses a few pixels belonging to neither. + */ +const PEEK_GAP_TOLERANCE_PX = 12 + +/** + * A transient floating layer — the wrapper Radix puts around popper content, so this + * covers every menu, context menu, select, and popover the sidebar opens. Same + * predicate emcn's own modal uses for "is a floating layer open" (`modal.tsx`). + * + * The pointer over one of these counts as inside the peek — otherwise reaching for a + * context-menu item would retract the card underneath it. + * + * Deliberately NOT the broader `data-native-surface-overlay` marker: emcn stamps that + * on the modal *scrim* too, which is `fixed inset-0`, so a `:not([aria-modal])` filter + * cannot exclude it (Radix puts `aria-modal` on the content, a different node) and any + * open modal would match at every pointer position and pin the card open. Tooltips are + * `pointer-events-none`, so they are never an event target and need no entry here. + */ +const POPPER_SELECTOR = '[data-radix-popper-content-wrapper]' + +/** + * An *open* popper. The `data-state` filter ignores one animating closed, so pressing + * Escape during a menu's exit still reaches the card. Mirrors emcn `modal.tsx`. + */ +const OPEN_POPPER_SELECTOR = `${POPPER_SELECTOR} [data-state="open"]` + +type PeekPhase = 'closed' | 'open' | 'exiting' + +function clearTimer(ref: React.MutableRefObject | null>) { + if (ref.current) { + clearTimeout(ref.current) + ref.current = null + } +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + +/** + * Whether a client point falls inside an element, padded outward by `pad`. + * + * Geometric rather than DOM containment on purpose: the trigger is wrapped in a + * tooltip whose subtree re-renders, and a containment check against a stale or + * detached node reads as "outside" and retracts the card from under the pointer. + * Coordinates cannot go stale. + */ +function containsPoint(element: Element | null, x: number, y: number, pad: number): boolean { + if (!element) return false + const rect = element.getBoundingClientRect() + return ( + x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad + ) +} + +export interface SidebarPeekResult { + /** Card is mounted as a floating overlay — drives positioning, chrome, and the expanded width. */ + isPeekActive: boolean + /** Card is settled open. Goes false first on exit, so the exit animation can play. */ + isPeekOpen: boolean + /** Attach to the floating card so the pointer hit-test can recognise it. */ + cardRef: React.RefObject + /** + * Attach to the control that opens the peek (the title-bar sidebar toggle). It + * counts as inside the peek once open, so travelling from the control into the + * card never reads as leaving. + */ + triggerRef: React.RefObject + onTriggerEnter: () => void + onTriggerLeave: () => void +} + +/** + * Drives the desktop sidebar's hover-peek: hovering the title-bar sidebar toggle + * floats the collapsed sidebar in over the content, and it retracts once the pointer + * leaves. Clicking that same control still docks the sidebar for good. + * + * The `exiting` phase keeps the card mounted for {@link PEEK_EXIT_DURATION_MS} so its + * exit animation can play; unmounting immediately would snap it away mid-animation. + * + * Retraction is detected from a document-level `pointermove` hit-test rather than + * `mouseleave`, because the menus and tooltips the sidebar opens live in body + * portals. A `mouseleave`-driven peek would retract the instant the pointer crossed + * into one of those, so {@link POPPER_SELECTOR} counts as inside. + * + * @param enabled Whether the peek is available at all (collapsed, on the desktop shell). + * Also masks the returned flags, so a consumer never sees a stale open card for the + * render in which the peek became unavailable. + * @param dismissed Force the card closed — a modal is open and owns the screen. + */ +export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeekResult { + const cardRef = useRef(null) + const triggerRef = useRef(null) + const openTimerRef = useRef | null>(null) + const closeTimerRef = useRef | null>(null) + const exitTimerRef = useRef | null>(null) + + const [phase, setPhase] = useState('closed') + + const open = useCallback(() => { + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + setPhase('open') + }, []) + + const close = useCallback(() => { + clearTimer(openTimerRef) + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + setPhase((current) => (current === 'open' ? 'exiting' : current)) + exitTimerRef.current = setTimeout( + () => { + exitTimerRef.current = null + setPhase('closed') + }, + prefersReducedMotion() ? 0 : PEEK_EXIT_DURATION_MS + ) + }, []) + + const onTriggerEnter = useCallback(() => { + // `dismissed` is checked here too, not just in the effect below: that effect only + // fires on the transition, so with a modal already open a hover would otherwise + // float the card over it. + if (!enabled || dismissed) return + clearTimer(closeTimerRef) + clearTimer(openTimerRef) + // Still on screen and animating out: snap it back instead of waiting out another + // dwell, which the pending exit timer would win — unmounting the card and then + // re-mounting it, a visible flicker with the pointer never leaving the toggle. + if (phase === 'exiting') { + open() + return + } + openTimerRef.current = setTimeout(() => { + openTimerRef.current = null + open() + }, PEEK_OPEN_DELAY_MS) + }, [dismissed, enabled, open, phase]) + + const onTriggerLeave = useCallback(() => { + clearTimer(openTimerRef) + }, []) + + /** + * Drop the card outright — no exit animation — the moment the peek stops being + * available (⌘B, fullscreen) or a modal takes the screen. + * + * Unconditional rather than gated on the current phase, because every phase needs + * clearing: a pending dwell would otherwise fire and mount the card over the modal, + * and an in-flight exit would keep animating on top of it. Instant is also right + * visually — the modal's own scrim covers the card's position on the same frame. + */ + useEffect(() => { + if (enabled && !dismissed) return + clearTimer(openTimerRef) + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + setPhase('closed') + }, [dismissed, enabled]) + + useEffect(() => { + if (phase !== 'open') return + + let lastSampleAt = Number.NEGATIVE_INFINITY + + const onPointerMove = (event: PointerEvent) => { + // Sampled on the event's own clock rather than a frame callback: rAF is + // throttled in an unfocused or occluded window, which would strand the card up. + if (event.timeStamp - lastSampleAt < PEEK_POINTER_SAMPLE_MS) return + lastSampleAt = event.timeStamp + + // The tolerance bridges the few px of title-bar lane between the trigger's + // bottom edge and the card's top edge. Poppers are matched by DOM instead — + // they can be anchored anywhere on screen. + const target = event.target instanceof Element ? event.target : null + const inside = + containsPoint(cardRef.current, event.clientX, event.clientY, PEEK_GAP_TOLERANCE_PX) || + containsPoint(triggerRef.current, event.clientX, event.clientY, PEEK_GAP_TOLERANCE_PX) || + Boolean(target?.closest(POPPER_SELECTOR)) + if (inside) { + clearTimer(closeTimerRef) + return + } + if (!closeTimerRef.current) { + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null + close() + }, PEEK_CLOSE_DELAY_MS) + } + } + + const onKeyDown = (event: KeyboardEvent) => { + // An open popper owns Escape first — dismissing a context menu shouldn't also + // take the card out from under the pointer. + if (event.key === 'Escape' && !document.querySelector(OPEN_POPPER_SELECTOR)) close() + } + + document.addEventListener('pointermove', onPointerMove, { passive: true }) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('keydown', onKeyDown) + } + }, [close, phase]) + + useEffect( + () => () => { + clearTimer(openTimerRef) + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + }, + [] + ) + + return { + isPeekActive: phase !== 'closed' && enabled, + isPeekOpen: phase === 'open' && enabled, + cardRef, + triggerRef, + onTriggerEnter, + onTriggerLeave, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index a9e1e226766..7f206c26da2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -1,12 +1,15 @@ 'use client' -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { PanelLeft } from '@sim/emcn/icons' import { usePathname } from 'next/navigation' import { getDesktopBridge } from '@/lib/desktop' +import { applyDesktopTitleBarMode, type DesktopTitleBarMode } from '@/app/_shell/desktop-title-bar' +import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek' import { Sidebar, SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { useFullscreenOriginStore } from '@/stores/fullscreen-origin' +import { useSearchModalStore } from '@/stores/modals/search/store' import { useSidebarStore } from '@/stores/sidebar/store' const FULLSCREEN_SUFFIXES = ['/upgrade'] as const @@ -15,6 +18,45 @@ const FULLSCREEN_SUFFIXES = ['/upgrade'] as const const SLIDE_TRANSITION = 'duration-[175ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none' +/** + * The peek card's floating chrome. + * + * Every value is an existing token: `rounded-lg` is `--radius`, matching the content + * pane it floats beside; `--border` is that pane's border; `shadow-overlay` and + * `--z-modal` are what the app's other edge-anchored panels use. The card's fill is + * the sidebar's own `--surface-1`, so docked and floating are the same surface. + * + * `w-auto` shrink-wraps the inner shell, which `[data-peek]` has already put at the + * expanded width. It must not be a length: `width` cannot interpolate to or from + * `auto`, so entering and leaving the peek snap instead of animating — otherwise the + * card widens as it appears and leaves a shrinking ghost on retract. + */ +const PEEK_CARD_CHROME = + 'absolute top-[var(--desktop-title-bar-height)] bottom-2 left-2 z-[var(--z-modal)] w-auto origin-top-left rounded-lg border border-[var(--border)] shadow-overlay' + +/** + * Peek card enter/exit — the popper idiom rather than a slide, since the card is + * anchored to the title-bar toggle and grows out of that corner exactly as emcn's + * Radix surfaces do (`dropdown-menu.tsx`: `fade-in-0 zoom-in-95`). + * + * Animations rather than transitions: an animation runs from mount, so the card needs + * no hidden "from" frame and no `requestAnimationFrame` to step into — rAF is throttled + * in an unfocused window, which would strand the card mounted-but-invisible. + * + * `duration-150` must match {@link PEEK_EXIT_DURATION_MS}. + */ +const PEEK_CARD_ENTER = cn( + PEEK_CARD_CHROME, + 'animate-in fade-in-0 zoom-in-95 duration-150 ease-out motion-reduce:animate-none' +) +const PEEK_CARD_EXIT = cn( + PEEK_CARD_CHROME, + 'pointer-events-none animate-out fade-out-0 zoom-out-95 fill-mode-forwards duration-150 ease-out motion-reduce:animate-none' +) + +/** The docked rail: in flow, width-animated by the collapse toggle. */ +const SIDEBAR_SHELL_IN_FLOW = cn('transition-[width]', SLIDE_TRANSITION) + interface WorkspaceChromeProps { children: React.ReactNode /** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */ @@ -44,6 +86,12 @@ function isFullscreenPath(pathname: string | null): boolean { * * On a direct load of a fullscreen route the wrapper mounts already collapsed, * so no slide plays (CSS transitions don't run on mount). + * + * On the macOS desktop shell, where collapsing hides the rail entirely, the same + * wrapper doubles as the hover-peek card: hovering the title-bar sidebar toggle + * takes it out of flow, floats it over the content inset from the window edge, and + * re-points `--sidebar-width` at the restore width. The sidebar is never re-mounted + * or duplicated for this — see {@link useSidebarPeek}. */ export function WorkspaceChrome({ children, @@ -56,6 +104,20 @@ export function WorkspaceChrome({ const setOrigin = useFullscreenOriginStore((s) => s.setOrigin) + /** + * Which title-bar treatment the host is using. `inset` is the macOS desktop shell, + * where collapsing hides the rail entirely (`--sidebar-collapsed-width: 0`) — the + * only host where the hover-peek applies. `null` is the web app (icon rail). + */ + const [titleBarMode, setTitleBarMode] = useState(null) + + /** + * The search palette is the one overlay reachable from the peeked card that renders + * a full-screen scrim, so the card yields to it. Read from the store rather than + * sniffed off the DOM — the store is the modal's own source of truth. + */ + const isSearchModalOpen = useSearchModalStore((s) => s.isOpen) + const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed) const hasHydrated = useSidebarStore((s) => s._hasHydrated) const syncSidebarWidth = useSidebarStore((s) => s.syncWidth) @@ -71,6 +133,15 @@ export function WorkspaceChrome({ */ const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed + /** + * The hover-peek only exists where collapsing leaves nothing behind — the macOS + * desktop shell. The web app keeps a 51px icon rail (with its own hover flyouts), + * and native fullscreen falls back to that same rail. + */ + const peekEnabled = isCollapsed && !isFullscreen && titleBarMode === 'inset' + const { isPeekActive, isPeekOpen, cardRef, triggerRef, onTriggerEnter, onTriggerLeave } = + useSidebarPeek(peekEnabled, isSearchModalOpen) + /** * Suppresses sidebar transitions across the initial hydration window. The * pre-paint script already set the correct `--sidebar-width`, but the store @@ -124,17 +195,28 @@ export function WorkspaceChrome({ }) }, []) - useEffect(() => { + /** + * A layout effect, not a passive one: the seed below arms the peek, and a passive + * effect lands after paint — long enough for a `mouseenter` on the toggle to be + * dropped while the peek still reads disabled, with nothing to retry it until the + * pointer leaves and returns. Everything here is a cheap synchronous read plus a + * subscription; the bridge's `getState` stays async and blocks nothing. + */ + useLayoutEffect(() => { + // Seed from the attribute the pre-paint script already wrote, rather than waiting + // for the async bridge below to resolve. + const initial = document.documentElement.getAttribute('data-sim-desktop-title-bar') + if (initial === 'inset' || initial === 'fullscreen') setTitleBarMode(initial) + const windowState = getDesktopBridge()?.windowState if (!windowState) return let disposed = false const applyWindowState = ({ isFullScreen }: { isFullScreen: boolean }) => { if (!disposed) { - document.documentElement.setAttribute( - 'data-sim-desktop-title-bar', - isFullScreen ? 'fullscreen' : 'inset' - ) + const mode: DesktopTitleBarMode = isFullScreen ? 'fullscreen' : 'inset' + applyDesktopTitleBarMode(document.documentElement, mode) + setTitleBarMode(mode) } } const unsubscribe = windowState.onStateChange(applyWindowState) @@ -180,13 +262,19 @@ export function WorkspaceChrome({ )} />
{!isFullscreen && ( - - - + + +
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx index f8b56eab31c..9fc05929352 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx @@ -25,6 +25,8 @@ vi.mock('@sim/browser-protocol', () => ({ })) vi.mock('@sim/emcn', () => ({ + /** `SettingsResourceRow` composes its tile classes with `cn`. */ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), Chip: ({ children, disabled, @@ -292,12 +294,21 @@ describe('Browser settings', () => { ]) }) - it('lists each data type inline as a standard settings row', async () => { + it('lists each data type as a standard settings row in one section', async () => { await render() + const section = container.querySelector('section[aria-label="Browsing data"]') + expect(section).not.toBeNull() + + const labels = [...(section?.querySelectorAll('span') ?? [])].map((s) => s.textContent) for (const label of ['Cookies', 'Site data', 'Cached images and files']) { - expect(container.querySelector(`section[aria-label="${label}"]`)).not.toBeNull() + expect(labels).toContain(label) } + expect([...(section?.querySelectorAll('button') ?? [])].map((b) => b.textContent)).toEqual([ + 'Delete cookies', + 'Delete site data', + 'Delete cached images and files', + ]) }) it.each([ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx index 1327c3fdc51..cf9d3f7aa34 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx @@ -166,28 +166,25 @@ export function Browser() { - {canClearData && - DATA_ROWS.map((row) => ( - setConfirming(row)}> - {row.action} - - } - > - {null} - - ))} - {canClearData && ( -

- {siteCount === 0 - ? 'Nothing saved. Sites you sign into in the browser stay on this device.' - : `${siteCount} ${siteCount === 1 ? 'site is' : 'sites are'} signed in or holding cookies, saved on this device only.`}{' '} - Saved passwords are never deleted here. -

+ +
+ {DATA_ROWS.map((row) => ( +
+ + setConfirming(row)}> + {row.action} + +
+ ))} +

+ {siteCount === 0 + ? 'Nothing saved. Sites you sign into in the browser stay on this device.' + : `${siteCount} ${siteCount === 1 ? 'site is' : 'sites are'} signed in or holding cookies, saved on this device only.`}{' '} + Saved passwords are never deleted here. +

+
+
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index f43411802b3..b38ba8b8149 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -15,6 +15,8 @@ const { mockBridge, mockSearch, mockToast } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ + /** `SettingsResourceRow` composes its tile classes with `cn`. */ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), ArrowLeft: () => , ArrowRight: () => , ChipConfirmModal: ({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx index 44eefc4b1c6..aad358a1fba 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx @@ -11,19 +11,16 @@ import { ArrowLeft, ArrowRight, ChipConfirmModal, Key, Plus, toast } from '@sim/ import { getDesktopBridge } from '@/lib/desktop' import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -/** The integrations page's responsive card grid and row chrome. */ +/** The integrations page's responsive card grid (see `integration-section.tsx`, `skills.tsx`). */ const CARD_GRID = '-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5' +/** Card hit area; the row chrome inside it comes from {@link SettingsResourceRow}. */ const CARD_CLASSES = - 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' -const CARD_TILE_CLASSES = - 'flex size-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)]' -const CARD_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]' -const CARD_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]' -const CARD_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]' + 'w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' const IMPORT_ERROR_MESSAGES: Record = { 'unsupported-platform': 'Importing from another browser is only supported on macOS.', @@ -204,30 +201,23 @@ export function PasswordsView({ credentials, onChange, onBack, onImported }: Pas className={CARD_CLASSES} onClick={() => setSelectedId(credential.id)} > -
-
- {credential.icon ? ( + + ) : ( - - )} -
-
-
- {siteLabel(credential.origin)} - - {credential.username || 'No username'} - -
- + + ) + } + iconFill + title={siteLabel(credential.origin)} + description={credential.username || 'No username'} + trailing={} + /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 6968fbc91f9..1b6c7f931d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -666,7 +666,7 @@ export function SearchModal({ <>
+ {/* The peek card already sits below the lane; reserving it again doubles the offset. */} + {!isPeeking && ( +
+ )}
-
+ className={cn( + 'relative flex flex-shrink-0 items-center px-2 pt-3', + !isPeeking && + '[[data-sim-desktop-title-bar=inset]_&]:pt-[var(--desktop-title-bar-height)]' + )} + > -
+ {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that + out-specifies the `[data-peek]` rule, stranding the card at a stale width. */} + {!isPeeking && ( +
+ )}
{ document.cookie = 'sidebar_collapsed=; path=/; max-age=0' }) @@ -32,3 +41,56 @@ describe('readCollapsedCookie', () => { expect(readCollapsedCookie()).toBe(false) }) }) + +describe('sidebar width CSS variables', () => { + beforeEach(() => { + document.documentElement.style.removeProperty('--sidebar-width') + document.documentElement.style.removeProperty('--sidebar-expanded-width') + useSidebarStore.setState({ isCollapsed: false, sidebarWidth: SIDEBAR_WIDTH.DEFAULT }) + }) + + it('publishes both variables when the width changes while expanded', () => { + useSidebarStore.getState().setSidebarWidth(300) + expect(widthVars()).toEqual({ width: '300px', expanded: '300px' }) + }) + + it('keeps the expanded variable at the restore width while collapsed', () => { + useSidebarStore.getState().setSidebarWidth(300) + useSidebarStore.getState().toggleCollapsed() + + expect(useSidebarStore.getState().isCollapsed).toBe(true) + expect(widthVars()).toEqual({ + width: `${SIDEBAR_WIDTH.COLLAPSED}px`, + expanded: '300px', + }) + }) + + it('restores the collapsed width from the expanded variable on expand', () => { + useSidebarStore.getState().setSidebarWidth(300) + useSidebarStore.getState().toggleCollapsed() + useSidebarStore.getState().toggleCollapsed() + + expect(widthVars()).toEqual({ width: '300px', expanded: '300px' }) + }) + + it('holds the expanded width across a syncWidth while collapsed', () => { + useSidebarStore.getState().setSidebarWidth(300) + useSidebarStore.getState().toggleCollapsed() + document.documentElement.style.removeProperty('--sidebar-expanded-width') + + useSidebarStore.getState().syncWidth() + + expect(widthVars()).toEqual({ + width: `${SIDEBAR_WIDTH.COLLAPSED}px`, + expanded: '300px', + }) + }) + + it('clamps a below-minimum persisted width into the expanded variable', () => { + useSidebarStore.setState({ isCollapsed: true, sidebarWidth: 10 }) + + useSidebarStore.getState().syncWidth() + + expect(widthVars().expanded).toBe(`${SIDEBAR_WIDTH.MIN}px`) + }) +}) diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts index bf63a663d3e..015e4f2ed96 100644 --- a/apps/sim/stores/sidebar/store.ts +++ b/apps/sim/stores/sidebar/store.ts @@ -18,10 +18,23 @@ function clampSidebarWidth(width: number): number { return Math.min(Math.max(width, SIDEBAR_WIDTH.MIN), max) } -function applySidebarWidth(width: number) { +/** + * Publishes both sidebar widths, owning the collapsed mapping so callers don't repeat it. + * + * `--sidebar-width` is the width the rail currently occupies (the collapsed width while + * collapsed), whereas `--sidebar-expanded-width` always holds the width to restore to. + * The desktop hover-peek needs the latter: it renders the sidebar at full width while + * the rail itself is still collapsed to zero. + */ +function applySidebarWidths(expandedWidth: number, collapsed: boolean) { if (typeof window === 'undefined') return - const value = Number.isFinite(width) ? width : SIDEBAR_WIDTH.DEFAULT - document.documentElement.style.setProperty('--sidebar-width', `${value}px`) + const expanded = Number.isFinite(expandedWidth) ? expandedWidth : SIDEBAR_WIDTH.DEFAULT + const root = document.documentElement + root.style.setProperty('--sidebar-expanded-width', `${expanded}px`) + root.style.setProperty( + '--sidebar-width', + `${collapsed ? getCollapsedSidebarWidth() : expanded}px` + ) } /** Reads the host-specific collapsed width established by the pre-paint layout script. */ @@ -63,26 +76,21 @@ export const useSidebarStore = create()( if (get().isCollapsed) return const clampedWidth = clampSidebarWidth(width) set({ sidebarWidth: clampedWidth }) - applySidebarWidth(clampedWidth) + applySidebarWidths(clampedWidth, false) }, toggleCollapsed: () => { const { isCollapsed, sidebarWidth } = get() const nextCollapsed = !isCollapsed + const expandedWidth = clampSidebarWidth(sidebarWidth) set({ isCollapsed: nextCollapsed }) applyCollapsedCookie(nextCollapsed) - applySidebarWidth( - nextCollapsed ? getCollapsedSidebarWidth() : clampSidebarWidth(sidebarWidth) - ) + applySidebarWidths(expandedWidth, nextCollapsed) }, syncWidth: () => { const { isCollapsed, sidebarWidth } = get() - if (isCollapsed) { - applySidebarWidth(getCollapsedSidebarWidth()) - return - } const clampedWidth = clampSidebarWidth(sidebarWidth) - if (clampedWidth !== sidebarWidth) set({ sidebarWidth: clampedWidth }) - applySidebarWidth(clampedWidth) + if (!isCollapsed && clampedWidth !== sidebarWidth) set({ sidebarWidth: clampedWidth }) + applySidebarWidths(clampedWidth, isCollapsed) }, setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }), }), @@ -99,10 +107,7 @@ export const useSidebarStore = create()( onRehydrateStorage: () => (state) => { if (state) { state.setHasHydrated(true) - const width = state.isCollapsed - ? getCollapsedSidebarWidth() - : clampSidebarWidth(state.sidebarWidth) - applySidebarWidth(width) + applySidebarWidths(clampSidebarWidth(state.sidebarWidth), state.isCollapsed) } }, /** Only width is persisted; collapse lives in the cookie. */