mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
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:
co-authored by
Claude Opus 5
parent
fae8b39776
commit
7e28a10eb2
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user