feat: the band narrows, wraps, and draws its own edge

The band publishes its width, so the pages filling it can answer it. Two
tiers: under 1000px the breadcrumb keeps the workspace disc and its env
badge and drops the name, and under PHONE_BAR (800) the trail stops
yielding at 10rem — that floor is what makes the bar wrap, so a page's
buttons take the line below rather than squeezing the name to an ellipsis.

The band also draws the line between itself and the page, and only while
something is scrolled under it. One capture listener on the window sees
every scroller, since most pages scroll inside their own box and never
move the layout's. An inset shadow, not a border: a border would move
every page down a pixel the moment the line appeared.

DetailPageLayout and InWorkspaceAppViewer drew that first line themselves;
two lines would stack, so theirs go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-30 02:45:53 -07:00
co-authored by Claude Opus 5
parent fae8b39776
commit 7e28a10eb2
6 changed files with 158 additions and 46 deletions
@@ -33,7 +33,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
item,
section,
afterName,
actingWorkspaceId
actingWorkspaceId,
narrow = false
}: {
item?: PageHeaderItem
section?: PageHeaderSection
@@ -42,6 +43,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
afterName?: Snippet
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
actingWorkspaceId?: string
/** The bar is short of room: the workspace part drops its names and keeps its marks. */
narrow?: boolean
} = $props()
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
@@ -119,6 +122,14 @@ same weight, size and icon size, so the line reads as one control rather than fo
// fork's own name inside, so the family's name is not painted as if it were the fork.
const scopeChipClass = twMerge(SEGMENT, 'hover:bg-transparent')
const forkChipClass = $derived(
twMerge(
'flex items-center gap-1 min-w-0 px-1 rounded',
forkAccent &&
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
)
)
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the path's icon. */
const scopeKind = $derived(item?.path?.split('/')[0])
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
@@ -176,7 +187,7 @@ same weight, size and icon size, so the line reads as one control rather than fo
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. -->
<div class="flex items-center min-w-0">
<div class={narrow ? 'flex items-center shrink-0' : 'flex items-center min-w-0'}>
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
boxes reacting to one pass of the pointer read as two controls fighting. -->
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
@@ -188,25 +199,33 @@ same weight, size and icon size, so the line reads as one control rather than fo
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
>
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it is
part of, and either alone leaves the other to be guessed. -->
{@render slash()}
<span
class={twMerge(
'flex items-center gap-1 min-w-0 px-1 rounded',
forkAccent &&
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
)}
style={forkAccent}
>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{#if narrow}
<!-- Short of room, the part keeps what it cannot be read without: the disc in the
workspace's own colour, the fork mark, and the environment. The names go — the
hover title still carries them, and the picker beside it names them all. -->
{@render workspaceDisc()}
{#if showFork}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
</span>
{/if}
{:else}
{@render envBadgeMark()}
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it
is part of, and either alone leaves the other to be guessed. -->
{@render slash()}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
{/if}
</svelte:element>
</div>
@@ -247,8 +266,15 @@ same weight, size and icon size, so the line reads as one control rather than fo
<div class="relative flex items-center min-w-0">
<!-- No hover fill: a fill offers to take you somewhere, and this only copies. The
pointer and the popup after the click are affordance enough. -->
<!-- Capped: a draft's path is a 40-character uuid slug, and the trail yields so
grudgingly that one would push a page's buttons off the end of the bar. Past the
cap it truncates; the click still copies the path whole. -->
<button
class={twMerge(SEGMENT, 'gap-1.5 hover:bg-transparent')}
class={twMerge(
SEGMENT,
'gap-1.5 hover:bg-transparent overflow-hidden',
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
)}
title="Copy path"
onclick={copyPath}
aria-label="Copy path {item.path}"
@@ -260,7 +286,12 @@ same weight, size and icon size, so the line reads as one control rather than fo
{/if}
{#each pathLevels as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<span class="truncate">{level}</span>
<!-- The last level absorbs the squeeze: the scopes above it are a couple of
characters each, and cutting them first leaves "m… / draft_a5b1…". -->
<span
class={i === pathLevels.length - 1 ? 'truncate' : 'truncate shrink-0 max-w-[8rem]'}
>{level}</span
>
{/each}
</button>
{#if afterName}{@render afterName()}{/if}
@@ -11,18 +11,24 @@ The row's height matches the sidebar's own header row, so the two read as one ba
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
import NavBreadcrumb from './NavBreadcrumb.svelte'
import { pageHeader } from './pageHeaderRegistry.svelte'
import { pageHeader, PHONE_BAR } from './pageHeaderRegistry.svelte'
import ContextBridge from './ContextBridge.svelte'
import { afterNavigate } from '$app/navigation'
import { twMerge } from 'tailwind-merge'
let {
navHidden = false,
hideNavHandle = false,
panelled = false,
onUnpin
}: {
navHidden?: boolean
/** Drops the sidebar's handle, for a page whose own floating control reveals the sidebar
* along with this band — two handles for one gesture would be one too many. */
hideNavHandle?: boolean
/** The sidebar is a panel over the page rather than a rail beside it — detached, or a
* window too narrow to seat one. Either way this bar carries the switch that opens it. */
panelled?: boolean
/** Sends the band back behind its handle, on a page that owns the viewport. */
onUnpin?: () => void
} = $props()
@@ -32,10 +38,56 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const item = $derived(content?.item)
const section = $derived(content?.section)
// Below this, the bar is short enough that the workspace's name is the first thing worth
// giving up: it is the part of the trail the user changes least and the picker beside it still
// names every workspace.
const NARROW_BAR = 1000
const narrow = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < NARROW_BAR)
/** A phone's bar: the trail gives way before the page's buttons do (see the markup). */
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// The band's edge is there to say "something is passing under me", so it is drawn only while
// something is. Scroll events do not bubble, but they do capture, so one listener on the window
// sees every box under the band — which matters because most pages scroll inside their own
// (the audit table, a detail page's panes) and never move this layout's own scroller.
let scrolledUnder = $state(false)
/** Sub-pixel layout and a border or two, between a box's top and the content box's. */
const AT_THE_TOP_PX = 4
function onScrollCapture(e: Event) {
const el = e.target as HTMLElement | null
if (!el || el.nodeType !== 1) return
const box = document.getElementById('content')
if (!box || !box.contains(el)) return
// Only a box that starts where the content does has anything passing under the band. The
// audit page's list begins 285px lower, behind its own chart and column headers, and a
// select's option list floats wherever it opens: neither moves anything under the header.
const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top
if (top > AT_THE_TOP_PX) return
scrolledUnder = el.scrollTop > 0
}
// A page left while scrolled would otherwise hand its edge to the next one, which may have
// nothing to scroll at all.
afterNavigate(() => (scrolledUnder = false))
</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 navDetached.val && !navHidden && !hideNavHandle}
<svelte:window onscrollcapture={onScrollCapture} />
<!-- The band draws its own bottom edge, and only while a page is scrolled under it: at the top of
a page there is nothing to divide, and pages that draw their own first line have dropped it —
two would stack. An inset shadow rather than a border: a border would make the row 45px and
every page below it would move a pixel the moment the edge appeared. -->
<div
data-page-header
bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)}
class={twMerge(
'flex items-center gap-1 shrink-0 min-w-0 bg-surface transition-shadow duration-150',
phone ? 'flex-wrap content-center min-h-11 py-1 pl-1 pr-2' : 'h-11 pl-2 pr-4',
scrolledUnder &&
'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]'
)}
>
{#if (navDetached.val || panelled) && !navHidden && !hideNavHandle}
<!-- 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
@@ -75,13 +127,17 @@ The row's height matches the sidebar's own header row, so the two read as one ba
<!-- 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.
On a phone it stops yielding at 10rem, and that floor is what makes the bar wrap: the
page's buttons no longer fit beside a trail that wide, so they take the line below
instead of squeezing the name down to an ellipsis.
An embed (`navHidden`) gets the page's controls without the trail that would offer to
navigate the host's workspace. -->
{#if !navHidden}
<div class="flex min-w-0 shrink-[0.1]">
<div class={twMerge('flex min-w-0', phone ? 'shrink min-w-[10rem]' : 'shrink-[0.1]')}>
<NavBreadcrumb
{item}
{section}
{narrow}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
@@ -89,12 +145,13 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{/if}
{#if item && !navHidden && (item.summaryContent || item.summary)}
<!-- A dot rather than a slash: the summary names the same item the path just located, it is
not another level of it. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class="flex items-center gap-1 min-w-0">
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
<!-- A dot rather than a slash: the summary names the same item the path just located,
it is not another level of it. Inside this box so that a bar wrapping on a phone
carries it down with the summary instead of stranding it on the line above. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
{@render item.summaryContent()}
{:else}
@@ -105,8 +162,16 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{#if actions.length > 0}
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
here that can give width back, and it can only do that if this box may shrink. -->
<div class="ml-auto flex items-center gap-2 min-w-0 pl-4">
here that can give width back, and it can only do that if this box may shrink. A phone
only tightens the gutter — pages fold their own buttons into a menu at that width, and
a box that refused to shrink would make the one flexible field take its max instead. -->
<div
class={twMerge(
'ml-auto flex items-center',
phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0',
phone ? 'gap-1 pl-1' : 'gap-2 pl-4'
)}
>
{#each actions as entry, i (i)}
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
@@ -16,6 +16,7 @@ the header, so nesting is decided by the caller rather than guessed from context
afterName,
actions,
actionsOrder,
actionsFlexible,
contexts,
actingWorkspaceId,
barRightInset,
@@ -29,6 +30,7 @@ the header, so nesting is decided by the caller rather than guessed from context
afterName,
actions,
actionsOrder,
actionsFlexible,
contexts,
actingWorkspaceId,
barRightInset,
@@ -13,7 +13,6 @@
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { appHeaderPinned } from '$lib/components/apps/appHeaderPin.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
@@ -126,15 +125,7 @@
})
</script>
<!-- The line separating the app from the band exists only while the band does: unpinned there is
nothing above the app to separate it from. It goes on the app's wrapper rather than on the
frame, which the header-less public page shares — and as an inset shadow rather than a border,
which would take a pixel off an app sized to fill the page exactly and leave it scrolling. -->
<div
class="h-full {appHeaderPinned.val
? 'shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]'
: ''}"
>
<div class="h-full">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
@@ -67,13 +67,11 @@
}
</script>
<main class="h-screen w-full" bind:clientWidth>
<main class="h-full w-full" bind:clientWidth>
{#if useDesktopLayout}
<div class="h-full w-full flex flex-col">
{@render header?.()}
<!-- 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">
<div class="grow min-h-0 w-full">
<Splitpanes>
<Pane size={65} minSize={50}>
{@render form?.({ graphInline: true })}
@@ -104,7 +102,7 @@
{:else}
<div class="h-full w-full flex flex-col">
{@render header?.()}
<div class="grow min-h-0 w-full flex flex-col border-t">
<div class="grow min-h-0 w-full flex flex-col">
<!-- 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. -->
@@ -29,6 +29,10 @@ export type PageHeaderContent = {
/** Orders these buttons among the other registrations', ascending; registration order breaks a
* tie. For a menu that must stay at the far end however many pages register before it. */
actionsOrder?: number
/** These actions include something that can give width back — a search field rather than a row
* of buttons. On a phone the bar then lets their box shrink; without it the box holds its
* content width, because squeezing a row of buttons only pushes them out of the bar. */
actionsFlexible?: boolean
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
* which at the far end of the bar would be a journey away from what it edits. */
@@ -62,6 +66,19 @@ export type PageHeaderContent = {
let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
let nextId = 0
/**
* Below this bar width a page keeps one button — the thing the page is for — and folds the rest
* into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet
* in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the
* line sits above the tablet rather than between the two.
*/
export const PHONE_BAR = 800
// The bar's own width, written by the bar and read by the pages that fill it. A page's buttons
// share that row with the breadcrumb, so it is the width they crowd against — not the window's,
// which the sidebar and a page's side panel both take from.
let barWidth = $state(0)
export const pageHeader = {
/**
* The registrations merged, later ones winning field by field: a route sets the frame (where
@@ -80,6 +97,7 @@ export const pageHeader = {
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.fullBleed !== undefined) merged.fullBleed = c.fullBleed
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
}
return merged
},
@@ -97,6 +115,13 @@ export const pageHeader = {
})
.sort((a, b) => a.order - b.order || a.i - b.i)
},
/** Width of the bar in px, or 0 before it has been measured. */
get barWidth(): number {
return barWidth
},
setBarWidth(w: number) {
barWidth = w
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
const id = nextId++