feat: a deployed app gets the viewport, and the header waits in the corner

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-23 14:33:19 +02:00
co-authored by Claude Opus 5
parent 0f0aaac876
commit 63c1f7b6e8
6 changed files with 126 additions and 18 deletions
@@ -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.
-->
<script lang="ts">
import { PanelLeft } from 'lucide-svelte'
import { PanelLeft, PanelTop } from 'lucide-svelte'
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
import NavBreadcrumb from './NavBreadcrumb.svelte'
@@ -16,9 +16,12 @@ The row's height matches the sidebar's own header row, so the two read as one ba
let {
navHidden = false,
onDock
onDock,
onUnpin
}: {
navHidden?: boolean
/** Sends the band back behind its handle, on a page that owns the viewport. */
onUnpin?: () => void
/** Docks the sidebar. The layout owns the two other things that must happen with it — the
* flag that suppresses the rail's entry animation, and closing the card. */
onDock?: () => void
@@ -32,6 +35,19 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</script>
<div data-page-header class="flex items-center gap-1 h-11 pl-2 pr-4 shrink-0 min-w-0 bg-surface">
{#if onUnpin}
<!-- First, before the sidebar's handle: this one is about the band the user is looking at,
the other about the sidebar beside it. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Unpin header"
title="Unpin header"
onclick={() => onUnpin?.()}
>
<PanelTop size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
{#if navDetached.val && !navHidden}
<!-- The only way back to a hidden sidebar: hovering slides the card in for a look, a click
puts it back for good. Docked, the sidebar speaks for itself and nothing leads the bar. -->
@@ -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)
})
@@ -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<boolean>('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
}
}
@@ -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 @@
})
</script>
<!-- The band draws no edge of its own, so the separation from it is this page's first line. It
goes on the app's wrapper rather than on the frame, which the header-less public page shares —
and as an inset shadow rather than a border, which would take a pixel off an app sized to fill
the page exactly and leave it scrolling. -->
<div class="h-full shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]">
<!-- The line separating the app from the band exists only while the band does: unpinned there is
nothing above the app to separate it from. It goes on the app's wrapper rather than on the
frame, which the header-less public page shares — and as an inset shadow rather than a border,
which would take a pixel off an app sized to fill the page exactly and leave it scrolling. -->
<div
class="h-full {appHeaderPinned.val
? 'shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]'
: ''}"
>
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
@@ -156,7 +161,7 @@
{#if canWriteApp && !hideEditBtn}
<!-- The page header carries it, with the app's name: a floating button over the app's own
canvas lands on whatever the app draws there. -->
<PageHeaderContent item={{ kind: 'app', path }} actions={editAction} />
<PageHeaderContent item={{ kind: 'app', path }} actions={editAction} fullBleed />
{/if}
{#snippet editAction()}
@@ -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
},
@@ -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}
<!-- One element for both placements, styled rather than branched: a page registers its
<!-- One element for every placement, styled rather than branched: a page registers its
inset on mount, and swapping between two `{#if}` arms would destroy the band and
build another one a frame later — a blink of no header, with the content sliding
up into the gap and the workspace menu losing the trigger it hangs from.
In flow, the band spans the content beside the rail. With an inset, a page owns
the right edge from the top (a session's side panel): the band floats, stopping
where that column starts, and the page pads its own content to clear it. -->
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. -->
<div
class={classNames(
rightInset != null ? 'absolute top-0 z-30' : 'shrink-0',
sidebarTransitionClass
floatBand ? 'absolute top-0 z-30' : 'shrink-0',
bandPeek
? 'transition-transform duration-150 ease-out ' +
(bandShown ? 'translate-y-0' : '-translate-y-full pointer-events-none')
: sidebarTransitionClass
)}
style:left={rightInset != null ? `${leftInset}rem` : undefined}
style:right={rightInset != null ? `${rightInset}px` : undefined}
style:padding-left={rightInset != null ? undefined : `${leftInset}rem`}
style:left={floatBand ? `${leftInset}rem` : undefined}
style:right={floatBand ? `${rightInset ?? 0}px` : undefined}
style:padding-left={floatBand ? undefined : `${leftInset}rem`}
onmouseenter={bandPeek ? cancelBandHide : undefined}
onmouseleave={bandPeek ? scheduleBandHide : undefined}
role={bandPeek ? 'presentation' : undefined}
>
<PageHeaderBar navHidden={menuHidden} onDock={() => setDetached(false)} />
<PageHeaderBar
navHidden={menuHidden}
onDock={() => setDetached(false)}
onUnpin={fullBleed && appHeaderPinned.val
? () => (appHeaderPinned.val = false)
: undefined}
/>
</div>
{#if bandPeek}
<!-- The way back to the chrome from an app that owns the viewport: hovering calls the
band down for a look, a click pins it. Faded once the page has settled, so it
costs an app's own corner as little as an always-present control can. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1 z-40 transition-opacity duration-300 {bandShown || bandHandleHot
? 'opacity-100'
: 'opacity-40'}"
style:left="calc({leftInset}rem + 0.25rem)"
onmouseenter={() => {
bandHandleHot = true
cancelBandHide()
bandPeeked = true
}}
onmouseleave={() => {
bandHandleHot = false
scheduleBandHide()
}}
>
<button
class="flex items-center p-1.5 rounded bg-surface/80 backdrop-blur-sm shadow-sm border hover:bg-surface"
aria-label="Pin header"
title="Pin header"
onclick={() => {
appHeaderPinned.val = true
bandPeeked = false
}}
>
<PanelTop size={16} class="flex-shrink-0 text-hint" />
</button>
</div>
{/if}
{/if}
{#if $enterpriseLicense && !menuHidden}
<!-- Announcements are an EE feature, so the component never mounts on CE: no