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
@@ -4,12 +4,14 @@
rail state renders only the two 36x36 icon controls. */
.root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 8px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-right: 12px;
padding-right: var(--dsh-session-list-edge-inset);
}
.root.rail {
@@ -173,7 +175,7 @@
min-height: 0;
display: flex;
flex-direction: column;
margin-right: -12px;
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
overflow: hidden;
}
@@ -195,7 +197,7 @@
.fade {
position: absolute;
left: 0;
right: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
@@ -211,27 +213,19 @@
from { opacity: 0; }
}
/* List: the only scrolling region. Block, not a flex column: as flex items
the 54/34 rows would shrink under content overflow; block children keep
their design heights do not shrink under content overflow. Its right
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: the only scrolling region. Block children keep their design heights
under content overflow. The stable 8px themed scrollbar and the remaining
padding together equal the shell's right inset, with or without overflow. */
.list {
--dsh-session-list-edge-inset: 12px;
--dsh-session-list-scrollbar-width: 8px;
flex: 1;
min-height: 0;
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;
scrollbar-gutter: stable;
}
.list::-webkit-scrollbar {
width: var(--dsh-session-list-scrollbar-width);
}
.list > [role='treeitem'] + [role='treeitem'],
.flatList > * + *,
.searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * {
margin-top: 2px;
@@ -238,7 +238,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
const now = Date.now()
return (
<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 && (
<div className={css.empty}>{t('empty.none')}</div>
)}
@@ -1,7 +1,7 @@
/**
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
* on disk: the session list keeps one stable right inset for row hover fills,
* with or without overflow, while outer clip seats add no hidden second inset.
* WorkspaceBrowser spacing contract, asserted against the CSS text on disk:
* row fills share the shell's trailing inset, the stable scrollbar counts
* inside it, and flat, grouped, and search views keep their intended rhythm.
*/
import { readFileSync } from 'node:fs'
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')
/**
* 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.
* @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.
*/
function declarations(className: string): Map<string, string> | undefined {
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
if (match === null) return undefined
const found = new Map<string, string>()
// The body group is unconditional in the pattern; the fallback only satisfies
// noUncheckedIndexedAccess.
for (const part of (match[2] ?? '').split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\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 found
return undefined
}
describe('WorkspaceBrowser.module.css list', () => {
const root = declarations('root')
const listArea = declarations('listArea')
const list = declarations('list')
const treeBody = declarations('treeBody')
const root = declarations('.root')
const listArea = declarations('.listArea')
const list = declarations('.list')
it('is the scrolling region', () => {
expect(list).toBeDefined()
expect(list!.get('overflow-y')).toBe('auto')
})
it('keeps row backgrounds edge-flush with the scrolling region', () => {
expect(root?.get('padding-right')).toBe('12px')
expect(listArea?.get('margin-right')).toBe('-12px')
expect(treeBody?.get('margin-right')).toBeUndefined()
expect(list?.get('margin-right')).toBeUndefined()
expect(list?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
it('counts the themed scrollbar inside the shell trailing inset', () => {
expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)')
expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
expect(listArea?.get('margin-right')).toBe('calc(-1 * 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')
})
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')
})
})