From 63c1f7b6e8302f98261989374bfc6b0dbffbdbd2 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 23 Sep 2026 14:33:19 +0200 Subject: [PATCH] feat: a deployed app gets the viewport, and the header waits in the corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app is the page, not a page inside Windmill's chrome, so the band leaves the flow and floats above the viewport until the handle in the app's corner calls it down. The handle is faded until the pointer nears it, hovering peeks the band over the app, and a click pins it — one flag for every deployed app, in this browser, because someone who wants the breadcrumb over a dashboard wants it over the next one too. Peeking overlays rather than pushes: the app is an iframe, and taking 44px off its box on every pass of the pointer would re-lay-out whatever it draws. Pinned, the band is in flow like any other route's and the app starts under it. The sidebar is untouched — docked stays docked, detached stays a peek — since the pin is about the header alone. A page says it wants the viewport with one registry field, so the layout keeps asking the registry rather than knowing about apps. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/PageHeaderBar.svelte | 20 ++++- .../lib/components/PageHeaderContent.svelte | 6 +- .../components/apps/appHeaderPin.svelte.ts | 16 ++++ .../apps/editor/InWorkspaceAppViewer.svelte | 17 ++-- .../components/pageHeaderRegistry.svelte.ts | 4 + .../src/routes/(root)/(logged)/+layout.svelte | 81 +++++++++++++++++-- 6 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 frontend/src/lib/components/apps/appHeaderPin.svelte.ts 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}