From 5b94c07849d44fe09edc4add6d89eb1380b60596 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 08:32:30 -0700 Subject: [PATCH] feat: show secondary detail actions as buttons while the bar has room for them Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/components/PageHeaderBar.svelte | 1 + .../lib/components/PageHeaderContent.svelte | 2 + .../details/DetailPageHeader.svelte | 39 +++++++++++++------ .../lib/components/details/FlowDetail.svelte | 1 + .../components/details/ScriptDetail.svelte | 1 + .../components/pageHeaderRegistry.svelte.ts | 4 ++ .../lib/components/pageHeaderStages.test.ts | 8 ++++ .../src/lib/components/pageHeaderStages.ts | 25 ++++++++---- 8 files changed, 62 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 84d7baaf81..abf3dbaf8f 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -78,6 +78,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba actions: actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE], folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0, + earlyFolds: content?.earlyActionFolds, natural: { family: trailMeasures.family, fork: trailMeasures.fork, diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index 76dd0834b0..03deefea14 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -19,6 +19,7 @@ the header, so nesting is decided by the caller rather than guessed from context actionsFlexible, actionsFill, actionFolds, + earlyActionFolds, belowBar, belowBarFold, separator, @@ -37,6 +38,7 @@ the header, so nesting is decided by the caller rather than guessed from context actionsFlexible, actionsFill, actionFolds, + earlyActionFolds, belowBar, belowBarFold, separator, diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index a014d1835d..e798af0eb2 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -20,10 +20,13 @@ /** Shown under the label once the button is a row of a menu. */ description?: string buttonProps: ButtonProps - /** A secondary action, kept out of the bar at every width: in the ellipsis menu, or in the - * dropdown of the enabled bar button labelled `dropdownOf` (the menu when there is none). - * Unset makes it a bar button, which folds into the menu only when the bar is short. */ + /** A secondary action: a bar button while the bar has room for every one, then, from the + * first fold, in the ellipsis menu or in the dropdown of the enabled bar button labelled + * `dropdownOf` (the menu when there is none). Unset makes it a main button, which goes to + * the menu only on the folds after that. */ placement?: 'menu' | { dropdownOf: string } + /** In its `placement` at every width, never a bar button of its own. */ + alwaysPlaced?: boolean } type ButtonProps = any @@ -101,18 +104,27 @@ // no error handler and says so itself. const renameKind = $derived(itemKind ?? errorHandlerKind) - // The band folds the bar buttons once its breadcrumb has given all it can (`actionFolds` below): - // first every one but the last, which is what the page is for (Edit), then that one too. The - // host knows when its own box is narrow while the window is not — a session's preview panel — - // and says so through `wide`, which only sends the error handler's toggle to the menu. + // The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below): + // first the secondary ones go to their `placement`, then every bar button but the last, which + // is what the page is for (Edit), then that one too. The host knows when its own box is narrow + // while the window is not — a session's preview panel — and says so through `wide`, which only + // sends the error handler's toggle to the menu. const fold = $derived(ownsPageHeader ? pageHeader.actionFold : 0) - const wideRow = $derived(wide && fold === 0) + const wideRow = $derived(wide && fold <= 1) const rowButtons = $derived(mainButtons.filter((b) => !b.placement)) - const barButtons = $derived(fold >= 2 ? [] : fold === 1 ? rowButtons.slice(-1) : rowButtons) + const barButtons = $derived( + fold === 0 + ? mainButtons.filter((b) => !b.alwaysPlaced) + : fold === 1 + ? rowButtons + : fold === 2 + ? rowButtons.slice(-1) + : [] + ) function dropdownHost(btn: MainButton): MainButton | undefined { - if (typeof btn.placement !== 'object') return undefined + if (typeof btn.placement !== 'object' || barButtons.includes(btn)) return undefined const label = btn.placement.dropdownOf return barButtons.find((b) => b.label === label && !b.buttonProps.disabled) } @@ -121,7 +133,9 @@ * dropdown would have held, then the secondary actions with no bar button to hang from. */ const menuButtons = $derived.by(() => { const folded = rowButtons.filter((b) => !barButtons.includes(b)) - const homeless = mainButtons.filter((b) => b.placement && !dropdownHost(b)) + const homeless = mainButtons.filter( + (b) => b.placement && !barButtons.includes(b) && !dropdownHost(b) + ) const childOf = (host: MainButton) => homeless.filter( (b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label @@ -295,7 +309,8 @@ pathTrigger: onSaved ? pathTrigger : undefined }} {actions} - actionFolds={2} + actionFolds={3} + earlyActionFolds={1} contexts={headerContexts} separator="always" /> diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 39e949b002..7bf8fda492 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -436,6 +436,7 @@ buttons.push({ label: 'Build app', placement: 'menu', + alwaysPlaced: true, buttonProps: { onClick: async () => { const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema) diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index d4646f6272..5880a4d03a 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -603,6 +603,7 @@ buttons.push({ label: 'Build app', placement: 'menu', + alwaysPlaced: true, buttonProps: { onClick: async () => { const app = createRawAppFromScript(script.path, script.summary, script.schema) diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index cd33b67adb..3116a90e66 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -57,6 +57,9 @@ export type PageHeaderContent = { * 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 + /** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons + * that are there only while the bar has room for everything. */ + earlyActionFolds?: number /** A second row of the band, under the breadcrumb and the actions: where a control too wide * for one row goes at a fold that has no room for it beside them, rather than being squeezed * until it is useless. The band is taller while it is there. */ @@ -167,6 +170,7 @@ export const pageHeader = { 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.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds if (c.belowBar !== undefined) merged.belowBar = c.belowBar if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold if (c.separator !== undefined) merged.separator = c.separator diff --git a/frontend/src/lib/components/pageHeaderStages.test.ts b/frontend/src/lib/components/pageHeaderStages.test.ts index 13b6f93e58..cb28141fef 100644 --- a/frontend/src/lib/components/pageHeaderStages.test.ts +++ b/frontend/src/lib/components/pageHeaderStages.test.ts @@ -26,6 +26,14 @@ describe('fitBar', () => { expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 }) }) + it('spends early folds before the breadcrumb drops anything', () => { + // 600 + 200 fits an 800px bar at the first fold; without early folds stage 2 would have + // dropped parts beside the full row instead. + expect(fitBar({ ...base, barWidth: 800, earlyFolds: 1 })).toMatchObject({ stage: 0, fold: 1 }) + // Past that, the breadcrumb gives way at the early fold before the later folds start. + expect(fitBar({ ...base, barWidth: 700, earlyFolds: 1 })).toMatchObject({ stage: 2, fold: 1 }) + }) + 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. diff --git a/frontend/src/lib/components/pageHeaderStages.ts b/frontend/src/lib/components/pageHeaderStages.ts index 95725f6ab2..4cc66363b5 100644 --- a/frontend/src/lib/components/pageHeaderStages.ts +++ b/frontend/src/lib/components/pageHeaderStages.ts @@ -122,6 +122,9 @@ export type BarMeasure = { actions: (number | undefined)[] /** How many levels past the full row the page's actions can fold through. */ folds: number + /** How many of those levels fold before the breadcrumb drops anything: buttons that are there + * only while there is room for everything. */ + earlyFolds?: number natural: Record } @@ -151,19 +154,27 @@ export function fitBar(m: BarMeasure): BarFit { const floorWidth = (s: number, f: number) => m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f) + const early = Math.min(m.earlyFolds ?? 0, m.folds) 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 + for (let f = 0; f < early; f++) { + if (floorWidth(0, f) <= m.barWidth) { + stage = 0 + fold = f 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++) { + for (let s = 0; s <= last; s++) { + if (floorWidth(s, early) <= m.barWidth) { + stage = s + fold = early + break choose + } + } + // The other levels 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 = early + 1; f <= m.folds; f++) { if (floorWidth(last, f) <= m.barWidth) { fold = f break choose