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)}
>
-
+ {/* 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. */