fix(web): keep sidebar scrollbar inside inset

This commit is contained in:
_Kerman
2026-08-04 14:11:44 +08:00
parent b57e29db59
commit c1ac85c15f
7 changed files with 139 additions and 52 deletions

View File

@@ -106,6 +106,8 @@ interface ListMetrics {
overflows: boolean overflows: boolean
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */ /** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number band: number
/** Distance from the first row background's right edge to the list border box. */
rowEdgeInset: number
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */ /** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
clientRight: number clientRight: number
/** Border-box right edge in viewport coordinates. */ /** Border-box right edge in viewport coordinates. */
@@ -133,6 +135,8 @@ function measureList(page: Page): Promise<ListMetrics> {
if (list === null) throw new Error('sidebar session list not in the DOM') if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]') const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list') if (time === null) throw new Error('no row relative-time element in the sidebar list')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
// Each indirection variable is resolved through its own throwaway probe // Each indirection variable is resolved through its own throwaway probe
// appended to the list: `var()` substitution then happens where the list // appended to the list: `var()` substitution then happens where the list
// sits in the cascade, which is the claim, and `color` normalizes whatever // sits in the cascade, which is the claim, and `color` normalizes whatever
@@ -167,6 +171,7 @@ function measureList(page: Page): Promise<ListMetrics> {
const style = getComputedStyle(list) const style = getComputedStyle(list)
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth) const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
return { return {
gutter: style.scrollbarGutter, gutter: style.scrollbarGutter,
width: pseudoWidth, width: pseudoWidth,
@@ -177,9 +182,10 @@ function measureList(page: Page): Promise<ListMetrics> {
token: resolve('--dsh-scrollbar-thumb'), token: resolve('--dsh-scrollbar-thumb'),
hoverToken: resolve('--dsh-scrollbar-thumb-hover'), hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight, overflows: list.scrollHeight > list.clientHeight,
band: list.getBoundingClientRect().width - list.clientWidth, band: listRect.width - list.clientWidth,
clientRight: list.getBoundingClientRect().left + list.clientWidth, rowEdgeInset: listRect.right - row.getBoundingClientRect().right,
borderRight: list.getBoundingClientRect().right, clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right, timeRight: time.getBoundingClientRect().right,
// The bar is drawn in the rightmost `barWidth` of the border box, whether // The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the // or not that space was reserved. Its width comes from the sheet where the
@@ -188,7 +194,26 @@ function measureList(page: Page): Promise<ListMetrics> {
// absent. Taking the UA width as the fallback is what keeps the assertion // absent. Taking the UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the // honest: assuming 0 there would report no occlusion precisely in the
// state that has it. // state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)), timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
}
})
}
/**
* Measure only overflow and row inset, which remain observable when every
* session is hidden under a collapsed workspace group.
* @param page - the page under test.
* @returns the list overflow state and first row's trailing inset.
*/
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: list.getBoundingClientRect().right - row.getBoundingClientRect().right,
} }
}) })
} }
@@ -222,6 +247,7 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`, `- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`, `- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`, `- reserved band: ${String(metrics.band)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`, `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`, `- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`, `- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -299,6 +325,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
// drawn over it. Removing the declaration makes it exactly 0. The value // drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform. // itself is not pinned — it tracks `scrollbar-width` and the platform.
expect(metrics.band).toBeGreaterThan(0) expect(metrics.band).toBeGreaterThan(0)
expect(metrics.rowEdgeInset).toBe(12)
// The reported symptom, stated directly: no part of the row's relative time // The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the // lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
// covered part. Unlike the client-edge comparison below it does not go // covered part. Unlike the client-edge comparison below it does not go
@@ -317,6 +344,20 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([]) expect(tripwire.pageErrors).toEqual([])
}, 60_000) }, 60_000)
it('keeps the row background inset when overflow disappears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.click()
try {
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
} finally {
await expandSeededSessions(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders the themed thumb through the WebKit path in both palettes', async () => { it('renders the themed thumb through the WebKit path in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme')) onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
const light = await measureList(page) const light = await measureList(page)

View File

@@ -12,6 +12,7 @@
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212) - --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
- list overflows: true - list overflows: true
- reserved band: 8px - reserved band: 8px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px - relative time covered by the bar: 0px
- relative time ends inside the content area: true - relative time ends inside the content area: true
- content area ends before the border box: true - content area ends before the border box: true
@@ -28,6 +29,7 @@
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87) - --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
- list overflows: true - list overflows: true
- reserved band: 8px - reserved band: 8px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px - relative time covered by the bar: 0px
- relative time ends inside the content area: true - relative time ends inside the content area: true
- content area ends before the border box: true - content area ends before the border box: true

View File

@@ -7,10 +7,11 @@
mid-slide. */ mid-slide. */
.root { .root {
--dsh-sidebar-inline-padding: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
padding: 6px 12px; padding: 6px var(--dsh-sidebar-inline-padding);
box-sizing: border-box; box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill); background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
@@ -189,14 +190,15 @@
max-width: 0; max-width: 0;
} }
/* Region seat: always mounted so the foot never moves; the browser inside /* Region seat: always mounted so the foot never moves. Its trailing margin
handles its own wide/rail content. */ cancels the wide shell inset so the nested scrollbar can sit at the sidebar
edge; the browser restores that inset inside its own rows. */
.regionArea { .regionArea {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-right: -12px; margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
overflow: hidden; overflow: hidden;
} }

View File

