mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 00:02:55 +00:00
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:
co-authored by
Claude Opus 5
parent
0f0aaac876
commit
63c1f7b6e8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user