From 7e28a10eb2d318007d63cb611d7055f49cf1bac2 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 30 Sep 2026 02:45:53 -0700 Subject: [PATCH] feat: the band narrows, wraps, and draws its own edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/components/NavBreadcrumb.svelte | 73 +++++++++++----- .../src/lib/components/PageHeaderBar.svelte | 85 ++++++++++++++++--- .../lib/components/PageHeaderContent.svelte | 2 + .../apps/editor/InWorkspaceAppViewer.svelte | 11 +-- .../details/DetailPageLayout.svelte | 8 +- .../components/pageHeaderRegistry.svelte.ts | 25 ++++++ 6 files changed, 158 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index 4f1e578def..70054e143e 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -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. --> -
+
- {@render slash()} - - - {scopeName} + {#if narrow} + + {@render workspaceDisc()} + {#if showFork} + + + {@render envBadgeMark()} + + {:else} {@render envBadgeMark()} - + {/if} {:else} - {@render envBadgeMark()} + + {#if showFork} + + {@render slash()} + + + {scopeName} + {@render envBadgeMark()} + + {:else} + {@render envBadgeMark()} + {/if} {/if}
@@ -247,8 +266,15 @@ same weight, size and icon size, so the line reads as one control rather than fo
+ {#if afterName}{@render afterName()}{/if} diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 936aad0c1d..b622b41fe0 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -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)) -
- {#if navDetached.val && !navHidden && !hideNavHandle} + + + +
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} {#if !navHidden} -
+
@@ -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)} - - -
+
+ + {#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} -
+ 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. --> +
{#each actions as entry, i (i)} {#key entry.contexts} diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index a9b7cabf91..a30fd89e8a 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -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, diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index fa8420859b..cc49becd24 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -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 @@ }) - -
+
-
+
{#if useDesktopLayout}
{@render header?.()} - -
+
{@render form?.({ graphInline: true })} @@ -104,7 +102,7 @@ {:else}
{@render header?.()} -
+
diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 34ea582766..f074c6ff06 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -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++