diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte
index c96bd729f4..c4f9287970 100644
--- a/frontend/src/lib/components/PageHeaderBar.svelte
+++ b/frontend/src/lib/components/PageHeaderBar.svelte
@@ -7,7 +7,7 @@ section name — and the route's own buttons at the far end.
The row's height matches the sidebar's own header row, so the two read as one band.
-->
+ {#if onUnpin}
+
+
+ {/if}
+
{#if navDetached.val && !navHidden}
diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte
index 98ee5d91b1..126e763a00 100644
--- a/frontend/src/lib/components/PageHeaderContent.svelte
+++ b/frontend/src/lib/components/PageHeaderContent.svelte
@@ -18,7 +18,8 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
contexts,
actingWorkspaceId,
- barRightInset
+ barRightInset,
+ fullBleed
}: PageHeaderContent = $props()
onMount(() => {
@@ -30,7 +31,8 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
contexts,
actingWorkspaceId,
- barRightInset
+ barRightInset,
+ fullBleed
}))
return () => pageHeader.release(id)
})
diff --git a/frontend/src/lib/components/apps/appHeaderPin.svelte.ts b/frontend/src/lib/components/apps/appHeaderPin.svelte.ts
new file mode 100644
index 0000000000..9f889f9b4e
--- /dev/null
+++ b/frontend/src/lib/components/apps/appHeaderPin.svelte.ts
@@ -0,0 +1,16 @@
+import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
+
+// A deployed app is the page, not a page inside Windmill's chrome, so it opens with the header out
+// of the way. Pinning brings the header back for good — one flag for every app rather than one per
+// app: someone who wants the breadcrumb over a dashboard wants it over the next one too.
+const pref = useLocalStorageValue('app_header_pinned', false, 'boolean')
+
+/** Whether the page header stays in flow on a deployed app's page. */
+export const appHeaderPinned = {
+ get val(): boolean {
+ return pref.val === true
+ },
+ set val(pinned: boolean) {
+ pref.val = pinned
+ }
+}
diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte
index 601cf6d377..c6b62adecb 100644
--- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte
+++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte
@@ -13,6 +13,7 @@
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'
@@ -125,11 +126,15 @@
})
-
-
+
+
-
+
{/if}
{#snippet editAction()}
diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts
index 4b23eb7077..3af05bfa49 100644
--- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts
+++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts
@@ -36,6 +36,9 @@ export type PageHeaderContent = {
* running in a fork. The breadcrumb then names that workspace, and its fork part shows only
* when it really is a fork. */
actingWorkspaceId?: string
+ /** The page is the whole viewport — a deployed app. The band leaves the flow and waits behind
+ * a handle in the corner, until the viewer pins it. */
+ fullBleed?: boolean
/** Width in px, at the right of the viewport, that this page owns from the top down — a
* session's side panel. The band stops there instead of running over it, and the band floats
* above the page rather than pushing it down, so that column starts at the top. */
@@ -75,6 +78,7 @@ export const pageHeader = {
if (c.contexts !== undefined) merged.contexts = c.contexts
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
+ if (c.fullBleed !== undefined) merged.fullBleed = c.fullBleed
}
return merged
},
diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte
index 1fde2d1432..b099e5df29 100644
--- a/frontend/src/routes/(root)/(logged)/+layout.svelte
+++ b/frontend/src/routes/(root)/(logged)/+layout.svelte
@@ -68,6 +68,7 @@
import {
PanelLeft,
PanelLeftClose,
+ PanelTop,
PanelLeftDashed,
PanelLeftOpen,
Home,
@@ -80,6 +81,7 @@
import { deepEqual } from 'fast-equals'
import { twMerge } from 'tailwind-merge'
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
+ import { appHeaderPinned } from '$lib/components/apps/appHeaderPin.svelte'
import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte'
import PageHeaderBar from '$lib/components/PageHeaderBar.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
@@ -517,6 +519,21 @@
if (detachedFloating) menuOpen = false
}, PEEK_CLOSE_DELAY_MS)
+ // A deployed app owns the viewport: the band floats above it until the corner handle calls it
+ // down, and stays once pinned. The sidebar is untouched — docked stays docked, detached stays a
+ // peek — since the pin is about the header alone.
+ const fullBleed = $derived(pageHeader.content?.fullBleed === true && !menuHidden)
+ const bandPeek = $derived(fullBleed && !appHeaderPinned.val)
+ let bandPeeked = $state(false)
+ let bandHandleHot = $state(false)
+ const bandShown = $derived(!bandPeek || bandPeeked)
+ const floatBand = $derived(pageHeader.content?.barRightInset != null || bandPeek)
+ // Same delay as the sidebar's peek, and for the same reason: the handle and the band do not
+ // touch, so the leave of one has to survive long enough for the enter of the other.
+ const { debounced: scheduleBandHide, clearDebounce: cancelBandHide } = debounce(() => {
+ bandPeeked = false
+ }, PEEK_CLOSE_DELAY_MS)
+
// The handle and the edge band both open the card this layout owns.
navHandleSlot.setOpener(() => (menuOpen = true), {
schedule: schedulePeekClose,
@@ -1544,24 +1561,72 @@
{#if !devOnly && (!menuHidden || pageHeader.actions.length > 0)}
{@const rightInset = pageHeader.content?.barRightInset}
{@const leftInset = useDrawer ? 0 : railWidth}
-
+ where that column starts, and the page pads its own content to clear it. Over a
+ full-bleed page (a deployed app) it floats too, but waits above the viewport
+ until the corner handle calls it down — and takes no pointer events up there. -->