@@ -0,0 +1,38 @@
/** Sidebar shell inset contract shared with the nested workspace browser. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/SidebarRoot.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one exact selector, keyed by property.
* @param selector - exact selector text.
* @returns the normalized declarations, or undefined when absent.
*/
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return undefined
}
describe('SidebarRoot.module.css inset', () => {
it('shares and cancels the wide shell trailing padding structurally', () => {
const root = declarations('.root')
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
expect(declarations('.regionArea')?.get('margin-right')).toBe(
'calc(-1 * var(--dsh-sidebar-inline-padding))',
)
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
})
})

View File

@@ -4,12 +4,14 @@
rail state renders only the two 36x36 icon controls. */ rail state renders only the two 36x36 icon controls. */
.root { .root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 8px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
padding-right: 12px; padding-right: var(--dsh-session-list-edge-inset);
} }
.root.rail { .root.rail {
@@ -173,7 +175,7 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-right: -12px; margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
overflow: hidden; overflow: hidden;
} }
@@ -195,7 +197,7 @@
.fade { .fade {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: var(--dsh-session-list-edge-inset);
bottom: 0; bottom: 0;
height: 72px; height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
@@ -211,27 +213,19 @@
from { opacity: 0; } from { opacity: 0; }
} }
/* List: the only scrolling region. Block, not a flex column: as flex items /* List: the only scrolling region. Block children keep their design heights
the 54/34 rows would shrink under content overflow; block children keep under content overflow. The stable 8px themed scrollbar and the remaining
their design heights do not shrink under content overflow. Its right padding together equal the shell's right inset, with or without overflow. */
padding is the sole gap between row backgrounds and the sidebar edge; the
outer seats move their clip boundary out so they add no hidden inset. */
.list { .list {
--dsh-session-list-edge-inset: 12px;
--dsh-session-list-scrollbar-width: 8px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-right: var(--dsh-session-list-edge-inset); padding-right: calc(var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width));
padding-bottom: 12px; padding-bottom: 12px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.list::-webkit-scrollbar { .flatList > * + *,
width: var(--dsh-session-list-scrollbar-width);
}
.list > [role='treeitem'] + [role='treeitem'],
.searchTree > [role='treeitem'] + [role='treeitem'], .searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * { .groupSection > * + * {
margin-top: 2px; margin-top: 2px;

View File

@@ -238,7 +238,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
const now = Date.now() const now = Date.now()
return ( return (
<div className={clsx(css.treeBody, css.wide)}> <div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label={t('section.sessions')}> <div className={clsx(css.list, css.flatList)} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && ( {rows.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div> <div className={css.empty}>{t('empty.none')}</div>
)} )}

View File

@@ -1,7 +1,7 @@
/** /**
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text * WorkspaceBrowser spacing contract, asserted against the CSS text on disk:
* on disk: the session list keeps one stable right inset for row hover fills, * row fills share the shell's trailing inset, the stable scrollbar counts
* with or without overflow, while outer clip seats add no hidden second inset. * inside it, and flat, grouped, and search views keep their intended rhythm.
*/ */
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@@ -10,46 +10,56 @@ import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8') const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
/** /**
* Declarations of one class rule, keyed by property with whitespace collapsed. * Declarations of one selector rule, keyed by property with whitespace collapsed.
* Declaration order and trailing semicolons are normalized away. * Declaration order and trailing semicolons are normalized away.
* @param className - local class name, without the leading dot. * @param selector - one exact selector, including a leading dot for local classes.
* @returns the rule's declarations, or undefined when no such rule exists. * @returns the rule's declarations, or undefined when no such rule exists.
*/ */
function declarations(className: string): Map<string, string> | undefined { function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ') const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments) for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (match === null) return undefined if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>() const found = new Map<string, string>()
// The body group is unconditional in the pattern; the fallback only satisfies for (const part of body.split(';')) {
// noUncheckedIndexedAccess. const colon = part.indexOf(':')
for (const part of (match[2] ?? '').split(';')) { if (colon === -1) continue
const colon = part.indexOf(':') found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
if (colon === -1) continue }
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' ')) return found
} }
return found return undefined
} }
describe('WorkspaceBrowser.module.css list', () => { describe('WorkspaceBrowser.module.css list', () => {
const root = declarations('root') const root = declarations('.root')
const listArea = declarations('listArea') const listArea = declarations('.listArea')
const list = declarations('list') const list = declarations('.list')
const treeBody = declarations('treeBody')
it('is the scrolling region', () => { it('is the scrolling region', () => {
expect(list).toBeDefined() expect(list).toBeDefined()
expect(list!.get('overflow-y')).toBe('auto') expect(list!.get('overflow-y')).toBe('auto')
}) })
it('keeps row backgrounds edge-flush with the scrolling region', () => { it('counts the themed scrollbar inside the shell trailing inset', () => {
expect(root?.get('padding-right')).toBe('12px') expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)')
expect(listArea?.get('margin-right')).toBe('-12px') expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
expect(treeBody?.get('margin-right')).toBeUndefined() expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
expect(list?.get('margin-right')).toBeUndefined() expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))')
expect(list?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)') expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)')
expect(list?.get('padding-right')).toBe(
'calc(var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width))',
)
expect(declarations('.list::-webkit-scrollbar')).toBeUndefined()
}) })
it('reserves the scrollbar inside the stable visual inset', () => { it('reserves the scrollbar whether or not the list overflows', () => {
expect(list!.get('scrollbar-gutter')).toBe('stable') expect(list!.get('scrollbar-gutter')).toBe('stable')
}) })
it('keeps 2px between rows and 4px between workspace groups', () => {
expect(declarations('.flatList > * + *')?.get('margin-top')).toBe('2px')
expect(declarations(".searchTree > [role='treeitem'] + [role='treeitem']")?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
})
}) })