feat: keep an item's summary and drop its path on a narrow page header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 08:14:32 -07:00
co-authored by Claude Opus 5.5
parent 5309bd2242
commit 60b4c8fdc5
3 changed files with 46 additions and 28 deletions
@@ -74,7 +74,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
barWidth: pageHeader.barWidth,
chrome: padLeft + padRight + (handleShown ? handleWidth + GAP : 0),
trail: trailMeasures.trail,
summary: BAR_STAGES.map((s) => (hasSummary && s.summary ? GAP + summaryFloorWidth : 0)),
summary: BAR_STAGES.map(() => (hasSummary ? GAP + summaryFloorWidth : 0)),
actions:
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
@@ -236,13 +236,16 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</svelte:boundary>
</div>
{#if item && hasSummary && stage.summary}
{#if item && hasSummary}
<!-- 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="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. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
<!-- A dot after the path: the summary names the same item the path just located, it is not
another level of it. With the path dropped it follows the workspace, and the slash is
the trail's own separator. Fixed width, so either fits the width measured below. -->
<span class="shrink-0 w-2 text-center text-hint/40 text-xs" aria-hidden="true"
>{stage.path ? '·' : '/'}</span
>
{#if item.summaryContent}
<!-- 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. -->
@@ -315,7 +318,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
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>
<span class="shrink-0 w-2 text-center text-hint/40 text-xs">·</span>
{#if item.summaryContent}
<span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span>
{:else}
@@ -34,18 +34,26 @@ describe('fitBar', () => {
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', () => {
it("takes the squeeze from the path's name before the summary", () => {
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)),
summary: BAR_STAGES.map(() => 100),
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 })
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 204, so 244
// has to go: all 140 the name has above its floor, then 104 from the summary.
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 196 }, scopesGive: false })
})
it('keeps the summary and drops the path when only one of them fits', () => {
const natural = { family: 0, fork: 0, summary: 300, name: 300 }
// Stage 2 is the first without the path: the name is no longer a part the bar sizes.
const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 })
expect(fit.stage).toBe(2)
expect(fit.caps.name).toBeUndefined()
})
})
+24 -17
View File
@@ -2,8 +2,8 @@
* 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.
* stage the page's actions fold, one level at a time; past their last level the item's name and
* its summary truncate, then the folders above it.
*/
/** What the breadcrumb itself drops. */
@@ -11,6 +11,10 @@ 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
/** The item's path. Dropped only for an item that has a summary: the summary is what a
* person calls the thing, while its path is where it is filed. Without one, the path is the
* item's only name and stays. */
path: boolean
forkName: boolean
/** The workspace part as its disc alone, still leading home, beside its picker's chevron. */
wsButton: boolean
@@ -18,13 +22,13 @@ export type TrailStage = {
middleLevels: boolean
}
export type BarStage = TrailStage & { summary: boolean; label: string }
export type BarStage = TrailStage & { label: string }
export const BAR_STAGES: BarStage[] = [
{
label: 'Full',
familyName: true,
summary: true,
path: true,
forkName: true,
wsButton: false,
middleLevels: true
@@ -32,15 +36,15 @@ export const BAR_STAGES: BarStage[] = [
{
label: 'Family name dropped',
familyName: false,
summary: true,
path: true,
forkName: true,
wsButton: false,
middleLevels: true
},
{
label: 'Summary dropped',
label: 'Path dropped for the summary',
familyName: false,
summary: false,
path: false,
forkName: true,
wsButton: false,
middleLevels: true
@@ -48,7 +52,7 @@ export const BAR_STAGES: BarStage[] = [
{
label: 'Fork name dropped',
familyName: false,
summary: false,
path: false,
forkName: false,
wsButton: false,
middleLevels: true
@@ -56,7 +60,7 @@ export const BAR_STAGES: BarStage[] = [
{
label: 'Workspace as its disc',
familyName: false,
summary: false,
path: false,
forkName: false,
wsButton: true,
middleLevels: true
@@ -64,7 +68,7 @@ export const BAR_STAGES: BarStage[] = [
{
label: 'Middle folders collapsed',
familyName: false,
summary: false,
path: false,
forkName: false,
wsButton: true,
middleLevels: false
@@ -78,8 +82,9 @@ export type TrailMeasures = { trail: number[]; family: number; fork: number; nam
/** 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
/** Past the last stage and fold, the item's name and then its summary keep this much before the
* folders give any. */
export const SQUEEZED_FLOOR = 40
/** Middle folders collapsed to one `…`; the first folder and the name always stay. */
export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
@@ -93,14 +98,14 @@ export function partVisible(part: Part, stage: BarStage, natural: Record<Part, n
if (natural[part] <= 0) return false
switch (part) {
case 'summary':
return stage.summary
return true
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
return stage.path || natural.summary <= 0
}
}
@@ -110,7 +115,7 @@ export type BarMeasure = {
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. */
/** Per stage: the summary at its floor with the separator before it, 0 where there is none. */
summary: number[]
/** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */
actions: (number | undefined)[]
@@ -166,7 +171,8 @@ export function fitBar(m: BarMeasure): BarFit {
}
const at = BAR_STAGES[stage]
const parts = (['summary', 'family', 'fork', 'name'] as Part[]).filter((p) =>
// The summary gives last: it is what a person calls the item, so the path's name goes first.
const parts = (['family', 'fork', 'name', 'summary'] 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]))
@@ -181,6 +187,7 @@ export function fitBar(m: BarMeasure): BarFit {
deficit -= give
}
for (const p of parts) take(p, FLOOR[p])
take('name', SQUEEZED_NAME_FLOOR)
take('name', SQUEEZED_FLOOR)
take('summary', SQUEEZED_FLOOR)
return { stage, fold, caps, scopesGive: deficit > 0 }
}