reserve more fixed height for unsaved-changes banner to avoid content shift (#9873)

* fix(frontend): reserve fixed height for unsaved-changes banner to avoid content shift

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): remove border around reserved banner slot and shrink it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): tighten top padding under the unsaved-changes banner

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): give unsaved-changes banner buttons minimal vertical breathing room

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): drop redundant Metadata section title in trigger and script editors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): tuck schedule editor labels under summary to match convention

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* revert(frontend): keep Metadata section title in ScriptBuilder for a separate PR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): drop leftover header-content margin on headless Section

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): drop top padding above resource editor first field

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(frontend): match variable editor bottom padding to resource

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): add Path label in new resource form to match edit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): reserve half the banner height to halve the idle gap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): reserve a third of the banner height when idle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): don't reserve banner slot or tighten top for new entities

Gate the reserved-height slot and the tight content top padding on the banner's baseline (bannerReserved) instead of merely on the banner snippet being present, so new-entity drawers keep normal top spacing and add no empty slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(frontend): trim banner comments to the 4-line invariant limit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(frontend): describe partial-reserve banner behavior accurately

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-02 11:58:31 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b92a86b8b3
commit 9c758df5eb
20 changed files with 148 additions and 73 deletions
@@ -991,13 +991,15 @@
</div>
{:else if step == 2 && manual}
<div class="flex flex-col gap-8">
<Path
bind:error={pathError}
bind:path
initialPath=""
namePlaceholder={resourceType}
kind="resource"
/>
<Label label="Path">
<Path
bind:error={pathError}
bind:path
initialPath=""
namePlaceholder={resourceType}
kind="resource"
/>
</Label>
<LabelsInput bind:labels class="-mt-5" />
{#if deployTo}
<Label
@@ -1247,13 +1249,15 @@
<span class="text-xs text-primary font-normal"> Finish connection in popup window </span>
{/if}
{:else}
<Path
initialPath=""
namePlaceholder={resourceType}
bind:error={pathError}
bind:path
kind="resource"
/>
<Label label="Path">
<Path
initialPath=""
namePlaceholder={resourceType}
bind:error={pathError}
bind:path
kind="resource"
/>
</Label>
<LabelsInput bind:labels class="-mt-5" />
{#if deployTo}
<Label
@@ -10,7 +10,7 @@
} from '$lib/utils'
import { AlertCircle, Diff } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
import { fade } from 'svelte/transition'
interface Props {
/** Whether there are unsaved local changes relative to the deployed baseline. */
@@ -25,6 +25,12 @@
disabled?: boolean
/** Diff drawer title. */
title?: string
/**
* Whether the banner can appear, so it partially reserves its slot to keep
* the shift small. Pass when `getDeployed()` is non-reactive; else defaults
* to `getDeployed() != null`.
*/
reserveSpace?: boolean
}
let {
@@ -33,7 +39,8 @@
getCurrent,
onDiscard,
disabled = false,
title = 'Deployed <> Local changes'
title = 'Deployed <> Local changes',
reserveSpace
}: Props = $props()
/** Same cleaning + YAML serialization the DiffDrawer applies before
@@ -49,6 +56,11 @@
}
}
// Whether the banner can appear (a deployed baseline exists). Gates the
// partially-reserved slot below (keeps the toggle shift small); new entities
// have none, so no slot and no gap.
let hasBaseline = $derived(reserveSpace ?? getDeployed() != null)
// Suppress the banner when:
// • There's no deployed baseline (brand-new entity — "Show diff"
// would early-return and "Discard" is semantically backwards),
@@ -58,10 +70,8 @@
// from baseline" check that can stale-fire after a save lands
// or when `false`/`undefined` toggle noise flips a field.
let visible = $derived.by(() => {
if (!show) return false
const deployed = getDeployed()
if (deployed == null) return false
return diffKey(deployed) !== diffKey(getCurrent())
if (!show || !hasBaseline) return false
return diffKey(getDeployed()) !== diffKey(getCurrent())
})
let diffDrawer: DiffDrawer | undefined = $state()
@@ -99,37 +109,44 @@
<DiffDrawer bind:this={diffDrawer} />
{#if visible}
<div
transition:slide|local={{ duration: 120 }}
class={twMerge(
'flex flex-row items-center justify-between gap-2 px-4 py-1',
classes.warning.bgClass,
'!border-0 !rounded-none'
)}
>
<div class="flex flex-row items-center gap-2 min-w-0">
<AlertCircle class={classes.warning.iconClass} size={16} />
<span class={twMerge('text-xs font-semibold truncate', classes.warning.titleClass)}>
You have unsaved changes
</span>
</div>
<div class="flex flex-row items-center gap-2 shrink-0">
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Diff }}
btnClasses={classes.warning.titleClass}
on:click={showDiff}>Show diff</Button
{#if hasBaseline}
<!-- Reserve ~a third of the banner height when idle (h-2.5), grow to the full
h-8 when it shows: small idle gap, ~22px animated shift on appear. h-8 =
button height + a couple px so the buttons don't touch the edges. -->
<div class={twMerge('shrink-0 transition-[height] duration-150', visible ? 'h-8' : 'h-2.5')}>
{#if visible}
<div
transition:fade|local={{ duration: 120 }}
class={twMerge(
'flex flex-row items-center justify-between gap-2 px-4 h-full',
classes.warning.bgClass,
'!border-0 !rounded-none'
)}
>
{#if !disabled}
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={onDiscard}>Discard</Button
>
{/if}
</div>
<div class="flex flex-row items-center gap-2 min-w-0">
<AlertCircle class={classes.warning.iconClass} size={16} />
<span class={twMerge('text-xs font-semibold truncate', classes.warning.titleClass)}>
You have unsaved changes
</span>
</div>
<div class="flex flex-row items-center gap-2 shrink-0">
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Diff }}
btnClasses={classes.warning.titleClass}
on:click={showDiff}>Show diff</Button
>
{#if !disabled}
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={onDiscard}>Discard</Button
>
{/if}
</div>
</div>
{/if}
</div>
{/if}
@@ -374,7 +374,7 @@
</script>
<div>
<div class="flex flex-col gap-6 py-2">
<div class="flex flex-col gap-6 pb-2">
{#if otherDirty.length > 0}
<Alert type="warning" title="Editing multiple workspaces">
You are going to edit the value in: {otherDirty.join(', ')}
@@ -58,6 +58,7 @@
<Drawer bind:this={drawer} size="50rem" {disableChatOffset}>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
bannerReserved={mode == 'edit'}
on:close={drawer?.closeDrawer}
>
{#await import('./ResourceEditor.svelte')}
@@ -79,6 +80,7 @@
{#snippet banner()}
<LocalDraftBanner
show={hasLocalDraft}
reserveSpace={mode == 'edit'}
getDeployed={() => resourceEditor?.localDraftDeployed()}
getCurrent={() => resourceEditor?.localDraftCurrent()}
onDiscard={() => resourceEditor?.discardLocalDraft()}
+1 -1
View File
@@ -104,7 +104,7 @@
{#if description}
<div class="text-xs text-primary mt-1 mb-2">{@html description}</div>
{/if}
<div class="flex flex-col gap-6 grow min-h-0 mt-4">
<div class={twMerge('flex flex-col gap-6 grow min-h-0', headless ? '' : 'mt-4')}>
<div class={twMerge('grow min-h-0', clazz)}>
{@render children?.()}
</div>
@@ -292,11 +292,13 @@
<Drawer bind:this={drawer} size="50rem">
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
bannerReserved={edit}
on:close={drawer?.closeDrawer}
>
{#snippet banner()}
<LocalDraftBanner
show={edit && selectedDirty}
reserveSpace={edit}
getDeployed={() => (selected ? initialStates[selected] : undefined)}
getCurrent={() => current}
onDiscard={() => {
@@ -308,7 +310,7 @@
disabled={!can_write}
/>
{/snippet}
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-8 pb-2">
{#if !can_write}
<Alert type="warning" title="Only read access">
You only have read access to this resource and cannot edit it
@@ -24,6 +24,11 @@
titleExtra?: import('svelte').Snippet
/** Rendered fixed below the header, above the scrollable content. */
banner?: import('svelte').Snippet
/**
* Whether the `banner` reserves space (its baseline exists). Only then does
* the content hug it with tight top padding; new entities keep normal padding.
*/
bannerReserved?: boolean
children?: import('svelte').Snippet
}
@@ -43,6 +48,7 @@
actions,
titleExtra,
banner,
bannerReserved = false,
children
}: Props = $props()
@@ -86,19 +92,29 @@
{/if}
</div>
{#if banner}
{@render banner()}
{/if}
{#snippet contentBox(tightTop = false)}
<div
class={classNames(
noPadding ? '' : tightTop ? 'px-4 pb-4 pt-1' : 'p-4',
'grow min-h-0 max-h-full',
forceOverflowVisible ? '!overflow-visible' : ''
)}
class:overflow-y-auto={overflow_y}
style={overflow_y ? 'scrollbar-gutter: stable;' : ''}
>
{@render children?.()}
</div>
{/snippet}
<div
class={classNames(
noPadding ? '' : 'p-4',
'grow min-h-0 max-h-full',
forceOverflowVisible ? '!overflow-visible' : ''
)}
class:overflow-y-auto={overflow_y}
style={overflow_y ? 'scrollbar-gutter: stable;' : ''}
>
{@render children?.()}
</div>
{#if banner}
<!-- Banner + content share one `divide-y` cell: the header keeps its single
divider (no bordered strip around the reserved slot) and the content hugs
the slot with tight top padding (see `contentBox`/`bannerReserved`). -->
<div class="flex flex-col grow min-h-0 max-h-full">
{@render banner()}
{@render contentBox(bannerReserved)}
</div>
{:else}
{@render contentBox()}
{/if}
</div>
@@ -350,6 +350,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Azure trigger ${initialPath}`
@@ -364,6 +365,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -368,7 +368,7 @@
{#if mode === 'suspended'}
<TriggerSuspendedJobsAlert {suspendedJobsModal} />
{/if}
<Section label="Metadata">
<Section headless>
<div class="flex flex-col gap-2">
<Label label="Path">
<Path
@@ -475,6 +475,7 @@
{#if useDrawer}
<Drawer size="700px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit email trigger ${initialPath}`
@@ -489,6 +490,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -367,6 +367,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit GCP Pub/Sub trigger ${initialPath}`
@@ -381,6 +382,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -535,7 +535,7 @@
{#if mode === 'suspended'}
<TriggerSuspendedJobsAlert {suspendedJobsModal} />
{/if}
<Section label="Metadata">
<Section headless>
<div class="flex flex-col gap-6">
<Label label="Summary" for="summary">
<!-- svelte-ignore a11y_autofocus -->
@@ -1005,6 +1005,7 @@
{#if useDrawer}
<Drawer size="700px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit route ${initialPath}`
@@ -1019,6 +1020,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -416,6 +416,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Kafka trigger ${initialPath}`
@@ -430,6 +431,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -391,6 +391,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit MQTT trigger ${initialPath}`
@@ -405,6 +406,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -395,7 +395,7 @@
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
<Section label="Metadata">
<Section headless>
<div class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Summary</span>
@@ -388,6 +388,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit NATS trigger ${initialPath}`
@@ -402,6 +403,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -566,6 +566,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Postgres trigger ${initialPath}`
@@ -578,6 +579,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -781,7 +781,7 @@
}}
/>
<div class="flex flex-col gap-8">
<Section label="Metadata">
<Section headless>
<div class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Summary</span>
@@ -807,7 +807,7 @@
bind:value={summary}
/>
</label>
<LabelsInput bind:labels />
<LabelsInput bind:labels class="-mt-4" />
<div class="flex flex-col gap-1">
<label for="path" class="text-xs font-semibold text-emphasis">Path</label>
@@ -1380,6 +1380,7 @@
{#if useDrawer}
<Drawer size="900px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit schedule ${initialPath}`
@@ -1396,6 +1397,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -370,6 +370,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit SQS trigger ${initialPath}`
@@ -384,6 +385,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
@@ -48,6 +48,11 @@ export interface TriggerDraftSync {
* loading and for brand-new triggers (no deployed baseline yet).
*/
readonly hasDraft: boolean
/**
* Whether a deployed baseline exists (banner can appear). Reactive, unlike
* reading `deployed` directly (a plain `let`), so callers can reserve its slot.
*/
readonly hasBaseline: boolean
/** The deployed baseline the dirty check compares against. */
readonly deployed: Cfg | undefined
/** The current form config (the live local draft). */
@@ -104,6 +109,10 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
cfgDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg)
)
// Reactive "banner is possible" — depends on `drawerLoading()` so it
// re-evaluates (and re-reads the plain-`let` baseline) once the load settles.
const hasBaseline = $derived(!opts.drawerLoading() && opts.deployed() != null)
/** `auto: true` marks a discard from the reactive persist-effect (not an
* explicit user action), so it respects the "Enable auto-save" toggle else
* with autosave off the editor would delete server drafts while writing none. */
@@ -189,6 +198,9 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
get hasDraft() {
return hasDraft
},
get hasBaseline() {
return hasBaseline
},
get deployed() {
return opts.deployed()
},
@@ -457,6 +457,7 @@
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit WebSocket trigger ${initialPath}`
@@ -471,6 +472,7 @@
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}