feat: shrink the page header in stages and let the bar fold page actions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 07:58:42 -07:00
co-authored by Claude Opus 5.5
parent a80c6ffacb
commit 23f6e69bf2
11 changed files with 690 additions and 231 deletions
+9 -13
View File
@@ -40,7 +40,7 @@
import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -230,19 +230,14 @@
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full group — the
// dropdown, Diff, the preview trio and Deploy — is ~610px.
const compactBelow = $derived(ownsPageHeader ? 1200 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons
// stand down, since there is no room to run a flow beside its own name.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons
// stand down, since there is no room to run a flow beside its own name.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
const diffEnabled = $derived(customUi?.topBar?.diff != false)
// Nothing to compare against until a deployed version exists.
@@ -1597,6 +1592,7 @@
: undefined
}}
actions={flowHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="none"
/>
+242 -115
View File
@@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line.
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
import { Menu, Menubar } from '$lib/components/meltComponents'
import MeltButton from '$lib/components/meltComponents/MeltButton.svelte'
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
@@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line.
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
import type { Snippet } from 'svelte'
import {
BAR_STAGES,
FLOOR,
shownPathLevels,
type Part,
type TrailMeasures,
type TrailStage
} from './pageHeaderStages'
let {
item,
section,
afterName,
actingWorkspaceId,
narrow = false,
nameOnly = false
nameOnly = false,
stage = BAR_STAGES[0],
caps = {},
scopesGive = false,
measures = $bindable()
}: {
item?: PageHeaderItem
section?: PageHeaderSection
@@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line.
afterName?: Snippet
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
actingWorkspaceId?: string
/** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the
* line before anything else has given way. The names truncate at every width either way. */
narrow?: boolean
/** Which parts the bar has dropped for room (see pageHeaderStages). */
stage?: TrailStage
/** Max widths the bar holds the names to, once dropping whole parts is not enough. */
caps?: Partial<Record<Part, number>>
/** The folders above the name truncate too: even the name at its last floor does not fit. */
scopesGive?: boolean
/** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */
measures?: TrailMeasures
/** Only the page's own name, with no workspace part and no picker — the band inside a
* session's preview frame, where the workspace is the host's and leading the reader out of
* it is exactly what that band must not offer. The page still has to say what it is. */
@@ -156,15 +171,8 @@ not this component) — so the lead can run the whole line.
twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD)
)
// Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that
// one would push a page's buttons off the end of the bar.
const pathSegment = $derived(
twMerge(
SEGMENT,
'gap-1.5 overflow-hidden',
itemIsLast ? LAST : LEAD,
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
)
twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD)
)
// Copying is the one thing a segment does that leaves no mark on the page, so it is the one
// without a hover fill: the pointer and the confirmation under it are the affordance. A segment
@@ -191,6 +199,18 @@ not this component) — so the lead can run the whole line.
const scopeKind = $derived(item?.path?.split('/')[0])
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
// The off-screen copies stand in a fixed-width box for what a page renders into the trail: a
// second render of a page's snippet would be a second live copy of its controls.
let afterNameWidth = $state(0)
let sectionContentWidth = $state(0)
/** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */
function capStyle(part: Part, measure: boolean): string | undefined {
if (measure) return `max-width: ${FLOOR[part]}px`
const cap = caps[part]
return cap === undefined ? undefined : `max-width: ${cap}px`
}
</script>
{#snippet workspaceDisc()}
@@ -241,80 +261,114 @@ not this component) — so the lead can run the whole line.
<span class={twMerge('shrink-0 text-hint/40 text-xs', extra)} aria-hidden="true">/</span>
{/snippet}
<nav
aria-label="Breadcrumb"
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
>
{#snippet workspaceMenu_(measure: boolean)}
{#if measure}
<span class="flex items-center h-9 px-1.5 shrink-0">
<ChevronDown size={ICON} class="flex-shrink-0" />
</span>
{:else}
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
class="h-auto"
contentStyle="margin-left: {-nameWidth}px"
>
{#snippet triggr({ trigger })}
<MeltButton
meltElement={trigger}
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
<ChevronDown size={ICON} class="flex-shrink-0" />
</MeltButton>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
</Menubar>
{/if}
{/snippet}
{#snippet trail(f: TrailStage, measure: boolean)}
{@const scopeTitle = showFork ? `${familyName} / ${scopeName}` : familyName}
{#if !nameOnly}
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. -->
<!-- `shrink-[999]`: the whole trail can give width back, and this part gives it first. Flex
takes it from the parts in proportion to what they ask for, so a factor this far above
the rest means the names here are eaten down to their icons before the page's own name
loses a character. -->
<div class="flex items-center min-w-0 shrink-[999]">
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. Its parts never shrink on their own: the bar drops them whole and caps the
names left, in the order pageHeaderStages sets. -->
<div class="flex items-center shrink-0">
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
boxes reacting to one pass of the pointer read as two controls fighting. -->
boxes reacting to one pass of the pointer read as two controls fighting. -->
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
the name to line its left edge up with the disc. -->
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
<a
href={homeHref}
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
the name to line its left edge up with the disc. -->
<div
class="flex items-center"
bind:clientWidth={() => nameWidth, (w) => !measure && (nameWidth = w)}
>
<svelte:element
this={measure ? 'span' : 'a'}
href={measure ? undefined : homeHref}
class={f.wsButton ? 'flex items-center gap-1 pl-1 py-0.5 rounded' : scopeChipClass}
title={scopeTitle}
>
<!-- The family's name goes before the fork's: the fork is where the work happens, the
family is which product it is part of, and of the two that is the one a reader can
do without. Both truncate to nothing rather than disappearing at a breakpoint —
what is left then is what the part cannot be read without, the disc in the
workspace's own colour, the fork mark and the environment. The hover title
carries the names whole, and the picker beside it names them all. -->
<span class="flex items-center gap-1 min-w-0 shrink-[999]">
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
</span>
{#if showFork}
{@render slash('mx-0.5')}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
{#if f.wsButton}
<!-- Down to its disc, the part still leads home: the fork it stands in rides on the
disc as a mark, and the environment stays beside it. -->
<span class="relative flex shrink-0">
{@render workspaceDisc()}
{#if showFork}
<span
class={twMerge(
'absolute -right-1 -bottom-0.5 size-3.5 rounded-full grid place-items-center ring-2 ring-surface',
forkAccent
? 'bg-[color:var(--fork-accent-badge)] text-white'
: 'bg-surface-sunken text-primary'
)}
style={forkAccent}
>
<GitFork size={9} />
</span>
{/if}
</span>
{:else}
{@render envBadgeMark()}
{:else}
<!-- The family's name goes before the fork's: the fork is where the work happens,
the family is which product it is part of, and of the two that is the one a
reader can do without. Outside a fork it is the workspace's own name and stays
until the part is down to its disc. -->
<span class="flex items-center gap-1 shrink-0">
{@render workspaceDisc()}
{#if f.familyName || !showFork}
<span class="truncate" style={capStyle('family', measure)}>{familyName}</span>
{/if}
</span>
{#if showFork}
{@render slash('mx-0.5')}
<span class={twMerge(forkChipClass, 'shrink-0')} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
{#if f.forkName}
<span class="truncate" style={capStyle('fork', measure)}>{scopeName}</span>
{/if}
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
{/if}
</a>
</svelte:element>
</div>
<Menubar>
{#snippet children({ createMenu })}
<Menu
bind:this={workspaceMenu}
{createMenu}
usePointerDownOutside
placement="bottom-start"
class="h-auto"
contentStyle="margin-left: {-nameWidth}px"
>
{#snippet triggr({ trigger })}
<MeltButton
meltElement={trigger}
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
title="Switch workspace"
>
<ChevronDown size={ICON} class="flex-shrink-0" />
</MeltButton>
{/snippet}
{#snippet children({ item: menuItem })}
<WorkspacePickerBody
item={menuItem}
collapseFamilies={actingWorkspaceId != undefined}
closeMenu={() => workspaceMenu?.close()}
/>
{/snippet}
</Menu>
{/snippet}
</Menubar>
{@render workspaceMenu_(measure)}
</div>
{/if}
@@ -324,6 +378,7 @@ not this component) — so the lead can run the whole line.
{#if item}
{#if item.path}
{@const shown = shownPathLevels(pathLevels, f)}
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
to know which item this is, and what they reach for to paste somewhere else. -->
<div class="relative flex items-center min-w-0">
@@ -334,19 +389,28 @@ not this component) — so the lead can run the whole line.
{:else}
{@render folderIcon()}
{/if}
{#each pathLevels as level, i (i)}
{#each shown as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<!-- The scopes above the name absorb the squeeze, the name itself last: a path is
read from its end, and which folder a thing is in is answerable from the
picker while the thing's own name is not. -->
<span
class={i === pathLevels.length - 1
? 'truncate'
: 'truncate min-w-0 shrink-[999] max-w-[8rem]'}>{level}</span
>
{#if i === shown.length - 1}
<span class="truncate shrink-0" style={capStyle('name', measure)}>{level}</span>
{:else if level === '…' && !f.middleLevels}
<span class="shrink-0 text-tertiary">…</span>
{:else}
<!-- The folder or user gives width only once the name is down to a few
characters: it is short and says whose thing this is, while a name long
enough to need truncating still reads from its first words. -->
<span
class={twMerge(
'truncate max-w-[8rem]',
scopesGive && !measure ? 'min-w-0' : 'shrink-0'
)}>{level}</span
>
{/if}
{/each}
{/snippet}
{#if item.pathTrigger}
{#if measure}
<span class={pathSegment}>{@render pathLabel()}</span>
{:else if item.pathTrigger}
<!-- The page hangs its own rename off this segment, anchored here so the editor
opens under the path rather than beside the summary. It wears the segment's
own classes, so a clickable path reads the same as one that only copies. -->
@@ -361,8 +425,8 @@ not this component) — so the lead can run the whole line.
{@render pathLabel()}
</button>
{/if}
{#if afterName}{@render afterName()}{/if}
{#if pathCopied}
{@render afterNameSlot(measure)}
{#if pathCopied && !measure}
<span
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
role="status"
@@ -386,15 +450,21 @@ not this component) — so the lead can run the whole line.
</span>
{/if}
{:else if section?.kind}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
{#if measure}
<span class={twMerge('inline-flex items-center px-1', SEGMENT, LAST)}>
{@render sectionKindIcon()}<span>{KIND_LABEL_LOWER[section.kind]}</span>
</span>
{:else}
<BreadcrumbSegment
label={KIND_LABEL_LOWER[section.kind]}
icon={sectionKindIcon}
extraClass={twMerge(SEGMENT, LAST)}
isCurrent
initialHighlight={kindKey(section.kind)}
initialScope={{ kind: section.kind }}
onPick={() => {}}
/>
{/if}
{:else if section}
{#if section.parent}
<!-- Gives width back with the workspace part, before the section's own name does. -->
@@ -405,30 +475,87 @@ not this component) — so the lead can run the whole line.
</span>
{@render slash()}
{/if}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'truncate'
)}
aria-current="page"
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
</span>
{#if afterName}{@render afterName()}{/if}
{#if section.content && measure}
<span class="shrink-0" style="width: {sectionContentWidth}px"></span>
{:else}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class={twMerge(
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
section.content ? '' : 'truncate'
)}
aria-current={measure ? undefined : 'page'}
bind:clientWidth={
() => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w)
}
>
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
</span>
{/if}
{@render afterNameSlot(measure)}
{:else if routePage}
{@const RouteIcon = routePage.icon}
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
whole, so the workspace and fork names give way first when the bar is full. -->
<span
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
aria-current="page"
aria-current={measure ? undefined : 'page'}
>
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
<span class="truncate">{routePage.label}</span>
</span>
{#if afterName}{@render afterName()}{/if}
{@render afterNameSlot(measure)}
{/if}
{/snippet}
{#snippet afterNameSlot(measure: boolean)}
{#if afterName}
{#if measure}
<span class="shrink-0" style="width: {afterNameWidth}px"></span>
{:else}
<span class="flex items-center shrink-0" bind:clientWidth={afterNameWidth}>
{@render afterName()}
</span>
{/if}
{/if}
{/snippet}
<nav
aria-label="Breadcrumb"
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
>
{@render trail(stage, false)}
</nav>
{#if measures}
<!-- The trail at every stage, each name at its floor, for the bar to choose a stage with; and
each name whole, for it to cap them with. Same snippet, same classes, so the widths are the
ones the trail on screen would take. -->
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
{#each BAR_STAGES as s, i (i)}
<nav
class="flex items-center gap-1.5 w-max text-xs font-normal text-primary"
bind:clientWidth={measures.trail[i]}
>
{@render trail(s, true)}
</nav>
{/each}
<!-- Always rendered, empty where the page has no such part: a width of 0 is how the bar
knows the part is not there. -->
<span
class={twMerge(scopeChipClass, 'inline-block whitespace-nowrap p-0')}
bind:clientWidth={measures.family}>{nameOnly ? '' : familyName}</span
>
<span
class={twMerge(forkChipClass, 'inline-block whitespace-nowrap p-0')}
bind:clientWidth={measures.fork}>{!nameOnly && showFork ? scopeName : ''}</span
>
<span
class={twMerge('inline-block whitespace-nowrap text-xs', itemIsLast ? LAST : LEAD)}
bind:clientWidth={measures.name}
>{item?.path ? (pathLevels[pathLevels.length - 1] ?? '') : ''}</span
>
</div>
{/if}
+141 -44
View File
@@ -16,6 +16,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
import ContextBridge from './ContextBridge.svelte'
import { afterNavigate } from '$app/navigation'
import { twMerge } from 'tailwind-merge'
import { onDestroy } from 'svelte'
import type { Attachment } from 'svelte/attachments'
import { BAR_STAGES, FLOOR, fitBar, type BarFit, type TrailMeasures } from './pageHeaderStages'
let {
navHidden = false,
@@ -34,14 +37,71 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const section = $derived(content?.section)
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
// Below this the trail holds a tighter cap on the path it draws. The names in it truncate on
// their own at any width (see NavBreadcrumb), so this is not a breakpoint the look changes at —
// it only stops a long path from claiming a third of a short bar before the squeeze starts.
const NARROW_BAR = 1000
const narrow = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < NARROW_BAR)
/** A phone's bar: the trail gives way before the page's buttons do (see the markup). */
/** A phone's bar: a tighter gutter around the same row. */
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// What the bar drops, and when the page's actions fold, follows from the widths of what is in
// it (see pageHeaderStages): measured here and in NavBreadcrumb, never guessed per page.
/** `gap-1` between the bar's boxes. */
const GAP = 4
/** Actions that shrink or fill the bar have no width of their own to plan around: the trail
* leaves them this much and they take whatever is left. */
const FLEXIBLE_ACTIONS_RESERVE = 240
let trailMeasures = $state<TrailMeasures>({ trail: [], family: 0, fork: 0, name: 0 })
/** The summary held at its floor, with the dot before it. */
let summaryFloorWidth = $state(0)
let summaryTextWidth = $state(0)
/** A page's own summary control, last seen uncapped. */
let summaryContentWidth = $state(0)
let handleWidth = $state(0)
/** The actions box at each fold level it has been seen at, for this page. */
let actionWidths = $state<(number | undefined)[]>([])
let actionsEpoch = $state(0)
const handleShown = $derived((navDetached.val || panelled) && !navHidden)
// The bar's own padding, in px, as the classes on the row below set it.
const padLeft = $derived(navHidden ? 16 : phone ? 4 : 8)
const padRight = $derived(content?.actionsFill ? 0 : phone ? 8 : 16)
const actionsRigid = $derived(!content?.actionsFlexible && !content?.actionsFill)
const FULL: BarFit = { stage: 0, fold: 0, caps: {}, scopesGive: false }
const fit = $derived.by((): BarFit => {
// Unmeasured counts as wide: the bar measures itself on mount, and starting narrow would fold
// a page's buttons for one frame and pop them back out the next.
if (pageHeader.barWidth === 0) return FULL
return fitBar({
barWidth: pageHeader.barWidth,
chrome: padLeft + padRight + (handleShown ? handleWidth + GAP : 0),
trail: trailMeasures.trail,
summary: BAR_STAGES.map((s) => (hasSummary && s.summary ? GAP + summaryFloorWidth : 0)),
actions:
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
natural: {
family: trailMeasures.family,
fork: trailMeasures.fork,
summary: hasSummary ? (item?.summaryContent ? summaryContentWidth : summaryTextWidth) : 0,
name: trailMeasures.name
}
})
})
const stage = $derived(BAR_STAGES[fit.stage])
onDestroy(pageHeader.provideActionFold(() => fit.fold))
// Re-attached whenever the fold level changes, so the first reading at a level is taken even
// when the row comes out exactly as wide as it was at the last one.
function measureActions(fold: number, _epoch: number): Attachment<HTMLElement> {
return (node) => {
const ro = new ResizeObserver(() => (actionWidths[fold] = GAP + node.offsetWidth))
ro.observe(node)
return () => ro.disconnect()
}
}
// Until the row has been seen at the level the bar picked, it is laid out but not shown: the bar
// may find it does not fit and fold it further, and that should not flash.
const actionsUnmeasured = $derived(actionsRigid && actionWidths[fit.fold] === undefined)
// The band's edge is there to say "something is passing under me", so it is drawn only while
// something is. Scroll events do not bubble, but they do capture, so one listener on the window
// sees every box under the band — which matters because most pages scroll inside their own
@@ -69,8 +129,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba
scrolledUnder = next
}
// A page left while scrolled would otherwise hand its edge to the next one, which may have
// nothing to scroll at all.
afterNavigate(() => (scrolledUnder = false))
// nothing to scroll at all. Its actions' widths go with it.
afterNavigate(({ from, to }) => {
scrolledUnder = false
if (from?.url.pathname !== to?.url.pathname) {
actionWidths = []
actionsEpoch++
}
})
const separator = $derived(pageHeader.content?.separator ?? 'onScroll')
const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder))
@@ -112,6 +178,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-nav-handle
bind:clientWidth={handleWidth}
onmouseenter={() => navHandleSlot.open()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
@@ -128,26 +195,13 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</div>
{/if}
<!-- The breadcrumb yields width more slowly than the page's controls (shrink-[0.3]) but it does
yield, at every width: the trail truncates from its start, so the squeeze is spent on the
workspace and the folders before it ever reaches the name of the thing the page is about.
The 5rem floor is what is left when every name in it has gone — the workspace disc, the
fork mark and the environment badge, which is as small as the part can be and still say
where the user is.
<!-- The breadcrumb's parts are rigid and the bar sizes them (see `fit`): it drops whole parts
and caps the names left, so this box only shrinks past what they add up to on a bar too
narrow for even the last stage.
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
the trail would offer to navigate the host's workspace, while the name is what says which
page the controls beside it belong to.
The 0.3 only holds against a summary or an actions box that shrinks too: alone, a factor
sum below 1 makes flex hand out only that share of the overflow, and the bar overflows
instead. -->
<div
class={twMerge(
'flex shrink-[0.3] min-w-[5rem]',
!hasSummary &&
(actions.length === 0 || (phone && !content?.actionsFlexible && !content?.actionsFill)) &&
'shrink'
)}
>
page the controls beside it belong to. -->
<div class="flex min-w-0">
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
the tree that named it, and the rename editor in there asks that tree who the acting user
is before it offers to rename anything. -->
@@ -162,7 +216,10 @@ The row's height matches the sidebar's own header row, so the two read as one ba
<NavBreadcrumb
{item}
{section}
{narrow}
{stage}
caps={fit.caps}
scopesGive={fit.scopesGive}
bind:measures={trailMeasures}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
@@ -172,36 +229,54 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</svelte:boundary>
</div>
{#if item && hasSummary}
{#if item && hasSummary && stage.summary}
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
<div class="flex items-center gap-1 shrink-0">
<!-- A dot rather than a slash: the summary names the same item the path just located, it
is not another level of it. Inside this box so that it narrows with the summary. -->
is not another level of it. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
<svelte:boundary onerror={(e) => console.error('page header: summary failed to render', e)}>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
</svelte:boundary>
<!-- Read only while uncapped: capped, the box says how wide the bar let it be, not how
wide the page's control wants to be. -->
<div
class="flex min-w-0 overflow-hidden"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
bind:clientWidth={
() => summaryContentWidth,
(w) => fit.caps.summary === undefined && (summaryContentWidth = w)
}
>
<svelte:boundary
onerror={(e) => console.error('page header: summary failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{:else}
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
<span
class="truncate text-xs font-medium text-emphasis"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
>{item.summary}</span
>
{/if}
</div>
{/if}
{#if actions.length > 0}
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
here that can give width back, and it can only do that if this box may shrink. A phone
only tightens the gutter — pages fold their own buttons into a menu at that width, and
a box that refused to shrink would make the one flexible field take its max instead. -->
<!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out
of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the
page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that
can give width back, which it can only do if this box may shrink. -->
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
bar and run to its right edge, and they stretch its full height because that surface
sits on the bottom edge. -->
<div
{@attach measureActions(fit.fold, actionsEpoch)}
class={twMerge(
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
@@ -209,8 +284,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
content?.actionsFill
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
: 'ml-auto items-center',
!content?.actionsFill && (phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4')
!content?.actionsFill && (actionsRigid ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4'),
actionsUnmeasured && 'invisible'
)}
>
{#each actions as entry, i (i)}
@@ -224,4 +300,25 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{/each}
</div>
{/if}
{#if item && hasSummary}
<!-- The summary at its floor, which is what a stage that keeps it needs; and whole, which is
what the bar caps it from. A page's own control stands in at the width last seen. -->
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
<div class="flex items-center gap-1 w-max" bind:clientWidth={summaryFloorWidth}>
<span class="shrink-0 text-hint/40 text-xs px-0.5">·</span>
{#if item.summaryContent}
<span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span>
{:else}
<span class="truncate text-xs font-medium" style="max-width: {FLOOR.summary}px"
>{item.summary}</span
>
{/if}
</div>
<span
class="inline-block whitespace-nowrap text-xs font-medium"
bind:clientWidth={summaryTextWidth}>{item.summary ?? ''}</span
>
</div>
{/if}
</div>
@@ -18,6 +18,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
separator,
contexts,
actingWorkspaceId,
@@ -33,6 +34,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsOrder,
actionsFlexible,
actionsFill,
actionFolds,
separator,
contexts,
actingWorkspaceId,
@@ -96,7 +96,7 @@
import EditorHeader from './EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
import Modal from './common/modal/Modal.svelte'
@@ -206,19 +206,14 @@
let pathSnapshot = $state<string | undefined>(undefined)
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full button group is
// ~515px — about 1100px between them, plus slack for a longer path than most.
const compactBelow = $derived(ownsPageHeader ? 1200 : 900)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900
)
// The second fold holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the full-page
@@ -2326,6 +2321,7 @@
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
actionFolds={2}
separator="none"
/>
{:else}
@@ -54,7 +54,7 @@
import EditorHeader from '$lib/components/EditorHeader.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
@@ -261,24 +261,16 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they shed the canvas
// toggles and fold Debug runs into the menu sooner: the whole group is ~840px and the trail
// with the summary takes ~430px of the same row.
// Measured on the band rather than estimated: with the canvas toggles shown the actions alone
// are 826-911px and the trail with the summary another ~500, so the row overflows the bar at
// every width up to about 1470 — 1350 was not high enough either. Below this the toggles stand
// down and the menu carries all three, which is what keeps them reachable rather than merely
// out of the way.
const compactBelow = $derived(ownsPageHeader ? 1500 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and Deploy. Which panels are open, the export and the
// editor/preview switch join the menu compact already shows.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone. Folded, the canvas
// toggles stand down and the menu carries them and Debug runs, which keeps them reachable
// rather than merely out of the way.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold holds the trail and Deploy. Which panels are open, the export and the
// editor/preview switch join the menu compact already shows.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
function closeSaveDrawer() {
saveDrawerOpen = false
@@ -1014,6 +1006,7 @@
pathTrigger: appPathTrigger
}}
actions={appHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="always"
/>
@@ -100,14 +100,9 @@
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
// own box is narrow while the window is not — a session's preview panel — and says so through
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
// with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
// the buttons out of the menu a frame later.
// Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row
// has to fold before that rather than at the ~1150 the two parts add up to on paper.
const COLLAPSE_BELOW = 1250
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
// `wide`. The band knows what its breadcrumb leaves, which the host cannot see, and folds the
// row once the breadcrumb has given all it can (`actionFolds` below).
const roomInBar = $derived(!ownsPageHeader || pageHeader.actionFold === 0)
const wideRow = $derived(wide && roomInBar)
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
@@ -278,6 +273,7 @@
pathTrigger: onSaved ? pathTrigger : undefined
}}
{actions}
actionFolds={1}
contexts={headerContexts}
separator="always"
/>
@@ -52,6 +52,11 @@ export type PageHeaderContent = {
* controls share one shape that opens into the page below. The bar gives up its right padding
* to it, since the surface is what meets the edge. */
actionsFill?: boolean
/** How many levels past the full row these actions can fold through, each a narrower row than
* the last. The bar picks the level from the room its breadcrumb leaves once it has dropped
* all it can, and the page reads it back as `pageHeader.actionFold`. Without it the actions
* never fold and the breadcrumb gives up width around them. */
actionFolds?: number
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
* which at the far end of the bar would be a journey away from what it edits. */
@@ -91,10 +96,9 @@ let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
let nextId = 0
/**
* Below this bar width a page keeps one button — the thing the page is for — and folds the rest
* into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet
* in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the
* line sits above the tablet rather than between the two.
* A phone's bar: a tighter gutter, and the width below which controls that cannot fold through
* `actionFolds` — a flexible filter row, a banner — slim down. A phone in portrait is ~390px of
* bar and a tablet in portrait ~720, which is why the line sits above the tablet.
*/
export const PHONE_BAR = 800
@@ -112,6 +116,9 @@ export const BAR_HEIGHT_PX = 48
// share that row with the breadcrumb, so it is the width they crowd against — not the window's,
// which the sidebar and a page's side panel both take from.
let barWidth = $state(0)
// The bar owns the fold level: it is derived from the bar's own measurements, so the bar hands
// over a getter rather than writing a copy that an effect would have to keep in step.
let actionFoldSource = $state<(() => number) | undefined>(undefined)
/**
* One registration's content, or undefined while it cannot answer.
@@ -151,6 +158,7 @@ export const pageHeader = {
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds
if (c.separator !== undefined) merged.separator = c.separator
}
return merged
@@ -176,6 +184,17 @@ export const pageHeader = {
setBarWidth(w: number) {
barWidth = w
},
/** How far the page's actions are folded: 0 for the full row, up to its `actionFolds`. */
get actionFold(): number {
return actionFoldSource?.() ?? 0
},
/** Called by the bar once; returns the release to call when the bar goes away. */
provideActionFold(get: () => number): () => void {
actionFoldSource = get
return () => {
if (actionFoldSource === get) actionFoldSource = undefined
}
},
/** Registers on mount and returns the id to release on destroy. */
register(get: () => PageHeaderContent): number {
const id = nextId++
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { BAR_STAGES, fitBar, type BarMeasure } from './pageHeaderStages'
// Each stage 50px narrower than the last, so the bar width alone says which one fits.
const trail = BAR_STAGES.map((_, i) => 600 - i * 50)
const base: BarMeasure = {
barWidth: 2000,
chrome: 0,
trail,
summary: BAR_STAGES.map(() => 0),
actions: [300, 200, 100],
folds: 2,
natural: { family: 0, fork: 0, summary: 0, name: 0 }
}
describe('fitBar', () => {
it('picks the first stage that fits beside the full row of actions', () => {
expect(fitBar({ ...base, barWidth: 900 }).stage).toBe(0)
expect(fitBar({ ...base, barWidth: 800 })).toMatchObject({ stage: 2, fold: 0 })
})
it('folds the actions only once the breadcrumb is at its last stage, and keeps it there', () => {
// 350 + 300 overflows a 600px bar; 350 + 200 fits. Stage 0 would fit beside the folded row
// too, but the parts a narrowing bar dropped do not come back when the buttons fold.
expect(fitBar({ ...base, barWidth: 600 })).toMatchObject({ stage: 5, fold: 1 })
expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 })
})
it('tries a level it has not seen at the width of the next one folded further', () => {
// Only the second fold has been measured: the levels before it are assumed no wider, so the
// bar tries the full row first and goes on to the first fold once it has seen it overflow.
const unseen = { ...base, barWidth: 600, actions: [undefined, undefined, 100] }
expect(fitBar(unseen).fold).toBe(0)
expect(fitBar({ ...unseen, actions: [300, undefined, 100] }).fold).toBe(1)
})
it('takes the squeeze from the summary first, then the names, the item name last', () => {
const fit = fitBar({
...base,
barWidth: 400,
actions: [0],
folds: 0,
trail: trail.map((w) => w - 400),
summary: BAR_STAGES.map((s) => (s.summary ? 100 : 0)),
natural: { family: 0, fork: 0, summary: 300, name: 300 }
})
// Stage 0 at its floors is 300px; whole, the summary and the name add 204 and 140, so 244
// has to go: all 204 the summary has above its floor, then 40 from the name.
expect(fit).toMatchObject({ stage: 0, caps: { summary: 96, name: 260 }, scopesGive: false })
})
})
@@ -0,0 +1,186 @@
/**
* How the page header gives up width. Whole parts drop in a fixed order, and the bar shows the
* first stage whose parts, each held at its floor width, fit beside the page's actions. Which
* stage that is depends on the names in the bar rather than on a pixel breakpoint. Past the last
* stage the page's actions fold, one level at a time; past their last level the item's name
* truncates, then the folders above it.
*/
/** What the breadcrumb itself drops. */
export type TrailStage = {
/** Inside a fork, the family's name before the fork's. Outside one it is the workspace's own
* name and stays until `wsButton`. */
familyName: boolean
forkName: boolean
/** The workspace part as its disc alone, still leading home, beside its picker's chevron. */
wsButton: boolean
/** The folders between the first one and the name; collapsed, they read as `…`. */
middleLevels: boolean
}
export type BarStage = TrailStage & { summary: boolean; label: string }
export const BAR_STAGES: BarStage[] = [
{
label: 'Full',
familyName: true,
summary: true,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Family name dropped',
familyName: false,
summary: true,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Summary dropped',
familyName: false,
summary: false,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Fork name dropped',
familyName: false,
summary: false,
forkName: false,
wsButton: false,
middleLevels: true
},
{
label: 'Workspace as its disc',
familyName: false,
summary: false,
forkName: false,
wsButton: true,
middleLevels: true
},
{
label: 'Middle folders collapsed',
familyName: false,
summary: false,
forkName: false,
wsButton: true,
middleLevels: false
}
]
export type Part = 'family' | 'fork' | 'summary' | 'name'
/** The trail at each stage with its names at their floors, and each name's own width. */
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
/** The width a part may truncate to before the next stage drops something whole. */
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 96, name: 160 }
/** Past the last stage and fold, the name keeps this much before the folders give any. */
export const SQUEEZED_NAME_FLOOR = 40
/** Middle folders collapsed to one `…`; the first folder and the name always stay. */
export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
const scopes = levels.slice(0, -1)
if (stage.middleLevels || scopes.length <= 1) return levels
return [scopes[0], '…', levels[levels.length - 1]]
}
/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */
export function partVisible(part: Part, stage: BarStage, natural: Record<Part, number>): boolean {
if (natural[part] <= 0) return false
switch (part) {
case 'summary':
return stage.summary
case 'family':
// Outside a fork the family's name is the workspace's: it goes only with the whole part.
return !stage.wsButton && (stage.familyName || natural.fork <= 0)
case 'fork':
return !stage.wsButton && stage.forkName
case 'name':
return true
}
}
export type BarMeasure = {
barWidth: number
/** The bar's padding and the sidebar handle: what is there at every stage. */
chrome: number
/** Per stage: the breadcrumb with each name at its floor. */
trail: number[]
/** Per stage: the summary at its floor with the gap before it, 0 where it is dropped. */
summary: number[]
/** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */
actions: (number | undefined)[]
/** How many levels past the full row the page's actions can fold through. */
folds: number
natural: Record<Part, number>
}
export type BarFit = {
stage: number
fold: number
/** Max widths for the parts that give width, in the order they give it. */
caps: Partial<Record<Part, number>>
/** Even the name at its squeezed floor does not fit: the folders above it truncate too. */
scopesGive: boolean
}
/**
* Flex shrinks every part in proportion, so however lopsided the factors, the name gives a few
* pixels while the summary still has room, and a few pixels are an ellipsis. The parts are rigid
* instead and the deficit is taken from them in a strict order, each down to its floor.
*/
export function fitBar(m: BarMeasure): BarFit {
// A level not yet seen is assumed as narrow as the next one folded further: trying it is how it
// gets measured, and once it has been, the bar never picks it again unless it fits.
const actionsAt = (fold: number) => {
for (let f = fold; f < m.actions.length; f++)
if (m.actions[f] !== undefined) return m.actions[f]!
return 0
}
const last = BAR_STAGES.length - 1
const floorWidth = (s: number, f: number) =>
m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f)
let stage = last
let fold = m.folds
choose: {
for (let s = 0; s <= last; s++) {
if (floorWidth(s, 0) <= m.barWidth) {
stage = s
fold = 0
break choose
}
}
// The actions fold only once the breadcrumb has given all it can, so a narrowing bar only
// ever loses things: the parts it dropped do not come back when the buttons fold.
for (let f = 1; f <= m.folds; f++) {
if (floorWidth(last, f) <= m.barWidth) {
fold = f
break choose
}
}
}
const at = BAR_STAGES[stage]
const parts = (['summary', 'family', 'fork', 'name'] as Part[]).filter((p) =>
partVisible(p, at, m.natural)
)
const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p]))
// The stage was measured with its parts at their floors; this is its width with them whole.
let deficit = floorWidth(stage, fold) + parts.reduce((s, p) => s + slack(p), 0) - m.barWidth
const caps: Partial<Record<Part, number>> = {}
const take = (p: Part, floor: number) => {
if (deficit <= 0 || !parts.includes(p)) return
const current = caps[p] ?? m.natural[p]
const give = Math.min(deficit, Math.max(0, current - Math.min(m.natural[p], floor)))
if (give > 0) caps[p] = current - give
deficit -= give
}
for (const p of parts) take(p, FLOOR[p])
take('name', SQUEEZED_NAME_FLOOR)
return { stage, fold, caps, scopesGive: deficit > 0 }
}
@@ -80,7 +80,7 @@
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -318,20 +318,15 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff,
// Jobs, Export, AI and Deploy — is ~560px.
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
// in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
// given all it can; anywhere else this editor's own top bar is theirs alone.
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
// in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
@@ -914,8 +909,8 @@
</a>
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
it can be fetched (S3, GitHub raw, your docs site) and link
<span class="font-mono">/replay?src=&lt;url&gt;</span> to have it load itself. Windmill
keeps no copy.
<span class="font-mono">/replay?src=&lt;url&gt;</span> to have it load itself. Windmill keeps
no copy.
</span>
</div>
<div class="flex-1 min-h-0">
@@ -960,6 +955,7 @@
pathTrigger: rawAppPathTrigger
}}
actions={rawAppHeaderActions}
actionFolds={2}
contexts={headerContexts}
separator="none"
/>