From 718c7cbd49bd2d363c53822b058864376cc94e79 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 21 Sep 2026 11:36:13 +0200 Subject: [PATCH] feat: snap-detach the operator sidebar, and join the handle to the picker Dragging the sidebar into the left snap zone detaches it for an operator, who has no icon-only rail to collapse into; the width the drag started from is what gets persisted, so re-attaching restores the sidebar's size. The handle's colour pastille slides onto the card's workspace pastille as the card opens, alongside the workspace name, and both buttons that attach or detach the sidebar carry a tooltip. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/sidebar/operatorRoutes.ts | 2 +- .../src/routes/(root)/(logged)/+layout.svelte | 76 +++++++++++++++---- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts index 7898eaf7ed..9e67921939 100644 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ b/frontend/src/lib/components/sidebar/operatorRoutes.ts @@ -2,7 +2,7 @@ import type { OperatorSettings } from '$lib/gen' import type { UserWorkspace } from '$lib/stores' /** An `operator_settings` key: each admits an operator to one page (`triggers` to all of them). */ -export type OperatorPageKey = keyof OperatorSettings +export type OperatorPageKey = keyof NonNullable /** * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 381bdfd4e4..62d25e1de4 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -228,7 +228,10 @@ const collapse = x < SIDEBAR_SNAP_COLLAPSE_REM // Operators have no icon-only rail to snap to, so the same gesture detaches // instead: dragging the sidebar off the left edge puts it behind the handle. + // Detaching is not a resize, so the width the drag started from is what gets + // persisted — re-attaching restores the sidebar at the size it had. if (collapse && $userStore?.operator) { + sidebarWidth = widthAtStart stop() setDetached(true) return @@ -540,6 +543,8 @@ (offscreen ? `transform:translateX(calc(${-u} * (100% + 0.5rem)));` : '') } } + // Shared by the name and the colour pastille below, which travel to the picker together. + const JOIN_PICKER_MS = 110 // The home-page workspace name travels to the card's workspace picker as the card opens, // and back as it closes, so the name reads as one label joining the picker. It aims where // the picker will be once the card has finished sliding (the card's current translate is @@ -562,13 +567,36 @@ const scale = parseFloat(getComputedStyle(target).fontSize) / parseFloat(getComputedStyle(node).fontSize) return { - duration: 180, + duration: JOIN_PICKER_MS, easing: cubicInOut, css: (_t: number, u: number) => `transform-origin: left center; transform: translate(${dx * u}px, ${dy * u}px) scale(${1 + (scale - 1) * u}); opacity: ${1 - u};` } } + // How far the handle's colour pastille must travel to land on the card's workspace pastille. + // Measured when the card opens (the two discs are the same size, so it is a plain slide) and + // aimed where the picker will be once the card has finished sliding in, like the name above. + let pastilleJoin = $state({ dx: 0, dy: 0 }) + function openNavCard() { + const from = document + .querySelector('[data-nav-handle] circle') + ?.closest('svg') + ?.getBoundingClientRect() + const card = document.querySelector('[data-nav-card]') + const to = card + ?.querySelector('.wm-workspace-name') + ?.closest('button') + ?.querySelector('circle') + ?.closest('svg') + ?.getBoundingClientRect() + if (from && card && to) { + const slide = new DOMMatrix(getComputedStyle(card).transform).m41 + pastilleJoin = { dx: to.left - slide - from.left, dy: to.top - from.top } + } + menuOpen = true + } + // Matches the edge band's `duration-200`, so the drawer opens as the tint finishes. const EDGE_OPEN_DELAY_MS = 200 let edgeOpenTimer: ReturnType | undefined @@ -1099,7 +1127,7 @@ aria-hidden="true" onmouseenter={() => { clearTimeout(edgeOpenTimer) - edgeOpenTimer = setTimeout(() => (menuOpen = true), EDGE_OPEN_DELAY_MS) + edgeOpenTimer = setTimeout(openNavCard, EDGE_OPEN_DELAY_MS) }} onmouseleave={() => clearTimeout(edgeOpenTimer)} > @@ -1109,18 +1137,38 @@ card; a click docks the sidebar again. -->
-
(menuOpen = true)}> - setDetached(false)} - /> + +
+ + setDetached(false)} + /> + {#snippet text()}Attach sidebar{/snippet} +