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. -->
- setDetached(false)} /> + setDetached(false)} + onUnpin={fullBleed && appHeaderPinned.val + ? () => (appHeaderPinned.val = false) + : undefined} + />
+ + {#if bandPeek} + + +
{ + bandHandleHot = true + cancelBandHide() + bandPeeked = true + }} + onmouseleave={() => { + bandHandleHot = false + scheduleBandHide() + }} + > + +
+ {/if} {/if} {#if $enterpriseLicense && !menuHidden}