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. -->
-
@@ -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))
-
@@ -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 @@
})
-
-
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++