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++