feat: one corner control for the chrome an app page hides

Hovering the button overlaid on a deployed app brings back whatever that page is
hiding: the band always, and the sidebar card too when the sidebar is detached. The
card hangs from this button rather than from the header's own handle, which is
off-screen with the band.

The handle in the band no longer attaches the sidebar — it reveals it and nothing more.
A control that hides the thing it sits on cannot also be the way to bring it back, so
attaching stays with the toggle in the sidebar's own footer, where detaching happens.

The band's two buttons go in the order the things nest: the sidebar's handle, then the
header's pin. The overlaid button stands where the pin will be once pinned — same 28px
box, and a slot further along when the sidebar's handle takes the first — so pinning
moves nothing. It wears the panel pair the sidebar's toggle uses: solid while the band
is away, dashed while it is shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-23 14:51:06 +02:00
co-authored by Claude Opus 5
parent 63c1f7b6e8
commit 49035cfd08
3 changed files with 48 additions and 32 deletions
@@ -7,7 +7,7 @@ section name — and the route's own buttons at the far end.
The row's height matches the sidebar's own header row, so the two read as one band.
-->
<script lang="ts">
import { PanelLeft, PanelTop } from 'lucide-svelte'
import { PanelLeft, PanelTopDashed } from 'lucide-svelte'
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
import NavBreadcrumb from './NavBreadcrumb.svelte'
@@ -16,15 +16,11 @@ The row's height matches the sidebar's own header row, so the two read as one ba
let {
navHidden = false,
onDock,
onUnpin
}: {
navHidden?: boolean
/** Sends the band back behind its handle, on a page that owns the viewport. */
onUnpin?: () => void
/** Docks the sidebar. The layout owns the two other things that must happen with it — the
* flag that suppresses the rail's entry animation, and closing the card. */
onDock?: () => void
} = $props()
const content = $derived(pageHeader.content)
@@ -35,22 +31,11 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</script>
<div data-page-header class="flex items-center gap-1 h-11 pl-2 pr-4 shrink-0 min-w-0 bg-surface">
{#if onUnpin}
<!-- First, before the sidebar's handle: this one is about the band the user is looking at,
the other about the sidebar beside it. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Unpin header"
title="Unpin header"
onclick={() => onUnpin?.()}
>
<PanelTop size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
{#if navDetached.val && !navHidden}
<!-- 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. -->
<!-- Reveals the hidden sidebar, and only that: hovering slides the card in, clicking holds
it there. Attaching it for good belongs to the toggle in the sidebar's own footer, where
detaching it happened — a control that hides the thing it sits on cannot also be the
way to bring it back. Docked, the sidebar speaks for itself and nothing leads the bar. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-nav-handle
@@ -62,13 +47,27 @@ The row's height matches the sidebar's own header row, so the two read as one ba
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Show sidebar"
onclick={() => onDock?.()}
onclick={() => navHandleSlot.open()}
>
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
</button>
</div>
{/if}
{#if onUnpin}
<!-- After the sidebar's handle: the sidebar is the outer thing, the band sits inside it. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Unpin the header from deployed apps"
title="Unpin the header from deployed apps"
onclick={() => onUnpin?.()}
>
<!-- Dashed while the band is shown, solid while it is away: the pair the sidebar's own
toggle uses, so the two controls read as one idea. -->
<PanelTopDashed size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
<!-- The breadcrumb yields width grudgingly (shrink-[0.1]): when a page fills the bar with
controls, they are what should narrow, not the name of where the user is. It still gives
way rather than pushing them off the bar once there is nothing left to take.
@@ -25,12 +25,17 @@ export const navHandleSlot = {
scheduleClose() {
hoverClose?.schedule()
},
open() {
/**
* Opens the card under `anchor`, or under the handle in the page header when none is given.
* A page that hides the header reveals the sidebar from its own control, and the card has to
* hang from the control the pointer is actually on — the header's handle is off-screen there.
*/
open(anchor?: Element | null) {
hoverClose?.cancel()
const handle = document.querySelector('[data-nav-handle]')
cardTop = handle
? Math.round(handle.getBoundingClientRect().bottom) + GAP_UNDER_HANDLE
: FALLBACK_CARD_TOP
const handle = anchor ?? document.querySelector('[data-nav-handle]')
const bottom = handle?.getBoundingClientRect().bottom
cardTop =
bottom != null && bottom > 0 ? Math.round(bottom) + GAP_UNDER_HANDLE : FALLBACK_CARD_TOP
opener?.()
}
}
@@ -526,6 +526,7 @@
const bandPeek = $derived(fullBleed && !appHeaderPinned.val)
let bandPeeked = $state(false)
let bandHandleHot = $state(false)
let bandHandleEl: HTMLDivElement | undefined = $state(undefined)
const bandShown = $derived(!bandPeek || bandPeeked)
const floatBand = $derived(pageHeader.content?.barRightInset != null || bandPeek)
// Same delay as the sidebar's peek, and for the same reason: the handle and the band do not
@@ -533,6 +534,9 @@
const { debounced: scheduleBandHide, clearDebounce: cancelBandHide } = debounce(() => {
bandPeeked = false
}, PEEK_CLOSE_DELAY_MS)
// Where the band's pin button will be once pinned, so the click moves nothing: the band's own
// pl-2, plus a slot (28px button + the row's gap-1) when the sidebar's handle takes the first.
const bandHandleOffset = $derived(navDetached.val && !menuHidden ? '2.5rem' : '0.5rem')
// The handle and the edge band both open the card this layout owns.
navHandleSlot.setOpener(() => (menuOpen = true), {
@@ -1587,7 +1591,6 @@
>
<PageHeaderBar
navHidden={menuHidden}
onDock={() => setDetached(false)}
onUnpin={fullBleed && appHeaderPinned.val
? () => (appHeaderPinned.val = false)
: undefined}
@@ -1600,24 +1603,33 @@
costs an app's own corner as little as an always-present control can. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1 z-40 transition-opacity duration-300 {bandShown || bandHandleHot
class="absolute top-2 z-40 transition-opacity duration-300 {bandShown || bandHandleHot
? 'opacity-100'
: 'opacity-40'}"
style:left="calc({leftInset}rem + 0.25rem)"
style:left="calc({leftInset}rem + {bandHandleOffset})"
bind:this={bandHandleEl}
onmouseenter={() => {
bandHandleHot = true
cancelBandHide()
bandPeeked = true
// The one control for the chrome this page is hiding: the band always, and
// the sidebar too when it is detached — hung from this button, since the
// header's own handle is off-screen with the band.
if (navDetached.val) navHandleSlot.open(bandHandleEl)
}}
onmouseleave={() => {
bandHandleHot = false
scheduleBandHide()
navHandleSlot.scheduleClose()
}}
>
<!-- Standing where the band's own first button will be — same 28px box, same offsets
as its pl-2 and 44px row — so pinning moves nothing. A border would make it
30 and the icon would jump on the click. -->
<button
class="flex items-center p-1.5 rounded bg-surface/80 backdrop-blur-sm shadow-sm border hover:bg-surface"
aria-label="Pin header"
title="Pin header"
class="flex items-center p-1.5 rounded bg-surface/80 backdrop-blur-sm shadow-sm hover:bg-surface"
aria-label="Pin the header on deployed apps"
title="Pin the header on deployed apps"
onclick={() => {
appHeaderPinned.val = true
bandPeeked = false