feat: no border under the page header, and the peek card closes on the way out

The band draws no bottom edge and no page asks it to, so `barBorder` goes with it —
a page that needs the separation draws its own line, as the detail pages now do above
the run form and the tab strip. With nothing toggling a border, the band is a flat
44px on every route.

The detached sidebar's card is a hover affordance, so it leaves when the pointer does:
off the handle and off the card both, on the same debounce a hover menu uses, because
the two do not touch and the leave of one fires before the enter of the other. The
mobile drawer is opened deliberately and still stays until it is dismissed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-22 18:49:33 +02:00
co-authored by Claude Opus 5
parent 189757d4bc
commit 9129cfe39f
9 changed files with 46 additions and 31 deletions
@@ -1559,7 +1559,6 @@
}}
actions={flowHeaderActions}
contexts={headerContexts}
barBorder={false}
/>
{:else}
<!-- Nav between steps-->
@@ -16,31 +16,23 @@ The row's height matches the sidebar's own header row, so the two read as one ba
import { pageHeader } from './pageHeaderRegistry.svelte'
import ContextBridge from './ContextBridge.svelte'
let { border = true }: { border?: boolean } = $props()
const content = $derived(pageHeader.content)
const actions = $derived(pageHeader.actions)
// A route can drop the band's bottom edge when its own content draws one. The border stays in
// the box either way — only its colour goes — so the band's contents do not shift a pixel when
// a navigation swaps one kind of route for the other.
const showBorder = $derived(content?.barBorder ?? border)
const item = $derived(content?.item)
const section = $derived(content?.section)
</script>
<div
data-page-header
class="flex items-center gap-1 h-11 px-2 shrink-0 min-w-0 bg-surface border-b {showBorder
? ''
: 'border-transparent'}"
>
<div data-page-header class="flex items-center gap-1 h-11 px-2 shrink-0 min-w-0 bg-surface">
{#if navDetached.val}
<!-- The only way back to a hidden sidebar: hovering slides the card in for a look, a click
puts it back for good. Docked, the sidebar speaks for itself and nothing leads the bar. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div data-nav-handle onmouseenter={() => navHandleSlot.open()}>
<div
data-nav-handle
onmouseenter={() => navHandleSlot.open()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<Tooltip class="flex" placement="bottom" small>
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
@@ -18,8 +18,7 @@ the header, so nesting is decided by the caller rather than guessed from context
contexts,
barPlacement,
actingWorkspaceId,
barRightInset,
barBorder
barRightInset
}: PageHeaderContent = $props()
onMount(() => {
@@ -31,8 +30,7 @@ the header, so nesting is decided by the caller rather than guessed from context
contexts,
barPlacement,
actingWorkspaceId,
barRightInset,
barBorder
barRightInset
}))
return () => pageHeader.release(id)
})
+1 -1
View File
@@ -869,7 +869,7 @@
<div class="w-full h-screen flex flex-col" bind:clientWidth={innerWidth}>
<!-- Everything that used to sit in a row above the graph — the page's name, its queue, the
filters and the timeframe — is in the page header. -->
<PageHeaderContent hint={runsHint} actions={headerActions} barBorder={false} />
<PageHeaderContent hint={runsHint} actions={headerActions} />
<!-- Graph -->
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-4 border rounded-md">
@@ -71,7 +71,9 @@
{#if useDesktopLayout}
<div class="h-full w-full flex flex-col">
{@render header?.()}
<div class="grow min-h-0 w-full">
<!-- The band draws no edge of its own, so the separation from it is this page's first
line, above the run form and the detail panel. -->
<div class="grow min-h-0 w-full border-t">
<Splitpanes>
<Pane size={65} minSize={50}>
{@render form?.({ graphInline: true })}
@@ -102,7 +104,7 @@
{:else}
<div class="h-full w-full flex flex-col">
{@render header?.()}
<div class="grow min-h-0 w-full flex flex-col">
<div class="grow min-h-0 w-full flex flex-col border-t">
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
classic scrollbars would spend a track under them, opening a band between the tabs
and the content. Wheel, trackpad and drag still scroll the strip. -->
@@ -43,8 +43,6 @@ export type PageHeaderContent = {
* running in a fork. The breadcrumb then names that workspace, and its fork part shows only
* when it really is a fork. */
actingWorkspaceId?: string
/** False drops the band's bottom edge, for a page that draws its own first line. */
barBorder?: boolean
/** Width in px, at the right of the viewport, that this page owns from the top down — a
* session's side panel. The band stops there instead of running over it, and the band floats
* above the page rather than pushing it down, so that column starts at the top. */
@@ -88,7 +86,6 @@ export const pageHeader = {
if (c.barPlacement !== undefined) merged.barPlacement = c.barPlacement
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.barBorder !== undefined) merged.barBorder = c.barBorder
}
return merged
},
@@ -1,6 +1,9 @@
// Opening the detached sidebar's card, from the handle or from anywhere else that reaches for the
// nav (the left edge band, the AI chat layout's menu button).
let opener: (() => void) | undefined
// Closing it once the pointer is off both the handle and the card, which the layout owns because
// the card is its markup.
let hoverClose: { schedule: () => void; cancel: () => void } | undefined
// Where the card's top edge goes: right under the handle, measured when it opens rather than fixed,
// so the card follows the handle's own height. The fallback matches the handle's resting bottom.
@@ -13,11 +16,17 @@ export const navHandleSlot = {
get cardTop(): number {
return cardTop
},
/** The root layout owns the card, so it registers how to open it. */
setOpener(open: () => void) {
/** The root layout owns the card, so it registers how to open and hover-close it. */
setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) {
opener = open
hoverClose = close
},
/** Pointer left the handle: close, unless it lands on the card before the delay is up. */
scheduleClose() {
hoverClose?.schedule()
},
open() {
hoverClose?.cancel()
const handle = document.querySelector('[data-nav-handle]')
cardTop = handle
? Math.round(handle.getBoundingClientRect().bottom) + GAP_UNDER_HANDLE
@@ -11,7 +11,7 @@
UserService,
WorkspaceService
} from '$lib/gen'
import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils'
import { capitalize, classNames, debounce, getModifierKey, sendUserToast } from '$lib/utils'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { isSessionPreviewFrame } from '$lib/components/sessions/sessionMode.svelte'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
@@ -507,8 +507,21 @@
let useDrawer = $derived(innerWidth < 768 || navDetached.val)
// Below 768px the burger row opens the drawer; wider, a detached sidebar has a floating handle.
let detachedFloating = $derived(navDetached.val && innerWidth >= 768)
// The peek card is a hover affordance, so it goes away when the pointer does — off the handle
// and off the card both. The delay is what lets the pointer cross the gap between them: they
// do not touch, and the leave of the one fires before the enter of the other.
const PEEK_CLOSE_DELAY_MS = 200
const { debounced: schedulePeekClose, clearDebounce: cancelPeekClose } = debounce(() => {
// Only the floating card peeks; the mobile drawer is opened deliberately and stays until
// it is dismissed.
if (detachedFloating) menuOpen = false
}, PEEK_CLOSE_DELAY_MS)
// The handle and the edge band both open the card this layout owns.
navHandleSlot.setOpener(() => (menuOpen = true))
navHandleSlot.setOpener(() => (menuOpen = true), {
schedule: schedulePeekClose,
cancel: cancelPeekClose
})
// Set once the user docks or detaches, so the rail that mounts from then on skips its
// app-entry animation (`wm-sidebar-in`) and only plays `railMorph`.
@@ -1086,7 +1099,10 @@
clearTimeout(edgeOpenTimer)
edgeOpenTimer = setTimeout(() => navHandleSlot.open(), EDGE_OPEN_DELAY_MS)
}}
onmouseleave={() => clearTimeout(edgeOpenTimer)}
onmouseleave={() => {
clearTimeout(edgeOpenTimer)
schedulePeekClose()
}}
></div>
{/if}
{#if useDrawer}
@@ -1126,8 +1142,11 @@
if (e.target === e.currentTarget) menuOpen = false
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-nav-card
onmouseenter={cancelPeekClose}
onmouseleave={schedulePeekClose}
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform',
detachedFloating ? 'rounded-lg border shadow-lg overflow-hidden' : '',
@@ -875,7 +875,6 @@
where the preview panel starts so that panel can run from the top of the viewport. -->
<PageHeaderContent
barRightInset={previewCollapsed || fullscreen ? undefined : previewWidth}
barBorder={false}
actions={sessionPageActions}
/>